mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
committed by
GitHub
parent
8fff38479e
commit
4b751ce48a
+11
-1
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"'));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user