}
{execution ? <>
{(()=>{const task=state.detail?.task;if(!task)return null;const counts=controller.sessionCounts(task);return `${counts.total} 个会话 · 当前 ${counts.current} · 历史 ${counts.historical} · 其他关联 ${counts.associated}`})()}
执行状态:{execution.control?.action === 'end' ? (execution.control.status === 'applied' ? '队列已结束,业务验收未确认' : '正在结束队列处理') : state.detail?.task?.completion?({pending:'等待执行收口',settled:'执行已安全收口',error:'执行收口失败'})[state.detail.task.completion.closeout.status]:executionLabel(execution.state,progress,execution.control?.status)}{executionRuntime ? ` · 会话:${({running:'正在运行',idle:'空闲',inactive:'未激活'})[executionRuntime]}` : ''}
{execution.progress && 工作进展:{execution.progress}
}
@@ -350,8 +353,8 @@ function activityLabel(type:string):string {return ({'task.create':'创建工作
function ErrorText({error}:{error:string}) {return error ? {error}
:null}
/** Present native work links without owning navigation or execution side effects. */
-export function SessionWorkLinks({work,onReveal,onEnter,opening=false}:{work:AssociatedWork[];onReveal:(ref:AssociatedWork['ref'])=>void;onEnter:(ref:AssociatedWork['ref'])=>void;opening?:boolean}) {
- return <>{work.map(item=>{item.purpose==='history'&&<> 处理会话已切换;内容投递状态见「A²D² 接续」 · >})}>
+export function SessionWorkLinks({work,onReveal,onEnter,opening=false}:{work:AssociatedWork[];onReveal:(ref:AssociatedWork['ref'])=>void;onEnter:(ref:AssociatedWork['ref'],session?:SessionRef)=>void;opening?:boolean}) {
+ return <>{work.map(item=>{item.purpose==='history'&&<> 处理会话已切换;内容投递状态见「A²D² 接续」 · >}onEnter(item.ref,session)}/>)}>
}
/** Mounted native entries observe role changes; polling never opens a Session or touches drafts. */
export function SessionWorkEntry({controller, sessionId}: {controller: WorkPanelController; sessionId: string}) {
@@ -365,16 +368,16 @@ export function SessionWorkEntry({controller, sessionId}: {controller: WorkPanel
const stop=controller.watchSessionWork(sessionId,setWork,setError)
return () => {abort.abort();stop()}
},[controller,sessionId])
- async function enter(ref:AssociatedWork['ref']) {
+ async function enter(ref:AssociatedWork['ref'],session?:SessionRef) {
if(!scope||scope.signal.aborted||opening)return
setOpening(true);setNavigationError(null)
- try {await controller.openCurrentTask(ref,scope.signal)}catch(error){if(!scope.signal.aborted)setNavigationError(message(error))}
+ try {await (session?controller.openHistoricalContext(ref,session,scope.signal):controller.openCurrentTask(ref,scope.signal))}catch(error){if(!scope.signal.aborted)setNavigationError(message(error))}
finally {if(!scope.signal.aborted)setOpening(false)}
}
if (!work.length&&!error&&!navigationError) return null
return
{error&&工作关联读取失败}
- {SessionWorkLinks({work,onReveal:ref=>void controller.revealTask(ref),onEnter:ref=>void enter(ref),opening})}
+ {SessionWorkLinks({work,onReveal:ref=>void controller.revealTask(ref),onEnter:(ref,session)=>void enter(ref,session),opening})}
{navigationError&&{navigationError}}
}
diff --git a/src/work-execution/dsh-host.ts b/src/work-execution/dsh-host.ts
index 56a5aef9..2339d02f 100644
--- a/src/work-execution/dsh-host.ts
+++ b/src/work-execution/dsh-host.ts
@@ -295,7 +295,7 @@ export class DshWorkExecutionHost implements WorkExecutionHost {
bindings = await this.observationScope(execution)
} catch { blockers.push({ code: 'execution-scope-unavailable', session: execution.session }); limitations.add('无法从权威 Task 记录证明完整关联范围,结算状态未知。') }
}
- for (const binding of bindings) roots.set(sessionKey(binding.session), { session: binding.session, role: binding.purpose })
+ for (const binding of bindings.filter(item => !item.historicalSource)) roots.set(sessionKey(binding.session), { session: binding.session, role: binding.purpose })
for (const old of execution.retired ?? []) roots.set(sessionKey(old.session), { session: old.session, role: 'retired' })
roots.set(sessionKey(execution.session), { session: execution.session, role: 'current' })
const receivedChildren = new Map()
diff --git a/src/work-execution/observation.ts b/src/work-execution/observation.ts
index 1121d0e5..4992243a 100644
--- a/src/work-execution/observation.ts
+++ b/src/work-execution/observation.ts
@@ -89,7 +89,7 @@ export function buildExecutionGraph(input: { task: WorkTask; execution?: WorkExe
const ref = { projectId: task.projectId, taskId: task.id }
if (execution && !sameTask(execution.ref, ref)) throw new Error('Execution does not belong to this Task')
const bound = new Map()
- for (const association of task.sessions) bound.set(sessionKey(association.session), { session: association.session, role: association.purpose, status: 'unknown', children: [], jobs: [] })
+ for (const association of task.sessions.filter(item => !item.historicalSource)) bound.set(sessionKey(association.session), { session: association.session, role: association.purpose, status: 'unknown', children: [], jobs: [] })
for (const old of execution?.retired ?? []) bound.set(sessionKey(old.session), { session: old.session, role: 'retired', status: 'unknown', children: [], jobs: [] })
if (execution) bound.set(sessionKey(execution.session), { session: execution.session, role: 'current', status: 'unknown', children: [], jobs: [] })
// Only an observation of an explicitly bound root or its native descendants belongs here.
diff --git a/src/work-schema.ts b/src/work-schema.ts
index be328dc5..76f99860 100644
--- a/src/work-schema.ts
+++ b/src/work-schema.ts
@@ -29,7 +29,7 @@ export const taskSchema = z.object({
completion: completionSchema.optional(), childCompletions: z.array(childCompletionSchema).optional(),
specification: specificationSchema, specificationHistory: z.array(specificationSchema),
breakdown: z.array(block), parent: taskRefSchema.optional(), children: z.array(taskRefSchema),
- sessions: z.array(z.object({ session: sessionRefSchema, purpose: z.enum(['related', 'review']) }).strict()),
+ sessions: z.array(z.object({ session: sessionRefSchema, purpose: z.enum(['related', 'review']), historicalSource: sessionRefSchema.optional() }).strict()),
activity: z.array(z.object({ commandId: referenceId, type: referenceId, version: z.number().int().positive() }).strict()),
}).strict()
export const continuationContentSchema = z.object({ body: text, decisions: text, evidence: text, unfinished: text, next: text }).strict()
@@ -138,7 +138,7 @@ export const commandSchema = z.discriminatedUnion('type', [
z.object({ ...change, type: z.literal('task.update-spec'), goal: text, specification: text, acceptance: content.acceptance }).strict(),
z.object({ ...change, type: z.literal('task.replace-breakdown'), blocks: z.array(block).max(1000) }).strict(),
z.object({ ...change, type: z.literal('task.link-child'), child: taskRefSchema, childExpectedVersion: z.number().int().positive() }).strict(),
- z.object({ ...change, type: z.literal('task.associate-session'), session: sessionRefSchema, purpose: z.enum(['related', 'review']) }).strict(),
+ z.object({ ...change, type: z.literal('task.associate-session'), session: sessionRefSchema, purpose: z.enum(['related', 'review']), historicalSource: sessionRefSchema.optional() }).strict(),
z.object({ ...change, type: z.literal('legacy.map'), address: legacyAddressSchema }).strict(),
z.object({ ...change, type: z.literal('task.start'), agentId: id, resource: resourceRefSchema, session: sessionRefSchema.optional(),
agentPreset: referenceId, model: modelSchema, permissionPreset: referenceId }).strict(),
diff --git a/src/work-types.ts b/src/work-types.ts
index 436f77ff..6af0c3ca 100644
--- a/src/work-types.ts
+++ b/src/work-types.ts
@@ -37,7 +37,7 @@ export interface ContinuationOperation {
export interface Project { id: string; name: string; version: number; defaultAgentId: string; resources: ResourceRef[]; continuationPolicy?: ContinuationPolicy; executionDefaults?: Partial> }
export interface WorkSpecification { version: number; text: string; acceptance: string[]; missing?: ('body' | 'acceptance')[] }
export interface BreakdownBlock { id: string; title: string; description: string; status?: WorkStage; evidence?: string[]; child?: TaskRef }
-export interface SessionAssociation { session: SessionRef; purpose: 'related' | 'review' }
+export interface SessionAssociation { session: SessionRef; purpose: 'related' | 'review'; historicalSource?: SessionRef }
export interface WorkActivity { commandId: string; type: string; version: number }
export interface WorkTask {
id: string; projectId: string; title: string; goal: string; version: number; agentId: string
@@ -149,7 +149,7 @@ export type WorkCommand =
| (TaskChange & { type: 'task.update-spec'; goal: string; specification: string; acceptance: string[] })
| (TaskChange & { type: 'task.replace-breakdown'; blocks: BreakdownBlock[] })
| (TaskChange & { type: 'task.link-child'; child: TaskRef; childExpectedVersion: number })
- | (TaskChange & { type: 'task.associate-session'; session: SessionRef; purpose: 'related' | 'review' })
+ | (TaskChange & { type: 'task.associate-session'; session: SessionRef; purpose: 'related' | 'review'; historicalSource?: SessionRef })
| (TaskChange & { type: 'legacy.map'; address: LegacyAddress })
| (Command & ExecutionRef & { type: 'task.configure-continuation'; owner: string; epoch: number; policy: ContinuationPolicy })
| (Command & ExecutionRef & { type: 'task.continue'; owner: string; epoch: number })
@@ -183,7 +183,7 @@ export type WorkQuery =
| { type: 'session.candidates'; projectId: string }
| { type: 'session.work'; session: SessionRef }
| { type: 'legacy.resolve'; address: LegacyAddress }
-export interface AssociatedWork { ref: TaskRef; title: string; projectName: string; purpose: 'related' | 'review' | 'control' | 'history'; executionId?: string; epoch?: number; currentSession?: SessionRef }
+export interface AssociatedWork { ref: TaskRef; title: string; projectName: string; purpose: 'related' | 'review' | 'control' | 'history'; executionId?: string; epoch?: number; currentSession?: SessionRef; historicalContexts?: SessionRef[] }
/** Completed fields correspond to command/query; task.detail includes related children and parent. */
export interface SourceResultFields {
githubAccount?: import('./work-sources/types.ts').GitHubAccount
diff --git a/src/work.ts b/src/work.ts
index c7771ddd..6d0a1a4c 100644
--- a/src/work.ts
+++ b/src/work.ts
@@ -1825,7 +1825,20 @@ export class WorkApplication {
}
if (command.type === 'task.associate-session') {
const project = state.projects.find(item => item.id === command.ref.projectId)
- if (!project || !(await this.candidates(project)).some(item => sameSession(item.ref, command.session))) {
+ const task = taskAt(state, command.ref)!
+ const execution = state.executions.find(item => item.id === task.completion?.executionId && sameTask(item.ref, command.ref))
+ if (command.historicalSource && (!isTaskCompleted(task) || command.purpose !== 'related' || !execution
+ || !(sameSession(execution.session, command.historicalSource) || execution.retired?.some(item => sameSession(item.session, command.historicalSource!)))
+ || sameSession(command.session, command.historicalSource) || sameSession(command.session, execution.session)
+ || execution.retired?.some(item => sameSession(item.session, command.session)))) {
+ return failed('invalid-historical-context', 'Historical context requires a completed Task and an exact execution source with a separate related Session')
+ }
+ if (command.historicalSource && state.executions.some(item => item.id !== execution!.id && (sameSession(item.session, command.session)
+ || item.retired?.some(old => sameSession(old.session, command.session)) || (item.continuation && sameSession(item.continuation.successor, command.session))))) {
+ return failed('session-in-use', 'Historical context Session belongs to another execution')
+ }
+ if (!project || !(await this.candidates(project)).some(item => sameSession(item.ref, command.session)
+ && (!command.historicalSource || sameResource(item.resource, execution!.resource)))) {
return failed('incompatible-session', 'Session is not a compatible member of this project resource')
}
}
@@ -2096,12 +2109,16 @@ export class WorkApplication {
const existing = task.sessions.find(item => sameSession(item.session, command.session))
if (existing) {
if (existing.purpose !== command.purpose) return failed('association-purpose-conflict', 'Session is already associated with another purpose')
+ if (command.historicalSource) {
+ if (existing.historicalSource && !sameSession(existing.historicalSource, command.historicalSource)) return failed('association-source-conflict', 'Session already has another historical source')
+ if (!existing.historicalSource) { existing.historicalSource = command.historicalSource; this.changed(task, command) }
+ }
return { status: 'completed', task }
}
if (state.tasks.some(item => item.projectId !== projectId && item.sessions.some(binding => sameSession(binding.session, command.session)))) {
return failed('cross-project-reference', 'Session is associated with work in another project')
}
- task.sessions.push({ session: command.session, purpose: command.purpose })
+ task.sessions.push({ session: command.session, purpose: command.purpose, ...(command.historicalSource ? { historicalSource: command.historicalSource } : {}) })
this.changed(task, command)
return { status: 'completed', task }
}
@@ -2262,7 +2279,11 @@ export class WorkApplication {
ref: item.ref, title: taskAt(state, item.ref)!.title, projectName: state.projects.find(project => project.id === item.ref.projectId)!.name,
purpose: 'control' as const, executionId: item.id, epoch: item.epoch,
})),
- ] }
+ ].map(item => {
+ const task = taskAt(state, item.ref)!
+ const contexts = isTaskCompleted(task) ? task.sessions.filter(binding => binding.historicalSource && sameSession(binding.historicalSource, query.session)).map(binding => binding.session) : []
+ return contexts.length ? { ...item, historicalContexts: contexts } : item
+ }) }
if (query.type === 'session.candidates') {
const project = state.projects.find(item => item.id === query.projectId)
return project ? { status: 'completed', candidates: await this.candidates(project) } : failed('unknown-project', 'Project does not exist')
diff --git a/tests/client/execution-components.spec.ts b/tests/client/execution-components.spec.ts
index a2650633..7d6d2e40 100644
--- a/tests/client/execution-components.spec.ts
+++ b/tests/client/execution-components.spec.ts
@@ -5,7 +5,7 @@ import { expect, it } from 'vitest'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import styles from '../../src/client/execution-components.module.css'
-import { ExecutionSessions, ExecutionContinuation } from '../../src/client/execution-components.tsx'
+import { ExecutionSessions, ExecutionContinuation, HistoricalContextLinks } from '../../src/client/execution-components.tsx'
import type { ExecutionGraph } from '../../src/work-execution/observation.ts'
import type { WorkExecution } from '../../src/work-types.ts'
@@ -61,3 +61,12 @@ it('renders independent child entry, real job owner and distinct delivery/consum
await writeFile(resolve(directory, 'component-contract.html'), `#138 公开组件合同#138 公开组件合同验证
${html}${continuation}`)
}
})
+
+
+it('uses the existing task action tokens for historical context buttons outside execution surfaces', () => {
+ const html = renderToStaticMarkup(createElement(HistoricalContextLinks, { sessions: [{ runtimeId: 'runtime', sessionId: 'session-existing-context' }], onEnter: () => {} }))
+ expect(html).toContain(styles.taskActions)
+ expect(html).toContain(styles.row)
+ expect(html).toContain('后续上下文')
+ expect(html).not.toContain('session-existing-context')
+})
diff --git a/tests/client/execution-continuation-render.spec.ts b/tests/client/execution-continuation-render.spec.ts
index e1ff7547..3f78ad60 100644
--- a/tests/client/execution-continuation-render.spec.ts
+++ b/tests/client/execution-continuation-render.spec.ts
@@ -191,3 +191,66 @@ it('keeps ended queue work disabled after its control has applied', async () =>
expect(view.nodes('button').some(node => node.props.children === '恢复处理')).toBe(false)
expect(controller.client.command).not.toHaveBeenCalled()
})
+
+it.each(['native', 'header', 'task'] as const)('opens the exact saved historical context from the completed %s surface without executing work', async surface => {
+ const detail = taskDetail(), successor = { runtimeId: 'runtime', sessionId: 'session-existing-historical-context' }
+ detail.task!.completion = completion
+ detail.task!.sessions = [{ session: successor, purpose: 'related', historicalSource: session }]
+ const controller = controllerFor(detail)
+ const query = controller.client.query.bind(controller.client)
+ controller.client.query = async (value, signal) => value.type === 'session.work'
+ ? { status: 'completed', work: [{ ref, title: detail.task!.title, projectName: 'Project', purpose: 'control', historicalContexts: [successor] }] }
+ : query(value, signal)
+ const entered = vi.spyOn(controller, 'openSession')
+ let view: ReturnType
+ if (surface === 'task') {
+ await controller.openTask(ref)
+ view = mountProductionComponent({ controller }, 'CurrentExecution')
+ } else view = mountProductionComponent({ controller, sessionId: 'current', blank: false, placement: surface === 'header' ? 'header' : 'dock' }, surface === 'native' ? 'ExecutionConversationContinuation' : 'ExecutionSessionWorkEntry', new URL('../../src/client/execution-native.tsx', import.meta.url))
+ mounted.push(view)
+ await expect.poll(() => view.text()).toContain('后续上下文')
+ expect(view.text()).not.toContain('后续上下文 ·')
+ expect(view.text()).not.toContain(successor.sessionId)
+ expect(entered).not.toHaveBeenCalled()
+ // Work detail also keeps its original execution Session link. The context is explicitly separate.
+ const context = view.nodes('button').find(button => button.props.children === '进入会话')!
+ await context.props.onClick()
+ await expect.poll(() => entered.mock.calls.length).toBe(1)
+ expect(entered).toHaveBeenCalledExactlyOnceWith(successor)
+ expect(controller.client.command).not.toHaveBeenCalled()
+ expect(detail.execution!.session).toEqual(session)
+ 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)
+ detail.task!.completion = completion
+ const entered = vi.spyOn(controller, 'openSession')
+ await expect(controller.openHistoricalContext(ref, { runtimeId: 'runtime', sessionId: 'unregistered' })).rejects.toThrow('后续上下文暂不可进入')
+ expect(entered).not.toHaveBeenCalled()
+ expect(controller.client.command).not.toHaveBeenCalled()
+})
+
+
+it.each(['control','history'] as const)('keeps the %s dock free of historical-context duplicates and preserves formal history navigation', async purpose => {
+ const detail = taskDetail(), successor = { runtimeId: 'runtime', sessionId: 'session-historical-context' }
+ detail.task!.completion = completion
+ detail.task!.sessions = [{ session: successor, purpose: 'related', historicalSource: session }]
+ const controller = controllerFor(detail, purpose), query = controller.client.query.bind(controller.client)
+ controller.client.query = vi.fn(async (value, signal) => value.type === 'session.work'
+ ? { status: 'completed' as const, work: [{ ref, title: detail.task!.title, projectName: 'Project', purpose, historicalContexts: [successor] }] }
+ : query(value, signal))
+ const entered = vi.spyOn(controller, 'openSession')
+ const view = mountProductionComponent({ controller, sessionId: 'current', blank: false, placement: 'dock' }, 'ExecutionSessionWorkEntry', new URL('../../src/client/execution-native.tsx', import.meta.url))
+ mounted.push(view)
+ await expect.poll(() => vi.mocked(controller.client.query).mock.calls.some(([value]) => value.type === 'session.work')).toBe(true)
+ expect(view.text()).not.toContain('后续上下文')
+ if (purpose === 'control') expect(view.text()).toBe('')
+ else {
+ expect(view.text()).toContain('处理会话已切换')
+ view.click('进入会话')
+ await expect.poll(() => entered.mock.calls.length).toBe(1)
+ expect(entered).toHaveBeenCalledExactlyOnceWith(session)
+ }
+ expect(controller.client.command).not.toHaveBeenCalled()
+})
diff --git a/tests/work-completion.spec.ts b/tests/work-completion.spec.ts
index 76a995a9..921dbf11 100644
--- a/tests/work-completion.spec.ts
+++ b/tests/work-completion.spec.ts
@@ -2,9 +2,10 @@ import { afterEach, expect, it } from 'vitest'
import { mkdtemp, realpath, readFile, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname, basename } from 'node:path'
-import { WorkApplication, type WorkHost } from '../src/work.ts'
+import { WorkApplication, WorkDao, type WorkHost } from '../src/work.ts'
import type { WorkCompleted, WorkResult, WorkTask, WorkExecution } from '../src/work-types.ts'
import type { GitHubIssue, GitHubSourcePort } from '../src/work-sources/types.ts'
+import { reserveContinuation } from '../src/work-execution/continuation.ts'
const operator={operator:true}
const roots:string[]=[]
afterEach(async()=>{for(const root of roots.splice(0)){if(dirname(root)!==tmpdir()||!basename(root).startsWith('completion-contract-')||await realpath(root)!==root)throw Error('Unsafe cleanup');await rm(root,{recursive:true,force:true})}})
@@ -198,3 +199,70 @@ it.each(['missing', 'throws'] as const)('names %s result observation before acce
expect(await h.guard(h.execution.session.sessionId)).toBe(true)
expect(h.writes()).toBe(0)
})
+
+it.each([false,true])('durably links completed source to its exact existing historical context (already associated=%s) without changing completion or execution', async alreadyAssociated => {
+ const h=await setup(),task=await h.create('historical'),execution=await h.start(task)
+ const successor={runtimeId:'runtime',sessionId:'existing-context'}
+ h.host.sessions=async()=>[{ref:successor,resource:execution.resource,title:'Existing context'}]
+ const beforeDone=await h.detail(task)
+ const command={type:'task.associate-session',commandId:'historical-link',ref:execution.ref,expectedVersion:beforeDone.task!.version,session:successor,purpose:'related',historicalSource:execution.session}
+ expect(await h.app.execute({...command,commandId:'active-link'},operator)).toMatchObject({status:'failed',error:{code:'invalid-historical-context'}})
+ if(alreadyAssociated) ok(await h.app.execute({type:'task.associate-session',commandId:'plain-link',ref:execution.ref,expectedVersion:beforeDone.task!.version,session:successor,purpose:'related'},operator))
+ await h.done(execution)
+ const before=await h.detail(task)
+ const completedCommand={...command,expectedVersion:before.task!.version}
+ expect(await h.app.execute({...completedCommand,commandId:'wrong-source',historicalSource:{...execution.session,sessionId:'unrelated'}},operator)).toMatchObject({status:'failed',error:{code:'invalid-historical-context'}})
+ expect(await h.app.execute({...completedCommand,commandId:'self-link',session:execution.session},operator)).toMatchObject({status:'failed'})
+ h.host.sessions=async()=>[{ref:successor,resource:{...execution.resource,workspaceId:'other'},title:'Wrong workspace'}]
+ expect(await h.app.execute({...completedCommand,commandId:'wrong-workspace'},operator)).toMatchObject({status:'failed',error:{code:'incompatible-session'}})
+ h.host.sessions=async()=>[{ref:successor,resource:execution.resource,title:'Existing context'}]
+ const linked=ok(await h.app.execute(completedCommand,operator))
+ expect(linked.task!.sessions).toContainEqual({session:successor,purpose:'related',historicalSource:execution.session})
+ expect(await h.app.execute({...completedCommand,commandId:'same-link',expectedVersion:linked.task!.version},operator)).toMatchObject({status:'completed',task:{version:linked.task!.version}})
+ const {historicalSource:omitted,...legacyCommand}=completedCommand
+ expect(await h.app.execute({...legacyCommand,commandId:'legacy-repeat',expectedVersion:linked.task!.version},operator)).toMatchObject({status:'completed',task:{version:linked.task!.version}})
+ const restored=new WorkApplication(h.app.directory,h.host)
+ const after=ok(await restored.query({type:'task.detail',ref:execution.ref},operator))
+ expect(after.task!.completion).toEqual(before.task!.completion)
+ expect(after.task!.specification).toEqual(before.task!.specification)
+ expect(after.execution).toEqual(before.execution)
+ const source=ok(await restored.query({type:'session.work',session:execution.session},operator))
+ expect(source.work).toMatchObject([{purpose:'control',historicalContexts:[successor]}])
+ expect(await restored.query({type:'session.work',session:successor},operator)).toMatchObject({work:[{purpose:'related'}]})
+ const {createElement}=await import('react'),{renderToStaticMarkup}=await import('react-dom/server')
+ const panelPath='../src/client/work-panel.tsx'
+ const {SessionWorkLinks}=await import(panelPath)
+ const markup=renderToStaticMarkup(createElement(SessionWorkLinks,{work:source.work!,onReveal:()=>{},onEnter:()=>{}}))
+ expect(markup).toContain('后续上下文')
+ expect(markup).toContain('进入会话')
+ expect(h.calls.ensure).toBe(1)
+})
+
+
+it.each(['current','retired','reserved'] as const)('rejects another Task execution %s Session as historical context while preserving ordinary related association', async occupied => {
+ const h=await setup(),task=await h.create('completed-source'),execution=await h.start(task)
+ await h.done(execution)
+ const otherTask=await h.create('other-business'),otherExecution=await h.start(otherTask)
+ let target=otherExecution.session
+ await WorkDao.forDirectory(h.app.directory).transaction(async state=>{
+ const other=state.executions.find(item=>item.id===otherExecution.id)!
+ if(occupied==='retired') {
+ other.epoch=2
+ target={runtimeId:other.session.runtimeId,sessionId:'other-retired-session'}
+ other.retired=[{session:target,epoch:1,operationId:'a3a35145-1234-4567-8910-123456789012'}]
+ }
+ if(occupied==='reserved') {
+ reserveContinuation(other,state.tasks.find(item=>item.id===otherTask.id)!,'other-reservation',false)
+ target=other.continuation!.successor
+ }
+ })
+ h.host.sessions=async()=>[{ref:target,resource:execution.resource,title:'Other business context'}]
+ const before=await h.detail(task),otherBefore=await h.detail(otherTask)
+ const command={type:'task.associate-session',commandId:'occupied-historical',ref:execution.ref,expectedVersion:before.task!.version,session:target,purpose:'related',historicalSource:execution.session}
+ expect(await h.app.execute(command,operator)).toMatchObject({status:'failed',error:{code:'session-in-use'}})
+ expect((await h.detail(task)).task).toEqual(before.task)
+ expect((await h.detail(task)).execution).toEqual(before.execution)
+ expect((await h.detail(otherTask)).execution).toEqual(otherBefore.execution)
+ const {historicalSource:omitted,...ordinary}=command
+ expect(await h.app.execute({...ordinary,commandId:'ordinary-related'},operator)).toMatchObject({status:'completed',task:{sessions:[{session:target,purpose:'related'}]}})
+})
diff --git a/tests/work-execution-boundary.spec.ts b/tests/work-execution-boundary.spec.ts
index cf04008e..f441320b 100644
--- a/tests/work-execution-boundary.spec.ts
+++ b/tests/work-execution-boundary.spec.ts
@@ -1,6 +1,8 @@
import { afterEach, beforeEach, expect, it } from 'vitest'
import LocalJobs from '@deepseek-ai/dsh-jobs-local'
import { SessionId } from '@deepseek-ai/dsh-session'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+import { buildExecutionGraph } from '../src/work-execution/observation.ts'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { ExecutionFixtures } from './work-execution-fixture.ts'
import { DshWorkExecutionHost } from '../src/work-execution/dsh-host.ts'
@@ -91,3 +93,47 @@ it('reads real retired-owner jobs without collecting results or including unowne
expect(h.ctx.jobs.read(terminal, SessionId(old.sessionId)).result).toBe('untouched result')
expect(h.model.requests).toHaveLength(0)
})
+
+
+it('keeps running historical context and its jobs outside execution settlement while ordinary related sessions remain observed', async () => {
+ const h = await fixtures.mount(await fixtures.root())
+ await h.ctx.plugin(LocalJobs)
+ h.ctx.jobs.attachController('historical-context-test')
+ const current = await h.ctx.sessionController.create({ workspaceId: h.workspace.id, agentPreset: 'contract' })
+ const context = await h.ctx.sessionController.create({ workspaceId: h.workspace.id, agentPreset: 'contract' })
+ const agent = h.ctx.agents.get(context.sessionId)!
+ let release!: () => void, entered!: () => void
+ const gate = new Promise(resolve => { release = resolve })
+ const started = new Promise(resolve => { entered = resolve })
+ h.ctx.tools.register(defineTool({ name: 'hold_historical_context', description: 'Independent historical conversation', parameters: {},
+ output: { schema: { type: 'object', additionalProperties: false, properties: { result: { type: 'string', required: true } } }, render: (_, value) => [{ type: 'text', text: value.result }] },
+ async execute() { entered(); await gate; return { result: 'Context complete' } } }))
+ const job = h.ctx.jobs.start({ kind: 'bash', label: 'historical context work', owner: context.sessionId, run: () => ({ cancel() { release() }, done: gate.then(() => ({ status: 'completed' as const })) }) })
+ h.model.nextTool = { name: 'hold_historical_context', arguments: {} }
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Continue the independent conversation' }], source: { kind: 'user' } }))
+ await started
+ const execution: WorkExecution = { ...executionDefaults, id: 'execution', ref: { projectId: 'project', taskId: 'task' }, session: { runtimeId: 'runtime', sessionId: current.sessionId }, resource: { runtimeId: 'runtime', workspaceId: h.workspace.id } }
+ const historical = { session: { runtimeId: 'runtime', sessionId: context.sessionId }, purpose: 'related' as const, historicalSource: execution.session }
+ const host = new DshWorkExecutionHost(h.ctx, 'runtime')
+ host.connectObservation(async () => [historical])
+ try {
+ const facts = await host.observation(execution)
+ expect(facts.settlement).toBe('settled')
+ expect(facts.blockers).toEqual([])
+ expect(facts.sessions.map(row => row.session.sessionId)).toEqual([current.sessionId])
+ const { historicalSource: omitted, ...related } = historical
+ const ordinary = await host.observation(execution, [related])
+ expect(ordinary.sessions.find(row => row.session.sessionId === context.sessionId)).toMatchObject({ status: 'running', jobs: [expect.objectContaining({ id: job })] })
+ expect(ordinary.blockers).toContainEqual({ code: 'executor-unsettled', session: historical.session })
+ expect(ordinary.blockers.some(row => row.jobId === job)).toBe(true)
+ const task = { id: 'task', projectId: 'project', title: 'Completed source', goal: 'Goal', version: 1, agentId: 'agent', specification: { version: 1, text: 'Spec', acceptance: [] }, specificationHistory: [], breakdown: [], children: [], sessions: [historical], activity: [] }
+ const graph = buildExecutionGraph({ task, execution, observation: ordinary })
+ expect(graph.sessions.map(row => row.session.sessionId)).toEqual([current.sessionId])
+ expect(graph.blockers).toEqual([])
+ expect(buildExecutionGraph({ task: { ...task, sessions: [related] }, execution, observation: ordinary }).sessions.some(row => row.session.sessionId === context.sessionId)).toBe(true)
+ } finally {
+ release()
+ await fixtures.run(() => agent.whenIdle())
+ await h.ctx.jobs.wait(job, 1000, context.sessionId)
+ }
+})