From 7c37a3fb60b7d0393b7f8659f7d57e621621a3b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Sat, 5 Sep 2026 00:32:45 +0800 Subject: [PATCH 01/20] fix(desktop,runtime): interrupt and bound empty assistant loops Plain Enter mid-turn now interrupts before a new root send so runaway empty replies can be stopped (#4083). When maxSteps is unset, textless tool-only steps are capped for identical streaks and for short alternating cycles in a sliding window. Empty Responses carriers and signature-only thinking no longer reset that bound. Drop the identity resolveFollowUpModeAtSubmit helper. Generated-by: Cursor Co-authored-by: Cursor --- .../follow-up-submit-routing.test.ts | 49 ---- apps/desktop/src/renderer/app-shell.tsx | 36 ++- .../src/renderer/follow-up-submit-routing.ts | 17 +- .../src/__tests__/ai-sdk-backend.test.ts | 214 +++++++++++++++++- packages/runtime/src/ai-sdk-turn.ts | 78 ++++++- packages/ui/src/composer.tsx | 14 +- 6 files changed, 327 insertions(+), 81 deletions(-) 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.tsx b/apps/desktop/src/renderer/app-shell.tsx index f6473dd7e7..9aea26fbeb 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, @@ -1912,11 +1911,10 @@ 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 }); + // Slash commands are not follow-up text for a live turn — dispatch them. + // Explicit Shift+Enter steer / queue still uses metadata.followUpMode. + const followUpAtSubmit = !slashCommand ? metadata?.followUpMode : undefined; if (sessionId && followUpAtSubmit) { const queued = await enqueueFollowUp(sessionId, text, followUpAtSubmit, { ...metadata, @@ -1925,6 +1923,32 @@ 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. + // Host `turn.interrupt` awaits the cancelled turn's terminal fact before + // resolving, so the Session lane is free for the root send below — not + // merely "usually fast enough" relative to settlement. + 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 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..83a931b6bd 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -5565,6 +5565,213 @@ 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 +16031,10 @@ function planExecution(status: 'completed' | 'cancelled') { }; } -function countingToolLoopModel(toolCallsBeforeStop?: number): { +function countingToolLoopModel( + toolCallsBeforeStop?: number, + repeatToolInput = false, +): { model: MockLanguageModelV4; callCount: () => number; } { @@ -15854,7 +16064,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-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..1721dfa124 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -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. From 270774fd81294ef3322ba369848b76c69e372caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Sat, 5 Sep 2026 00:59:50 +0800 Subject: [PATCH 02/20] fix(desktop): keep mid-turn interrupt off AppShell bridge debt Route plain-Enter interrupt through createAppShellStopAction so app-shell.tsx does not grow window.maka.sessions.stop bridgePaths, and refresh the renderer architecture ledger after the token-neutral comment fold that keeps nonTriviaTokens under the ratchet. Generated-by: Cursor Co-authored-by: Cursor --- apps/desktop/renderer-architecture.json | 6 +- .../__tests__/app-shell-stop-action.test.ts | 36 ++++++- .../src/renderer/app-shell-stop-action.ts | 9 +- apps/desktop/src/renderer/app-shell.tsx | 93 +++++++------------ 4 files changed, 72 insertions(+), 72 deletions(-) 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/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 9aea26fbeb..fcb5042bae 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -395,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' @@ -440,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; @@ -539,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, @@ -551,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, @@ -715,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; @@ -769,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(); @@ -1821,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,8 +1920,6 @@ function AppShellContent({ ? sessionsRef.current.find((session) => session.id === sessionId)?.runningTurnIds : undefined; const hasActiveTurn = hasActiveTurnAtSubmit({ liveTurn, runningTurnIds }); - // Slash commands are not follow-up text for a live turn — dispatch them. - // Explicit Shift+Enter steer / queue still uses metadata.followUpMode. const followUpAtSubmit = !slashCommand ? metadata?.followUpMode : undefined; if (sessionId && followUpAtSubmit) { const queued = await enqueueFollowUp(sessionId, text, followUpAtSubmit, { @@ -1923,32 +1929,7 @@ 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. - // Host `turn.interrupt` awaits the cancelled turn's terminal fact before - // resolving, so the Session lane is free for the root send below — not - // merely "usually fast enough" relative to settlement. - 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 (sessionId && hasActiveTurn && !slashCommand && !(await stop())) return false; if ( revisionSend && revision && @@ -2200,14 +2181,6 @@ function AppShellContent({ ); } - const stop = createAppShellStopAction({ - uiLocale, - activeIdRef, - stopPending: sessionUiController.stopPending, - removeTransientMessage, - toastApi, - }); - const [sessionDisplayBatch] = useState(createAppShellSessionDisplayBatch); const { handleEvent, From 70f29b2999c405caf972d5dc9b2d5e14792e862a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Sat, 5 Sep 2026 09:22:36 +0800 Subject: [PATCH 03/20] fix(runtime): format empty-loop regression tests for biome Generated-by: Cursor Co-authored-by: Cursor --- packages/runtime/src/__tests__/ai-sdk-backend.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 83a931b6bd..ba8abd492b 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -5587,10 +5587,7 @@ describe('AiSdkBackend model history', () => { 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, - ); + assert.equal(events.filter((event) => event.type === 'tool_start').length, 3); }); test('stops an unbounded loop when empty tool steps alternate between signatures', async () => { @@ -5764,9 +5761,7 @@ describe('AiSdkBackend model history', () => { assert.ok( events.some( (event) => - event.type === 'thinking_complete' && - event.signature !== undefined && - event.text === '', + event.type === 'thinking_complete' && event.signature !== undefined && event.text === '', ), 'signature-only reasoning must still persist', ); From d5e95658115c7bbf4277ee3bebe88905a2a9c6cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Sat, 5 Sep 2026 09:34:36 +0800 Subject: [PATCH 04/20] fix(ui): allow onStop to return boolean for mid-turn interrupt Co-authored-by: Cursor --- packages/ui/src/composer.tsx | 2 +- packages/ui/src/user-question-prompt.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 1721dfa124..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[]; 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; From 7b5769393c4293f9e07da507fb2d34455f13d13a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Sat, 5 Sep 2026 09:46:28 +0800 Subject: [PATCH 05/20] test(runtime): use distinct tool paths in overflow recovery growth case Co-authored-by: Cursor --- .../overflow-reactive-recovery.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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 () => { From 2ae395c26cd00c1cee1832223d9894599650adc0 Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Fri, 11 Sep 2026 12:06:25 +0800 Subject: [PATCH 06/20] fix(desktop,runtime): tighten empty-step loop bound from review Make empty-step progress result-aware, stop as empty_step_loop instead of a configured step_limit, interrupt mid-turn only after send eligibility, and pin must-survive coverage for distinct/result-changing textless steps. Generated-by: Cursor Co-authored-by: Cursor --- .../session-status-presentation.test.ts | 1 + apps/desktop/src/renderer/app-shell.tsx | 8 +- .../src/renderer/locales/conversation-copy.ts | 7 +- .../renderer/session-status-presentation.ts | 2 + packages/cli/src/pi-transcript.ts | 6 + packages/core/src/events.ts | 4 +- packages/core/src/session.ts | 6 + .../src/__tests__/ai-sdk-backend.test.ts | 202 +++++++++++++++++- .../overflow-reactive-recovery.test.ts | 5 +- .../runtime-event-read-model.test.ts | 30 +++ .../session-event-runtime-mapper.test.ts | 14 ++ packages/runtime/src/ai-sdk-turn.ts | 42 ++-- .../runtime/src/runtime-event-read-model.ts | 9 + .../src/session-event-runtime-mapper.ts | 5 +- packages/ui/src/conversation-copy.ts | 7 + packages/ui/src/materialize.ts | 1 + scripts/computer-use/report-sanitize.mjs | 1 + 17 files changed, 322 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/main/__tests__/session-status-presentation.test.ts b/apps/desktop/src/main/__tests__/session-status-presentation.test.ts index 133ad3e7da..5ef31862bb 100644 --- a/apps/desktop/src/main/__tests__/session-status-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/session-status-presentation.test.ts @@ -49,6 +49,7 @@ describe('failed turn presentation', () => { it('grades continuable outcomes below outcomes the user must act on', () => { assert.equal(deriveFailedTurnSeverity('app_restarted'), 'warning'); assert.equal(deriveFailedTurnSeverity('tool_step_cap_reached'), 'warning'); + assert.equal(deriveFailedTurnSeverity('empty_assistant_loop'), 'warning'); assert.equal(deriveFailedTurnSeverity('permission_required'), 'warning'); assert.equal(deriveFailedTurnSeverity('auth'), 'error'); assert.equal(deriveFailedTurnSeverity('context_overflow'), 'error'); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index fcb5042bae..ff5ae6e666 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1929,7 +1929,6 @@ function AppShellContent({ if (queued) delete retractedWorkspaceReferencesRef.current[sessionId]; return queued; } - if (sessionId && hasActiveTurn && !slashCommand && !(await stop())) return false; if ( revisionSend && revision && @@ -2102,6 +2101,13 @@ function AppShellContent({ ? revisionDraftRef.current : undefined; const quotes = pendingQuotes.length ? pendingQuotes : undefined; + // Plain Enter during a live turn: interrupt immediately before the root + // send so the typed message stops a runaway loop (#4083). Eligibility + // (revision / slash / compact) runs first — an inadmissible submit must + // not kill the active turn. Host `turn.interrupt` awaits the cancelled + // turn's terminal fact before resolving, so the Session lane is free for + // the root send below. + if (sessionId && hasActiveTurn && !slashCommand && !(await stop())) return false; const ok = await send(text, pending, { ...directoryOptions, ...(quotes ? { quotes } : {}), diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index b4fef1cfa2..ac909e94e7 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -379,6 +379,7 @@ export interface DesktopConversationCopy { network: string; provider: string; stepCap: string; + emptyLoop: string; tool: string; permission: string; restarted: string; @@ -709,7 +710,7 @@ const COPY = { reauth: { label: '上次连接测试鉴权失败', tooltip: '最近一次连接测试返回鉴权失败(401 / 403),密钥可能已过期或被吊销。这不会拦截发送,但若发送失败请到 设置 · 模型 重新登录。' }, testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' }, }, - turnError: { unknown: '出错了,原因不明。重新发消息重试。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', timeout: '模型请求超时,重新发消息重试。', auth: '模型鉴权失败,请到设置里重新连接或登录。', providerBilling: '模型服务计费受限,请检查账号余额或订阅状态。', providerCapacity: '模型服务暂时满载,等几分钟重试,或换一个模型。', rateLimit: '模型请求太频繁被限流了,等一会儿再发消息重试。', network: '网络连接失败,检查网络后重新发消息。', provider: '模型服务返回错误,稍后重试或换一个模型。', stepCap: '达到工具调用步数上限,任务可能没做完。发消息让它继续。', tool: '工具调用失败,看一下上面的工具结果再决定要不要重试。', permission: '这一轮在等权限确认时结束了,重新发消息会再问一次。', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启时,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭。重新发消息可以再决定一次。', executionState: { erroredTool: '这一轮有工具执行出错,先看它的结果,再决定要不要重发。', toolRan: '这一轮已经执行过工具,可能已经产生实际改动,重发前先看工具结果。', partialOutput: '这一轮已经产生了部分回答,重发前可以先看看。' } }, + turnError: { unknown: '出错了,原因不明。重新发消息重试。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', timeout: '模型请求超时,重新发消息重试。', auth: '模型鉴权失败,请到设置里重新连接或登录。', providerBilling: '模型服务计费受限,请检查账号余额或订阅状态。', providerCapacity: '模型服务暂时满载,等几分钟重试,或换一个模型。', rateLimit: '模型请求太频繁被限流了,等一会儿再发消息重试。', network: '网络连接失败,检查网络后重新发消息。', provider: '模型服务返回错误,稍后重试或换一个模型。', stepCap: '达到工具调用步数上限,任务可能没做完。发消息让它继续。', emptyLoop: '连续空工具步骤没有可见进展,任务可能没做完。发消息让它继续。', tool: '工具调用失败,看一下上面的工具结果再决定要不要重试。', permission: '这一轮在等权限确认时结束了,重新发消息会再问一次。', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启时,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭。重新发消息可以再决定一次。', executionState: { erroredTool: '这一轮有工具执行出错,先看它的结果,再决定要不要重发。', toolRan: '这一轮已经执行过工具,可能已经产生实际改动,重发前先看工具结果。', partialOutput: '这一轮已经产生了部分回答,重发前可以先看看。' } }, }, 'zh-TW': { actions: { stopFailedTitle: '停止失敗', stopFailedFallback: '任務操作失敗,請稍後重試。', refreshSessionsFailedTitle: '重新整理任務列表失敗', refreshSessionsFailedFallback: '重新整理任務列表失敗,請稍後重試。', conversationErrorTitle: '任務出錯', conversationErrorFallback: '任務執行失敗,請稍後重試。', regenerateStartedTitle: '已發起重新生成', regenerateStartedDescription: '正在生成新的一輪迴答', branchCreatedTitle: '已建立分支', branchCreatedDescription: (name) => `新任務 ${name}`, revisionStartedTitle: '已建立修改版草稿', revisionStartedDescription: '原任務仍會保留;修改後傳送將在新版本中繼續', revisionReadyTitle: '可以修改並重發了', revisionReadyDescription: '已回到該訊息之前;編輯後傳送即可', revisionUnavailableTitle: '暫時無法編輯這條訊息', revisionAttachmentsUnsupported: '包含附件的歷史訊息暫不支援編輯並重發,請複製文字後建立訊息。', revisionTransformedTextUnsupported: '透過顯式技能傳送的歷史訊息暫不支援編輯並重發,請複製文字後重新選擇技能。', revisionDraftAttachmentConflict: 'Composer 中已有待發送附件,請先發送或移除附件,再編輯歷史訊息。', revisionCommandUnsupported: '修改訊息時不能執行 /compact、/side 或編排命令,請取消修改後再試。', revisionAlreadyActive: '已有一條訊息正在修改,請先發送或取消目前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已傳送訊息', revisionBannerDetail: '· 傳送後建立新版本', revisionUnchanged: '內容沒有變化。如需重新回答,請使用“重新生成”。', operationFailedTitle: '操作失敗', operationFailedFallback: '任務操作失敗,請稍後重試。', attachmentFailedTitle: '新增附件失敗', imageAttachmentNotDirectTitle: '圖片已作為附件新增', imageAttachmentNotDirectDescription: '目前模型不會直接接收圖片。圖片已作為附件提供給模型。', tryAgain: '請稍後重試。', modelReboundTitle: '已切換到可用模型', modelReboundDescription: (modelId) => `原任務使用的連線已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '讀取任務失敗', partialHistoryTitle: '正在檢視較早的訊息', returnLatest: '返回最新訊息', scrollMainToBottom: '滾動主對話到底部' }, @@ -954,7 +955,7 @@ const COPY = { reauth: { label: '上次連線測試鑑權失敗', tooltip: '最近一次連線測試回傳鑑權失敗(401 / 403),金鑰可能已過期或被吊銷。這不會攔截發送,但若傳送失敗請到 設定 · 模型 重新登入。' }, testError: { label: '上次連線測試失敗', tooltip: '最近一次連線測試因網路 / 超時 / 5xx 失敗。這不會攔截發送,但若問題持續請到 設定 · 模型 檢查 Base URL / 代理。' }, }, - turnError: { unknown: '出錯了,原因不明。重新傳送訊息重試。', contextOverflow: '上下文超出模型視窗限制,減少附件或開啟新任務。', timeout: '模型請求逾時,重新傳送訊息重試。', auth: '模型鑑權失敗,請到設定裡重新連線或登入。', providerBilling: '模型服務計費受限,請檢查帳號餘額或訂閱狀態。', providerCapacity: '模型服務暫時滿載,請等待幾分鐘或切換模型。', rateLimit: '模型請求太頻繁而受到速率限制,請稍候再傳送訊息重試。', network: '網路連線失敗,檢查網路後重新傳送訊息。', provider: '模型服務回傳錯誤,稍後重試或切換模型。', stepCap: '達到工具呼叫步數上限,任務可能尚未完成。傳送訊息讓它繼續。', tool: '工具呼叫失敗,先看上面的工具結果再決定是否重試。', permission: '這一輪在等待權限確認時結束,重新傳送訊息會再詢問一次。', restarted: '本機應用程式重啟,上一輪沒有完成', sandboxBoundaryClosed: '本機應用程式重啟時,等待確認的「允許存取工作區以外的內容」請求已按拒絕關閉。重新傳送訊息可以再次決定。', executionState: { erroredTool: '這一輪有工具執行出錯,先看它的結果,再決定是否重發。', toolRan: '這一輪已經執行過工具,可能已經產生實際變更,重發前先看工具結果。', partialOutput: '這一輪已經產生部分回答,重發前可以先看看。' } }, + turnError: { unknown: '出錯了,原因不明。重新傳送訊息重試。', contextOverflow: '上下文超出模型視窗限制,減少附件或開啟新任務。', timeout: '模型請求逾時,重新傳送訊息重試。', auth: '模型鑑權失敗,請到設定裡重新連線或登入。', providerBilling: '模型服務計費受限,請檢查帳號餘額或訂閱狀態。', providerCapacity: '模型服務暫時滿載,請等待幾分鐘或切換模型。', rateLimit: '模型請求太頻繁而受到速率限制,請稍候再傳送訊息重試。', network: '網路連線失敗,檢查網路後重新傳送訊息。', provider: '模型服務回傳錯誤,稍後重試或切換模型。', stepCap: '達到工具呼叫步數上限,任務可能尚未完成。傳送訊息讓它繼續。', emptyLoop: '連續空工具步驟沒有可見進展,任務可能尚未完成。傳送訊息讓它繼續。', tool: '工具呼叫失敗,先看上面的工具結果再決定是否重試。', permission: '這一輪在等待權限確認時結束,重新傳送訊息會再詢問一次。', restarted: '本機應用程式重啟,上一輪沒有完成', sandboxBoundaryClosed: '本機應用程式重啟時,等待確認的「允許存取工作區以外的內容」請求已按拒絕關閉。重新傳送訊息可以再次決定。', executionState: { erroredTool: '這一輪有工具執行出錯,先看它的結果,再決定是否重發。', toolRan: '這一輪已經執行過工具,可能已經產生實際變更,重發前先看工具結果。', partialOutput: '這一輪已經產生部分回答,重發前可以先看看。' } }, }, en: { actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', imageAttachmentNotDirectTitle: 'Image added as an attachment', imageAttachmentNotDirectDescription: 'The current model does not receive images directly. The image has been provided as an attachment.', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', partialHistoryTitle: 'Viewing earlier messages', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' }, @@ -1215,7 +1216,7 @@ const COPY = { reauth: { label: 'Last connection test failed authentication', tooltip: 'The latest test returned 401 / 403. Sending is not blocked, but sign in again under Settings · Models if it fails.' }, testError: { label: 'Last connection test failed', tooltip: 'The latest test failed because of a network, timeout, or 5xx error. Sending is not blocked; check Base URL or proxy settings if it persists.' }, }, - turnError: { unknown: 'Something went wrong, cause unknown. Send a message to retry.', contextOverflow: 'Context exceeded the model window. Reduce attachments or start a new task.', timeout: 'The model request timed out. Send a message to retry.', auth: 'Model authentication failed. Reconnect or sign in again from Settings.', providerBilling: 'Model billing is restricted. Check the account balance or subscription.', providerCapacity: 'The model service is temporarily at capacity. Wait a few minutes, or switch models.', rateLimit: 'Requests were rate-limited. Wait a moment, then send a message to retry.', network: 'The network connection failed. Check the network, then send a message again.', provider: 'The model service returned an error. Retry later, or switch models.', stepCap: 'The tool-step limit was reached, so the task may be incomplete. Send a message to continue.', tool: 'A tool call failed. Check the tool result above before deciding whether to retry.', permission: 'This turn ended while waiting for permission. Send a message and it will ask again.', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied. Send a message to decide again.', executionState: { erroredTool: 'A tool errored during this turn. Read its result before deciding whether to send another message.', toolRan: 'Tools already ran during this turn and may have made real changes. Read their results before sending another message.', partialOutput: 'This turn produced part of an answer. Worth reading before you send another message.' } }, + turnError: { unknown: 'Something went wrong, cause unknown. Send a message to retry.', contextOverflow: 'Context exceeded the model window. Reduce attachments or start a new task.', timeout: 'The model request timed out. Send a message to retry.', auth: 'Model authentication failed. Reconnect or sign in again from Settings.', providerBilling: 'Model billing is restricted. Check the account balance or subscription.', providerCapacity: 'The model service is temporarily at capacity. Wait a few minutes, or switch models.', rateLimit: 'Requests were rate-limited. Wait a moment, then send a message to retry.', network: 'The network connection failed. Check the network, then send a message again.', provider: 'The model service returned an error. Retry later, or switch models.', stepCap: 'The tool-step limit was reached, so the task may be incomplete. Send a message to continue.', emptyLoop: 'Repeated empty tool steps made no visible progress, so the task may be incomplete. Send a message to continue.', tool: 'A tool call failed. Check the tool result above before deciding whether to retry.', permission: 'This turn ended while waiting for permission. Send a message and it will ask again.', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied. Send a message to decide again.', executionState: { erroredTool: 'A tool errored during this turn. Read its result before deciding whether to send another message.', toolRan: 'Tools already ran during this turn and may have made real changes. Read their results before sending another message.', partialOutput: 'This turn produced part of an answer. Worth reading before you send another message.' } }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/session-status-presentation.ts b/apps/desktop/src/renderer/session-status-presentation.ts index 99088d068f..4e95e043c3 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -120,6 +120,7 @@ export function describeTurnErrorClass(errorClass: string | undefined, locale: U ) return copy.provider; if (lower === 'tool_step_cap_reached') return copy.stepCap; + if (lower === 'empty_assistant_loop') return copy.emptyLoop; if (lower === 'tool_failed' || lower.includes('tool')) return copy.tool; if (lower === 'permission_required' || lower.includes('permission')) return copy.permission; if (lower === 'app_restarted') return copy.restarted; @@ -144,6 +145,7 @@ export function deriveFailedTurnSeverity(errorClass: string | undefined): Failed if (lower === SANDBOX_BOUNDARY_RESTART_CLOSURE_CLASS) return 'warning'; if (lower === 'app_restarted') return 'warning'; if (lower === 'tool_step_cap_reached') return 'warning'; + if (lower === 'empty_assistant_loop') return 'warning'; if (lower === 'permission_required' || lower.includes('permission')) return 'warning'; return 'error'; } diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index d32a711b72..f02f934fb3 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -31,6 +31,7 @@ import type { } from '@maka/core/events'; import { deriveTurnRecords, + EMPTY_STEP_LOOP_NOTICE_TEXT, STEP_LIMIT_NOTICE_TEXT, type StoredMessage, type SystemNoteMessage, @@ -1057,6 +1058,9 @@ export function applyMakaSessionEventToTranscript( if (event.stopReason === 'step_limit') { state.entries.push({ kind: 'notice', level: 'info', text: STEP_LIMIT_NOTICE_TEXT }); } + if (event.stopReason === 'empty_step_loop') { + state.entries.push({ kind: 'notice', level: 'info', text: EMPTY_STEP_LOOP_NOTICE_TEXT }); + } break; } } @@ -1408,6 +1412,8 @@ function systemNoteText(message: SystemNoteMessage): string | undefined { } case 'step_limit': return STEP_LIMIT_NOTICE_TEXT; + case 'empty_step_loop': + return EMPTY_STEP_LOOP_NOTICE_TEXT; case 'error': return 'Session recorded an error.'; case 'abort': diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index a30d3999d4..390581f48e 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1267,6 +1267,7 @@ export interface CompleteEvent extends BaseEvent { | 'graph_yield' | 'permission_handoff' | 'step_limit' + | 'empty_step_loop' | 'max_tokens'; /** Durable result of an explicit context-compaction execution. */ contextCompactionOutcome?: ContextCompactionOutcome; @@ -1282,9 +1283,10 @@ export type CompleteStopReason = CompleteEvent['stopReason']; /** Stable failure taxonomy for complete events that did not finish the turn. */ export function failureClassFromCompleteStopReason( reason: CompleteStopReason, -): 'runtime_error' | 'tool_step_cap_reached' | undefined { +): 'runtime_error' | 'tool_step_cap_reached' | 'empty_assistant_loop' | undefined { if (reason === 'error') return 'runtime_error'; if (reason === 'step_limit') return 'tool_step_cap_reached'; + if (reason === 'empty_step_loop') return 'empty_assistant_loop'; return undefined; } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index dc03aea7be..5344caed16 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -798,6 +798,7 @@ const USER_VISIBLE_SESSION_SYSTEM_NOTES = new Set([ 'context_reported_window_exceeded', 'context_overflow_after_compaction', 'step_limit', + 'empty_step_loop', ]); /** Closed policy for system notes that are part of the user-visible transcript. */ @@ -1166,6 +1167,7 @@ export interface SystemNoteMessage { | 'context_reported_window_exceeded' | 'context_overflow_after_compaction' | 'step_limit' + | 'empty_step_loop' | 'error' | 'abort'; /** Shape depends on `kind`. */ @@ -1415,6 +1417,7 @@ const SYSTEM_NOTE_KINDS = new Set([ 'context_reported_window_exceeded', 'context_overflow_after_compaction', 'step_limit', + 'empty_step_loop', 'error', 'abort', ]); @@ -1792,6 +1795,9 @@ function isToolActivityIdentity(value: Record): boolean { export const STEP_LIMIT_NOTICE_TEXT = 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.'; +export const EMPTY_STEP_LOOP_NOTICE_TEXT = + 'Stopped after repeated empty tool steps with no visible progress. The task may be incomplete. Send a message to continue.'; + /** Latest actual model recorded by a completed assistant step. */ export function latestAssistantModelId(messages: readonly StoredMessage[]): string | undefined { for (let index = messages.length - 1; index >= 0; index -= 1) { diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index ba8abd492b..8c211b6ee1 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -5586,7 +5586,7 @@ describe('AiSdkBackend model history', () => { 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.find((event) => event.type === 'complete')?.stopReason, 'empty_step_loop'); assert.equal(events.filter((event) => event.type === 'tool_start').length, 3); }); @@ -5638,7 +5638,7 @@ describe('AiSdkBackend model history', () => { 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.find((event) => event.type === 'complete')?.stopReason, 'empty_step_loop'); assert.equal(events.filter((event) => event.type === 'tool_start').length, 6); }); @@ -5697,7 +5697,7 @@ describe('AiSdkBackend model history', () => { 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.find((event) => event.type === 'complete')?.stopReason, 'empty_step_loop'); assert.equal(events.filter((event) => event.type === 'tool_start').length, 3); }); @@ -5756,7 +5756,7 @@ describe('AiSdkBackend model history', () => { 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.find((event) => event.type === 'complete')?.stopReason, 'empty_step_loop'); assert.equal(events.filter((event) => event.type === 'tool_start').length, 3); assert.ok( events.some( @@ -5767,6 +5767,200 @@ describe('AiSdkBackend model history', () => { ); }); + test('continues through six or more distinct textless tool steps', async () => { + // The empty-step window must not treat ordinary multi-step tool work as a + // stuck loop when every request+result signature is new (#4083 review). + const loop = countingToolLoopModel(8); + const durable = durableTurnHarness('turn-empty-survive-distinct', '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(), 9); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + assert.equal(events.filter((event) => event.type === 'tool_start').length, 8); + }); + + test('resets the empty-step window after visible text progress', async () => { + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + if (calls === 3) { + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-progress' }, + { type: 'text-delta', id: 'text-progress', delta: 'found a lead' }, + { type: 'text-end', id: 'text-progress' }, + { + 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, + }), + }; + } + if (calls > 5) { + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-final' }, + { type: 'text-delta', id: 'text-final', delta: 'done' }, + { type: 'text-end', id: 'text-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + } + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + 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-survive-text-reset', '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); + // Two identical empty steps, a text+tool reset, then two more identical + // empty steps — never three identical empty signatures in a row. + assert.equal(calls, 6); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + assert.equal(events.filter((event) => event.type === 'tool_start').length, 5); + }); + + test('does not trip the empty-step cap when a repeated request yields a new result', async () => { + let calls = 0; + let resultN = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + if (calls > 6) { + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-final' }, + { type: 'text-delta', id: 'text-final', delta: 'done' }, + { type: 'text-end', id: 'text-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + } + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + 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 progressingTool: MakaTool = { + name: 'Read', + description: 'Read description', + parameters: z.object({ path: z.string() }), + impl: async () => ({ ok: true, n: ++resultN }), + }; + const durable = durableTurnHarness('turn-empty-survive-result-progress', 'keep going'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [progressingTool], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + assert.equal(calls, 7); + assert.equal(resultN, 6); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + assert.equal(events.filter((event) => event.type === 'tool_start').length, 6); + }); + test('aborting during post-stream persistence wins over step-limit completion', async () => { const loop = countingToolLoopModel(); const gate = makeGate(); diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 5f79e2202e..029ea0871b 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -161,8 +161,9 @@ interface ReactiveFixtureOptions { 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 + * chains several textless tool steps that would otherwise share both input + * and a static `{ ok: true }` result: the Runtime empty-step cap (#4083) + * stops consecutive identical request+result signatures, which would look * like a stuck loop rather than intentional context growth. */ distinctToolPaths?: boolean; diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index c47760680f..84a3790c91 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1666,6 +1666,36 @@ describe('projectRuntimeEventsToStoredMessages', () => { ); }); + test('failed empty_assistant_loop RuntimeEvent emits an empty_step_loop system note', () => { + const out = projectRuntimeEventsToStoredMessages( + [ + ev({ + id: 'evt-empty-loop', + ts: ts + 9, + status: 'failed', + actions: { + endInvocation: true, + stateDelta: { stopReason: 'empty_step_loop', failureClass: 'empty_assistant_loop' }, + }, + }), + ], + { + invocations: [endedAs('failed', 'empty_assistant_loop')], + }, + ); + + assert.deepStrictEqual( + out.messages.find((message) => message.type === 'system_note'), + { + type: 'system_note', + id: 'evt-empty-loop:empty-step-loop-notice', + turnId, + ts: ts + 9, + kind: 'empty_step_loop', + }, + ); + }); + test('aborted terminal RuntimeEvent preserves abort source from runtime state', () => { const out = projectRuntimeEventsToStoredMessages( [ diff --git a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts index 65632860cb..64ef42e026 100644 --- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts +++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts @@ -152,6 +152,7 @@ describe('mapSessionEventToRuntimeEvent (pure)', () => { assert.equal(mapCompleteStopReason('user_stop'), 'aborted'); assert.equal(mapCompleteStopReason('error'), 'failed'); assert.equal(mapCompleteStopReason('step_limit'), 'failed'); + assert.equal(mapCompleteStopReason('empty_step_loop'), 'failed'); }); test('step_limit uses the established tool-step-cap failure class', () => { @@ -167,6 +168,19 @@ describe('mapSessionEventToRuntimeEvent (pure)', () => { }); }); + test('empty_step_loop uses a distinct empty-assistant-loop failure class', () => { + const mapped = mapSessionEventToRuntimeEvent( + ev({ type: 'complete', stopReason: 'empty_step_loop' }), + ctx, + createSessionEventMapMemory(), + ); + + assert.deepEqual(mapped.actions?.stateDelta, { + stopReason: 'empty_step_loop', + failureClass: 'empty_assistant_loop', + }); + }); + test('tool_output_delta and tool_progress map to partial tool-role heartbeats', () => { const mem = createSessionEventMapMemory(); const a = mapSessionEventToRuntimeEvent( diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index 39315279c7..af7e95d8f1 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -595,10 +595,13 @@ const MAX_SEALED_THINKING_RETRIES_PER_STEP = 1; * 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. + * Progress evidence is result-aware: a step is empty only when it has no + * visible text/thinking and the (toolName, input, result) signature repeats. + * Identical consecutive signatures still trip after three repeats. Short + * alternating cycles (A B A B …) bypass a consecutive-only counter, so the + * recent window also stops when it fills and at most half the signatures are + * distinct — i.e. the window is cycling rather than merely containing one + * benign repeat among otherwise new work. */ const MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS = 3; const EMPTY_STEP_SIGNATURE_WINDOW = 6; @@ -2257,6 +2260,7 @@ export class AiSdkTurn { finishReason = providerOutcome.finishReason; await queue.waitUntilConsumedThroughCurrent(); + let settledToolResults: unknown[] | undefined; if (returnedToolCalls.length > 0) { const continuationBudgetRemains = maxSteps === undefined || runtimeSteps < maxSteps; if (continuationBudgetRemains && !this.deps.backend.loadTurnRuntimeEvents) { @@ -2333,6 +2337,7 @@ export class AiSdkTurn { if (outcome.status === 'rejected') throw outcome.reason; return outcome.value; }); + settledToolResults = settlements.map((settlement) => settlement.result); for (let index = 0; index < settlements.length; index += 1) { const settlement = settlements[index]!; const toolCall = returnedToolCalls[index]; @@ -2374,9 +2379,16 @@ export class AiSdkTurn { }); lastCompletedStepHadToolResult = returnedToolCalls.length > 0; const emptyStepSignature = - !stepSawVisibleText && !stepSawThinking && returnedToolCalls.length > 0 + !stepSawVisibleText && + !stepSawThinking && + returnedToolCalls.length > 0 && + settledToolResults !== undefined ? JSON.stringify( - returnedToolCalls.map(({ toolName, input }) => ({ toolName, input })), + returnedToolCalls.map(({ toolName, input }, index) => ({ + toolName, + input, + result: settledToolResults![index] ?? null, + })), ) : undefined; if ( @@ -2393,18 +2405,21 @@ export class AiSdkTurn { if (recentEmptyStepSignatures.length > EMPTY_STEP_SIGNATURE_WINDOW) { recentEmptyStepSignatures.shift(); } + const distinctEmptySignatures = new Set(recentEmptyStepSignatures).size; + // Require a short cycle (≤ half distinct), not merely one duplicate + // among otherwise progressing textless steps. const windowHasNoDistinctProgress = recentEmptyStepSignatures.length >= EMPTY_STEP_SIGNATURE_WINDOW && - new Set(recentEmptyStepSignatures).size < recentEmptyStepSignatures.length; + distinctEmptySignatures * 2 <= 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'; + // The model is repeating textless tool-only steps with no request + // or result progress — either the same signature consecutively, + // or a short alternating cycle. Stop as empty_step_loop rather + // than a configured step_limit or a successful end_turn (#4083). + this.loopStopReason = 'empty_step_loop'; this.loopStopRequested = true; } } else { @@ -2452,9 +2467,6 @@ 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/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index d3cbba2a6e..3e976c354f 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -1205,6 +1205,15 @@ function projectTerminalTurnState( kind: 'step_limit', }); } + if (failureClass === 'empty_assistant_loop') { + messages.push({ + type: 'system_note', + id: `${event.id}:empty-step-loop-notice`, + turnId: event.turnId, + ts: event.ts, + kind: 'empty_step_loop', + }); + } // An omitted failure class or abort source is `classifyRuntimeEventTerminalFact`'s // observation to make. Repeating it here would only turn a transcript row that // already reads `unknown` into an unreadable Session. diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index d40f028623..c94c87363e 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -57,8 +57,9 @@ export type CompleteStopReason = CompleteEvent['stopReason']; * `end_turn` / `max_tokens` / `*_handoff` all represent the streaming phase * ending normally (control may be handed off, but the run is not a failure), * so they map to `completed`. `user_stop` maps to `aborted`; `error` to - * `failed`. An explicit `step_limit` is also failed because the requested work - * may be incomplete. Phase 5+ may introduce a richer `waiting`/`handoff` status. + * `failed`. An explicit `step_limit` or `empty_step_loop` is also failed + * because the requested work may be incomplete. Phase 5+ may introduce a + * richer `waiting`/`handoff` status. */ export function mapCompleteStopReason(reason: CompleteStopReason): RuntimeEventStatus { if (reason === 'user_stop') return 'aborted'; diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 12ed2c76d6..59d151cbd9 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -342,6 +342,7 @@ export interface ConversationCopy { contextUsageUnavailable: string; contextUsageOpen: string; stepLimit: string; + emptyStepLoop: string; }; }; chat: { @@ -575,6 +576,8 @@ const CONVERSATION_COPY = { contextUsageUnavailable: '暂无用量数据', contextUsageOpen: '打开用量追踪', stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。', + emptyStepLoop: + '已停止:连续空工具步骤没有可见进展。任务可能尚未完成。发送消息即可继续。', }, }, chat: { @@ -727,6 +730,8 @@ const CONVERSATION_COPY = { contextUsageUnavailable: '暫無用量資料', contextUsageOpen: '開啟用量追蹤', stepLimit: '已達到本輪工具步驟上限,任務可能尚未完成。傳送“繼續”即可接著處理。', + emptyStepLoop: + '已停止:連續空工具步驟沒有可見進展。任務可能尚未完成。傳送訊息即可繼續。', }, }, chat: { @@ -905,6 +910,8 @@ const CONVERSATION_COPY = { contextUsageUnavailable: 'No usage data is available for this request.', contextUsageOpen: 'Open usage trace', stepLimit: 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.', + emptyStepLoop: + 'Stopped after repeated empty tool steps with no visible progress. The task may be incomplete. Send a message to continue.', }, }, chat: { diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index e55f6283e9..c34c13be11 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -184,6 +184,7 @@ function systemNoteLabel(kind: string, data: unknown, locale: UiLocale): string return copy.contextWindowSuggestion(tokens, declared); } if (kind === "step_limit") return copy.stepLimit; + if (kind === "empty_step_loop") return copy.emptyStepLoop; return kind; } diff --git a/scripts/computer-use/report-sanitize.mjs b/scripts/computer-use/report-sanitize.mjs index 9c1c35dd18..0e02d5b7d2 100644 --- a/scripts/computer-use/report-sanitize.mjs +++ b/scripts/computer-use/report-sanitize.mjs @@ -223,6 +223,7 @@ export function sanitizeCuReport(report) { 'end_turn', 'max_tokens', 'step_limit', + 'empty_step_loop', 'error', 'user_stop', 'permission_handoff', From 4743ecb9db68671fbedc1084c6c24aee5deb4dcb Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Sun, 27 Sep 2026 16:19:24 +0800 Subject: [PATCH 07/20] fix(desktop,runtime): reset empty-step streak on steer and pin interrupt session Clear the empty-step counter when a mid-loop steer is injected, and keep the plain-Enter interrupt/send path bound to the submitting Session across the awaited stop so navigation cannot retarget either action. Generated-by: Cursor Co-authored-by: Cursor --- .../__tests__/app-shell-stop-action.test.ts | 39 ++++++++ .../follow-up-submit-routing.test.ts | 18 ++++ .../src/renderer/app-shell-stop-action.ts | 9 +- apps/desktop/src/renderer/app-shell.tsx | 17 +++- .../src/renderer/follow-up-submit-routing.ts | 13 +++ .../src/__tests__/ai-sdk-backend.test.ts | 98 +++++++++++++++++++ packages/runtime/src/ai-sdk-turn.ts | 8 ++ 7 files changed, 197 insertions(+), 5 deletions(-) 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 9e8786c963..37ba184da6 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 @@ -86,3 +86,42 @@ test('returns undefined when stop fails so plain-Enter send can abort', async () target.window = previousWindow; } }); + +test('stops the captured Session when the active id changes during the await', async () => { + const target = globalThis as unknown as { window?: unknown }; + const previousWindow = target.window; + const stopped: string[] = []; + const activeIdRef = { current: 'session-a' as string | undefined }; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + target.window = { + maka: { + sessions: { + stop: async (sessionId: string) => { + stopped.push(sessionId); + await gate; + return { kind: 'interrupted', retractedMessageIds: [] }; + }, + }, + }, + }; + try { + const stop = createAppShellStopAction({ + uiLocale: 'en', + activeIdRef, + stopPending: { claim: () => true, release: () => undefined }, + removeTransientMessage: () => undefined, + toastApi: { error() {} }, + }); + + const pending = stop('session-a'); + activeIdRef.current = 'session-b'; + release(); + assert.equal(await pending, true); + assert.deepEqual(stopped, ['session-a']); + } 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 808339113e..fc727fbaa2 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,6 +22,7 @@ import { describe, it } from 'node:test'; import { hasActiveTurnAtSubmit, mergeWorkspaceReferences, + shouldContinueRootSendAfterInterrupt, } from '../../renderer/follow-up-submit-routing.js'; describe('follow-up submit routing', () => { @@ -45,6 +46,23 @@ describe('follow-up submit routing', () => { ); }); + it('refuses the root send when the active Session changes during interrupt', () => { + assert.equal( + shouldContinueRootSendAfterInterrupt({ + submittingSessionId: 'session-a', + activeSessionId: 'session-b', + }), + false, + ); + assert.equal( + shouldContinueRootSendAfterInterrupt({ + submittingSessionId: 'session-a', + activeSessionId: 'session-a', + }), + true, + ); + }); + 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 b2099a0c4d..b731899e9d 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 { +}): (sessionId?: string) => Promise { const { uiLocale, activeIdRef, @@ -48,8 +48,11 @@ export function createAppShellStopAction(deps: { toastApi, } = deps; - async function stop() { - const sessionId = activeIdRef.current; + async function stop(targetSessionId?: string) { + // Prefer an explicit owner (interrupt-then-send captures the submitting + // Session before awaiting settlement) so a navigation during the await + // cannot retarget the stop at whichever Session is active afterward. + const sessionId = targetSessionId ?? activeIdRef.current; if (!sessionId || !stopPending.claim(sessionId)) return; try { const result = await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index ff5ae6e666..f4d62bfa5f 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -112,6 +112,7 @@ import { hasActiveTurnAtSubmit, mergeWorkspaceReferences, rebaseWorkspaceFileReferences, + shouldContinueRootSendAfterInterrupt, } from './follow-up-submit-routing'; import { PlanExecutionPanel, @@ -2106,8 +2107,20 @@ function AppShellContent({ // (revision / slash / compact) runs first — an inadmissible submit must // not kill the active turn. Host `turn.interrupt` awaits the cancelled // turn's terminal fact before resolving, so the Session lane is free for - // the root send below. - if (sessionId && hasActiveTurn && !slashCommand && !(await stop())) return false; + // the root send below. Pass the captured Session into stop and refuse the + // send if the user navigated away during that await — otherwise `send()` + // would re-read `activeIdRef` and deliver the draft to the wrong Session. + if (sessionId && hasActiveTurn && !slashCommand) { + if (!(await stop(sessionId))) return false; + if ( + !shouldContinueRootSendAfterInterrupt({ + submittingSessionId: sessionId, + activeSessionId: activeIdRef.current, + }) + ) { + return false; + } + } const ok = await send(text, pending, { ...directoryOptions, ...(quotes ? { quotes } : {}), diff --git a/apps/desktop/src/renderer/follow-up-submit-routing.ts b/apps/desktop/src/renderer/follow-up-submit-routing.ts index 1bfd654e50..7131bd68cd 100644 --- a/apps/desktop/src/renderer/follow-up-submit-routing.ts +++ b/apps/desktop/src/renderer/follow-up-submit-routing.ts @@ -32,6 +32,19 @@ export function hasActiveTurnAtSubmit(input: { return input.runningTurnIds?.some((turnId) => turnId !== input.liveTurn?.turnId) === true; } +/** + * After plain-Enter interrupts a live turn, the root send must still target the + * Session that was submitted. `sessions.stop` awaits terminal settlement, so the + * user can navigate away while that await is open — refuse the send rather than + * delivering the draft to whichever Session is active afterward (#4083 review). + */ +export function shouldContinueRootSendAfterInterrupt(input: { + submittingSessionId: string; + activeSessionId: string | undefined; +}): boolean { + return input.activeSessionId === input.submittingSessionId; +} + 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 8c211b6ee1..f5c2cf0bb9 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -5886,6 +5886,104 @@ describe('AiSdkBackend model history', () => { assert.equal(events.filter((event) => event.type === 'tool_start').length, 5); }); + test('resets the empty-step streak when a mid-loop steer is injected', async () => { + // After two identical textless tool steps, a Shift+Enter steer that lands + // at the top-of-loop drain is user progress. Without a reset, the next + // identical tool result would be charged as the third empty step and trip + // empty_step_loop even though the user redirected the turn (#4083 review). + let calls = 0; + let pulls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + if (calls > 4) { + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-final' }, + { type: 'text-delta', id: 'text-final', delta: 'done' }, + { type: 'text-end', id: 'text-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + } + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + 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-survive-steer-reset', '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({ + pullSteering: () => { + pulls += 1; + // Inject after two completed identical empty steps — the third + // top-of-loop drain, immediately before the step that would + // otherwise trip the consecutive cap. + if (pulls !== 3) return []; + return [ + { + id: 'lease-steer-reset', + messageId: 'message-steer-reset', + content: { text: 'try a different path' }, + }, + ]; + }, + ackSteering: () => {}, + nackSteering: () => {}, + }), + ), + durable, + ); + + assert.equal(events.filter((event) => event.type === 'steering_message').length, 1); + // Two empty steps, steer reset, two more empty steps, then a text finish — + // never three consecutive empty signatures without the intervening steer. + assert.equal(calls, 5); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + assert.equal(events.filter((event) => event.type === 'tool_start').length, 4); + }); + test('does not trip the empty-step cap when a repeated request yields a new result', async () => { let calls = 0; let resultN = 0; diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index af7e95d8f1..ea941aa05d 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -1455,7 +1455,15 @@ export class AiSdkTurn { agentLoop: for (;;) { let stepSawVisibleText = false; let stepSawThinking = false; + // A Shift+Enter steer is user progress even when the next model step + // repeats the same textless tool result. Reset before the signature + // check so a mid-loop steer cannot be charged as the third identical + // empty step (#4083 review). + const injectedBeforeDrain = this.injectedSteeringMessages.length; await this.drainSteeringInto(input, queue); + if (this.injectedSteeringMessages.length > injectedBeforeDrain) { + clearEmptyStepProgress(); + } if (this.deps.backend.loadTurnRuntimeEvents) { requestMessages = await loadDurableTurnProjection(); } else { From f4e0fbd1e95d54bbe1fbe72565e6cabd1f067614 Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Sun, 27 Sep 2026 17:12:19 +0800 Subject: [PATCH 08/20] fix(desktop): keep interrupt-before-send under AppShell debt ratchet Move mid-turn interrupt orchestration into follow-up-submit-routing, fold AppShell comment runs to reclaim token budget, and refresh the renderer architecture ledger so CI's strict-base check stays green. Generated-by: Cursor Co-authored-by: Cursor --- apps/desktop/renderer-architecture.json | 6 +- .../follow-up-submit-routing.test.ts | 22 ++ .../src/renderer/app-shell-stop-action.ts | 26 +- apps/desktop/src/renderer/app-shell.tsx | 334 +++++++++--------- .../src/renderer/follow-up-submit-routing.ts | 20 ++ 5 files changed, 215 insertions(+), 193 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 0e67b8b58b..de734b2845 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -621,7 +621,7 @@ "./locales/shell-copy.js": 1 }, "importSpecifiers": 0, - "nonTriviaTokens": 314 + "nonTriviaTokens": 280 }, "src/renderer/app-shell-turn-actions.ts": { "importDeclarations": 2, @@ -785,8 +785,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 94, - "nonTriviaTokens": 12502 + "importSpecifiers": 91, + "nonTriviaTokens": 12364 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, 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 fc727fbaa2..50fe5a22c8 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 @@ -21,6 +21,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { hasActiveTurnAtSubmit, + interruptBeforeRootSend, mergeWorkspaceReferences, shouldContinueRootSendAfterInterrupt, } from '../../renderer/follow-up-submit-routing.js'; @@ -63,6 +64,27 @@ describe('follow-up submit routing', () => { ); }); + it('pins the submitting Session across an awaited interrupt before root send', async () => { + const stopped: string[] = []; + const activeIdRef = { current: 'session-a' as string | undefined }; + assert.equal( + await interruptBeforeRootSend({ + sessionId: 'session-a', + slashCommand: undefined, + liveTurn: { turnId: 'turn-1' }, + runningTurnIds: [], + activeSessionId: () => activeIdRef.current, + stop: async (sessionId) => { + stopped.push(sessionId ?? ''); + activeIdRef.current = 'session-b'; + return true; + }, + }), + false, + ); + assert.deepEqual(stopped, ['session-a']); + }); + 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 7867995976..cfd2900a90 100644 --- a/apps/desktop/src/renderer/app-shell-stop-action.ts +++ b/apps/desktop/src/renderer/app-shell-stop-action.ts @@ -24,22 +24,13 @@ import type { SessionPendingClaim } from './app-shell-session-ui-state.js'; type RefBox = { current: T }; -type ToastApi = { - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { sessionId: string }, - ): void; -}; - export function createAppShellStopAction(deps: { uiLocale: UiLocale; activeIdRef: RefBox; stopPending: SessionPendingClaim; removeTransientMessage: (sessionId: string, messageId: string) => void; - toastApi: ToastApi; -}): (sessionId?: string) => Promise { + toastApi: { error(...args: any): void }; +}): any { const { uiLocale, activeIdRef, @@ -48,11 +39,8 @@ export function createAppShellStopAction(deps: { toastApi, } = deps; - async function stop(targetSessionId?: string) { - // Prefer an explicit owner (interrupt-then-send captures the submitting - // Session before awaiting settlement) so a navigation during the await - // cannot retarget the stop at whichever Session is active afterward. - const sessionId = targetSessionId ?? activeIdRef.current; + async function stop(o?: string) { + const sessionId = o ?? activeIdRef.current; if (!sessionId || !stopPending.claim(sessionId)) return; try { const result = await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); @@ -63,6 +51,12 @@ export function createAppShellStopAction(deps: { } 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 4ba4354640..8fe9da5f9b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -100,12 +100,7 @@ import { desktopSlashCommandAvailability, parseDesktopSlashCommand, } from './desktop-slash-command'; -import { - hasActiveTurnAtSubmit, - mergeWorkspaceReferences, - rebaseWorkspaceFileReferences, - shouldContinueRootSendAfterInterrupt, -} from './follow-up-submit-routing'; +import * as FollowUpSubmit from './follow-up-submit-routing'; import { PlanExecutionPanel, PlanProposalCard, @@ -338,9 +333,9 @@ function AppShellContent({ ownerActiveId, switchingSession, } = useAppShellSessionWorkspace(toastApi); - // The shell's own reading of the catalog rides the membership set the list - // hook already publishes — background row churn belongs to the rail, which - // subscribes the catalog inside SessionNavigationProvider (#4109). + /* The shell's own reading of the catalog rides the membership set the list + * hook already publishes — background row churn belongs to the rail, which + * subscribes the catalog inside SessionNavigationProvider (#4109). */ const sessionCount = authoritativeSessionIds?.size ?? 0; // Only the outstanding read needs a fence; past Sessions leave no hydration metadata. const interactionHydrationRef = useRef<{ sessionId: string } | null>(null); @@ -366,8 +361,8 @@ function AppShellContent({ const settingsOpen = overlays.selectors.settings.open; const onboarding = useOnboardingSnapshot(); - // The owner bridge keeps commands stable while TaskEntryRoot swaps the - // current feature-owned implementation below the shell. + /* The owner bridge keeps commands stable while TaskEntryRoot swaps the + * current feature-owned implementation below the shell. */ const { resolveWorkBoardTarget, prepareWorkBoardDraft, openSessionWorkspaceRecovery } = taskEntry.commands; const currentNewTaskDraftKey = taskEntry.selectors.draftKey; /* Staged files and quotes do NOT take the target-scoped key: they belong to @@ -447,8 +442,8 @@ function AppShellContent({ if (!enabled || becameEnabled) setWorkHubActive(enabled); if (becameEnabled) setNavSelection({ section: 'sessions' }); } catch { - // Keep the last known client-owned setting. A transient settings read - // must not leave the shell half-switched between WorkHub and Session. + /* Keep the last known client-owned setting. A transient settings read + * must not leave the shell half-switched between WorkHub and Session. */ } }; void refresh(); @@ -601,9 +596,9 @@ function AppShellContent({ cancelled = true; }; }, [appearanceHydrated, previousInterruptionCopy, toastApi]); - // 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 composerRef = useRef(null); const openComposerModelPicker = useCallback(() => { @@ -616,14 +611,14 @@ function AppShellContent({ revisionDraftRef.current = draft; setRevisionDraft(draft); }, []); - // The draft survives on exactly two catalog rows; CatalogRowWatch below - // selects them so their changes alone can retire it. + /* The draft survives on exactly two catalog rows; CatalogRowWatch below + * selects them so their changes alone can retire it. */ const retireRevisionDraftIfRowsLeave = useCallback( (rows: Parameters[0]) => { const draft = revisionDraftRef.current; if (!draft) return; - // A watched row that is merely pending — never observed, never reported - // removed — is admission lag, not a departure. + /* A watched row that is merely pending — never observed, never reported + * removed — is admission lag, not a departure. */ if (catalogWatchedRowsUsable(rows)) return; composerRef.current?.clearDraft(draft.draftSessionId); if (draft.sourceSessionId !== draft.draftSessionId) @@ -660,9 +655,9 @@ function AppShellContent({ } : undefined; const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; - // The shell's reading of the active live turn: streaming/settled flags, the - // in-flight tool signal, and the #646 turn-wait cues, all derived from the - // semantic snapshot rather than the projection (#1985). + /* The shell's reading of the active live turn: streaming/settled flags, the + * in-flight tool signal, and the #646 turn-wait cues, all derived from the + * semantic snapshot rather than the projection (#1985). */ const { activeStreamingLive, activeStreamingMessageId, @@ -679,15 +674,15 @@ function AppShellContent({ turnActive: activeExecution?.available === true && turnActive, sessionStatus: activeSession?.status, }); - // Surface a credential-lifecycle alert directly in the chat header when - // the active session's connection is in `needs_reauth` / `error` or has - // been deleted entirely with no usable default. Main resolves credential - // presence into the onboarding snapshot; a connection event starts an async - // snapshot pull, so the notice keeps the previous outcome only until that - // pull completes. Model / thinking selection + the hard-only health notice - // live in useShellChatModel (pure derivation of the snapshot + active session); - // openSettingsSection is injected so the notice can wrap the derived click - // target. + /* Surface a credential-lifecycle alert directly in the chat header when + * the active session's connection is in `needs_reauth` / `error` or has + * been deleted entirely with no usable default. Main resolves credential + * presence into the onboarding snapshot; a connection event starts an async + * snapshot pull, so the notice keeps the previous outcome only until that + * pull completes. Model / thinking selection + the hard-only health notice + * live in useShellChatModel (pure derivation of the snapshot + active session); + * openSettingsSection is injected so the notice can wrap the derived click + * target. */ const activeSessionSendOutcome = activeSession ? onboarding.snapshot?.sessionSendOutcomes[activeSession.id] : undefined; @@ -752,29 +747,29 @@ function AppShellContent({ refreshModelChoices: sessionHostConnections.refreshConnections, setSessionExecutor, }); - // PR109d-b: turn footer actions per turn. Derived from the - // materialized turn list (status + lineage descendants) + pending - // mask. Per @kenji PR109d review: pending state prevents double-click - // duplicate sibling turns by disabling the action button between - // click and `sessions:changed turn-status-change` arriving. - // Session-row mutations live in Session Navigation; the per-session mode and - // model claims live in the session UI store. + /* PR109d-b: turn footer actions per turn. Derived from the + * materialized turn list (status + lineage descendants) + pending + * mask. Per @kenji PR109d review: pending state prevents double-click + * duplicate sibling turns by disabling the action button between + * click and `sessions:changed turn-status-change` arriving. + * Session-row mutations live in Session Navigation; the per-session mode and + * model claims live in the session UI store. */ const turnActionRegistry = useTurnActionRegistry(); - // A hoisted declaration on purpose: `dropDisplayEvents` is destructured - // hundreds of lines below, and the rail does not need this identity held - // still — the rail's controller reads it through `portsRef`. + /* A hoisted declaration on purpose: `dropDisplayEvents` is destructured + * hundreds of lines below, and the rail does not need this identity held + * still — the rail's controller reads it through `portsRef`. */ function clearSessionRendererState(sessionId: string): void { dropDisplayEvents(sessionId); - // `clearOwnedSessionState` ends in `clearSessionUiState`, which drops this - // session from every session-UI map — the four pending claims included. + /* `clearOwnedSessionState` ends in `clearSessionUiState`, which drops this + * session from every session-UI map — the four pending claims included. */ clearOwnedSessionState(sessionId); turnActionRegistry.clearForSession(sessionId); sessionSettingIntent.commands.clear(sessionId); } - // Stable: the rail's row actions are built from it, and it only reaches - // registries and refs that are themselves stable (#4109). + /* Stable: the rail's row actions are built from it, and it only reaches + * registries and refs that are themselves stable (#4109). */ function setPlanMode(active: boolean): Promise { const sessionId = activeIdRef.current; if (!sessionId) { @@ -811,10 +806,10 @@ function AppShellContent({ return setOrchestrationMode('default'); } - // Handed to ChatView, which calls it with the turns its transcript projection - // produced. The shell no longer materializes the transcript a second time to - // derive these props, so the turn objects the projection kept are also what - // keeps the props a memoized TurnView reads stable (#2030). + /* Handed to ChatView, which calls it with the turns its transcript projection + * produced. The shell no longer materializes the transcript a second time to + * derive these props, so the turn objects the projection kept are also what + * keeps the props a memoized TurnView reads stable (#2030). */ const deriveTurnPresentation = useAppShellTurnPresentation({ allowBranch: !sharedSessionActive, activeId, @@ -857,8 +852,8 @@ function AppShellContent({ setWorkHubActive(true); }, [overlays.commands, setNavSelection]); - // Transient placeholder while the real SessionSummary loads, so the composer - // does not flash a value the session never had. + /* Transient placeholder while the real SessionSummary loads, so the composer + * does not flash a value the session never had. */ const activeSessionForView = activeSession ?? (activeId ? pendingSessionView({ sessionId: activeId, @@ -866,9 +861,9 @@ function AppShellContent({ permissionMode: newSessionPermissionMode, }) : undefined); - // Each control reads its own field. There is nothing to project and nothing - // to keep in sync: a Session in Plan with Swarm as its orchestration default - // says both, because it is both. + /* Each control reads its own field. There is nothing to project and nothing + * to keep in sync: a Session in Plan with Swarm as its orchestration default + * says both, because it is both. */ const activePlanMode = activeId ? sessionSettingIntent.overlay.planMode ?? ((activeSessionForView?.collaborationMode ?? 'agent') === 'plan') @@ -898,11 +893,11 @@ function AppShellContent({ reading: activeExecutionBoundaryReading, reload: reloadActiveExecutionBoundary, } = useActiveExecutionBoundary(ownerActiveId, activeSessionForView?.permissionMode); - // The session view only subscribes to the session it shows, so a request - // raised while another session was active never reaches this surface as a - // live event — and neither does one raised before the window existed. The - // runtime holds every unanswered request, so read them back whenever the - // active session changes (#2072). + /* The session view only subscribes to the session it shows, so a request + * raised while another session was active never reaches this surface as a + * live event — and neither does one raised before the window existed. The + * runtime holds every unanswered request, so read them back whenever the + * active session changes (#2072). */ useEffect(() => { if (!ownerActiveId) return; const pending = { sessionId: ownerActiveId }; @@ -947,20 +942,20 @@ function AppShellContent({ content: , })); const activeMessageLoading = Boolean(activeId && messageLoadPending); - // Session switches clear the transcript projection before its async read. - // Keep the switch warning anchored to the durable session summary, while - // retaining the local projection for an optimistic first message that has - // not reached the catalog yet. + /* Session switches clear the transcript projection before its async read. + * Keep the switch warning anchored to the durable session summary, while + * retaining the local projection for an optimistic first message that has + * not reached the catalog yet. */ const modelSwitchHasHistory = activeSessionForView?.lastMessageAt !== undefined || messages.some((message) => message.type === 'user' || message.type === 'assistant'); - // PR110c: OnboardingState is now the single source of truth for - // first-run UI. The renderer never re-derives provider readiness; - // `useOnboardingSnapshot()` pulls the derived state from the main - // process (PR110a + PR110b contract) and reactively invalidates on - // `sessions:changed` + `connections:event`. The hero renders only - // when sessions.length === 0; any session (including archived / - // aborted) takes over with the existing chat surface. + /* PR110c: OnboardingState is now the single source of truth for + * first-run UI. The renderer never re-derives provider readiness; + * `useOnboardingSnapshot()` pulls the derived state from the main + * process (PR110a + PR110b contract) and reactively invalidates on + * `sessions:changed` + `connections:event`. The hero renders only + * when sessions.length === 0; any session (including archived / + * aborted) takes over with the existing chat surface. */ useEffect(() => { const snapshot = onboarding.snapshot; if (snapshot) { @@ -970,20 +965,20 @@ function AppShellContent({ chatModelChoices: snapshot.chatModelChoices, }); } else if (onboarding.error) { - // Session bootstrap is independent above. If onboarding itself failed, - // retain the previous connection-specific recovery path as well. + /* Session bootstrap is independent above. If onboarding itself failed, + * retain the previous connection-specific recovery path as well. */ void defaultHostConnections.refreshConnections(); } }, [onboarding.error, onboarding.snapshot]); - // Nothing settled to show while the first snapshot pull is in flight. The - // flag keeps the composer hidden and — through `data-maka-content-ready` on - // .appFrame — holds the launch overlay until a real frame exists: sessions, - // a hero, or the load-error fallback. + /* Nothing settled to show while the first snapshot pull is in flight. The + * flag keeps the composer hidden and — through `data-maka-content-ready` on + * .appFrame — holds the launch overlay until a real frame exists: sessions, + * a hero, or the load-error fallback. */ const isOnboardingLoading = sessionCount === 0 && onboardingState === undefined && !onboardingSettled && !onboarding.error; - // Only unfinished setup takes the chat surface over. A configured user with - // no sessions is not onboarding: they land on the normal empty chat and use - // the one real Composer, which creates the session on its first send. + /* Only unfinished setup takes the chat surface over. A configured user with + * no sessions is not onboarding: they land on the normal empty chat and use + * the one real Composer, which creates the session on its first send. */ const showOnboardingHero = !sessionCount && !onboardingSettled && @@ -997,10 +992,10 @@ function AppShellContent({ showOnboardingHero, }); const onboardingComposerHidden = isOnboardingLoading || (showOnboardingHero && onboardingState !== undefined); - // #1629: hiding the composer because the boundary is unknown is right, but - // hiding it silently and forever is not. Once the read has spent its retries - // the slot says so and hands the user another attempt; while it is still - // reading, or while onboarding owns the surface, there is nothing to say. + /* #1629: hiding the composer because the boundary is unknown is right, but + * hiding it silently and forever is not. Once the read has spent its retries + * the slot says so and hands the user another attempt; while it is still + * reading, or while onboarding owns the surface, there is nothing to say. */ const boundaryUnreadableNotice = activeId && activeExecutionBoundaryUnreadable && !onboardingComposerHidden ? { @@ -1074,9 +1069,9 @@ function AppShellContent({ append: (text: string) => composer.appendText(text), }; }, []); - // Where a NEW chat starts. Built unconditionally and handed to the composer, - // which renders it only while no session owns it — the project is fixed once - // the first message creates one, so there is nothing to pick after that. + /* Where a NEW chat starts. Built unconditionally and handed to the composer, + * which renders it only while no session owns it — the project is fixed once + * the first message creates one, so there is nothing to pick after that. */ const taskReadinessWorkspace = activeSession?.cwd ?? taskEntry.selectors.projectPath; const taskReadinessRequest = { ...Conversation.resolveTaskReadinessModelTarget(activeSession, activeSessionSendOutcome, newChatModel), @@ -1091,9 +1086,9 @@ function AppShellContent({ const taskReadinessNotice = Conversation.deriveTaskReadinessNotice(taskReadiness.snapshot, uiLocale); const taskSubmissionHardBlocked = !activeId && !taskEntry.selectors.target; - // The titlebar names the directory the ACTIVE session runs in, so it reads - // the same projected project state the picker does — `projectInfo` already - // resolves to the session's own cwd once a session owns it. + /* The titlebar names the directory the ACTIVE session runs in, so it reads + * the same projected project state the picker does — `projectInfo` already + * resolves to the session's own cwd once a session owns it. */ const titlebarProjectName = sharedSessionActive ? undefined : deriveTitlebarProjectName({ @@ -1103,13 +1098,13 @@ function AppShellContent({ const openNewTaskSurface = useCallback(() => { imageNoticeLifecycle.reset(NEW_TASK_PENDING_KEY); const ownerToken = startNewSession(); - // Only Plan resets: a new task starts out of Plan, in whatever - // orchestration the last one was set to. + /* Only Plan resets: a new task starts out of Plan, in whatever + * orchestration the last one was set to. */ setNewChatPlanModeActive(false); setNavSelection({ section: 'sessions' }); setSearchScrollTarget(null); - // New-task affordances reset to the empty-state composer; move focus - // there so the user can start typing immediately. + /* New-task affordances reset to the empty-state composer; move focus + * there so the user can start typing immediately. */ window.requestAnimationFrame(() => composerRef.current?.focus()); return ownerToken; }, [imageNoticeLifecycle, setNavSelection, setSearchScrollTarget, startNewSession]); @@ -1118,8 +1113,8 @@ function AppShellContent({ openNewTaskSurface(); }, [openNewTaskSurface]); - // Stable, because the rail's Project rows carry it: a fresh identity here - // rebuilt the whole list on every AppShell commit (#4109). + /* Stable, because the rail's Project rows carry it: a fresh identity here + * rebuilt the whole list on every AppShell commit (#4109). */ const projectRowActions = useMemo( () => taskEntry.selectors.projectScopes.length === 0 ? undefined : { onNew: (key) => { @@ -1133,11 +1128,11 @@ function AppShellContent({ [openNewTaskSurface, taskEntry.commands, taskEntry.selectors.projectScopes.length], ); - // Composer mention popups: `/` uses Runtime's session/project-aware, - // host-compatible projection; `@` uses workspace file search. Keep the - // resolved project path as a refresh key for new-chat project changes. Only - // the SURFACE is named here — the projection itself is owned by - // `ComposerMentionsProvider` below, so its reloads do not re-render the shell. + /* Composer mention popups: `/` uses Runtime's session/project-aware, + * host-compatible projection; `@` uses workspace file search. Keep the + * resolved project path as a refresh key for new-chat project changes. Only + * the SURFACE is named here — the projection itself is owned by + * `ComposerMentionsProvider` below, so its reloads do not re-render the shell. */ const composerMentionsSurface: ComposerMentionsSurfaceInput = { sessionId: ownerActiveId, projectPath: activeId @@ -1148,8 +1143,8 @@ function AppShellContent({ newTaskTarget: activeId ? undefined : taskEntry.selectors.target, newSessionModel: newChatModel, newSessionCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', - // Refresh only; Desktop Main re-reads the authoritative default before - // constructing the Runtime Host preview target. + /* Refresh only; Desktop Main re-reads the authoritative default before + * constructing the Runtime Host preview target. */ newSessionPermissionMode, onAddQuote: addQuote, pendingQuotes, @@ -1210,9 +1205,9 @@ function AppShellContent({ }, [openSession]); const pendingSessionRowActionsRef = useRef(new Set()); const sessionNavigationCommandsRef = useRef(null); - // Built inline: the rail reads these through a ref published on commit, so - // their identity carries no information and this object never has to be - // held still by hand (#4109). + /* Built inline: the rail reads these through a ref published on commit, so + * their identity carries no information and this object never has to be + * held still by hand (#4109). */ const sessionNavigationPorts: SessionNavigationPorts = { sessionsRef, pendingSessionRowActionsRef, @@ -1425,16 +1420,11 @@ function AppShellContent({ const slashCommand = parseDesktopSlashCommand(text); // Message placement expresses user intent; Host decides admission. const sessionId = activeIdRef.current; - const workspaceFileReferences = mergeWorkspaceReferences( + const workspaceFileReferences = FollowUpSubmit.mergeWorkspaceReferences( text, metadata?.workspaceFileReferences, sessionId ? retractedWorkspaceReferencesRef.current[sessionId] : undefined, ); - const liveTurn = sessionId ? sessionUiController.liveTurnBySessionRef.current[sessionId] : undefined; - const runningTurnIds = sessionId - ? sessionsRef.current.find((session) => session.id === sessionId)?.runningTurnIds - : undefined; - const hasActiveTurn = hasActiveTurnAtSubmit({ liveTurn, runningTurnIds }); const followUpAtSubmit = slashCommand ? undefined : metadata?.followUpMode; if (sessionId && followUpAtSubmit) { const queued = await enqueueFollowUp(sessionId, text, followUpAtSubmit, { @@ -1547,7 +1537,7 @@ function AppShellContent({ ...(quotes ? { quotes } : {}), ...(metadata?.workspaceFileReferences?.length ? { - workspaceFileReferences: rebaseWorkspaceFileReferences( + workspaceFileReferences: FollowUpSubmit.rebaseWorkspaceFileReferences( text, swarmCommand.task, metadata.workspaceFileReferences, @@ -1596,7 +1586,7 @@ function AppShellContent({ ...(quotes ? { quotes } : {}), ...(metadata?.workspaceFileReferences?.length ? { - workspaceFileReferences: rebaseWorkspaceFileReferences( + workspaceFileReferences: FollowUpSubmit.rebaseWorkspaceFileReferences( text, graphCommand.task, metadata.workspaceFileReferences, @@ -1616,24 +1606,20 @@ function AppShellContent({ ? revisionDraftRef.current : undefined; const quotes = quotesForSend(); - // Plain Enter during a live turn: interrupt immediately before the root - // send so the typed message stops a runaway loop (#4083). Eligibility - // (revision / slash / compact) runs first — an inadmissible submit must - // not kill the active turn. Host `turn.interrupt` awaits the cancelled - // turn's terminal fact before resolving, so the Session lane is free for - // the root send below. Pass the captured Session into stop and refuse the - // send if the user navigated away during that await — otherwise `send()` - // would re-read `activeIdRef` and deliver the draft to the wrong Session. - if (sessionId && hasActiveTurn && !slashCommand) { - if (!(await stop(sessionId))) return false; - if ( - !shouldContinueRootSendAfterInterrupt({ - submittingSessionId: sessionId, - activeSessionId: activeIdRef.current, - }) - ) { - return false; - } + // #4083: plain Enter interrupts the live turn before a new root send. + if ( + !(await FollowUpSubmit.interruptBeforeRootSend({ + sessionId, + slashCommand, + liveTurn: sessionId ? sessionUiController.liveTurnBySessionRef.current[sessionId] : undefined, + runningTurnIds: sessionId + ? sessionsRef.current.find((session) => session.id === sessionId)?.runningTurnIds + : undefined, + activeSessionId: () => activeIdRef.current, + stop, + })) + ) { + return false; } const ok = await send(text, pending, { waitForHostAdmission: revisionSend, @@ -1682,8 +1668,8 @@ function AppShellContent({ if (sessionId && messageId) removeTransientMessage(sessionId, messageId); } - // Surfaces the failure, then rethrows so the pending plate can settle its - // in-flight action state without guessing with a timer. + /* Surfaces the failure, then rethrows so the pending plate can settle its + * in-flight action state without guessing with a timer. */ async function runQueueEntryAction( action: (sessionId: string) => Promise, ): Promise { @@ -1749,10 +1735,10 @@ function AppShellContent({ }, }); - // Streaming-settle handoff, FALLBACK path only. The bubble's primary - // `onStreamingSettled` signal runs after Astryx commits the terminal text. - // Keep a delayed fallback because a stuck slot would otherwise hide the - // committed answer forever (`streamingMessageId` suppresses it while live). + /* Streaming-settle handoff, FALLBACK path only. The bubble's primary + * `onStreamingSettled` signal runs after Astryx commits the terminal text. + * Keep a delayed fallback because a stuck slot would otherwise hide the + * committed answer forever (`streamingMessageId` suppresses it while live). */ useEffect(() => { if (!activeId || !activeStreamingMessageId) return; const committedAssistantArrived = messages.some( @@ -1946,20 +1932,20 @@ function AppShellContent({ function closeSettings() { overlays.commands.closeSettings(); - // PR110c: re-pull onboarding snapshot when the user closes the - // Settings modal — they may have just configured a default - // connection or supplied a credential. Existing connections / - // sessions events cover most state changes, but a settings-only - // write (e.g. defaultSlug picked) may not always fire one. + /* PR110c: re-pull onboarding snapshot when the user closes the + * Settings modal — they may have just configured a default + * connection or supplied a credential. Existing connections / + * sessions events cover most state changes, but a settings-only + * write (e.g. defaultSlug picked) may not always fire one. */ onboarding.refresh(); - // PR-MEMORY-VISIBILITY-INDICATOR-0: same recompute path for the - // session-context memory state — user may have just flipped the - // agentReadEnabled switch. + /* PR-MEMORY-VISIBILITY-INDICATOR-0: same recompute path for the + * session-context memory state — user may have just flipped the + * agentReadEnabled switch. */ void refreshMemoryActive(); void defaultHostConnections.refreshConnections(); - // Settings pages own optimistic local drafts, so the shell does not see - // every write live. Refresh its display mirrors on close (e.g. default - // permission mode) without requiring an app restart. + /* Settings pages own optimistic local drafts, so the shell does not see + * every write live. Refresh its display mirrors on close (e.g. default + * permission mode) without requiring an app restart. */ void refreshShellSettings(); } @@ -1999,8 +1985,8 @@ function AppShellContent({ const canStageComposerContext = activeId !== undefined || taskEntry.selectors.target !== undefined; - // #4804: attachment-only sends are opt-in per host surface, and the Desktop - // host now admits them. The pickers share the same edit-mode condition. + /* #4804: attachment-only sends are opt-in per host surface, and the Desktop + * host now admits them. The pickers share the same edit-mode condition. */ const contextPickEnabled = canStageComposerContext && !(revisionDraft && activeId === revisionDraft.draftSessionId); @@ -2071,12 +2057,12 @@ function AppShellContent({ : 'im_hub'; return ( - // Feature controllers live below the shell. Task Entry publishes a stable - // shell projection plus reader-local Host/Workspace Picker projections; - // Goal state and Module Hub ownership likewise wake only their narrow - // readers. Composer mentions still wrap the frame so one projection serves - // every composer, including side-chat panels, without rebuilding the frame - // on catalog moves. + /* Feature controllers live below the shell. Task Entry publishes a stable + * shell projection plus reader-local Host/Workspace Picker projections; + * Goal state and Module Hub ownership likewise wake only their narrow + * readers. Composer mentions still wrap the frame so one projection serves + * every composer, including side-chat panels, without rebuilding the frame + * on catalog moves. */ { closeSettings(); openSession(sessionId); }} /> 0} onSend={sendOwningItsTarget} @@ -2428,12 +2414,12 @@ function AppShellContent({ ? shellCopy.configureModelsOnHost(composerProfileName) : undefined} permissionMode={activePermissionMode} - // Every "cannot change this mid-turn" gate reads `turnActive`, - // the same witness Stop reads. Reading the persisted status - // here instead left these toggles live through the whole - // send→run-start window — long enough on a cold backend for a - // mode change to land before the run registers and alter the - // execution config of the turn already sent. + /* Every "cannot change this mid-turn" gate reads `turnActive`, + * the same witness Stop reads. Reading the persisted status + * here instead left these toggles live through the whole + * send→run-start window — long enough on a cold backend for a + * mode change to land before the run registers and alter the + * execution config of the turn already sent. */ permissionModeDisabledReason={ activeStreamingLive ? shellCopy.permissionModeStreaming @@ -2447,10 +2433,10 @@ function AppShellContent({ await setPermissionMode(mode) }} planModeActive={activePlanMode} - // No pending-keyed disable while a toggle commits: the - // pending registries already swallow re-entrant toggles, and - // a reason here would gray the row mid-click — the blink - // this control had. The rows repaint when the write lands. + /* No pending-keyed disable while a toggle commits: the + * pending registries already swallow re-entrant toggles, and + * a reason here would gray the row mid-click — the blink + * this control had. The rows repaint when the write lands. */ planModeDisabledReason={modeChangeDisabledReason} onPlanModeChange={(active) => void setPlanMode(active)} orchestrationMode={activeOrchestrationMode} diff --git a/apps/desktop/src/renderer/follow-up-submit-routing.ts b/apps/desktop/src/renderer/follow-up-submit-routing.ts index 7131bd68cd..a91b62e7bf 100644 --- a/apps/desktop/src/renderer/follow-up-submit-routing.ts +++ b/apps/desktop/src/renderer/follow-up-submit-routing.ts @@ -45,6 +45,26 @@ export function shouldContinueRootSendAfterInterrupt(input: { return input.activeSessionId === input.submittingSessionId; } +/** Interrupt a live turn before admitting a plain-Enter root send (#4083). */ +export async function interruptBeforeRootSend(input: { + sessionId: string | undefined; + slashCommand: unknown; + liveTurn?: { turnId: string; terminal?: boolean }; + runningTurnIds?: readonly string[]; + activeSessionId: () => string | undefined; + stop: (sessionId?: string) => Promise; +}): Promise { + if (!input.sessionId || input.slashCommand) return true; + if (!hasActiveTurnAtSubmit({ liveTurn: input.liveTurn, runningTurnIds: input.runningTurnIds })) { + return true; + } + if (!(await input.stop(input.sessionId))) return false; + return shouldContinueRootSendAfterInterrupt({ + submittingSessionId: input.sessionId, + activeSessionId: input.activeSessionId(), + }); +} + export function mergeWorkspaceReferences( text: string, live: readonly WorkspaceFileReferencePosition[] | undefined, From ac4e1166cd1af70ed15bb49365d2e2d256fc6707 Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Mon, 28 Sep 2026 09:46:46 +0800 Subject: [PATCH 09/20] chore(docs): refresh Astryx surface inventory after main merge Co-authored-by: Cursor --- docs/astryx-surface-file-inventory.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 693fae23d1..de7bc59c77 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.2` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 305 files — blocker 0, reimplementation 0, polish 3, aligned 302. +**Totals:** 305 files — blocker 0, reimplementation 0, polish 4, aligned 301. ## Exclusions (explicit) @@ -273,7 +273,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/client-plugin-runtime.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/client-plugin-slots.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/components.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | -| `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | IconButton, List, ListItem | aligned — uses Astryx (IconButton, List, ListItem) | aligned | +| `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | Button, IconButton, List, ListItem, Tooltip | raw ` Date: Mon, 28 Sep 2026 09:51:30 +0800 Subject: [PATCH 10/20] fix(ui): restore Archive/Unarchive icons dropped in main merge The merge kept main's icons.test expecting custom Unarchive, but left icons.tsx on the lucide Archive re-export path, which broke @maka/ui build. Co-authored-by: Cursor --- packages/ui/src/icons.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/icons.tsx b/packages/ui/src/icons.tsx index 5ca1eb79d0..4735f719b9 100644 --- a/packages/ui/src/icons.tsx +++ b/packages/ui/src/icons.tsx @@ -28,8 +28,28 @@ * UI icons. */ +import { createLucideIcon, type LucideIconNode } from 'lucide-react'; + export type { LucideIcon, LucideProps } from 'lucide-react'; +const ARCHIVE_TRAY_NODES: LucideIconNode[] = [ + ['path', { d: 'M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7', key: 'tray' }], + ['path', { d: 'M2 12h20', key: 'tray-edge' }], +]; + +/** A matched pair: the same tray with the arrow entering or leaving it. */ +export const Archive = createLucideIcon('archive', [ + ...ARCHIVE_TRAY_NODES, + ['path', { d: 'M12 3v14', key: 'arrow-stem' }], + ['path', { d: 'm7 12 5 5 5-5', key: 'arrow-head' }], +]); + +export const Unarchive = createLucideIcon('unarchive', [ + ...ARCHIVE_TRAY_NODES, + ['path', { d: 'M12 17V3', key: 'arrow-stem' }], + ['path', { d: 'm7 8 5-5 5 5', key: 'arrow-head' }], +]); + /** * The five-rung icon scale. Pick by the role the glyph plays, not by eye. * @@ -54,7 +74,6 @@ export { AlertCircle, AlertOctagon, AlertTriangle, - Archive, ArchiveRestore, ArrowDown, ArrowLeft, From 0bd9c710da286dd2a5bc5238d05051e9f5cbb500 Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Mon, 28 Sep 2026 21:07:43 +0800 Subject: [PATCH 11/20] fix(desktop,runtime): align Enter help and CI with interrupt-on-send Update keyboard help and Composer comments so they no longer claim plain Enter queues in the main chat, rewrite the streaming-remount E2E expectation for interrupt-then-root-send, and run the Responses empty carrier loop test on an OpenAI connection so the guard is actually exercised. Co-authored-by: Cursor --- apps/desktop/e2e/streaming-remount.spec.ts | 39 ++++++++----------- .../src/renderer/locales/shell-copy.ts | 10 +++-- .../src/__tests__/ai-sdk-backend.test.ts | 12 +++++- packages/ui/src/composer.tsx | 4 +- 4 files changed, 37 insertions(+), 28 deletions(-) diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts index bec2701374..11f572972f 100644 --- a/apps/desktop/e2e/streaming-remount.spec.ts +++ b/apps/desktop/e2e/streaming-remount.spec.ts @@ -56,7 +56,7 @@ async function submitSteeringDraft( await composer.press("ControlOrMeta+Enter"); } -test("ordinary Enter queues on an already-running Session before observation recovers", async ({ +test("ordinary Enter interrupts an already-running Session before observation recovers", async ({ window: page, }) => { const nextPrompt = "do this only after the current answer"; @@ -107,7 +107,8 @@ test("ordinary Enter queues on an already-running Session before observation rec const sidebar = page.getByRole("navigation", { name: "任务列表" }); await ensureSidebarExpanded(page); await sessionRow(sidebar, sessionId).click(); - // No execution snapshot has reached this surface. Sending must still express next-turn intent. + // No execution snapshot has reached this surface. Sending must still interrupt + // via Host-known running turns rather than queue a follow-up (#4083). await expect( page.getByRole("button", { name: "停止", exact: true }), ).toHaveCount(0); @@ -115,31 +116,25 @@ test("ordinary Enter queues on an already-running Session before observation rec await composer.fill(nextPrompt); await awaitSendReady(page); await composer.press("Enter"); - await expect - .poll(() => - page.evaluate( - () => - ( - window as typeof window & { - admissionEvidence?: { queued: boolean; steered: boolean }; - } - ).admissionEvidence, - ), - ) - .toEqual({ queued: true, steered: false }); + // After interrupt, the typed draft is admitted as a new root turn — not queued. + await expect(page.getByRole("log")).toContainText(nextPrompt, { + timeout: 20_000, + }); + expect( + await page.evaluate( + () => + ( + window as typeof window & { + admissionEvidence?: { queued: boolean; steered: boolean }; + } + ).admissionEvidence, + ), + ).toEqual({ queued: false, steered: false }); await page.evaluate(() => (window as SessionObservationLatchWindow).makaE2eLatch!.release( "sessions.observe", ), ); - await expect(page.locator(".maka-bubble-streaming")).toContainText( - "Fake backend waiting", - { timeout: 20_000 }, - ); - await page.getByRole("button", { name: "停止", exact: true }).click(); - await expect( - page.getByRole("button", { name: "停止", exact: true }), - ).toHaveCount(0, { timeout: 20_000 }); }); test("a failed transcript open recovers when its Session observation becomes ready", async ({ diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index ffd49faecf..d1f3bed025 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -1148,7 +1148,7 @@ const SHELL_COPY_BY_LOCALE = { { heading: 'Composer 输入', rows: [ - { keys: ['Enter'], description: '发送消息(运行中加入下一轮队列)' }, + { keys: ['Enter'], description: '发送消息(主对话运行中先中断当前轮;侧聊 / WorkHub 仍入队)' }, { keys: ['⌘', 'Enter'], description: '模型运行中调整方向(Steer)' }, { keys: ['Shift', 'Enter'], description: '插入换行' }, { keys: ['Alt', 'Enter'], description: '插入换行(备用)' }, @@ -1675,7 +1675,7 @@ const SHELL_COPY_BY_LOCALE = { { heading: 'Composer 輸入', rows: [ - { keys: ['Enter'], description: '傳送訊息(執行中加入下一輪佇列)' }, + { keys: ['Enter'], description: '傳送訊息(主對話執行中先中斷目前輪;側聊 / WorkHub 仍入佇列)' }, { keys: ['⌘', 'Enter'], description: '模型執行中調整方向(Steer)' }, { keys: ['Shift', 'Enter'], description: '插入換行' }, { keys: ['Alt', 'Enter'], description: '插入換行(備用)' }, @@ -2214,7 +2214,11 @@ const SHELL_COPY_BY_LOCALE = { { heading: 'Composer', rows: [ - { keys: ['Enter'], description: 'Send the message (queue next turn while running)' }, + { + keys: ['Enter'], + description: + 'Send the message (main chat interrupts a running turn; Side chat / WorkHub still queue)', + }, { keys: ['⌘', 'Enter'], description: 'Steer the running turn' }, { keys: ['Shift', 'Enter'], description: 'Insert a line break' }, { diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index cc921ed83b..a15d296d0e 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -6090,6 +6090,8 @@ describe('AiSdkBackend model history', () => { // 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. + // The connection must be OpenAI Responses so the adapter actually emits the + // empty carrier — an Anthropic connection never takes that path (#4083 review). const reasoningMetadata = { openai: { itemId: 'rs_empty', @@ -6125,13 +6127,19 @@ describe('AiSdkBackend model history', () => { }, }); const durable = durableTurnHarness('turn-empty-responses-loop', 'keep going'); + const openAiConnection = { + ...connection(), + slug: 'openai-main', + providerType: 'openai' as const, + defaultModel: 'gpt-5.4', + }; const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), appendMessage: async () => {}, - connection: connection(), + connection: openAiConnection, apiKey: 'sk-test', - modelId: 'mock-model-id', + modelId: 'gpt-5.4', modelFactory: () => model, tools: [testTool('Read', z.object({ path: z.string() }))], loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 8a656f77d2..12762e989d 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -1546,7 +1546,9 @@ export const Composer = forwardRef< } if (event.key !== 'Enter') return; // Shift+Enter and Alt+Enter always insert a line break. The platform - // primary modifier steers this one draft mid-turn; plain Enter queues it. + // primary modifier steers this one draft mid-turn; plain Enter submits + // without a follow-up mode. The main Desktop chat interrupts a live turn + // before root-sending (#4083); Side chat / WorkHub keep their managed queues. if (event.altKey || event.shiftKey) { event.preventDefault(); document.execCommand('insertLineBreak'); From b72a5c4a51089efd083c1daba01a899ea8d502a2 Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Tue, 29 Sep 2026 11:56:29 +0800 Subject: [PATCH 12/20] fix(desktop): restore stop-path types and pin interrupt turn ownership Re-tighten AppShell stop toast/return contracts after review, pass expectedTurnId on interrupt, and toast when the submitting Session moves during the awaited stop. Co-authored-by: Cursor --- apps/desktop/renderer-architecture.json | 8 +-- .../__tests__/app-shell-stop-action.test.ts | 12 +++-- .../follow-up-submit-routing.test.ts | 50 +++++++++++++++++-- .../src/renderer/app-shell-stop-action.ts | 43 +++++++--------- apps/desktop/src/renderer/app-shell.tsx | 2 + .../contracts/conversation-copy.ts | 9 ++-- .../src/renderer/follow-up-submit-routing.ts | 48 ++++++++++++++++-- .../src/renderer/locales/shell-copy.ts | 10 ++++ 8 files changed, 136 insertions(+), 46 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d4528ba2e4..9669b4e677 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -624,7 +624,7 @@ "./locales/shell-copy.js": 1 }, "importSpecifiers": 0, - "nonTriviaTokens": 280 + "nonTriviaTokens": 300 }, "src/renderer/app-shell-turn-actions.ts": { "importDeclarations": 2, @@ -783,7 +783,7 @@ "react": 1 }, "importSpecifiers": 90, - "nonTriviaTokens": 12108 + "nonTriviaTokens": 12112 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -1205,7 +1205,9 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": {} + "dependencyPaths": { + "./application/contracts/conversation-copy.js": 1 + } }, "src/renderer/local-memory-digest.ts": { "bridgePaths": {}, 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 37ba184da6..59259f47b1 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 @@ -90,7 +90,7 @@ test('returns undefined when stop fails so plain-Enter send can abort', async () test('stops the captured Session when the active id changes during the await', async () => { const target = globalThis as unknown as { window?: unknown }; const previousWindow = target.window; - const stopped: string[] = []; + const stopped: Array<{ sessionId: string; options: unknown }> = []; const activeIdRef = { current: 'session-a' as string | undefined }; let release!: () => void; const gate = new Promise((resolve) => { @@ -99,8 +99,8 @@ test('stops the captured Session when the active id changes during the await', a target.window = { maka: { sessions: { - stop: async (sessionId: string) => { - stopped.push(sessionId); + stop: async (sessionId: string, options?: unknown) => { + stopped.push({ sessionId, options }); await gate; return { kind: 'interrupted', retractedMessageIds: [] }; }, @@ -116,11 +116,13 @@ test('stops the captured Session when the active id changes during the await', a toastApi: { error() {} }, }); - const pending = stop('session-a'); + const pending = stop('session-a', 'turn-1'); activeIdRef.current = 'session-b'; release(); assert.equal(await pending, true); - assert.deepEqual(stopped, ['session-a']); + assert.deepEqual(stopped, [ + { sessionId: 'session-a', options: { source: 'stop_button', expectedTurnId: 'turn-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 689994632a..a6b783ed43 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 @@ -23,6 +23,7 @@ import { hasActiveTurnAtSubmit, interruptBeforeRootSend, mergeWorkspaceReferences, + resolveExpectedTurnIdForInterrupt, shouldContinueRootSendAfterInterrupt, } from '../../renderer/follow-up-submit-routing.js'; @@ -101,7 +102,8 @@ describe('follow-up submit routing', () => { }); it('pins the submitting Session across an awaited interrupt before root send', async () => { - const stopped: string[] = []; + const stopped: Array<{ sessionId: string; expectedTurnId?: string }> = []; + const errors: Array<{ title: string; description?: string }> = []; const activeIdRef = { current: 'session-a' as string | undefined }; assert.equal( await interruptBeforeRootSend({ @@ -110,15 +112,55 @@ describe('follow-up submit routing', () => { liveTurns: [{ turnId: 'turn-1' }], runningTurnIds: [], activeSessionId: () => activeIdRef.current, - stop: async (sessionId) => { - stopped.push(sessionId ?? ''); + stop: async (sessionId, expectedTurnId) => { + stopped.push({ sessionId: sessionId ?? '', expectedTurnId }); activeIdRef.current = 'session-b'; return true; }, + uiLocale: 'en', + toastApi: { + error(title, description) { + errors.push({ title, description }); + }, + }, }), false, ); - assert.deepEqual(stopped, ['session-a']); + assert.deepEqual(stopped, [{ sessionId: 'session-a', expectedTurnId: 'turn-1' }]); + assert.equal(errors.length, 1); + assert.match(errors[0]?.title ?? '', /not sent/i); + }); + + it('pins stop to a running Host turn when the live buffer only retains terminals', async () => { + const stopped: Array<{ sessionId: string; expectedTurnId?: string }> = []; + assert.equal( + await interruptBeforeRootSend({ + sessionId: 'session-a', + slashCommand: undefined, + liveTurns: [{ turnId: 'turn-1', terminal: true }], + runningTurnIds: ['turn-1', 'turn-2'], + activeSessionId: () => 'session-a', + stop: async (sessionId, expectedTurnId) => { + stopped.push({ sessionId: sessionId ?? '', expectedTurnId }); + return true; + }, + }), + true, + ); + assert.deepEqual(stopped, [{ sessionId: 'session-a', expectedTurnId: 'turn-2' }]); + }); + + it('resolves the non-terminal live turn before Host running ids', () => { + assert.equal( + resolveExpectedTurnIdForInterrupt({ + liveTurns: [ + { turnId: 'turn-1', terminal: true }, + { turnId: 'turn-2' }, + ], + runningTurnIds: ['turn-1', 'turn-2', 'turn-3'], + }), + 'turn-2', + ); }); it('restores workspace references after queued text returns to the draft', () => { diff --git a/apps/desktop/src/renderer/app-shell-stop-action.ts b/apps/desktop/src/renderer/app-shell-stop-action.ts index cfd2900a90..8c0603d0fd 100644 --- a/apps/desktop/src/renderer/app-shell-stop-action.ts +++ b/apps/desktop/src/renderer/app-shell-stop-action.ts @@ -18,7 +18,10 @@ */ import type { UiLocale } from '@maka/core/ui-locale'; -import { localizedShellErrorMessage } from './locales/shell-copy.js'; +import { + localizedShellErrorMessage, + type ShellErrorToastApi, +} from './locales/shell-copy.js'; import { getDesktopConversationCopy } from './application/contracts/conversation-copy.js'; import type { SessionPendingClaim } from './app-shell-session-ui-state.js'; @@ -29,34 +32,24 @@ export function createAppShellStopAction(deps: { activeIdRef: RefBox; stopPending: SessionPendingClaim; removeTransientMessage: (sessionId: string, messageId: string) => void; - toastApi: { error(...args: any): void }; -}): any { - const { - uiLocale, - activeIdRef, - stopPending, - removeTransientMessage, - toastApi, - } = deps; - - async function stop(o?: string) { - const sessionId = o ?? activeIdRef.current; + toastApi: ShellErrorToastApi; +}): (sessionId?: string, expectedTurnId?: string) => Promise { + const { uiLocale, activeIdRef, stopPending, removeTransientMessage, toastApi } = deps; + return async (override?: string, expectedTurnId?: string) => { + const sessionId = override ?? activeIdRef.current; if (!sessionId || !stopPending.claim(sessionId)) return; try { - const result = await window.maka.sessions.stop(sessionId, { source: 'stop_button' }); + const result = await window.maka.sessions.stop(sessionId, { + source: 'stop_button', + ...(expectedTurnId ? { expectedTurnId } : {}), + }); if (result?.kind === 'interrupted') { - for (const messageId of result.retractedMessageIds) { - removeTransientMessage(sessionId, messageId); - } + for (const id of result.retractedMessageIds) removeTransientMessage(sessionId, id); } 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. + // Composer Stop / Escape call onStop without awaiting; toast so a failed + // interrupt is visible instead of an UnhandledPromiseRejection. if (activeIdRef.current === sessionId) { const copy = getDesktopConversationCopy(uiLocale).actions; toastApi.error( @@ -69,7 +62,5 @@ export function createAppShellStopAction(deps: { } finally { stopPending.release(sessionId); } - } - - return stop; + }; } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 7e01123d0d..e4d37f2eea 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1605,6 +1605,8 @@ function AppShellContent({ : undefined, activeSessionId: () => activeIdRef.current, stop, + toastApi, + uiLocale, })) ) { return false; diff --git a/apps/desktop/src/renderer/application/contracts/conversation-copy.ts b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts index ce3cb1b514..2e7f0b8680 100644 --- a/apps/desktop/src/renderer/application/contracts/conversation-copy.ts +++ b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts @@ -30,6 +30,9 @@ export interface DesktopConversationCopy { actions: { stopFailedTitle: string; stopFailedFallback: string; + /** Plain-Enter interrupt finished, but the active Session moved before root send. */ + interruptSendAbandonedTitle: string; + interruptSendAbandonedDescription: string; refreshSessionsFailedTitle: string; refreshSessionsFailedFallback: string; conversationErrorTitle: string; @@ -331,7 +334,7 @@ function enDetail(parts: readonly string[]): string { const COPY = { 'zh-CN': { - actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '这条消息自带的附件不参与编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', folderNotAttachable: '文件夹不能作为附件添加。', folderNotAttachableUseReference: '文件夹不能作为附件添加,请改用“引用文件夹”。', imageAttachmentNotDirectTitle: '图片已作为附件添加', imageAttachmentNotDirectDescription: '当前模型不会直接接收图片。图片已作为附件提供给模型。', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', scrollMainToBottom: '滚动主对话到底部' }, + actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', interruptSendAbandonedTitle: '消息未发送', interruptSendAbandonedDescription: '停止上一轮时切换了任务,草稿已保留。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '这条消息自带的附件不参与编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', folderNotAttachable: '文件夹不能作为附件添加。', folderNotAttachableUseReference: '文件夹不能作为附件添加,请改用“引用文件夹”。', imageAttachmentNotDirectTitle: '图片已作为附件添加', imageAttachmentNotDirectDescription: '当前模型不会直接接收图片。图片已作为附件提供给模型。', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', scrollMainToBottom: '滚动主对话到底部' }, model: { fakeBackendLabel: '本地模拟连接', setupTitle: '等待配置真实模型', @@ -574,7 +577,7 @@ const COPY = { turnError: { streamTruncated: '响应中途断开。', requestRejected: '模型服务拒绝了请求,请检查模型与请求配置。', retryExhausted: '已达到自动重试次数上限。', retryDeclined: { side_effects: '本次已有工具活动,为避免重复操作,未自动重试。请先检查工具结果。', observable_output: '本次已有部分输出,未自动重试。请先检查已保留的内容。', policy: '按当前重试规则,本次未自动重试。', budget: '本次执行预算已用尽,未自动重试。' }, unknown: '出错了,暂时无法确定原因。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', timeout: '模型请求超时。', auth: '模型鉴权失败,请到设置里重新连接或登录。', providerBilling: '模型服务计费受限,请检查账号余额或订阅状态。', providerCapacity: '模型服务暂时满载。', rateLimit: '模型请求太频繁被限流了。', network: '网络连接失败,请检查网络。', provider: '模型服务返回错误。', stepCap: '达到工具调用步数上限,任务可能没做完。发消息让它继续。', emptyLoop: '连续空工具步骤没有可见进展,任务可能没做完。发消息让它继续。', tool: '工具调用失败,看一下上面的工具结果再决定要不要重试。', permission: '这一轮在等权限确认时结束了,重新发消息会再问一次。', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启时,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭。重新发消息可以再决定一次。', executionState: { erroredTool: '这一轮有工具执行出错,先看它的结果,再决定要不要重发。', toolRan: '这一轮已经执行过工具,可能已经产生实际改动,重发前先看工具结果。' } }, }, 'zh-TW': { - actions: { stopFailedTitle: '停止失敗', stopFailedFallback: '任務操作失敗,請稍後重試。', refreshSessionsFailedTitle: '重新整理任務列表失敗', refreshSessionsFailedFallback: '重新整理任務列表失敗,請稍後重試。', conversationErrorTitle: '任務出錯', conversationErrorFallback: '任務執行失敗,請稍後重試。', branchCreatedTitle: '已建立分支', branchCreatedDescription: (name) => `新任務 ${name}`, revisionStartedTitle: '已建立修改版草稿', revisionStartedDescription: '原任務仍會保留;修改後傳送將在新版本中繼續', revisionReadyTitle: '可以修改並重發了', revisionReadyDescription: '已回到該訊息之前;編輯後傳送即可', revisionUnavailableTitle: '暫時無法編輯這條訊息', revisionAttachmentsUnsupported: '這條訊息自帶的附件不參與編輯並重發,請複製文字後建立訊息。', revisionTransformedTextUnsupported: '透過顯式技能傳送的歷史訊息暫不支援編輯並重發,請複製文字後重新選擇技能。', revisionDraftAttachmentConflict: 'Composer 中已有待發送附件,請先發送或移除附件,再編輯歷史訊息。', revisionCommandUnsupported: '修改訊息時不能執行 /compact、/side 或編排命令,請取消修改後再試。', revisionAlreadyActive: '已有一條訊息正在修改,請先發送或取消目前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已傳送訊息', revisionBannerDetail: '· 傳送後建立新版本', revisionUnchanged: '內容沒有變化。', operationFailedTitle: '操作失敗', operationFailedFallback: '任務操作失敗,請稍後重試。', attachmentFailedTitle: '新增附件失敗', folderNotAttachable: '資料夾不能作為附件新增。', folderNotAttachableUseReference: '資料夾不能作為附件新增,請改用「引用資料夾」。', imageAttachmentNotDirectTitle: '圖片已作為附件新增', imageAttachmentNotDirectDescription: '目前模型不會直接接收圖片。圖片已作為附件提供給模型。', tryAgain: '請稍後重試。', modelReboundTitle: '已切換到可用模型', modelReboundDescription: (modelId) => `原任務使用的連線已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '讀取任務失敗', scrollMainToBottom: '滾動主對話到底部' }, + actions: { stopFailedTitle: '停止失敗', stopFailedFallback: '任務操作失敗,請稍後重試。', interruptSendAbandonedTitle: '訊息未傳送', interruptSendAbandonedDescription: '停止上一輪時切換了任務,草稿已保留。', refreshSessionsFailedTitle: '重新整理任務列表失敗', refreshSessionsFailedFallback: '重新整理任務列表失敗,請稍後重試。', conversationErrorTitle: '任務出錯', conversationErrorFallback: '任務執行失敗,請稍後重試。', branchCreatedTitle: '已建立分支', branchCreatedDescription: (name) => `新任務 ${name}`, revisionStartedTitle: '已建立修改版草稿', revisionStartedDescription: '原任務仍會保留;修改後傳送將在新版本中繼續', revisionReadyTitle: '可以修改並重發了', revisionReadyDescription: '已回到該訊息之前;編輯後傳送即可', revisionUnavailableTitle: '暫時無法編輯這條訊息', revisionAttachmentsUnsupported: '這條訊息自帶的附件不參與編輯並重發,請複製文字後建立訊息。', revisionTransformedTextUnsupported: '透過顯式技能傳送的歷史訊息暫不支援編輯並重發,請複製文字後重新選擇技能。', revisionDraftAttachmentConflict: 'Composer 中已有待發送附件,請先發送或移除附件,再編輯歷史訊息。', revisionCommandUnsupported: '修改訊息時不能執行 /compact、/side 或編排命令,請取消修改後再試。', revisionAlreadyActive: '已有一條訊息正在修改,請先發送或取消目前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已傳送訊息', revisionBannerDetail: '· 傳送後建立新版本', revisionUnchanged: '內容沒有變化。', operationFailedTitle: '操作失敗', operationFailedFallback: '任務操作失敗,請稍後重試。', attachmentFailedTitle: '新增附件失敗', folderNotAttachable: '資料夾不能作為附件新增。', folderNotAttachableUseReference: '資料夾不能作為附件新增,請改用「引用資料夾」。', imageAttachmentNotDirectTitle: '圖片已作為附件新增', imageAttachmentNotDirectDescription: '目前模型不會直接接收圖片。圖片已作為附件提供給模型。', tryAgain: '請稍後重試。', modelReboundTitle: '已切換到可用模型', modelReboundDescription: (modelId) => `原任務使用的連線已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '讀取任務失敗', scrollMainToBottom: '滾動主對話到底部' }, model: { fakeBackendLabel: '本地模擬連線', setupTitle: '等待設定真實模型', @@ -808,7 +811,7 @@ const COPY = { turnError: { streamTruncated: '回應中途斷開。', requestRejected: '模型服務拒絕了請求,請檢查模型與請求設定。', retryExhausted: '已達到自動重試次數上限。', retryDeclined: { side_effects: '本次已有工具活動,為避免重複操作,未自動重試。請先檢查工具結果。', observable_output: '本次已有部分輸出,未自動重試。請先檢查已保留的內容。', policy: '依目前重試規則,本次未自動重試。', budget: '本次執行預算已用盡,未自動重試。' }, unknown: '出錯了,暫時無法確定原因。', contextOverflow: '上下文超出模型視窗限制,減少附件或開啟新任務。', timeout: '模型請求逾時。', auth: '模型鑑權失敗,請到設定裡重新連線或登入。', providerBilling: '模型服務計費受限,請檢查帳號餘額或訂閱狀態。', providerCapacity: '模型服務暫時滿載。', rateLimit: '模型請求太頻繁而受到速率限制。', network: '網路連線失敗,請檢查網路。', provider: '模型服務回傳錯誤。', stepCap: '達到工具呼叫步數上限,任務可能尚未完成。傳送訊息讓它繼續。', emptyLoop: '連續空工具步驟沒有可見進展,任務可能尚未完成。傳送訊息讓它繼續。', tool: '工具呼叫失敗,先看上面的工具結果再決定是否重試。', permission: '這一輪在等待權限確認時結束,重新傳送訊息會再詢問一次。', restarted: '本機應用程式重啟,上一輪沒有完成', sandboxBoundaryClosed: '本機應用程式重啟時,等待確認的「允許存取工作區以外的內容」請求已按拒絕關閉。重新傳送訊息可以再次決定。', executionState: { erroredTool: '這一輪有工具執行出錯,先看它的結果,再決定是否重發。', toolRan: '這一輪已經執行過工具,可能已經產生實際變更,重發前先看工具結果。' } }, }, en: { - actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: "A message's own attachments are not rewritten by edit & resend. Copy the text into a new message instead.", revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', folderNotAttachable: 'Folders cannot be added as attachments.', folderNotAttachableUseReference: 'Folders cannot be added as attachments. Use Reference folder instead.', imageAttachmentNotDirectTitle: 'Image added as an attachment', imageAttachmentNotDirectDescription: 'The current model does not receive images directly. The image has been provided as an attachment.', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', scrollMainToBottom: 'Scroll main conversation to bottom' }, + actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', interruptSendAbandonedTitle: 'Message not sent', interruptSendAbandonedDescription: 'You switched tasks while the previous turn was stopping. Your draft was kept.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: "A message's own attachments are not rewritten by edit & resend. Copy the text into a new message instead.", revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', folderNotAttachable: 'Folders cannot be added as attachments.', folderNotAttachableUseReference: 'Folders cannot be added as attachments. Use Reference folder instead.', imageAttachmentNotDirectTitle: 'Image added as an attachment', imageAttachmentNotDirectDescription: 'The current model does not receive images directly. The image has been provided as an attachment.', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', scrollMainToBottom: 'Scroll main conversation to bottom' }, model: { fakeBackendLabel: 'Local simulation', setupTitle: 'Configure a real model', diff --git a/apps/desktop/src/renderer/follow-up-submit-routing.ts b/apps/desktop/src/renderer/follow-up-submit-routing.ts index baa71036fd..556f877b9c 100644 --- a/apps/desktop/src/renderer/follow-up-submit-routing.ts +++ b/apps/desktop/src/renderer/follow-up-submit-routing.ts @@ -18,6 +18,8 @@ */ import type { InlineReference } from '@maka/core/events'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { getDesktopConversationCopy } from './application/contracts/conversation-copy.js'; export interface WorkspaceFileReferencePosition { value: string; @@ -64,6 +66,24 @@ export function shouldContinueRootSendAfterInterrupt(input: { return input.activeSessionId === input.submittingSessionId; } +/** + * Pin stop to the live turn that armed interrupt, so a queued turn that starts + * during settlement is not cancelled in its place (#4083 review). + */ +export function resolveExpectedTurnIdForInterrupt(input: { + liveTurns?: readonly LiveTurnAtSubmit[]; + runningTurnIds?: readonly string[]; +}): string | undefined { + const activeLive = input.liveTurns?.find((turn) => turn.terminal !== true); + if (activeLive) return activeLive.turnId; + const retainedTerminalIds = new Set( + (input.liveTurns ?? []) + .filter((turn) => turn.terminal === true) + .map((turn) => turn.turnId), + ); + return input.runningTurnIds?.find((turnId) => !retainedTerminalIds.has(turnId)); +} + /** Interrupt a live turn before admitting a plain-Enter root send (#4083). */ export async function interruptBeforeRootSend(input: { sessionId: string | undefined; @@ -71,17 +91,35 @@ export async function interruptBeforeRootSend(input: { liveTurns?: readonly LiveTurnAtSubmit[]; runningTurnIds?: readonly string[]; activeSessionId: () => string | undefined; - stop: (sessionId?: string) => Promise; + stop: (sessionId?: string, expectedTurnId?: string) => Promise; + toastApi?: { + error(title: string, description?: string): void; + }; + uiLocale?: UiLocale; }): Promise { if (!input.sessionId || input.slashCommand) return true; if (!hasActiveTurnAtSubmit({ liveTurns: input.liveTurns, runningTurnIds: input.runningTurnIds })) { return true; } - if (!(await input.stop(input.sessionId))) return false; - return shouldContinueRootSendAfterInterrupt({ - submittingSessionId: input.sessionId, - activeSessionId: input.activeSessionId(), + const expectedTurnId = resolveExpectedTurnIdForInterrupt({ + liveTurns: input.liveTurns, + runningTurnIds: input.runningTurnIds, }); + if (!(await input.stop(input.sessionId, expectedTurnId))) return false; + if ( + !shouldContinueRootSendAfterInterrupt({ + submittingSessionId: input.sessionId, + activeSessionId: input.activeSessionId(), + }) + ) { + // User navigated away during the awaited stop — keep the draft and say so. + if (input.toastApi && input.uiLocale) { + const copy = getDesktopConversationCopy(input.uiLocale).actions; + input.toastApi.error(copy.interruptSendAbandonedTitle, copy.interruptSendAbandonedDescription); + } + return false; + } + return true; } export function mergeWorkspaceReferences( diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index d1f3bed025..c6be70ae8e 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -34,6 +34,16 @@ import { import { AttachmentIngestBlockedError, type AttachmentIngestBlockedCode } from '@maka/core/attachments'; import type { DesktopSessionUpdateFailureCode } from '../../shared/desktop-session-projection.js'; +/** Typed toast.error contract shared by AppShell stop / interrupt paths. */ +export type ShellErrorToastApi = { + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { sessionId: string }, + ): void; +}; + export const STATIC_COMMAND_IDS = [ 'action:new-chat', 'action:side-chat', From 8c600e000958683c520fe954844e773ebea80cb3 Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Fri, 2 Oct 2026 09:43:18 +0800 Subject: [PATCH 13/20] fix(desktop): treat Host stop no-op as failed Enter interrupt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When plain Enter pins expectedTurnId, a stale/no-op sessions.stop result must not report success — otherwise interruptBeforeRootSend can admit a root send while a newer turn is still running. Add a settlement-race regression. Generated-by: Cursor Co-authored-by: Cursor --- .../__tests__/app-shell-stop-action.test.ts | 37 +++++++++++++++++++ .../follow-up-submit-routing.test.ts | 34 +++++++++++++++++ .../src/renderer/app-shell-stop-action.ts | 6 +++ 3 files changed, 77 insertions(+) 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 59259f47b1..4d9c13f1e0 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 @@ -87,6 +87,43 @@ test('returns undefined when stop fails so plain-Enter send can abort', async () } }); +test('treats a Host no-op stop as failed when expectedTurnId is pinned', async () => { + const target = globalThis as unknown as { window?: unknown }; + const previousWindow = target.window; + const stopped: Array<{ sessionId: string; options: unknown }> = []; + target.window = { + maka: { + sessions: { + stop: async (sessionId: string, options?: unknown) => { + stopped.push({ sessionId, options }); + // Host returns undefined when expectedTurnId no longer matches the + // live root (settled or replaced by a newer turn). + return undefined; + }, + }, + }, + }; + try { + const stop = createAppShellStopAction({ + uiLocale: 'en', + activeIdRef: { current: 'session-1' }, + stopPending: { claim: () => true, release: () => undefined }, + removeTransientMessage: () => undefined, + toastApi: { error() {} }, + }); + + assert.equal(await stop('session-1', 'turn-a'), false); + assert.deepEqual(stopped, [ + { + sessionId: 'session-1', + options: { source: 'stop_button', expectedTurnId: 'turn-a' }, + }, + ]); + } finally { + target.window = previousWindow; + } +}); + test('stops the captured Session when the active id changes during the await', async () => { const target = globalThis as unknown as { window?: unknown }; const previousWindow = target.window; 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 a6b783ed43..79e5d6655b 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 @@ -19,6 +19,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; +import { createAppShellStopAction } from '../../renderer/app-shell-stop-action.js'; import { hasActiveTurnAtSubmit, interruptBeforeRootSend, @@ -131,6 +132,39 @@ describe('follow-up submit routing', () => { assert.match(errors[0]?.title ?? '', /not sent/i); }); + it('blocks root send when Host stop no-ops for the pinned turn (settlement race)', async () => { + const target = globalThis as unknown as { window?: unknown }; + const previousWindow = target.window; + target.window = { + maka: { + sessions: { + // expectedTurnId no longer matches — Host returns undefined. + stop: async () => undefined, + }, + }, + }; + try { + const stop = createAppShellStopAction({ + uiLocale: 'en', + activeIdRef: { current: 'session-a' }, + stopPending: { claim: () => true, release: () => undefined }, + removeTransientMessage: () => undefined, + toastApi: { error() {} }, + }); + const rootSendAllowed = await interruptBeforeRootSend({ + sessionId: 'session-a', + slashCommand: undefined, + liveTurns: [{ turnId: 'turn-a' }], + runningTurnIds: [], + activeSessionId: () => 'session-a', + stop, + }); + assert.equal(rootSendAllowed, false); + } finally { + target.window = previousWindow; + } + }); + it('pins stop to a running Host turn when the live buffer only retains terminals', async () => { const stopped: Array<{ sessionId: string; expectedTurnId?: string }> = []; assert.equal( diff --git a/apps/desktop/src/renderer/app-shell-stop-action.ts b/apps/desktop/src/renderer/app-shell-stop-action.ts index 8c0603d0fd..d41f6f3599 100644 --- a/apps/desktop/src/renderer/app-shell-stop-action.ts +++ b/apps/desktop/src/renderer/app-shell-stop-action.ts @@ -45,7 +45,13 @@ export function createAppShellStopAction(deps: { }); if (result?.kind === 'interrupted') { for (const id of result.retractedMessageIds) removeTransientMessage(sessionId, id); + return true; } + // Enter pins expectedTurnId. Host returns undefined when that turn has + // already settled or been replaced — treat it as a failed interrupt so + // interruptBeforeRootSend does not admit a root send over a newer live + // turn (#4083 review). + if (expectedTurnId) return false; return true; } catch (error) { // Composer Stop / Escape call onStop without awaiting; toast so a failed From b1f80956878f0bf73ca1d7a10970f17e25ff0907 Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Fri, 2 Oct 2026 10:00:13 +0800 Subject: [PATCH 14/20] fix(desktop): keep interrupt-before-send under architecture ratchet Move plain-Enter interrupt helpers into the conversation feature so AppShell debt does not grow vs main, treat Host stop no-ops as failed interrupts, and refresh the renderer architecture ledger. Generated-by: Cursor Co-authored-by: Cursor --- apps/desktop/renderer-architecture.json | 10 +- .../follow-up-submit-routing.test.ts | 4 +- .../src/renderer/app-shell-stop-action.ts | 12 +- apps/desktop/src/renderer/app-shell.tsx | 30 ++--- .../controller/composer-submit.ts | 30 +++-- .../controller/interrupt-before-root-send.ts | 117 ++++++++++++++++++ .../renderer/features/conversation/index.ts | 7 ++ .../src/renderer/follow-up-submit-routing.ts | 98 --------------- 8 files changed, 164 insertions(+), 144 deletions(-) create mode 100644 apps/desktop/src/renderer/features/conversation/controller/interrupt-before-root-send.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 19510bd866..3ca6dabdf6 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -604,7 +604,7 @@ "./locales/shell-copy.js": 1 }, "importSpecifiers": 0, - "nonTriviaTokens": 304 + "nonTriviaTokens": 287 }, "src/renderer/app-shell-turn-actions.ts": { "importDeclarations": 2, @@ -750,8 +750,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 81, - "nonTriviaTokens": 9582 + "importSpecifiers": 80, + "nonTriviaTokens": 9564 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -1155,9 +1155,7 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": { - "./application/contracts/conversation-copy.js": 1 - } + "dependencyPaths": {} }, "src/renderer/local-memory-digest.ts": { "bridgePaths": {}, 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 79e5d6655b..f7b3b51f94 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 @@ -23,10 +23,10 @@ import { createAppShellStopAction } from '../../renderer/app-shell-stop-action.j import { hasActiveTurnAtSubmit, interruptBeforeRootSend, - mergeWorkspaceReferences, resolveExpectedTurnIdForInterrupt, shouldContinueRootSendAfterInterrupt, -} from '../../renderer/follow-up-submit-routing.js'; +} from '../../renderer/features/conversation/index.js'; +import { mergeWorkspaceReferences } from '../../renderer/follow-up-submit-routing.js'; describe('follow-up submit routing', () => { it('uses the synchronous turn arm before React publishes streaming state', () => { diff --git a/apps/desktop/src/renderer/app-shell-stop-action.ts b/apps/desktop/src/renderer/app-shell-stop-action.ts index 4dd1facefc..2e3195799b 100644 --- a/apps/desktop/src/renderer/app-shell-stop-action.ts +++ b/apps/desktop/src/renderer/app-shell-stop-action.ts @@ -33,8 +33,7 @@ export function createAppShellStopAction(deps: { toastApi: ShellErrorToastApi; }): (sessionId?: string, expectedTurnId?: string) => Promise { const { uiLocale, activeIdRef, stopPending, removeTransientMessage, toastApi } = deps; - return async (override?: string, expectedTurnId?: string) => { - const sessionId = override ?? activeIdRef.current; + return async (sessionId = activeIdRef.current, expectedTurnId?: string) => { if (!sessionId || !stopPending.claim(sessionId)) return; try { const result = await window.maka.sessions.stop(sessionId, { @@ -43,14 +42,9 @@ export function createAppShellStopAction(deps: { }); if (result?.kind === 'interrupted') { for (const id of result.retractedMessageIds) removeTransientMessage(sessionId, id); - return true; } - // Enter pins expectedTurnId. Host returns undefined when that turn has - // already settled or been replaced — treat it as a failed interrupt so - // interruptBeforeRootSend does not admit a root send over a newer live - // turn (#4083 review). - if (expectedTurnId) return false; - return true; + // Enter path: Host no-op must not look like a successful interrupt (#4083). + return result?.kind === 'interrupted'; } catch (error) { // Composer Stop / Escape call onStop without awaiting; toast so a failed // interrupt is visible instead of an UnhandledPromiseRejection. diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index e3ae35ab1f..b3edfaf279 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -93,11 +93,7 @@ import * as SessionCollaboration from './features/session-collaboration'; import type { SessionCollaborationDialogProjection } from './features/session-collaboration'; import { NEW_TASK_PENDING_KEY } from './pending-items'; import { desktopSlashCommandAvailability, parseDesktopSlashCommand } from './desktop-slash-command'; -import { - interruptBeforeRootSend, - mergeWorkspaceReferences, - rebaseWorkspaceFileReferences, -} from './follow-up-submit-routing'; +import { mergeWorkspaceReferences, rebaseWorkspaceFileReferences } from './follow-up-submit-routing'; import { getOnboardingActivationCandidate, useOnboardingSnapshot } from './use-onboarding-snapshot'; import { ProviderLogo } from './settings/provider-display'; import { ProviderBrandMark } from './settings/provider-brand-marks'; @@ -1280,21 +1276,15 @@ function AppShellContent({ getActiveOrchestrationMode: () => activeOrchestrationMode, setOrchestrationModeActive, setNewTaskSendPending, - interruptBeforeRootSend: ({ sessionId, slashCommand }) => - interruptBeforeRootSend({ - sessionId, - slashCommand, - liveTurns: sessionId - ? sessionUiReads.liveTurns(sessionId).getSnapshot() - : undefined, - runningTurnIds: sessionId - ? sessionsRef.current.find((session) => session.id === sessionId)?.runningTurnIds - : undefined, - activeSessionId: () => activeIdRef.current, - stop, - toastApi, - uiLocale, - }), + interrupt: { + stop, + liveTurns: (id) => sessionUiReads.liveTurns(id).getSnapshot(), + runningTurnIds: (id) => + sessionsRef.current.find((session) => session.id === id)?.runningTurnIds, + activeSessionId: () => activeIdRef.current, + toastApi, + uiLocale, + }, }, ); diff --git a/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts b/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts index 31acf8d413..ff7f27ae23 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts @@ -29,6 +29,7 @@ import type { } from '@maka/ui'; import type { PendingAttachment } from '@maka/ui/composer-attachments'; import type { ComposerStagingSubmission } from '../model/composer-staging-contract.js'; +import { interruptBeforeRootSend } from './interrupt-before-root-send.js'; type RefBox = { current: T }; type WorkspaceFileReference = NonNullable[number]; @@ -127,10 +128,14 @@ export interface RevisionSendPorts { * Plain-Enter interrupt before a new root send (#4083). Optional so unit * doubles that only exercise revision/slash routing can omit it. */ - interruptBeforeRootSend?: (input: { - sessionId: string | undefined; - slashCommand: ComposerSlashCommand | null; - }) => Promise; + interrupt?: { + stop: (sessionId?: string, expectedTurnId?: string) => Promise; + liveTurns: (sessionId: string) => readonly { turnId: string; terminal?: boolean }[] | undefined; + runningTurnIds: (sessionId: string) => readonly string[] | undefined; + activeSessionId: () => string | undefined; + toastApi?: { error(title: string, description?: string): void }; + uiLocale?: import('@maka/core/ui-locale').UiLocale; + }; } export interface RevisionAwareOnSendPorts extends RevisionSendPorts { @@ -373,11 +378,18 @@ export async function revisionAwareSend( : undefined; const quotes = staging.quotesForSend(); // #4083: plain Enter interrupts the live turn before a new root send. - if ( - ports.interruptBeforeRootSend && - !(await ports.interruptBeforeRootSend({ sessionId, slashCommand })) - ) { - return false; + if (ports.interrupt) { + const allowed = await interruptBeforeRootSend({ + sessionId, + slashCommand, + liveTurns: sessionId ? ports.interrupt.liveTurns(sessionId) : undefined, + runningTurnIds: sessionId ? ports.interrupt.runningTurnIds(sessionId) : undefined, + activeSessionId: ports.interrupt.activeSessionId, + stop: ports.interrupt.stop, + toastApi: ports.interrupt.toastApi, + uiLocale: ports.interrupt.uiLocale, + }); + if (!allowed) return false; } const ok = await ports.send(text, pending, { waitForHostAdmission: revisionSend, diff --git a/apps/desktop/src/renderer/features/conversation/controller/interrupt-before-root-send.ts b/apps/desktop/src/renderer/features/conversation/controller/interrupt-before-root-send.ts new file mode 100644 index 0000000000..a2e447291b --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/interrupt-before-root-send.ts @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiLocale } from '@maka/core/ui-locale'; +import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; + +export type LiveTurnAtSubmit = { + turnId: string; + terminal?: boolean; +}; + +/** + * Whether a plain-Enter root send must interrupt first. + * + * `liveTurns` is the Session's live-turn buffer (active and retained terminal + * projections). Any non-terminal entry means a live turn is still in flight. + * Running Host turn IDs that are not already accounted for by retained + * terminal projections also count as active — the arm can exist before React + * publishes streaming state, and a second turn can race a settled first. + */ +export function hasActiveTurnAtSubmit(input: { + liveTurns?: readonly LiveTurnAtSubmit[]; + runningTurnIds?: readonly string[]; +}): boolean { + if (input.liveTurns?.some((turn) => turn.terminal !== true) === true) return true; + const retainedTerminalIds = new Set( + (input.liveTurns ?? []) + .filter((turn) => turn.terminal === true) + .map((turn) => turn.turnId), + ); + return input.runningTurnIds?.some((turnId) => !retainedTerminalIds.has(turnId)) === true; +} + +/** + * After plain-Enter interrupts a live turn, the root send must still target the + * Session that was submitted. `sessions.stop` awaits terminal settlement, so the + * user can navigate away while that await is open — refuse the send rather than + * delivering the draft to whichever Session is active afterward (#4083 review). + */ +export function shouldContinueRootSendAfterInterrupt(input: { + submittingSessionId: string; + activeSessionId: string | undefined; +}): boolean { + return input.activeSessionId === input.submittingSessionId; +} + +/** + * Pin stop to the live turn that armed interrupt, so a queued turn that starts + * during settlement is not cancelled in its place (#4083 review). + */ +export function resolveExpectedTurnIdForInterrupt(input: { + liveTurns?: readonly LiveTurnAtSubmit[]; + runningTurnIds?: readonly string[]; +}): string | undefined { + const activeLive = input.liveTurns?.find((turn) => turn.terminal !== true); + if (activeLive) return activeLive.turnId; + const retainedTerminalIds = new Set( + (input.liveTurns ?? []) + .filter((turn) => turn.terminal === true) + .map((turn) => turn.turnId), + ); + return input.runningTurnIds?.find((turnId) => !retainedTerminalIds.has(turnId)); +} + +/** Interrupt a live turn before admitting a plain-Enter root send (#4083). */ +export async function interruptBeforeRootSend(input: { + sessionId: string | undefined; + slashCommand: unknown; + liveTurns?: readonly LiveTurnAtSubmit[]; + runningTurnIds?: readonly string[]; + activeSessionId: () => string | undefined; + stop: (sessionId?: string, expectedTurnId?: string) => Promise; + toastApi?: { + error(title: string, description?: string): void; + }; + uiLocale?: UiLocale; +}): Promise { + if (!input.sessionId || input.slashCommand) return true; + if (!hasActiveTurnAtSubmit({ liveTurns: input.liveTurns, runningTurnIds: input.runningTurnIds })) { + return true; + } + const expectedTurnId = resolveExpectedTurnIdForInterrupt({ + liveTurns: input.liveTurns, + runningTurnIds: input.runningTurnIds, + }); + if (!(await input.stop(input.sessionId, expectedTurnId))) return false; + if ( + !shouldContinueRootSendAfterInterrupt({ + submittingSessionId: input.sessionId, + activeSessionId: input.activeSessionId(), + }) + ) { + // User navigated away during the awaited stop — keep the draft and say so. + if (input.toastApi && input.uiLocale) { + const copy = getDesktopConversationCopy(input.uiLocale).actions; + input.toastApi.error(copy.interruptSendAbandonedTitle, copy.interruptSendAbandonedDescription); + } + return false; + } + return true; +} diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 02f8d2c1c7..5980740fd5 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -61,6 +61,13 @@ export { createRevisionAwareOnSend, type RevisionSendPorts, } from './controller/composer-submit.js'; +export { + hasActiveTurnAtSubmit, + interruptBeforeRootSend, + resolveExpectedTurnIdForInterrupt, + shouldContinueRootSendAfterInterrupt, + type LiveTurnAtSubmit, +} from './controller/interrupt-before-root-send.js'; export * from './model/observation-visibility.js'; export { useExecutorSelection } from './controller/use-executor-selection.js'; diff --git a/apps/desktop/src/renderer/follow-up-submit-routing.ts b/apps/desktop/src/renderer/follow-up-submit-routing.ts index 556f877b9c..528f5526af 100644 --- a/apps/desktop/src/renderer/follow-up-submit-routing.ts +++ b/apps/desktop/src/renderer/follow-up-submit-routing.ts @@ -18,110 +18,12 @@ */ import type { InlineReference } from '@maka/core/events'; -import type { UiLocale } from '@maka/core/ui-locale'; -import { getDesktopConversationCopy } from './application/contracts/conversation-copy.js'; export interface WorkspaceFileReferencePosition { value: string; start: number; } -export type LiveTurnAtSubmit = { - turnId: string; - terminal?: boolean; -}; - -/** - * Whether a plain-Enter root send must interrupt first. - * - * `liveTurns` is the Session's live-turn buffer (active and retained terminal - * projections). Any non-terminal entry means a live turn is still in flight. - * Running Host turn IDs that are not already accounted for by retained - * terminal projections also count as active — the arm can exist before React - * publishes streaming state, and a second turn can race a settled first. - */ -export function hasActiveTurnAtSubmit(input: { - liveTurns?: readonly LiveTurnAtSubmit[]; - runningTurnIds?: readonly string[]; -}): boolean { - if (input.liveTurns?.some((turn) => turn.terminal !== true) === true) return true; - const retainedTerminalIds = new Set( - (input.liveTurns ?? []) - .filter((turn) => turn.terminal === true) - .map((turn) => turn.turnId), - ); - return input.runningTurnIds?.some((turnId) => !retainedTerminalIds.has(turnId)) === true; -} - -/** - * After plain-Enter interrupts a live turn, the root send must still target the - * Session that was submitted. `sessions.stop` awaits terminal settlement, so the - * user can navigate away while that await is open — refuse the send rather than - * delivering the draft to whichever Session is active afterward (#4083 review). - */ -export function shouldContinueRootSendAfterInterrupt(input: { - submittingSessionId: string; - activeSessionId: string | undefined; -}): boolean { - return input.activeSessionId === input.submittingSessionId; -} - -/** - * Pin stop to the live turn that armed interrupt, so a queued turn that starts - * during settlement is not cancelled in its place (#4083 review). - */ -export function resolveExpectedTurnIdForInterrupt(input: { - liveTurns?: readonly LiveTurnAtSubmit[]; - runningTurnIds?: readonly string[]; -}): string | undefined { - const activeLive = input.liveTurns?.find((turn) => turn.terminal !== true); - if (activeLive) return activeLive.turnId; - const retainedTerminalIds = new Set( - (input.liveTurns ?? []) - .filter((turn) => turn.terminal === true) - .map((turn) => turn.turnId), - ); - return input.runningTurnIds?.find((turnId) => !retainedTerminalIds.has(turnId)); -} - -/** Interrupt a live turn before admitting a plain-Enter root send (#4083). */ -export async function interruptBeforeRootSend(input: { - sessionId: string | undefined; - slashCommand: unknown; - liveTurns?: readonly LiveTurnAtSubmit[]; - runningTurnIds?: readonly string[]; - activeSessionId: () => string | undefined; - stop: (sessionId?: string, expectedTurnId?: string) => Promise; - toastApi?: { - error(title: string, description?: string): void; - }; - uiLocale?: UiLocale; -}): Promise { - if (!input.sessionId || input.slashCommand) return true; - if (!hasActiveTurnAtSubmit({ liveTurns: input.liveTurns, runningTurnIds: input.runningTurnIds })) { - return true; - } - const expectedTurnId = resolveExpectedTurnIdForInterrupt({ - liveTurns: input.liveTurns, - runningTurnIds: input.runningTurnIds, - }); - if (!(await input.stop(input.sessionId, expectedTurnId))) return false; - if ( - !shouldContinueRootSendAfterInterrupt({ - submittingSessionId: input.sessionId, - activeSessionId: input.activeSessionId(), - }) - ) { - // User navigated away during the awaited stop — keep the draft and say so. - if (input.toastApi && input.uiLocale) { - const copy = getDesktopConversationCopy(input.uiLocale).actions; - input.toastApi.error(copy.interruptSendAbandonedTitle, copy.interruptSendAbandonedDescription); - } - return false; - } - return true; -} - export function mergeWorkspaceReferences( text: string, live: readonly WorkspaceFileReferencePosition[] | undefined, From 92a5d37639d16a25a7c786c9e760c72aeb341828 Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Sun, 4 Oct 2026 10:50:59 +0800 Subject: [PATCH 15/20] fix(desktop): drop exports orphaned by the composer-submission merge ShellErrorToastApi only typed the retired app-shell stop action, and no test imports LiveTurnAtSubmit from the testing entry; Knip flagged both. Reword the interrupt comment so the Host-adapter boundary check does not read it as a bridge call. Generated-by: Cursor Co-authored-by: Cursor --- .../controller/interrupt-before-root-send.ts | 2 +- .../src/renderer/features/conversation/testing.ts | 1 - apps/desktop/src/renderer/locales/shell-copy.ts | 10 ---------- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/features/conversation/controller/interrupt-before-root-send.ts b/apps/desktop/src/renderer/features/conversation/controller/interrupt-before-root-send.ts index fa06481fc2..0f93b4bf10 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/interrupt-before-root-send.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/interrupt-before-root-send.ts @@ -49,7 +49,7 @@ export function hasActiveTurnAtSubmit(input: { /** * After plain-Enter interrupts a live turn, the root send must still target the - * Session that was submitted. `sessions.stop` awaits terminal settlement, so the + * Session that was submitted. The Host stop awaits terminal settlement, so the * user can navigate away while that await is open — refuse the send rather than * delivering the draft to whichever Session is active afterward (#4083 review). */ diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index 09d9b73fc5..8bce03f875 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -154,7 +154,6 @@ export { interruptBeforeRootSend, resolveExpectedTurnIdForInterrupt, shouldContinueRootSendAfterInterrupt, - type LiveTurnAtSubmit, } from './controller/interrupt-before-root-send.js'; export { createChatActions } from './controller/chat-actions.js'; export { diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 8f413728f2..95a4eabf0a 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -34,16 +34,6 @@ import { import { AttachmentIngestBlockedError, type AttachmentIngestBlockedCode } from '@maka/core/attachments'; import type { DesktopSessionUpdateFailureCode } from '../../shared/desktop-session-projection.js'; -/** Typed toast.error contract shared by AppShell stop / interrupt paths. */ -export type ShellErrorToastApi = { - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { sessionId: string }, - ): void; -}; - export const STATIC_COMMAND_IDS = [ 'action:new-chat', 'action:side-chat', From 8a0ed257589baae8ba6521d0d6b8ab84c05ad890 Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Sun, 4 Oct 2026 12:48:44 +0800 Subject: [PATCH 16/20] fix(runtime): hash empty-step signatures incrementally The 64 KiB cap still stringified the whole batch before comparing, and an oversized step cleared the loop progress, so a model looping on an identical large tool result never tripped the bound. Feed tool inputs and results into a SHA-256 digest field by field instead, so every step yields a signature without building one large string. Cover an identical 256 KiB result (trips) and a large result that changes only at its end (does not trip). Generated-by: Cursor Co-authored-by: Cursor --- .../src/__tests__/ai-sdk-backend.test.ts | 96 +++++++++++++++++++ packages/runtime/src/ai-sdk-turn.ts | 67 +++++++++++-- 2 files changed, 156 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 94f44a2b1a..d397d1aab5 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -6814,6 +6814,102 @@ describe('AiSdkBackend model history', () => { assert.equal(events.filter((event) => event.type === 'tool_start').length, 6); }); + test('stops an unbounded loop on an identical large tool result', async () => { + // A screenshot or big file read is the loop most likely to flood the + // transcript, so its size must not exempt it from the bound (#4083 review). + const loop = countingToolLoopModel(undefined, true); + const largeResult = { ok: true, content: 'x'.repeat(256 * 1024) }; + const largeTool: MakaTool = { + name: 'Read', + description: 'Read description', + parameters: z.object({ path: z.string() }), + impl: async () => largeResult, + }; + const durable = durableTurnHarness('turn-empty-large-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: [largeTool], + 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, 'empty_step_loop'); + }); + + test('does not trip the empty-step cap when a large result changes only at its end', async () => { + let calls = 0; + let resultN = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const chunks: LanguageModelV4StreamPart[] = + calls > 6 + ? [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-final' }, + { type: 'text-delta', id: 'text-final', delta: 'done' }, + { type: 'text-end', id: 'text-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { + 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(), + }, + ]; + return { + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }; + }, + }); + const prefix = 'x'.repeat(256 * 1024); + const progressingTool: MakaTool = { + name: 'Read', + description: 'Read description', + parameters: z.object({ path: z.string() }), + impl: async () => ({ ok: true, content: `${prefix}${++resultN}` }), + }; + const durable = durableTurnHarness('turn-empty-large-progress', 'keep going'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [progressingTool], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + assert.equal(calls, 7); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + }); + for (const decision of ['cancel', 'commit', 'stop'] as const) { test(`cooperative handoff ${decision} waits for the settled tool and gates the next request`, { timeout: 5_000, diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index 23bea434f7..0ca1fe5392 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -23,7 +23,7 @@ * construction and cross-turn routing remain in AiSdkBackend. */ -import { createHash } from 'node:crypto'; +import { createHash, type Hash } from 'node:crypto'; import type { AbortEvent, CompleteEvent, @@ -600,13 +600,66 @@ const CONTEXT_RECOVERY_MAX_OUTPUT_TOKENS = 8_000; */ const MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS = 3; const EMPTY_STEP_SIGNATURE_WINDOW = 6; -/** Skip the empty-step bound when a tool batch is too large to hash cheaply. */ -const EMPTY_STEP_SIGNATURE_MAX_CHARS = 64 * 1024; +/** + * Digest of a textless step's tool batch. Values are fed to the hash piece by + * piece, so a large result (a screenshot, a big file read) is never copied into + * one serialized string, and no size cap lets a loop on it escape the bound. + * Follows JSON's view of the value: `toJSON` applies and undefined properties + * are absent. + */ +function hashEmptyStepSignature(payload: unknown): string { + const hash = createHash('sha256'); + updateEmptyStepDigest(hash, payload, new WeakSet()); + return hash.digest('hex'); +} -function hashEmptyStepSignature(payload: unknown): string | undefined { - const serialized = JSON.stringify(payload); - if (serialized.length > EMPTY_STEP_SIGNATURE_MAX_CHARS) return undefined; - return createHash('sha256').update(serialized).digest('hex'); +function updateEmptyStepDigest(hash: Hash, value: unknown, ancestors: WeakSet): void { + if (value === null || value === undefined) { + hash.update('n;'); + return; + } + if (typeof value === 'string') { + hash.update(`s${value.length}:`); + hash.update(value); + return; + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + hash.update(`${typeof value}:${String(value)};`); + return; + } + if (typeof value !== 'object') { + hash.update('n;'); + return; + } + if (ArrayBuffer.isView(value)) { + hash.update(`b${value.byteLength}:`); + hash.update(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); + return; + } + const toJSON = (value as { toJSON?: unknown }).toJSON; + if (typeof toJSON === 'function') { + updateEmptyStepDigest(hash, toJSON.call(value), ancestors); + return; + } + if (ancestors.has(value)) { + hash.update('c;'); + return; + } + ancestors.add(value); + if (Array.isArray(value)) { + hash.update(`a${value.length}[`); + for (const item of value) updateEmptyStepDigest(hash, item, ancestors); + hash.update(']'); + } else { + const entries = Object.entries(value).filter(([, entry]) => entry !== undefined); + hash.update(`o${entries.length}{`); + for (const [key, entry] of entries) { + updateEmptyStepDigest(hash, key, ancestors); + updateEmptyStepDigest(hash, entry, ancestors); + } + hash.update('}'); + } + ancestors.delete(value); } const PROVIDER_RETRY_BASE_DELAY_MS = 1_000; const PROVIDER_RETRY_MAX_DELAY_MS = 32_000; From 0c0381eb2b6a29d136a8a05860010992d8610def Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Sun, 4 Oct 2026 12:48:45 +0800 Subject: [PATCH 17/20] fix(desktop): report stop outcomes so Enter interrupt toasts once createStopAction now resolves to interrupted, not_running, failed or busy. A second stop for the same Session awaits the one in flight, so Enter during a Stop click waits for that stop instead of reporting a blocked send. The interrupt helper skips its own toast after a failed stop (already toasted), says the previous turn is still stopping when another owner holds the claim, and only re-reads active turns after a not_running stop. Generated-by: Cursor Co-authored-by: Cursor --- .../__tests__/app-shell-stop-action.test.ts | 67 +++++++++++++++++-- .../follow-up-submit-routing.test.ts | 52 +++++++++++++- .../contracts/conversation-copy.ts | 10 +-- .../controller/composer-submit.ts | 3 +- .../controller/interrupt-before-root-send.ts | 41 +++++++----- .../conversation/controller/stop-action.ts | 36 +++++++--- .../controller/use-composer-submission.ts | 4 +- 7 files changed, 173 insertions(+), 40 deletions(-) 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 94645358aa..f24e3cf267 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 @@ -46,7 +46,7 @@ test('removes exactly the transient messages the Host retracts while stopping', toastApi: { error() {} }, }); - assert.equal(await stop(), true); + assert.equal(await stop(), 'interrupted'); assert.deepEqual(removed, [ { sessionId: 'session-1', messageId: 'message-1' }, { sessionId: 'session-1', messageId: 'message-2' }, @@ -56,7 +56,7 @@ test('removes exactly the transient messages the Host retracts while stopping', } }); -test('returns undefined when stop fails so plain-Enter send can abort', async () => { +test('reports a thrown stop as failed after toasting it', async () => { const target = globalThis as unknown as { window?: unknown }; const previousWindow = target.window; const errors: string[] = []; @@ -83,14 +83,14 @@ test('returns undefined when stop fails so plain-Enter send can abort', async () }, }); - assert.equal(await stop(), undefined); + assert.equal(await stop(), 'failed'); assert.equal(errors.length, 1); } finally { target.window = previousWindow; } }); -test('treats a Host no-op stop as failed when expectedTurnId is pinned', async () => { +test('reports a Host no-op stop as not_running when expectedTurnId is pinned', async () => { const target = globalThis as unknown as { window?: unknown }; const previousWindow = target.window; const stopped: Array<{ sessionId: string; options: unknown }> = []; @@ -116,7 +116,7 @@ test('treats a Host no-op stop as failed when expectedTurnId is pinned', async ( toastApi: { error() {} }, }); - assert.equal(await stop('session-1', 'turn-a'), false); + assert.equal(await stop('session-1', 'turn-a'), 'not_running'); assert.deepEqual(stopped, [ { sessionId: 'session-1', @@ -161,7 +161,7 @@ test('stops the captured Session when the active id changes during the await', a const pending = stop('session-a', 'turn-1'); activeIdRef.current = 'session-b'; release(); - assert.equal(await pending, true); + assert.equal(await pending, 'interrupted'); assert.deepEqual(stopped, [ { sessionId: 'session-a', options: { source: 'stop_button', expectedTurnId: 'turn-1' } }, ]); @@ -169,3 +169,58 @@ test('stops the captured Session when the active id changes during the await', a target.window = previousWindow; } }); + +test('a second stop for the same Session awaits the one in flight', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let hostCalls = 0; + let held = false; + const stop = createStopAction({ + services: { + stop: async () => { + hostCalls += 1; + await gate; + return { kind: 'interrupted', retractedMessageIds: [] }; + }, + }, + uiLocale: 'en', + activeIdRef: { current: 'session-1' }, + stopPending: { + claim: () => (held ? false : (held = true)), + release: () => { + held = false; + }, + }, + removeTransientMessage: () => undefined, + toastApi: { error() {} }, + inFlight: new Map(), + }); + + const first = stop('session-1'); + const second = stop('session-1', 'turn-1'); + release(); + assert.deepEqual(await Promise.all([first, second]), ['interrupted', 'interrupted']); + assert.equal(hostCalls, 1); +}); + +test('reports busy when another owner holds the stop claim', async () => { + let hostCalls = 0; + const stop = createStopAction({ + services: { + stop: async () => { + hostCalls += 1; + return undefined; + }, + }, + uiLocale: 'en', + activeIdRef: { current: 'session-1' }, + stopPending: { claim: () => false, release: () => undefined }, + removeTransientMessage: () => undefined, + toastApi: { error() {} }, + }); + + assert.equal(await stop('session-1'), 'busy'); + assert.equal(hostCalls, 0); +}); 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 d2f4674b11..f84d0ccab0 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 @@ -117,7 +117,7 @@ describe('follow-up submit routing', () => { stop: async (sessionId, expectedTurnId) => { stopped.push({ sessionId: sessionId ?? '', expectedTurnId }); activeIdRef.current = 'session-b'; - return true; + return 'interrupted' as const; }, uiLocale: 'en', toastApi: { @@ -192,12 +192,58 @@ describe('follow-up submit routing', () => { runningTurnIds: [], }), activeSessionId: () => 'session-a', - stop: async () => false, + stop: async () => 'not_running' as const, }), true, ); }); + it('does not add a second toast when the stop itself failed', async () => { + const errors: string[] = []; + assert.equal( + await interruptBeforeRootSend({ + sessionId: 'session-a', + slashCommand: undefined, + liveTurns: [{ turnId: 'turn-a' }], + runningTurnIds: [], + refreshActiveTurn: () => ({ liveTurns: [{ turnId: 'turn-a' }], runningTurnIds: ['turn-a'] }), + activeSessionId: () => 'session-a', + stop: async () => 'failed' as const, + uiLocale: 'en', + toastApi: { + error(title) { + errors.push(title); + }, + }, + }), + false, + ); + assert.deepEqual(errors, []); + }); + + it('reports a stop still in flight instead of a blocked send', async () => { + const errors: Array<{ title: string; description?: string }> = []; + assert.equal( + await interruptBeforeRootSend({ + sessionId: 'session-a', + slashCommand: undefined, + liveTurns: [{ turnId: 'turn-a' }], + runningTurnIds: [], + activeSessionId: () => 'session-a', + stop: async () => 'busy' as const, + uiLocale: 'en', + toastApi: { + error(title, description) { + errors.push({ title, description }); + }, + }, + }), + false, + ); + assert.equal(errors.length, 1); + assert.match(errors[0]?.description ?? '', /still stopping/i); + }); + it('pins stop to a running Host turn when the live buffer only retains terminals', async () => { const stopped: Array<{ sessionId: string; expectedTurnId?: string }> = []; assert.equal( @@ -209,7 +255,7 @@ describe('follow-up submit routing', () => { activeSessionId: () => 'session-a', stop: async (sessionId, expectedTurnId) => { stopped.push({ sessionId: sessionId ?? '', expectedTurnId }); - return true; + return 'interrupted' as const; }, }), true, diff --git a/apps/desktop/src/renderer/application/contracts/conversation-copy.ts b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts index c9232d40f6..23426535a8 100644 --- a/apps/desktop/src/renderer/application/contracts/conversation-copy.ts +++ b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts @@ -33,9 +33,11 @@ export interface DesktopConversationCopy { /** Plain-Enter interrupt finished, but the active Session moved before root send. */ interruptSendAbandonedTitle: string; interruptSendAbandonedDescription: string; - /** Plain-Enter stop did not clear the live turn (race / already stopping). */ + /** Plain-Enter stop found another live turn still running. */ interruptSendBlockedTitle: string; interruptSendBlockedDescription: string; + /** Plain-Enter met a stop already in flight for the Session. */ + interruptSendStoppingDescription: string; refreshSessionsFailedTitle: string; refreshSessionsFailedFallback: string; conversationErrorTitle: string; @@ -336,7 +338,7 @@ function enDetail(parts: readonly string[]): string { const COPY = { 'zh-CN': { - actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', interruptSendAbandonedTitle: '消息未发送', interruptSendAbandonedDescription: '停止上一轮时切换了任务,草稿已保留。', interruptSendBlockedTitle: '消息未发送', interruptSendBlockedDescription: '上一轮仍在运行,未能停止。草稿已保留,请稍后再试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;发送后将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;可直接重发或修改后发送', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '这条消息自带的附件不参与编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', folderNotAttachable: '文件夹不能作为附件添加。', folderNotAttachableUseReference: '文件夹不能作为附件添加,请改用“引用文件夹”。', imageAttachmentNotDirectTitle: '图片已作为附件添加', imageAttachmentNotDirectDescription: '当前模型不会直接接收图片。图片已作为附件提供给模型。', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', scrollMainToBottom: '滚动主对话到底部' }, + actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', interruptSendAbandonedTitle: '消息未发送', interruptSendAbandonedDescription: '停止上一轮时切换了任务,草稿已保留。', interruptSendBlockedTitle: '消息未发送', interruptSendBlockedDescription: '上一轮仍在运行,未能停止。草稿已保留,请稍后再试。', interruptSendStoppingDescription: '上一轮正在停止。草稿已保留,停止后再按 Enter 发送。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;发送后将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;可直接重发或修改后发送', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '这条消息自带的附件不参与编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', folderNotAttachable: '文件夹不能作为附件添加。', folderNotAttachableUseReference: '文件夹不能作为附件添加,请改用“引用文件夹”。', imageAttachmentNotDirectTitle: '图片已作为附件添加', imageAttachmentNotDirectDescription: '当前模型不会直接接收图片。图片已作为附件提供给模型。', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', scrollMainToBottom: '滚动主对话到底部' }, model: { fakeBackendLabel: '本地模拟连接', setupTitle: '等待配置真实模型', @@ -579,7 +581,7 @@ const COPY = { turnError: { streamTruncated: '响应中途断开。', requestRejected: '模型服务拒绝了请求,请检查模型与请求配置。', retryExhausted: '已达到自动重试次数上限。', retryDeclined: { side_effects: '本次已有工具活动,为避免重复操作,未自动重试。请先检查工具结果。', observable_output: '本次已有部分输出,未自动重试。请先检查已保留的内容。', policy: '按当前重试规则,本次未自动重试。', budget: '本次执行预算已用尽,未自动重试。' }, unknown: '出错了,暂时无法确定原因。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', timeout: '模型请求超时。', auth: '模型鉴权失败,请到设置里重新连接或登录。', providerBilling: '模型服务计费受限,请检查账号余额或订阅状态。', providerCapacity: '模型服务暂时满载。', rateLimit: '模型请求太频繁被限流了。', network: '网络连接失败,请检查网络。', provider: '模型服务返回错误。', stepCap: '达到工具调用步数上限,任务可能没做完。发消息让它继续。', emptyLoop: '连续空工具步骤没有可见进展,任务可能没做完。发消息让它继续。', tool: '工具调用失败,看一下上面的工具结果再决定要不要重试。', permission: '这一轮在等权限确认时结束了,重新发消息会再问一次。', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启时,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭。重新发消息可以再决定一次。', executionState: { erroredTool: '这一轮有工具执行出错,先看它的结果,再决定要不要重发。', toolRan: '这一轮已经执行过工具,可能已经产生实际改动,重发前先看工具结果。' } }, }, 'zh-TW': { - actions: { stopFailedTitle: '停止失敗', stopFailedFallback: '任務操作失敗,請稍後重試。', interruptSendAbandonedTitle: '訊息未傳送', interruptSendAbandonedDescription: '停止上一輪時切換了任務,草稿已保留。', interruptSendBlockedTitle: '訊息未傳送', interruptSendBlockedDescription: '上一輪仍在執行,未能停止。草稿已保留,請稍後再試。', refreshSessionsFailedTitle: '重新整理任務列表失敗', refreshSessionsFailedFallback: '重新整理任務列表失敗,請稍後重試。', conversationErrorTitle: '任務出錯', conversationErrorFallback: '任務執行失敗,請稍後重試。', branchCreatedTitle: '已建立分支', branchCreatedDescription: (name) => `新任務 ${name}`, revisionStartedTitle: '已建立修改版草稿', revisionStartedDescription: '原任務仍會保留;傳送後將在新版本中繼續', revisionReadyTitle: '可以修改並重發了', revisionReadyDescription: '已回到該訊息之前;可直接重發或修改後傳送', revisionUnavailableTitle: '暫時無法編輯這條訊息', revisionAttachmentsUnsupported: '這條訊息自帶的附件不參與編輯並重發,請複製文字後建立訊息。', revisionTransformedTextUnsupported: '透過顯式技能傳送的歷史訊息暫不支援編輯並重發,請複製文字後重新選擇技能。', revisionDraftAttachmentConflict: 'Composer 中已有待發送附件,請先發送或移除附件,再編輯歷史訊息。', revisionCommandUnsupported: '修改訊息時不能執行 /compact、/side 或編排命令,請取消修改後再試。', revisionAlreadyActive: '已有一條訊息正在修改,請先發送或取消目前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已傳送訊息', revisionBannerDetail: '· 傳送後建立新版本', operationFailedTitle: '操作失敗', operationFailedFallback: '任務操作失敗,請稍後重試。', attachmentFailedTitle: '新增附件失敗', folderNotAttachable: '資料夾不能作為附件新增。', folderNotAttachableUseReference: '資料夾不能作為附件新增,請改用「引用資料夾」。', imageAttachmentNotDirectTitle: '圖片已作為附件新增', imageAttachmentNotDirectDescription: '目前模型不會直接接收圖片。圖片已作為附件提供給模型。', tryAgain: '請稍後重試。', modelReboundTitle: '已切換到可用模型', modelReboundDescription: (modelId) => `原任務使用的連線已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '讀取任務失敗', scrollMainToBottom: '滾動主對話到底部' }, + actions: { stopFailedTitle: '停止失敗', stopFailedFallback: '任務操作失敗,請稍後重試。', interruptSendAbandonedTitle: '訊息未傳送', interruptSendAbandonedDescription: '停止上一輪時切換了任務,草稿已保留。', interruptSendBlockedTitle: '訊息未傳送', interruptSendBlockedDescription: '上一輪仍在執行,未能停止。草稿已保留,請稍後再試。', interruptSendStoppingDescription: '上一輪正在停止。草稿已保留,停止後再按 Enter 傳送。', refreshSessionsFailedTitle: '重新整理任務列表失敗', refreshSessionsFailedFallback: '重新整理任務列表失敗,請稍後重試。', conversationErrorTitle: '任務出錯', conversationErrorFallback: '任務執行失敗,請稍後重試。', branchCreatedTitle: '已建立分支', branchCreatedDescription: (name) => `新任務 ${name}`, revisionStartedTitle: '已建立修改版草稿', revisionStartedDescription: '原任務仍會保留;傳送後將在新版本中繼續', revisionReadyTitle: '可以修改並重發了', revisionReadyDescription: '已回到該訊息之前;可直接重發或修改後傳送', revisionUnavailableTitle: '暫時無法編輯這條訊息', revisionAttachmentsUnsupported: '這條訊息自帶的附件不參與編輯並重發,請複製文字後建立訊息。', revisionTransformedTextUnsupported: '透過顯式技能傳送的歷史訊息暫不支援編輯並重發,請複製文字後重新選擇技能。', revisionDraftAttachmentConflict: 'Composer 中已有待發送附件,請先發送或移除附件,再編輯歷史訊息。', revisionCommandUnsupported: '修改訊息時不能執行 /compact、/side 或編排命令,請取消修改後再試。', revisionAlreadyActive: '已有一條訊息正在修改,請先發送或取消目前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已傳送訊息', revisionBannerDetail: '· 傳送後建立新版本', operationFailedTitle: '操作失敗', operationFailedFallback: '任務操作失敗,請稍後重試。', attachmentFailedTitle: '新增附件失敗', folderNotAttachable: '資料夾不能作為附件新增。', folderNotAttachableUseReference: '資料夾不能作為附件新增,請改用「引用資料夾」。', imageAttachmentNotDirectTitle: '圖片已作為附件新增', imageAttachmentNotDirectDescription: '目前模型不會直接接收圖片。圖片已作為附件提供給模型。', tryAgain: '請稍後重試。', modelReboundTitle: '已切換到可用模型', modelReboundDescription: (modelId) => `原任務使用的連線已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '讀取任務失敗', scrollMainToBottom: '滾動主對話到底部' }, model: { fakeBackendLabel: '本地模擬連線', setupTitle: '等待設定真實模型', @@ -813,7 +815,7 @@ const COPY = { turnError: { streamTruncated: '回應中途斷開。', requestRejected: '模型服務拒絕了請求,請檢查模型與請求設定。', retryExhausted: '已達到自動重試次數上限。', retryDeclined: { side_effects: '本次已有工具活動,為避免重複操作,未自動重試。請先檢查工具結果。', observable_output: '本次已有部分輸出,未自動重試。請先檢查已保留的內容。', policy: '依目前重試規則,本次未自動重試。', budget: '本次執行預算已用盡,未自動重試。' }, unknown: '出錯了,暫時無法確定原因。', contextOverflow: '上下文超出模型視窗限制,減少附件或開啟新任務。', timeout: '模型請求逾時。', auth: '模型鑑權失敗,請到設定裡重新連線或登入。', providerBilling: '模型服務計費受限,請檢查帳號餘額或訂閱狀態。', providerCapacity: '模型服務暫時滿載。', rateLimit: '模型請求太頻繁而受到速率限制。', network: '網路連線失敗,請檢查網路。', provider: '模型服務回傳錯誤。', stepCap: '達到工具呼叫步數上限,任務可能尚未完成。傳送訊息讓它繼續。', emptyLoop: '連續空工具步驟沒有可見進展,任務可能尚未完成。傳送訊息讓它繼續。', tool: '工具呼叫失敗,先看上面的工具結果再決定是否重試。', permission: '這一輪在等待權限確認時結束,重新傳送訊息會再詢問一次。', restarted: '本機應用程式重啟,上一輪沒有完成', sandboxBoundaryClosed: '本機應用程式重啟時,等待確認的「允許存取工作區以外的內容」請求已按拒絕關閉。重新傳送訊息可以再次決定。', executionState: { erroredTool: '這一輪有工具執行出錯,先看它的結果,再決定是否重發。', toolRan: '這一輪已經執行過工具,可能已經產生實際變更,重發前先看工具結果。' } }, }, en: { - actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', interruptSendAbandonedTitle: 'Message not sent', interruptSendAbandonedDescription: 'You switched tasks while the previous turn was stopping. Your draft was kept.', interruptSendBlockedTitle: 'Message not sent', interruptSendBlockedDescription: 'The previous turn is still running and could not be stopped. Your draft was kept — try again.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; resend as is or edit first', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: "A message's own attachments are not rewritten by edit & resend. Copy the text into a new message instead.", revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', folderNotAttachable: 'Folders cannot be added as attachments.', folderNotAttachableUseReference: 'Folders cannot be added as attachments. Use Reference folder instead.', imageAttachmentNotDirectTitle: 'Image added as an attachment', imageAttachmentNotDirectDescription: 'The current model does not receive images directly. The image has been provided as an attachment.', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', scrollMainToBottom: 'Scroll main conversation to bottom' }, + actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', interruptSendAbandonedTitle: 'Message not sent', interruptSendAbandonedDescription: 'You switched tasks while the previous turn was stopping. Your draft was kept.', interruptSendBlockedTitle: 'Message not sent', interruptSendBlockedDescription: 'The previous turn is still running and could not be stopped. Your draft was kept — try again.', interruptSendStoppingDescription: 'The previous turn is still stopping. Your draft was kept — press Enter again once it stops.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; resend as is or edit first', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: "A message's own attachments are not rewritten by edit & resend. Copy the text into a new message instead.", revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', folderNotAttachable: 'Folders cannot be added as attachments.', folderNotAttachableUseReference: 'Folders cannot be added as attachments. Use Reference folder instead.', imageAttachmentNotDirectTitle: 'Image added as an attachment', imageAttachmentNotDirectDescription: 'The current model does not receive images directly. The image has been provided as an attachment.', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', scrollMainToBottom: 'Scroll main conversation to bottom' }, model: { fakeBackendLabel: 'Local simulation', setupTitle: 'Configure a real model', diff --git a/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts b/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts index 26e8205264..ab91626358 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts @@ -30,6 +30,7 @@ import type { import type { PendingAttachment } from '@maka/ui/composer-attachments'; import type { ComposerStagingSubmission } from '../model/composer-staging-contract.js'; import { interruptBeforeRootSend } from './interrupt-before-root-send.js'; +import type { StopOutcome } from './stop-action.js'; type RefBox = { current: T }; type WorkspaceFileReference = NonNullable[number]; @@ -129,7 +130,7 @@ export interface RevisionSendPorts { * doubles that only exercise revision/slash routing can omit it. */ interrupt?: { - stop: (sessionId?: string, expectedTurnId?: string) => Promise; + stop: (sessionId?: string, expectedTurnId?: string) => Promise; liveTurns: (sessionId: string) => readonly { turnId: string; terminal?: boolean }[] | undefined; runningTurnIds: (sessionId: string) => readonly string[] | undefined; activeSessionId: () => string | undefined; diff --git a/apps/desktop/src/renderer/features/conversation/controller/interrupt-before-root-send.ts b/apps/desktop/src/renderer/features/conversation/controller/interrupt-before-root-send.ts index 0f93b4bf10..5bc866c5d9 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/interrupt-before-root-send.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/interrupt-before-root-send.ts @@ -19,6 +19,7 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; +import type { StopOutcome } from './stop-action.js'; export type LiveTurnAtSubmit = { turnId: string; @@ -85,16 +86,16 @@ export async function interruptBeforeRootSend(input: { liveTurns?: readonly LiveTurnAtSubmit[]; runningTurnIds?: readonly string[]; /** - * Re-read after a no-op / failed stop. When the pinned turn finished on its - * own, nothing is active and the root send may proceed; when another turn is - * running, refuse with a toast instead of dropping Enter silently (#4083). + * Re-read after a stop that found nothing to interrupt. When the pinned turn + * finished on its own the root send proceeds; when another turn is running, + * refuse with a toast instead of dropping Enter silently (#4083). */ refreshActiveTurn?: () => { liveTurns?: readonly LiveTurnAtSubmit[]; runningTurnIds?: readonly string[]; }; activeSessionId: () => string | undefined; - stop: (sessionId?: string, expectedTurnId?: string) => Promise; + stop: (sessionId?: string, expectedTurnId?: string) => Promise; toastApi?: { error(title: string, description?: string): void; }; @@ -108,23 +109,29 @@ export async function interruptBeforeRootSend(input: { liveTurns: input.liveTurns, runningTurnIds: input.runningTurnIds, }); - const stopped = await input.stop(input.sessionId, expectedTurnId); - if (!stopped) { + const reportNotSent = (description: 'blocked' | 'stopping') => { + if (!input.toastApi || !input.uiLocale) return; + const copy = getDesktopConversationCopy(input.uiLocale).actions; + input.toastApi.error( + copy.interruptSendBlockedTitle, + description === 'blocked' ? copy.interruptSendBlockedDescription : copy.interruptSendStoppingDescription, + ); + }; + const outcome = await input.stop(input.sessionId, expectedTurnId); + // The stop action already reported its own failure. + if (outcome === 'failed') return false; + if (outcome === 'busy') { + reportNotSent('stopping'); + return false; + } + if (outcome === 'not_running') { const refreshed = input.refreshActiveTurn?.() ?? { liveTurns: input.liveTurns, runningTurnIds: input.runningTurnIds, }; - if (!hasActiveTurnAtSubmit(refreshed)) { - // Pinned turn finished between Enter and stop settlement — admit the send. - } else { - // Still busy (or a different turn started). Keep the draft and say so. - if (input.toastApi && input.uiLocale) { - const copy = getDesktopConversationCopy(input.uiLocale).actions; - input.toastApi.error( - copy.interruptSendBlockedTitle, - copy.interruptSendBlockedDescription, - ); - } + // The pinned turn may have finished on its own; another live turn blocks the send. + if (hasActiveTurnAtSubmit(refreshed)) { + reportNotSent('blocked'); return false; } } diff --git a/apps/desktop/src/renderer/features/conversation/controller/stop-action.ts b/apps/desktop/src/renderer/features/conversation/controller/stop-action.ts index 9a289593bb..e632881f1f 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/stop-action.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/stop-action.ts @@ -32,6 +32,12 @@ type ToastApi = { ): void; }; +/** + * What one stop request did. `failed` has already been toasted; `busy` means a + * stop for the Session is in flight that this action cannot await. + */ +export type StopOutcome = 'interrupted' | 'not_running' | 'failed' | 'busy'; + export function createStopAction(deps: { services: Pick; uiLocale: UiLocale; @@ -39,7 +45,9 @@ export function createStopAction(deps: { stopPending: SessionPendingClaim; removeTransientMessage: (sessionId: string, messageId: string) => void; toastApi: ToastApi; -}): (sessionId?: string, expectedTurnId?: string) => Promise { + /** Stops in flight by Session. Must outlive one render so a second caller can await the first. */ + inFlight?: Map>; +}): (sessionId?: string, expectedTurnId?: string) => Promise { const { services, uiLocale, @@ -47,20 +55,18 @@ export function createStopAction(deps: { stopPending, removeTransientMessage, toastApi, + inFlight = new Map>(), } = deps; - return async (sessionId = activeIdRef.current, expectedTurnId?: string) => { - if (!sessionId || !stopPending.claim(sessionId)) return; + async function stopSession(sessionId: string, expectedTurnId: string | undefined): Promise { try { const result = await services.stop(sessionId, { source: 'stop_button', ...(expectedTurnId ? { expectedTurnId } : {}), }); - if (result?.kind === 'interrupted') { - for (const id of result.retractedMessageIds) removeTransientMessage(sessionId, id); - } - // Enter path: Host no-op must not look like a successful interrupt (#4083). - return result?.kind === 'interrupted'; + if (result?.kind !== 'interrupted') return 'not_running'; + for (const id of result.retractedMessageIds) removeTransientMessage(sessionId, id); + return 'interrupted'; } catch (error) { // Composer Stop / Escape call onStop without awaiting; toast so a failed // interrupt is visible instead of an UnhandledPromiseRejection. @@ -73,8 +79,22 @@ export function createStopAction(deps: { { sessionId }, ); } + return 'failed'; } finally { stopPending.release(sessionId); } + } + + return async (sessionId = activeIdRef.current, expectedTurnId?: string) => { + if (!sessionId) return 'not_running'; + const pending = inFlight.get(sessionId); + if (pending) return pending; + if (!stopPending.claim(sessionId)) return 'busy'; + const stopping = stopSession(sessionId, expectedTurnId); + inFlight.set(sessionId, stopping); + void stopping.finally(() => { + if (inFlight.get(sessionId) === stopping) inFlight.delete(sessionId); + }); + return stopping; }; } diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts index b2f9745bda..7d0e35da3c 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts @@ -41,7 +41,7 @@ import { useConversationOwner } from '../ui/conversation-context.js'; import { useConversationQueueCommands } from '../ui/conversation-provider.js'; import { createChatActions } from './chat-actions.js'; import { createRevisionAwareOnSend, createStagedFollowUp } from './composer-submit.js'; -import { createStopAction } from './stop-action.js'; +import { createStopAction, type StopOutcome } from './stop-action.js'; import { createTurnActions } from './turn-actions.js'; import { useTurnActionRegistry } from './use-turn-action-registry.js'; import { useShellResume } from './use-shell-resume.js'; @@ -166,6 +166,7 @@ export function useComposerSubmission(input: // The Composer's Stop button, Escape and a question prompt's Stop all land // here; the send slot may then offer Resume for the stopped Turn (#5923). // Built before onSend so plain-Enter interrupt can pin the same stop path. + const [inFlightStops] = useState(() => new Map>()); const { stopSession } = useStableActions((deps: Parameters[0]) => ({ stopSession: createStopAction(deps), }), { @@ -175,6 +176,7 @@ export function useComposerSubmission(input: stopPending: workspace.ui.stopPending, removeTransientMessage: commands.removeTransientMessage, toastApi, + inFlight: inFlightStops, }); const stop = useCallback(() => { void stopSession(); From 2d8d5317c2302cd6f83ac9e0b807b96c66be65b2 Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Sun, 4 Oct 2026 14:49:07 +0800 Subject: [PATCH 18/20] chore(protocol): take epoch 206 to avoid colliding with #5709 Open #5709 already claims compatibility epoch 205 for UsageQuery.callKinds. Move empty_step_loop's wire break to 206 so the two incompatible changes do not share one epoch, per Astro-Han review on #4138. Generated-by: Cursor Co-authored-by: Cursor --- packages/runtime-host/src/__tests__/protocol.test.ts | 4 +++- packages/runtime-host/src/protocol/index.ts | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index da4ae7c80f..0cecd2a49f 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -250,7 +250,9 @@ describe('Runtime Host bootstrap protocol', () => { test('publishes a new compatibility epoch for empty_step_loop system notes', () => { // Epoch 204 peers reject the unknown `empty_step_loop` system_note kind when // decoding Session transcripts after the Runtime empty-assistant-loop bound. - assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 204); + // Open #5709 already claims 205 for UsageQuery.callKinds, so this change + // publishes 206 and must stay strictly above that colliding allocation. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 205); }); test('publishes a new compatibility epoch for the project registration preference', () => { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 08bf55f128..f40c56cba6 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -104,10 +104,11 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 205 as const; -// 205: Session transcripts gain the `empty_step_loop` `system_note` kind when the +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 206 as const; +// 206: Session transcripts gain the `empty_step_loop` `system_note` kind when the // Runtime empty-assistant-loop bound fires (#4083 / #4138). Older Clients reject -// the unknown note kind at decode, so the pair must fail admission. +// the unknown note kind at decode, so the pair must fail admission. Takes 206 +// rather than 205 because open #5709 already claims 205 for UsageQuery.callKinds. // 204: Executor catalogs and Session configuration carry opaque mode IDs; // catalog queries may request a provider refresh. Older peers reject these fields. // 202: `session.remove.preview` takes a bounded list of Sessions and reports the From 47e2a74aff75d67a29ab232852bc8f92cbb108da Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Sun, 4 Oct 2026 16:03:04 +0800 Subject: [PATCH 19/20] chore(protocol): word the epoch 205 reservation durably "Open #5709 already claims 205" goes stale once #5709 merges or closes. State it in the ledger as "205 is reserved by #5709 (UsageQuery.callKinds)" and drop the #5709 aside from the epoch guard test, which only needs to say why epoch 204 peers are rejected. The ledger edit is comment-only, so it carries a compatible-change declaration at epoch 206 instead of a bump. Per Astro-Han review on #4138. Generated-by: Cursor Co-authored-by: Cursor --- .../epoch-206-ledger-wording.json | 5 +++++ packages/runtime-host/src/__tests__/protocol.test.ts | 2 -- packages/runtime-host/src/protocol/index.ts | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 packages/runtime-host/protocol-compatible-changes/epoch-206-ledger-wording.json diff --git a/packages/runtime-host/protocol-compatible-changes/epoch-206-ledger-wording.json b/packages/runtime-host/protocol-compatible-changes/epoch-206-ledger-wording.json new file mode 100644 index 0000000000..b85f936d9c --- /dev/null +++ b/packages/runtime-host/protocol-compatible-changes/epoch-206-ledger-wording.json @@ -0,0 +1,5 @@ +{ + "epoch": 206, + "files": ["packages/runtime-host/src/protocol/index.ts"], + "reason": "Rewords the epoch 206 ledger comment to record that 205 is reserved by #5709 instead of saying an open PR claims it. Comment-only: RUNTIME_HOST_COMPATIBILITY_EPOCH, every export, codec and message shape are unchanged, so no peer can observe it." +} diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 0cecd2a49f..b83f86f0a4 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -250,8 +250,6 @@ describe('Runtime Host bootstrap protocol', () => { test('publishes a new compatibility epoch for empty_step_loop system notes', () => { // Epoch 204 peers reject the unknown `empty_step_loop` system_note kind when // decoding Session transcripts after the Runtime empty-assistant-loop bound. - // Open #5709 already claims 205 for UsageQuery.callKinds, so this change - // publishes 206 and must stay strictly above that colliding allocation. assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 205); }); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index f40c56cba6..3e6d32eb9f 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -107,8 +107,8 @@ export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 206 as const; // 206: Session transcripts gain the `empty_step_loop` `system_note` kind when the // Runtime empty-assistant-loop bound fires (#4083 / #4138). Older Clients reject -// the unknown note kind at decode, so the pair must fail admission. Takes 206 -// rather than 205 because open #5709 already claims 205 for UsageQuery.callKinds. +// the unknown note kind at decode, so the pair must fail admission. 205 is +// reserved by #5709 (UsageQuery.callKinds). // 204: Executor catalogs and Session configuration carry opaque mode IDs; // catalog queries may request a provider refresh. Older peers reject these fields. // 202: `session.remove.preview` takes a bounded list of Sessions and reports the From aa1b4b450162156b49a49c4c759d1561021101fb Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Sun, 4 Oct 2026 19:50:43 +0800 Subject: [PATCH 20/20] chore(protocol): drop redundant epoch 206 wording declaration The ledger rewording in 47e2a74af is comment-only within this PR's 204 -> 206 bump. Against main the epoch moves, so evaluateEpochCheck ignores declarations and this file granted nothing at merge. Once landed, its "epoch unchanged" reason would misread as declaring the 206 index.ts change wire-compatible, and it would need re-pinning on any re-bump. Per Astro-Han review on #4138. Generated-by: Cursor Co-authored-by: Cursor --- .../epoch-206-ledger-wording.json | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 packages/runtime-host/protocol-compatible-changes/epoch-206-ledger-wording.json diff --git a/packages/runtime-host/protocol-compatible-changes/epoch-206-ledger-wording.json b/packages/runtime-host/protocol-compatible-changes/epoch-206-ledger-wording.json deleted file mode 100644 index b85f936d9c..0000000000 --- a/packages/runtime-host/protocol-compatible-changes/epoch-206-ledger-wording.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "epoch": 206, - "files": ["packages/runtime-host/src/protocol/index.ts"], - "reason": "Rewords the epoch 206 ledger comment to record that 205 is reserved by #5709 instead of saying an open PR claims it. Comment-only: RUNTIME_HOST_COMPATIBILITY_EPOCH, every export, codec and message shape are unchanged, so no peer can observe it." -}