diff --git a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts index b829b09021..e726f6ac7f 100644 --- a/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts +++ b/apps/desktop/src/main/__tests__/follow-up-submit-routing.test.ts @@ -46,12 +46,12 @@ describe('follow-up submit routing', () => { ); }); - it('routes burst input through the selected follow-up lane', () => { + it('routes an explicit steer while leaving plain Enter to interrupt-and-send', () => { assert.equal( resolveFollowUpModeAtSubmit({ hasActiveTurn: true, }), - 'queue', + undefined, ); assert.equal( resolveFollowUpModeAtSubmit({ @@ -60,6 +60,13 @@ describe('follow-up submit routing', () => { }), 'steer', ); + assert.equal( + resolveFollowUpModeAtSubmit({ + requestedMode: 'queue', + hasActiveTurn: true, + }), + 'queue', + ); }); it('starts a normal turn only when no active-turn witness exists', () => { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index dab53a0d6f..18e48ceb5b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1865,10 +1865,11 @@ function AppShellContent({ const runningTurnIds = sessionId ? sessionsRef.current.find((session) => session.id === sessionId)?.runningTurnIds : undefined; + const hasActiveTurn = hasActiveTurnAtSubmit({ liveTurn, runningTurnIds }); const followUpAtSubmit = !slashCommand ? resolveFollowUpModeAtSubmit({ requestedMode: metadata?.followUpMode, - hasActiveTurn: hasActiveTurnAtSubmit({ liveTurn, runningTurnIds }), + hasActiveTurn, }) : undefined; if (sessionId && followUpAtSubmit) { @@ -1879,6 +1880,29 @@ function AppShellContent({ if (queued) delete retractedWorkspaceReferencesRef.current[sessionId]; return queued; } + // Plain Enter during a live turn: interrupt first, then fall through to a + // new root send so the typed message stops the runaway loop (#4083). + // Interrupt retracts any prior queue entries, so this must precede send. + if (sessionId && hasActiveTurn && !slashCommand) { + try { + const stopped = await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); + if (stopped?.kind === 'interrupted') { + for (const messageId of stopped.retractedMessageIds) { + removeTransientMessage(sessionId, messageId); + } + } + } catch (error) { + if (activeIdRef.current === sessionId) { + const copy = getDesktopConversationCopy(uiLocale).actions; + showSessionError( + sessionId, + copy.operationFailedTitle, + localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale), + ); + } + return false; + } + } if ( revisionSend && revision && diff --git a/apps/desktop/src/renderer/follow-up-submit-routing.ts b/apps/desktop/src/renderer/follow-up-submit-routing.ts index 768477dba3..be552b3c65 100644 --- a/apps/desktop/src/renderer/follow-up-submit-routing.ts +++ b/apps/desktop/src/renderer/follow-up-submit-routing.ts @@ -34,12 +34,14 @@ export function hasActiveTurnAtSubmit(input: { export function resolveFollowUpModeAtSubmit(input: { requestedMode?: FollowUpMode; - hasActiveTurn: boolean; + /** + * Retained so call sites keep compiling. Mid-turn plain Enter no longer + * queues; the send path interrupts and opens a new root (#4083). + */ + hasActiveTurn?: boolean; }): FollowUpMode | undefined { if (input.requestedMode) return input.requestedMode; - // Mid-turn submits always queue; Shift+Enter carries the one-shot steer as - // the requested mode. - return input.hasActiveTurn ? 'queue' : undefined; + return undefined; } export function mergeWorkspaceReferences( diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 621487e3af..f109800878 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -6064,6 +6064,161 @@ describe('AiSdkBackend model history', () => { assert.equal(usage?.type === 'token_usage' ? usage.total : undefined, 2); }); + test('stops an unbounded loop after consecutive identical empty tool steps', async () => { + // Desktop often omits maxSteps. A model that repeats the same tool call with + // no visible text would otherwise flood empty assistant rows forever (#4083). + const loop = countingToolLoopModel(undefined, true); + const durable = durableTurnHarness('turn-empty-loop', 'keep going'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => loop.model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + assert.equal(loop.callCount(), 3); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit'); + assert.equal( + events.filter((event) => event.type === 'tool_start').length, + 3, + ); + }); + + test('stops an unbounded loop when Responses reasoning-end is only an empty carrier', async () => { + // OpenAI Responses emits `{ kind: 'thinking', text: '' }` at reasoning-end + // whenever provider metadata is present. That carrier must not count as + // visible thinking, or identical textless tool steps never reach the cap. + const reasoningMetadata = { + openai: { + itemId: 'rs_empty', + reasoningEncryptedContent: 'encrypted-carrier', + }, + }; + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'r1', providerMetadata: reasoningMetadata }, + { type: 'reasoning-end', id: 'r1', providerMetadata: reasoningMetadata }, + { + type: 'tool-call', + toolCallId: `tool-${calls}`, + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-empty-responses-loop', 'keep going'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + assert.equal(calls, 3); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit'); + assert.equal(events.filter((event) => event.type === 'tool_start').length, 3); + }); + + test('stops an unbounded loop when only a thinking signature accompanies identical tool calls', async () => { + // Anthropic can emit omitted/redacted reasoning as a standalone signature + // with no text. The signature must persist for replay, but must not count + // as visible thinking or the empty-step cap never fires (#4083). + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'r1' }, + { + type: 'reasoning-delta', + id: 'r1', + delta: '', + providerMetadata: { anthropic: { signature: `sig-${calls}` } }, + }, + { type: 'reasoning-end', id: 'r1' }, + { + type: 'tool-call', + toolCallId: `tool-${calls}`, + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-empty-signature-loop', 'keep going'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + assert.equal(calls, 3); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit'); + assert.equal(events.filter((event) => event.type === 'tool_start').length, 3); + assert.ok( + events.some( + (event) => + event.type === 'thinking_complete' && + event.signature !== undefined && + event.text === '', + ), + 'signature-only reasoning must still persist', + ); + }); + test('aborting during post-stream persistence wins over step-limit completion', async () => { const loop = countingToolLoopModel(); const gate = makeGate(); @@ -14960,7 +15115,10 @@ function planExecution(status: 'completed' | 'cancelled') { }; } -function countingToolLoopModel(toolCallsBeforeStop?: number): { +function countingToolLoopModel( + toolCallsBeforeStop?: number, + repeatToolInput = false, +): { model: MockLanguageModelV4; callCount: () => number; } { @@ -14990,7 +15148,7 @@ function countingToolLoopModel(toolCallsBeforeStop?: number): { type: 'tool-call', toolCallId: `tool-${calls}`, toolName: 'Read', - input: JSON.stringify({ path: `notes-${calls}.md` }), + input: JSON.stringify({ path: repeatToolInput ? 'notes.md' : `notes-${calls}.md` }), }, { type: 'finish', diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index feac5acf09..e9075cb3bf 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -912,6 +912,14 @@ const MAX_WAITING_CODE_MODE_CELLS = 1; const MAX_PROVIDER_ATTEMPTS_PER_STEP = 10; const MAX_IDLE_WATCHDOG_RETRIES_PER_STEP = 1; const MAX_INCOMPLETE_STREAM_RETRIES_PER_STEP = 1; +/** + * Desktop interactive turns often omit `maxSteps`, so a model that keeps + * emitting the same tool-call with no visible assistant text can loop forever + * and flood the transcript with empty AI replies (#4083). Only identical, + * textless steps are counted: ordinary multi-step tool workflows and an + * explicit `maxSteps` remain authoritative. + */ +const MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS = 3; const PROVIDER_RETRY_BASE_DELAY_MS = 1_000; const PROVIDER_RETRY_MAX_DELAY_MS = 32_000; const PROVIDER_RETRY_JITTER_FACTOR = 0.25; @@ -2175,7 +2183,11 @@ export class AiSdkBackend implements AgentBackend { let providerOutcome: ModelStepOutcome; let finishReason: ModelFinishReason = 'stop'; let terminalProviderError: unknown; + let consecutiveIdenticalEmptySteps = 0; + let previousEmptyStepSignature: string | undefined; agentLoop: for (;;) { + let stepSawVisibleText = false; + let stepSawThinking = false; await this.drainSteeringInto(scope, input, queue); if (this.input.loadTurnRuntimeEvents) { requestMessages = await loadDurableTurnProjection(); @@ -2362,7 +2374,10 @@ export class AiSdkBackend implements AgentBackend { stepTextPartStartOffset = stepText.length; } else if (event.kind === 'text') { stepText += event.text; - if (event.text.length > 0) attemptSawText = true; + if (event.text.length > 0) { + attemptSawText = true; + stepSawVisibleText = true; + } queue.push({ type: 'text_delta', id: this.newId(), @@ -2381,6 +2396,12 @@ export class AiSdkBackend implements AgentBackend { stepTextPartStartOffset, ); } else if (event.kind === 'thinking') { + // OpenAI Responses emits an empty thinking carrier at + // `reasoning-end` whenever provider metadata is present. That + // is not user-visible progress, so it must not reset the + // empty-step loop cap (#4083). Persistence still uses + // `sawStepThinking` so the encrypted carrier round-trips. + if (event.text.length > 0) stepSawThinking = true; sawStepThinking = true; stepThinking += event.text; if (event.text.length > 0) attemptSawThinking = true; @@ -2426,6 +2447,9 @@ export class AiSdkBackend implements AgentBackend { text: event.text, } satisfies ThinkingDeltaEvent); } else if (event.kind === 'thinking-signature') { + // A standalone signature is omitted/redacted reasoning, not + // user-visible progress. Persist it for replay, but do not + // reset the empty-step loop cap (#4083). attemptSawContinuationMetadata = true; stepSignature = event.signature; } else if (event.kind === 'provider-tool-input') { @@ -2799,6 +2823,33 @@ export class AiSdkBackend implements AgentBackend { ...(providerStepUsage ? { usage: providerStepUsage } : {}), }); lastCompletedStepHadToolResult = returnedToolCalls.length > 0; + const emptyStepSignature = + !stepSawVisibleText && !stepSawThinking && returnedToolCalls.length > 0 + ? JSON.stringify( + returnedToolCalls.map(({ toolName, input }) => ({ toolName, input })), + ) + : undefined; + if ( + maxSteps === undefined && + emptyStepSignature !== undefined && + !scope.loopStopRequested + ) { + consecutiveIdenticalEmptySteps = + emptyStepSignature === previousEmptyStepSignature + ? consecutiveIdenticalEmptySteps + 1 + : 1; + previousEmptyStepSignature = emptyStepSignature; + if (consecutiveIdenticalEmptySteps >= MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS) { + // The model is repeating the same tool-only step with no visible + // progress. Stop as a failed tool-step cap rather than reporting a + // successful end_turn with no answer (#4083). + scope.loopStopReason = 'step_limit'; + scope.loopStopRequested = true; + } + } else { + consecutiveIdenticalEmptySteps = 0; + previousEmptyStepSignature = undefined; + } const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps; if ( sandboxBoundaryFinalizationStep || @@ -2842,6 +2893,10 @@ export class AiSdkBackend implements AgentBackend { !scope.loopStopRequested && !scope.aborted ) { + // A redirected prompt deserves a fresh empty-step streak; otherwise + // a prior empty run would stop the turn before the steer can land. + consecutiveIdenticalEmptySteps = 0; + previousEmptyStepSignature = undefined; currentStepMessageId = this.newId(); continue agentLoop; } diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 8340af48c9..424726e7de 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -1193,8 +1193,9 @@ export const Composer = forwardRef< function submit(event: FormEvent) { event.preventDefault(); - // Mid-turn the host queues the draft as a follow-up by default; only - // Shift+Enter (see onInputKeyDown) steers it into the active Turn. + // Mid-turn the host used to queue the draft as a follow-up by default; plain + // Enter now interrupts and starts a new root (#4083). Shift+Enter (see + // onInputKeyDown) still steers into the active Turn. void sendCurrent(); } @@ -1253,7 +1254,8 @@ export const Composer = forwardRef< } if (event.key !== 'Enter') return; // Alt+Enter always inserts a line break. During a running turn, Shift+Enter - // steers this one draft into the active Turn; plain Enter queues it. + // steers this one draft into the active Turn; plain Enter interrupts and + // starts a new root (#4083). if (event.altKey || (event.shiftKey && !props.streaming)) { event.preventDefault(); document.execCommand('insertLineBreak'); @@ -1338,9 +1340,9 @@ export const Composer = forwardRef< // One slot, one button, two states — Astryx's send/stop toggle. Mid-turn an // empty draft has nothing to submit, so the slot is Stop; the moment there is // a draft, handing it over is the only meaningful action there and the button - // returns to Send (the host queues it as a follow-up). Stop is not lost in - // that window: Esc interrupts from the input, which is where the hands already - // are. + // returns to Send (the host interrupts then starts a new root, #4083). Stop is + // not lost in that window: Esc interrupts from the input, which is where the + // hands already are. const stopShown = props.streaming === true && !text.trim(); // The pending plate renders the follow-up queue only: steering entries are // already handed to the active Turn and leave the plate at that moment.