diff --git a/docs/verification/issue-370-completed-context-navigation.md b/docs/verification/issue-370-completed-context-navigation.md index f1fb3e7..393470c 100644 --- a/docs/verification/issue-370-completed-context-navigation.md +++ b/docs/verification/issue-370-completed-context-navigation.md @@ -35,4 +35,6 @@ 6. 开发环境页面发现按钮未继承操作样式、输入区提示与主内容重复。历史入口复用现有 `taskActions` 与 `row` 样式;历史上下文不再单独生成输入区提示,原有正式交接提示保持。3 个相关 UI 测试文件共 58 项通过,类型检查通过。原始记录为本地 `/tmp/370-visual-tests.log`、`/tmp/370-visual-typecheck.log` 与 `/tmp/370-visual-client-typecheck.log`;测试 mock 的字面量类型修正后,客户端类型检查已单独通过,宿主检查复用同轮通过结果。 +7. 实际验收发现历史导航等待旧执行的 `task.detail` 完整历史观察,导致入口显示或点击受阻。仅调整客户端:历史入口使用已有 `session.work.historicalContexts` 投影显示;点击通过现有 `project.detail` 找到准确项目与 Task,核验完成事实及目标关联。普通当前会话导航仍使用 `task.detail`,后端查询与执行图行为不变。生产组件在 `task.detail` 永久不返回时仍显示并准确导航;新读数缺少任务、完成记录或精确关联时拒绝。2 个相关测试文件共 61 项通过,客户端类型检查通过。原始记录为本地 `/tmp/370-history-read-red.log`、`/tmp/370-history-read-tests.log` 与 `/tmp/370-history-read-typecheck.log`。 + 本报告只记录代码级验证。实际开发环境页面截图、发布、既有生产后续会话登记与最终验收由主 agent 继续执行;本分支未部署、未重启服务、未修改生产数据。 diff --git a/src/client/execution-native.tsx b/src/client/execution-native.tsx index b0c6297..6341993 100644 --- a/src/client/execution-native.tsx +++ b/src/client/execution-native.tsx @@ -115,7 +115,7 @@ function NativeTaskContinuation({ controller, sessionId, work }: { controller: W {error &&

{error}

}
- {completed && binding.historicalSource?.sessionId === sessionId && binding.historicalSource.runtimeId === detail.execution?.resource.runtimeId).map(binding => binding.session)} busy={busy} onEnter={session => void enterContext(session)}/>} + void enterContext(session)}/> {detail ? void controlWork('continue'), onRecover: () => void controlWork('reconcile'), onResume: () => void controlWork('resume') } : {})}/> : !error &&

