diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 4570aa0895..4727b590c3 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -665,7 +665,7 @@ "./locales/shell-copy.js": 1 }, "importSpecifiers": 2, - "nonTriviaTokens": 302 + "nonTriviaTokens": 301 }, "src/renderer/app-shell-turn-actions.ts": { "importDeclarations": 4, @@ -895,8 +895,8 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 147, - "nonTriviaTokens": 15588 + "importSpecifiers": 146, + "nonTriviaTokens": 15587 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts index a16796aa15..9e8786c963 100644 --- a/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts @@ -44,8 +44,7 @@ test('removes exactly the transient messages the Host retracts while stopping', toastApi: { error() {} }, }); - await stop(); - + assert.equal(await stop(), true); assert.deepEqual(removed, [ { sessionId: 'session-1', messageId: 'message-1' }, { sessionId: 'session-1', messageId: 'message-2' }, @@ -54,3 +53,36 @@ test('removes exactly the transient messages the Host retracts while stopping', target.window = previousWindow; } }); + +test('returns undefined when stop fails so plain-Enter send can abort', async () => { + const target = globalThis as unknown as { window?: unknown }; + const previousWindow = target.window; + const errors: string[] = []; + target.window = { + maka: { + sessions: { + stop: async () => { + throw new Error('stop failed'); + }, + }, + }, + }; + try { + const stop = createAppShellStopAction({ + uiLocale: 'en', + activeIdRef: { current: 'session-1' }, + stopPending: { claim: () => true, release: () => undefined }, + removeTransientMessage: () => undefined, + toastApi: { + error(title) { + errors.push(title); + }, + }, + }); + + assert.equal(await stop(), undefined); + assert.equal(errors.length, 1); + } finally { + target.window = previousWindow; + } +}); 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 596a3dd656..808339113e 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 @@ -22,7 +22,6 @@ import { describe, it } from 'node:test'; import { hasActiveTurnAtSubmit, mergeWorkspaceReferences, - resolveFollowUpModeAtSubmit, } from '../../renderer/follow-up-submit-routing.js'; describe('follow-up submit routing', () => { @@ -46,54 +45,6 @@ describe('follow-up submit routing', () => { ); }); - it('routes burst input through the selected follow-up lane', () => { - assert.equal( - resolveFollowUpModeAtSubmit({ - hasActiveTurn: true, - slashCommand: null, - }), - 'queue', - ); - assert.equal( - resolveFollowUpModeAtSubmit({ - requestedMode: 'steer', - hasActiveTurn: true, - slashCommand: null, - }), - 'steer', - ); - }); - - it('starts a normal turn only when no active-turn witness exists', () => { - assert.equal( - resolveFollowUpModeAtSubmit({ - hasActiveTurn: false, - slashCommand: null, - }), - undefined, - ); - }); - - it('dispatches a slash command mid-turn instead of steering it into the Turn', () => { - assert.equal( - resolveFollowUpModeAtSubmit({ - hasActiveTurn: true, - slashCommand: { kind: 'side' }, - }), - undefined, - ); - // An explicit steer request loses to the command too: Shift+Enter on - // `/side` still opens the side chat. - assert.equal( - resolveFollowUpModeAtSubmit({ - requestedMode: 'steer', - hasActiveTurn: true, - slashCommand: { kind: 'side' }, - }), - undefined, - ); - }); - it('restores workspace references after queued text returns to the draft', () => { assert.deepEqual( mergeWorkspaceReferences( diff --git a/apps/desktop/src/renderer/app-shell-stop-action.ts b/apps/desktop/src/renderer/app-shell-stop-action.ts index 36525e8311..b2099a0c4d 100644 --- a/apps/desktop/src/renderer/app-shell-stop-action.ts +++ b/apps/desktop/src/renderer/app-shell-stop-action.ts @@ -39,7 +39,7 @@ export function createAppShellStopAction(deps: { stopPending: SessionPendingClaim; removeTransientMessage: (sessionId: string, messageId: string) => void; toastApi: ToastApi; -}): () => Promise { +}): () => Promise { const { uiLocale, activeIdRef, @@ -58,13 +58,8 @@ export function createAppShellStopAction(deps: { removeTransientMessage(sessionId, messageId); } } + return true; } catch (error) { - // The Composer wires this through both the Stop button onClick - // and the Escape key. Both invoke `onStop` without awaiting, so - // a rejected IPC would otherwise surface as an - // UnhandledPromiseRejection and the user would see nothing. - // Surface it as a toast so the user knows the model wasn't - // actually interrupted and can retry. if (activeIdRef.current === sessionId) { const copy = getDesktopConversationCopy(uiLocale).actions; toastApi.error( diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index f6473dd7e7..fcb5042bae 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -112,7 +112,6 @@ import { hasActiveTurnAtSubmit, mergeWorkspaceReferences, rebaseWorkspaceFileReferences, - resolveFollowUpModeAtSubmit, } from './follow-up-submit-routing'; import { PlanExecutionPanel, @@ -396,14 +395,14 @@ function AppShellContent({ reportError: reportTaskEntryError, manageProjects: openProjectSettings, }); - // Named on its own because the rail depends on it: `taskEntry.commands` is a - // fresh object every render, so depending on the bag rather than the command - // would rebuild the rail's Project rows on every AppShell commit (#4109). + /* Named on its own because the rail depends on it: `taskEntry.commands` is a + * fresh object every render, so depending on the bag rather than the command + * would rebuild the rail's Project rows on every AppShell commit (#4109). */ const { selectLocalProject } = taskEntry.commands; const currentNewTaskDraftKey = taskEntry.selectors.draftKey; - // Staged files and quotes do NOT take the target-scoped key: they belong to - // the composer the user is looking at, and an in-flight send needs an owner - // that cannot move under it. See NEW_TASK_PENDING_KEY. + /* Staged files and quotes do NOT take the target-scoped key: they belong to + * the composer the user is looking at, and an in-flight send needs an owner + * that cannot move under it. See NEW_TASK_PENDING_KEY. */ const attachmentDraftKey = activeId ?? NEW_TASK_PENDING_KEY; const directoryHostId = activeId ? (activeCatalogSession?.profileKind === 'local' @@ -441,19 +440,19 @@ function AppShellContent({ clearQuotes, restoreQuotes, } = useAppShellComposerQuotes({ draftKey: attachmentDraftKey }); - // Held for the whole of sendOwningItsTarget; see ChatComposerRegion. + /* Held for the whole of sendOwningItsTarget; see ChatComposerRegion. */ const [newTaskSendPending, setNewTaskSendPending] = useState(false); - // What a new chat will start with, held the way the Session holds it: a - // Plan toggle and one orchestration value, not one fused choice. + /* What a new chat will start with, held the way the Session holds it: a + * Plan toggle and one orchestration value, not one fused choice. */ const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); - // The state above is what the transcript renders; this is what the guard - // reads. A scroller can ask twice in one task — two scroll events before - // React has re-rendered anything — and a state read is still the old value - // for both of them. + /* The state above is what the transcript renders; this is what the guard + * reads. A scroller can ask twice in one task — two scroll events before + * React has re-rendered anything — and a state read is still the old value + * for both of them. */ const historyLoadPendingRef = useRef(false); const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ sessionId: string; @@ -540,8 +539,8 @@ function AppShellContent({ unsubscribe(); }; }, [setNavSelection]); - // #1985: the shell's complete read of session UI state. See the hook for why - // the two token-rate maps are absent. + /* #1985: the shell's complete read of session UI state. See the hook for why + * the two token-rate maps are absent. */ const { messageLoadErrorBySession, messageRetryPendingBySession, @@ -552,8 +551,8 @@ function AppShellContent({ streamingSessionIds, activeLiveTurnSnapshot, } = useAppShellSessionUiReads(sessionUiController, activeId); - // The chat surface follows the active Session's Host. Settings and global - // commands remain owned by the default Host. + /* The chat surface follows the active Session's Host. Settings and global + * commands remain owned by the default Host. */ const { memoryActive, refreshMemoryActive } = useShellMemoryPill({ toastApi, uiLocale, @@ -716,10 +715,10 @@ function AppShellContent({ }, []); const updateReminder = updateReminderFromStatus(appUpdateStatus); - // Dispatches on the task, not on the raw status: the footer is this - // callback's only caller and it only renders for the two states above, so - // reading the status again here would be the same "who needs the user" list - // maintained twice. + /* Dispatches on the task, not on the raw status: the footer is this + * callback's only caller and it only renders for the two states above, so + * reading the status again here would be the same "who needs the user" list + * maintained twice. */ const openUpdateDownload = useCallback(() => { if (updateReminder?.state === 'downloaded') { if (updateInstallInFlightRef.current) return; @@ -770,9 +769,9 @@ function AppShellContent({ ); }); }, [updateReminder, shellCopy, toastApi, uiLocale]); - // Persisted composer defaults seed the empty-state model, project path, and - // recent workspace history so the home view is populated before the async - // `app:info` round-trip completes on mount. + /* Persisted composer defaults seed the empty-state model, project path, and + * recent workspace history so the home view is populated before the async + * `app:info` round-trip completes on mount. */ const persistedComposerDefaults = loadComposerDefaults(); const [helpOpen, closeHelp, openHelp] = useKeyboardHelp(); const [paletteOpen, openPalette, closePalette] = useCommandPalette(); @@ -1822,6 +1821,14 @@ function AppShellContent({ }); } + const stop = createAppShellStopAction({ + uiLocale, + activeIdRef, + stopPending: sessionUiController.stopPending, + removeTransientMessage, + toastApi, + }); + /** * The send the composer calls, wrapped so the new-task target cannot move * out from under it (#3408). `sendCurrent` captures the draft key it @@ -1912,11 +1919,8 @@ function AppShellContent({ const runningTurnIds = sessionId ? sessionsRef.current.find((session) => session.id === sessionId)?.runningTurnIds : undefined; - const followUpAtSubmit = resolveFollowUpModeAtSubmit({ - requestedMode: metadata?.followUpMode, - hasActiveTurn: hasActiveTurnAtSubmit({ liveTurn, runningTurnIds }), - slashCommand, - }); + const hasActiveTurn = hasActiveTurnAtSubmit({ liveTurn, runningTurnIds }); + const followUpAtSubmit = !slashCommand ? metadata?.followUpMode : undefined; if (sessionId && followUpAtSubmit) { const queued = await enqueueFollowUp(sessionId, text, followUpAtSubmit, { ...metadata, @@ -1925,6 +1929,7 @@ function AppShellContent({ if (queued) delete retractedWorkspaceReferencesRef.current[sessionId]; return queued; } + if (sessionId && hasActiveTurn && !slashCommand && !(await stop())) return false; if ( revisionSend && revision && @@ -2176,14 +2181,6 @@ function AppShellContent({ ); } - const stop = createAppShellStopAction({ - uiLocale, - activeIdRef, - stopPending: sessionUiController.stopPending, - removeTransientMessage, - toastApi, - }); - const [sessionDisplayBatch] = useState(createAppShellSessionDisplayBatch); const { handleEvent, diff --git a/apps/desktop/src/renderer/follow-up-submit-routing.ts b/apps/desktop/src/renderer/follow-up-submit-routing.ts index 3c2959f83a..1bfd654e50 100644 --- a/apps/desktop/src/renderer/follow-up-submit-routing.ts +++ b/apps/desktop/src/renderer/follow-up-submit-routing.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { FollowUpMode, InlineReference } from '@maka/core/events'; +import type { InlineReference } from '@maka/core/events'; export interface WorkspaceFileReferencePosition { value: string; @@ -32,21 +32,6 @@ export function hasActiveTurnAtSubmit(input: { return input.runningTurnIds?.some((turnId) => turnId !== input.liveTurn?.turnId) === true; } -export function resolveFollowUpModeAtSubmit(input: { - requestedMode?: FollowUpMode; - hasActiveTurn: boolean; - /** The parsed command, if the text was one. Only its presence matters here. */ - slashCommand: object | null; -}): FollowUpMode | undefined { - // A slash command tells the app to do something; it is not text for the - // Turn that happens to be running. Dispatch it instead of queueing it. - if (input.slashCommand) return 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; -} - export function mergeWorkspaceReferences( text: string, live: readonly WorkspaceFileReferencePosition[] | undefined, diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 055875101e..ba8abd492b 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -5565,6 +5565,208 @@ 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 empty tool steps alternate between signatures', async () => { + // A consecutive-only counter resets on A→B→A→B. The recent window must still + // treat that as no distinct progress once it fills with fewer distinct + // signatures than steps (#4083). + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const path = calls % 2 === 1 ? 'notes-a.md' : 'notes-b.md'; + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: `tool-${calls}`, + toolName: 'Read', + input: JSON.stringify({ path }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-empty-alternating-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, 6); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'step_limit'); + assert.equal(events.filter((event) => event.type === 'tool_start').length, 6); + }); + + 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(); @@ -15824,7 +16026,10 @@ function planExecution(status: 'completed' | 'cancelled') { }; } -function countingToolLoopModel(toolCallsBeforeStop?: number): { +function countingToolLoopModel( + toolCallsBeforeStop?: number, + repeatToolInput = false, +): { model: MockLanguageModelV4; callCount: () => number; } { @@ -15854,7 +16059,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/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 36a3b8e2e9..5f79e2202e 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -159,6 +159,13 @@ interface ReactiveFixtureOptions { providerNative?: boolean; /** Explicit send-level step budget forwarded to the backend. */ maxSteps?: number; + /** + * Give each scripted `tool` step a distinct Read path. Needed when a test + * chains several textless tool steps: the Runtime empty-step cap (#4083) + * stops consecutive identical tool signatures, which would otherwise look + * like a stuck loop rather than intentional context growth. + */ + distinctToolPaths?: boolean; /** The FIRST tool step reports an unusable usage object (no token counts). */ firstStepUsageMissing?: boolean; /** Tool-search availability with the deferred `Big` tool. */ @@ -399,7 +406,9 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture } const chunks = kind === 'tool' - ? toolCallChunks(call, 'Read', { path: 'one.md' }) + ? toolCallChunks(call, 'Read', { + path: options.distinctToolPaths ? `one-${call}.md` : 'one.md', + }) : kind === 'bigtool' ? toolCallChunks(call, 'Read', { path: 'big.md' }, RETRY_STEP_TEXT_SENTINEL) : kind === 'bigread' @@ -1506,9 +1515,11 @@ describe('reactive overflow recovery in the streaming backend', () => { // Review P1-1 repro: four completed tool steps grow the provider-visible // request far beyond the attempt's INITIAL messages. Recovery must fold the // durable rejected-request history rather than relying on that stale base; - // same-turn tool growth must remain recoverable. + // same-turn tool growth must remain recoverable. Distinct paths keep this + // growth from matching the identical empty-step cap (#4083). const fixture = buildReactiveFixture({ script: ['tool', 'tool', 'tool', 'tool', 'overflow', 'done'], + distinctToolPaths: true, }); await runTurn(fixture); @@ -1520,7 +1531,7 @@ describe('reactive overflow recovery in the streaming backend', () => { assert.equal(fixture.recorded.length, 1); assert.equal(fixture.model.doStreamCalls.length, 6); // The four completed tool steps ran exactly once each. - assert.deepEqual(fixture.toolExecutions, ['one.md', 'one.md', 'one.md', 'one.md']); + assert.deepEqual(fixture.toolExecutions, ['one-1.md', 'one-2.md', 'one-3.md', 'one-4.md']); }); test('an unusable first-attempt step usage fails the whole record closed even when the retry succeeds', async () => { diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index c501a66ac7..39315279c7 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -589,6 +589,19 @@ const MAX_INCOMPLETE_STREAM_RETRIES_PER_STEP = 1; // 2026-08-28 incident shape) would otherwise spend the full attempt budget // accumulating fragments before failing anyway, so fail fast after one. const MAX_SEALED_THINKING_RETRIES_PER_STEP = 1; +/** + * Desktop interactive turns often omit `maxSteps`, so a model that keeps + * emitting textless tool-only steps can loop forever and flood the transcript + * with empty AI replies (#4083). Ordinary multi-step tool workflows and an + * explicit `maxSteps` remain authoritative. + * + * Identical consecutive signatures still trip after three repeats. Alternating + * textless signatures (A B A B …) bypass a consecutive-only counter, so the + * recent window also stops when it fills with fewer distinct signatures than + * steps — i.e. the window shows no distinct progress. + */ +const MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS = 3; +const EMPTY_STEP_SIGNATURE_WINDOW = 6; const PROVIDER_RETRY_BASE_DELAY_MS = 1_000; const PROVIDER_RETRY_MAX_DELAY_MS = 32_000; const PROVIDER_RETRY_JITTER_FACTOR = 0.25; @@ -1428,7 +1441,17 @@ export class AiSdkTurn { let providerOutcome: ModelStepOutcome; let finishReason: ModelFinishReason = 'stop'; let terminalProviderError: unknown; + let consecutiveIdenticalEmptySteps = 0; + let previousEmptyStepSignature: string | undefined; + const recentEmptyStepSignatures: string[] = []; + const clearEmptyStepProgress = (): void => { + consecutiveIdenticalEmptySteps = 0; + previousEmptyStepSignature = undefined; + recentEmptyStepSignatures.length = 0; + }; agentLoop: for (;;) { + let stepSawVisibleText = false; + let stepSawThinking = false; await this.drainSteeringInto(input, queue); if (this.deps.backend.loadTurnRuntimeEvents) { requestMessages = await loadDurableTurnProjection(); @@ -1796,7 +1819,10 @@ export class AiSdkTurn { } else if (event.kind === 'text') { if (event.text.length > 0) recordStepContent('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.deps.newId(), @@ -1836,7 +1862,15 @@ export class AiSdkTurn { } } else if (event.kind === 'thinking') { if (event.text.length > 0) recordStepContent('thinking'); - if (event.text.length > 0) attemptSawThinking = true; + // 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 appends to + // `stepThinkingParts` below so the encrypted carrier round-trips. + if (event.text.length > 0) { + attemptSawThinking = true; + stepSawThinking = true; + } if (event.providerOptions !== undefined) { if (event.providerOptionsOrigin !== 'maka_transport') { attemptSawContinuationMetadata = true; @@ -2339,6 +2373,43 @@ export class AiSdkTurn { ...(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 && + !this.loopStopRequested + ) { + consecutiveIdenticalEmptySteps = + emptyStepSignature === previousEmptyStepSignature + ? consecutiveIdenticalEmptySteps + 1 + : 1; + previousEmptyStepSignature = emptyStepSignature; + recentEmptyStepSignatures.push(emptyStepSignature); + if (recentEmptyStepSignatures.length > EMPTY_STEP_SIGNATURE_WINDOW) { + recentEmptyStepSignatures.shift(); + } + const windowHasNoDistinctProgress = + recentEmptyStepSignatures.length >= EMPTY_STEP_SIGNATURE_WINDOW && + new Set(recentEmptyStepSignatures).size < recentEmptyStepSignatures.length; + if ( + consecutiveIdenticalEmptySteps >= MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS || + windowHasNoDistinctProgress + ) { + // The model is repeating textless tool-only steps with no visible + // progress — either the same signature consecutively, or a short + // alternating cycle. Stop as a failed tool-step cap rather than + // reporting a successful end_turn with no answer (#4083). + this.loopStopReason = 'step_limit'; + this.loopStopRequested = true; + } + } else { + clearEmptyStepProgress(); + } const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps; if ( sandboxBoundaryFinalizationStep || @@ -2381,6 +2452,9 @@ export class AiSdkTurn { !this.loopStopRequested && !this.aborted ) { + // A redirected prompt deserves a fresh empty-step streak; otherwise + // a prior empty run would stop the turn before the steer can land. + clearEmptyStepProgress(); currentStepMessageId = this.deps.newId(); continue agentLoop; } diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 9ba7b50206..d89166101b 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -308,7 +308,7 @@ export const Composer = forwardRef< text: string, metadata?: ComposerSendMetadata, ): boolean | void | Promise; - onStop(): void | Promise; + onStop(): boolean | void | Promise; onPickAttachments?(): void | Promise; onPickDirectory?(): void | Promise; pendingDirectories?: readonly import('@maka/core/events').DirectoryReference[]; @@ -1301,8 +1301,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(); } @@ -1366,7 +1367,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'); @@ -1455,9 +1457,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. diff --git a/packages/ui/src/user-question-prompt.tsx b/packages/ui/src/user-question-prompt.tsx index a26c6b29c5..fb95e834de 100644 --- a/packages/ui/src/user-question-prompt.tsx +++ b/packages/ui/src/user-question-prompt.tsx @@ -34,7 +34,7 @@ import { getConversationCopy } from './conversation-copy.js'; export function UserQuestionPrompt(props: { request: UserQuestionRequestEvent; onRespond(response: UserQuestionResponse): void | Promise; - onStop(): void | Promise; + onStop(): boolean | void | Promise; stopPending?: boolean; }) { const copy = getConversationCopy(useUiLocale()).questions;