=> {
mock.remote.session.list.mockResolvedValue(ok({ items: include
- ? [{ sessionId: ID, updatedAt: 1, running: false, blank: true }]
+ ? [{ sessionId: ID, updatedAt: 1, running: false, blank: true, agentAvailable: true }]
: [] }))
await svc.refresh()
}
@@ -140,7 +140,7 @@ describe('Client reference sources', () => {
expect(b.svc.list.getSnapshot().ids).not.toContain(ID)
expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toEqual({ gateway: 1 })
expect(mock.log.requests(FOLLOW)).toHaveLength(0)
- expect(mock.remote.subagents.list).not.toHaveBeenCalled()
+ expect(mock.remote.session.projections).not.toHaveBeenCalled()
await b.feed(true)
expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toEqual({ gateway: 1 })
})
diff --git a/packages/api/session-controller/tests/remote/session.client.ts b/packages/api/session-controller/tests/remote/session.client.ts
index b4cd74676c..4d86abcf9e 100644
--- a/packages/api/session-controller/tests/remote/session.client.ts
+++ b/packages/api/session-controller/tests/remote/session.client.ts
@@ -145,7 +145,7 @@ export const sessionWorld: RemoteTable = {
data: 'AA==',
}),
'session/page': pageRule(ok({ records: [], hasMore: false })),
- 'subagents/list': ok({ entries: [], parentAvailable: true }),
+ 'session/projections': ok({ asOfSeq: -1, values: { subagentCatalog: [] } }),
'subagents/prompt': ok({ messageId: 'fake-message' }),
'subagents/interruptByParent': ok({ accepted: true }),
},
diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts
index c89ae1f97a..c8be6f2252 100644
--- a/packages/api/session-controller/tests/session-cold.host.spec.ts
+++ b/packages/api/session-controller/tests/session-cold.host.spec.ts
@@ -282,12 +282,12 @@ describe('Remote Agent and Session lookup policy', () => {
content: [{ type: 'text', text: 'survives restart' }],
source: { kind: 'user' },
})
- const events = [{
+ const events: SessionEvent[] = [{
type: 'agent/inbox/spliced',
- seq: 0,
+ seq: SessionSeq(0),
time: 1001,
data: { target: 'next-turn', start: 0, inserted: [message] },
- }] as SessionEvent[]
+ }]
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts
index 346ec758a1..c2cba32d55 100644
--- a/packages/api/session-controller/tests/session-fork.host.spec.ts
+++ b/packages/api/session-controller/tests/session-fork.host.spec.ts
@@ -5,18 +5,23 @@ import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
-import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
+import { ToolCallId, createMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
-import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
+import SessionStore, { TOOL_OUTCOME_UNKNOWN, SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import TokenMeter from '@deepseek-ai/dsh-token-meter'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
import {
createSessionTestRemote, installSessionReadTestServices, testSessionPersistence,
} from './test-remote.ts'
const sid = (id: string): SessionId => id as SessionId
+const message = (text: string) => createUserMessage({
+ content: [{ type: 'text', text }], source: { kind: 'user' },
+})
function request(payload: P): P {
return payload
@@ -90,7 +95,49 @@ const remote = (ctx: Context) => createSessionTestRemote(ctx, {
})
describe('sessions.fork', () => {
- it.each(['message', 'turn-end', 'omitted', 'past-end'] as const)(
+ it('keeps the completed manual compaction checkpoint when atSeq is omitted', async () => {
+ const ctx = new Context()
+ try {
+ await mountAgentLoopTestDependencies(ctx)
+ const harness = await mountAgentLoopTestHarness(ctx)
+ await ctx.plugin(TokenMeter)
+ const compact = new BasicCompactionEngine(ctx, { auto: false })
+ const adapter = new MockAdapter([
+ textResponse('reply'), textResponse('checkpoint'), textResponse('continued'),
+ ])
+ ctx.llm.registerAdapter(['mock'], adapter)
+ ctx.provide('workspaceRegistry', { list: () => [] } as never)
+ const source = await harness.create(sid('manual-compaction-source'), { provider: 'mock', model: 'mock' })
+ const originalPrompt = 'older conversation history '.repeat(60)
+ source.followup(message(originalPrompt))
+ await source.whenIdle()
+ expect(await compact.compactNow(source, new AbortController().signal)).not.toBeNull()
+ const compacted = source.session.snapshotEvents()
+ expect(compacted.at(-1)).toMatchObject({ type: 'compaction/end', data: { turn: null } })
+ source.inbox.append('next-turn', message('queued after compaction'))
+
+ const response = await createSessionTestRemote(ctx, {
+ defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp',
+ }).fork({ sessionId: source.id })
+
+ if (!response.ok) throw response.error
+ const child = ctx.agents.get(response.value.sessionId)!
+ expect(child.session.inheritedEventCount).toBe(compacted.length)
+ expect(child.session.snapshotEvents().slice(0, child.session.inheritedEventCount)).toEqual(compacted)
+ expect(child.session.deriveMessages()).toEqual(source.session.deriveMessages())
+ child.followup(message('continue from checkpoint'))
+ await child.whenIdle()
+ const requestText = JSON.stringify(adapter.requests.at(-1)?.messages)
+ expect(requestText).toContain('checkpoint')
+ expect(requestText).toContain('continue from checkpoint')
+ expect(requestText).not.toContain(originalPrompt)
+ expect(requestText).not.toContain('queued after compaction')
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it.each(['turn-end', 'omitted'] as const)(
'excludes the next user input when forking at %s', async (anchor) => {
const ctx = new Context()
try {
@@ -100,23 +147,17 @@ describe('sessions.fork', () => {
ctx.llm.registerAdapter(['mock'], adapter)
ctx.provide('workspaceRegistry', { list: () => [] } as never)
const source = await harness.create(sid('source'), { provider: 'mock', model: 'mock' })
- const message = (text: string) => createUserMessage({
- content: [{ type: 'text', text }], source: { kind: 'user' },
- })
source.followup(message('A'))
await source.whenIdle()
const boundary = source.session.snapshotEvents().at(-1)!.seq
- if (anchor === 'message' || anchor === 'turn-end') {
+ if (anchor === 'turn-end') {
source.followup(message('B'))
await source.whenIdle()
} else {
source.inbox.append('next-turn', message('B'))
}
const original = source.session.snapshotEvents()
- const atSeq = anchor === 'omitted' ? undefined
- : anchor === 'past-end' ? source.session.seq + 1
- : anchor === 'turn-end' ? boundary
- : original.find(event => event.type === 'assistant/message')!.seq
+ const atSeq = anchor === 'omitted' ? undefined : boundary
const response = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp',
}).fork({ sessionId: source.id, ...(atSeq === undefined ? {} : { atSeq }) })
@@ -137,10 +178,10 @@ describe('sessions.fork', () => {
},
)
- it('cuts at the anchored completed turn and records lineage and cwd', async () => {
+ it('cuts exactly at a completed turn and records lineage and cwd', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-source', 2)
- const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 1 }))
+ const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 2 }))
expect(response.ok ? null : response.error).toBeNull()
if (!response.ok) return
const child = ctx.sessions.get(response.value.sessionId)
@@ -184,7 +225,7 @@ describe('sessions.fork', () => {
const response = await remote(ctx).fork(request({ sessionId: grandchild.id }))
- expect(response.ok).toBe(true)
+ expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
expect(attachSession).toHaveBeenCalledWith(response.value.sessionId)
expect(ctx.sessions.get(response.value.sessionId)?.header).toMatchObject({
@@ -239,7 +280,7 @@ describe('sessions.fork', () => {
const response = await remote(ctx).fork(request({ sessionId: sourceId }))
- expect(response.ok).toBe(true)
+ expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
expect(resume).not.toHaveBeenCalled()
expect(ctx.agents.get(sourceId)).toBeUndefined()
@@ -251,30 +292,62 @@ describe('sessions.fork', () => {
await ctx.fiber.dispose()
})
- it('uses the last completed turn only for omitted and past-end anchors', async () => {
+ it('uses the latest completed prefix only when atSeq is omitted', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-tail', 2, 'open')
const proxy = remote(ctx)
- const expectedTypes = [
- 'turn/start', 'user/message', 'turn/end',
- 'turn/start', 'user/message', 'turn/end',
- 'session/end-seed',
- ]
const omitted = await proxy.fork(request({ sessionId: source.id }))
expect(omitted.ok).toBe(true)
if (omitted.ok) {
expect(ctx.sessions.get(omitted.value.sessionId)?.snapshotEvents().map(event => event.type))
- .toEqual(expectedTypes)
+ .toEqual([
+ 'turn/start', 'user/message', 'turn/end',
+ 'turn/start', 'user/message', 'turn/end',
+ 'session/end-seed',
+ ])
}
const pastEnd = await proxy.fork(request({ sessionId: source.id, atSeq: 999 }))
- expect(pastEnd.ok).toBe(true)
- if (pastEnd.ok) {
- expect(ctx.sessions.get(pastEnd.value.sessionId)?.snapshotEvents().map(event => event.type))
- .toEqual(expectedTypes)
- }
+ expect(pastEnd).toMatchObject({
+ ok: false,
+ error: { code: 'session/fork-unavailable', details: { sessionId: source.id } },
+ })
+ if (!pastEnd.ok) expect(pastEnd.error.message).toMatch(/does not exist/)
await ctx.fiber.dispose()
})
+ it('rejects an omitted boundary when no turn has completed', async () => {
+ const ctx = await composed()
+ const source = await liveAgent(ctx, 'session-never-completed', 0, 'open')
+ const response = await remote(ctx).fork(request({ sessionId: source.id }))
+ expect(response).toMatchObject({
+ ok: false,
+ error: { code: 'session/fork-unavailable', details: { sessionId: source.id } },
+ })
+ if (!response.ok) expect(response.error.message).toMatch(/no completed turn/)
+ await ctx.fiber.dispose()
+ })
+
+ it.each([false, true])('inherits standalone tail events with the next turn open: %s', async (openNextTurn) => {
+ const ctx = await composed()
+ try {
+ const source = await liveAgent(ctx, 'session-standalone-tail', 1)
+ source.append('session/title', { title: 'After the turn', messageSeqs: [], source: { kind: 'user' } })
+ const inherited = source.snapshotEvents()
+ if (openNextTurn) source.append('turn/start', { turn: 2 })
+
+ const response = await remote(ctx).fork({ sessionId: source.id })
+
+ if (!response.ok) throw response.error
+ const child = ctx.sessions.get(response.value.sessionId)!
+ expect(child.inheritedEventCount).toBe(inherited.length)
+ expect(child.snapshotEvents().slice(0, child.inheritedEventCount)).toEqual(inherited)
+ expect(child.snapshotEvents().slice(child.inheritedEventCount).map(event => event.type))
+ .toEqual(['session/end-seed'])
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
it('rejects invalid fork anchors before reading or creating a Session', async () => {
const ctx = await composed()
const proxy = remote(ctx)
@@ -287,33 +360,128 @@ describe('sessions.fork', () => {
await ctx.fiber.dispose()
})
- it('cuts through an aborted turn: stopped is closed, not open', async () => {
+ it('cuts before an aborted turn/end and synthesizes a forked closer', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-aborted', 1, 'aborted')
- // What a stopped message's fork button anchors on: the frozen node sits
- // one event before its turn/end, floored client-side to that event's seq.
const anchor = (source.snapshotEvents().at(-1)?.seq ?? 0) - 1
const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor }))
- expect(response.ok).toBe(true)
+ expect(response.ok ? undefined : response.error).toBeUndefined()
+ if (!response.ok) return
+ const child = ctx.sessions.get(response.value.sessionId)
+ expect(child?.snapshotEvents().map(event => event.type)).toEqual([
+ 'turn/start', 'user/message', 'turn/end',
+ 'turn/start', 'user/message', 'session/end-seed', 'turn/end',
+ ])
+ const closer = child?.snapshotEvents().at(-1)
+ expect(closer?.type === 'turn/end' && closer.data.reason).toEqual({ kind: 'forked' })
+ await ctx.fiber.dispose()
+ })
+
+ it('cuts exactly inside a live open turn and preserves the source', async () => {
+ const ctx = await composed()
+ const source = await liveAgent(ctx, 'session-open', 1, 'open')
+ const boundary = source.snapshotEvents().at(-1)?.seq ?? 0
+ const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary }))
+ expect(response.ok ? undefined : response.error).toBeUndefined()
+ if (!response.ok) return
+ const child = ctx.sessions.get(response.value.sessionId)
+ expect(child?.snapshotEvents().map(event => event.type)).toEqual([
+ 'turn/start', 'user/message', 'turn/end',
+ 'turn/start', 'user/message', 'session/end-seed', 'turn/end',
+ ])
+ const closer = child?.snapshotEvents().at(-1)
+ expect(closer?.type === 'turn/end' && closer.data.reason).toEqual({ kind: 'forked' })
+ expect(child?.inheritedEventCount).toBe(boundary + 1)
+ expect(source.snapshotEvents().at(-1)?.type).toBe('user/message')
+ await ctx.fiber.dispose()
+ })
+
+ it('does not extend an explicit boundary through standalone tail events', async () => {
+ const ctx = await composed()
+ const source = await liveAgent(ctx, 'session-exact', 1)
+ const boundary = source.snapshotEvents().at(-1)?.seq ?? 0
+ source.append('session/title', { title: 'after boundary', messageSeqs: [], source: { kind: 'fallback' } })
+
+ const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary }))
+
+ expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
expect(ctx.sessions.get(response.value.sessionId)?.snapshotEvents().map(event => event.type)).toEqual([
- 'turn/start', 'user/message', 'turn/end',
- 'turn/start', 'user/message', 'turn/end',
- 'session/end-seed',
+ 'turn/start', 'user/message', 'turn/end', 'session/end-seed',
])
await ctx.fiber.dispose()
})
- it('rejects an in-log anchor whose turn is still open', async () => {
+ it('repairs a cold open-step cut without resuming its Agent', async () => {
const ctx = await composed()
- const source = await liveAgent(ctx, 'session-open', 1, 'open')
- const anchor = source.snapshotEvents().at(-1)?.seq ?? 0
- const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor }))
- expect(response).toMatchObject({
- ok: false,
- error: { code: 'session/fork-unavailable', details: { sessionId: source.id } },
+ const sourceId = sid('session-cold-mid-turn')
+ const callId = ToolCallId('cold-dangling')
+ const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id: sourceId, createdAt: 1, cwd: '/proj', isSeeded: false }
+ const events = [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+ { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
+ {
+ type: 'user/message',
+ seq: 2,
+ time: 3,
+ data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
+ surfaceOp: 'append',
+ },
+ {
+ type: 'assistant/message',
+ seq: 3,
+ time: 4,
+ data: {
+ stream: [],
+ turn: 1,
+ step: 1,
+ message: createMessage({
+ role: 'assistant',
+ content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
+ source: { kind: 'model', provider: 'mock', model: 'mock' },
+ }),
+ },
+ surfaceOp: 'append',
+ },
+ { type: 'tool/call', seq: 4, time: 5, data: { turn: 1, step: 1, callId, name: 'read', arguments: '{}' } },
+ { type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
+ {
+ type: 'turn/end',
+ seq: 6,
+ time: 7,
+ data: { turn: 1, reason: { kind: 'error', error: { message: 'scheduler failed', code: 'UNKNOWN' } } },
+ },
+ ] as SessionEvent[]
+ ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
+ list: () => Promise.resolve([header]),
+ inspect: () => Promise.resolve({ meta: header, events }),
+ }) as never)
+ const resume = vi.spyOn(ctx.agents, 'resume')
+
+ const response = await remote(ctx).fork(request({ sessionId: sourceId, atSeq: 4 }))
+
+ expect(response.ok ? undefined : response.error).toBeUndefined()
+ if (!response.ok) return
+ expect(resume).not.toHaveBeenCalled()
+ const child = ctx.sessions.get(response.value.sessionId)
+ expect(child?.snapshotEvents().map(event => event.type)).toEqual([
+ 'turn/start', 'step/start', 'user/message', 'assistant/message',
+ 'tool/call', 'session/end-seed', 'tool/result', 'step/end', 'turn/end',
+ ])
+ expect(child?.inheritedEventCount).toBe(5)
+ expect(child?.snapshotEvents().at(-3)).toMatchObject({
+ type: 'tool/result',
+ data: {
+ turn: 1,
+ step: 1,
+ message: { source: { callId } },
+ error: { code: TOOL_OUTCOME_UNKNOWN },
+ },
+ sourceEventSeqs: [4],
})
- if (!response.ok) expect(response.error.message).toMatch(/has not completed/)
+ const closer = child?.snapshotEvents().at(-1)
+ expect(closer?.type === 'turn/end' && closer.data.reason).toEqual({ kind: 'forked' })
+ expect(child?.deriveMessages().at(-1)).toMatchObject({ source: { kind: 'tool', callId } })
await ctx.fiber.dispose()
})
@@ -343,7 +511,7 @@ describe('sessions.fork', () => {
})
source.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary.seq }))
- expect(response.ok).toBe(true)
+ expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
const child = ctx.agents.get(response.value.sessionId)
if (child === undefined) throw new Error('fork did not publish the child agent')
diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts
index b8bed4b26a..d836aa9734 100644
--- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts
+++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts
@@ -5,11 +5,18 @@ import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { type Agent, type AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import { LlmAttemptId, ToolCallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
import type { SessionFollowFrame, SessionPage, SessionWireEvent } from '@deepseek-ai/dsh-api-session-controller/types'
import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts'
+type CheckpointSource = Extract
+
+/** Build a typed checkpoint source for a journal fixture without owning compaction. */
+function checkpointSource(compactionId: string): CheckpointSource {
+ return { kind: 'compact-checkpoint', compactionId: compactionId as CheckpointSource['compactionId'] }
+}
/** Append a production-shaped human prompt to the session surface. */
function appendUserText(session: Session, text: string): SessionEvent {
return session.append('user/message', createUserMessage({
@@ -768,7 +775,7 @@ describe('Session history raw journal', () => {
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary' }],
- source: { kind: 'plugin', plugin: 'compact' },
+ source: checkpointSource('journal-compaction'),
}), {
surfaceOp: { op: 'replace', startSeq: shadowedStart, endSeq: shadowedEnd },
sourceEventSeqs: [...shadowed, summary.seq],
diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts
index bb9e77b4e2..51133aafde 100644
--- a/packages/api/session-controller/tests/session-models.host.spec.ts
+++ b/packages/api/session-controller/tests/session-models.host.spec.ts
@@ -298,7 +298,7 @@ describe('Web session model selection', () => {
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
agent.session.append('user/message', {
- id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' },
+ id: 'summary', role: 'user', source: { kind: 'compact-checkpoint', compactionId: 'model-selection-compaction' },
content: [{ type: 'text', text: 'image summarized' }],
} as never, {
surfaceOp: { op: 'replace', startSeq: imageEvent.seq, endSeq: imageEvent.seq },
diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts
index ee3298fbf8..fa0d65f857 100644
--- a/packages/api/session-controller/tests/sessions-service.client.spec.ts
+++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts
@@ -59,12 +59,12 @@ type FeedRow = {
origin?: 'subagent'
running?: boolean
blank?: boolean
- projections?: Record
+ projections?: import('../src/types.ts').SessionProjectionValues
}
async function feedList(b: Bench, rows: FeedRow[]): Promise {
b.mock.remote.session.list.mockResolvedValue(ok({
- items: rows.map(r => ({
+ items: rows.map(r => ({ agentAvailable: true,
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
@@ -73,7 +73,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise {
? {}
: { projections: { asOfSeq: 0, values: r.projections } }),
})),
- }) as never)
+ }))
await b.svc.refresh()
await Promise.resolve() // manager notifier flush
}
@@ -113,7 +113,7 @@ describe('list store projection', () => {
it('reflects live increments (host stream via manager) into the store', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }])
- b.svc.handleSessionAdded({
+ b.svc.handleSessionAdded({ agentAvailable: true,
sessionId: sid('s2'), updatedAt: 2, running: false, blank: true,
})
await Promise.resolve()
@@ -146,6 +146,24 @@ describe('search', () => {
})
describe('scope tree', () => {
+ it('opens a conversation from follow projections without a second projection request', async ({ bench }) => {
+ const b = bench()
+ b.mock.stream(FOLLOW, followScript(ok({
+ records: [], hasMore: false,
+ projections: { asOfSeq: 0, values: { subagentCatalog: [{
+ id: sid('child'), createdAt: 1, mode: 'one-shot',
+ }] } },
+ })))
+ await feedList(b, [{ id: 's1' }])
+ using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+ await reference.ready
+ await vi.waitFor(() => {
+ expect(b.svc.list.getSnapshot().projectionsBySession[sid('s1')]?.values.subagentCatalog)
+ .toEqual([{ id: sid('child'), createdAt: 1, mode: 'one-shot' }])
+ })
+ expect(b.mock.remote.session.projections).not.toHaveBeenCalled()
+ })
+
it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }])
@@ -507,7 +525,7 @@ describe('Agent scope disposal lifecycle', () => {
const b = bench()
const readiness = b.ctx.plugin(() => undefined)
await readiness
- b.svc.handleSessionAdded({
+ b.svc.handleSessionAdded({ agentAvailable: true,
sessionId: sid('live'), updatedAt: 1, running: false, blank: true,
})
await Promise.resolve()
@@ -701,55 +719,70 @@ describe('borrow-only bindings', () => {
})
describe('catalog-addressed navigation', () => {
- it('opens an explicit catalog without retaining it and keeps one-shot labels optional', async ({ bench }) => {
+ it('retains a projected child independently of its parent and shares its history generation', async ({ bench }) => {
const b = bench()
- b.svc.handleControlFrame({
- type: 'projection', sessionId: sid('one-shot'), key: 'title', value: 'Investigate startup', seq: 2,
- })
- b.mock.remote.subagents.list.mockResolvedValue(ok({
- entries: [
- { kind: 'child', id: sid('one-shot'), mode: 'one-shot', activity: 'inactive', hasChildren: false },
- { kind: 'diagnostic', id: sid('missing'), reason: 'unavailable' },
- ],
- parentAvailable: true,
+ await feedList(b, [{ id: 'root' }])
+ b.mock.remote.session.projections.mockResolvedValue(ok({
+ asOfSeq: 0, values: { subagentCatalog: [{ id: sid('child'), createdAt: 1, mode: 'continuable', label: 'Child' }] },
}))
- b.svc.setSubagentCatalogOpen(sid('root'), true)
- await b.svc.refreshSubagents(sid('root'))
- b.svc.setSubagentCatalogOpen(sid('root'), false)
-
- expect(b.svc.list.getSnapshot().byId[sid('one-shot')]).toMatchObject({
- title: 'Investigate startup',
- displayTitle: 'Investigate startup',
- projectionValues: { title: 'Investigate startup' },
- })
- expect(b.svc.list.getSnapshot().byId[sid('missing')]).toBeUndefined()
- expect(b.svc.binding(sid('one-shot'))).toBeUndefined()
- expect(b.mock.remote.subagents.list).toHaveBeenCalledOnce()
+ await b.svc.refreshProjections(sid('root'))
+ const address = b.svc.subagentAddress(sid('child'))!
+ using first = b.svc.retain(sid('child'), { source: 'controllerOperation' })
+ using second = b.svc.retain(address, { source: 'gateway' })
+ await Promise.all([first.ready, second.ready])
+ expect(first.binding).toBe(second.binding)
+ expect(b.svc.scope(sid('root'))).toBeUndefined()
+ expect(b.svc.list.getSnapshot().ids).toEqual([sid('root')])
+ expect(b.svc.list.getSnapshot().byId[sid('child')]?.retainedBy)
+ .toEqual({ controllerOperation: 1, gateway: 1 })
+ const binding = first.binding
+ first.release()
+ expect(b.svc.binding(sid('child'))).toBe(binding)
+ expect(b.svc.list.getSnapshot().byId[sid('child')]?.retainedBy).toEqual({ gateway: 1 })
+ second.release()
+ expect(b.svc.binding(sid('child'))).toBeUndefined()
+ expect(b.svc.list.getSnapshot().byId[sid('child')]?.retainedBy).toEqual({})
+ expect(b.svc.subagentAddress(sid('child'))).toEqual(address)
+ expect(b.mock.log.requests(FOLLOW)).toHaveLength(1)
+ expect(b.mock.remote.session.projections).toHaveBeenCalledOnce()
})
+ for (const title of [undefined, 'Investigate startup']) {
+ it(`loads an unretained one-shot child with projected title ${String(title)}`, async ({ bench }) => {
+ const b = bench()
+ if (title !== undefined) b.svc.handleControlFrame({
+ type: 'projection', sessionId: sid('one-shot'), key: 'title', value: title, seq: 2,
+ })
+ b.mock.remote.session.projections.mockResolvedValue(ok({
+ asOfSeq: 0, values: { subagentCatalog: [{ id: sid('one-shot'), createdAt: 1, mode: 'one-shot' }] },
+ }))
+ await b.svc.refreshProjections(sid('root'))
+ expect(b.svc.list.getSnapshot().byId[sid('one-shot')]?.displayTitle).toBe(title ?? 'one-shot')
+ if (title !== undefined) expect(b.svc.list.getSnapshot().byId[sid('one-shot')]).toMatchObject({
+ title, projectionValues: { title },
+ })
+ expect(b.svc.binding(sid('one-shot'))).toBeUndefined()
+ expect(b.svc.retainInfo(sid('one-shot')).getSnapshot().referenceCount).toBe(0)
+ expect(b.mock.remote.session.projections).toHaveBeenCalledOnce()
+ expect(b.mock.log.requests(FOLLOW)).toHaveLength(0)
+ })
+ }
+
it('keeps projected titles in standard list rows for an addressed route', async ({ bench }) => {
const b = bench()
- b.mock.remote.subagents.list.mockImplementation((payload) => {
- const parentSessionId = payload
- if (parentSessionId === sid('root')) {
- return Promise.resolve(ok({
- entries: [{
- kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
- activity: 'inactive', hasChildren: true,
- }] as never[],
- parentAvailable: true,
- }))
+ b.mock.remote.session.projections.mockImplementation((payload) => {
+ const { sessionId } = payload as { sessionId: SessionId }
+ if (sessionId === sid('root')) {
+ return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
+ id: sid('child'), mode: 'continuable', label: 'Child',
+ }] } }))
}
- if (parentSessionId === sid('child')) {
- return Promise.resolve(ok({
- entries: [{
- kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
- activity: 'inactive', hasChildren: false,
- }] as never[],
- parentAvailable: false,
- }))
+ if (sessionId === sid('child')) {
+ return Promise.resolve(ok({ asOfSeq: 0, values: { title: 'Child session title', subagentCatalog: [{ createdAt: 1,
+ id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
+ }] } }))
}
- return Promise.resolve(ok({ entries: [], parentAvailable: false }))
+ return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
})
await feedList(b, [
{ id: 'root' },
@@ -759,8 +792,8 @@ describe('catalog-addressed navigation', () => {
},
{ id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' },
])
- await b.svc.refreshSubagents(sid('root'))
- await b.svc.refreshSubagents(sid('child'))
+ await b.svc.refreshProjections(sid('root'))
+ await b.svc.refreshProjections(sid('child'))
using _reference = b.svc.retain({
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
}, { source: 'controllerOperation' })
@@ -776,31 +809,23 @@ describe('catalog-addressed navigation', () => {
it('projects a retained descendant and discovers ancestor addresses without retaining ancestor scopes', async ({ bench }) => {
const b = bench()
- b.mock.remote.subagents.list.mockImplementation((payload) => {
- const parentSessionId = payload
- if (parentSessionId === sid('root')) {
- return Promise.resolve(ok({
- entries: [{
- kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
- activity: 'inactive', hasChildren: true,
- }] as never[],
- parentAvailable: true,
- }))
+ b.mock.remote.session.projections.mockImplementation((payload) => {
+ const { sessionId } = payload as { sessionId: SessionId }
+ if (sessionId === sid('root')) {
+ return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
+ id: sid('child'), mode: 'continuable', label: 'Child',
+ }] } }))
}
- if (parentSessionId === sid('child')) {
- return Promise.resolve(ok({
- entries: [{
- kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
- activity: 'inactive', hasChildren: false,
- }] as never[],
- parentAvailable: false,
- }))
+ if (sessionId === sid('child')) {
+ return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
+ id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
+ }] } }))
}
- return Promise.resolve(ok({ entries: [], parentAvailable: false }))
+ return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
})
await feedList(b, [{ id: 'root' }])
- await b.svc.refreshSubagents(sid('root'))
- await b.svc.refreshSubagents(sid('child'))
+ await b.svc.refreshProjections(sid('root'))
+ await b.svc.refreshProjections(sid('child'))
using reference = b.svc.retain({
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
}, { source: 'controllerOperation' })
@@ -812,7 +837,9 @@ describe('catalog-addressed navigation', () => {
expect(list.byId[sid('grandchild')]).toMatchObject({ parentId: sid('child'), origin: 'subagent' })
expect(b.svc.binding(sid('child'))).toBeUndefined()
expect(b.svc.subagentAddress(sid('child'))).toEqual({
- parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable',
+ parentSessionId: sid('root'),
+ childSessionId: sid('child'),
+ mode: 'continuable',
})
using child = b.svc.retain(sid('child'), { source: 'controllerOperation' })
await child.ready
@@ -915,13 +942,12 @@ describe('fork', () => {
})
})
- it('floors a fractional anchor to the real event seq the wire accepts', async ({ bench }) => {
+ it('sends the exact boundary seq verbatim: callers pass real event seqs', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') }))
- // The frozen node of an interrupted turn carries turnEnd.seq - 0.9.
- await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child')
+ await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41 })).resolves.toBe('child')
expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: 'source', atSeq: 41 })
})
@@ -958,7 +984,7 @@ describe('fork', () => {
describe('catalog arrival', () => {
it('keeps a retained generation indexed after its Host row is removed', async ({ bench }) => {
const b = bench()
- b.svc.handleSessionAdded({ sessionId: sid('s-new'), updatedAt: 1, running: false, blank: true })
+ b.svc.handleSessionAdded({ agentAvailable: true, sessionId: sid('s-new'), updatedAt: 1, running: false, blank: true })
await Promise.resolve()
expect(b.svc.binding(sid('s-new'))).toBeUndefined()
const reference = b.svc.retain(sid('s-new'), { source: 'controllerOperation' })
@@ -1027,7 +1053,7 @@ describe('blank mirror', () => {
it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async ({ bench }) => {
const b = bench()
await feedList(b, [])
- b.svc.handleSessionAdded({
+ b.svc.handleSessionAdded({ agentAvailable: true,
sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a',
})
await Promise.resolve()
diff --git a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts
new file mode 100644
index 0000000000..3c8cf0ca00
--- /dev/null
+++ b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts
@@ -0,0 +1,216 @@
+import { Context } from '@deepseek-ai/cordis'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
+import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
+import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
+import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import { describe, expect, it, vi } from 'vitest'
+import {
+ createSessionTestController,
+ installSessionReadTestServices,
+ testSessionPersistence,
+} from './test-remote.ts'
+
+const defaults = {
+ defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
+ cwd: '/tmp',
+}
+
+const PARENT = SessionId('catalog-parent')
+const CHILD = SessionId('catalog-child')
+const header: SessionHeader = {
+ version: SESSION_FORMAT_VERSION,
+ id: PARENT,
+ createdAt: 1,
+ isSeeded: false,
+ cwd: '/workspace',
+}
+const events: SessionEvent[] = [{
+ type: 'subagent/catalog',
+ seq: SessionSeq(0),
+ time: 1,
+ data: {
+ version: 0,
+ childId: CHILD,
+ childCreatedAt: 2,
+ mode: 'continuable',
+ label: 'worker',
+ },
+}]
+
+async function bench() {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(AgentRegistry)
+ ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
+ list: () => Promise.resolve([header]),
+ inspect: (sessionId: SessionId) => Promise.resolve(
+ sessionId === PARENT ? { meta: header, events } : undefined,
+ ),
+ }) as never)
+ installSessionReadTestServices(ctx)
+ await ctx.plugin(SubagentRuntime)
+ const controller = createSessionTestController(ctx, defaults)
+ return { ctx, controller }
+}
+
+describe('SessionController subagent catalog', () => {
+ it('pushes complete catalog values through the shared control stream', async () => {
+ const { ctx, controller } = await bench()
+ const signal = new AbortController()
+ const stream = controller.control(signal.signal)[Symbol.asyncIterator]()
+ try {
+ await stream.next()
+ const parent = ctx.sessions.create(PARENT, { meta: { createdAt: 7, cwd: '/workspace' } })
+ parent.append('subagent/catalog', {
+ version: 0, childId: CHILD, childCreatedAt: 8, mode: 'continuable', label: 'worker',
+ })
+ let frame = await stream.next()
+ while (!frame.done && !(frame.value.type === 'projection' && frame.value.key === 'subagentCatalog'
+ && Array.isArray(frame.value.value) && frame.value.value.length === 1)) {
+ frame = await stream.next()
+ }
+ expect(frame.value).toMatchObject({ type: 'projection', sessionId: PARENT, key: 'subagentCatalog',
+ value: [{ id: CHILD, createdAt: 8, mode: 'continuable', label: 'worker' }] })
+ } finally {
+ signal.abort()
+ await stream.return?.()
+ }
+ })
+
+ it('requests a stored parent catalog by id without loading an Agent', async () => {
+ const { ctx, controller } = await bench()
+ const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
+ const signal = new AbortController().signal
+
+ await expect(controller.projections({ sessionId: PARENT }, signal)).resolves.toMatchObject({ values: { subagentCatalog: [{
+ id: CHILD,
+ mode: 'continuable',
+ label: 'worker',
+ }] } })
+ expect(observe).toHaveBeenCalledOnce()
+ expect(observe).toHaveBeenCalledWith(PARENT, {
+ signal,
+ })
+ })
+
+ it('serves a live parent from its maintained registry state without folding its log', async () => {
+ const { ctx, controller } = await bench()
+ const parent = ctx.sessions.create(PARENT, { meta: { createdAt: 7, cwd: '/workspace' } })
+ parent.append('turn/start', { turn: 1 })
+ parent.append('subagent/catalog', {
+ version: 0,
+ childId: CHILD,
+ childCreatedAt: 8,
+ mode: 'one-shot',
+ label: 'live worker',
+ })
+ const snapshot = vi.spyOn(ctx.sessionProjections, 'snapshot')
+ const hydrate = vi.spyOn(ctx.sessionProjections, 'hydrate')
+
+ const result = await controller.projections({ sessionId: PARENT }, new AbortController().signal)
+ expect(result).toMatchObject({ values: { subagentCatalog: [{
+ id: CHILD,
+ mode: 'one-shot',
+ label: 'live worker',
+ }] } })
+ expect(snapshot).toHaveBeenCalledWith(parent)
+ expect(hydrate).not.toHaveBeenCalled()
+ })
+
+ it('reports Agent availability through Session summaries independently of projections', async () => {
+ const { ctx, controller } = await bench()
+ const parent = ctx.sessions.create(PARENT, { meta: { createdAt: 1, cwd: '/workspace' } })
+ parent.append('subagent/catalog', {
+ version: 0, childId: CHILD, childCreatedAt: 8, mode: 'one-shot',
+ })
+ const summaries: boolean[] = []
+ ctx.on('api-session/added', (summary) => { summaries.push(summary.agentAvailable) })
+ const dispose = ctx.agents.register({ id: PARENT, session: parent, status: 'idle', ctx } as Agent)
+ await dispose
+ expect(summaries).toEqual([true])
+ const available = await controller.list({}, new AbortController().signal)
+ expect(available.items.find(item => item.sessionId === PARENT)?.agentAvailable).toBe(true)
+ await dispose()
+ expect(summaries).toEqual([true, false])
+ const unavailable = await controller.list({}, new AbortController().signal)
+ expect(unavailable.items.find(item => item.sessionId === PARENT)?.agentAvailable).toBe(false)
+ const result = await controller.projections({ sessionId: PARENT }, new AbortController().signal)
+ expect(result).toMatchObject({ asOfSeq: 0, values: { subagentCatalog: [{ id: CHILD, createdAt: 8, mode: 'one-shot' }] } })
+ })
+
+ it('does not republish a Session when its Agent outlives the Session registration', async () => {
+ const { ctx } = await bench()
+ const session = ctx.sessions.prepare(SessionId('detached'), { meta: { cwd: '/workspace' } })
+ const detach = ctx.sessions.enter(session)
+ const disposeAgent = ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
+ await disposeAgent
+ const added = vi.fn()
+ ctx.on('api-session/added', added)
+ detach()
+ await disposeAgent()
+ expect(added).not.toHaveBeenCalled()
+ })
+
+ it('reads registered projections without a subagent provider or catalog key', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(AgentRegistry)
+ installSessionReadTestServices(ctx)
+ const controller = createSessionTestController(ctx, defaults)
+ const session = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } })
+ const expected = ctx.sessionProjections.snapshot(session)
+ expect(expected.values.subagentCatalog).toBeUndefined()
+ await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).resolves.toEqual(expected)
+ expect(ctx.agents.get(PARENT)).toBeUndefined()
+ })
+
+ it('validates the parent id and keeps not-found distinct from cancellation', async () => {
+ const { controller } = await bench()
+ const signal = new AbortController().signal
+ await expect(controller.projections({ sessionId: SessionId('') }, signal)).rejects.toMatchObject({
+ code: 'gateway/bad-request',
+ })
+ await expect(controller.projections({ sessionId: SessionId('missing') }, signal)).resolves.toEqual(null)
+ const aborted = new AbortController()
+ aborted.abort()
+ await expect(controller.projections({ sessionId: PARENT }, aborted.signal)).rejects.toMatchObject({
+ code: 'gateway/cancelled',
+ })
+ })
+
+ it('maps query cancellation, missing projections, and unexpected failures', async () => {
+ const { ctx, controller } = await bench()
+ const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
+ observe.mockRejectedValueOnce(new SessionQueryError(
+ 'query cancelled',
+ 'SESSION_QUERY_ABORTED',
+ ))
+ await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({
+ code: 'gateway/cancelled',
+ })
+
+ observe.mockRestore()
+ // An observation without the catalog state means no registry served it.
+ const stateless: SessionObservation = {
+ source: 'prepared',
+ header,
+ inheritedEventCount: SessionLogOffset(0),
+ events,
+ cursor: SessionSeq(0),
+ retain: () => stateless,
+ [Symbol.dispose]: () => {},
+ }
+ vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValueOnce(stateless)
+ await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({
+ code: 'session/projections-unavailable',
+ })
+
+ vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValueOnce(new Error('storage offline'))
+ await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({
+ code: 'gateway/internal',
+ })
+ })
+})
diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts
index 721288f7dc..b4d3db8198 100644
--- a/packages/api/session-controller/tests/test-remote.ts
+++ b/packages/api/session-controller/tests/test-remote.ts
@@ -9,6 +9,7 @@ import type {
ImageAttachmentLimits,
} from '@deepseek-ai/dsh-attachment'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionProjectionsValue } from '../src/types.ts'
import {
SessionPersistenceNotFoundError,
SessionPersistenceRevision,
@@ -57,6 +58,7 @@ import type {
SessionSearchValue,
SessionSelectModelRequest,
SessionSelectModelValue,
+ SessionProjectionsRequest,
SessionUpdateQueueRequest,
SessionUpdateQueueValue,
} from '../src/types.ts'
@@ -80,6 +82,7 @@ export interface TestSessionRemote {
signal?: AbortSignal,
): Promise>
page(request: SessionPageRequest, signal?: AbortSignal): Promise>
+ projections(request: SessionProjectionsRequest, signal?: AbortSignal): Promise>
follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable
control(signal?: AbortSignal): AsyncIterable
}
@@ -360,6 +363,10 @@ export function createSessionTestRemote(
() => direct.page(request, signal),
signal,
),
+ projections: (request, signal = new AbortController().signal) => remoteResult(
+ () => direct.projections(request, signal),
+ signal,
+ ),
follow: (request, signal = new AbortController().signal) => direct.follow(request, signal),
control: (signal = new AbortController().signal) => direct.control(signal),
}
diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts
index e83dac78fd..ece1a9ff99 100644
--- a/packages/api/session-controller/tests/transport.client.spec.ts
+++ b/packages/api/session-controller/tests/transport.client.spec.ts
@@ -81,7 +81,6 @@ function sessionClient(remote: SessionTransportRemote): SessionRemotes {
),
commands: { execute: () => Promise.reject(new Error('stream tests never run commands')) },
subagents: {
- list: () => Promise.reject(new Error('stream tests never read the subagent catalog')),
prompt: () => Promise.reject(new Error('stream tests never prompt a subagent')),
interruptByParent: () => Promise.reject(new Error('stream tests never interrupt a subagent')),
},
@@ -195,7 +194,7 @@ const invalidWireEvents: [string, unknown][] = [
...[false, undefined].map((isError): [string, unknown] => [
`contradictory tool error ${String(isError)}`,
{ ...surfaceEvent('tool/result'), data: {
- message: { content: [{ type: 'tool-result', content: [], ...(isError === undefined ? {} : { isError }) }] },
+ message: { role: 'tool', content: [], ...(isError === undefined ? {} : { isError }) },
error: { name: 'Error', code: 'FAILURE' },
} },
]),
@@ -243,11 +242,11 @@ describe('Session Client stream adapters', () => {
{ ...surfaceEvent(), surfaceOp: { op: 'replace', startSeq: 2, endSeq: 0 }, sourceEventSeqs: [2, 0] },
{ ...surfaceEvent('assistant/message'), data: { turn: 1, step: 1, message: {}, stream: [] } },
{ ...surfaceEvent('tool/result'), data: {
- message: { content: [{ type: 'tool-result', content: [], isError: true }] },
+ message: { role: 'tool', content: [], isError: true },
error: { name: 'Error', code: 'FAILURE' }, meta: { extension: ['retained'] },
} },
{ ...surfaceEvent('tool/result'), sourceEventSeqs: [0], data: {
- message: { content: [{ type: 'tool-result', content: [], isError: true }] },
+ message: { role: 'tool', content: [], isError: true },
} },
{ type: 'request/header', seq: 10, time: 10, data: {
reason: 'initial', header: { config: { provider: 'mock', model: 'mock' } },
diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts
index bac8a541d4..04b43972e9 100644
--- a/packages/api/session-controller/tests/transport.host.spec.ts
+++ b/packages/api/session-controller/tests/transport.host.spec.ts
@@ -441,7 +441,12 @@ describe('SessionHistoryController', () => {
const signal = new AbortController().signal
await expect(transport.page({
- address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
+ address: {
+ kind: 'subagent',
+ parentSessionId,
+ childSessionId,
+ mode: 'continuable',
+ },
throughSeq: 0,
}, signal)).resolves.toMatchObject({
records: [{ type: 'event', event: { type: 'subagent/descriptor' } }],
@@ -456,7 +461,12 @@ describe('SessionHistoryController', () => {
throughSeq: 0,
}, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' })
await expect(transport.page({
- address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'one-shot' },
+ address: {
+ kind: 'subagent',
+ parentSessionId,
+ childSessionId,
+ mode: 'one-shot',
+ },
throughSeq: 0,
}, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' })
await expect(transport.page({
@@ -659,7 +669,12 @@ describe('SessionHistoryController', () => {
const history = new SessionHistoryController(ctx, vi.fn())
await expect(history.page({
- address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
+ address: {
+ kind: 'subagent',
+ parentSessionId,
+ childSessionId,
+ mode: 'continuable',
+ },
throughSeq: -1,
}, signal())).rejects.toMatchObject({
code: 'subagent/catalog-diagnostic', details: { reason: 'unsupported' },
@@ -690,7 +705,12 @@ describe('SessionHistoryController', () => {
}))
const childSnapshot = vi.spyOn(child.ctx.sessionProjections, 'snapshot')
const page = await child.transport.page({
- address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
+ address: {
+ kind: 'subagent',
+ parentSessionId,
+ childSessionId,
+ mode: 'continuable',
+ },
throughSeq: 0,
}, signal())
expect('projections' in page).toBe(false)
diff --git a/packages/bundle/headless/src/json-stream.ts b/packages/bundle/headless/src/json-stream.ts
index f02583f2ad..2fa8cbc449 100644
--- a/packages/bundle/headless/src/json-stream.ts
+++ b/packages/bundle/headless/src/json-stream.ts
@@ -301,12 +301,12 @@ export function projectJsonRun(
// Compaction replaces older results in the surface; those are history,
// not this run's output, and would otherwise duplicate a callId.
if (event.surfaceOp !== 'append') return
- const block = event.data.message.content[0]
+ const message = event.data.message
write({
type: 'tool_result',
- callId: block.toolCallId,
- status: block.isError === true ? 'error' : 'completed',
- result: resultText(block.content),
+ callId: message.toolCallId,
+ status: message.isError === true ? 'error' : 'completed',
+ result: resultText(message.content),
})
return
}
diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts
index 96f646d264..4a92ad0991 100644
--- a/packages/bundle/headless/tests/headless.spec.ts
+++ b/packages/bundle/headless/tests/headless.spec.ts
@@ -13,7 +13,7 @@ import type {
ResumeAgentOptions,
} from '@deepseek-ai/dsh-agent'
import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model'
-import { LlmAttemptId, ToolCallId, createAssistantMessage, createToolResultMessage, type StreamChunk } from '@deepseek-ai/dsh-llm'
+import { LlmAttemptId, ToolCallId, createAssistantMessage, createToolResultMessage, type MessageId, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
@@ -263,8 +263,8 @@ describe('headless runner', () => {
const test = await bench({
before(session) {
const setupMessage = {
- role: 'user', content: [{ type: 'text', text: 'setup' }], source: { kind: 'user' }, id: 'setup',
- } as UserMessage
+ role: 'user', content: [{ type: 'text', text: 'setup' }], source: { kind: 'user' }, id: brandString('setup'),
+ } satisfies UserMessage
appendTurn(session, 0, setupMessage, 'pre-task noise', true)
},
async afterPrompt(session, message) {
@@ -607,8 +607,8 @@ describe('headless runner', () => {
})
const session = test.ctx.sessions.create(brandString('session-exact'), { meta: { cwd: process.cwd() } })
const history = {
- role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: 'history',
- } as UserMessage
+ role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: brandString('history'),
+ } satisfies UserMessage
appendTurn(session, 0, history, 'earlier answer', true)
const before = session.seq
expect(await test.run()).toMatchObject({ code: 0, out: 'resumed answer\n', err: '' })
@@ -789,8 +789,8 @@ describe('headless runner', () => {
const test = await bench({
before(session) {
const history = {
- role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: 'history',
- } as UserMessage
+ role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: brandString('history'),
+ } satisfies UserMessage
appendTurn(session, 0, history, 'earlier answer', true)
selectPreset(session, 'minimal')
},
@@ -845,8 +845,8 @@ describe('headless runner', () => {
const test = await bench({
before(session) {
const history = {
- role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: 'history',
- } as UserMessage
+ role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: brandString('history'),
+ } satisfies UserMessage
appendTurn(session, 0, history, 'earlier answer', true)
capturedLength = session.seq
Object.defineProperty(session, 'eventAt', { value: () => undefined })
diff --git a/packages/bundle/headless/tests/json-stream.spec.ts b/packages/bundle/headless/tests/json-stream.spec.ts
index a9332ee894..3e49a70dd5 100644
--- a/packages/bundle/headless/tests/json-stream.spec.ts
+++ b/packages/bundle/headless/tests/json-stream.spec.ts
@@ -59,7 +59,7 @@ function toolResult(
data: {
turn: 1,
step: 1,
- message: { content: [{ type: 'tool-result', toolCallId: callId, content, isError: false }] },
+ message: { role: 'tool', toolCallId: callId, isError: false, content },
},
} as unknown as SessionEvent
}
@@ -169,12 +169,10 @@ describe('--json projection', () => {
turn: 1,
step: 1,
message: {
- content: [{
- type: 'tool-result',
- toolCallId: 'c2',
- content: [{ type: 'image' }, { type: 'text' }, { type: 'text', text: 'boom' }],
- isError: true,
- }],
+ role: 'tool',
+ toolCallId: 'c2',
+ isError: true,
+ content: [{ type: 'image' }, { type: 'text' }, { type: 'text', text: 'boom' }],
},
},
} as unknown as SessionEvent)
diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx
index 7c2a0ec626..5068b90218 100644
--- a/packages/client/locale/tests/language-row.client.spec.tsx
+++ b/packages/client/locale/tests/language-row.client.spec.tsx
@@ -20,7 +20,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
function emptySessions() {
const store = createSnapshotStore(
- { ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
+ { ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, jobsBySession: {} })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml
index 1888d7c2e0..2ed6513974 100644
--- a/packages/client/ui-agent-preset/README.i18n.yaml
+++ b/packages/client/ui-agent-preset/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md
-README.md: a76a265c7af54f88fc12540598d6552a74c8dc50
-README.zh.md: de66174015b383f8d0c883bc2a9e544b30621c5e
+README.md: c7a87da591c1cafc433fbb3bb5fa216d94ab85ef
+README.zh.md: a54b47baed132e790fc4f44ad1155f48a19c430b
diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md
index a76a265c7a..c7a87da591 100644
--- a/packages/client/ui-agent-preset/README.md
+++ b/packages/client/ui-agent-preset/README.md
@@ -47,6 +47,8 @@ When the roster carries the self-referential `cordis` preset, its dashed add-car
The settings section writes the Host's existing `agent-presets` namespace through `settings.update`. Its visibility switch sets only `modeSelectionEnabled`, and its make-default action writes `default` only while the picker is shown. After either write, the Host roster supplies the effective default, and the chip controller's `agentPresets/select` path carries it to the same still-blank session; that path is the only session-mutation API these surfaces use. Display options and Host-effective visibility come from `agentPresets/list` — the roster already marks the effective Host default and carries `modeSelectionEnabled`, so a non-loopback read-only client stays consistent without introspecting the settings schema. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip renders only while `modeSelectionEnabled` is true; hiding it drops a pending stage and local menu or refusal state, while the header label remains registered and reads each session's recorded preset. The stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the Host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the Host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change.
+The chip follows the preset projection of its bound Session while its plugin and Provider generation remain active. Settings synchronization creates the same bound controller when needed, even before the chip mounts. A late projection or another client's preset update refreshes the display without submitting a selection; list notifications do not repeat an in-flight selection. Settings changes wait for that selection and recheck the captured blank Session before applying. A choice staged during a pending request remains available afterward.
+
-----
diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md
index de66174015..a54b47baed 100644
--- a/packages/client/ui-agent-preset/README.zh.md
+++ b/packages/client/ui-agent-preset/README.zh.md
@@ -47,6 +47,8 @@ kind: "package-reference"
设置分区通过现有的 `settings.update` 写入宿主的 `agent-presets` 命名空间。可见性开关只设置 `modeSelectionEnabled`;仅当选择器显示时,设为默认动作才会写入 `default`。两种写入之后,都由宿主名单给出当前生效的默认值,再由 chip controller 的 `agentPresets/select` 链路把它带到同一个仍为空白的会话;这些界面只使用这一条会话修改 API。展示选项与宿主的生效可见性来自 `agentPresets/list`——名单本身已标记宿主当前生效的默认值并携带 `modeSelectionEnabled`,因此非 loopback 的只读客户端无需内省 settings schema 也能保持一致。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 仅在 `modeSelectionEnabled` 为 `true` 时渲染;隐藏它会丢弃待处理的暂存选择及本地菜单或失败横幅状态,而标题标签保持注册并读取每个会话已记录的 preset。暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。系统会通过 composer 列上方的瞬时横幅提示拒绝结果,因为 chip 的标签此时已经恢复原值,而被宿主拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有用户刚做出的选择会触发提示;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有宿主 API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而复制、删除与设置模块拥有的目录打开操作负责管理名单并驱动宿主桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,协议链路不会通知文件变动。
+只要插件及 Provider generation 仍有效,选择芯片就会跟随其绑定 Session 的预设投影。即使选择芯片尚未挂载,设置同步也会按需创建同一个绑定控制器。延后到达的投影或其他客户端的预设更新会刷新显示,而不会提交选择;列表通知不会重复仍在进行中的选择操作。设置修改会等待该操作结束,并在应用之前重新检查已捕获的空白 Session。请求期间暂存的新选择会保留。
+
-----
diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts
index 1dad3aae98..c335518aff 100644
--- a/packages/client/ui-agent-preset/src/client/index.ts
+++ b/packages/client/ui-agent-preset/src/client/index.ts
@@ -68,22 +68,32 @@ export function apply(ctx: ClientContext): void {
const controller = new AgentPresetSettingsController(ctx)
const staged: AgentPresetStage = { id: undefined, introduce: false }
const seats = new WeakMapWithValues()
+ const boundSeatDisposers = new Set<() => Promise>()
+ ctx.effect(() => async () => {
+ await Promise.all([...boundSeatDisposers].map(dispose => dispose()))
+ }, 'ui-agent-preset: bound selections')
const unboundSeat = new AgentPresetSeatController(ctx, () => undefined, staged)
- const seatFor = (scope: ClientContext, binding: SessionBinding): AgentPresetSeatController => {
- let seat = seats.get(binding)
- if (seat !== undefined) return seat
- seat = new AgentPresetSeatController(scope, () => {
- if (scope.sessions.binding(binding.sessionId) !== binding) return undefined
- const summary = scope.sessions.list.getSnapshot().byId[binding.sessionId]
+ const seatFor = (binding: SessionBinding): AgentPresetSeatController => {
+ const existing = seats.get(binding)
+ if (existing !== undefined) return existing
+ const seat = new AgentPresetSeatController(ctx, () => {
+ if (ctx.sessions.binding(binding.sessionId) !== binding) return undefined
+ const summary = ctx.sessions.list.getSnapshot().byId[binding.sessionId]
return summary !== undefined
- && (scope.sessions.retainInfo(binding.sessionId).getSnapshot().retainedBy.mainView ?? 0) > 0
+ && (ctx.sessions.retainInfo(binding.sessionId).getSnapshot().retainedBy.mainView ?? 0) > 0
? summary
: undefined
}, staged)
seats.set(binding, seat)
- binding.ctx.effect(() => () => {
- seats.delete(binding)
+ const dispose = binding.ctx.effect(() => {
+ const stop = ctx.sessions.list.subscribe(() => { void seat.apply() })
+ return () => {
+ stop()
+ seats.delete(binding)
+ boundSeatDisposers.delete(dispose)
+ }
}, 'ui-agent-preset: Provider binding')
+ boundSeatDisposers.add(dispose)
return seat
}
const section = new AgentPresetSectionController(ctx, () => {
@@ -91,14 +101,14 @@ export function apply(ctx: ClientContext): void {
void unboundSeat.load()
for (const seat of seats.values) void seat.load()
})
- const mainBlankSeat = (scope: ClientContext): AgentPresetSeatController | undefined => {
- const summary = Object.values(scope.sessions.list.getSnapshot().byId)
+ const mainBlankSeat = (): AgentPresetSeatController | undefined => {
+ const summary = Object.values(ctx.sessions.list.getSnapshot().byId)
.find((session) => {
/* v8 ignore next -- retained source counts omit zero-valued entries. */
return session.blank && (session.retainedBy.mainView ?? 0) > 0
})
- const binding = summary === undefined ? undefined : scope.sessions.binding(summary.id)
- return binding === undefined ? undefined : seatFor(scope, binding)
+ const binding = summary === undefined ? undefined : ctx.sessions.binding(summary.id)
+ return binding === undefined ? undefined : seatFor(binding)
}
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
@@ -134,8 +144,8 @@ export function apply(ctx: ClientContext): void {
let creatorDraft: (() => void) | undefined
ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => {
const seatInjected = (sessionId: SessionId | undefined): AgentPresetSeatInjected => {
- const binding = sessionId === undefined ? undefined : scope.sessions.binding(sessionId)
- const seat = binding === undefined ? unboundSeat : seatFor(scope, binding)
+ const binding = sessionId === undefined ? undefined : ctx.sessions.binding(sessionId)
+ const seat = binding === undefined ? unboundSeat : seatFor(binding)
return {
hooks: { agentPresetSeat: seat.store, showPresetPicker: ctx.settingsScope.developerTools.enabled },
load: () => seat.load(),
@@ -152,7 +162,7 @@ export function apply(ctx: ClientContext): void {
scope.effect(() => {
creatorDraft = () => {
if (!section.store.getSnapshot().showPicker) return
- const seat = mainBlankSeat(scope) ?? unboundSeat
+ const seat = mainBlankSeat() ?? unboundSeat
seat.stage('cordis', true)
scope.uiWorkspace.startSession()
void seat.apply()
@@ -183,7 +193,7 @@ export function apply(ctx: ClientContext): void {
const summary = Object.values(ctx.sessions.list.getSnapshot().byId)
.find(session => session.blank && (session.retainedBy.mainView ?? 0) > 0)
const binding = summary === undefined ? undefined : ctx.sessions.binding(summary.id)
- const seat = binding === undefined ? undefined : seats.get(binding)
+ const seat = binding === undefined ? undefined : seatFor(binding)
const sessionId = seat?.blankSessionId()
return async (id: string) => {
if (seat === undefined || sessionId === undefined || binding === undefined
diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts
index ca19a56b69..45bd9373e4 100644
--- a/packages/client/ui-agent-preset/src/client/seat-store.ts
+++ b/packages/client/ui-agent-preset/src/client/seat-store.ts
@@ -63,6 +63,8 @@ export class AgentPresetSeatController {
/** Only the newest roster read may publish after overlapping refreshes. */
private loadGeneration = 0
+ /** Completion of the active Host selection; Settings choices wait before staging. */
+ private pendingSelection: Promise | undefined
constructor(
private readonly ctx: ClientContext,
@@ -78,6 +80,11 @@ export class AgentPresetSeatController {
this.store.set({ ...this.store.getSnapshot(), ...patch })
}
+ private clearStage(): void {
+ this.staged.id = undefined
+ this.staged.introduce = false
+ }
+
/**
* Read the roster and open the chip on the Host-effective default.
* @returns once the snapshot reflects the host.
@@ -92,8 +99,7 @@ export class AgentPresetSeatController {
}
const { presets, modeSelectionEnabled } = roster.value
if (!modeSelectionEnabled) {
- this.staged.id = undefined
- this.staged.introduce = false
+ this.clearStage()
}
this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
const session = this.currentSession()
@@ -128,8 +134,7 @@ export class AgentPresetSeatController {
async select(id: string): Promise {
if (this.store.getSnapshot().busy) return undefined
this.stage(id)
- await this.apply()
- return this.store.getSnapshot().error ?? undefined
+ return await this.apply()
}
/**
@@ -160,7 +165,7 @@ export class AgentPresetSeatController {
/**
* Apply a Settings choice only if its captured Session is still current and
- * blank. The selection uses the existing stage/apply path.
+ * blank after any pending selection settles. The selection uses the existing stage/apply path.
* @param expectedSessionId - blank Session captured before the Settings write.
* @param id - the effective default that the write persisted.
* @returns the Host refusal text, or undefined when applied or no longer relevant.
@@ -169,11 +174,11 @@ export class AgentPresetSeatController {
expectedSessionId: SessionSummary['id'],
id: string,
): Promise {
+ while (this.pendingSelection !== undefined) await this.pendingSelection
const session = this.currentSession()
if (session === undefined || !session.blank || session.id !== expectedSessionId) return undefined
this.stage(id)
- await this.apply()
- return this.store.getSnapshot().error ?? undefined
+ return await this.apply()
}
/** Acknowledge the introduction cue once the chip has played it. */
@@ -188,9 +193,11 @@ export class AgentPresetSeatController {
*
* Called both by `select()` and by whoever observes the current session
* changing, because the session may appear either before or after the pick.
- * @returns once the switch settled, or immediately when there is nothing to do.
+ * List updates do not repeat a selection while its response is pending.
+ * @returns this attempt's Host refusal, or undefined when successful or no switch starts.
*/
- async apply(): Promise {
+ async apply(): Promise {
+ if (this.store.getSnapshot().busy) return
const staged = this.staged.id
const session = this.currentSession()
if (staged === undefined) {
@@ -202,32 +209,37 @@ export class AgentPresetSeatController {
// A started session's history was produced under its own composition; the
// host refuses the swap, so the stage is no longer meaningful.
if (!session.blank || presetOf(session) === staged) {
- this.staged.id = undefined
- this.staged.introduce = false
+ this.clearStage()
return
}
- this.set({ busy: true, error: null })
- const result = await this.ctx.remote.agentPresets.select(session.id, staged)
- this.staged.id = undefined
- this.staged.introduce = false
- if (!result.ok) {
- const { error } = result
- this.set({
- busy: false,
- // A refusal carries its cause twice: `message` wraps it in the
- // roster's own frame, which names the preset the surface reporting
- // this already names, and a `reason` detail holds the same cause
- // without it. Read by the detail rather than by the code, because
- // every refusal that has a cause to give names it the same way.
- error: 'reason' in error.details && typeof error.details.reason === 'string'
+ const completion = Promise.withResolvers()
+ this.pendingSelection = completion.promise
+ this.clearStage()
+ try {
+ this.set({ busy: true, error: null })
+ const result = await this.ctx.remote.agentPresets.select(session.id, staged)
+ if (!result.ok) {
+ const { error } = result
+ const refusal = 'reason' in error.details && typeof error.details.reason === 'string'
? error.details.reason
- : error.message,
- current: presetOf(session) ?? '',
- })
- return
+ : error.message
+ this.set({
+ // A refusal carries its cause twice: `message` wraps it in the
+ // roster's own frame, which names the preset the surface reporting
+ // this already names, and a `reason` detail holds the same cause
+ // without it. Read by the detail rather than by the code, because
+ // every refusal that has a cause to give names it the same way.
+ error: refusal,
+ current: this.staged.id ?? presetOf(session) ?? '',
+ })
+ return refusal
+ }
+ this.set({ current: this.staged.id ?? result.value })
+ } finally {
+ this.pendingSelection = undefined
+ this.set({ busy: false })
+ completion.resolve(undefined)
}
- // Consumed: the next new session opens on the Host-effective default again.
- this.set({ busy: false, current: result.value })
}
}
diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts
index 123fd48b15..1e28cb44f4 100644
--- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts
+++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts
@@ -74,6 +74,8 @@ const ROSTER_HIDDEN = {
async function bench(options: {
failSettingsUpdate?: boolean
+ selectGate?: Promise
+ settingsRosterGate?: Promise
} = {}) {
const ctx = new Context()
// The host's answer, mutable so a spec can move the default the way the
@@ -87,6 +89,8 @@ async function bench(options: {
const calls: string[] = []
let savedDefault = 'standard'
let selectionEnabled = true
+ let settingsSaved = false
+ const settingsRosterStarted = Promise.withResolvers()
// The row reads `describe` to learn whether this browser may write at all,
// and its default write is the one op this spec records.
const settings = {
@@ -112,6 +116,7 @@ async function bench(options: {
ROSTER = !selectionEnabled
? ROSTER_HIDDEN
: savedDefault === 'minimal' ? ROSTER_MOVED : ROSTER_ONE
+ settingsSaved = true
return Promise.resolve({ ok: true as const, value: {} })
},
openAgentPresetDirectory: (agentPreset: string) => {
@@ -126,7 +131,14 @@ async function bench(options: {
// `inject`, and the property is what `ctx.remote.agentPresets` reads,
// because the double is a plain provided object rather than a Service.
const agentPresets = {
- list: () => { calls.push('list'); return Promise.resolve(ROSTER) },
+ list: () => {
+ calls.push('list')
+ if (settingsSaved) {
+ settingsRosterStarted.resolve(undefined)
+ if (options.settingsRosterGate !== undefined) return options.settingsRosterGate.then(() => ROSTER)
+ }
+ return Promise.resolve(ROSTER)
+ },
read: () => Promise.resolve({
ok: true as const,
value: { agentPreset: 'standard', trust: 'system', content: '' },
@@ -141,13 +153,13 @@ async function bench(options: {
deletePreset: () => Promise.resolve({ ok: true as const, value: undefined }),
select: (_agentId: SessionId, agentPreset: string) => {
calls.push(`select:${agentPreset}`)
- return Promise.resolve({ ok: true as const, value: agentPreset })
+ return Promise.resolve(options.selectGate).then(() => ({ ok: true as const, value: agentPreset }))
},
}
ctx.provide('remote.agentPresets', agentPresets as never)
Object.assign(remote, { agentPresets })
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
- return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault, remote }
+ return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault, remote, settingsRosterStarted: settingsRosterStarted.promise }
}
function declareRoot(slots: SlotRegistry): () => void {
@@ -234,10 +246,70 @@ function sessionsDouble(ctx: Context, state: {
}),
/** Push a list change the way the runtime's store does. */
notify: () => { for (const fn of listeners) fn() },
+ listenerCount: () => listeners.size,
}
}
+async function settingsWithoutChip(initialPreset: string, settingsRosterGate?: Promise) {
+ const b = await bench(settingsRosterGate === undefined ? {} : { settingsRosterGate })
+ declareRoot(b.slots)
+ const sessions = sessionsDouble(b.ctx, {
+ current: 'blank',
+ byId: { blank: { id: 'blank', blank: true, projectionValues: { agentPreset: initialPreset } } },
+ })
+ const bindingOwner = b.ctx.plugin({ apply() {} })
+ await bindingOwner.await()
+ sessions.binding('blank')!.ctx = bindingOwner.ctx
+ b.ctx.provide('sessions', sessions as never)
+ const feature = b.ctx.plugin({ inject: [...inject], apply })
+ await feature.await()
+ const section = (b.slots.entries('settings.section')[0]!
+ .inject as unknown as () => AgentPresetSectionInjected)()
+ await section.load()
+ expect(b.slots.entries('conversation.hero.agentPreset')).toHaveLength(0)
+ return { ...b, bindingOwner, feature, section, sessions }
+}
+
describe('ui-agent-preset apply', () => {
+ const settingsActions = [
+ { action: 'makeDefault', initial: 'standard', selected: 'minimal', run: (section: AgentPresetSectionInjected) => section.makeDefault('minimal') },
+ { action: 'setPickerVisible', initial: 'minimal', selected: 'standard', run: (section: AgentPresetSectionInjected) => section.setPickerVisible(false) },
+ ]
+
+ it.each(settingsActions)('synchronizes $action before the chip mounts', async ({ initial, selected, run }) => {
+ const b = await settingsWithoutChip(initial)
+ try {
+ await run(b.section)
+ expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([`select:${selected}`])
+ expect(b.sessions.listenerCount()).toBe(1)
+ } finally {
+ await b.ctx.fiber.dispose()
+ }
+ expect(b.sessions.listenerCount()).toBe(0)
+ })
+
+ it.each(settingsActions.flatMap(action => (['feature', 'binding'] as const).map(owner => ({ ...action, owner }))))(
+ 'does not finish $action into a disposed $owner before the chip mounts',
+ async ({ initial, run, owner }) => {
+ const saved = Promise.withResolvers()
+ const b = await settingsWithoutChip(initial, saved.promise)
+ const operation = run(b.section)
+ try {
+ expect(b.sessions.listenerCount()).toBe(1)
+ await b.settingsRosterStarted
+ await (owner === 'feature' ? b.feature : b.bindingOwner).dispose()
+ expect(b.sessions.listenerCount()).toBe(0)
+ saved.resolve(undefined)
+ await operation
+ expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([])
+ } finally {
+ saved.resolve(undefined)
+ await operation
+ await b.ctx.fiber.dispose()
+ }
+ },
+ )
+
it('keeps the host Loader entry inert', () => {
expect(hostApply).not.toThrow()
})
@@ -473,6 +545,58 @@ describe('ui-agent-preset apply', () => {
await ctx.fiber.dispose()
})
+ it.each(['feature', 'binding'] as const)('tracks bound preset changes until its %s owner stops', async (disposedOwner) => {
+ const selection = Promise.withResolvers()
+ const { ctx, slots, calls } = await bench({ selectGate: selection.promise })
+ try {
+ declareRoot(slots)
+ declareConversation(slots)
+ ctx.provide('conversation', {} as never)
+ const state = {
+ current: 's1',
+ byId: { s1: { id: 's1', blank: true, projectionValues: {} as { agentPreset?: string } } },
+ }
+ const sessions = sessionsDouble(ctx, state)
+ const bindingOwner = ctx.plugin({ apply() {} })
+ await bindingOwner.await()
+ sessions.binding('s1')!.ctx = bindingOwner.ctx
+ ctx.provide('sessions', sessions as never)
+ ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
+ const feature = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply })
+ await feature.await()
+ const seat = (slots.entries('conversation.hero.agentPreset')[0]!
+ .inject as unknown as (sessionId: SessionId) => AgentPresetSeatInjected)(SessionId('s1'))
+ await seat.load()
+ expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('')
+
+ state.byId.s1.projectionValues.agentPreset = 'standard'
+ sessions.notify()
+ expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
+ state.byId.s1.projectionValues.agentPreset = 'minimal'
+ sessions.notify()
+ expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
+ expect(calls.filter(call => call.startsWith('select:'))).toEqual([])
+
+ const selecting = seat.select('standard')
+ expect(seat.hooks.agentPresetSeat.getSnapshot().busy).toBe(true)
+ sessions.notify()
+ sessions.notify()
+ expect(calls.filter(call => call.startsWith('select:'))).toEqual(['select:standard'])
+ selection.resolve(undefined)
+ await selecting
+ state.byId.s1.projectionValues.agentPreset = 'standard'
+ sessions.notify()
+
+ await (disposedOwner === 'feature' ? feature : bindingOwner).dispose()
+ state.byId.s1.projectionValues.agentPreset = 'minimal'
+ sessions.notify()
+ expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
+ } finally {
+ selection.resolve(undefined)
+ await ctx.fiber.dispose()
+ }
+ })
+
it('does not sync a blank Session that is not retained by the main view', async () => {
const { ctx, slots, calls } = await bench()
declareRoot(slots)
@@ -822,6 +946,102 @@ describe('ui-agent-preset apply', () => {
})
describe('AgentPresetSeatController reconciliation', () => {
+ it.each([
+ { refuseFirst: false, refuseLatest: false },
+ { refuseFirst: false, refuseLatest: true },
+ { refuseFirst: true, refuseLatest: false },
+ { refuseFirst: true, refuseLatest: true },
+ ])('waits for a newer Settings selection (first refused: $refuseFirst, latest refused: $refuseLatest)', async ({ refuseFirst, refuseLatest }) => {
+ const refusal = { ok: false as const, error: new RemoteError('gateway/internal', 'selection refused', {}) }
+ type Outcome = { ok: true; value: string } | typeof refusal
+ const requests: { preset: string; outcome: ReturnType> }[] = []
+ const session = { id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } }
+ const controller = new AgentPresetSeatController({
+ remote: { agentPresets: { select: (_id: SessionId, preset: string) => {
+ const outcome = Promise.withResolvers()
+ requests.push({ preset, outcome })
+ return outcome.promise
+ } } },
+ } as never, () => session)
+ const first = controller.select('minimal')
+ let settingsSettled = false
+ const settings = controller.syncBlankSession(session.id, 'cordis').finally(() => { settingsSettled = true })
+ await controller.apply()
+ expect(requests.map(request => request.preset)).toEqual(['minimal'])
+ requests[0]!.outcome.resolve(refuseFirst ? refusal : { ok: true, value: 'minimal' })
+ await first
+ expect(requests.map(request => request.preset)).toEqual(['minimal', 'cordis'])
+ expect(settingsSettled).toBe(false)
+ await controller.apply()
+ expect(requests).toHaveLength(2)
+ requests[1]!.outcome.resolve(refuseLatest ? refusal : { ok: true, value: 'cordis' })
+ await expect(settings).resolves.toBe(refuseLatest ? 'selection refused' : undefined)
+ expect(controller.store.getSnapshot()).toMatchObject({
+ current: refuseLatest ? 'standard' : 'cordis', busy: false,
+ error: refuseLatest ? 'selection refused' : null,
+ })
+ })
+
+ it.each([false, true])('preserves a stage created while an earlier selection is pending (refused: %s)', async (refused) => {
+ const refusal = { ok: false as const, error: new RemoteError('gateway/internal', 'selection refused', {}) }
+ const firstReply = Promise.withResolvers<{ ok: true; value: string } | typeof refusal>()
+ const select = vi.fn((_id: SessionId, preset: string) => preset === 'minimal'
+ ? firstReply.promise
+ : Promise.resolve({ ok: true as const, value: preset }))
+ const controller = new AgentPresetSeatController({ remote: { agentPresets: { select } } } as never,
+ () => ({ id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } }))
+ const first = controller.select('minimal')
+ controller.stage('cordis', true)
+ await controller.apply()
+ expect(select).toHaveBeenCalledOnce()
+ firstReply.resolve(refused ? refusal : { ok: true, value: 'minimal' })
+ await first
+ expect(controller.store.getSnapshot().current).toBe('cordis')
+ await controller.apply()
+ expect(select.mock.calls.map(call => call[1])).toEqual(['minimal', 'cordis'])
+ })
+
+ it.each([false, true])('serializes Settings choices and preserves each refusal (middle refused: %s)', async (refused) => {
+ const refusal = { ok: false as const, error: new RemoteError('gateway/internal', 'selection refused', {}) }
+ type Outcome = { ok: true; value: string } | typeof refusal
+ const requests: { preset: string; outcome: ReturnType> }[] = []
+ const session = { id: SessionId('blank'), blank: true, projectionValues: { agentPreset: 'standard' } }
+ const controller = new AgentPresetSeatController({
+ remote: { agentPresets: { select: (_id: SessionId, preset: string) => {
+ const outcome = Promise.withResolvers()
+ requests.push({ preset, outcome })
+ return outcome.promise
+ } } },
+ } as never, () => session)
+ const first = controller.select('minimal')
+ const second = controller.syncBlankSession(session.id, 'cordis')
+ const third = controller.syncBlankSession(session.id, 'coding')
+ expect(requests.map(request => request.preset)).toEqual(['minimal'])
+ requests[0]!.outcome.resolve({ ok: true, value: 'minimal' })
+ await first
+ expect(requests.map(request => request.preset)).toEqual(['minimal', 'cordis'])
+ requests[1]!.outcome.resolve(refused ? refusal : { ok: true, value: 'cordis' })
+ await expect(second).resolves.toBe(refused ? 'selection refused' : undefined)
+ expect(requests.map(request => request.preset)).toEqual(['minimal', 'cordis', 'coding'])
+ requests[2]!.outcome.resolve({ ok: true, value: 'coding' })
+ await third
+ expect(controller.store.getSnapshot()).toMatchObject({ current: 'coding', busy: false })
+ })
+
+ it('does not apply a waiting Settings choice to a replacement binding', async () => {
+ const reply = Promise.withResolvers<{ ok: true; value: string }>()
+ const select = vi.fn(() => reply.promise)
+ let current = { id: SessionId('first'), blank: true, projectionValues: { agentPreset: 'standard' } }
+ const controller = new AgentPresetSeatController({ remote: { agentPresets: { select } } } as never, () => current)
+ const first = controller.select('minimal')
+ const settings = controller.syncBlankSession(current.id, 'cordis')
+ current = { ...current, id: SessionId('replacement') }
+ reply.resolve({ ok: true, value: 'minimal' })
+ await Promise.all([first, settings])
+ await controller.apply()
+ expect(select).toHaveBeenCalledExactlyOnceWith(SessionId('first'), 'minimal')
+ })
+
it('does not capture a non-blank Session', () => {
const controller = new AgentPresetSeatController({} as never, () => ({
id: SessionId('started'), blank: false,
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 044e530fbc..c42928d650 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/packages/client/ui-chat/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 901c97e620a843d5d87964dbfe021c3e33e6fe3f
-README.zh.md: 5500f0935c508b1ddce7113cf06003528fc1574b
+README.md: b916802917bfdd406fb333174371507d466b8e19
+README.zh.md: 01de4d633cb1501a03200aa96f72e89622d0a7d8
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index 901c97e620..b916802917 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -102,6 +102,8 @@ None; Chat presentation does not assemble or mutate provider requests.
+- **Developer messages are not displayed** — presentation is intentionally deferred; encountering `developer/message` throws instead of rendering a fallback row.
+
- **The transcript reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. Turn navigation is wider than the window: the rail merges the loaded Turns with the host `turnOutline` projection, so every started Turn gets a fixed-pitch mark (10px apart; a ladder taller than the frame scrolls inside it with gradient fades), and activating an unloaded mark pages history through the Turn's `turn/start` seq before landing on its row. Without the projection (assemblies not mounting `dsh-session-turn-outline`) the rail falls back to loaded Turns only.
- **Rail previews are card-sized** — one prompt line (50 characters) and up to three response lines (120), on loaded and unloaded Turns alike; an unloaded Turn's response arrives from the outline only once the Turn settled, so an open Turn previews its prompt (or just the Turn number) until then.
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index 5500f0935c..01de4d633c 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -102,6 +102,8 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
+- **不展示 developer 消息** — 展示能力有意留待后续实现;遇到 `developer/message` 时抛出错误,不渲染回退行。
+
- **transcript 只反映已加载的 Session 窗口**——只有会话控制器加载前一页事件后,更早的 transcript node 才会出现。轮次导航比窗口更宽:轨道把已加载的轮次与宿主 `turnOutline` 投影合并,每个已开始的轮次都有固定间距刻度(相隔 10px;阶梯高于外框时在框内滚动并以渐变淡出标示可滚方向),激活未加载刻度会先把历史分页拉到该轮次的 `turn/start` seq 再落到它的行上。没有该投影时(未挂载 `dsh-session-turn-outline` 的装配),轨道回退到仅显示已加载轮次。
- **导航预览按卡片尺寸截断**——提示词一行(50 字符)、回复至多三行(120 字符),已加载与未加载 Turn 一致;未加载 Turn 的回复要等该轮落定后才随大纲到达,进行中的轮次在此之前只预览提示词(或仅轮次号)。
diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
index a03c46b2ed..e8cb78c304 100644
--- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
+++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
@@ -44,7 +44,9 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
text={assistantText(closing.blocks)}
time={closing.time}
clock="end"
- onBranch={() => { forkAt(closing.finalNode.seq) }}
+ // The branch action owns boundary resolution: it sends the real
+ // turn/end seq it already has, and the Host cuts exactly there.
+ onBranch={() => { forkAt(data.seq) }}
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
className={css.actions}
extraActions={assistantActions}
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/command.ts b/packages/client/ui-chat/src/client/conversation-nodes/command.ts
index e21f50c5f5..7604d88edc 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/command.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/command.ts
@@ -20,7 +20,7 @@ declare module '../contract/chat-nodes.ts' {
type CommandId = CommandNode['commandId']
-const COMPACT_PLUGIN: CompactionCheckpointSource['plugin'] = 'compact'
+const COMPACT_KIND: CompactionCheckpointSource['kind'] = 'compact-checkpoint'
interface CommandState {
readonly command: CommandNode
@@ -82,11 +82,10 @@ function compactSource(event: Parameters[0]
if (event.type !== 'user/message' || !isReplacementSurfaceEvent(event)) return undefined
const source = event.data.source as unknown as {
kind?: unknown
- plugin?: unknown
compactionId?: unknown
sourceCommandId?: CommandId
}
- if (source.kind !== 'plugin' || source.plugin !== COMPACT_PLUGIN || typeof source.compactionId !== 'string') return undefined
+ if (source.kind !== COMPACT_KIND || typeof source.compactionId !== 'string') return undefined
return {
compactionId: source.compactionId,
...source.sourceCommandId === undefined ? {} : { sourceCommandId: source.sourceCommandId },
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts b/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts
index 5995638194..e61bb53109 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts
@@ -66,8 +66,6 @@ export function contextProducer(source: unknown): ContextProducerView {
return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
case 'agent-instructions':
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
- case 'plugin':
- return { role: 'inject', label: readString(record, 'plugin') ?? kind }
case 'skill-invocation':
return { role: 'inject', label: readString(record, 'name') ?? kind }
default:
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts
index 823bbe3c93..22ebf7cb08 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts
@@ -36,19 +36,24 @@ declare module '../contract/chat-nodes.ts' {
function isCompactionCheckpoint(event: Parameters[0]): boolean {
if (event.type !== 'user/message' || !isReplacementSurfaceEvent(event)) return false
- const source = event.data.source
- return source.kind === 'plugin' && source.plugin === 'compact'
+ const source = event.data.source as { kind?: unknown }
+ return source.kind === 'compact-checkpoint'
}
/** User, steering, and injected-context message classification Definition. */
export const messageDefinition: ConversationNodeDefinition = {
kind: 'input-message',
target: 'chat',
- match: event => event.type === 'user/message'
- && isAppendSurfaceEvent(event)
- && !isCompactionCheckpoint(event)
- ? { id: String(event.data.id), role: 'start' }
- : null,
+ match: (event) => {
+ if (event.type === 'user/message') {
+ return isAppendSurfaceEvent(event) && !isCompactionCheckpoint(event)
+ ? { id: String(event.data.id), role: 'start' }
+ : null
+ }
+ // Developer history is persisted for V4; presentation is intentionally deferred.
+ if (event.type === 'developer/message') throw new Error('Chat developer messages are not supported yet')
+ return null
+ },
start: (_context, match, reader) => {
if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message')
const event = match.event
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts
index f6653b92bd..2e914e5072 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts
@@ -51,16 +51,16 @@ function rootCall(match: ConversationMatch): RunningToolCall {
function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolResultNode | undefined {
if (match.event.type !== 'tool/result') return undefined
- const result = match.event.data.message.content[0]
+ const message = match.event.data.message
return {
kind: 'tool-result',
seq: match.event.seq,
time: match.event.time,
- callId: String(match.event.data.message.source.callId),
+ callId: String(message.source.callId),
call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw },
callTime: previous?.time ?? null,
- content: result.content,
- isError: result.isError === true,
+ content: message.content,
+ isError: message.isError === true,
...match.event.data.error === undefined ? {} : { error: match.event.data.error },
meta: match.event.data.meta,
subCalls: [],
diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
index 4e2493d17c..013ed04786 100644
--- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
@@ -331,7 +331,7 @@ describe('MessageItem arms', () => {
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'line one\n\nline two' }],
- source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] },
+ source: { kind: 'fixture', empty: {}, list: [] },
producer: { role: 'inject', label: 'fixture' },
form: null,
} as never}
@@ -351,7 +351,7 @@ describe('MessageItem arms', () => {
expect(ctxView.container.querySelector('[data-context-text]')?.textContent)
.toBe('line one\n\nline two')
const fields = [...ctxView.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
- expect(fields).toEqual(['plugin', 'empty', 'list'])
+ expect(fields).toEqual(['empty', 'list'])
fireEvent.keyDown(disclosure, { key: ' ' })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
@@ -650,7 +650,7 @@ describe('MessageItem arms', () => {
const view = render(
{
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*later$/ }))
const fields = [...view.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
- expect(fields).toEqual(['plugin', 'form'])
+ expect(fields).toEqual(['form'])
})
it('the snapshot form attributes each part to the subsystem that produced it', () => {
@@ -668,8 +668,7 @@ describe('MessageItem arms', () => {
seq: 3,
content: [{ type: 'text', text: 'Current runtime context.\n\nsandbox\n\nworkspace' }],
source: {
- kind: 'plugin',
- plugin: '@deepseek-ai/dsh-system-prompt',
+ kind: 'runtime-context',
form: 'snapshot',
sections: [{ name: 'sandbox:policy', text: 'workspace-write' }, { name: 'workspace', text: '/repo' }],
},
@@ -690,7 +689,7 @@ describe('MessageItem arms', () => {
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'background job bash-1 finished.' }],
- source: { kind: 'plugin', plugin: 'tool-jobs', form: 'notice', summary: 'bash pnpm test [status: completed]' },
+ source: { kind: 'tool-jobs', form: 'notice', summary: 'bash pnpm test [status: completed]' },
producer: { role: 'inject', label: 'tool-jobs' },
form: 'notice',
} as never}
@@ -705,7 +704,7 @@ describe('MessageItem arms', () => {
const view = render(
= {}): CompactionSummar
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
const store = createSnapshotStore(
- { ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
+ { ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, jobsBySession: {} })
return bindSnapshotSelector(store)
}
@@ -1086,7 +1086,9 @@ describe('ChatView', () => {
expect(branchButtons).toHaveLength(1)
expect(branchButtons[0]!.getAttribute('aria-disabled')).toBeNull()
fireEvent.click(branchButtons[0]!)
- expect(h.forkAt).toHaveBeenCalledWith(1)
+ // The branch action sends the real turn/end seq (the exact inclusive
+ // Host boundary), not the assistant node seq.
+ expect(h.forkAt).toHaveBeenCalledWith(3)
})
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
@@ -2045,7 +2047,7 @@ describe('ChatView', () => {
expect(buttons).toHaveLength(1)
expect(buttons[0]!.getAttribute('aria-disabled')).toBeNull()
fireEvent.click(buttons[0]!)
- expect(h.forkAt.mock.calls).toEqual([[2]])
+ expect(h.forkAt.mock.calls).toEqual([[3]])
})
it('disables fork when the indexed Turn has a later steering Node', () => {
diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
index f393a952bf..f116edab09 100644
--- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
+++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
@@ -180,7 +180,7 @@ function systemMessage(text: string) {
id: `system-${text}`,
role: 'system',
content: text === '' ? [] : [{ type: 'text', text }],
- source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
+ source: { kind: 'system-prompt' },
}
}
@@ -208,18 +208,22 @@ function assistantMessage(id: string, text: string) {
function toolResult(callId: string, text: string, isError = false) {
return {
id: `result-${callId}`,
- role: 'user',
+ role: 'tool',
+ toolCallId: callId,
source: { kind: 'tool', callId },
- content: [{
- type: 'tool-result',
- toolCallId: callId,
- content: [{ type: 'text', text }],
- isError,
- }],
+ content: [{ type: 'text', text }],
+ isError,
}
}
describe('built-in conversation node Definitions', () => {
+ it('rejects developer history until presentation is implemented', () => {
+ expect(() => assembler([at(0, 'developer/message', { turn: 1, step: 1, message: {
+ id: 'developer', role: 'developer', source: { kind: 'tool-registry' },
+ content: [{ type: 'tool-addition', toolName: 'search' }],
+ } }, { surfaceOp: 'append' })])).toThrow('developer messages are not supported yet')
+ })
+
it('rejects an unrelated event passed directly to the request-prompt start', () => {
const input = at(1, 'turn/start', { turn: 1 })
const invalidStart = {
@@ -323,7 +327,7 @@ describe('built-in conversation node Definitions', () => {
...textMessage('context-1', 'workspace context'),
turn: 1,
step: 1,
- source: { kind: 'plugin', plugin: 'context' },
+ source: { kind: 'context' },
}, { surfaceOp: 'append' }),
at(4, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
@@ -456,7 +460,7 @@ describe('built-in conversation node Definitions', () => {
at(1, 'turn/start', { turn: 1 }),
at(2, 'user/message', {
...textMessage('context-1', 'runtime context'),
- source: { kind: 'plugin', plugin: 'context' },
+ source: { kind: 'context' },
}, { surfaceOp: 'append' }),
at(3, 'user/message', textMessage('user-1', 'question'), { surfaceOp: 'append' }),
at(4, 'step/start', { turn: 1, step: 1 }),
@@ -622,7 +626,7 @@ describe('built-in conversation node Definitions', () => {
at(1, 'turn/start', { turn: 1 }),
at(2, 'user/message', {
...textMessage('context-1', 'runtime context'),
- source: { kind: 'plugin', plugin: 'context' },
+ source: { kind: 'context' },
}, { surfaceOp: 'append' }),
at(3, 'step/start', { turn: 1, step: 1 }),
at(4, 'assistant/live-chunk', {
@@ -1612,7 +1616,7 @@ describe('built-in conversation node Definitions', () => {
at(4, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }),
at(5, 'user/message', {
...textMessage('runtime-context', 'runtime facts'),
- source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt', form: 'snapshot' },
+ source: { kind: 'runtime-context', form: 'snapshot' },
}, { surfaceOp: 'append' }),
at(6, 'request/header', {
reason: 'initial',
@@ -1647,7 +1651,7 @@ describe('built-in conversation node Definitions', () => {
at(11, 'step/start', { turn: 1, step: 3 }),
at(12, 'user/message', {
turn: 1, step: 3, id: 'summary', role: 'user',
- content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compaction' },
+ content: [{ type: 'text', text: 'summary' }], source: { kind: 'compact-checkpoint', compactionId: 'compaction-1' },
}, { surfaceOp: { op: 'replace', startSeq: 5, endSeq: 9 }, sourceEventSeqs: [5, 8, 9] }),
at(13, 'request/header', {
reason: 'series', header: { config: { provider: 'test', model: 'test' }, tools: [] },
@@ -1791,7 +1795,7 @@ describe('built-in conversation node Definitions', () => {
at(4, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }),
at(5, 'user/message', {
...textMessage('runtime-context', 'runtime facts'),
- source: { kind: 'plugin', plugin: 'context' },
+ source: { kind: 'context' },
}, { surfaceOp: 'append' }),
at(6, 'request/header', {
reason: 'initial',
@@ -1910,7 +1914,7 @@ describe('built-in conversation node Definitions', () => {
}),
at(6, 'user/message', {
...textMessage('compacted', 'summary'),
- source: { kind: 'plugin', plugin: 'compact' },
+ source: { kind: 'compact-checkpoint', compactionId: 'chat-compaction-1' },
}, { surfaceOp: { op: 'replace', startSeq: 4, endSeq: 4 } }),
at(7, 'request/header', {
reason: 'series',
@@ -2091,7 +2095,7 @@ describe('built-in conversation node Definitions', () => {
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'user/message', {
...textMessage('replacement-user', 'model-only context'),
- source: { kind: 'plugin', plugin: 'foreign' },
+ source: { kind: 'foreign' },
}, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 1 } }),
at(4, 'assistant/message', {
turn: 1,
@@ -2179,8 +2183,7 @@ describe('built-in conversation node Definitions', () => {
at(13, 'user/message', {
...textMessage('manual-checkpoint', 'checkpoint'),
source: {
- kind: 'plugin',
- plugin: 'compact',
+ kind: 'compact-checkpoint',
compactionId: 'manual-1',
sourceCommandId: 'command-1',
},
@@ -2204,7 +2207,7 @@ describe('built-in conversation node Definitions', () => {
}),
at(22, 'user/message', {
...textMessage('automatic-checkpoint', 'checkpoint'),
- source: { kind: 'plugin', plugin: 'compact', compactionId: 'automatic-1' },
+ source: { kind: 'compact-checkpoint', compactionId: 'automatic-1' },
}, { surfaceOp: { op: 'replace', startSeq: 3, endSeq: 4 } }),
at(23, 'compaction/end', { compactionId: 'automatic-1', turn: null }),
])
@@ -2223,7 +2226,7 @@ describe('built-in conversation node Definitions', () => {
const value = assembler([
at(13, 'user/message', {
...textMessage('checkpoint', 'checkpoint'),
- source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-1' },
+ source: { kind: 'compact-checkpoint', compactionId: 'compact-1' },
}, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 8 } }),
], true)
const before = node(snapshot(value), 'compaction')
@@ -2264,7 +2267,7 @@ describe('built-in conversation node Definitions', () => {
}),
at(11, 'user/message', {
...textMessage('checkpoint-windowed', 'checkpoint'),
- source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-windowed' },
+ source: { kind: 'compact-checkpoint', compactionId: 'compact-windowed' },
}, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 3 } }),
], true)
@@ -2288,7 +2291,7 @@ describe('built-in conversation node Definitions', () => {
}),
at(22, 'user/message', {
...textMessage('legacy-checkpoint', 'checkpoint'),
- source: { kind: 'plugin', plugin: 'compact' },
+ source: { kind: 'compact-checkpoint' },
}, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 3 } }),
at(23, 'compaction/end', { turn: null }),
], true)
@@ -2494,8 +2497,7 @@ describe('built-in conversation node Definitions', () => {
at(21, 'user/message', {
...textMessage('manual-checkpoint', 'checkpoint'),
source: {
- kind: 'plugin',
- plugin: 'compact',
+ kind: 'compact-checkpoint',
compactionId: 'manual-1',
sourceCommandId: 'command-1',
},
diff --git a/packages/client/ui-chat/tests/event-projection.client.spec.ts b/packages/client/ui-chat/tests/event-projection.client.spec.ts
index f1818da09c..c895f8a3bc 100644
Binary files a/packages/client/ui-chat/tests/event-projection.client.spec.ts and b/packages/client/ui-chat/tests/event-projection.client.spec.ts differ
diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
index ad2896b854..ca5aeb1e00 100644
--- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
+++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
@@ -16,7 +16,7 @@ afterEach(cleanup)
function emptySessions() {
return bindSnapshotSelector(createSnapshotStore({
- ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {},
+ ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, jobsBySession: {},
}))
}
diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts
index cb2b327fcc..c4bd56c026 100644
--- a/packages/client/ui-conversation/src/client/contract/records.ts
+++ b/packages/client/ui-conversation/src/client/contract/records.ts
@@ -197,13 +197,9 @@ export interface CompactionSummaryNode {
}
/**
- * Fallback for surface events this UI version does not know: the documented
- * default arm of `SessionEventMap`, which is merge-extensible, so the
- * projection's switch cannot end in `assertNever`. No event produces this node
- * because `isAppendSurfaceEvent` admits only the four types in core's
- * `SurfaceEventType`, and each has its own arm (`system/message` is claimed by
- * a Chat Definition that renders no transcript row) — and it exists so widening
- * that set core-side degrades to a raw row instead of dropping the event silently.
+ * Fallback for unclaimed append-surface events. The merge-extensible
+ * SessionEventMap permits unfamiliar events to retain a raw presentation.
+ * Known unsupported developer events throw before fallback selection.
*/
export interface UnknownSurfaceNode {
kind: 'unknown'
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx
index d156480507..d3c3a9f600 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx
@@ -27,8 +27,8 @@ export function ConversationMainPanel(props: ConversationSlotProps) {
// The exemption is deliberately open-state-wide, not loading-only: a
// summary-blank session is the hero before its open starts (`cold`) and
// after one fails (`error`) for the same reason — there is no history.
- // A restored continuable subagent also stays settled until its eagerly
- // loaded parent catalog establishes availability. This keeps the composer
+ // A restored continuable subagent waits for a Host summary to establish
+ // parent availability. This keeps the composer
// hidden instead of briefly rendering the parent-offline takeover.
const parentAvailabilityPending = session?.subagent?.address.mode === 'continuable'
&& session.subagent.parentAvailable === undefined
diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
index d063279939..8a919e624c 100644
--- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
+++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
@@ -71,7 +71,7 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind
ids: [],
byId: {},
phase: 'ready',
- subagentsByParent: {},
+ projectionsBySession: {},
jobsBySession: {},
})
const reference = {
@@ -89,8 +89,7 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind
using: async (_target, _options, operation) => await operation(reference),
retainInfo: () => createSnapshotStore({ referenceCount: 1, retainedBy: {} }),
subagentAddress: () => undefined,
- setSubagentCatalogOpen: () => {},
- refreshSubagents: () => Promise.reject(new Error('unused fake Sessions operation')),
+ refreshProjections: () => Promise.reject(new Error('unused fake Sessions operation')),
refresh: () => Promise.reject(new Error('unused fake Sessions operation')),
search: () => Promise.reject(new Error('unused fake Sessions operation')),
fork: () => Promise.reject(new Error('unused fake Sessions operation')),
diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
index 48f7ee1f62..b5de7d3ade 100644
--- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
@@ -22,7 +22,7 @@ afterEach(() => {
function emptySessions() {
return bindSnapshotSelector(createSnapshotStore({
- ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {},
+ ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, jobsBySession: {},
}))
}
diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
index 74140e0946..9e0366952c 100644
--- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
@@ -175,7 +175,7 @@ function bench(over?: BenchOptions) {
useResource,
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, phase: 'ready',
- subagentsByParent: {}, jobsBySession: {},
+ projectionsBySession: {}, jobsBySession: {},
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
index 6b65e8aeb0..15d87a0ca7 100644
--- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
@@ -54,7 +54,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
- subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
+ projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined,
})),
useSessionStatus: bindSnapshotSelector(
createSnapshotStore(new Map()),
diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
index 44b4a48ade..347b4d05c1 100644
--- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
@@ -148,7 +148,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
useSession: bindSnapshotSelector(sessionStore),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
- subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
+ projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined,
})),
useSessionStatus: bindSnapshotSelector(
createSnapshotStore(new Map()),
diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
index c216f64f20..17aadfb056 100644
--- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
@@ -164,7 +164,7 @@ function mount(
...listed && options.nestedSubagent === true && { [parent]: parentRow },
...listed && { [SID]: childRow },
},
- phase: 'ready', subagentsByParent: {}, jobsBySession: {},
+ phase: 'ready', projectionsBySession: {}, jobsBySession: {},
})
const workspaces = createSnapshotStore(workspaceState(workspaceRows))
const session = createSnapshotStore(snapshot)
diff --git a/packages/client/ui-conversation/tests/system-prompt.client.spec.ts b/packages/client/ui-conversation/tests/system-prompt.client.spec.ts
index 4604de6f4b..4411cfb9c7 100644
--- a/packages/client/ui-conversation/tests/system-prompt.client.spec.ts
+++ b/packages/client/ui-conversation/tests/system-prompt.client.spec.ts
@@ -7,7 +7,7 @@ function system(seq: number, text: string, replaces?: number): SessionEvent {
seq, time: seq, type: 'system/message',
data: { turn: 1, step: seq, message: { role: 'system', content: [{ type: 'text', text }] } },
surfaceOp: replaces === undefined ? 'append' : { op: 'replace', startSeq: replaces, endSeq: replaces },
- } as SessionEvent
+ } as unknown as SessionEvent
}
function replace(seq: number, start: number, end: number): SessionEvent {
diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts
index 87d13e5e41..7eb765ec2b 100644
--- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts
+++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts
@@ -200,8 +200,7 @@ export const deliverablesDefinition: ConversationNodeDefinition(select: (value: boolean) => T): T => select(true),
useSessions: (select: (state: SessionListState) => T): T => select(sessions),
@@ -185,8 +185,12 @@ function result(seq: number, callId: string, isError = false, turn = 1): Session
turn,
step: 1,
message: {
- source: { type: 'tool-result', callId },
- content: [{ type: 'tool-result', content: [], isError }],
+ id: `result-${callId}`,
+ role: 'tool',
+ toolCallId: callId,
+ source: { kind: 'tool', callId },
+ content: [],
+ isError,
},
})
}
diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx
index d82f2b0c47..e42e0ef556 100644
--- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx
+++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx
@@ -40,7 +40,7 @@ function props(jobs: readonly JobView[] | undefined): JobListActionProps {
ids: [SESSION],
byId: {},
phase: 'ready',
- subagentsByParent: {},
+ projectionsBySession: {},
jobsBySession: jobs === undefined ? {} : { [SESSION]: jobs },
} satisfies SessionListState
function useSessions(select: (snapshot: SessionListState) => T): T {
diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx
index 2ffd6ef7fe..b097c4b62c 100644
--- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx
+++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx
@@ -77,7 +77,7 @@ function mountFrame(windowWidth = frameWidth) {
},
},
phase: 'ready',
- subagentsByParent: {},
+ projectionsBySession: {},
jobsBySession: {},
})
const workspaceState: WorkspaceSnapshot = {
diff --git a/packages/client/ui-layout/tests/document-title.client.spec.tsx b/packages/client/ui-layout/tests/document-title.client.spec.tsx
index eb77ab430c..0aefd57215 100644
--- a/packages/client/ui-layout/tests/document-title.client.spec.tsx
+++ b/packages/client/ui-layout/tests/document-title.client.spec.tsx
@@ -20,7 +20,7 @@ function titleSources() {
ids: [sessionId],
byId: { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, retainedBy: { mainView: 1 }, blank: false, updatedAt: 1 } },
phase: 'ready',
- subagentsByParent: {},
+ projectionsBySession: {},
jobsBySession: {},
})
const panelInfo = createSnapshotStore({ activePanelId: null })
diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml
index f0ed9ac063..671cf8ecc9 100644
--- a/packages/client/ui-model-selection/README.i18n.yaml
+++ b/packages/client/ui-model-selection/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md
-README.md: 5e16cdf18d2eccb63097c2a524bfb2d20178d15e
-README.zh.md: a99581e827c5ce66a31f6e4cbad11394e03dde4d
+README.md: 20d55111a298076a7f41db3a7bcd859f2c8433a6
+README.zh.md: d60c811e7fdd57936f11963916a835064e1fff96
diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md
index 5e16cdf18d..20d55111a2 100644
--- a/packages/client/ui-model-selection/README.md
+++ b/packages/client/ui-model-selection/README.md
@@ -35,6 +35,8 @@ Models stay grouped by provider. The composer menu shows model and effort names
When the Host reports that no adapter serves the session's route, this plugin raises a composer block and the input goes inert with its own copy; recovering clears it without a reload. A `null` before the first load or after one failed never blocks, and catalog membership never blocks either — a route serving a model it does not advertise is missing from the groups yet usable.
+Only the current Client binding's directory can publish its composer block. Cleanup from an older binding preserves a replacement directory's block; without a replacement directory, cleanup removes the obsolete block.
+
### Selection failures
When another writer owns the Session, model-selection failures tell the user to quit other running DSH instances and retry.
diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md
index a99581e827..d60c811e7f 100644
--- a/packages/client/ui-model-selection/README.zh.md
+++ b/packages/client/ui-model-selection/README.zh.md
@@ -35,6 +35,8 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
当 Host 报告没有适配器服务该会话的路由时,本插件注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即清除。首次加载之前或加载失败之后的 `null` 绝不阻断;目录成员关系同样不阻断——一条仍在服务、只是不公布该模型的路由不在分组里,却可用。
+只有当前 Client binding 的目录可以发布对应的 composer 阻塞块。旧 binding 的清理保留后继目录的阻塞块;若尚无后继目录,则移除已失效的阻塞块。
+
### 选择失败
当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。
diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts
index 03f653f50d..d92c2c08b3 100644
--- a/packages/client/ui-model-selection/src/client/service.ts
+++ b/packages/client/ui-model-selection/src/client/service.ts
@@ -28,7 +28,7 @@ declare module '@deepseek-ai/cordis' {
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
interface LiveState {
- /** Per-session directories; entries are deleted by their scope disposer. */
+ /** Directories keyed by Client binding, removed by their scope disposer. */
readonly directories: WeakMapWithValues
}
@@ -91,6 +91,7 @@ export class ModelDirectoryResolver extends Service {
const conversation = this.ctx.get('conversation')
if (conversation !== undefined) {
const publish = (): void => {
+ if (sessions.binding(sessionId) !== binding) return
conversation.blocks.set(sessionId, directory.store.getSnapshot().routable === false
? { reason: this.blockReason() }
: undefined)
@@ -100,6 +101,8 @@ export class ModelDirectoryResolver extends Service {
const stop = directory.store.subscribe(publish)
return () => {
stop()
+ const current = sessions.binding(sessionId)
+ if (current !== undefined && current !== binding && live.directories.get(current) !== undefined) return
conversation.blocks.set(sessionId, undefined)
}
}, 'ui-model-selection: composer block')
diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
index 47dfb1e5c5..ecae6a1281 100644
--- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
+++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
@@ -168,7 +168,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
handle.ctx.effect(() => () => {
if (bindings.get(id) === binding) bindings.delete(id)
})
- return handle
+ return { ...handle, projection }
}
return {
ctx, fiber, mint, calls, remote,
@@ -353,6 +353,68 @@ describe('ui-model-selection dual entry', () => {
expect(face2.directory).not.toBe(face1.directory)
})
+ it('keeps the replacement directory block when the previous scope finishes cleanup', async () => {
+ const b = await bench()
+ const first = b.mint('s1')
+ const oldDirectory = b.ctx.modelDirectories.directoryFor(sid('s1'))
+ await oldDirectory.load()
+ const replacement = b.mint('s1')
+ replacement.projection.set({ lastUsed: null, next: { provider: 'missing', model: 'unserved' } })
+ const directory = b.ctx.modelDirectories.directoryFor(sid('s1'))
+ try {
+ expect(directory).not.toBe(oldDirectory)
+ expect(directory.store.getSnapshot().routable).toBe(false)
+ expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer'])
+ await first.fiber.dispose()
+ expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer'])
+ await replacement.fiber.dispose()
+ expect(b.blockOf('s1')).toBeUndefined()
+ } finally {
+ await Promise.all([first.fiber.dispose(), replacement.fiber.dispose()])
+ await b.ctx.fiber.dispose()
+ }
+ })
+
+ it.each([false, true])('ignores late old projection publication while the replacement is routable=%s', async (routable) => {
+ const b = await bench()
+ const first = b.mint('s1')
+ const oldDirectory = b.ctx.modelDirectories.directoryFor(sid('s1'))
+ await oldDirectory.load()
+ const replacement = b.mint('s1')
+ replacement.projection.set({ lastUsed: null,
+ next: { provider: routable ? 'deepseek-official' : 'missing', model: 'replacement' } })
+ const directory = b.ctx.modelDirectories.directoryFor(sid('s1'))
+ try {
+ expect(directory.store.getSnapshot().routable).toBe(routable)
+ const expected = b.blockOf('s1')
+ await Promise.resolve().then(() => {
+ first.projection.set({ lastUsed: null,
+ next: { provider: routable ? 'missing' : 'deepseek-official', model: 'late-old' } })
+ })
+ expect(oldDirectory.store.getSnapshot().routable).toBe(!routable)
+ expect(b.blockOf('s1')).toBe(expected)
+ } finally {
+ await Promise.all([first.fiber.dispose(), replacement.fiber.dispose()])
+ await b.ctx.fiber.dispose()
+ }
+ })
+
+ it('clears an obsolete block when the new binding has no model directory', async () => {
+ const b = await bench()
+ const first = b.mint('s1')
+ first.projection.set({ lastUsed: null, next: { provider: 'missing', model: 'unserved' } })
+ await b.ctx.modelDirectories.directoryFor(sid('s1')).load()
+ const replacement = b.mint('s1')
+ try {
+ expect(b.blockOf('s1')).toBeDefined()
+ await first.fiber.dispose()
+ expect(b.blockOf('s1')).toBeUndefined()
+ } finally {
+ await Promise.all([first.fiber.dispose(), replacement.fiber.dispose()])
+ await b.ctx.fiber.dispose()
+ }
+ })
+
it('blocks the composer only once the Host reports the route unservable', async () => {
const b = await bench()
b.mint('s1')
diff --git a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx
index f108723d5e..62f1ba2295 100644
--- a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx
+++ b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx
@@ -34,7 +34,7 @@ function bench(over: {
byId: over.cwd === undefined ? {} : { [SESSION]: { cwd: over.cwd } },
current: SESSION,
phase: 'ready',
- subagentsByParent: {},
+ projectionsBySession: {},
jobsBySession: {},
currentAddress: undefined,
} as unknown as SessionListState
diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml
index 2b68bc2db2..74be8bbf76 100644
--- a/packages/client/ui-plan/README.i18n.yaml
+++ b/packages/client/ui-plan/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md
-README.md: 0ba994c4003dc5a3be5be11f217435692039f380
-README.zh.md: 5de065cb59d597261628b43607a45ad46d098e1b
+README.md: b6072cd7d75446a0a45abe4009273235e4ff4e1b
+README.zh.md: 44b5ce85ccd6250f30904f80b01ee083f804f1ce
diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md
index 0ba994c400..b6072cd7d7 100644
--- a/packages/client/ui-plan/README.md
+++ b/packages/client/ui-plan/README.md
@@ -35,7 +35,7 @@ While the effective target is plan mode, the seat renders the blue "Plan ×" sta
When a Turn ends, each submitted plan appears in its final artifact area, using the file-delivery card treatment with a Markdown icon, title, and Open action. A pending plan opens automatically once per submission in the current browser session. Closing it stays effective across review remounts; a new submission opens its own plan. Historical cards open only when clicked. Use the card or review strip’s View full plan link to read the complete Markdown. Plan tabs show a plain text file icon. Different submissions retain separate tabs; the review buttons alone decide whether implementation may begin.
-A review without a logged invocation also opens automatically. Its complete text lives only in the tab’s navigation memory, and the pending review card can reopen it. Reloading the page loses that text; an expired preview directs the user back to a pending review.
+A review without a logged invocation also opens automatically. Its complete text lives only in the tab’s navigation memory, and the pending review card can reopen it. Reloading the page loses that text; an expired preview directs the user back to a pending review. Plans opened from an embedded child conversation use the visible sidebar while retaining the child’s address for document reads.
### Failures
diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md
index 5de065cb59..44b5ce85cc 100644
--- a/packages/client/ui-plan/README.zh.md
+++ b/packages/client/ui-plan/README.zh.md
@@ -35,7 +35,7 @@ kind: "package-reference"
回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“查看全文”链接可阅读完整 Markdown。计划标签页显示纯文本文件图标。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。
-没有已记录调用标识的审批也会自动打开。完整正文只保存在标签页的导航内存中,待处理审批卡片可以重新打开它。刷新页面会丢失该正文;失效的预览会提示用户返回待处理审批。
+没有已记录调用标识的审批也会自动打开。完整正文只保存在标签页的导航内存中,待处理审批卡片可以重新打开它。刷新页面会丢失该正文;失效的预览会提示用户返回待处理审批。在嵌入式子对话中打开计划时,预览使用当前可见的侧栏,读取文档仍保留子会话地址。
### 失败
diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts
index 4a69be9469..bd0a270474 100644
--- a/packages/client/ui-plan/src/client/index.ts
+++ b/packages/client/ui-plan/src/client/index.ts
@@ -71,7 +71,7 @@ export function apply(ctx: ClientContext): void {
openPlan: (callId) => {
const child = ctx.sessions.subagentAddress(sessionId)
const session = child === undefined ? { kind: 'session' as const, sessionId } : { kind: 'subagent' as const, ...child }
- ctx.sidebarRight.openResourceIn(sessionId, planAddress({ session, callId }))
+ ctx.sidebarRight.openResource(planAddress({ session, callId }))
},
})
const reviewWindow = randomUUID()
@@ -84,7 +84,7 @@ export function apply(ctx: ClientContext): void {
inject: (sessionId: SessionId): PlanReviewOpenInjected => ({
openReview: (review, requestKey) => {
if (review.callId !== undefined) { open(sessionId).openPlan(review.callId); return }
- ctx.sidebarRight.openResourceIn(sessionId, reviewPreviewAddress(sessionId, `${reviewWindow}:${requestKey}`), {
+ ctx.sidebarRight.openResource(reviewPreviewAddress(sessionId, `${reviewWindow}:${requestKey}`), {
params: { planReview: { markdown: review.plan, title: extractMarkdownPlainText(review.plan, { mode: 'first-line' }) } },
})
},
diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts
index bbc55871ed..71bc23a044 100644
--- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts
+++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts
@@ -19,21 +19,25 @@ import { submittedPlan } from '../src/client/plan.ts'
import type { PlanChipInjected } from '../src/client/index.ts'
import { apply, inject } from '../src/client/index.ts'
import { apply as nodeApply } from '../src/index.ts'
+import { createSidebarRightController } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/service.ts'
+import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
+import { createSidebarRightStore } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/stores.ts'
function providePreview(ctx: Context) {
const events = new ConversationEventRegistry(ctx)
ctx.provide('uiConversation', { events })
const removeResources = vi.fn()
const removeType = vi.fn()
- const registerType = vi.fn((_definition: Parameters[0]) => removeType)
+ const registerType = vi.fn(() => removeType)
const openResourceIn = vi.fn()
+ const openResource = vi.fn()
const subagentAddress = vi.fn(() => undefined)
ctx.provide('sessions', { subagentAddress })
ctx.provide('resources', { register: vi.fn(() => removeResources) })
ctx.provide('sidebarRightTabs', { register: registerType })
- ctx.provide('sidebarRight', { openResourceIn })
+ ctx.provide('sidebarRight', { openResourceIn, openResource })
ctx.provide('remote.session', {})
- return { events, removeResources, removeType, registerType, openResourceIn, subagentAddress }
+ return { events, removeResources, removeType, registerType, openResourceIn, openResource, subagentAddress }
}
const SID = 's-plan' as SessionId
@@ -63,6 +67,45 @@ async function bench() {
}
describe('ui-plan browser apply', () => {
+ it('opens an embedded child plan in the visible parent sidebar without adopting a child store', async () => {
+ const b = await bench()
+ const parent = 'visible-parent' as SessionId
+ const child = 'embedded-child' as SessionId
+ const tabs = new SidebarRightTabRegistry(b.ctx)
+ b.registerType.mockImplementation(definition => tabs.register(definition))
+ const store = createSidebarRightStore(() => ({ kind: 'guide', title: 'Guide' })).create()
+ const { controller, adopt } = createSidebarRightController(tabs, vi.fn())
+ const release = adopt(parent, store)
+ store.actions.open(parent)
+ const unbind = controller.bind({
+ sessionId: parent, actions: store.actions, surfaces: store.getSnapshot().bySession, canSplitPane: () => true,
+ })
+ b.openResource.mockImplementation(controller.openResource.bind(controller))
+ b.openResourceIn.mockImplementation(controller.openResourceIn.bind(controller))
+ b.subagentAddress.mockReturnValue({ parentSessionId: parent, childSessionId: child, mode: 'continuable' })
+ const fiber = b.ctx.plugin({ inject: [...inject], apply })
+ try {
+ await fiber.await()
+ const card = b.slots.entries('conversation.chat.turnTail')[0]!
+ const opener = (card.inject as unknown as (sessionId: SessionId) => PlanOpenInjected)(child)
+ const plan = submittedPlan({ type: 'tool/call', data: { callId: 'child-plan', name: 'exit_plan_mode', arguments: '{"plan":"# Child plan"}' } })!
+ opener.openPlan(plan.callId)
+ const resources = () => Object.values(store.getSnapshot().bySession[parent]!.layout.tabs).map(tab => tab.contentId)
+ expect(resources()).toContain('dsh-resource://plan/subagent/visible-parent/embedded-child/continuable/child-plan')
+ expect(store.getSnapshot().bySession[child]).toBeUndefined()
+ const review = b.slots.entries('conversation.plan-review.actions')[0]!
+ const reviewOpener = (review.inject as unknown as (sessionId: SessionId) => PlanReviewOpenInjected)(child)
+ reviewOpener.openReview({ id: 'pending', question: 'Approve?', plan: '# Temporary child plan', approve: { label: 'Approve' } }, 'child-question')
+ expect(resources().some(address => address.startsWith('dsh-resource://plan-review/embedded-child/'))).toBe(true)
+ expect(store.getSnapshot().bySession[child]).toBeUndefined()
+ } finally {
+ await fiber.dispose()
+ unbind()
+ release()
+ controller.tabDomain.dispose()
+ }
+ })
+
it('declares every service it binds', () => {
expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'remote.session', 'sessions', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs'])
})
@@ -136,29 +179,29 @@ describe('ui-plan browser apply', () => {
expect(card.component).toBe(PlanCards)
const injected = (card.inject as unknown as (sessionId: SessionId) => PlanOpenInjected)(SID)
injected.openPlan(plan.callId)
- expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, address)
+ expect(b.openResource).toHaveBeenLastCalledWith(address)
const review = b.slots.entries('conversation.plan-review.actions')[0]!
expect(review.component).toBe(PlanReviewOpen)
const reviewInjected = (review.inject as unknown as (sessionId: SessionId) => PlanReviewOpenInjected)(SID)
const pending = { id: 'review', question: 'Approve?', plan: plan.markdown, callId: plan.callId, approve: { label: 'Approve' } }
reviewInjected.openReview(pending, 'question:1')
- expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, address)
+ expect(b.openResource).toHaveBeenLastCalledWith(address)
b.subagentAddress.mockReturnValue({ parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' })
injected.openPlan(plan.callId)
- expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, 'dsh-resource://plan/subagent/parent/s-plan/continuable/call')
+ expect(b.openResource).toHaveBeenLastCalledWith('dsh-resource://plan/subagent/parent/s-plan/continuable/call')
reviewInjected.openReview(pending, 'question:1')
- expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, 'dsh-resource://plan/subagent/parent/s-plan/continuable/call')
+ expect(b.openResource).toHaveBeenLastCalledWith('dsh-resource://plan/subagent/parent/s-plan/continuable/call')
const temporary = { id: 'review', question: 'Approve?', plan: '# Temporary\n\nComplete body', approve: { label: 'Approve' } }
reviewInjected.openReview(temporary, 'question:2')
- const first = b.openResourceIn.mock.calls.at(-1)!
- expect(type.canOpen!(first[1])).toBe(true)
- expect(first).toEqual([SID, expect.stringMatching(/^dsh-resource:\/\/plan-review\/s-plan\//), {
+ const first = b.openResource.mock.calls.at(-1)!
+ expect(type.canOpen!(first[0])).toBe(true)
+ expect(first).toEqual([expect.stringMatching(/^dsh-resource:\/\/plan-review\/s-plan\//), {
params: { planReview: { title: 'Temporary', markdown: temporary.plan } },
}])
reviewInjected.openReview(temporary, 'question:2')
- expect(b.openResourceIn).toHaveBeenLastCalledWith(...first)
+ expect(b.openResource).toHaveBeenLastCalledWith(...first)
reviewInjected.openReview(temporary, 'question:3')
- expect(b.openResourceIn.mock.calls.at(-1)![1]).not.toBe(first[1])
+ expect(b.openResource.mock.calls.at(-1)![0]).not.toBe(first[0])
expect(b.slots.entries('sidebar.right.pane.tab')[0]!.component).toBe(PlanPreview)
expect(b.slots.entries('sidebar.right.pane.tab.title')[0]!.component).toBe(PlanTitle)
await fiber.dispose()
diff --git a/packages/client/ui-renderer/src/client/registry.ts b/packages/client/ui-renderer/src/client/registry.ts
index 94cbcee427..ea3891b65f 100644
--- a/packages/client/ui-renderer/src/client/registry.ts
+++ b/packages/client/ui-renderer/src/client/registry.ts
@@ -352,16 +352,17 @@ export class SlotRegistry extends Service {
}
/**
- * Bind scoped Store instances to one owner Context lifetime. Cleanup drops
- * only materialized in-memory instances; persisted state belongs to the
- * durable scope key. Rebinding the same key transfers cleanup ownership to
- * the newest Context generation.
+ * Bind scoped Store instances to one Context generation. Rebinding the key
+ * drops the previous generation's memory instances before the new owner can
+ * resolve them. Cleanup never clears persisted state, which belongs to the
+ * durable scope key, or drops a replacement generation's instances.
*
* @param binding - materialized scope identity and its owning Context.
*/
bindStoreScope(binding: Pick): void {
const current = this._storeScopeOwners.get(binding.key)
if (current === binding.ctx) return
+ if (current !== undefined) this.releaseStoreScope(binding.key)
this._storeScopeOwners.set(binding.key, binding.ctx)
binding.ctx.effect(() => () => {
if (this._storeScopeOwners.get(binding.key) !== binding.ctx) return
diff --git a/packages/client/ui-renderer/tests/registry.client.spec.ts b/packages/client/ui-renderer/tests/registry.client.spec.ts
index bc85bcf19e..1db77dd05f 100644
--- a/packages/client/ui-renderer/tests/registry.client.spec.ts
+++ b/packages/client/ui-renderer/tests/registry.client.spec.ts
@@ -764,6 +764,56 @@ describe('store instance axis', () => {
await replacement.fiber.dispose()
})
+ it.each(['t.panel', 't.maybe'] as const)('separates %s memory when a Session reopens before previous Context cleanup', async (slot) => {
+ const { bench, host } = await storeBench()
+ const persisted = new Map()
+ const clearPersisted = vi.fn()
+ const handle = {
+ create: vi.fn((scopeKey?: string) => {
+ const key = scopeKey ?? 'root'
+ let expanded = false
+ let saved = persisted.get(key) ?? 0
+ return {
+ getSnapshot: () => ({ expanded, saved }),
+ subscribe: () => () => {},
+ actions: {
+ expand: () => { expanded = true },
+ save: (value: number) => { saved = value; persisted.set(key, value) },
+ },
+ clearPersisted,
+ }
+ }),
+ }
+ bench.erased.register({ name: slot, store: handle }, C)
+ const [entry] = host.entriesOf(slot)
+ const first = scopedBinding(bench.ctx, 's1')
+ const other = scopedBinding(bench.ctx, 's2')
+ const replacement = scopedBinding(bench.ctx, 's1')
+ try {
+ const before = host.storeOf(entry as never, first.binding) as ReturnType
+ before.actions.expand()
+ before.actions.save(7)
+ const unaffected = host.storeOf(entry as never, other.binding) as ReturnType
+ unaffected.actions.expand()
+ const after = host.storeOf(entry as never, replacement.binding) as ReturnType
+ expect(after).not.toBe(before)
+ expect(after.getSnapshot()).toEqual({ expanded: false, saved: 7 })
+ expect(handle.create.mock.calls.map(([key]) => key)).toEqual(['s1', 's2', 's1'])
+ expect(host.storeOf(entry as never, other.binding)).toBe(unaffected)
+ expect(unaffected.getSnapshot().expanded).toBe(true)
+ after.actions.expand()
+ after.actions.save(9)
+ await first.fiber.dispose()
+ expect(host.storeOf(entry as never, replacement.binding)).toBe(after)
+ expect(after.getSnapshot()).toEqual({ expanded: true, saved: 9 })
+ expect(persisted.get('s1')).toBe(9)
+ expect(clearPersisted).not.toHaveBeenCalled()
+ } finally {
+ await Promise.all([first.fiber.dispose(), replacement.fiber.dispose(), other.fiber.dispose()])
+ await bench.ctx.fiber.dispose()
+ }
+ })
+
it('does not materialize scoped stores solely for scope release', async () => {
const { bench } = await storeBench()
const root = fakeHandle()
diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml
index eec5a1a51f..03b356e547 100644
--- a/packages/client/ui-session/README.i18n.yaml
+++ b/packages/client/ui-session/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-session/README.md
-README.md: 4fff368e58d7b422ba6948be78e543c31f5cef59
-README.zh.md: db37539b59231bfef50f3bdcc33aeae94e881a44
+README.md: 22ece0c11eee6b2a59acbbd8c5a9650b593a7da4
+README.zh.md: 4b52bef28ad065a7ff10f468056723c4d301a0a0
diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md
index 4fff368e58..22ece0c11e 100644
--- a/packages/client/ui-session/README.md
+++ b/packages/client/ui-session/README.md
@@ -10,6 +10,8 @@ English | [中文](README.zh.md)
Use this package to expose Session catalog, retain information, and unified UI status through standard Slot hooks. It materializes per-`SessionBinding` hooks and props, while `SessionProvider` can inherit an outer binding or bind an explicit `SessionReference`. It owns process-local pending-interaction and completion-reminder policy without owning Controller transport, history, or references.
+Running status comes from Host list baselines or status events. Catalog rows and local fallback rows do not establish running status; main view references still acknowledge completions.
+
## Table of Contents
- [Model Experience](#model-experience)
diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md
index db37539b59..4b52bef28a 100644
--- a/packages/client/ui-session/README.zh.md
+++ b/packages/client/ui-session/README.zh.md
@@ -10,6 +10,8 @@ kind: "package-reference"
使用本包可通过标准 Slot 钩子公开 Session catalog、retain 信息与统一 UI 状态。它按 `SessionBinding` 物化钩子和 prop,而 `SessionProvider` 可以继承外围 binding 或绑定显式 `SessionReference`。它拥有进程本地的 pending-interaction 与完成提醒策略,但不拥有 Controller transport、历史或 reference。
+运行状态来自 Host 列表基线或状态事件。目录和保留代际的 fallback 行不会建立运行状态;主视图引用仍会确认完成提醒。
+
## 目录
- [模型体验](#model-experience)
diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json
index ac006223f5..3420171409 100644
--- a/packages/client/ui-session/package.json
+++ b/packages/client/ui-session/package.json
@@ -48,8 +48,10 @@
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
+ "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+ "@deepseek-ai/dsh-remote-mock": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-util-values": "workspace:^",
"@types/react": "~18.3.1",
diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts
index 5e3285bbd0..1f94bce4bb 100644
--- a/packages/client/ui-session/src/client/index.ts
+++ b/packages/client/ui-session/src/client/index.ts
@@ -7,6 +7,7 @@ import type {
SessionReference,
SessionRetainInfo,
SessionSnapshot,
+ SessionSummary,
UseProjection,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -492,13 +493,15 @@ export class UiSession extends Service {
private reconcileStatus(): void {
const list = this.sessions.list.getSnapshot()
const present = new Set(Object.keys(list.byId) as SessionId[])
- for (const id of present) {
- const row = list.byId[id]
- if (row === undefined) continue
+ // Rows from catalogs and retained Client bindings do not establish Host running state.
+ for (const id of list.ids) {
+ const row = list.byId[id] as SessionSummary
const previous = this.running.get(id)
if (previous === undefined) this.running.set(id, row.running)
else if (previous !== row.running) this.observeRunning(id, row.running)
- if ((row.retainedBy.mainView ?? 0) > 0) this.completionUnread.delete(id)
+ }
+ for (const id of present) {
+ if (this.isMain(id)) this.completionUnread.delete(id)
}
if (list.phase === 'ready') {
for (const id of this.running.keys()) {
diff --git a/packages/client/ui-session/tests/status-controller.client.spec.ts b/packages/client/ui-session/tests/status-controller.client.spec.ts
new file mode 100644
index 0000000000..1bf3bfec88
--- /dev/null
+++ b/packages/client/ui-session/tests/status-controller.client.spec.ts
@@ -0,0 +1,64 @@
+/** Running-state ownership across the real Controller catalog and UI status source. */
+import { expect, vi } from 'vitest'
+import { ok } from '@deepseek-ai/dsh-remote-mock'
+import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
+import { SESSION_FORMAT_VERSION, type SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
+
+const it = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-client-ui-session']) })
+const parentId = 'status-parent' as SessionId
+const childId = 'status-child' as SessionId
+
+it('preserves unlisted child status through metadata updates and main view acknowledgement', async ({ mock, start }) => {
+ mock.remote.session.list.mockResolvedValue(ok({ items: [{
+ sessionId: parentId, updatedAt: 1, running: false, blank: false, agentAvailable: true,
+ }] }))
+ mock.remote.session.projections.mockResolvedValue(ok({
+ asOfSeq: 0,
+ values: { subagentCatalog: [{ id: childId, createdAt: 1, mode: 'continuable', label: 'Child' }] },
+ }))
+ mock.stream('session/follow', (_request, stream) => {
+ stream.push({
+ type: 'snapshot',
+ header: { version: SESSION_FORMAT_VERSION, id: childId, createdAt: 1, isSeeded: false,
+ parentSession: parentId, origin: 'subagent' },
+ cursor: -1, records: [], hasMore: false,
+ projections: { asOfSeq: -1, values: {} }, assistantStream: { revision: 0 },
+ } satisfies SessionFollowFrame)
+ })
+ const client = await start()
+ const sessions = client.ctx.sessions
+ const ui = client.ctx.uiSession
+ await sessions.refresh()
+ await sessions.refreshProjections(parentId)
+ await client.flush()
+ expect(sessions.list.getSnapshot().ids).toEqual([parentId])
+ expect(sessions.binding(childId)).toBeUndefined()
+ expect(ui.sessionStatus.getSnapshot().get(childId)).toMatchObject({ running: undefined, completionUnread: false })
+
+ mock.streams.push('$events', { type: 'emit', event: 'api-session/status', args: [childId, true] })
+ await mock.streams.drained('$events')
+ await client.flush()
+ expect(ui.sessionStatus.getSnapshot().get(childId)).toMatchObject({ running: true, completionUnread: false })
+
+ mock.streams.push('$events', { type: 'emit', event: 'api-session/activity', args: [parentId, 2] })
+ await mock.streams.drained('$events')
+ await client.flush()
+ expect(sessions.list.getSnapshot().byId[parentId]?.updatedAt).toBe(2)
+ expect(ui.sessionStatus.getSnapshot().get(childId)).toMatchObject({ running: true, completionUnread: false })
+
+ mock.streams.push('$events', { type: 'emit', event: 'api-session/status', args: [childId, false] })
+ await mock.streams.drained('$events')
+ await client.flush()
+ expect(ui.sessionStatus.getSnapshot().get(childId)).toMatchObject({ running: false, completionUnread: true })
+
+ using reference = sessions.retain(childId, { source: 'mainView' })
+ await reference.ready
+ expect(sessions.list.getSnapshot().ids).not.toContain(childId)
+ await vi.waitFor(() => {
+ expect(ui.sessionStatus.getSnapshot().get(childId)).toMatchObject({ running: false, completionUnread: false })
+ })
+ reference.release()
+ await client.flush()
+ expect(ui.sessionStatus.getSnapshot().get(childId)?.completionUnread).toBe(false)
+})
diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts
index bce5fdadcc..e9c80918bd 100644
--- a/packages/client/ui-session/tests/ui-session.client.spec.ts
+++ b/packages/client/ui-session/tests/ui-session.client.spec.ts
@@ -53,7 +53,7 @@ function createSessionsBench(ctx: Context): SessionsBench {
ids: [],
byId: {},
phase: 'ready',
- subagentsByParent: {},
+ projectionsBySession: {},
jobsBySession: {},
})
const bindings = new Map()
@@ -522,6 +522,27 @@ describe('UiSession bindings', () => {
})
describe('UiSession status', () => {
+ it('keeps synthetic-row status unknown until an event or Host baseline establishes it', () => {
+ const ctx = new Context()
+ const bench = createSessionsBench(ctx)
+ const id = sessionId('catalog-only')
+ bench.list.update((draft) => {
+ draft.byId[id] = { id, displayTitle: id, running: false, retainedBy: {}, blank: false, updatedAt: 0 }
+ })
+ const service = createUiSession(ctx, bench)
+ expect(service.sessionStatus.getSnapshot().get(id)?.running).toBeUndefined()
+ expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
+
+ bench.emitStatus(id, true)
+ bench.list.update((draft) => { draft.byId[id]!.displayTitle = 'Renamed child' })
+ expect(service.sessionStatus.getSnapshot().get(id)?.running).toBe(true)
+ expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
+
+ bench.list.update((draft) => { draft.ids.push(id) })
+ expect(service.sessionStatus.getSnapshot().get(id)?.running).toBe(false)
+ expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(true)
+ })
+
it('records non-main completions and lets main-view activity acknowledge them', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx
index 34c20283df..74a5b22cfb 100644
--- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx
+++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx
@@ -88,7 +88,7 @@ function mount({
updatedAt: 0,
},
},
- phase: 'ready', subagentsByParent: {}, jobsBySession: {},
+ phase: 'ready', projectionsBySession: {}, jobsBySession: {},
}
const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
const props: SettingsRootComponentProps = {
diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml
index 4de8cd03f3..8cfd6b578a 100644
--- a/packages/client/ui-sidebar-right/README.i18n.yaml
+++ b/packages/client/ui-sidebar-right/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md
-README.md: dbe09becc8f5f322720c21f89e5042a1f423e6f0
-README.zh.md: 357f9366298dcc4313068d78f73105691d82c129
+README.md: e9c143f34076c308f3afdb348cc09506f3983a35
+README.zh.md: 72d0c20817c690c1dcca5a97b67adbdfa1f11261
diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md
index dbe09becc8..e9c143f340 100644
--- a/packages/client/ui-sidebar-right/README.md
+++ b/packages/client/ui-sidebar-right/README.md
@@ -97,7 +97,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r
## The Tab domain
-The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store, reconciles restored records immediately, and follows its commits. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles, and floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller.
+The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store, reconciles restored records immediately, and follows its commits. Reconstructing a Session store replaces its adoption, releasing the old subscription and cleanup reference. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles, and floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller.
Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler synchronously retains any background cleanup before allowing explicit close or replacement. The resource owner tracks completion and retry; the sidebar does not wait. A thrown handler preserves the tab. Collapse, presentation changes and plugin disposal do not invoke close handlers; the tab abort signal identifies occurrence disposal, not an explicit close.
diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md
index 357f936629..72d0c20817 100644
--- a/packages/client/ui-sidebar-right/README.zh.md
+++ b/packages/client/ui-sidebar-right/README.zh.md
@@ -97,7 +97,7 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包
## Tab 域
-Tab 域按(Session,Tab id)保留导航、中止信号与绑定动作。私有装配回调采用各 Session 的 store,立即对齐恢复的记录,再跟随其提交。记录消失或插件卸载才中止 signal,收起和切会话不销毁记录;undo 恢复的是新 occurrence。`useTabInfo()` 组合框架绑定的 store 与导航 hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,浮窗不受整栏收起影响。`adopt` 不在公开控制器上。
+Tab 域按(Session,Tab id)保留导航、中止信号与绑定动作。私有装配回调采用各 Session 的 store,立即对齐恢复的记录,再跟随其提交。同一 Session 的 store 重建会替换其 `adopt` 注册,同时释放旧订阅与清理引用。记录消失或插件卸载才中止 signal,收起和切会话不销毁记录;undo 恢复的是新 occurrence。`useTabInfo()` 组合框架绑定的 store 与导航 hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,浮窗不受整栏收起影响。`adopt` 不在公开控制器上。
标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 在允许显式关闭或替换前同步保存后台清理任务。资源所有者跟踪完成和重试,侧栏不等待清理。handler 抛错时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。
diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts
index f4131a7031..c50c757f23 100644
--- a/packages/client/ui-sidebar-right/src/client/index.ts
+++ b/packages/client/ui-sidebar-right/src/client/index.ts
@@ -122,16 +122,18 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => {
const handle = createSidebarRightStore(() => defaultSeed(tabs))
- // The runtime mints one instance of this handle per session (the scope key
- // is the session id) and caches it per key. Each is adopted as it is minted,
- // so a tab's own action reaches its session's store while another session
- // is on screen, and that store's commits sync the Tab domain themselves.
- const adoptions: Array<() => void> = []
+ // Each Session Context generation owns one Store. Background tab actions
+ // use the latest adoption for that Session.
+ const adoptions = new Map void>()
const store: typeof handle = {
...handle,
create: (scopeKey) => {
const instance = handle.create(scopeKey)
- if (scopeKey !== undefined) adoptions.push(adopt(scopeKey as SessionId, instance))
+ if (scopeKey !== undefined) {
+ const sessionId = scopeKey as SessionId
+ adoptions.get(sessionId)?.()
+ adoptions.set(sessionId, adopt(sessionId, instance))
+ }
return { ...instance, clearPersisted() {
instance.clearPersisted()
if (scopeKey !== undefined) forget(scopeKey as SessionId)
@@ -212,7 +214,8 @@ export function apply(ctx: ClientContext): void {
disposeExpand()
disposeSeat()
for (const dispose of disposeTypes.reverse()) dispose()
- for (const release of adoptions) release()
+ for (const release of adoptions.values()) release()
+ adoptions.clear()
}
}, 'ui-sidebar-right: seats and shipped tab type')
}
diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts
index 6afbbd8235..530334eae2 100644
--- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts
+++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts
@@ -18,6 +18,7 @@ import { apply as hostApply } from '../src/index.ts'
import { SidebarRightController } from '../src/client/service.ts'
import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts'
import type { createSidebarRightStore } from '../src/client/stores.ts'
+import * as sidebarStores from '../src/client/stores.ts'
import { RightbarSeat } from '../src/client/shell/SidebarRight.tsx'
import { RightbarRoot } from '../src/client/shell/RightbarRoot.tsx'
import { ExpandButton } from '../src/client/shell/ExpandButton.tsx'
@@ -173,6 +174,50 @@ describe('ui-sidebar-right apply', () => {
expect(ctx.sidebarRight.openTabs.getSnapshot()).toEqual([])
})
+ it('releases replaced store adoptions once and keeps the latest store for each Session', async () => {
+ const originalFactory = sidebarStores.createSidebarRightStore
+ const stops: Array void>>> = []
+ const factory = vi.spyOn(sidebarStores, 'createSidebarRightStore').mockImplementation((seed) => {
+ const handle = originalFactory(seed)
+ return {
+ ...handle,
+ create(scopeKey) {
+ const instance = handle.create(scopeKey)
+ return {
+ ...instance,
+ subscribe(listener) {
+ const stop = vi.fn(instance.subscribe(listener))
+ stops.push(stop)
+ return stop
+ },
+ }
+ },
+ }
+ })
+ let b: Awaited> | undefined
+ try {
+ b = await boot()
+ const handle = b.seat('rightbar.session').store as ReturnType
+ const other = 's-other' as SessionId
+ handle.create(SESSION)
+ handle.create(other)
+ handle.create(SESSION)
+ handle.create(other)
+ const current = handle.create(SESSION)
+ const currentOther = handle.create(other)
+ expect(stops.map(stop => stop.mock.calls.length)).toEqual([1, 1, 1, 1, 0, 0])
+ current.actions.setExpanded(SESSION, true)
+ currentOther.actions.setExpanded(other, true)
+ expect(new Set(b.ctx.sidebarRight.openTabs.getSnapshot().map(tab => tab.sessionId)))
+ .toEqual(new Set([SESSION, other]))
+ await b.fiber.dispose()
+ expect(stops.map(stop => stop.mock.calls.length)).toEqual([1, 1, 1, 1, 1, 1])
+ } finally {
+ await b?.ctx.fiber.dispose()
+ factory.mockRestore()
+ }
+ })
+
it('hands the guide body the registry\'s entry boxes, observable', async () => {
const { ctx, seat, injectedOf } = await boot()
const { hooks: { guideEntries } } = injectedOf(seat('sidebar.right.pane.tab')) as GuideInjected
diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml
index a36e5f58be..45a496719c 100644
--- a/packages/client/ui-subagent/README.i18n.yaml
+++ b/packages/client/ui-subagent/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
-README.md: 136829a6f5ea7f40e291f913651cd98adac9d916
-README.zh.md: 9a4633d7ba3b6c66527ec79f5850f42b1ec2a449
+README.md: 1404e893add386b88a24d78675a4f0be06f497a0
+README.zh.md: 97c743b580e25cd50bcb01a3a7547cb7415a0e08
diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md
index 136829a6f5..1404e893ad 100644
--- a/packages/client/ui-subagent/README.md
+++ b/packages/client/ui-subagent/README.md
@@ -25,13 +25,13 @@ Use this package to browse every subagent conversation beneath a parent session,
## Use this package
-The session header keeps the current session title as the lineage breadcrumb and, when the session has subagent descendants, appends a `/` count trigger before the header's action row; the trigger opens the descendant catalog, counts the complete subagent-only lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits.
+The session header keeps the current session title as the lineage breadcrumb and appends a `/` count trigger when its direct catalog has entries or a read has failed. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits.
This package registers the `dsh-resource://subagentchat/session/?parent=&mode=` resource and builtin Sidebar tab type. The resource refreshes the direct-parent catalog before it retains the child `SessionReference`, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls.
### Browsing the tree
-Rows display mode plus activity and an optional log-backed title; running uses the shared ongoing loader, an inactive child whose latest closed turn completed normally uses the shared success dot, other inactive children use the shared idle dot, and diagnostic rows use the shared error dot. Every row reserves the same 14px status column, centering smaller dots so titles align with the loader state. The compact header trigger vertically centers its activity glyph and count with a 4px gap. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id; corrupt, unsupported, or unavailable rows remain readable but disabled.
+Rows display mode plus activity and an optional log-backed title; running uses the shared ongoing loader, an inactive child whose latest closed turn completed normally uses the shared success dot, and other inactive children use the shared idle dot. Every row reserves the same 14px status column, centering smaller dots so titles align with the loader state. The compact header trigger vertically centers its activity glyph and count with a 4px gap. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id. A row is a known leaf only after its own catalog loads empty.
### Continuing a conversation
@@ -53,11 +53,13 @@ The catalog and composer behavior are specified by the [Web subagent conversatio
### Catalog derivation
-The header lineage renderer reads `subagentsByParent` and session summaries through the standard `useSessions` hook. The compact tree remains direct-catalog authoritative: each healthy row's `hasChildren` hint determines disclosure before interaction, a catalog level reserves the disclosure column only when at least one healthy row is a branch, and expanding a branch immediately reserves one disabled loading row per known direct descendant before lazily replacing them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed.
+The header lineage renderer reads `projectionsBySession` through the standard `useSessions` hook. The renderer selects `subagentCatalog` from each Session’s shared values for membership, disclosure, and counts; Activity prefers the unified UI status and falls back to Session summaries; summaries supply titles and usage. Expanding a row loads its initial catalog when needed. Live projection frames update every loaded level without menu subscriptions or repeated membership queries. A row remains expandable while its catalog is absent, loading, or failed, and becomes a known leaf after a ready empty catalog.
+
+Breadcrumb addresses derive from the Provider-bound Session address and loaded parent catalogs, including never-selected ancestors.
### Duration, completion, and tokens
-Token totals sum the four disjoint `tokenUsage` buckets. The `subagentTiming` projection sums completed-turn duration and records whether the latest closed turn ended with `completed`; opening another turn clears that completion until its own `turn/end`. Duration advances once per second only for an open turn on a running child and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata.
+Each visible catalog level advances its own clock while it contains a running child; collapsing the level or closing the menu releases that clock. Token totals sum the four disjoint `tokenUsage` buckets. The `subagentTiming` projection sums completed-turn duration and records whether the latest closed turn ended with `completed`; opening another turn clears that completion until its own `turn/end`. Duration advances once per second only for an open turn on a running child and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata.
### Composer election
diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md
index 9a4633d7ba..97c743b580 100644
--- a/packages/client/ui-subagent/README.zh.md
+++ b/packages/client/ui-subagent/README.zh.md
@@ -25,13 +25,13 @@ kind: "package-reference"
## 使用本包
-会话页头保留当前会话 title 作为谱系面包屑,并在会话存在 subagent 后代时,于页头操作行之前追加 `/` 数量触发器;触发器打开后代目录,统计仅含 subagent 的完整谱系、在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。
+会话页头保留当前会话 title 作为谱系面包屑,并在直接目录有子项或读取失败时追加 `/` 数量触发器。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。
本包注册 `dsh-resource://subagentchat/session/?parent=&mode=` 资源与 builtin Sidebar tab 类型。资源先刷新直接 parent 目录,再保留 child 的 `SessionReference`,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。
### 浏览目录
-行显示 mode、活动状态与由日志支撑的可选 title;running 使用共享 ongoing loading,最近一个已结束轮次正常完成的 inactive child 使用共享 success 绿点,其他 inactive child 使用共享 idle 灰点,诊断行使用共享 error 红点。每行都为状态图标预留相同的 14px 列宽,并将较小的圆点居中,使 title 与 loading 状态对齐。紧凑的页头触发器会垂直居中活动图标与数量,并保留 4px 水平间距。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id;损坏、不受支持或不可用的行仍保持可读但禁用。
+行显示 mode、活动状态与由日志支撑的可选 title;running 使用共享 ongoing loading,最近一个已结束轮次正常完成的 inactive child 使用共享 success 绿点,其他 inactive child 使用共享 idle 灰点。每行都为状态图标预留相同的 14px 列宽,并将较小的圆点居中,使 title 与 loading 状态对齐。紧凑的页头触发器会垂直居中活动图标与数量,并保留 4px 水平间距。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id。只有一行自身的目录加载为空后,它才是已知叶子。
### 续接对话
@@ -53,11 +53,13 @@ kind: "package-reference"
### 目录派生
-页头谱系 renderer 通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。紧凑树仍以直接目录为权威依据:每个健康行的 `hasChildren` 提示在交互前决定是否显示展开控件;每层目录仅在其中至少一个健康行是分支时才预留展开列;展开分支时会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。
+页头谱系 renderer 通过标准 `useSessions` 钩子读取 `projectionsBySession`。renderer 从每个 Session 的共享值中选择 `subagentCatalog`,用于成员关系、展开控件与数量;活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要提供标题与用量。展开行会按需加载初始目录。实时 projection 帧更新所有已加载层级,无需菜单订阅或重复成员查询。行在目录缺席、加载中或失败时保持可展开,并在目录就绪且为空后成为已知叶子。
+
+面包屑地址从 Provider 所绑定的 Session 地址和已加载的 parent 目录推导,也包括从未选中过的祖先。
### 耗时、完成状态与 token
-token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。`subagentTiming` 投影会累加已结束轮次的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次开始时会清除该完成状态,直到自身的 `turn/end` 到达。耗时仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。
+每个可见目录层在包含运行中 child 时独立推进时钟;折叠该层或关闭菜单会释放时钟。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。`subagentTiming` 投影会累加已结束轮次的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次开始时会清除该完成状态,直到自身的 `turn/end` 到达。耗时仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。
### 编辑器选举
diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css
index 6563066ff4..b9a28a3781 100644
--- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css
+++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css
@@ -169,19 +169,6 @@
height: 17px;
}
-.disabled {
- color: var(--dsw-alias-label-dimmed);
- cursor: not-allowed;
-}
-
-.disabled:hover {
- background: transparent;
-}
-
-.loadingRow {
- cursor: default;
-}
-
.disclosure,
.disclosureSpace {
flex: none;
diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx
index 714398f075..6dd416fb95 100644
--- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx
+++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx
@@ -1,10 +1,8 @@
-import {
- useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent,
-} from 'react'
+import { useMemo, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent } from 'react'
import { createPortal } from 'react-dom'
import {
- type SessionListState, type SessionProjectionMap, type SessionSummary,
- type SubagentCatalogSnapshot,
+ type SessionProjectionMap, type SessionSummary,
+ type SessionProjectionSnapshot,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -16,17 +14,18 @@ import { NS } from './locales.ts'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-token-meter/client'
import css from './SubagentHeaderLineage.module.css'
-import { indexSubagentDescendants } from './subagent-lineage.ts'
-type CatalogEntry = SubagentCatalogSnapshot['entries'][number]
-type Catalogs = SessionListState['subagentsByParent']
+type SubagentCatalogSnapshot = Omit & {
+ state: 'loading' | 'ready' | 'error'
+ entries: (SessionProjectionMap['subagentCatalog'][number] & { activity: 'running' | 'inactive' })[]
+}
+type Catalogs = Readonly>
/** Business actions supplied by the slot registration. */
export interface SubagentCatalogInjected {
openChild: (address: SubagentAddress) => void
openChildAside: (address: SubagentAddress) => void
refresh: (parentSessionId: SessionId) => void
- setCatalogOpen: (parentSessionId: SessionId, open: boolean) => void
}
/** Full props for the session-header lineage renderer. */
@@ -41,7 +40,6 @@ interface CatalogRowsProps {
summaries: Readonly>
expanded: ReadonlySet
level: number
- now: number
openChild: (address: SubagentAddress) => void
openChildAside: (address: SubagentAddress) => void
refresh: (parentSessionId: SessionId) => void
@@ -49,17 +47,6 @@ interface CatalogRowsProps {
closeCatalog: () => void
}
-function diagnosticReason(
- entry: Extract,
- t: TranslateNS,
-): string {
- switch (entry.reason) {
- case 'corrupt': return t('diagnostic.corrupt')
- case 'unsupported': return t('diagnostic.unsupported')
- case 'unavailable': return t('diagnostic.unavailable')
- }
-}
-
function treeItems(root: HTMLDivElement | null): HTMLElement[] {
return root === null
? []
@@ -177,8 +164,6 @@ function formatExactDuration(ms: number, t: TranslateNS): string {
})
}
-const NO_DESCENDANTS = { count: 0, runningCount: 0 } as const
-
function SubagentSwitcherIcon() {
return (