diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 83652f26..c0c14d45 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,71 @@ # 工作栏实现与验证进度 +## 2026-09-13 SSH 问答 UI 复验与取消后续发 + +在真实 Electron 窗口中使用当前 `App`、入口样式和主题函数,经测试 IPC 接入生产 +`createManagedRemoteAcpRuntime`。远端使用共享 Linux x64 Host、真实 SSH attach、当前源码 +Agent、原生 OpenCode 和生产模型桥;模型响应来自远端 loopback 确定性服务。配置、项目、 +队列和存储 API 复用 App 测试 fixture,因此本次证据范围为 App Renderer 到真实 SSH +Runtime,未覆盖完整生产 Main/Preload、生产数据库持久化、发布安装或重启恢复。 + +窗口测试发现待答期间取消后,同一对话立即发送会报 +`Active prompt operation identity cannot change before terminalization`。Agent 取消分支 +原先只写语义记录,重复终态写入失败后未留下可核对的操作终态;Desktop 也保留了旧 +binding。修复复用 Agent 的异常终态记录方法,并在 Desktop 显式取消后核对终态、关闭 +已终结 binding 和释放会话通道。新增两项回归验证 Agent 取消核对及同一对话续发。 + +已通过的 UI 场景:父子问题同时待答、重复事件不丢草稿、自由文本回答、选项回答、注入 +一次回复失败后原输入重试、跳过、待答期间取消、取消后在同一对话发出新请求并正常完成。 +鼠标和输入使用 Electron 原生输入 API。重复问题和回复失败由驱动注入,其余问题、工具 +进展及完成事件来自真实远端 Runtime;驱动消费内部 checkpoint,并将取消映射为公开 +`error/status=cancelled` 事件,不把内部 Runtime 事件直接送给 Renderer。 + +浅深主题各检查四组窗口尺寸,问题表单无横向溢出、草稿保留,提交按钮可滚动到可见位置。 +短窗口需纵向滚动查看问题与输入,未宣称整张卡片同时可见。检查目标为 1280×800、 +960×720、720×640、640×420,Windows 175% 缩放下实际 CSS 内容区各多 1 像素。 +临时驱动、JSON 测试报告、各轮日志及 PNG 保留在批准临时目录,使用 +`question-ui-*`、`remote-question-*` 和 `question-validation-*` 文件名前缀;驱动按 +[Electron UI 自动化规范](../../quality/electron-ui-automation.md)的现有启动方法重建, +不是仓库通用 UI runner。SSH 测试结束后停止专属 daemon 并清理远端专属目录和上传文件。 + +复现时在批准临时目录执行 `node question-ui-build.cjs`、 +`node remote-question-build.cjs`,随后设置 `GOODBUDDY_QUESTION_UI=1` 并执行 +`node remote-question-run.cjs`。脚本依赖本机既有加密 Host 配置和 Host 上的测试 Node +依赖目录;不得将这些本机条件当作新环境已经满足。早期驱动的内部事件转发、原生输入 +等待和跨轮模型结果判断错误均已修正;它们与实际取消续发缺陷分别记录,未将失败轮次 +改记为通过。首轮窗口退出过早,未取得完整模型夹具计数,历史夹具请求累计数不完整; +外部模型调用为 0。 + +最终 SSH/UI 一轮完成 4 次输入区提交、2 次回答和 1 次跳过,收到 4 个原生问题,取消 +核对结果为 `terminal/cancelled/processTree=empty`,随后新请求完成。该轮模型夹具请求 +精确为 7 次,外部模型调用 0 次,Renderer 控制台错误 0 条,驱动退出码 0。最终定向 +回归分两条命令执行:`npm exec vitest run src/agent-daemon/runtime-acp-backend.test.ts +src/main/agent/acp-remote-runtime.test.ts` 为 96 项通过; +`npm exec vitest run src/agent-daemon/remote-question-binary.test.ts +src/agent-daemon/opencode-subagent-plugin.test.ts src/agent-daemon/model-bridge.test.ts +src/main/remote-agent/protocol-remote-runtime-channel.test.ts +src/agent-daemon/runtime-composition.test.ts src/main/ipc.test.ts` 为 179 项通过、5 项跳过。 +`npm run typecheck`、`npm run lint` 和 `git diff --check` 通过。 + +首轮默认并行 `npm test` 的打包测试超时,整轮随后被 120 秒工具时限中断;单 worker +重跑为 4,084 项通过、66 项跳过,耗时 626,816 毫秒。此结果早于本节取消修复, +不作为最终源码全量结果。 + +取消修复后的全量命令为 `node node_modules/vitest/vitest.mjs run --maxWorkers=1 +--reporter=json --outputFile=<批准临时目录>/question-validation-final.json`,覆盖 358 份 +测试文件,结果为 4,087 项通过、1 项失败、66 项跳过,监督进程记录耗时 617,138 毫秒、 +退出码 1。唯一失败是 `tests/agent-package.test.ts:372` 的大清单安装用例,耗时 +63,366 毫秒,超过该用例 60 秒上限。随后 +`npm exec vitest run tests/agent-package.test.ts -- --maxWorkers=1` 单独复跑为 +22 项通过、1 项跳过,整文件耗时 60.08 秒;没有修改该打包测试或放宽超时。 +全量失败记录保留,不能将分次复跑结果写成单轮全绿。 + +最终 UI 原始日志为 `question-ui-1789278503920.log`,8 张待答截图为 +`question-ui-{light,dark}-{1280,960,720,640}.png`,另有完成截图 +`question-ui-success.png`。已逐张打开待答 PNG 审阅字体、字段、按钮、焦点和短窗口 +滚动位置;未发现问题卡片遮挡或按钮不可达。尺寸切换等待 CSS 过渡结束后再截图, +窗口使用独立临时 session partition。当前证据不覆盖全键盘导航或正式 profile 的清理。 + ## 2026-09-13 托管 SSH 原生问答 - 当前源码支持前台托管 SSH OpenCode 的父、子孙会话原生问题、回答和拒答,复用已有 @@ -21,7 +87,7 @@ Runtime、认证控制通道及托管入口测试。`npm run typecheck`、`npm run lint` 通过。 本轮全量 `npm test -- --reporter=json --outputFile=<临时报告>` 为 4,081 项通过、 0 项失败、66 项跳过;之后的插件身份与并发去重补充由最终定向回归和 Host 复测覆盖。 -- 当前验证未操作 Electron 窗口、重装共享 Agent 或模拟整机重启;问答队列的界面行为由 +- 本节早期验证未操作 Electron 窗口、重装共享 Agent 或模拟整机重启;问答队列的界面行为由 既有 App/ChatTimeline 测试覆盖。同一存活 Prompt 的待答补发由 Runtime 回归验证, 不把它描述成 Agent 进程重启后的问题恢复。旧 Agent 和任意第三方 ACP 服务的范围见 [Runtime 交互边界](./runtime-interactions.md)。 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index b41279b5..b43ac18d 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -275,6 +275,9 @@ Execute 直接启动已签名 Runtime: `127.0.0.1` 的临时 HTTP 入口。该入口使用进程级随机路径,能力 URL 不写入语义记录。 插件复用 OpenCode 注入客户端的进程内 HTTP 适配器调用 `/question/{id}/reply` 或 `/reject`;空答案数组表示拒答,非空答案数量须与问题数量一致。问答不经过 ACP 权限审批。 + 用户取消托管 Prompt 时,Agent 同步记录语义终态和操作终态。Desktop 等待取消升级及 + 终态核对后关闭旧 binding、释放会话通道;同一对话的下一条消息使用新 binding,避免 + 将新请求写入仍携带旧 operation ID 的 binding。断线附加原请求的规则保持不变。 - 语义记录分页另带 Agent 当前 `pendingQuestions`,用于附加同一存活 Prompt 后恢复待答 问题;历史问答元数据不重新生成表单。原生已回答、拒答、会话空闲、取消、Prompt 终结 和进程退出清除相应映射,迟到的回答报错。回复失败保留桌面输入供重试,答案不会跨连接 diff --git a/src/agent-daemon/runtime-acp-backend.test.ts b/src/agent-daemon/runtime-acp-backend.test.ts index 28d80d1a..ae07d7ba 100644 --- a/src/agent-daemon/runtime-acp-backend.test.ts +++ b/src/agent-daemon/runtime-acp-backend.test.ts @@ -1337,6 +1337,25 @@ describe('RuntimeAcpBackend', () => { ).toEqual(['offline']) }) + it('retains the owned cancellation outcome when the semantic terminal already exists', async () => { + const fixture = harness({ agentOwned: true }) + let terminalWritten = false + fixture.semanticPrompts!.append.mockImplementation(() => { + if (terminalWritten) throw new Error('Prompt is already terminal') + terminalWritten = true + }) + await open(fixture) + await invoke(fixture, 'runtime/preparePrompt', fixture.preparation()) + await invoke(fixture, 'runtime/escalateCancellation', { + bindingId: 'binding-1', sessionId: 'session-1', + operationId: 'request-1', requestId: 'request-1', reason: 'requested' + }) + await expect(invoke(fixture, 'runtime/reconcilePrompt', { + bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1' + })).resolves.toEqual({ status: 'terminal', terminalState: 'cancelled', processTree: 'empty' }) + await fixture.backend.dispose() + }) + it('closes an idle model bridge on detach without poisoning or stopping Runtime work', async () => { const fixture = harness() await open(fixture) diff --git a/src/agent-daemon/runtime-acp-backend.ts b/src/agent-daemon/runtime-acp-backend.ts index 36c4146f..843b7fc0 100644 --- a/src/agent-daemon/runtime-acp-backend.ts +++ b/src/agent-daemon/runtime-acp-backend.ts @@ -1847,20 +1847,11 @@ export class RuntimeAcpBackend { 'Runtime prompt cancellation timed out' ).catch(() => undefined) } - try { - this.#options.semanticPrompts.append({ - bindingId: binding.request.bindingId, - operationId: binding.activeOperationId, - kind: 'prompt-terminal', - payload: { - status: 'cancelled', - reason: escalation.reason - }, - terminalState: 'cancelled' - }) - } catch { - // A concurrently completed prompt already committed terminal evidence. - } + this.#appendAbnormalOwnedPromptTerminal(binding, { + status: 'cancelled', + name: 'PromptCancelled', + message: 'Prompt execution was cancelled' + }) } await this.#stopAndReconcile(binding, 'user-cancelled') return { bindingId: escalation.bindingId, stopped: true } diff --git a/src/main/agent/acp-remote-runtime.test.ts b/src/main/agent/acp-remote-runtime.test.ts index 118ffeb2..6fc8541c 100644 --- a/src/main/agent/acp-remote-runtime.test.ts +++ b/src/main/agent/acp-remote-runtime.test.ts @@ -3115,6 +3115,25 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => { expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled() }) + it('reconciles an owned cancellation before the next prompt in the same conversation', async () => { + const fixture = ownedChannel() + fixture.pageOwnedPromptTranscript.mockImplementationOnce(() => new Promise(() => {})) + const controller = new AbortController() + const stream = fixture.instance.run(request, controller.signal) + await stream.next() + const pending = stream.next() + await vi.waitFor(() => expect(fixture.pageOwnedPromptTranscript).toHaveBeenCalledOnce()) + controller.abort(new Error('cancel pending question')) + await expect(pending).rejects.toThrow('cancel pending question') + expect(await fixture.store.getByConversation(request.conversationId)).toBeUndefined() + const events = await collect(fixture.instance.run( + { ...request, requestId: 'after-cancel' }, new AbortController().signal + )) + expect(events.some(event => event.type === 'done')).toBe(true) + expect(fixture.startOwnedPrompt).toHaveBeenCalledTimes(2) + await fixture.instance.dispose() + }) + it('waits for the in-flight Agent cancellation before releasing its lease', async () => { let releaseEscalation!: () => void const escalation = new Promise((resolve) => { diff --git a/src/main/agent/acp-remote-runtime.ts b/src/main/agent/acp-remote-runtime.ts index c3aee6dd..ba07c326 100644 --- a/src/main/agent/acp-remote-runtime.ts +++ b/src/main/agent/acp-remote-runtime.ts @@ -2752,6 +2752,20 @@ export class AcpRemoteRuntime implements AgentRuntime { ? abortReason(signal) : new Error('远端 Runtime 事件消费提前结束') ) + // Cancellation stops transcript polling, so reconcile before reusing + // this session for another prompt. + if (signal.aborted) { + const reconciled = await this.reconcilePromptOperation( + context, + binding + ) + session.binding = reconciled.terminal + ? await this.persistClosedBinding(reconciled.binding) + : reconciled.binding + this.sessions.delete(request.conversationId) + this.contexts.delete(request.conversationId) + await this.closeContext(context).catch(() => undefined) + } } signal.removeEventListener('abort', cancel) prompt.open = false