Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/verification/issue-370-completed-context-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 继续执行;本分支未部署、未重启服务、未修改生产数据。
2 changes: 1 addition & 1 deletion src/client/execution-native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ function NativeTaskContinuation({ controller, sessionId, work }: { controller: W
{error && <p role="alert">{error}</p>}
</aside>
<div className={styles.configPane}>
{completed && <HistoricalContextLinks sessions={detail?.task?.sessions.filter(binding => binding.historicalSource?.sessionId === sessionId && binding.historicalSource.runtimeId === detail.execution?.resource.runtimeId).map(binding => binding.session)} busy={busy} onEnter={session => void enterContext(session)}/>}
<HistoricalContextLinks sessions={work.historicalContexts} busy={busy} onEnter={session => void enterContext(session)}/>
{detail ? <ExecutionContinuation graph={detail.executionGraph} execution={detail.execution} policy={policy} busy={busy} completed={completed} {...(work.purpose === 'control' && !completed && detail.execution?.session.sessionId === sessionId ? { onContinue: () => void controlWork('continue'), onRecover: () => void controlWork('reconcile'), onResume: () => void controlWork('resume') } : {})}/> : !error && <p role="status">正在读取接续事实…</p>}
</div>
</div>
Expand Down
7 changes: 3 additions & 4 deletions src/client/work-panel-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 39 additions & 4 deletions tests/client/execution-continuation-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -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()
})
Expand Down Expand Up @@ -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<WorkCompleted> => {
if (value.type === 'task.detail') return new Promise<WorkCompleted>(() => {})
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()
})
Loading