diff --git a/docs/features/assistant-workbar/consecutive-requests-live-2026-09-17.json b/docs/features/assistant-workbar/consecutive-requests-live-2026-09-17.json new file mode 100644 index 00000000..f5d00585 --- /dev/null +++ b/docs/features/assistant-workbar/consecutive-requests-live-2026-09-17.json @@ -0,0 +1,57 @@ +{ + "date": "2026-09-17", + "status": "passed-with-counting-limitation", + "sourceBase": "3639566e1b52296c6a9bc409bcfcecb86c905e30", + "source": "Working tree including consecutive-request cancellation and terminal snapshot fixes", + "environment": { + "platform": "Windows x64", + "runtime": "OpenCode", + "mode": "ask", + "model": "Existing configured default text model", + "upstreamProtocol": "http", + "fullProductionApp": true, + "isolatedProfile": true, + "nativeInput": true + }, + "checks": { + "twoQueuedRequestsCompleteAndPersist": true, + "cancelAfterExternalDispatch": true, + "cancelToResendMs": 25, + "resendCompletesAndPersists": true, + "cancellationPersists": true, + "stopAbsentAfterBothScenarios": true, + "sendEnabledWithUnsentProbe": true, + "terminalScreenshotsReviewed": true + }, + "requests": { + "instrumentedExternalDispatches": 4, + "http200": 3, + "cancelledBeforeResponseHeaders": 1, + "observedRetries": 0, + "toolExecutions": 0, + "initialUninstrumentedCompletedGenerations": 2, + "totalUiSubmissions": 6, + "totalExternalDispatches": null + }, + "limitations": [ + "Initial Main counting hook missed child-process HTTP traffic; total dispatches across attempts are unknown.", + "Successful rerun used a transparent Main relay to the unchanged configured HTTP upstream for dispatch counting.", + "Seven tool definitions remained exposed by production Ask, with zero observed tool executions.", + "Upstream proxy retries and cancelled request outcome at the upstream are not observable.", + "No deterministic stale-event ordering, remote Host, or restart verification in this live run." + ], + "validation": { + "focusedTestsPassed": 362, + "typecheck": "passed", + "productionBundle": "passed" + }, + "cleanup": { + "ownedProcessesStopped": true, + "isolatedProfileRemoved": true, + "temporaryBuildRemoved": true, + "sensitiveCopiesRemoved": true, + "originalSettingsUnchanged": true, + "targetSourceHashesUnchanged": true + }, + "localArtifacts": "consecutive-ui-0917/{final-evidence.json,scenarios.json,report.json,normal-terminal.png,cancel-resend-terminal.png} under the approved temporary directory" +} diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 8280f3d8..1fb4b9e8 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,47 @@ # 工作栏实现与验证进度 +## 2026-09-17 连续请求取消与停止状态 + +- 复现并修复 OpenCode 迟到的会话取消中断下一条请求:同一次运行复用一个有界 abort, + 等待取消结束后释放会话锁;取消失败时移除对应绑定。规则见 + [本机 OpenCode](./runtime-process-reuse-technical-design.md#4-本机-opencode)。 +- 复现并修复旧 streaming 快照覆盖本地终态后停止按钮残留。完成、取消两种回归在 + 修复前失败,修复后通过;连续两条消息结束后按钮恢复。规则见 + [消息底部请求状态](./runtime-interactions.md#消息底部请求状态)。 +- App 257 项测试通过;最终 OpenCode Runtime、权限与真实 lifecycle 105 项通过。 + lifecycle 使用真实 OpenCode 二进制与 SDK、本地模型 fixture,验证延迟取消后同会话 + 下一条正常完成及 SSE 回收。该轮外部真实模型调用 0 次;后续完整 App 实测见下文。 +- `npm test`:4454 项通过、67 项跳过、2 项失败;失败均为 heartbeat 迁移的 + `duplicate column name: pinned`。`npm run typecheck`、修改文件定向 ESLint、 + `git diff --check` 通过;全库 lint 因范围外 `docs/features/story-graph/demo.js` + 的 22 项 `document` 未定义错误失败。 +- 托管 SSH 经 `ManagedRemoteAcpRuntime` / `AgentOwnedAcpPrompt` 使用独立 ACP + 取消流程,不复用此 HTTP abort 实现;本次未改 Agent 或远程协议,未做共享 Host 实测。 + +### Real-model follow-up + +Current-source production Main, Preload, and Renderer were built into dedicated +temporary output and launched in an isolated Electron profile on Windows. Native +input submitted two queued OpenCode Ask requests, then cancelled another request +after external HTTP dispatch and clicked resend within 25 ms. Both queued answers +and the resend completed and were persisted. The cancellation was also persisted. +After each scenario, the real DOM had no Stop button; Send was enabled after +entering an unsent readiness probe. Both terminal screenshots were reviewed. + +The instrumented run dispatched exactly four external text-model HTTP requests: +three returned HTTP 200 and one was cancelled before response headers. There were +no observed retries or tool executions. A transparent Main relay forwarded to the +unchanged configured HTTP upstream to count child-process requests; model answers +and events were not simulated. An initial driver attempt completed two additional +real generations before its counting hook was found not to cover child traffic. +The total external dispatch count across both attempts is therefore unknown. + +This verifies the observed full-App flows, not deterministic stale-event ordering, +remote Hosts, or restart behavior. Isolated credentials, profile, build, and owned +processes were cleaned up; original settings and target source hashes were unchanged. +The follow-up focused suite passed all 362 tests, and `npm run typecheck` passed. +Redacted evidence: [real-model report](./consecutive-requests-live-2026-09-17.json). + ## 2026-09-15 Managed browser popups Implemented [page-created tabs](./browser-tabs-technical-design.md#62-page-created-tabs): native diff --git a/docs/features/assistant-workbar/runtime-interactions.md b/docs/features/assistant-workbar/runtime-interactions.md index acc37927..48612042 100644 --- a/docs/features/assistant-workbar/runtime-interactions.md +++ b/docs/features/assistant-workbar/runtime-interactions.md @@ -92,6 +92,8 @@ DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省 正文、推理或工具进展会清除旧状态;子任务更新不覆盖父请求的重试提示。 - 完成、失败、取消后保留真实终态,迟到的状态事件不能把请求恢复成处理中。底部状态点 为静态,不凭请求尚未结束暗示仍有进展;会话列表闪点和发送、停止按钮保持原有行为。 + 持久化快照中的同一条消息仍为生成中时,也不能覆盖本地已收到的终态,即使快照的 + 会话更新时间更晚;否则连续发送或刷新会让已结束的请求重新显示停止按钮。 - Continue、DeepSeek Harness 和远端 ACP 保留其已接入的处理与工具事件,不伪造未接入 的原生重试状态。直连模型的重试等待与请求开始文案见 [直连模型 UI 设计](../direct-model-agent/ui-design.md#6-错误与通知);本次不改变任何 diff --git a/docs/features/assistant-workbar/runtime-process-reuse-technical-design.md b/docs/features/assistant-workbar/runtime-process-reuse-technical-design.md index d613760a..24df8087 100644 --- a/docs/features/assistant-workbar/runtime-process-reuse-technical-design.md +++ b/docs/features/assistant-workbar/runtime-process-reuse-technical-design.md @@ -159,6 +159,10 @@ SelectedRuntimeManager 会话绑定记录规范化绝对目录及原生 sessionId。同一 conversation 的请求继续串行, 不同 conversation 并行,不引入覆盖整个 Server 的 Prompt 队列。 +取消或失败时,每次请求只发送一次会话级 abort,并在该调用结束后才释放会话锁,避免 +迟到的 abort 中断下一条请求。abort 使用既有控制请求超时;失败或超时后移除对应绑定, +下一条请求创建新 Session。这也适用于配置外部 OpenCode HTTP Server 的路径;托管 SSH +使用独立的 ACP 取消路径。 现有 `snapshot: false` 必须保留。 `mcpMutationTail` 目前属于 Runtime 对象;共享后同一原生目录的变更必须由同一 owner diff --git a/src/main/agent/opencode-runtime-lifecycle.test.ts b/src/main/agent/opencode-runtime-lifecycle.test.ts index c88f0408..50ceb096 100644 --- a/src/main/agent/opencode-runtime-lifecycle.test.ts +++ b/src/main/agent/opencode-runtime-lifecycle.test.ts @@ -49,6 +49,9 @@ it.skipIf(!existsSync(binaryPath))( const gateway = new KnowledgeMcpGateway({} as never) await gateway.start() let openEventBodies = 0 + let delayedAbort: Promise | undefined + let abortStarted: (() => void) | undefined + let promptCount = 0 const runtime = new OpenCodeRuntime({ embedded: true, binaryPath: '', bundledBinaryPath: binaryPath, configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway, @@ -61,8 +64,14 @@ it.skipIf(!existsSync(binaryPath))( createClient: (options) => createOpencodeClient({ ...options, fetch: async (request, init) => { - const response = await fetch(request, init) const url = request instanceof Request ? request.url : request + const pathname = new URL(url).pathname + if (pathname.endsWith('/abort') && delayedAbort) { + abortStarted?.() + await delayedAbort + } + if (pathname.endsWith('/prompt_async')) promptCount++ + const response = await fetch(request, init) if (new URL(url).pathname !== '/event' || !response.body) return response openEventBodies++ const reader = response.body.getReader() @@ -130,6 +139,30 @@ it.skipIf(!existsSync(binaryPath))( expect(run()).resolves.toBe('LIFECYCLE_OK') ]) await expect.poll(() => openEventBodies).toBe(0) + let releaseAbort!: () => void + delayedAbort = new Promise((resolve) => { releaseAbort = resolve }) + const abortPending = new Promise((resolve) => { abortStarted = resolve }) + const reusedConversation = crypto.randomUUID() + cancelledController = new AbortController() + const first = run(cancelledController, true, reusedConversation).catch((error: unknown) => error) + let second: Promise | undefined + try { + await abortPending + const beforeResend = promptCount + second = run(new AbortController(), false, reusedConversation) + await new Promise((resolve) => setTimeout(resolve, 100)) + const beforeAbortCompletes = promptCount + releaseAbort() + expect(await first).toMatchObject({ name: 'AbortError' }) + await expect(second).resolves.toBe('LIFECYCLE_OK') + expect(beforeAbortCompletes).toBe(beforeResend) + expect(promptCount).toBe(beforeResend + 1) + await expect.poll(() => openEventBodies).toBe(0) + } finally { + releaseAbort() + await Promise.allSettled([first, second]) + delayedAbort = undefined + } const conversationId = crypto.randomUUID() await expect(run(new AbortController(), false, conversationId)).resolves.toBe('LIFECYCLE_OK') await expect(runtime.compactConversation({ diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index 477c53f2..ccce5af1 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -1924,6 +1924,137 @@ describe("OpenCodeRuntime embedded launcher", () => { await runtime.dispose(); }); + it.each(["cancel", "stream-error"])("waits for delayed session abort before reusing a conversation after %s", async (outcome) => { + const setup = runClient([]); + const controller = new AbortController(); + let releaseAbort!: () => void; + const abortGate = new Promise((resolve) => { releaseAbort = resolve; }); + let endFirst!: () => void; + const firstGate = new Promise((resolve) => { endFirst = resolve; }); + let secondActive = false; + let secondInterrupted = false; + let subscriptions = 0; + vi.mocked(setup.session.abort).mockImplementation(async () => { + await abortGate; + if (secondActive) secondInterrupted = true; + return { data: true, error: undefined } as never; + }); + vi.mocked(setup.event.subscribe).mockImplementation(async () => { + const first = ++subscriptions === 1; + return { + stream: (async function* () { + if (first) { + await firstGate; + if (outcome === "stream-error") throw new Error("stream failed"); + return; + } + secondActive = true; + await abortGate; + // Let every pending abort reach the server before B completes. + await new Promise((resolve) => setTimeout(resolve, 0)); + if (secondInterrupted) throw new Error("B interrupted by A's late abort"); + yield { type: "session.idle", properties: { sessionID: "session-1" } }; + secondActive = false; + })(), + } as never; + }); + const runtime = embeddedRuntime(setup.client, { + embedded: false, baseUrl: "http://127.0.0.1:4096", + }); + const collect = async (requestId: string, signal: AbortSignal) => { + const events: RuntimeEvent[] = []; + for await (const event of runtime.run({ + requestId, conversationId: "shared-conversation", prompt: requestId, + workMode: "execute", + }, signal)) events.push(event); + return events; + }; + const first = collect("A", controller.signal).catch((error: unknown) => error); + let second: Promise | undefined; + try { + await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledTimes(1)); + if (outcome === "cancel") controller.abort(new Error("A cancelled")); + endFirst(); + second = collect("B", new AbortController().signal).catch((error: unknown) => error); + await vi.waitFor(() => expect(setup.session.abort).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 20)); + const promptsBeforeAbort = vi.mocked(setup.session.promptAsync).mock.calls.length; + releaseAbort(); + expect(await first).toMatchObject({ message: outcome === "cancel" ? "A cancelled" : "stream failed" }); + expect(await second).toEqual(expect.arrayContaining([expect.objectContaining({ type: "done" })])); + expect(promptsBeforeAbort).toBe(1); + expect(setup.session.abort).toHaveBeenCalledOnce(); + expect(setup.session.create).toHaveBeenCalledOnce(); + expect(setup.session.promptAsync).toHaveBeenCalledTimes(2); + } finally { + endFirst(); + releaseAbort(); + await Promise.all([first, second]); + await runtime.dispose(); + } + }); + + it.each(["never-returns", "rejects", "error", "false"])("replaces the session after abort %s and lets the next request finish", async (outcome) => { + const setup = runClient([]); + const controller = new AbortController(); + let abortSignal: AbortSignal | undefined; + let abortedAtStart: boolean | undefined; + vi.mocked(setup.session.abort).mockImplementation(async (_input, options) => { + abortSignal = options?.signal ?? undefined; + abortedAtStart = abortSignal?.aborted; + if (outcome === "never-returns") return new Promise(() => undefined); + if (outcome === "rejects") throw new Error("abort failed"); + return { data: false, error: outcome === "error" ? { message: "abort failed" } : undefined } as never; + }); + vi.mocked(setup.session.create) + .mockResolvedValueOnce({ data: { id: "session-1" } } as never) + .mockResolvedValueOnce({ data: { id: "session-2" } } as never); + let subscriptions = 0; + vi.mocked(setup.event.subscribe).mockImplementation(async () => { + const first = ++subscriptions === 1; + return { + stream: (async function* () { + if (first) { + controller.abort(new Error("A cancelled")); + return; + } + yield { type: "session.idle", properties: { sessionID: "session-2" } }; + })(), + } as never; + }); + const runtime = embeddedRuntime(setup.client, { + embedded: false, baseUrl: "http://127.0.0.1:4096", + }, { controlRequestTimeoutMs: 20 }); + const collect = async (requestId: string, signal: AbortSignal) => { + const events: RuntimeEvent[] = []; + for await (const event of runtime.run({ + requestId, conversationId: "shared-conversation", prompt: requestId, + workMode: "execute", + }, signal)) events.push(event); + return events; + }; + try { + let settled = false; + const first = collect("A", controller.signal).catch((error: unknown) => error) + .finally(() => { settled = true; }); + await vi.waitFor(() => expect(settled).toBe(true)); + expect(await first).toMatchObject({ message: "A cancelled" }); + expect(abortedAtStart).toBe(false); + expect(abortSignal?.aborted).toBe(outcome === "never-returns"); + expect(setup.session.abort).toHaveBeenCalledOnce(); + await expect(collect("B", new AbortController().signal)).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ type: "done", sessionId: "session-2" })]), + ); + expect(setup.session.create).toHaveBeenCalledTimes(2); + expect(setup.session.promptAsync).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionID: "session-2" }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + } finally { + await runtime.dispose(); + } + }); + it("loads assigned Skills before prompting", async () => { const child = fakeChild(); const promptAsync = vi.fn().mockResolvedValue({ error: undefined }); @@ -4046,10 +4177,10 @@ describe("OpenCodeRuntime embedded permission mediation", () => { await expect(collectRun(runtime, "execute")).rejects.toThrow( "OpenCode 权限回复失败", ); - expect(session.abort).toHaveBeenCalledWith({ - sessionID: "session-1", - directory: process.cwd(), - }); + expect(session.abort).toHaveBeenCalledWith( + { sessionID: "session-1", directory: process.cwd() }, + { signal: expect.any(AbortSignal) }, + ); await runtime.dispose(); }); diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 4f1756d9..e6f9554a 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -2378,13 +2378,26 @@ export class OpenCodeRuntime implements AgentRuntime { AbortSignal.any([signal, subscriptionController.signal]), ); + let sessionAbort: Promise | undefined; const abortSession = (): void => { - void client.session - .abort({ - sessionID: sessionId, - directory, + sessionAbort ??= this.controlRequest( + "取消会话", + (controlSignal) => client.session.abort( + { sessionID: sessionId, directory }, + { signal: controlSignal }, + ), + ) + .then((response) => { + if (response.error || response.data !== true) { + throw new Error("OpenCode 会话取消失败"); + } }) - .catch(() => undefined); + .catch(() => { + // An unconfirmed abort must never target a reused session. + if (this.sessions.get(request.conversationId)?.id === sessionId) { + this.sessions.delete(request.conversationId); + } + }); }; signal.addEventListener("abort", abortSession, { once: true }); @@ -2994,6 +3007,8 @@ export class OpenCodeRuntime implements AgentRuntime { throw error; } finally { signal.removeEventListener("abort", abortSession); + // Keep the conversation locked until its session-wide abort settles. + await sessionAbort; for (const questionId of reportedQuestionIds.values()) { this.pendingQuestions.delete(questionId); } diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 4a0d9b29..710b373c 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -6053,6 +6053,11 @@ describe("App", () => { queueItemId: secondItem.id, prompt: secondItem.label, }); + act(() => { + agentListener?.({ requestId: run.mock.calls[1]![0].requestId, type: "done" }); + }); + await waitFor(() => expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument()); + expect(screen.getByLabelText("发送")).toBeInTheDocument(); }); it("restores a queued message when Agent preflight rejects it", async () => { @@ -7779,6 +7784,27 @@ describe("App", () => { ], }); + it.each(["complete", "error"] as const)("does not revive a locally %s message from a newer conversation's stale streaming snapshot", async (state) => { + const started = await startStreamingRun(); + act(() => { + agentListener?.({ requestId: started.requestId, type: "text", delta: "Retained terminal output" }); + agentListener?.(state === "complete" + ? { requestId: started.requestId, type: "done" } + : { requestId: started.requestId, type: "error", status: "cancelled", message: "Cancelled response" }); + }); + await waitFor(() => expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument()); + const snapshot = persistedSnapshot(started, { state: "streaming", content: "Stale partial output" }); + snapshot.updatedAt = Date.now() + 10_000; + vi.mocked(api.conversations.list).mockResolvedValue([snapshot]); + const callsBeforeRefresh = vi.mocked(api.conversations.list).mock.calls.length; + await act(async () => { conversationsChangedListener?.(); }); + await waitFor(() => expect(api.conversations.list).toHaveBeenCalledTimes(callsBeforeRefresh + 1)); + expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument(); + expect(screen.getByText("Retained terminal output")).toBeInTheDocument(); + expect(screen.queryByText("Stale partial output")).not.toBeInTheDocument(); + expect(screen.getByLabelText("发送")).toBeInTheDocument(); + }); + it("converges a streaming message onto the persisted completed state after reconnecting", async () => { const started = await startStreamingRun(); expect(conversationsChangedListener).toBeDefined(); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ecd52386..b6346cb1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1546,6 +1546,11 @@ function mergePersistedConversations( ...conversation.messages.map((persistedMessage) => { const localMessage = localMessageById.get(persistedMessage.id); const message = mergeMessageImageState(persistedMessage, persistedMessage, localMessage); + // A newer conversation timestamp can still contain an older message + // snapshot while a terminal event is being persisted. + if (localMessage && localMessage.state !== "streaming" && message.state === "streaming") { + return mergeMessageImageState(localMessage, persistedMessage, localMessage); + } if (!conversation.activeRequest && local.activeRequest?.messageId === message.id) { return { ...message, pendingQuestions: undefined }; }