From 608a9a5feca7d5c0427b0252345bf435efec461b Mon Sep 17 00:00:00 2001 From: mesalogo Date: Wed, 30 Sep 2026 02:18:33 +0800 Subject: [PATCH] fix: keep cancelled turns as interrupted instead of merging into the next request - Build runtime history per turn: a cancelled or failed reply keeps its partial text plus an interruption marker (or a marker-only placeholder), so its user message is never left unanswered and merged into the next request. - Use the same history for send, manual compaction and Main compact validation. - OpenCode: keep a cancelled prompt in the reused native session and prefix the next turn with a one-time synthetic interruption notice; failed prompts are still removed. - Add an opt-in DeepSeek live test covering direct model and OpenCode. --- src/main/agent/opencode-runtime.test.ts | 47 +++++++ src/main/agent/opencode-runtime.ts | 35 ++++- src/main/ipc.ts | 7 +- src/renderer/src/App.test.tsx | 27 ++++ src/renderer/src/App.tsx | 9 +- src/renderer/src/message-terminal-status.ts | 12 +- src/shared/runtime-history.test.ts | 51 +++++++ src/shared/runtime-history.ts | 61 ++++++++ tests/cancelled-turn-live.test.ts | 147 ++++++++++++++++++++ 9 files changed, 371 insertions(+), 25 deletions(-) create mode 100644 src/shared/runtime-history.test.ts create mode 100644 src/shared/runtime-history.ts create mode 100644 tests/cancelled-turn-live.test.ts diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index 1b3d15c0..e0881d34 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -4188,6 +4188,53 @@ describe("OpenCodeRuntime embedded permission mediation", () => { await runtime.dispose(); }); + it("keeps a cancelled prompt and marks the next reused-session turn as following an interruption", async () => { + const setup = runClient([]); + const controller = new AbortController(); + let subscriptions = 0; + vi.mocked(setup.event.subscribe).mockImplementation(async (_input, init) => { + const first = ++subscriptions === 1; + const signal = (init as { signal: AbortSignal }).signal; + return { + stream: (async function* () { + if (first) { + await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true })); + return; + } + yield { type: "session.idle", properties: { sessionID: "session-1" } }; + })(), + } as never; + }); + const runtime = embeddedRuntime(setup.client); + const collect = async (prompt: string, signal: AbortSignal) => { + for await (const event of runtime.run({ + requestId: crypto.randomUUID(), conversationId: "conversation-1", prompt, workMode: "ask", + }, signal)) void event; + }; + try { + const first = collect("first question", controller.signal).catch((error: unknown) => error); + await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledTimes(1)); + controller.abort(new Error("cancelled")); + await expect(first).resolves.toMatchObject({ message: "cancelled" }); + expect(setup.session.deleteMessage).not.toHaveBeenCalled(); + + await collect("new question", new AbortController().signal); + await collect("third question", new AbortController().signal); + + const parts = vi.mocked(setup.session.promptAsync).mock.calls.map( + ([input]) => (input as { parts: { text?: string; synthetic?: boolean }[] }).parts, + ); + expect(parts[1]).toEqual([ + expect.objectContaining({ synthetic: true, text: expect.stringContaining("interrupted the previous turn") }), + { type: "text", text: "new question" }, + ]); + expect(parts[2]).toEqual([{ type: "text", text: "third question" }]); + expect(setup.session.create).toHaveBeenCalledOnce(); + } finally { + await runtime.dispose(); + } + }); + it("deletes an ephemeral OpenCode session when released", async () => { const { client, session } = runClient([ { diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 9d2045a0..1172f816 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -72,6 +72,8 @@ import { } from "./opencode-subagent"; import { promptWithUntrustedConversationHistory } from "./runtime-conversation-history"; +const opencodeInterruptionNotice = + "[The user interrupted the previous turn before it finished. Its request may be incomplete; answer the new message below and do not resume or re-answer the interrupted request unless asked.]"; const STARTUP_TIMEOUT_MS = 30_000; const STARTUP_POLL_INTERVAL_MS = 100; const STARTUP_PROBE_TIMEOUT_MS = 500; @@ -732,7 +734,11 @@ export class OpenCodeRuntime implements AgentRuntime { private clientInitialization?: Promise; private server?: OpenCodeServer; private startingChild?: SpawnedProcess; - private readonly sessions = new Map(); + // `interrupted` marks a reused session whose last turn the user cancelled. + private readonly sessions = new Map< + string, + { id: string; directory: string; interrupted?: boolean } + >(); private initializationController?: AbortController; private initializationWaiters = 0; private readonly sessionInitializations = new Map>(); @@ -2461,6 +2467,14 @@ export class OpenCodeRuntime implements AgentRuntime { request, session.created, ); + const sessionState = this.sessions.get(request.conversationId); + // The native transcript keeps a cancelled turn; tell the model it was + // interrupted so it answers the new message instead of merging both. + const interruptionNotice = + !session.created && sessionState?.id === sessionId && sessionState.interrupted + ? opencodeInterruptionNotice + : undefined; + if (sessionState?.id === sessionId) sessionState.interrupted = false; const imageParts = (request.images ?? []).map((image) => ({ type: "file" as const, mime: image.mediaType, @@ -2515,6 +2529,15 @@ export class OpenCodeRuntime implements AgentRuntime { .join("\n\n") || undefined, ...(toolOverrides ? { tools: toolOverrides } : {}), parts: [ + ...(interruptionNotice + ? [ + { + type: "text" as const, + text: interruptionNotice, + synthetic: true, + }, + ] + : []), { type: "text" as const, text: promptText }, ...imageParts, ], @@ -3065,8 +3088,14 @@ export class OpenCodeRuntime implements AgentRuntime { signal.removeEventListener("abort", abortSession); // Keep the conversation locked until its session-wide abort settles. await sessionAbort; - // A reused session would otherwise merge this unanswered prompt into the next turn. - if ( + const currentSession = this.sessions.get(request.conversationId); + if (runFailed && signal.aborted && currentSession?.id === sessionId) { + // Keep the cancelled turn visible to the model, as it is to the user; + // the next prompt carries an interruption notice instead. + currentSession.interrupted = true; + } else if ( + // A failed prompt is dropped from a reused session so it is not + // merged into the next turn. runFailed && !selectedCommand && checklistMessageIds.size === 0 && diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e509db4f..9e7aecba 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,6 +1,7 @@ import { registerKnowledgeIpcHandlers } from './knowledge/knowledge-ipc' import { registerModelSettingsIpcHandlers } from './model-settings-ipc' import { knowledgeReferenceKey, toKnowledgeReference } from '../shared/knowledge-reference' +import { buildRuntimeHistory } from '../shared/runtime-history' import { localInferenceService } from './local-inference-service' import { inferenceActionSchema, inferenceCancelSchema } from '../shared/local-inference-contracts' import type { NativeClientCoordinator } from './agent/native-client-coordinator' @@ -4795,11 +4796,7 @@ export function registerIpcHandlers( ) { throw new Error('对话 Runtime 或 Project 已更改,请刷新后重试') } - const persistedHistory = conversation.messages - .filter( - (message) => - message.state === 'complete' && message.content.trim() - ) + const persistedHistory = buildRuntimeHistory(conversation.messages) if ( persistedHistory.length !== request.history.length || persistedHistory.some( diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 9d399607..ca6cf2ab 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -23,6 +23,7 @@ import { type ApplicationSettings, } from "../../shared/application-settings-contracts"; import type { GlobalShortcutSettingsSnapshot } from "../../shared/shortcut"; +import { cancelledResponseMarker } from "../../shared/runtime-history"; import type { TerminalSnapshot } from "../../shared/terminal-contracts"; import type { RuntimeNativeClientApi } from "./RuntimeNativeClientActions"; import { loadBrandingPreferences, saveBrandingPreferences } from "./branding"; @@ -6041,6 +6042,32 @@ describe("App", () => { ]); }); + it("sends a cancelled turn as interrupted instead of an unanswered user message", async () => { + render(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "First question" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const requestId = run.mock.calls[0]![0].requestId; + act(() => { + agentListener?.({ requestId, type: "text", delta: "Partial reply" }); + agentListener?.({ requestId, type: "error", status: "cancelled", message: "请求已取消" }); + }); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "New question" }, + }); + await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled()); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledTimes(2)); + const history = run.mock.calls[1]?.[0].history ?? []; + expect(history.slice(-2)).toEqual([ + { role: "user", content: "First question" }, + { role: "assistant", content: `Partial reply\n\n${cancelledResponseMarker}` }, + ]); + expect(run.mock.calls[1]?.[0].historyMessageIds).toHaveLength(history.length); + }); + it("keeps a tool failure in details and hides retry after continuing", async () => { render(); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 036a42c4..d0aad78b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -13,6 +13,7 @@ import { PendingDocumentImports } from './PendingDocumentImports'; import { AttachmentCapabilityNotice } from './AttachmentCapabilityNotice'; import { DocumentConversationContext } from './DocumentConversationContext'; import { maximumAttachmentsPerMessage } from '../../shared/attachment-limits'; +import { buildRuntimeHistory } from '../../shared/runtime-history'; import { ApplicationAvailability, ApplicationCenter, @@ -7091,9 +7092,7 @@ function App(): React.JSX.Element { 8, ); const historySnapshot = conversationSnapshot.messages; - const retainedHistorySnapshot = historySnapshot.filter( - (message) => message.state === "complete" && message.content.trim(), - ); + const retainedHistorySnapshot = buildRuntimeHistory(historySnapshot); const projectIdSnapshot = queuedInput ? queuedInput.projectId : activeProjectId || undefined; @@ -7435,9 +7434,7 @@ function App(): React.JSX.Element { notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") }); return; } - const history = completeConversation.messages.filter( - (message) => message.state === "complete" && message.content.trim(), - ); + const history = buildRuntimeHistory(completeConversation.messages); if (history.length < 2) { notify({ tone: "info", diff --git a/src/renderer/src/message-terminal-status.ts b/src/renderer/src/message-terminal-status.ts index f0750f15..12883f3a 100644 --- a/src/renderer/src/message-terminal-status.ts +++ b/src/renderer/src/message-terminal-status.ts @@ -1,11 +1 @@ -import type { ConversationMessage } from '../../shared/assistant-contracts' - -export function isCancelledMessage(message: Pick): boolean { - if (message.state !== 'error') return false - if (message.terminalStatus !== undefined) return message.terminalStatus === 'cancelled' - // Released snapshots lack terminalStatus. Only recognize exact old labels; - // every new terminal event supplies the structured value above. - return message.status === '请求已取消' || message.status === '已取消' || - message.status === '远端 Runtime 请求已取消' -} +export { isCancelledMessage } from '../../shared/runtime-history' diff --git a/src/shared/runtime-history.test.ts b/src/shared/runtime-history.test.ts new file mode 100644 index 00000000..220872e0 --- /dev/null +++ b/src/shared/runtime-history.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + buildRuntimeHistory, + cancelledResponseMarker, + failedResponseMarker +} from './runtime-history' + +const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` + +describe('buildRuntimeHistory', () => { + it('keeps a cancelled turn answered by its partial reply and an interruption marker', () => { + expect(buildRuntimeHistory([ + { id: id(1), role: 'user', content: 'First question', state: 'complete' }, + { id: id(2), role: 'assistant', content: 'Partial answer', state: 'error', terminalStatus: 'cancelled' }, + { id: id(3), role: 'user', content: 'continue', state: 'complete' }, + { id: id(4), role: 'assistant', content: '', state: 'error', terminalStatus: 'cancelled' }, + { id: id(5), role: 'user', content: 'continue', state: 'complete' }, + { id: id(6), role: 'assistant', content: 'Final answer', state: 'complete' } + ])).toEqual([ + { id: id(1), role: 'user', content: 'First question' }, + { id: id(2), role: 'assistant', content: `Partial answer\n\n${cancelledResponseMarker}` }, + { id: id(3), role: 'user', content: 'continue' }, + { id: id(4), role: 'assistant', content: cancelledResponseMarker }, + { id: id(5), role: 'user', content: 'continue' }, + { id: id(6), role: 'assistant', content: 'Final answer' } + ]) + }) + + it('marks failed replies and recognizes legacy cancelled labels', () => { + expect(buildRuntimeHistory([ + { id: id(1), role: 'user', content: 'A', state: 'complete' }, + { id: id(2), role: 'assistant', content: '', state: 'error', status: '请求已取消' }, + { id: id(3), role: 'user', content: 'B', state: 'complete' }, + { id: id(4), role: 'assistant', content: 'half', state: 'error', terminalStatus: 'failed' } + ]).map((message) => message.content)).toEqual([ + 'A', cancelledResponseMarker, 'B', `half\n\n${failedResponseMarker}` + ]) + }) + + it('skips streaming, empty and orphaned terminal messages', () => { + expect(buildRuntimeHistory([ + { id: id(1), role: 'assistant', content: 'orphan', state: 'error', terminalStatus: 'cancelled' }, + { id: id(2), role: 'user', content: ' ', state: 'complete' }, + { id: id(3), role: 'user', content: 'Q', state: 'complete' }, + { id: id(4), role: 'assistant', content: 'A', state: 'complete' }, + { id: id(5), role: 'assistant', content: 'late', state: 'error', terminalStatus: 'failed' }, + { id: id(6), role: 'user', content: 'Now', state: 'complete' }, + { id: id(7), role: 'assistant', content: 'typing', state: 'streaming' } + ]).map((message) => message.id)).toEqual([id(3), id(4), id(6)]) + }) +}) diff --git a/src/shared/runtime-history.ts b/src/shared/runtime-history.ts new file mode 100644 index 00000000..b77fc7d6 --- /dev/null +++ b/src/shared/runtime-history.ts @@ -0,0 +1,61 @@ +import type { ConversationMessage } from './assistant-contracts' + +export const cancelledResponseMarker = + '[This response was interrupted by the user before it finished.]' +export const failedResponseMarker = + '[This response failed before it finished.]' + +type HistorySourceMessage = Pick< + ConversationMessage, + 'id' | 'role' | 'content' | 'state' | 'terminalStatus' | 'status' +> + +export type RuntimeHistoryMessage = { + id: string + role: ConversationMessage['role'] + content: string +} + +export function isCancelledMessage( + message: Pick +): boolean { + if (message.state !== 'error') return false + if (message.terminalStatus !== undefined) return message.terminalStatus === 'cancelled' + // Released snapshots lack terminalStatus. Only recognize exact old labels; + // every new terminal event supplies the structured value above. + return message.status === '请求已取消' || message.status === '已取消' || + message.status === '远端 Runtime 请求已取消' +} + +/** + * Builds the model-visible history from stored messages. A cancelled or failed + * reply stays in place with its partial text and an explicit marker, so its + * user message is never left unanswered and merged into the next turn. + */ +export function buildRuntimeHistory( + messages: readonly HistorySourceMessage[] +): RuntimeHistoryMessage[] { + const history: RuntimeHistoryMessage[] = [] + for (const message of messages) { + if (message.state === 'complete') { + if (message.content.trim()) { + history.push({ id: message.id, role: message.role, content: message.content }) + } + continue + } + if (message.state !== 'error' || message.role !== 'assistant') continue + // Only a reply to a retained user turn needs a marker. + if (history.at(-1)?.role !== 'user') continue + const marker = isCancelledMessage(message) + ? cancelledResponseMarker + : failedResponseMarker + history.push({ + id: message.id, + role: 'assistant', + content: message.content.trim() + ? `${message.content.trimEnd()}\n\n${marker}` + : marker + }) + } + return history +} diff --git a/tests/cancelled-turn-live.test.ts b/tests/cancelled-turn-live.test.ts new file mode 100644 index 00000000..7ae2e848 --- /dev/null +++ b/tests/cancelled-turn-live.test.ts @@ -0,0 +1,147 @@ +// @vitest-environment node +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { parseEnv } from 'node:util' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { ModelAgentRuntime } from '../src/main/agent/model-runtime' +import { OpenCodeRuntime } from '../src/main/agent/opencode-runtime' +import type { AgentRuntime, RuntimeEvent } from '../src/main/agent/runtime' +import { buildRuntimeHistory } from '../src/shared/runtime-history' + +// Opt-in: GB_CANCEL_LIVE_ENV=.env.deepseek.local npx vitest run tests/cancelled-turn-live.test.ts +const envPath = process.env.GB_CANCEL_LIVE_ENV +const opencodePath = resolve('.runtime-resources', process.arch, process.platform === 'win32' ? 'opencode.exe' : 'opencode') + +const firstPrompt = 'List 40 European capital cities, one per line, each with a one-sentence description.' +const secondPrompt = 'What is 17 + 25? Reply with only the number and nothing else.' + +type Env = { apiKey: string; baseUrl: string; model: string } + +async function loadEnv(): Promise { + const env = parseEnv(await readFile(resolve(envPath!), 'utf8')) + return { apiKey: env.DEEPSEEK_API_KEY!, baseUrl: env.DEEPSEEK_BASE_URL!, model: env.DEEPSEEK_MODEL! } +} + +/** Runs until some answer text streams, then cancels like the Stop button. */ +async function runAndCancel(runtime: AgentRuntime, conversationId: string): Promise { + const controller = new AbortController() + let partial = '' + try { + for await (const event of runtime.run({ + requestId: crypto.randomUUID(), conversationId, workMode: 'ask', prompt: firstPrompt + }, controller.signal)) { + if (event.type === 'text') partial += event.delta + if (partial.length >= 60 && !controller.signal.aborted) controller.abort(new Error('用户取消了请求')) + } + } catch (error) { + if (!controller.signal.aborted) throw error + } + expect(controller.signal.aborted).toBe(true) + return partial +} + +async function runText( + runtime: AgentRuntime, + request: Parameters[0] +): Promise { + let output = '' + for await (const event of runtime.run(request, new AbortController().signal) as AsyncGenerator) { + if (event.type === 'text') output += event.delta + } + return output.trim() +} + +function expectOnlyNewAnswer(output: string): void { + console.info(`SECOND_TURN_OUTPUT=${JSON.stringify(output)}`) + expect(output).toMatch(/\b42\b/u) + // Merging the cancelled turn would resume the capital list. + expect(output).not.toMatch(/Paris|Berlin|Madrid|Vienna|Rome|London/u) + expect(output.length).toBeLessThan(40) +} + +describe.skipIf(!envPath)('cancelled turn handling with a real model', () => { + let env: Env + let workspace: string + beforeAll(async () => { + env = await loadEnv() + workspace = await mkdtemp(join(tmpdir(), 'gb-cancel-live-')) + }) + afterAll(async () => { + await rm(workspace, { recursive: true, force: true }) + }) + + it('direct model answers only the new message after a cancelled turn', async () => { + const runtime = new ModelAgentRuntime({ + apiKey: env.apiKey, baseUrl: env.baseUrl, model: env.model, + protocol: 'openai-chat-completions', authentication: 'api-key' + }) + try { + const conversationId = crypto.randomUUID() + const partial = await runAndCancel(runtime, conversationId) + console.info(`DIRECT_PARTIAL_CHARS=${partial.length}`) + // Same stored shape the renderer keeps after Stop. + const history = buildRuntimeHistory([ + { id: crypto.randomUUID(), role: 'user', content: firstPrompt, state: 'complete' }, + { id: crypto.randomUUID(), role: 'assistant', content: partial, state: 'error', terminalStatus: 'cancelled' } + ]) + expect(history).toHaveLength(2) + expectOnlyNewAnswer(await runText(runtime, { + requestId: crypto.randomUUID(), conversationId, workMode: 'ask', prompt: secondPrompt, + history: history.map(({ role, content }) => ({ role, content })), + historyMessageIds: history.map(({ id }) => id) + })) + } finally { + await runtime.dispose() + } + }, 180_000) + + it('direct model answers only the new message when the cancelled turn had no output', async () => { + const runtime = new ModelAgentRuntime({ + apiKey: env.apiKey, baseUrl: env.baseUrl, model: env.model, + protocol: 'openai-chat-completions', authentication: 'api-key' + }) + try { + const history = buildRuntimeHistory([ + { id: crypto.randomUUID(), role: 'user', content: firstPrompt, state: 'complete' }, + { id: crypto.randomUUID(), role: 'assistant', content: '', state: 'error', terminalStatus: 'cancelled' } + ]) + expectOnlyNewAnswer(await runText(runtime, { + requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), workMode: 'ask', prompt: secondPrompt, + history: history.map(({ role, content }) => ({ role, content })), + historyMessageIds: history.map(({ id }) => id) + })) + } finally { + await runtime.dispose() + } + }, 180_000) + + it('OpenCode reused session answers only the new message after a cancelled turn', async () => { + const runtime = new OpenCodeRuntime({ + embedded: true, binaryPath: '', bundledBinaryPath: opencodePath, configPath: '', + defaultWorkspace: workspace, + modelProfile: { + id: crypto.randomUUID(), name: 'DeepSeek live', baseUrl: env.baseUrl, modelName: env.model, + apiKey: env.apiKey, protocol: 'openai-chat-completions', authentication: 'api-key' + } + }) + try { + const conversationId = crypto.randomUUID() + const partial = await runAndCancel(runtime, conversationId) + console.info(`OPENCODE_PARTIAL_CHARS=${partial.length}`) + expectOnlyNewAnswer(await runText(runtime, { + requestId: crypto.randomUUID(), conversationId, workMode: 'ask', prompt: secondPrompt + })) + // The cancelled request stays in the native transcript, so the model + // still knows what was asked before. + const recall = await runText(runtime, { + requestId: crypto.randomUUID(), conversationId, workMode: 'ask', + prompt: 'In one short phrase, what did I ask for in my very first message of this conversation? Do not list anything.' + }) + console.info(`OPENCODE_RECALL=${JSON.stringify(recall)}`) + expect(recall).toMatch(/capital/iu) + } finally { + await runtime.dispose() + } + }, 300_000) +})