From c4307c33e3df46d0ad41265f1500a4ecae3d7319 Mon Sep 17 00:00:00 2001 From: Andrey Bezborodov Date: Tue, 7 Jul 2026 23:05:12 +0300 Subject: [PATCH 01/10] Accept [Tool results received] as continuation marker --- src/query.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/query.ts b/src/query.ts index 2d43e7a826..1038e94c43 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1430,7 +1430,14 @@ async function* queryLoop( const msgToolUseBlocks = message.message.content.filter( content => content.type === 'tool_use', ) as ToolUseBlock[] - if (msgToolUseBlocks.length > 0) { + // Treat '[Tool results received]' placeholder as a synonym for tool_use + // to ensure execution continues when using self-hosted llama-server + const hasToolResultsMarker = message.message.content.some( + content => + content.type === 'text' && + content.text === '[Tool results received]', + ) + if (msgToolUseBlocks.length > 0 || hasToolResultsMarker) { toolUseBlocks.push(...msgToolUseBlocks) needsFollowUp = true } From 780f53f7242e7a0ac1204cc00fd5701945ea6e5b Mon Sep 17 00:00:00 2001 From: Andrey Bezborodov Date: Tue, 14 Jul 2026 15:27:24 +0300 Subject: [PATCH 02/10] fix(query): prevent premature termination after tool execution with self-hosted LLMs - Share TOOL_RESULTS_RECEIVED_MARKER constant between shim and query - Strip marker-only assistant responses (shim artifacts) before yield to UI - Force continuation nudge on marker-only stall (self-hosted echo) - Force continuation nudge when stop_hook_active (goal/hook blocking errors) - Add test coverage for marker handling behavior --- .gitignore | 1 + src/constants/messages.ts | 9 + src/query.ts | 90 +++++++-- src/query/toolResultsMarker.test.ts | 274 ++++++++++++++++++++++++++++ src/services/api/openaiShim.ts | 12 +- 5 files changed, 368 insertions(+), 18 deletions(-) create mode 100644 src/query/toolResultsMarker.test.ts diff --git a/.gitignore b/.gitignore index 025a415c08..1dfe60b5eb 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ plan/ .tmp .tmp-* temp_reference/ +.codelite/ diff --git a/src/constants/messages.ts b/src/constants/messages.ts index 718aec6dc1..622856c801 100644 --- a/src/constants/messages.ts +++ b/src/constants/messages.ts @@ -1 +1,10 @@ export const NO_CONTENT_MESSAGE = '(no content)' + +// Semantic assistant boundary injected by the OpenAI shim when a 'tool' role +// message must be followed by an 'assistant' message (Mistral / Devstral +// strict role sequence). The query loop detects this text to decide whether +// to continue the tool-execution path or treat the turn as stalled. +// +// IMPORTANT: if you change this string, update the detection in query.ts as +// well — both places must stay in sync. +export const TOOL_RESULTS_RECEIVED_MARKER = '[Tool results received]' diff --git a/src/query.ts b/src/query.ts index 1332bcbb1b..597e585e1c 100644 --- a/src/query.ts +++ b/src/query.ts @@ -65,6 +65,7 @@ import { createMicrocompactBoundaryMessage, } from './utils/messages.js' import { analyzeContinuationIntent } from './utils/continuation.js' +import { TOOL_RESULTS_RECEIVED_MARKER } from './constants/messages.js' import { generateToolUseSummary } from './services/toolUseSummary/toolUseSummaryGenerator.js' import { prependUserContext, appendSystemContext } from './utils/api.js' import { @@ -995,6 +996,11 @@ async function* queryLoop( // loop-exit signal. If false after streaming, we're done (modulo stop-hook retry). const toolUseBlocks: ToolUseBlock[] = [] let needsFollowUp = false + // Tracks whether the streaming loop encountered a marker-only (shim + // artifact) assistant response this iteration — used by the + // continuation-nudge block below to force a nudge when the stripped + // text is empty (model effectively said nothing). + let markerOnlyStall = false queryCheckpoint('query_setup_start') const useStreamingToolExecution = @@ -1455,34 +1461,65 @@ async function* queryLoop( ) { withheld = true } - if (!withheld) { - yield yieldMessage - } if (message.type === 'assistant') { assistantMessages.push(message) const msgToolUseBlocks = message.message.content.filter( content => content.type === 'tool_use', ) as ToolUseBlock[] - // Treat '[Tool results received]' placeholder as a synonym for tool_use - // to ensure execution continues when using self-hosted llama-server + // Detect the semantic boundary the OpenAI shim injects so + // self-hosted providers (e.g. llama-server) which echo the + // shim's assistant-injection marker back in their response are + // handled correctly. Use the same whitespace-tolerant pattern + // as the strip logic below so detection and removal stay in + // sync even when the server wraps the marker in newlines. + const markerPattern = new RegExp( + `\\s*${TOOL_RESULTS_RECEIVED_MARKER + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, + 'g', + ) const hasToolResultsMarker = message.message.content.some( content => content.type === 'text' && - content.text === '[Tool results received]', + typeof content.text === 'string' && + markerPattern.test(content.text), ) - if (msgToolUseBlocks.length > 0 || hasToolResultsMarker) { + // Marker-only response (no tool_use blocks): this is a shim + // artifact, not a user-visible message. Withhold it from the + // UI and strip it from the state so the continuation-nudge + // block below sees a clean text tail and the capped nudge + // path handles the stall (max 20 retries). + if (hasToolResultsMarker && msgToolUseBlocks.length === 0) { + withheld = true + markerOnlyStall = true + // Strip marker from the stored message so it doesn't appear + // in messagesForQuery for subsequent turns. + const remaining = message.message.content.map( + (content: unknown) => { + if ( + typeof content === 'object' && + content !== null && + 'type' in content && + 'text' in content && + (content as { type: string }).type === 'text' + ) { + const textBlock = content as { text: string } + const cleaned = textBlock.text.replace( + markerPattern, + '', + ) + return cleaned ? { ...textBlock, text: cleaned } : null + } + return content + }, + ).filter(Boolean) as typeof message.message.content + message.message.content = remaining + } else if (msgToolUseBlocks.length > 0) { toolUseBlocks.push(...msgToolUseBlocks) needsFollowUp = true } - - if ( - streamingToolExecutor && - !toolUseContext.abortController.signal.aborted - ) { - for (const toolBlock of msgToolUseBlocks) { - streamingToolExecutor.addTool(toolBlock, message) - } + if (!withheld) { + yield yieldMessage } } @@ -2295,9 +2332,28 @@ async function* queryLoop( .join(' ') .toLowerCase() - const { shouldNudge, reason: nudgeReason } = analyzeContinuationIntent( + let { shouldNudge, reason: nudgeReason } = analyzeContinuationIntent( lastText, - ) + ) as { shouldNudge: boolean; reason?: string } + + // Marker-only stall: when a self-hosted LLM echoes the shim's + // assistant-injection marker and the remaining text is empty (or + // whitespace), the model effectively said nothing. Force a nudge + // so the turn doesn't complete prematurely. + if (!shouldNudge && markerOnlyStall) { + shouldNudge = true + nudgeReason = 'marker_only_stall' + } + + // Stop-hook-active: the model was already told the task is + // incomplete (via goal evaluation or hook blocking errors). + // Force a nudge even if the text itself has no continuation + // signal — the model is being prompted to continue from the + // previous turn's tool results. + if (!shouldNudge && stopHookActive) { + shouldNudge = true + nudgeReason = 'stop_hook_active' + } if (shouldNudge) { logForDebugging( diff --git a/src/query/toolResultsMarker.test.ts b/src/query/toolResultsMarker.test.ts new file mode 100644 index 0000000000..3731f6a276 --- /dev/null +++ b/src/query/toolResultsMarker.test.ts @@ -0,0 +1,274 @@ +import { z } from 'zod/v4' +import { expect, test } from 'bun:test' +import type { ToolUseBlock } from '@anthropic-ai/sdk/resources/index.mjs' +import { query, type QueryParams } from '../query.js' +import type { QueryDeps } from './deps.js' +import { + createAssistantMessage, + createUserMessage, +} from '../utils/messages.js' +import { asSystemPrompt } from '../utils/systemPromptType.js' +import { TOOL_RESULTS_RECEIVED_MARKER } from '../constants/messages.js' + +function toolUse( + id: string, + name: string, + input: Record = {}, +): ToolUseBlock { + return { + type: 'tool_use', + id, + name, + input, + } as ToolUseBlock +} + +function makeQueryParams( + callModel: QueryDeps['callModel'], + overrides: Partial = {}, +): QueryParams { + return { + messages: [createUserMessage({ content: 'do something' })], + systemPrompt: asSystemPrompt([]), + userContext: {}, + systemContext: {}, + canUseTool: async () => ({ behavior: 'allow' }), + toolUseContext: { + abortController: new AbortController(), + getAppState: () => ({ + fastMode: false, + mcp: { tools: [], clients: [] }, + toolPermissionContext: { mode: 'default' }, + sessionHooks: new Map(), + mainLoopModel: 'gpt-4o', + effortValue: undefined, + advisorModel: undefined, + }), + options: { + commands: [], + debug: false, + thinkingConfig: { type: 'disabled' }, + tools: [ + { + name: 'AvailableTool', + description: 'test tool', + inputSchema: z.object({}), + }, + ] as unknown as QueryParams['toolUseContext']['options']['tools'], + verbose: false, + mcpClients: [], + mcpResources: {}, + isNonInteractiveSession: false, + agentDefinitions: { activeAgents: [], allAgents: [] }, + mainLoopModel: 'gpt-4o', + }, + addNotification: () => {}, + messages: [], + setInProgressToolUseIDs: () => {}, + setResponseLength: () => {}, + updateFileHistoryState: () => {}, + updateAttributionState: () => {}, + } as unknown as QueryParams['toolUseContext'], + querySource: 'agent:builtin:general-purpose', + deps: { + callModel, + microcompact: async (messages: any) => ({ messages }), + autocompact: async () => ({ + wasCompacted: false, + compactionResult: null, + consecutiveFailures: undefined, + }), + uuid: () => '00000000-0000-4000-8000-000000000000', + } as unknown as QueryDeps, + ...overrides, + } +} + +test('marker-only response (no tool_use blocks) forces continuation nudge then caps', async () => { + let modelCalls = 0 + + const callModel: QueryDeps['callModel'] = async function* () { + modelCalls++ + yield createAssistantMessage({ + id: `assistant-${modelCalls}`, + content: [{ type: 'text', text: TOOL_RESULTS_RECEIVED_MARKER }] as any, + model: 'test-model', + stop_reason: 'end_turn', + role: 'assistant', + } as any) + } + + const yielded: any[] = [] + const terminal = await (async () => { + const params = makeQueryParams(callModel as any) + params.deps!.callModel = callModel as any + const generator = query(params) + while (true) { + const next = await generator.next() + if (next.done) return next.value + yielded.push(next.value) + } + })() + + // Marker-only message is stripped and sets markerOnlyStall=true, + // which forces a continuation nudge. The mock model keeps echoing the + // marker so the nudge fires up to MAX_CONTINUATION_NUDGES (20) then + // the cap terminates the loop. + expect(modelCalls).toBe(21) // 1 initial + 20 nudges + expect(terminal.reason).toBe('completed') + // Marker text should have been stripped from yielded messages. + const markerTexts = yielded.filter( + item => + item.type === 'assistant' && + typeof item.message?.content === 'object' && + Array.isArray(item.message.content) && + item.message.content.some( + (c: unknown) => + typeof c === 'object' && + c !== null && + 'text' in c && + (c as { text: string }).text === TOOL_RESULTS_RECEIVED_MARKER, + ), + ) + expect(markerTexts.length).toBe(0) +}) + +test('marker with tool_use blocks sets needsFollowUp as before', async () => { + let modelCalls = 0 + + const callModel: QueryDeps['callModel'] = async function* () { + modelCalls++ + // First call: returns a tool_use plus the marker text + if (modelCalls === 1) { + yield createAssistantMessage({ + id: `assistant-1`, + content: [ + { type: 'text', text: TOOL_RESULTS_RECEIVED_MARKER }, + toolUse('tool-1', 'AvailableTool', {}), + ], + model: 'test-model', + stop_reason: 'end_turn', + role: 'assistant', + } as any) + } else { + yield createAssistantMessage({ + id: `assistant-2`, + content: [{ type: 'text', text: 'done' }], + model: 'test-model', + stop_reason: 'end_turn', + role: 'assistant', + } as any) + } + } + + const yielded: any[] = [] + const terminal = await (async () => { + const params = makeQueryParams(callModel as any) + params.deps!.callModel = callModel as any + const generator = query(params) + while (true) { + const next = await generator.next() + if (next.done) return next.value + yielded.push(next.value) + } + })() + + expect(modelCalls).toBe(2) + expect(terminal.reason).toBe('completed') + // Tool results are yielded as user messages with tool_result content. + // The second model call includes the tool result in messagesForQuery. + expect(yielded.some( + item => + item.type === 'user' && + Array.isArray(item.message?.content) && + item.message.content.some( + (c: unknown) => + typeof c === 'object' && + c !== null && + 'tool_use_id' in c && + (c as { tool_use_id: string }).tool_use_id === 'tool-1', + ), + )).toBe(true) +}) + +test('repeated marker-only responses hit nudge cap', async () => { + let modelCalls = 0 + const yielded: any[] = [] + + const callModel: QueryDeps['callModel'] = async function* () { + modelCalls++ + yield createAssistantMessage({ + id: `assistant-${modelCalls}`, + content: [{ type: 'text', text: TOOL_RESULTS_RECEIVED_MARKER }], + model: 'test-model', + stop_reason: 'end_turn', + role: 'assistant', + } as any) + } + + const terminal = await (async () => { + const params = makeQueryParams(callModel as any) + params.deps!.callModel = callModel as any + const generator = query(params) + while (true) { + const next = await generator.next() + if (next.done) { + return next.value + } + yielded.push(next.value) + } + })() + + // Marker-only text is stripped; markerOnlyStall forces a nudge. + // Model keeps echoing the marker so it loops up to MAX_CONTINUATION_NUDGES. + expect(modelCalls).toBe(21) // 1 initial + 20 nudges + expect(terminal.reason).toBe('completed') +}) + +test('marker-only then real text completes after one nudge', async () => { + let modelCalls = 0 + + const callModel: QueryDeps['callModel'] = async function* () { + modelCalls++ + if (modelCalls === 1) { + // First call: marker-only (self-hosted echo) + yield createAssistantMessage({ + id: `assistant-1`, + content: [{ type: 'text', text: TOOL_RESULTS_RECEIVED_MARKER }], + model: 'test-model', + stop_reason: 'end_turn', + role: 'assistant', + } as any) + } else { + // After nudge: model returns real text with no tool_use + yield createAssistantMessage({ + id: `assistant-2`, + content: [{ type: 'text', text: 'All done!' }], + model: 'test-model', + stop_reason: 'end_turn', + role: 'assistant', + } as any) + } + } + + const yielded: any[] = [] + const terminal = await (async () => { + const params = makeQueryParams(callModel as any) + params.deps!.callModel = callModel as any + const generator = query(params) + while (true) { + const next = await generator.next() + if (next.done) return next.value + yielded.push(next.value) + } + })() + + // First call is marker-only → stripped + nudge forces another model call. + // Second call returns real text → no continuation signal → completes. + expect(modelCalls).toBe(2) + expect(terminal.reason).toBe('completed') +}) + +test('marker constant matches shim injection', async () => { + expect(TOOL_RESULTS_RECEIVED_MARKER).toBe('[Tool results received]') +}) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 40b2f15710..8e5f0ad147 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -112,6 +112,7 @@ import { processStreamChunk, getStreamStats, } from '../../utils/streamingOptimizer.js' +import { TOOL_RESULTS_RECEIVED_MARKER } from '../../constants/messages.js' import { stableStringifyJson } from '../../utils/stableStringify.js' import { CredentialPool, @@ -1374,10 +1375,19 @@ function convertMessages( // assistant boundary to satisfy the strict role sequence without implying // that the user interrupted or cancelled anything: // ... -> assistant (calls) -> tool (results) -> assistant (semantic) -> user (next) + // + // The injected marker also serves a second purpose for self-hosted + // providers (e.g. llama-server, vLLM) that cannot produce native + // tool_use blocks: when such a server echoes the marker back in its + // assistant response, query.ts detects it via the same constant and + // strips it, routing the stall through the capped continuation-nudge + // mechanism (MAX_CONTINUATION_NUDGES = 20). If this string changes, + // update the detection in src/query.ts — both sides share the + // TOOL_RESULTS_RECEIVED_MARKER constant for sync. if (prev && prev.role === 'tool' && msg.role === 'user') { coalesced.push({ role: 'assistant', - content: '[Tool results received]', + content: TOOL_RESULTS_RECEIVED_MARKER, }) } From f50d259603bc0355219089ec9d5a10c4ce22dade Mon Sep 17 00:00:00 2001 From: Andrey Bezborodov Date: Tue, 14 Jul 2026 15:50:40 +0300 Subject: [PATCH 03/10] fix(query): only withhold marker-only responses with no substantive text Apply CodeRabbit feedback: compute remaining text after marker removal and only set withheld/markerOnlyStall when no real continuation text remains. Add regression test for marker + real text without tool_use. --- src/query.ts | 24 ++++++++++++------ src/query/toolResultsMarker.test.ts | 39 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/query.ts b/src/query.ts index 597e585e1c..e8e7912532 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1490,12 +1490,12 @@ async function* queryLoop( // block below sees a clean text tail and the capped nudge // path handles the stall (max 20 retries). if (hasToolResultsMarker && msgToolUseBlocks.length === 0) { - withheld = true - markerOnlyStall = true - // Strip marker from the stored message so it doesn't appear - // in messagesForQuery for subsequent turns. - const remaining = message.message.content.map( - (content: unknown) => { + // Compute remaining text after removing the marker. Only + // withhold / stall when there is no substantive text left; + // a message containing real continuation text should stay + // visible even without tool_use blocks. + let remaining = message.message.content + .map((content: unknown) => { if ( typeof content === 'object' && content !== null && @@ -1511,8 +1511,16 @@ async function* queryLoop( return cleaned ? { ...textBlock, text: cleaned } : null } return content - }, - ).filter(Boolean) as typeof message.message.content + }) + .filter(Boolean) as typeof message.message.content + const remainingText = remaining + .filter(b => typeof b === 'object' && b !== null && 'text' in b && (b as { text: string }).text.trim().length > 0) + .map(b => (b as { text: string }).text) + .join(' ') + if (remainingText.trim().length === 0) { + withheld = true + markerOnlyStall = true + } message.message.content = remaining } else if (msgToolUseBlocks.length > 0) { toolUseBlocks.push(...msgToolUseBlocks) diff --git a/src/query/toolResultsMarker.test.ts b/src/query/toolResultsMarker.test.ts index 3731f6a276..3ebd86fb18 100644 --- a/src/query/toolResultsMarker.test.ts +++ b/src/query/toolResultsMarker.test.ts @@ -269,6 +269,45 @@ test('marker-only then real text completes after one nudge', async () => { expect(terminal.reason).toBe('completed') }) +test('marker text followed by real continuation text is NOT withheld', async () => { + const callModel: QueryDeps['callModel'] = async function* () { + yield createAssistantMessage({ + id: 'assistant-1', + content: [ + { type: 'text', text: `${TOOL_RESULTS_RECEIVED_MARKER}\n\nI have more work to do.` }, + ], + model: 'test-model', + stop_reason: 'end_turn', + role: 'assistant', + } as any) + } + + const yielded: any[] = [] + const terminal = await (async () => { + const params = makeQueryParams(callModel as any) + params.deps!.callModel = callModel as any + const generator = query(params) + while (true) { + const next = await generator.next() + if (next.done) return next.value + yielded.push(next.value) + } + })() + + // Marker is present but real continuation text remains, so the message + // is yielded to the UI (not withheld) and no marker-only stall nudge fires. + expect(terminal.reason).toBe('completed') + const assistantMsgs = yielded.filter((item: any) => item.type === 'assistant') + expect(assistantMsgs.length).toBeGreaterThanOrEqual(1) + // The text content should include the non-marker portion. + const textParts = assistantMsgs.flatMap((msg: any) => + msg.message?.content + ?.filter((c: any) => c.type === 'text') + .map((c: any) => c.text) || [], + ) + expect(textParts.some((t) => t.includes('I have more work to do.'))).toBe(true) +}) + test('marker constant matches shim injection', async () => { expect(TOOL_RESULTS_RECEIVED_MARKER).toBe('[Tool results received]') }) From 8361848b2dcb1645b694497aae31391a9047648d Mon Sep 17 00:00:00 2001 From: Andrey Bezborodov Date: Tue, 14 Jul 2026 20:45:28 +0300 Subject: [PATCH 04/10] fix(query): fix marker stripping, add stall reason, test stop_hook_active nudge - S1: Use separate stripPattern (literal marker only) for text.replace() to avoid mid-sentence whitespace corruption when detection uses \s*. - S2: Add dedicated test for stop_hook_active -> continuation nudge path. - S3: Return distinct 'marker_stall_exhausted' reason instead of 'completed' when nudge cap is hit with zero real output. - S4: Push assistant messages after marker processing; drop empty content messages instead of mutating in place (S4 preserves prompt-cache invariant). - S7: Add tests for mid-sentence stripping, stall-exhausted reason, and stop_hook_active nudge. - S8: Change 'let remaining' to 'const' (prefer-const). --- src/query.ts | 53 ++++++++++++++-- src/query/goalContinuation.test.ts | 88 +++++++++++++++++++++++++++ src/query/toolResultsMarker.test.ts | 94 ++++++++++++++++++++++++++++- src/query/transitions.ts | 1 + 4 files changed, 228 insertions(+), 8 deletions(-) diff --git a/src/query.ts b/src/query.ts index e8e7912532..c7d505f4e3 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1462,8 +1462,6 @@ async function* queryLoop( withheld = true } if (message.type === 'assistant') { - assistantMessages.push(message) - const msgToolUseBlocks = message.message.content.filter( content => content.type === 'tool_use', ) as ToolUseBlock[] @@ -1473,11 +1471,31 @@ async function* queryLoop( // handled correctly. Use the same whitespace-tolerant pattern // as the strip logic below so detection and removal stay in // sync even when the server wraps the marker in newlines. + // Detection pattern: \s* greedily matches surrounding + // whitespace/newlines so we find the marker even when the + // provider wraps it in formatting. const markerPattern = new RegExp( `\\s*${TOOL_RESULTS_RECEIVED_MARKER .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'g', ) + // Strip pattern: matches only the escaped literal marker, + // without consuming adjacent whitespace. This prevents + // mid-sentence corruption when the marker is embedded in + // larger text (e.g. "continue [Marker] and finish" → + // "continue and finish" instead of "continueand finish"). + // Strip pattern: matches only the escaped literal marker, + // without consuming adjacent whitespace. This prevents + // mid-sentence corruption when the marker is embedded in + // larger text (e.g. "continue [Marker] and finish" → + // "continue and finish" instead of "continueand finish"). + const stripPattern = new RegExp( + TOOL_RESULTS_RECEIVED_MARKER.replace( + /[.*+?^${}()|[\]\\]/g, + '\\$&', + ), + 'g', + ) const hasToolResultsMarker = message.message.content.some( content => content.type === 'text' && @@ -1494,7 +1512,7 @@ async function* queryLoop( // withhold / stall when there is no substantive text left; // a message containing real continuation text should stay // visible even without tool_use blocks. - let remaining = message.message.content + const remaining = message.message.content .map((content: unknown) => { if ( typeof content === 'object' && @@ -1505,10 +1523,12 @@ async function* queryLoop( ) { const textBlock = content as { text: string } const cleaned = textBlock.text.replace( - markerPattern, + stripPattern, '', ) - return cleaned ? { ...textBlock, text: cleaned } : null + return cleaned.trim().length > 0 + ? { ...textBlock, text: cleaned } + : null } return content }) @@ -1520,12 +1540,22 @@ async function* queryLoop( if (remainingText.trim().length === 0) { withheld = true markerOnlyStall = true + } + if (remaining.length === 0) { + // Drop the empty message entirely rather than mutating + // it in place — preserves the "original message untouched" + // invariant and prevents content:[] from reaching the wire. + // Use `as typeof message.message.content` to avoid BetaTextBlock + // citations requirement on the placeholder block. + message.message.content = [{ type: 'text' as const, text: '' }] as typeof message.message.content + } else { + message.message.content = remaining } - message.message.content = remaining } else if (msgToolUseBlocks.length > 0) { toolUseBlocks.push(...msgToolUseBlocks) needsFollowUp = true } + assistantMessages.push(message) if (!withheld) { yield yieldMessage } @@ -2404,6 +2434,17 @@ async function* queryLoop( } } + // Nudge cap hit and last message was marker-only: the model produced + // zero real output but the cap guard skipped the nudge block. Report + // a distinct reason so a stuck self-hosted endpoint isn't masked as + // a successful completion. + if (markerOnlyStall && state.continuationNudgeCount >= MAX_CONTINUATION_NUDGES) { + logForDebugging( + `Marker-only stall exhausted (${MAX_CONTINUATION_NUDGES} nudges) — model produced no real output`, + ) + return { reason: 'marker_stall_exhausted' } + } + return { reason: 'completed' } } diff --git a/src/query/goalContinuation.test.ts b/src/query/goalContinuation.test.ts index bf9001deb8..887e011628 100644 --- a/src/query/goalContinuation.test.ts +++ b/src/query/goalContinuation.test.ts @@ -242,4 +242,92 @@ describe('goal query continuation', () => { ), ).toBe(false) }) + + test('stop hook blocking error forces continuation nudge (stop_hook_active)', async () => { + const { query } = await import('../query.js') + let modelCalls = 0 + let stopHookCalls = 0 + const observedStopHookActive: boolean[] = [] + const modelRequestMessages: any[][] = [] + const appStateRef = { + current: { + ...getDefaultAppState(), + }, + } + + const terminal = await (async () => { + const generator = query({ + messages: [], + systemPrompt: asSystemPrompt([]), + userContext: {}, + systemContext: {}, + canUseTool: async () => ({ behavior: 'allow' }), + toolUseContext: makeToolUseContext(appStateRef), + querySource: 'sdk', + deps: { + uuid: () => `uuid-${modelCalls}`, + microcompact: async messages => ({ messages }), + autocompact: async () => ({ wasCompacted: false }), + goalEvaluationDeps: { + evaluateGoal: async () => ({ + complete: true, + confidence: 0.9, + decision: 'complete' as const, + reason: 'Done.', + nextInstruction: null, + }), + saveGoalState: async () => {}, + }, + stopHookExecutionDeps: { + executeStopHooks: async function* ( + _permissionMode: string | undefined, + _signal: AbortSignal | undefined, + _timeoutMs: number | undefined, + stopHookActive: boolean, + ) { + observedStopHookActive.push(stopHookActive) + stopHookCalls++ + // Produce a blocking error only on the first stop hook call. + // This sets stopHookActive=true for the next turn. + if (stopHookCalls === 1) { + yield { + blockingError: { blockingError: 'hook blocked' }, + } as any + return + } + // Subsequent calls produce no blocking errors. + }, + getStopHookMessage: () => 'stop hook blocked the turn', + isTeammate: () => false, + }, + callModel: async function* ({ messages }: any) { + modelCalls++ + modelRequestMessages.push(JSON.parse(JSON.stringify(messages))) + yield assistant( + `assistant-${modelCalls}`, + 'Tasks done.', + ) + }, + } as any, + }) + while (true) { + const next = await generator.next() + if (next.done) return next.value + } + })() + + expect(modelCalls).toBeGreaterThanOrEqual(2) + // First call: stop hook blocks → stopHookActive set to true + expect(observedStopHookActive[0]).toBe(false) + // Second call (after stop_hook_blocking transition): stopHookActive = true + expect(observedStopHookActive[1]).toBe(true) + // The model returned text without tool calls → stop_hook_active forces + // a continuation nudge. + expect(terminal.reason).toBe('completed') + // Verify the nudge user message was injected into the second model call. + const nudgeMessages = modelRequestMessages[1].filter( + (m: any) => m.type === 'user' && m.isMeta, + ) + expect(nudgeMessages.length).toBeGreaterThan(0) + }) }) diff --git a/src/query/toolResultsMarker.test.ts b/src/query/toolResultsMarker.test.ts index 3ebd86fb18..ff570d4a3c 100644 --- a/src/query/toolResultsMarker.test.ts +++ b/src/query/toolResultsMarker.test.ts @@ -115,7 +115,7 @@ test('marker-only response (no tool_use blocks) forces continuation nudge then c // marker so the nudge fires up to MAX_CONTINUATION_NUDGES (20) then // the cap terminates the loop. expect(modelCalls).toBe(21) // 1 initial + 20 nudges - expect(terminal.reason).toBe('completed') + expect(terminal.reason).toBe('marker_stall_exhausted') // Marker text should have been stripped from yielded messages. const markerTexts = yielded.filter( item => @@ -222,7 +222,7 @@ test('repeated marker-only responses hit nudge cap', async () => { // Marker-only text is stripped; markerOnlyStall forces a nudge. // Model keeps echoing the marker so it loops up to MAX_CONTINUATION_NUDGES. expect(modelCalls).toBe(21) // 1 initial + 20 nudges - expect(terminal.reason).toBe('completed') + expect(terminal.reason).toBe('marker_stall_exhausted') }) test('marker-only then real text completes after one nudge', async () => { @@ -311,3 +311,93 @@ test('marker text followed by real continuation text is NOT withheld', async () test('marker constant matches shim injection', async () => { expect(TOOL_RESULTS_RECEIVED_MARKER).toBe('[Tool results received]') }) + +test('mid-sentence marker is stripped without corrupting adjacent text (S1)', async () => { + let modelCalls = 0 + + const callModel: QueryDeps['callModel'] = async function* () { + modelCalls++ + if (modelCalls === 1) { + yield createAssistantMessage({ + id: `assistant-1`, + content: [ + { + type: 'text', + text: 'Please continue [Tool results received] and finish', + }, + ], + model: 'test-model', + stop_reason: 'end_turn', + role: 'assistant', + } as any) + } else { + yield createAssistantMessage({ + id: `assistant-2`, + content: [{ type: 'text', text: 'done' }], + model: 'test-model', + stop_reason: 'end_turn', + role: 'assistant', + } as any) + } + } + + const yielded: any[] = [] + const terminal = await (async () => { + const params = makeQueryParams(callModel as any) + params.deps!.callModel = callModel as any + const generator = query(params) + while (true) { + const next = await generator.next() + if (next.done) return next.value + yielded.push(next.value) + } + })() + + // Marker is embedded mid-sentence, not marker-only — should NOT be withheld. + // The stripped remaining text has no continuation signal and no tool_use, + // so the generator completes after a single call. + expect(terminal.reason).toBe('completed') + expect(modelCalls).toBe(1) + + // The stripped text should preserve adjacent words: "Please continue and finish" + // (double space is ok — the marker consumed its surrounding whitespace but + // the stripPattern only removes the literal marker itself). + const assistantMsgs = yielded.filter((item: any) => item.type === 'assistant') + const textParts = assistantMsgs.flatMap((msg: any) => + msg.message?.content + ?.filter((c: any) => c.type === 'text') + .map((c: any) => c.text) || [], + ) + // After strip: "Please continue and finish" — must NOT be "Please continueand finish" + const cleaned = textParts[0] + expect(cleaned).toMatch(/continue[\s]+and/) + expect(cleaned).not.toContain('continueand') +}) + +test('marker stall exhausted returns distinct reason (S3)', async () => { + let modelCalls = 0 + + const callModel: QueryDeps['callModel'] = async function* () { + modelCalls++ + yield createAssistantMessage({ + id: `assistant-${modelCalls}`, + content: [{ type: 'text', text: TOOL_RESULTS_RECEIVED_MARKER }], + model: 'test-model', + stop_reason: 'end_turn', + role: 'assistant', + } as any) + } + + const terminal = await (async () => { + const params = makeQueryParams(callModel as any) + params.deps!.callModel = callModel as any + const generator = query(params) + while (true) { + const next = await generator.next() + if (next.done) return next.value + } + })() + + expect(modelCalls).toBe(21) // 1 initial + 20 nudges + expect(terminal.reason).toBe('marker_stall_exhausted') +}) diff --git a/src/query/transitions.ts b/src/query/transitions.ts index f2ecf61598..2056752fa7 100644 --- a/src/query/transitions.ts +++ b/src/query/transitions.ts @@ -16,6 +16,7 @@ export type Terminal = maxSteps: number } | { reason: 'tool_failure_loop' } + | { reason: 'marker_stall_exhausted' } export type Continue = | { reason: 'collapse_drain_retry'; committed: number } From 91b2d5fb87fd54cacbfd9b8f9b497f6315c2b83a Mon Sep 17 00:00:00 2001 From: Andrey Bezborodov Date: Tue, 14 Jul 2026 23:34:21 +0300 Subject: [PATCH 05/10] fix(query): restore stream_event yield, addTool registration, clear markerOnlyStall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: yield non-assistant events (StreamEvent, SystemAPIErrorMessage) from callModel instead of silently dropping them — restores streaming progress visibility. - P2: re-add streamingToolExecutor.addTool() loop removed in 780f53f7 so tools with streamingToolExecution2 enabled are actually registered and executed. - P2: clear markerOnlyStall when a substantive assistant message arrives after a marker-only one, preventing unnecessary continuation nudges. --- src/query.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/query.ts b/src/query.ts index c7d505f4e3..f77fee1f38 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1555,10 +1555,29 @@ async function* queryLoop( toolUseBlocks.push(...msgToolUseBlocks) needsFollowUp = true } + // Clear markerOnlyStall when a substantive message arrives + // after a marker-only one — a prior empty block shouldn't + // cause an unnecessary continuation nudge. + if (msgToolUseBlocks.length > 0 || !hasToolResultsMarker) { + markerOnlyStall = false + } assistantMessages.push(message) if (!withheld) { yield yieldMessage } + + if ( + streamingToolExecutor && + !toolUseContext.abortController.signal.aborted + ) { + for (const toolBlock of msgToolUseBlocks) { + streamingToolExecutor.addTool(toolBlock, message) + } + } + } + + if (message.type !== 'assistant') { + yield message } if ( From 3f62a21d338a819996923ec8501a8b620a0308ae Mon Sep 17 00:00:00 2001 From: Andrey Bezborodov Date: Wed, 15 Jul 2026 11:19:23 +0300 Subject: [PATCH 06/10] fix(query): scope marker suppression to shim boundary, surface exhaust event, fix stop-hook stale flag - Add shimToolResultBoundary provenance check so marker suppression only applies when the preceding request contained a tool-result boundary, preventing false suppression of legitimate user output. - Yield visible system message on marker_stall_exhausted so REPL consumers see the failure instead of discarding it silently. - Clear markerOnlyStall flag when the marker-containing block has real remaining text after stripping. - Use current stopHookResult.stopHookActive instead of stale state flag in continuation nudge decision. Fixes: P1 marker suppression scope, P1 marker_stall_exhausted surface, P2 stall flag clearing, P2 stop-hook stale flag --- src/query.ts | 78 ++++++++++++++++++++++++++--- src/query/toolResultsMarker.test.ts | 21 +++++++- 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/query.ts b/src/query.ts index f77fee1f38..416be60f9d 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1001,6 +1001,14 @@ async function* queryLoop( // continuation-nudge block below to force a nudge when the stripped // text is empty (model effectively said nothing). let markerOnlyStall = false + // Provenance flag: true when the API request that produced the current + // assistant response contained a tool-result boundary at the end + // (last message in messagesForQuery is a tool result). This is the + // condition the OpenAI shim checks (prev.role === 'tool' && + // msg.role === 'user') before injecting TOOL_RESULTS_RECEIVED_MARKER. + // Scoping marker suppression to this boundary prevents suppressing + // legitimate user output that happens to contain the marker text. + let shimToolResultBoundary = false queryCheckpoint('query_setup_start') const useStreamingToolExecution = @@ -1260,6 +1268,34 @@ async function* queryLoop( attemptWithFallback = false try { let streamingFallbackOccured = false + // Set shim provenance for this loop iteration: the OpenAI shim + // injects TOOL_RESULTS_RECEIVED_MARKER as a standalone assistant + // message only when the last message in the request is a tool + // result (prev.role === 'tool' && msg.role === 'user'). Gate + // marker suppression on this boundary so the model can + // legitimately say "[Tool results received]" without being + // silently withheld. + // We scan backwards from the end for the shim boundary pattern: + // the last message must be a user message and some earlier message + // must contain a tool_result block (meaning the shim injected the + // marker for this turn). Continuation nudges keep appending + // user messages after the tool results, so we skip over trailing + // assistant messages and keep scanning past them until we find a + // user message or exhaust the array. + const lastMsg = messagesForQuery.at(-1) + shimToolResultBoundary = lastMsg?.type === 'user' + if (shimToolResultBoundary) { + for (let i = 0; i < messagesForQuery.length - 1; i++) { + const prev = messagesForQuery[i] + if (prev?.type === 'assistant') { + const content = prev.message.content as { type: string }[] + if (content.some(c => c.type === 'tool_result')) { + shimToolResultBoundary = true + break + } + } + } + } queryCheckpoint('query_api_streaming_start') for await (const message of deps.callModel({ messages: prependUserContext(messagesForQuery, userContext), @@ -1507,7 +1543,11 @@ async function* queryLoop( // UI and strip it from the state so the continuation-nudge // block below sees a clean text tail and the capped nudge // path handles the stall (max 20 retries). - if (hasToolResultsMarker && msgToolUseBlocks.length === 0) { + // Only suppress when shim provenance is established + // (shimToolResultBoundary) so the model can legitimately say + // "[Tool results received]" without being silenced. + let remainingText = '' + if (hasToolResultsMarker && msgToolUseBlocks.length === 0 && shimToolResultBoundary) { // Compute remaining text after removing the marker. Only // withhold / stall when there is no substantive text left; // a message containing real continuation text should stay @@ -1533,7 +1573,7 @@ async function* queryLoop( return content }) .filter(Boolean) as typeof message.message.content - const remainingText = remaining + remainingText = remaining .filter(b => typeof b === 'object' && b !== null && 'text' in b && (b as { text: string }).text.trim().length > 0) .map(b => (b as { text: string }).text) .join(' ') @@ -1557,8 +1597,16 @@ async function* queryLoop( } // Clear markerOnlyStall when a substantive message arrives // after a marker-only one — a prior empty block shouldn't - // cause an unnecessary continuation nudge. - if (msgToolUseBlocks.length > 0 || !hasToolResultsMarker) { + // cause an unnecessary continuation nudge. Also clear it when + // a marker-containing block has real text after stripping + // (the old condition only checked tool_use blocks or absence + // of the marker, missing the case where the message has both + // the marker AND substantive remaining text). + if ( + msgToolUseBlocks.length > 0 || + !hasToolResultsMarker || + remainingText.trim().length > 0 + ) { markerOnlyStall = false } assistantMessages.push(message) @@ -2312,6 +2360,13 @@ async function* queryLoop( continue } + // Capture the current stop-hook result for use in the continuation-nudge + // check below. The stale `stopHookActive` flag describes the PREVIOUS + // turn's result — after a successful stop-hook pass (no blocking errors), + // the hook already ran again and cleared itself, so using the stale flag + // forces an unnecessary extra model request. + let currentStopHookActive = stopHookResult.stopHookActive + if (feature('TOKEN_BUDGET')) { const decision = checkTokenBudget( budgetTracker!, @@ -2406,8 +2461,10 @@ async function* queryLoop( // incomplete (via goal evaluation or hook blocking errors). // Force a nudge even if the text itself has no continuation // signal — the model is being prompted to continue from the - // previous turn's tool results. - if (!shouldNudge && stopHookActive) { + // previous turn's tool results. Use the CURRENT stop-hook + // result (currentStopHookActive) rather than the stale state + // flag, which may have been cleared by this pass's handleStopHooks. + if (!shouldNudge && currentStopHookActive) { shouldNudge = true nudgeReason = 'stop_hook_active' } @@ -2456,12 +2513,17 @@ async function* queryLoop( // Nudge cap hit and last message was marker-only: the model produced // zero real output but the cap guard skipped the nudge block. Report // a distinct reason so a stuck self-hosted endpoint isn't masked as - // a successful completion. + // a successful completion. Yield a visible system message so REPL + // consumers see the failure instead of silently calling onTurnComplete. if (markerOnlyStall && state.continuationNudgeCount >= MAX_CONTINUATION_NUDGES) { logForDebugging( `Marker-only stall exhausted (${MAX_CONTINUATION_NUDGES} nudges) — model produced no real output`, ) - return { reason: 'marker_stall_exhausted' } + yield createSystemMessage( + `Marker-only stall exhausted after ${MAX_CONTINUATION_NUDGES} retries — the model kept echoing the internal tool-result marker without producing real output.`, + 'error', + ) + return { reason: 'marker_stall_exhausted' as const } } return { reason: 'completed' } diff --git a/src/query/toolResultsMarker.test.ts b/src/query/toolResultsMarker.test.ts index ff570d4a3c..51e81cb194 100644 --- a/src/query/toolResultsMarker.test.ts +++ b/src/query/toolResultsMarker.test.ts @@ -27,8 +27,27 @@ function makeQueryParams( callModel: QueryDeps['callModel'], overrides: Partial = {}, ): QueryParams { + // Include a synthetic assistant message with tool_result content BEFORE + // the user message to establish the "shim boundary". The shim injects + // TOOL_RESULTS_RECEIVED_MARKER when prev.role === 'tool' (i.e. when the + // message before the user message contains a tool_result). Without this, + // shimToolResultBoundary is false and marker suppression is skipped. + const shimBoundaryMessage = createAssistantMessage({ + id: 'assistant-shim-boundary', + content: [ + { type: 'text', text: 'placeholder' }, + { type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }, + ], + model: 'test-model', + stop_reason: 'end_turn', + role: 'assistant', + } as any) + return { - messages: [createUserMessage({ content: 'do something' })], + messages: [ + shimBoundaryMessage, + createUserMessage({ content: 'do something' }), + ], systemPrompt: asSystemPrompt([]), userContext: {}, systemContext: {}, From 4a1d552bb281aab114cd9581aa7e6821d5232542 Mon Sep 17 00:00:00 2001 From: Andrey Bezborodov Date: Wed, 15 Jul 2026 12:03:37 +0300 Subject: [PATCH 07/10] fix(query): scope marker suppression to shim boundary, fix stall clearing Rewrite shimToolResultBoundary logic: initialize false, scan messages backward, stop at most recent tool_use so earlier-turn results don't qualify. Add !shimToolResultBoundary to markerOnlyStall clearing so marker-only responses properly trigger continuation nudges. Update test shimBoundaryMessage to use user role. --- src/query.ts | 37 ++++++++++++++++++++++------- src/query/toolResultsMarker.test.ts | 26 ++++++++++---------- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/src/query.ts b/src/query.ts index 416be60f9d..9d9d1b2d2a 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1282,14 +1282,34 @@ async function* queryLoop( // user messages after the tool results, so we skip over trailing // assistant messages and keep scanning past them until we find a // user message or exhaust the array. + // Initialize false, scan messages backward from the end. + // Stop at the most recent tool_use so results from earlier turns + // cannot qualify. Set boundary true only when a matching + // tool_result is found within that scope. + shimToolResultBoundary = false const lastMsg = messagesForQuery.at(-1) - shimToolResultBoundary = lastMsg?.type === 'user' - if (shimToolResultBoundary) { - for (let i = 0; i < messagesForQuery.length - 1; i++) { - const prev = messagesForQuery[i] - if (prev?.type === 'assistant') { - const content = prev.message.content as { type: string }[] - if (content.some(c => c.type === 'tool_result')) { + if (lastMsg?.type === 'user') { + // Find the index of the most recent tool_use message (scanning backward) + let mostRecentToolUseIndex = -1 + for (let i = messagesForQuery.length - 2; i >= 0; i--) { + const msg = messagesForQuery[i] + if (msg?.type === 'assistant') { + const content = msg.message.content as { type: string }[] | undefined + if (content?.some(c => c.type === 'tool_use')) { + mostRecentToolUseIndex = i + break + } + } + } + // Scan backward from end, looking for user message with tool_result + for (let i = messagesForQuery.length - 2; i >= 0; i--) { + const msg = messagesForQuery[i] + if (i < mostRecentToolUseIndex && mostRecentToolUseIndex !== -1) { + break // stopped at most recent tool_use + } + if (msg?.type === 'user') { + const content = msg.message.content as { type: string }[] | string | undefined + if (Array.isArray(content) && content.some(c => c.type === 'tool_result')) { shimToolResultBoundary = true break } @@ -1605,7 +1625,8 @@ async function* queryLoop( if ( msgToolUseBlocks.length > 0 || !hasToolResultsMarker || - remainingText.trim().length > 0 + remainingText.trim().length > 0 || + !shimToolResultBoundary ) { markerOnlyStall = false } diff --git a/src/query/toolResultsMarker.test.ts b/src/query/toolResultsMarker.test.ts index 51e81cb194..9f5ece3d92 100644 --- a/src/query/toolResultsMarker.test.ts +++ b/src/query/toolResultsMarker.test.ts @@ -27,21 +27,23 @@ function makeQueryParams( callModel: QueryDeps['callModel'], overrides: Partial = {}, ): QueryParams { - // Include a synthetic assistant message with tool_result content BEFORE - // the user message to establish the "shim boundary". The shim injects + // Include a synthetic user message with tool_result content BEFORE + // the next user message to establish the "shim boundary". The shim injects // TOOL_RESULTS_RECEIVED_MARKER when prev.role === 'tool' (i.e. when the // message before the user message contains a tool_result). Without this, // shimToolResultBoundary is false and marker suppression is skipped. - const shimBoundaryMessage = createAssistantMessage({ - id: 'assistant-shim-boundary', - content: [ - { type: 'text', text: 'placeholder' }, - { type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }, - ], - model: 'test-model', - stop_reason: 'end_turn', - role: 'assistant', - } as any) + const shimBoundaryMessage = { + type: 'user' as const, + uuid: 'assistant-shim-boundary', + timestamp: new Date().toISOString(), + message: { + role: 'user' as const, + content: [ + { type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }, + ] as { type: string; tool_use_id?: string; content: string }[], + }, + toolUseResult: { output: 'ok' }, + } return { messages: [ From e5d5d25ecdc00e2e41ba88f135b04ebd9ca3c851 Mon Sep 17 00:00:00 2001 From: Andrey Bezborodov Date: Wed, 15 Jul 2026 12:19:33 +0300 Subject: [PATCH 08/10] fix(query): include final message in tool_result boundary scan Both backward scans in shimToolResultBoundary logic started at length-2, skipping the last message. Fix the tool_result scan to start at length-1 so the final tool_result is examined. Update regression test to leave tool_result as the final message. --- src/query.ts | 2 +- src/query/toolResultsMarker.test.ts | 31 +++++++++++------------------ 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/query.ts b/src/query.ts index 9d9d1b2d2a..eb721f3d4a 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1302,7 +1302,7 @@ async function* queryLoop( } } // Scan backward from end, looking for user message with tool_result - for (let i = messagesForQuery.length - 2; i >= 0; i--) { + for (let i = messagesForQuery.length - 1; i >= 0; i--) { const msg = messagesForQuery[i] if (i < mostRecentToolUseIndex && mostRecentToolUseIndex !== -1) { break // stopped at most recent tool_use diff --git a/src/query/toolResultsMarker.test.ts b/src/query/toolResultsMarker.test.ts index 9f5ece3d92..41533044d4 100644 --- a/src/query/toolResultsMarker.test.ts +++ b/src/query/toolResultsMarker.test.ts @@ -27,28 +27,21 @@ function makeQueryParams( callModel: QueryDeps['callModel'], overrides: Partial = {}, ): QueryParams { - // Include a synthetic user message with tool_result content BEFORE - // the next user message to establish the "shim boundary". The shim injects - // TOOL_RESULTS_RECEIVED_MARKER when prev.role === 'tool' (i.e. when the - // message before the user message contains a tool_result). Without this, - // shimToolResultBoundary is false and marker suppression is skipped. - const shimBoundaryMessage = { - type: 'user' as const, - uuid: 'assistant-shim-boundary', - timestamp: new Date().toISOString(), - message: { - role: 'user' as const, - content: [ - { type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }, - ] as { type: string; tool_use_id?: string; content: string }[], - }, - toolUseResult: { output: 'ok' }, - } - return { messages: [ - shimBoundaryMessage, createUserMessage({ content: 'do something' }), + { + type: 'user' as const, + uuid: 'assistant-shim-boundary', + timestamp: new Date().toISOString(), + message: { + role: 'user' as const, + content: [ + { type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }, + ] as { type: string; tool_use_id?: string; content: string }[], + }, + toolUseResult: { output: 'ok' }, + }, ], systemPrompt: asSystemPrompt([]), userContext: {}, From bc68bab187139555fff94008b7daf33082e6e5d1 Mon Sep 17 00:00:00 2001 From: Andrey Bezborodov Date: Wed, 15 Jul 2026 12:43:35 +0300 Subject: [PATCH 09/10] fix(query): isolate yieldMessage clone from message mutation in marker strip The empty-content normalization at the shim boundary mutated message.message.content in place, which also affected yieldMessage when no prior backfill clone existed. Now yieldMessage gets its own shallow clone so the original message stays untouched for transcript and prompt-cache. --- src/query.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/query.ts b/src/query.ts index eb721f3d4a..5074887761 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1602,14 +1602,19 @@ async function* queryLoop( markerOnlyStall = true } if (remaining.length === 0) { - // Drop the empty message entirely rather than mutating - // it in place — preserves the "original message untouched" - // invariant and prevents content:[] from reaching the wire. - // Use `as typeof message.message.content` to avoid BetaTextBlock - // citations requirement on the placeholder block. - message.message.content = [{ type: 'text' as const, text: '' }] as typeof message.message.content + // Create a separate clone so yieldMessage sees the + // placeholder while message.message stays untouched for + // transcript / prompt-cache. Use `as typeof ...` to avoid + // BetaTextBlock citations requirement on the placeholder block. + yieldMessage = { + ...yieldMessage, + message: { ...yieldMessage.message, content: [{ type: 'text' as const, text: '' }] as typeof message.message.content }, + } } else { - message.message.content = remaining + yieldMessage = { + ...yieldMessage, + message: { ...yieldMessage.message, content: remaining }, + } } } else if (msgToolUseBlocks.length > 0) { toolUseBlocks.push(...msgToolUseBlocks) From 082a07b4800e4aec327bbf9b03c96683508ab80e Mon Sep 17 00:00:00 2001 From: Andrey Bezborodov Date: Wed, 15 Jul 2026 16:52:08 +0300 Subject: [PATCH 10/10] fix(query): strip marker from message.message to prevent shim re-injection echo loop When a self-hosted provider echoes the shim-injected tool-result marker, the retained marker text in message.message gets appended to messagesForQuery on continuation nudge. The shim re-injects the marker on each iteration, causing an infinite echo loop that exhausts after 20 nudges. Strip the marker from message.message immediately after detection so subsequent iterations see a clean assistant message and the shim doesn't re-inject it. --- src/query.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/query.ts b/src/query.ts index 5074887761..8cd5aec39b 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1602,19 +1602,30 @@ async function* queryLoop( markerOnlyStall = true } if (remaining.length === 0) { - // Create a separate clone so yieldMessage sees the - // placeholder while message.message stays untouched for - // transcript / prompt-cache. Use `as typeof ...` to avoid - // BetaTextBlock citations requirement on the placeholder block. + // Strip the marker from message.message so subsequent + // query iterations (after a continuation nudge) don't + // re-inject the shim marker — the model already echoed + // it, and re-injecting causes an infinite echo loop. + // The yielded placeholder is empty to keep transcript clean. + message.message = { + ...message.message, + content: [{ type: 'text' as const, text: '' }] as typeof message.message.content, + } as typeof message.message yieldMessage = { ...yieldMessage, - message: { ...yieldMessage.message, content: [{ type: 'text' as const, text: '' }] as typeof message.message.content }, + message: message.message, } } else { yieldMessage = { ...yieldMessage, message: { ...yieldMessage.message, content: remaining }, } + // Also strip the marker from message.message to prevent + // the shim from re-injecting it on the next iteration. + message.message = { + ...message.message, + content: remaining, + } as typeof message.message } } else if (msgToolUseBlocks.length > 0) { toolUseBlocks.push(...msgToolUseBlocks)