正在读取接续事实…

}
diff --git a/src/client/work-panel-controller.ts b/src/client/work-panel-controller.ts index 0510144..780e253 100644 --- a/src/client/work-panel-controller.ts +++ b/src/client/work-panel-controller.ts @@ -1022,12 +1022,11 @@ export class WorkPanelController { signal?.addEventListener('abort', cancel, { once: true }) const revision = this.revision, sourceRevision = this.sourceRevision try { - const detail = await this.client.query({ type: 'task.detail', ref }, abort.signal) + const detail = await this.client.query(historicalContext ? { type: 'project.detail', projectId: ref.projectId } : { type: 'task.detail', ref }, abort.signal) if (abort.signal.aborted || revision !== this.revision || sourceRevision !== this.sourceRevision) return if (historicalContext) { - const task = detail.task - if (task?.id !== ref.taskId || task.projectId !== ref.projectId || !task.completion - || !task.sessions.some(binding => binding.purpose === 'related' && binding.historicalSource + const task = detail.tasks?.find(item => item.id === ref.taskId && item.projectId === ref.projectId) + if (!task?.completion || !task.sessions.some(binding => binding.purpose === 'related' && binding.historicalSource && binding.session.runtimeId === historicalContext.runtimeId && binding.session.sessionId === historicalContext.sessionId)) throw Error('后续上下文暂不可进入,请刷新工作详情。') this.openSession(historicalContext) return diff --git a/tests/client/execution-continuation-render.spec.ts b/tests/client/execution-continuation-render.spec.ts index 3f78ad6..b80aee7 100644 --- a/tests/client/execution-continuation-render.spec.ts +++ b/tests/client/execution-continuation-render.spec.ts @@ -17,6 +17,7 @@ function controllerFor(detail: WorkCompleted, purpose: AssociatedWork['purpose'] command: vi.fn(async () => { throw Error('Render must not write') }), query: async query => { if (query.type === 'task.detail') return detail + if (query.type === 'project.detail') return { status: 'completed', tasks: detail.task ? [detail.task] : [] } if (query.type === 'resources.list') return { status: 'completed', resources: [{ ref: { runtimeId: 'runtime', workspaceId: 'ws' }, title: 'Workspace' }] } if (query.type === 'session.work') return { status: 'completed', work: [{ ref, title: 'Task continuation', projectName: 'Project', purpose }] } return { status: 'completed' } @@ -222,11 +223,20 @@ it.each(['native', 'header', 'task'] as const)('opens the exact saved historical expect(detail.task!.completion).toEqual(completion) }) -it('rejects a stale historical context link rather than substituting the canonical executor', async () => { - const detail = taskDetail(), controller = controllerFor(detail) +it.each(['missingTask', 'wrongProject', 'unfinished', 'removedBinding', 'reviewBinding', 'missingSource', 'wrongRuntime'] as const)('rejects %s historical context from the fresh project read without entering the canonical executor', async invalid => { + const detail = taskDetail(), successor = { runtimeId: 'runtime', sessionId: 'session-existing-context' } detail.task!.completion = completion - const entered = vi.spyOn(controller, 'openSession') - await expect(controller.openHistoricalContext(ref, { runtimeId: 'runtime', sessionId: 'unregistered' })).rejects.toThrow('后续上下文暂不可进入') + detail.task!.sessions = [{ session: successor, purpose: 'related', historicalSource: session }] + if (invalid === 'missingTask') delete detail.task + if (invalid === 'wrongProject') detail.task!.projectId = 'different-project' + if (invalid === 'unfinished') delete detail.task!.completion + if (invalid === 'removedBinding') detail.task!.sessions = [] + if (invalid === 'reviewBinding') detail.task!.sessions[0]!.purpose = 'review' + if (invalid === 'missingSource') delete detail.task!.sessions[0]!.historicalSource + if (invalid === 'wrongRuntime') detail.task!.sessions[0]!.session = { ...successor, runtimeId: 'foreign-runtime' } + const controller = controllerFor(detail), query = vi.spyOn(controller.client, 'query'), entered = vi.spyOn(controller, 'openSession') + await expect(controller.openHistoricalContext(ref, successor)).rejects.toThrow('后续上下文暂不可进入') + expect(query).toHaveBeenCalledExactlyOnceWith({ type: 'project.detail', projectId: ref.projectId }, expect.any(AbortSignal)) expect(entered).not.toHaveBeenCalled() expect(controller.client.command).not.toHaveBeenCalled() }) @@ -254,3 +264,28 @@ it.each(['control','history'] as const)('keeps the %s dock free of historical-co } expect(controller.client.command).not.toHaveBeenCalled() }) + + +it('shows and opens projected historical context while the old execution detail never resolves', async () => { + const detail = taskDetail(), successor = { runtimeId: 'runtime', sessionId: 'session-existing-context' } + detail.task!.completion = completion + detail.task!.sessions = [{ session: successor, purpose: 'related', historicalSource: session }] + const controller = controllerFor(detail), query = controller.client.query.bind(controller.client) + controller.client.query = vi.fn(async (value, signal): Promise => { + if (value.type === 'task.detail') return new Promise(() => {}) + if (value.type === 'session.work') return { status: 'completed', work: [{ ref, title: detail.task!.title, projectName: 'Project', purpose: 'control', historicalContexts: [successor] }] } + return query(value, signal) + }) + const entered = vi.spyOn(controller, 'openSession') + const view = mountProductionComponent({ controller, sessionId: 'current' }, 'ExecutionConversationContinuation', new URL('../../src/client/execution-native.tsx', import.meta.url)) + mounted.push(view) + await expect.poll(() => view.text()).toContain('后续上下文') + expect(view.text()).toContain('正在读取接续事实') + expect(entered).not.toHaveBeenCalled() + const before = vi.mocked(controller.client.query).mock.calls.length + view.click('进入会话') + await expect.poll(() => entered.mock.calls.length).toBe(1) + expect(entered).toHaveBeenCalledExactlyOnceWith(successor) + expect(vi.mocked(controller.client.query).mock.calls.slice(before).map(([value]) => value)).toEqual([{ type: 'project.detail', projectId: ref.projectId }]) + expect(controller.client.command).not.toHaveBeenCalled() +})