diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts index d39e3b6ef8..f41d1af67c 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/main/__tests__/app-shell-stop-action.test.ts b/apps/desktop/src/main/__tests__/app-shell-stop-action.test.ts index 171c05c8bd..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,8 +46,7 @@ test('removes exactly the transient messages the Host retracts while stopping', toastApi: { error() {} }, }); - await stop(); - + assert.equal(await stop(), 'interrupted'); assert.deepEqual(removed, [ { sessionId: 'session-1', messageId: 'message-1' }, { sessionId: 'session-1', messageId: 'message-2' }, @@ -56,3 +55,172 @@ test('removes exactly the transient messages the Host retracts while stopping', target.window = previousWindow; } }); + +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[] = []; + target.window = { + maka: { + sessions: { + stop: async () => { + throw new Error('stop failed'); + }, + }, + }, + }; + try { + const stop = createStopAction({ + services: windowSubmissionServices(), + uiLocale: 'en', + activeIdRef: { current: 'session-1' }, + stopPending: { claim: () => true, release: () => undefined }, + removeTransientMessage: () => undefined, + toastApi: { + error(title) { + errors.push(title); + }, + }, + }); + + assert.equal(await stop(), 'failed'); + assert.equal(errors.length, 1); + } finally { + target.window = previousWindow; + } +}); + +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 }> = []; + 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 = createStopAction({ + services: windowSubmissionServices(), + uiLocale: 'en', + activeIdRef: { current: 'session-1' }, + stopPending: { claim: () => true, release: () => undefined }, + removeTransientMessage: () => undefined, + toastApi: { error() {} }, + }); + + assert.equal(await stop('session-1', 'turn-a'), 'not_running'); + 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; + const stopped: Array<{ sessionId: string; options: unknown }> = []; + 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, options?: unknown) => { + stopped.push({ sessionId, options }); + await gate; + return { kind: 'interrupted', retractedMessageIds: [] }; + }, + }, + }, + }; + try { + const stop = createStopAction({ + services: windowSubmissionServices(), + uiLocale: 'en', + activeIdRef, + stopPending: { claim: () => true, release: () => undefined }, + removeTransientMessage: () => undefined, + toastApi: { error() {} }, + }); + + const pending = stop('session-a', 'turn-1'); + activeIdRef.current = 'session-b'; + release(); + assert.equal(await pending, 'interrupted'); + assert.deepEqual(stopped, [ + { sessionId: 'session-a', options: { source: 'stop_button', expectedTurnId: 'turn-1' } }, + ]); + } finally { + 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 90ff94d15d..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 @@ -20,10 +20,262 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { + createStopAction, + hasActiveTurnAtSubmit, + interruptBeforeRootSend, mergeWorkspaceReferences, + resolveExpectedTurnIdForInterrupt, + shouldContinueRootSendAfterInterrupt, } from '../../renderer/features/conversation/testing.js'; +import { windowSubmissionServices } from './app-shell-chat-actions-fixture.js'; describe('follow-up submit routing', () => { + it('uses the synchronous turn arm before React publishes streaming state', () => { + assert.equal( + hasActiveTurnAtSubmit({ + liveTurns: [{ turnId: 'turn-1' }], + runningTurnIds: [], + }), + true, + ); + }); + + it('ignores a terminal projection whose only running id is the same turn', () => { + assert.equal( + hasActiveTurnAtSubmit({ + liveTurns: [{ turnId: 'turn-1', terminal: true }], + runningTurnIds: ['turn-1'], + }), + false, + ); + }); + + it('treats a non-terminal buffer entry as active even when a terminal turn is retained', () => { + assert.equal( + hasActiveTurnAtSubmit({ + liveTurns: [ + { turnId: 'turn-1', terminal: true }, + { turnId: 'turn-2' }, + ], + runningTurnIds: ['turn-1'], + }), + true, + ); + }); + + it('ignores multiple retained terminal turns whose running ids are already settled', () => { + assert.equal( + hasActiveTurnAtSubmit({ + liveTurns: [ + { turnId: 'turn-1', terminal: true }, + { turnId: 'turn-2', terminal: true }, + ], + runningTurnIds: ['turn-1', 'turn-2'], + }), + false, + ); + }); + + it('treats a running turn outside the retained terminal buffer as active', () => { + assert.equal( + hasActiveTurnAtSubmit({ + liveTurns: [{ turnId: 'turn-1', terminal: true }], + runningTurnIds: ['turn-1', 'turn-2'], + }), + true, + ); + }); + + 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('pins the submitting Session across an awaited interrupt before root send', async () => { + 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({ + sessionId: 'session-a', + slashCommand: undefined, + liveTurns: [{ turnId: 'turn-1' }], + runningTurnIds: [], + activeSessionId: () => activeIdRef.current, + stop: async (sessionId, expectedTurnId) => { + stopped.push({ sessionId: sessionId ?? '', expectedTurnId }); + activeIdRef.current = 'session-b'; + return 'interrupted' as const; + }, + uiLocale: 'en', + toastApi: { + error(title, description) { + errors.push({ title, description }); + }, + }, + }), + false, + ); + assert.deepEqual(stopped, [{ sessionId: 'session-a', expectedTurnId: 'turn-1' }]); + assert.equal(errors.length, 1); + assert.match(errors[0]?.title ?? '', /not sent/i); + }); + + it('blocks root send when Host stop no-ops and a turn is still active', async () => { + const target = globalThis as unknown as { window?: unknown }; + const previousWindow = target.window; + const errors: Array<{ title: string; description?: string }> = []; + target.window = { + maka: { + sessions: { + // expectedTurnId no longer matches — Host returns undefined. + stop: async () => undefined, + }, + }, + }; + try { + const stop = createStopAction({ + services: windowSubmissionServices(), + 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: [], + refreshActiveTurn: () => ({ + liveTurns: [{ turnId: 'turn-b' }], + runningTurnIds: ['turn-b'], + }), + activeSessionId: () => 'session-a', + stop, + uiLocale: 'en', + toastApi: { + error(title, description) { + errors.push({ title, description }); + }, + }, + }); + assert.equal(rootSendAllowed, false); + assert.equal(errors.length, 1); + assert.match(errors[0]?.title ?? '', /not sent/i); + } finally { + target.window = previousWindow; + } + }); + + it('admits root send when Host stop no-ops but nothing is active anymore', async () => { + assert.equal( + await interruptBeforeRootSend({ + sessionId: 'session-a', + slashCommand: undefined, + liveTurns: [{ turnId: 'turn-a' }], + runningTurnIds: [], + refreshActiveTurn: () => ({ + liveTurns: [{ turnId: 'turn-a', terminal: true }], + runningTurnIds: [], + }), + activeSessionId: () => 'session-a', + 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( + 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 'interrupted' as const; + }, + }), + 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', () => { assert.deepEqual( mergeWorkspaceReferences( 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 a8ce197a9e..137d27793a 100644 --- a/apps/desktop/src/main/__tests__/session-status-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/session-status-presentation.test.ts @@ -74,6 +74,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/application/contracts/conversation-copy.ts b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts index d3b1d2e848..23426535a8 100644 --- a/apps/desktop/src/renderer/application/contracts/conversation-copy.ts +++ b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts @@ -30,6 +30,14 @@ export interface DesktopConversationCopy { actions: { stopFailedTitle: string; stopFailedFallback: string; + /** Plain-Enter interrupt finished, but the active Session moved before root send. */ + interruptSendAbandonedTitle: string; + interruptSendAbandonedDescription: string; + /** 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; @@ -244,6 +252,7 @@ export interface DesktopConversationCopy { network: string; provider: string; stepCap: string; + emptyLoop: string; tool: string; permission: string; restarted: string; @@ -329,7 +338,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: '· 发送后创建新版本', 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: '等待配置真实模型', @@ -569,10 +578,10 @@ const COPY = { reauth: { label: '上次连接测试鉴权失败', tooltip: '最近一次连接测试返回鉴权失败(401 / 403),密钥可能已过期或被吊销。这不会拦截发送,但若发送失败请到 设置 · 模型 重新登录。' }, testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' }, }, - turnError: { streamTruncated: '响应中途断开。', requestRejected: '模型服务拒绝了请求,请检查模型与请求配置。', retryExhausted: '已达到自动重试次数上限。', retryDeclined: { side_effects: '本次已有工具活动,为避免重复操作,未自动重试。请先检查工具结果。', observable_output: '本次已有部分输出,未自动重试。请先检查已保留的内容。', policy: '按当前重试规则,本次未自动重试。', budget: '本次执行预算已用尽,未自动重试。' }, unknown: '出错了,暂时无法确定原因。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', timeout: '模型请求超时。', auth: '模型鉴权失败,请到设置里重新连接或登录。', providerBilling: '模型服务计费受限,请检查账号余额或订阅状态。', providerCapacity: '模型服务暂时满载。', rateLimit: '模型请求太频繁被限流了。', network: '网络连接失败,请检查网络。', provider: '模型服务返回错误。', stepCap: '达到工具调用步数上限,任务可能没做完。发消息让它继续。', tool: '工具调用失败,看一下上面的工具结果再决定要不要重试。', permission: '这一轮在等权限确认时结束了,重新发消息会再问一次。', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启时,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭。重新发消息可以再决定一次。', executionState: { erroredTool: '这一轮有工具执行出错,先看它的结果,再决定要不要重发。', toolRan: '这一轮已经执行过工具,可能已经产生实际改动,重发前先看工具结果。' } }, + 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: '· 傳送後建立新版本', 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: '等待設定真實模型', @@ -803,10 +812,10 @@ const COPY = { reauth: { label: '上次連線測試鑑權失敗', tooltip: '最近一次連線測試回傳鑑權失敗(401 / 403),金鑰可能已過期或被吊銷。這不會攔截發送,但若傳送失敗請到 設定 · 模型 重新登入。' }, testError: { label: '上次連線測試失敗', tooltip: '最近一次連線測試因網路 / 超時 / 5xx 失敗。這不會攔截發送,但若問題持續請到 設定 · 模型 檢查 Base URL / 代理。' }, }, - turnError: { streamTruncated: '回應中途斷開。', requestRejected: '模型服務拒絕了請求,請檢查模型與請求設定。', retryExhausted: '已達到自動重試次數上限。', retryDeclined: { side_effects: '本次已有工具活動,為避免重複操作,未自動重試。請先檢查工具結果。', observable_output: '本次已有部分輸出,未自動重試。請先檢查已保留的內容。', policy: '依目前重試規則,本次未自動重試。', budget: '本次執行預算已用盡,未自動重試。' }, unknown: '出錯了,暫時無法確定原因。', contextOverflow: '上下文超出模型視窗限制,減少附件或開啟新任務。', timeout: '模型請求逾時。', auth: '模型鑑權失敗,請到設定裡重新連線或登入。', providerBilling: '模型服務計費受限,請檢查帳號餘額或訂閱狀態。', providerCapacity: '模型服務暫時滿載。', rateLimit: '模型請求太頻繁而受到速率限制。', network: '網路連線失敗,請檢查網路。', provider: '模型服務回傳錯誤。', stepCap: '達到工具呼叫步數上限,任務可能尚未完成。傳送訊息讓它繼續。', tool: '工具呼叫失敗,先看上面的工具結果再決定是否重試。', permission: '這一輪在等待權限確認時結束,重新傳送訊息會再詢問一次。', restarted: '本機應用程式重啟,上一輪沒有完成', sandboxBoundaryClosed: '本機應用程式重啟時,等待確認的「允許存取工作區以外的內容」請求已按拒絕關閉。重新傳送訊息可以再次決定。', executionState: { erroredTool: '這一輪有工具執行出錯,先看它的結果,再決定是否重發。', toolRan: '這一輪已經執行過工具,可能已經產生實際變更,重發前先看工具結果。' } }, + 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; 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', @@ -1053,7 +1062,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: { streamTruncated: 'The response stream ended before completion.', requestRejected: 'The model service rejected the request. Check the model and request configuration.', retryExhausted: 'The automatic retry limit was reached.', retryDeclined: { side_effects: 'Tool activity already occurred in this attempt. Automatic retry was declined to avoid repeating operations. Check the tool results first.', observable_output: 'This attempt already produced output, so it was not retried automatically. Check the retained content first.', policy: 'This attempt was not retried under the current retry policy.', budget: 'The execution budget was exhausted, so this attempt was not retried automatically.' }, unknown: 'Something went wrong; the cause is unknown.', contextOverflow: 'Context exceeded the model window. Reduce attachments or start a new task.', timeout: 'The model request timed out.', 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.', rateLimit: 'Requests were rate-limited.', network: 'The network connection failed. Check the network.', provider: 'The model service returned an error.', 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.' } }, + turnError: { streamTruncated: 'The response stream ended before completion.', requestRejected: 'The model service rejected the request. Check the model and request configuration.', retryExhausted: 'The automatic retry limit was reached.', retryDeclined: { side_effects: 'Tool activity already occurred in this attempt. Automatic retry was declined to avoid repeating operations. Check the tool results first.', observable_output: 'This attempt already produced output, so it was not retried automatically. Check the retained content first.', policy: 'This attempt was not retried under the current retry policy.', budget: 'The execution budget was exhausted, so this attempt was not retried automatically.' }, unknown: 'Something went wrong; the cause is unknown.', contextOverflow: 'Context exceeded the model window. Reduce attachments or start a new task.', timeout: 'The model request timed out.', 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.', rateLimit: 'Requests were rate-limited.', network: 'The network connection failed. Check the network.', provider: 'The model service returned an error.', 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.' } }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/application/contracts/session-status-presentation.ts b/apps/desktop/src/renderer/application/contracts/session-status-presentation.ts index 05a848c4e5..418f2409bd 100644 --- a/apps/desktop/src/renderer/application/contracts/session-status-presentation.ts +++ b/apps/desktop/src/renderer/application/contracts/session-status-presentation.ts @@ -99,6 +99,7 @@ export function describeTurnErrorClass(errorClass: string | undefined, locale: U case 'ratelimit': return copy.rateLimit; case 'requestrejected': return copy.requestRejected; case 'tool_step_cap_reached': return copy.stepCap; + case 'empty_assistant_loop': return copy.emptyLoop; case 'tool_failed': return copy.tool; case 'permission_required': return copy.permission; case 'app_restarted': return copy.restarted; @@ -124,6 +125,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/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts b/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts index f26988ef18..ab91626358 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,8 @@ 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'; +import type { StopOutcome } from './stop-action.js'; type RefBox = { current: T }; type WorkspaceFileReference = NonNullable[number]; @@ -123,6 +125,28 @@ export interface RevisionSendPorts { mode: Exclude, active: boolean, ) => Promise; + /** + * Plain-Enter interrupt before a new root send (#4083). Optional so unit + * doubles that only exercise revision/slash routing can omit it. + */ + 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; + }; +} + +function interruptActiveTurnSnapshot( + interrupt: NonNullable['interrupt']>, + sessionId: string | undefined, +) { + return { + liveTurns: sessionId ? interrupt.liveTurns(sessionId) : undefined, + runningTurnIds: sessionId ? interrupt.runningTurnIds(sessionId) : undefined, + }; } export interface RevisionAwareOnSendPorts extends RevisionSendPorts { @@ -364,6 +388,22 @@ export async function revisionAwareSend( ? ports.revisionDraftRef.current : undefined; const quotes = staging.quotesForSend(); + // #4083: plain Enter interrupts the live turn before a new root send. + if (ports.interrupt) { + const interrupt = ports.interrupt; + const atSubmit = interruptActiveTurnSnapshot(interrupt, sessionId); + const allowed = await interruptBeforeRootSend({ + sessionId, + slashCommand, + ...atSubmit, + refreshActiveTurn: () => interruptActiveTurnSnapshot(interrupt, sessionId), + activeSessionId: interrupt.activeSessionId, + stop: interrupt.stop, + toastApi: interrupt.toastApi, + uiLocale: interrupt.uiLocale, + }); + if (!allowed) return false; + } const ok = await ports.send(text, pending, { waitForHostAdmission: revisionSend, targetSessionId: expectedRevisionDraft?.draftSessionId, 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..5bc866c5d9 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/interrupt-before-root-send.ts @@ -0,0 +1,152 @@ +/* + * 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'; +import type { StopOutcome } from './stop-action.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. 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). + */ +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[]; + /** + * 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; + 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, + }); + 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, + }; + // The pinned turn may have finished on its own; another live turn blocks the send. + if (hasActiveTurnAtSubmit(refreshed)) { + reportNotSent('blocked'); + 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/controller/stop-action.ts b/apps/desktop/src/renderer/features/conversation/controller/stop-action.ts index 8b3b319e60..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; -}): () => 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,25 +55,21 @@ export function createStopAction(deps: { stopPending, removeTransientMessage, toastApi, + inFlight = new Map>(), } = deps; - async function stop() { - const sessionId = activeIdRef.current; - 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' }); - if (result?.kind === 'interrupted') { - for (const messageId of result.retractedMessageIds) { - removeTransientMessage(sessionId, messageId); - } - } + const result = await services.stop(sessionId, { + source: 'stop_button', + ...(expectedTurnId ? { expectedTurnId } : {}), + }); + if (result?.kind !== 'interrupted') return 'not_running'; + for (const id of result.retractedMessageIds) removeTransientMessage(sessionId, id); + return 'interrupted'; } 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( @@ -75,10 +79,22 @@ export function createStopAction(deps: { { sessionId }, ); } + return 'failed'; } finally { stopPending.release(sessionId); } } - return stop; + 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 72174df642..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 @@ -25,6 +25,7 @@ import { activeHostTurn } from '../../../application/contracts/session-execution import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; import { parseDesktopSlashCommand } from '../../../application/contracts/desktop-slash-command.js'; import { catalogWatchedRowsUsable } from '../../../application/contracts/session-catalog/catalog-row-watch.js'; +import { useSessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js'; import { useStableActions } from '../../../application/contracts/use-stable-actions.js'; import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import type { @@ -40,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'; @@ -68,6 +69,7 @@ export function useComposerSubmission(input: const { staging, shell, newTask, sharedSessionActive, ownerSessionId } = input; const services = useComposerSubmissionServices(); const { workspace, commands } = useConversationOwner(); + const sessionCatalog = useSessionCatalogController(); const queue = useConversationQueueCommands(); const composerRef = queue.composer; const uiLocale = useUiLocale(); @@ -161,6 +163,25 @@ export function useComposerSubmission(input: toastApi, }); + // 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), + }), { + services, + uiLocale, + activeIdRef, + stopPending: workspace.ui.stopPending, + removeTransientMessage: commands.removeTransientMessage, + toastApi, + inFlight: inFlightStops, + }); + const stop = useCallback(() => { + void stopSession(); + }, [stopSession]); + // The Composer's submit callback, built by the shared factory its tests drive. const { onSend } = useStableActions((ports: Parameters>[0]) => ({ onSend: createRevisionAwareOnSend(ports), @@ -206,6 +227,16 @@ export function useComposerSubmission(input: getActiveOrchestrationMode: shell.orchestrationMode, setOrchestrationModeActive: shell.setOrchestrationModeActive, setNewTaskSendPending, + // #4083: plain Enter interrupts the live turn before a new root send. + interrupt: { + stop: stopSession, + liveTurns: (id) => workspace.ui.reads.liveTurns(id).getSnapshot(), + runningTurnIds: (id) => + sessionCatalog.getState().sessions.find((session) => session.id === id)?.runningTurnIds, + activeSessionId: () => activeIdRef.current, + toastApi, + uiLocale, + }, }); const turn = useStableActions(createTurnActions, { @@ -218,19 +249,6 @@ export function useComposerSubmission(input: refreshSessions: shell.refreshSessions, toastApi, }); - // 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). - const { stop } = useStableActions((deps: Parameters[0]) => { - const stopSession = createStopAction(deps); - return { stop: () => { void stopSession(); } }; - }, { - services, - uiLocale, - activeIdRef, - stopPending: workspace.ui.stopPending, - removeTransientMessage: commands.removeTransientMessage, - toastApi, - }); // The draft survives on exactly two catalog rows; their departure retires it. const retireRevisionDraftIfRowsLeave = useCallback( diff --git a/apps/desktop/src/renderer/features/conversation/submission-services.ts b/apps/desktop/src/renderer/features/conversation/submission-services.ts index f419af00c5..495cdc1957 100644 --- a/apps/desktop/src/renderer/features/conversation/submission-services.ts +++ b/apps/desktop/src/renderer/features/conversation/submission-services.ts @@ -83,7 +83,10 @@ export interface ComposerSubmissionServices { input: { readonly sourceTurnId: string; readonly copyId: string }, ): Promise; abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; - stop(sessionId: string, input: { readonly source: 'stop_button' }): Promise; + stop( + sessionId: string, + input: { readonly source: 'stop_button'; readonly expectedTurnId?: string }, + ): Promise; branchFromTurn( sessionId: string, input: { readonly sourceTurnId: string; readonly copyId: string }, diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index ac0dcb4bff..8bce03f875 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -149,6 +149,12 @@ export { useComposerQuotes } from './controller/use-composer-quotes.js'; export { useComposerStaging } from './ui/composer-staging-context.js'; export { deriveTaskReadinessNotice, isTaskSubmissionHardBlocked } from './model/task-readiness-notice.js'; export { mergeWorkspaceReferences, rebaseWorkspaceFileReferences } from './model/follow-up-submit-routing.js'; +export { + hasActiveTurnAtSubmit, + interruptBeforeRootSend, + resolveExpectedTurnIdForInterrupt, + shouldContinueRootSendAfterInterrupt, +} from './controller/interrupt-before-root-send.js'; export { createChatActions } from './controller/chat-actions.js'; export { completeTurnRevisionCopyAttempt, diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index bbecc4bc4d..95a4eabf0a 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -1142,7 +1142,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: '插入换行(备用)' }, @@ -1666,7 +1666,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: '插入換行(備用)' }, @@ -2202,7 +2202,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/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 5d5199b222..618de49f6c 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -32,6 +32,7 @@ import type { } from '@maka/core/events'; import { deriveTurnRecords, + EMPTY_STEP_LOOP_NOTICE_TEXT, isRuntimeSystemNoteKind, STEP_LIMIT_NOTICE_TEXT, type StoredMessage, @@ -1059,6 +1060,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; } } @@ -1395,6 +1399,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; } } diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 25cb9dc453..9d5dda5bb0 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1396,6 +1396,7 @@ export interface CompleteEvent extends BaseEvent { | 'graph_yield' | 'permission_handoff' | 'step_limit' + | 'empty_step_loop' | 'max_tokens'; /** External provider terminal reason, retained even when the caller cancelled the turn. */ providerStopReason?: string; @@ -1413,9 +1414,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 fd18305681..82290cf423 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1287,6 +1287,7 @@ export const RUNTIME_SYSTEM_NOTE_KINDS = [ 'context_reported_window_exceeded', 'context_overflow_after_compaction', 'step_limit', + 'empty_step_loop', ] as const; /** @@ -1985,6 +1986,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-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index a59cdbffff..b83f86f0a4 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -247,6 +247,12 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 192); }); + 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 > 205); + }); + test('publishes a new compatibility epoch for the project registration preference', () => { // Epoch 46 Hosts reject the optional preference field on the closed register // input, so mixed-version peers must fail during the handshake instead. diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 712f0c82c0..3e6d32eb9f 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -104,7 +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 = 204 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. 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 diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 3008d4bc3b..d397d1aab5 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -6312,6 +6312,604 @@ 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, 'empty_step_loop'); + 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, 'empty_step_loop'); + 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. + // 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', + 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 openAiConnection = { + ...connection(), + slug: 'openai-main', + providerType: 'openai' as const, + defaultModel: 'gpt-5.4', + }; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: openAiConnection, + apiKey: 'sk-test', + modelId: 'gpt-5.4', + 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, 'empty_step_loop'); + 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, 'empty_step_loop'); + 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('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('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; + 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('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, @@ -17753,7 +18351,10 @@ function planExecution(status: 'completed' | 'cancelled') { }; } -function countingToolLoopModel(toolCallsBeforeStop?: number): { +function countingToolLoopModel( + toolCallsBeforeStop?: number, + repeatToolInput = false, +): { model: MockLanguageModelV4; callCount: () => number; } { @@ -17783,7 +18384,7 @@ function countingToolLoopModel(toolCallsBeforeStop?: number): { type: 'tool-call', toolCallId: `tool-${calls}`, toolName: 'Read', - input: JSON.stringify({ path: `notes-${calls}.md` }), + input: JSON.stringify({ path: repeatToolInput ? 'notes.md' : `notes-${calls}.md` }), }, { type: 'finish', diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 31a0e812a3..7d2ec306a0 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -167,6 +167,14 @@ 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 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; /** Per provider-call reported usage, keyed by 1-based call number. */ usageByCall?: Record; /** The FIRST tool step reports an unusable usage object (no token counts). */ @@ -431,7 +439,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' @@ -1733,9 +1743,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); @@ -1747,7 +1759,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 () => { @@ -1988,10 +2000,12 @@ describe('reactive overflow recovery in the streaming backend', () => { // overflow folds and the retry succeeds; two more tool steps are accepted // by the provider, and the overflow that follows them is a different step // with new history behind it, so it gets its own fold instead of failing - // the turn. + // the turn. Distinct paths keep this growth from matching the identical + // empty-step cap (#4083). const fixture = buildReactiveFixture({ script: ['tool', 'overflow', 'tool', 'tool', 'overflow', 'done'], bigPriors: true, + distinctToolPaths: true, }); await runTurn(fixture); @@ -2004,7 +2018,7 @@ describe('reactive overflow recovery in the streaming backend', () => { assert.equal(fixture.recorded.length, 2); assert.equal(fixture.summarizerCalls(), 2); // No completed tool step was replayed across either retry. - assert.deepEqual(fixture.toolExecutions, ['one.md', 'one.md', 'one.md']); + assert.deepEqual(fixture.toolExecutions, ['one-1.md', 'one-3.md', 'one-4.md']); // The second fold covers the steps accepted after the first one, so it is // a fold of new history rather than a repeat of the same prefix. assert.equal( 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 9d3cb1760b..aa1f598bf6 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -2048,6 +2048,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 5e94c9d347..2093f824c9 100644 --- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts +++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts @@ -168,6 +168,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', () => { @@ -183,6 +184,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 09a9a769b7..0ca1fe5392 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -23,6 +23,7 @@ * construction and cross-turn routing remain in AiSdkBackend. */ +import { createHash, type Hash } from 'node:crypto'; import type { AbortEvent, CompleteEvent, @@ -583,6 +584,83 @@ const MAX_WAITING_CODE_MODE_CELLS = 1; const MAX_PROVIDER_ATTEMPTS_PER_STEP = 10; const CONTEXT_RECOVERY_MAX_OUTPUT_TOKENS = 8_000; +/** + * 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. + * + * 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; +/** + * 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 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; const PROVIDER_RETRY_JITTER_FACTOR = 0.25; @@ -1461,7 +1539,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; ({ plan, providerTools, modelTools, nestedTools } = snapshotStepTools()); resolvedSystemPrompt = await this.resolveSystemPrompt(); systemPrompt = joinPromptFragments([ @@ -1470,7 +1558,15 @@ export class AiSdkTurn { this.orchestration.mode === 'graph' ? renderGraphModePrompt() : undefined, ]); capacityProviderTools.splice(0, capacityProviderTools.length, ...providerTools); + // A mid-loop 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 { @@ -1672,7 +1768,10 @@ export class AiSdkTurn { stepTextPartStartOffset = stepText.length; } else if (event.kind === 'text') { stepText += event.text; - if (event.text.length > 0) attemptSawVisibleContent = true; + if (event.text.length > 0) { + attemptSawVisibleContent = true; + stepSawVisibleText = true; + } queue.push({ type: 'text_delta', id: this.deps.newId(), @@ -1711,7 +1810,15 @@ export class AiSdkTurn { stepThinkingPartsById.set(event.reasoningPartId, part); } } else if (event.kind === 'thinking') { - if (event.text.length > 0) attemptSawVisibleContent = 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) { + attemptSawVisibleContent = true; + stepSawThinking = true; + } if (event.providerOptions !== undefined) { if (event.providerOptionsOrigin !== 'maka_transport') { attemptSawReplayBarrier = true; @@ -2135,6 +2242,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) { @@ -2205,11 +2313,13 @@ export class AiSdkTurn { (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected', ); if (rejectedSettlement) throw rejectedSettlement.reason; + const toolResults: unknown[] = []; settlementOutcomes.forEach((outcome, index) => { // All settlements completed and rejection was checked above; // preserve provider order for Plan and Yield result handling. if (outcome.status === 'rejected') throw outcome.reason; const settlement = outcome.value; + toolResults.push(settlement.result); const toolCall = returnedToolCalls[index]; if (isPlanToolResult(settlement.result)) { this.handlePlanToolResult(settlement.result, queue); @@ -2222,6 +2332,9 @@ export class AiSdkTurn { this.handleAgentGraphYieldToolResult(settlement.result); } }); + // Only the results are kept, and only until this step's empty-step + // signature is taken below (#4083). + settledToolResults = toolResults; // Continuation reads durable events, not raw results. Do not retain // an entire completed batch across the next provider request. settlementOutcomes.length = 0; @@ -2251,6 +2364,53 @@ export class AiSdkTurn { ...(providerStepUsage ? { usage: providerStepUsage } : {}), }); lastCompletedStepHadToolResult = returnedToolCalls.length > 0; + const emptyStepSignature = + !stepSawVisibleText && + !stepSawThinking && + returnedToolCalls.length > 0 && + settledToolResults !== undefined + ? hashEmptyStepSignature( + returnedToolCalls.map(({ toolName, input }, index) => ({ + toolName, + input, + result: settledToolResults![index] ?? null, + })), + ) + : 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 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 && + distinctEmptySignatures * 2 <= recentEmptyStepSignatures.length; + if ( + consecutiveIdenticalEmptySteps >= MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS || + windowHasNoDistinctProgress + ) { + // 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 { + clearEmptyStepProgress(); + } const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps; if ( sandboxBoundaryFinalizationStep || diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index a15cf9ffac..a9670bbd27 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -1534,6 +1534,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 75175e0542..78f49e45d3 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/composer.tsx b/packages/ui/src/composer.tsx index d2c9b71f39..0bba1d1963 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -354,7 +354,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[]; @@ -1559,7 +1559,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'); diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index acc785c7a3..077eb31554 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -333,6 +333,7 @@ export interface ConversationCopy { contextUsageUnavailable: string; contextUsageOpen: string; stepLimit: string; + emptyStepLoop: string; }; }; chat: { @@ -543,6 +544,8 @@ const CONVERSATION_COPY = { contextUsageUnavailable: '暂无用量数据', contextUsageOpen: '打开用量追踪', stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。', + emptyStepLoop: + '已停止:连续空工具步骤没有可见进展。任务可能尚未完成。发送消息即可继续。', }, }, chat: { @@ -668,6 +671,8 @@ const CONVERSATION_COPY = { contextUsageUnavailable: '暫無用量資料', contextUsageOpen: '開啟用量追蹤', stepLimit: '已達到本輪工具步驟上限,任務可能尚未完成。傳送“繼續”即可接著處理。', + emptyStepLoop: + '已停止:連續空工具步驟沒有可見進展。任務可能尚未完成。傳送訊息即可繼續。', }, }, chat: { @@ -790,6 +795,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 57ce751495..f343c3d0a7 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -180,6 +180,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/packages/ui/src/user-question-prompt.tsx b/packages/ui/src/user-question-prompt.tsx index 0f9535bba4..3fec614277 100644 --- a/packages/ui/src/user-question-prompt.tsx +++ b/packages/ui/src/user-question-prompt.tsx @@ -40,7 +40,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; 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',