feat(tooling): complete packages/* noUncheckedIndexedAccess adoption (phase 2) (#104626)

* feat(tooling): extend strict-ratchet lane to all remaining leaf packages

* fix(packages): burn down indexed-access debt in ai and agent-core

All 196 noUncheckedIndexedAccess errors fixed behavior-identically
(iteration, .at()/slices, DataView byte math, guarded queue peeks) plus
cleanup finds: a sparse-tool diagnostic bug, deduplicated Responses
call/item ID parsing with an honest string|undefined splitter, and a
clearer local failure for redacted-thinking blocks missing signatures.
Removes all 17 remaining non-null assertions across ai, gateway-client,
and llm-core so the assertion ban aligns 1:1 with the ratchet lane.
speech-core joins memory-host-sdk as structurally deferred (imports
src/** via plugin-sdk path mappings).
This commit is contained in:
Peter Steinberger
2026-07-11 11:54:06 -07:00
committed by GitHub
parent 8fff38479e
commit 4b751ce48a
31 changed files with 296 additions and 132 deletions
+11 -1
View File
@@ -236,7 +236,17 @@
"packages/terminal-core/**/*.ts",
"packages/normalization-core/**/*.ts",
"packages/model-catalog-core/**/*.ts",
"packages/web-content-core/**/*.ts"
"packages/web-content-core/**/*.ts",
"packages/agent-core/**/*.ts",
"packages/acp-core/**/*.ts",
"packages/ai/**/*.ts",
"packages/gateway-client/**/*.ts",
"packages/gateway-protocol/**/*.ts",
"packages/llm-core/**/*.ts",
"packages/media-core/**/*.ts",
"packages/media-generation-core/**/*.ts",
"packages/plugin-package-contract/**/*.ts",
"packages/sdk/**/*.ts"
],
"rules": {
"typescript/no-non-null-assertion": "error"
+8 -6
View File
@@ -139,12 +139,13 @@ export function agentLoopContinue(
streamFn?: StreamFn,
runtime?: AgentCoreStreamRuntimeDeps,
): EventStream<AgentEvent, AgentMessage[]> {
if (context.messages.length === 0) {
const lastMessage = context.messages.at(-1);
if (!lastMessage) {
throw new Error("Cannot continue: no messages in context");
}
if (context.messages[context.messages.length - 1].role === "assistant") {
throw new TranscriptNotContinuableError(context.messages[context.messages.length - 1].role);
if (lastMessage.role === "assistant") {
throw new TranscriptNotContinuableError(lastMessage.role);
}
const stream = createAgentStream();
@@ -205,12 +206,13 @@ export async function runAgentLoopContinue(
streamFn?: StreamFn,
runtime?: AgentCoreStreamRuntimeDeps,
): Promise<AgentMessage[]> {
if (context.messages.length === 0) {
const lastMessage = context.messages.at(-1);
if (!lastMessage) {
throw new Error("Cannot continue: no messages in context");
}
if (context.messages[context.messages.length - 1].role === "assistant") {
throw new TranscriptNotContinuableError(context.messages[context.messages.length - 1].role);
if (lastMessage.role === "assistant") {
throw new TranscriptNotContinuableError(lastMessage.role);
}
const newMessages: AgentMessage[] = [];
@@ -551,7 +551,10 @@ export class CoreAgentHarness<
private async flushPendingSessionWrites(): Promise<void> {
while (this.pendingSessionWrites.length > 0) {
const write = this.pendingSessionWrites[0];
const write = this.pendingSessionWrites.at(0);
if (!write) {
break;
}
if (write.type === "message") {
await this.session.appendMessage(write.message);
} else if (write.type === "model_change") {
@@ -632,7 +635,8 @@ export class CoreAgentHarness<
options?: { images?: ImageContent[] },
): Promise<AssistantMessage> {
let activeTurnState = turnState;
let messages: AgentMessage[] = [createUserMessage(text, options?.images)];
const promptMessage = createUserMessage(text, options?.images);
let messages: AgentMessage[] = [promptMessage];
if (this.nextTurnQueue.length > 0) {
const queuedMessages = this.nextTurnQueue.splice(0);
try {
@@ -641,7 +645,7 @@ export class CoreAgentHarness<
this.nextTurnQueue.unshift(...queuedMessages);
throw normalizeHookError(error);
}
messages = [...queuedMessages, messages[0]];
messages = [...queuedMessages, promptMessage];
}
const beforeResult = await this.emitHook({
type: "before_agent_start",
@@ -689,8 +693,7 @@ export class CoreAgentHarness<
})();
try {
const newMessages = await runResultPromise;
for (let i = newMessages.length - 1; i >= 0; i--) {
const message = newMessages[i];
for (const message of newMessages.toReversed()) {
if (message.role === "assistant") {
return message;
}
@@ -95,9 +95,9 @@ export function collectEntriesForBranchSummaryFromBranches<TEntry extends Branch
): CollectBranchPathEntriesResult<TEntry> {
const oldPath = new Set(oldBranch.map((entry) => entry.id));
let commonAncestorId: string | null = null;
for (let i = targetBranch.length - 1; i >= 0; i--) {
if (oldPath.has(targetBranch[i].id)) {
commonAncestorId = targetBranch[i].id;
for (const targetEntry of targetBranch.toReversed()) {
if (oldPath.has(targetEntry.id)) {
commonAncestorId = targetEntry.id;
break;
}
}
@@ -184,8 +184,7 @@ export function prepareBranchEntries(
}
}
}
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
for (const entry of entries.toReversed()) {
const message = getMessageFromEntry(entry);
if (!message) {
continue;
@@ -169,8 +169,7 @@ function getAssistantUsage(msg: AgentMessage): Usage | undefined {
/** Return usage from the last successful assistant message in session entries. */
export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
for (const entry of entries.toReversed()) {
if (entry.type === "message") {
const usage = getAssistantUsage(entry.message);
if (usage) {
@@ -197,7 +196,11 @@ function getLastAssistantUsageInfo(
messages: AgentMessage[],
): { usage: Usage; index: number } | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const usage = getAssistantUsage(messages[i]);
const message = messages.at(i);
if (!message) {
continue;
}
const usage = getAssistantUsage(message);
if (usage && usage.contextUsage?.state !== "unavailable") {
return { usage, index: i };
}
@@ -224,8 +227,8 @@ export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEst
const usageTokens = calculateContextTokens(usageInfo.usage);
let trailingTokens = 0;
for (let i = usageInfo.index + 1; i < messages.length; i++) {
trailingTokens += estimateTokens(messages[i]);
for (const message of messages.slice(usageInfo.index + 1)) {
trailingTokens += estimateTokens(message);
}
return {
@@ -324,6 +327,9 @@ function findValidCutPoints(
const cutPoints: number[] = [];
for (let i = startIndex; i < endIndex; i++) {
const entry = entries[i];
if (!entry) {
continue;
}
switch (entry.type) {
case "message": {
const role = (entry.message as HarnessMessage).role;
@@ -367,6 +373,9 @@ export function findTurnStartIndex(
): number {
for (let i = entryIndex; i >= startIndex; i--) {
const entry = entries[i];
if (!entry) {
continue;
}
if (entry.type === "branch_summary" || entry.type === "custom_message") {
return i;
}
@@ -403,17 +412,25 @@ export function findCutPoint(
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
}
let accumulatedTokens = 0;
let cutIndex = cutPoints[0];
const firstCutIndex = cutPoints.at(0);
if (firstCutIndex === undefined) {
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
}
let cutIndex = firstCutIndex;
for (let i = endIndex - 1; i >= startIndex; i--) {
const entry = entries[i];
if (entry.type !== "message") {
if (!entry || entry.type !== "message") {
continue;
}
const messageTokens = estimateTokens(entry.message);
accumulatedTokens += messageTokens;
if (accumulatedTokens >= keepRecentTokens) {
cutIndex = cutPoints[cutPoints.length - 1];
const lastCutIndex = cutPoints.at(-1);
if (lastCutIndex === undefined) {
throw new Error("compaction cut-point list became empty during selection");
}
cutIndex = lastCutIndex;
for (const cutPoint of cutPoints) {
if (cutPoint >= i) {
cutIndex = cutPoint;
@@ -425,6 +442,9 @@ export function findCutPoint(
}
while (cutIndex > startIndex) {
const prevEntry = entries[cutIndex - 1];
if (!prevEntry) {
break;
}
if (prevEntry.type === "compaction") {
break;
}
@@ -434,6 +454,9 @@ export function findCutPoint(
cutIndex--;
}
const cutEntry = entries[cutIndex];
if (!cutEntry) {
throw new Error("compaction cut point does not reference a session entry");
}
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
@@ -677,13 +700,13 @@ export function prepareCompaction(
pathEntries: SessionTreeEntry[],
settings: CompactionSettings,
): Result<CompactionPreparation | undefined, CompactionError> {
if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") {
if (pathEntries.at(-1)?.type === "compaction" || pathEntries.length === 0) {
return ok(undefined);
}
let prevCompactionIndex = -1;
for (let i = pathEntries.length - 1; i >= 0; i--) {
if (pathEntries[i].type === "compaction") {
if (pathEntries.at(i)?.type === "compaction") {
prevCompactionIndex = i;
break;
}
@@ -718,7 +741,8 @@ export function prepareCompaction(
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
const messagesToSummarize: AgentMessage[] = [];
for (let i = boundaryStart; i < historyEnd; i++) {
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
const entry = pathEntries.at(i);
const msg = entry ? getMessageFromEntryForCompaction(entry) : undefined;
if (msg) {
messagesToSummarize.push(msg);
}
@@ -726,7 +750,8 @@ export function prepareCompaction(
const turnPrefixMessages: AgentMessage[] = [];
if (cutPoint.isSplitTurn) {
for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
const entry = pathEntries.at(i);
const msg = entry ? getMessageFromEntryForCompaction(entry) : undefined;
if (msg) {
turnPrefixMessages.push(msg);
}
@@ -192,16 +192,19 @@ async function loadJsonlStorage(
throw invalidSession(filePath, "missing session header");
}
const header = parseHeaderLine(lines[headerIndex], filePath);
const headerLine = lines.at(headerIndex);
if (headerLine === undefined) {
throw invalidSession(filePath, "missing session header");
}
const header = parseHeaderLine(headerLine, filePath);
const entries: SessionTreeEntry[] = [];
let leafId: string | null = null;
let appendParentId: string | null = null;
for (let lineIndex = headerIndex + 1; lineIndex < lines.length; lineIndex++) {
const line = lines[lineIndex];
for (const [offset, line] of lines.slice(headerIndex + 1).entries()) {
if (!line.trim()) {
continue;
}
const entry = parseEntryLine(line, filePath, lineIndex + 1);
const entry = parseEntryLine(line, filePath, headerIndex + offset + 2);
entries.push(entry);
const leafUpdate = leafIdUpdateAfterEntry(entry);
if (leafUpdate !== undefined) {
@@ -81,8 +81,7 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
// Replay only the compacted entry's retained tail plus newer branch entries; older
// transcript content is represented by the synthetic compaction summary above.
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = pathEntries[i];
for (const entry of pathEntries.slice(0, compactionIdx)) {
if (entry.id === compaction.firstKeptEntryId) {
foundFirstKept = true;
}
@@ -90,8 +89,8 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
appendMessage(entry);
}
}
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
appendMessage(pathEntries[i]);
for (const entry of pathEntries.slice(compactionIdx + 1)) {
appendMessage(entry);
}
} else {
for (const entry of pathEntries) {
@@ -21,7 +21,7 @@ export function uuidv7(): string {
const timestamp = Date.now();
if (timestamp > lastTimestamp) {
sequence = random[6] * 0x1000000 + random[7] * 0x10000 + random[8] * 0x100 + random[9];
sequence = new DataView(random.buffer, random.byteOffset + 6, 4).getUint32(0);
lastTimestamp = timestamp;
} else {
// Same-ms calls increment the sequence so generated ids remain sortable and
@@ -43,12 +43,12 @@ export function uuidv7(): string {
bytes[7] = (sequence >>> 20) & 0xff;
bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f);
bytes[9] = (sequence >>> 6) & 0xff;
bytes[10] = ((sequence & 0x3f) << 2) | (random[10] & 0x03);
bytes[11] = random[11];
bytes[12] = random[12];
bytes[13] = random[13];
bytes[14] = random[14];
bytes[15] = random[15];
const randomLowBits = random.at(10);
if (randomLowBits === undefined) {
throw new Error("UUID random buffer is shorter than 11 bytes");
}
bytes[10] = ((sequence & 0x3f) << 2) | (randomLowBits & 0x03);
bytes.set(random.subarray(11), 11);
return formatUuid(bytes);
}
@@ -111,7 +111,7 @@ function replaceUnpairedSurrogates(content: string): string {
if (i + 1 < content.length) {
const next = content.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
output += content[i] + content[i + 1];
output += content.charAt(i) + content.charAt(i + 1);
i++;
continue;
}
@@ -120,7 +120,7 @@ function replaceUnpairedSurrogates(content: string): string {
} else if (code >= 0xdc00 && code <= 0xdfff) {
output += "";
} else {
output += content[i];
output += content.charAt(i);
}
}
return output;
@@ -217,8 +217,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
let outputBytesCount = 0;
let truncatedBy: "lines" | "bytes" = input.totalLines > input.maxLines ? "lines" : "bytes";
for (let i = 0; i < input.lines.length && i < input.maxLines; i++) {
const line = input.lines[i];
for (const [i, line] of input.lines.slice(0, input.maxLines).entries()) {
const lineBytes = utf8ByteLength(line) + (i > 0 ? 1 : 0); // +1 for newline
if (outputBytesCount + lineBytes > input.maxBytes) {
@@ -273,7 +272,10 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
let lastLinePartial = false;
for (let i = input.lines.length - 1; i >= 0 && outputLinesArr.length < input.maxLines; i--) {
const line = input.lines[i];
const line = input.lines.at(i);
if (line === undefined) {
continue;
}
const lineBytes = utf8ByteLength(line) + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
if (outputBytesCount + lineBytes > input.maxBytes) {
+3 -6
View File
@@ -83,8 +83,7 @@ export function clampThinkingLevel<TApi extends Api>(
// stronger levels so unsupported xhigh/max requests cannot increase cost.
const thinkingLevelMap = resolveThinkingLevelMap(model);
if ((level === "xhigh" || level === "max") && thinkingLevelMap?.[level] === null) {
for (let i = requestedIndex - 1; i >= 0; i--) {
const candidate = EXTENDED_THINKING_LEVELS[i];
for (const candidate of EXTENDED_THINKING_LEVELS.slice(0, requestedIndex).toReversed()) {
if (availableLevels.includes(candidate)) {
return candidate;
}
@@ -92,14 +91,12 @@ export function clampThinkingLevel<TApi extends Api>(
}
// Prefer the next stronger available level, then walk down if the request was above the model cap.
for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) {
const candidate = EXTENDED_THINKING_LEVELS[i];
for (const candidate of EXTENDED_THINKING_LEVELS.slice(requestedIndex)) {
if (availableLevels.includes(candidate)) {
return candidate;
}
}
for (let i = requestedIndex - 1; i >= 0; i--) {
const candidate = EXTENDED_THINKING_LEVELS[i];
for (const candidate of EXTENDED_THINKING_LEVELS.slice(0, requestedIndex).toReversed()) {
if (availableLevels.includes(candidate)) {
return candidate;
}
+17 -7
View File
@@ -29,7 +29,6 @@ import type {
ThinkingContent,
Tool,
ToolCall,
ToolResultMessage,
} from "../types.js";
import { createDeferredEventBuffer } from "../utils/deferred-event-buffer.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
@@ -688,8 +687,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
costModel = { ...model, cost: CLAUDE_FABLE_5_FALLBACK_MODEL_COST };
calculateCost(costModel, output.usage);
eventSink.push({ type: "start", partial: output });
for (let i = 0; i < blocks.length; i += 1) {
const block = blocks[i];
for (const [i, block] of blocks.entries()) {
if (block.type !== "text") {
continue;
}
@@ -1450,6 +1448,9 @@ function convertMessages(
for (let i = 0; i < transformedMessages.length; i++) {
const msg = transformedMessages[i];
if (!msg) {
continue;
}
if (msg.role === "user") {
const isRuntimeContextCarrier = msg.runtimeContextCarrier === true;
@@ -1517,9 +1518,12 @@ function convertMessages(
}
// Redacted thinking: pass the opaque payload back as redacted_thinking
if (block.redacted) {
if (!block.thinkingSignature) {
throw new Error("redacted thinking block is missing its opaque signature");
}
blocks.push({
type: "redacted_thinking",
data: block.thinkingSignature!,
data: block.thinkingSignature,
});
continue;
}
@@ -1579,8 +1583,11 @@ function convertMessages(
});
let j = i + 1;
while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") {
const nextMsg = transformedMessages[j] as ToolResultMessage;
while (j < transformedMessages.length) {
const nextMsg = transformedMessages.at(j);
if (nextMsg?.role !== "toolResult") {
break;
}
toolResults.push({
type: "tool_result",
tool_use_id: nextMsg.toolCallId,
@@ -1603,13 +1610,16 @@ function convertMessages(
for (let i = params.length - 1; i >= 0; i--) {
const message = params[i];
if (message.role !== "user" || cacheBreakpointOptOutParamIndexes.has(i)) {
if (!message || message.role !== "user" || cacheBreakpointOptOutParamIndexes.has(i)) {
continue;
}
if (Array.isArray(message.content)) {
for (let j = message.content.length - 1; j >= 0; j--) {
const block = message.content[j];
if (!block) {
continue;
}
if (block.type === "text" || block.type === "image") {
if (fallbackToolResult && messageCacheControlLimit === 1) {
applyContentBlockCacheControl(fallbackToolResult, cacheControl);
+2 -1
View File
@@ -144,7 +144,8 @@ function getGeminiMajorVersion(modelId: string): number | undefined {
if (!match) {
return undefined;
}
return Number.parseInt(match[1], 10);
const majorVersion = match.at(1);
return majorVersion === undefined ? undefined : Number.parseInt(majorVersion, 10);
}
function supportsMultimodalFunctionResponse(modelId: string): boolean {
@@ -69,7 +69,7 @@ describe("Mistral bounded-stream-read real wire proof (loopback http.createServe
/mistral: stream body exceeds \d+ bytes \(got (\d+)\)/,
);
expect(match).not.toBeNull();
const got = Number(match![1]);
const got = Number(match?.[1]);
expect(got).toBeGreaterThan(MAX);
expect(got).toBeLessThan(TOTAL);
// Print to vitest stdout for PR-body real behavior proof capture.
@@ -160,7 +160,7 @@ describe("Mistral bounded-stream-read direct (synthetic ReadableStream)", () =>
/mistral: stream body exceeds \d+ bytes \(got (\d+)\)/,
);
expect(match).not.toBeNull();
const got = Number(match![1]);
const got = Number(match?.[1]);
// Synthetic stream chunks are exactly 1 MiB aligned, so cap+1 reads
// give exactly cap + 1 MiB = 16 MiB + 1 MiB = 17 825 792 bytes.
expect(got).toBe(16777216 + CHUNK);
+2 -2
View File
@@ -824,7 +824,7 @@ describe("Mistral provider", () => {
};
const toolMessage = payload.messages.find((message) => message.role === "tool");
expect(toolMessage).toBeDefined();
const toolContent = Array.isArray(toolMessage!.content) ? toolMessage!.content : [];
const toolContent = Array.isArray(toolMessage?.content) ? toolMessage.content : [];
const textBlock = toolContent.find((block) => block.type === "text");
expect(textBlock?.text).toEqual(expect.stringContaining('{"type":"resource"'));
expect(textBlock?.text).toContain('{\\"key\\":\\"***\\"}');
@@ -876,7 +876,7 @@ describe("Mistral provider", () => {
};
const toolMessage = payload.messages.find((message) => message.role === "tool");
expect(toolMessage).toBeDefined();
const toolContent = Array.isArray(toolMessage!.content) ? toolMessage!.content : [];
const toolContent = Array.isArray(toolMessage?.content) ? toolMessage.content : [];
const textBlock = toolContent.find((block) => block.type === "text");
// Structured blocks should provide the output, not an empty fallback
expect(textBlock?.text).toEqual(expect.stringContaining('{"type":"resource_link"'));
+2 -2
View File
@@ -766,8 +766,8 @@ async function consumeChatStream(
finishCurrentBlock(currentBlock);
for (const index of toolBlockIdentities.keys()) {
const block = output.content[index];
if (block.type !== "toolCall") {
const block = output.content.at(index);
if (block?.type !== "toolCall") {
continue;
}
const toolBlock = block as ToolCall & { partialArgs?: string };
@@ -1438,8 +1438,9 @@ async function* parseWebSocket(
if (signal?.aborted) {
throw new Error("Request was aborted");
}
if (queue.length > 0) {
yield queue.shift()!;
const next = queue.shift();
if (next !== undefined) {
yield next;
continue;
}
if (done) {
+28 -17
View File
@@ -33,7 +33,6 @@ import type {
ThinkingContent,
Tool,
ToolCall,
ToolResultMessage,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { headersToRecord } from "../utils/headers.js";
@@ -891,7 +890,7 @@ function addCacheControlToLastConversationMessage(
): void {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (cacheOptOutIndexes.has(i)) {
if (!message || cacheOptOutIndexes.has(i)) {
continue;
}
if (message.role === "user" || message.role === "assistant") {
@@ -910,7 +909,10 @@ function addCacheControlToLastTool(
return;
}
const lastTool = tools[tools.length - 1] as ChatCompletionToolWithCacheControl;
const lastTool: ChatCompletionToolWithCacheControl | undefined = tools.at(-1);
if (!lastTool) {
return;
}
lastTool.cache_control = cacheControl;
}
@@ -1003,7 +1005,7 @@ export function convertMessages(
// These come from providers like github-copilot, openai, opencode
// Extract just the call_id part and normalize it
if (id.includes("|")) {
const [callId] = id.split("|");
const callId = id.slice(0, id.indexOf("|"));
// Sanitize to allowed chars and truncate to 40 chars (OpenAI limit)
return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40);
}
@@ -1034,6 +1036,9 @@ export function convertMessages(
for (let i = 0; i < transformedMessages.length; i++) {
const msg = transformedMessages[i];
if (!msg) {
continue;
}
// Some providers don't allow user messages directly after tool results
// Insert a synthetic assistant message to bridge the gap
if (
@@ -1127,7 +1132,7 @@ export function convertMessages(
}
// Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss)
let signature = nonEmptyThinkingBlocks[0].thinkingSignature;
let signature = nonEmptyThinkingBlocks.at(0)?.thinkingSignature;
if (model.provider === "opencode-go" && signature === "reasoning") {
signature = "reasoning_content";
}
@@ -1155,16 +1160,18 @@ export function convertMessages(
arguments: JSON.stringify(tc.arguments),
},
}));
const reasoningDetails = toolCalls
.filter((tc) => tc.thoughtSignature)
.map((tc) => {
try {
return JSON.parse(tc.thoughtSignature!);
} catch {
return null;
}
})
.filter(Boolean);
const reasoningDetails = toolCalls.flatMap((tc) => {
const signature = tc.thoughtSignature;
if (!signature) {
return [];
}
try {
const parsed: unknown = JSON.parse(signature);
return parsed ? [parsed] : [];
} catch {
return [];
}
});
if (reasoningDetails.length > 0) {
(
assistantMsg as typeof assistantMsg & { reasoning_details?: unknown }
@@ -1195,8 +1202,11 @@ export function convertMessages(
const imageBlocks: Array<{ type: "image_url"; image_url: { url: string } }> = [];
let j = i;
for (; j < transformedMessages.length && transformedMessages[j].role === "toolResult"; j++) {
const toolMsg = transformedMessages[j] as ToolResultMessage;
while (j < transformedMessages.length) {
const toolMsg = transformedMessages.at(j);
if (toolMsg?.role !== "toolResult") {
break;
}
// Extract text and image content
const textResult = extractToolResultText(toolMsg.content);
@@ -1231,6 +1241,7 @@ export function convertMessages(
}
}
}
j += 1;
}
i = j - 1;
@@ -67,13 +67,22 @@ import { transformMessages } from "./transform-messages.js";
const EMPTY_TOOL_RESULT_TEXT = "(no output)";
// itemId is undefined when the id has no separator so replay paths keep
// omitting the optional item id instead of serializing an empty string.
function splitResponsesToolCallId(id: string): [callId: string, itemId: string | undefined] {
const separatorIndex = id.indexOf("|");
return separatorIndex === -1
? [id, undefined]
: [id.slice(0, separatorIndex), id.slice(separatorIndex + 1)];
}
function resolveResponsesToolCallId(
item: { call_id?: unknown; id?: unknown },
fallbackId?: string,
): string {
const callId = typeof item.call_id === "string" ? item.call_id.trim() : "";
const itemId = typeof item.id === "string" ? item.id.trim() : "";
const [fallbackCallId = "", fallbackItemId = ""] = (fallbackId ?? "").split("|");
const [fallbackCallId, fallbackItemId = ""] = splitResponsesToolCallId(fallbackId ?? "");
const resolvedCallId = callId || fallbackCallId;
const resolvedItemId = itemId || fallbackItemId;
if (resolvedCallId) {
@@ -292,7 +301,8 @@ export function convertResponsesMessages<TApi extends Api>(
if (!id.includes("|")) {
return normalizeIdPart(id);
}
const [callId, itemId] = id.split("|");
// The includes("|") guard above guarantees the item id component exists.
const [callId, itemId = ""] = splitResponsesToolCallId(id);
const normalizedCallId = normalizeIdPart(callId);
const isForeignToolCall = source.provider !== model.provider || source.api !== model.api;
let normalizedItemId = isForeignToolCall
@@ -405,7 +415,7 @@ export function convertResponsesMessages<TApi extends Api>(
previousReplayItemWasReasoning = false;
} else if (block.type === "toolCall") {
const toolCall = block;
const [callId, itemIdRaw] = toolCall.id.split("|");
const [callId, itemIdRaw] = splitResponsesToolCallId(toolCall.id);
let itemId: string | undefined = shouldReplayResponsesItemIds ? itemIdRaw : undefined;
// For different-model messages, set id to undefined to avoid pairing validation.
@@ -435,7 +445,7 @@ export function convertResponsesMessages<TApi extends Api>(
const hasImages = msg.content.some((c): c is ImageContent => c.type === "image");
const mediaPlaceholder = describeToolResultMediaPlaceholder(msg.content);
const hasText = sanitizedTextResult.trim().length > 0;
const [callId] = msg.toolCallId.split("|");
const [callId] = splitResponsesToolCallId(msg.toolCallId);
let output: string | ResponseFunctionCallOutputItemList;
if (hasImages && model.input.includes("image")) {
@@ -752,18 +762,21 @@ export async function processResponsesStream<TApi extends Api>(
): StreamingToolCallState | undefined => {
const uniqueCandidates = [...new Set(candidates)];
if (!identity.itemId && !identity.callId) {
return uniqueCandidates.length === 1 ? uniqueCandidates[0] : undefined;
return uniqueCandidates.length === 1 ? uniqueCandidates.at(0) : undefined;
}
const compatible = uniqueCandidates.filter((state) => !identitiesConflict(state, identity));
const matches = compatible.filter((state) => sharesIdentity(state, identity));
if (matches.length === 1) {
return adoptToolCallIdentity(matches[0], identity);
const matched = matches.length === 1 ? matches.at(0) : undefined;
if (matched) {
return adoptToolCallIdentity(matched, identity);
}
// Only a sole active call may adopt an identity it did not already know.
// Parallel calls require a positive match so missing indices stay fail-closed.
return uniqueCandidates.length === 1 && compatible.length === 1 && matches.length === 0
? adoptToolCallIdentity(compatible[0], identity)
: undefined;
const soleCompatible =
uniqueCandidates.length === 1 && compatible.length === 1 && matches.length === 0
? compatible.at(0)
: undefined;
return soleCompatible ? adoptToolCallIdentity(soleCompatible, identity) : undefined;
};
const resolveStreamingToolCall = (
event: { output_index?: unknown; item_id?: unknown },
@@ -150,6 +150,17 @@ describe("OpenAI tool projection", () => {
});
});
it("classifies sparse descriptors as unreadable tools", () => {
const tools: Array<{ name: string; parameters: Record<string, unknown> }> = [];
tools.length = 1;
expect(projectOpenAITools(tools)).toEqual({
inputToolCount: 1,
tools: [],
diagnostics: [{ toolIndex: 0, violations: ["tool[0] is unreadable"] }],
});
});
it("rejects pinned and required choices when their function tools are unavailable", () => {
const projection = projectOpenAITools([
{
@@ -69,7 +69,12 @@ export function projectOpenAITools(tools: readonly OpenAIToolDescriptor[]): Open
for (let toolIndex = 0; toolIndex < inputToolCount; toolIndex += 1) {
let tool: OpenAIToolDescriptor;
try {
tool = tools[toolIndex];
const candidate = tools[toolIndex];
if (!candidate) {
diagnostics.push(unreadableToolDiagnostic(toolIndex));
continue;
}
tool = candidate;
} catch {
diagnostics.push(unreadableToolDiagnostic(toolIndex));
continue;
+7 -3
View File
@@ -41,6 +41,10 @@ export function buildBaseOptions(
};
}
export function clampReasoning(effort: ThinkingLevel): Exclude<ThinkingLevel, "xhigh">;
export function clampReasoning(
effort: ThinkingLevel | undefined,
): Exclude<ThinkingLevel, "xhigh"> | undefined;
export function clampReasoning(
effort: ThinkingLevel | undefined,
): Exclude<ThinkingLevel, "xhigh"> | undefined {
@@ -54,7 +58,7 @@ export function adjustMaxTokensForThinking(
reasoningLevel: ThinkingLevel,
customBudgets?: ThinkingBudgets,
): { maxTokens: number; thinkingBudget: number } {
const defaultBudgets: ThinkingBudgets = {
const defaultBudgets: Required<ThinkingBudgets> = {
minimal: 1024,
low: 2048,
medium: 8192,
@@ -64,8 +68,8 @@ export function adjustMaxTokensForThinking(
const budgets = { ...defaultBudgets, ...customBudgets };
const minOutputTokens = 1024;
const level = clampReasoning(reasoningLevel)!;
let thinkingBudget = budgets[level]!;
const level = clampReasoning(reasoningLevel);
let thinkingBudget = budgets[level];
const maxTokens =
baseMaxTokens === undefined
? modelMaxTokens
+3 -3
View File
@@ -37,7 +37,7 @@ export function repairJson(json: string): string {
let stringValuePrefix = "";
for (let index = 0; index < json.length; index++) {
const char = json[index];
const char = json.charAt(index);
if (!inString) {
repaired += char;
@@ -56,8 +56,8 @@ export function repairJson(json: string): string {
}
if (char === "\\") {
const nextChar = json[index + 1];
if (nextChar === undefined) {
const nextChar = json.charAt(index + 1);
if (!nextChar) {
repaired += "\\\\";
continue;
}
+4 -2
View File
@@ -142,9 +142,11 @@ function resolveContextInputTokens(message: AssistantMessage): number | undefine
export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {
// Case 1: Check error message patterns
if (message.stopReason === "error" && message.errorMessage) {
// Hoist so the regex closures keep the narrowing without assertions.
const errorMessage = message.errorMessage;
// Skip messages matching known non-overflow patterns (e.g. throttling / rate-limit)
const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!));
if (!isNonOverflow && OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) {
const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(errorMessage));
if (!isNonOverflow && OVERFLOW_PATTERNS.some((p) => p.test(errorMessage))) {
return true;
}
}
@@ -313,13 +313,13 @@ function findOpenInlineCodeStart(text: string): number {
let openTicks = 0;
let index = 0;
while (index < text.length) {
if (text[index] !== "`") {
if (text.charAt(index) !== "`") {
index += 1;
continue;
}
const runStart = index;
let runLength = 0;
while (index < text.length && text[index] === "`") {
while (index < text.length && text.charAt(index) === "`") {
runLength += 1;
index += 1;
}
@@ -338,8 +338,12 @@ function findOpenFenceStart(text: string): number {
const fenceRe = /(^|\n)(```|~~~)[^\n]*(?:\n|$)/g;
let open: { marker: string; index: number } | null = null;
for (const match of text.matchAll(fenceRe)) {
const index = (match.index ?? 0) + match[1].length;
const marker = match[2] ?? "";
const prefix = match.at(1);
const marker = match.at(2);
if (prefix === undefined || marker === undefined) {
continue;
}
const index = (match.index ?? 0) + prefix.length;
if (open !== null && open.marker === marker) {
open = null;
} else if (!open) {
+6 -2
View File
@@ -804,10 +804,14 @@ export class GatewayClient {
if (this.pendingStop?.ws === ws) {
return this.pendingStop;
}
let resolve!: () => void;
const resolvers: Array<() => void> = [];
const promise = new Promise<void>((res) => {
resolve = res;
resolvers.push(res);
});
const resolve = resolvers.at(0);
if (!resolve) {
throw new Error("pending stop promise did not initialize its resolver");
}
this.pendingStop = { ws, promise, resolve };
return this.pendingStop;
}
@@ -275,14 +275,19 @@ describe("GatewayClient", () => {
});
});
let resolveFirstHello!: () => void;
let resolveSecondHello!: () => void;
const firstHelloResolvers: Array<() => void> = [];
const secondHelloResolvers: Array<() => void> = [];
const firstHello = new Promise<void>((resolve) => {
resolveFirstHello = resolve;
firstHelloResolvers.push(resolve);
});
const secondHello = new Promise<void>((resolve) => {
resolveSecondHello = resolve;
secondHelloResolvers.push(resolve);
});
const resolveFirstHello = firstHelloResolvers.at(0);
const resolveSecondHello = secondHelloResolvers.at(0);
if (!resolveFirstHello || !resolveSecondHello) {
throw new Error("hello promises did not initialize their resolvers");
}
const closeEvents: Array<{ code: number; reason: string }> = [];
let helloCount = 0;
const client = new GatewayClient({
+15 -4
View File
@@ -11,16 +11,22 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
private waiting: ((value: IteratorResult<T>) => void)[] = [];
private done = false;
private finalResultPromise: Promise<R>;
private resolveFinalResult!: (result: R) => void;
private resolveFinalResult: (result: R) => void;
private isComplete: (event: T) => boolean;
private extractResult: (event: T) => R;
constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) {
this.isComplete = isComplete;
this.extractResult = extractResult;
const resolvers: Array<(result: R) => void> = [];
this.finalResultPromise = new Promise((resolve) => {
this.resolveFinalResult = resolve;
resolvers.push(resolve);
});
const resolveFinalResult = resolvers.at(0);
if (!resolveFinalResult) {
throw new Error("event stream result promise did not initialize its resolver");
}
this.resolveFinalResult = resolveFinalResult;
}
push(event: T): void {
@@ -47,7 +53,10 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
this.resolveFinalResult(result);
}
while (this.waiting.length > 0) {
const waiter = this.waiting.shift()!;
const waiter = this.waiting.shift();
if (!waiter) {
break;
}
waiter({ value: undefined as unknown, done: true });
}
}
@@ -55,7 +64,9 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
async *[Symbol.asyncIterator](): AsyncIterator<T> {
while (true) {
if (this.queue.length > 0) {
yield this.queue.shift()!;
for (const event of this.queue.splice(0, 1)) {
yield event;
}
} else if (this.done) {
return;
} else {
+10
View File
@@ -25,6 +25,16 @@ export const STRICT_RATCHET_PACKAGE_DIRS = [
"packages/normalization-core",
"packages/model-catalog-core",
"packages/web-content-core",
"packages/ai",
"packages/agent-core",
"packages/acp-core",
"packages/gateway-client",
"packages/gateway-protocol",
"packages/llm-core",
"packages/media-core",
"packages/media-generation-core",
"packages/plugin-package-contract",
"packages/sdk",
];
const TEST_ROOT_TYPECHECK_PATH_RE =
/^(?:test\/(?!fixtures\/).*\.(?:[cm]?ts|[cm]?tsx)|test\/tsconfig\/tsconfig\.test\.root\.json)$/u;
+10
View File
@@ -173,6 +173,16 @@ describe("oxlint config", () => {
"packages/normalization-core/**/*.ts",
"packages/model-catalog-core/**/*.ts",
"packages/web-content-core/**/*.ts",
"packages/agent-core/**/*.ts",
"packages/acp-core/**/*.ts",
"packages/ai/**/*.ts",
"packages/gateway-client/**/*.ts",
"packages/gateway-protocol/**/*.ts",
"packages/llm-core/**/*.ts",
"packages/media-core/**/*.ts",
"packages/media-generation-core/**/*.ts",
"packages/plugin-package-contract/**/*.ts",
"packages/sdk/**/*.ts",
],
rules: {
"typescript/no-non-null-assertion": "error",
+20 -9
View File
@@ -14,22 +14,33 @@ if (
) {
throw new Error("expected strict-ratchet tsconfig includes to be strings");
}
const includedPackageDirs = config.include
.filter((entry) => entry.startsWith("packages/") && entry.endsWith("/src/**/*"))
.map((entry) => entry.replace(/\/src\/\*\*\/\*$/u, ""));
const includedPackages = config.include
.filter(
(entry) =>
entry.startsWith("packages/") &&
(entry.endsWith("/**/*") || entry.endsWith("/*.ts") || entry.endsWith("/**/*.ts")),
)
.map((include) => ({
include,
packageDir: include.replace(/\/(?:src\/\*\*\/\*|\*\.ts|\*\*\/\*\.ts)$/u, ""),
}));
const includedPackageDirs = includedPackages.map(({ packageDir }) => packageDir);
describe("strict ratchet routing", () => {
it("keeps the changed-lane package list pinned to the tsconfig", () => {
expect(includedPackageDirs).toEqual(STRICT_RATCHET_PACKAGE_DIRS);
});
it.each(includedPackageDirs)("routes %s changes through the ratchet lane", (packageDir) => {
const result = detectChangedLanes([`${packageDir}/src/example.ts`]);
const plan = createChangedCheckPlan(result);
it.each(includedPackages)(
"routes $packageDir changes through the ratchet lane",
({ include }) => {
const result = detectChangedLanes([include.replace(/(?:\*\*\/\*|\*)/u, "example")]);
const plan = createChangedCheckPlan(result);
expect(result.lanes.strictRatchet).toBe(true);
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:strict-ratchet");
});
expect(result.lanes.strictRatchet).toBe(true);
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:strict-ratchet");
},
);
it("routes the ratchet tsconfig through its lane", () => {
expect(detectChangedLanes(["tsconfig.strict-ratchet.json"]).lanes.strictRatchet).toBe(true);
+13 -2
View File
@@ -1,8 +1,9 @@
// noUncheckedIndexedAccess ratchet: grow include as directories migrate.
// Keep aligned with the oxlint no-non-null-assertion override and changed-lanes routing.
// Delete this lane when the base tsconfig enables noUncheckedIndexedAccess.
// memory-host-sdk is excluded: it re-exports core src/** directly, so the flag
// would apply transitively to all of core; it migrates together with src/.
// memory-host-sdk and speech-core are excluded: they import core src/** (directly
// or via openclaw/plugin-sdk path mappings), so the flag
// would apply transitively to all of core; they migrate together with src/.
{
"extends": "./tsconfig.json",
"compilerOptions": {
@@ -19,6 +20,16 @@
"packages/normalization-core/src/**/*",
"packages/model-catalog-core/src/**/*",
"packages/web-content-core/src/**/*",
"packages/ai/src/**/*",
"packages/agent-core/src/**/*",
"packages/acp-core/src/**/*",
"packages/gateway-client/src/**/*",
"packages/gateway-protocol/src/**/*",
"packages/llm-core/src/**/*",
"packages/media-core/src/**/*",
"packages/media-generation-core/src/**/*",
"packages/plugin-package-contract/src/**/*",
"packages/sdk/src/**/*",
"src/**/*.d.ts",
"packages/**/*.d.ts"
],