refactor(gui): todos ride the generic projection pair; ConversationSnapshot evacuated

TodoDock reads useProjection('todos') (whole list or null pre-first-write;
absent renders nothing) and declare-merges the todos key through the pure-type
outlet — the identical member the tool-todo host unit owns, drift rejected by
any program holding both. The core Session's todos field, its todo/write case,
and the snapshot member retire; the client folds nothing. Session specs for
the retired client fold move to the value-store spec's seq coverage; snapshot
literals across component specs drop the field.
This commit is contained in:
imccyu
2026-07-27 22:02:36 +08:00
parent 913125294b
commit d9d7e523f9
18 changed files with 43 additions and 97 deletions

View File

@@ -269,7 +269,4 @@ export interface ConversationSnapshot {
*/ */
blank: boolean blank: boolean
lastAgentError: string | null lastAgentError: string | null
/** Current whole-list `todo/write` projection — the tail page's full-log value, then each live
* write (last write wins); empty = the log holds no plan. */
todos: readonly TodoItem[]
} }

View File

@@ -40,8 +40,6 @@ export const ev = {
at(seq, { type: 'step/end', data: { turn, step } }), at(seq, { type: 'step/end', data: { turn, step } }),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent => turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent =>
at(seq, { type: 'todo/write', data: { todos } }),
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent => commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }), at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent => commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>

View File

@@ -62,7 +62,7 @@ export class FakeApiClient implements IApiClient {
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] })) onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[] }>> = => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false })) () => Promise.resolve(ok({ events: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const })) onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))

View File

@@ -22,9 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
return { api, session: new Session(SID, api) } return { api, session: new Session(SID, api) }
} }
function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) { function histResponse(events: SessionEvent[], hasMore = false) {
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less. // history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } })) return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
} }
describe('open', () => { describe('open', () => {
@@ -175,42 +175,6 @@ describe('live event path', () => {
}) })
}) })
it('folds todo/write into snapshot.todos last-write-wins, live and on window replay', async () => {
const listA = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'in_progress' as const }]
const listB = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'completed' as const }]
const { session } = await opened()
expect(session.getSnapshot().todos).toEqual([])
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.todoWrite(6, listA))
expect(session.getSnapshot().todos).toEqual(listA)
feed(ev.todoWrite(7, listB))
expect(session.getSnapshot().todos).toEqual(listB)
// Window replay converges on the same last snapshot (history contains both writes).
const replayed = makeSession()
replayed.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ev.todoWrite(6, listA), ev.todoWrite(7, listB)])
await replayed.session.open()
expect(replayed.session.getSnapshot().todos).toEqual(listB)
})
it('seeds todos from the tail page projection when the last write precedes the window', async () => {
const list = [{ content: '窗口外的计划', status: 'in_progress' as const }]
// Cold open: the page window carries NO todo/write; the projection rides the response.
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list)
await session.open()
expect(session.getSnapshot().todos).toEqual(list)
// Paging an older window in must not clear the session-level projection.
api.onHistory = () => histResponse(plainTurn(94, 8, '旧问', '旧答'), false)
await session.loadOlder()
expect(session.getSnapshot().todos).toEqual(list)
// A later live write still overrides the seeded projection.
session.handleMuxEnvelope('r' as never, {
type: 'session/event', sessionId: SID,
event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]),
})
expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }])
})
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')] const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
@@ -224,37 +188,6 @@ describe('live event path', () => {
const seqs = session.getSnapshot().nodes.map(n => n.seq) const seqs = session.getSnapshot().nodes.map(n => n.seq)
expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9 expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
}) })
it('gap repair adopts the repull response projection (a missed todo/write outside the new tail page)', async () => {
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
expect(session.getSnapshot().todos).toEqual([])
// The missed range contained a todo/write that the repulled page no longer
// covers; the response's session-level projection is the only carrier.
const current = [{ content: '断线期间写的', status: 'in_progress' as const }]
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(8, 1, 'c', 'd')], false, current)
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(11, 1, 'd') })
await vi.waitFor(() => {
expect(api.callsOf('session.history').length).toBe(2)
})
await Promise.resolve()
expect(session.getSnapshot().todos).toEqual(current)
})
it('clears the plan when a tail response omits the projection (a write the log never kept)', async () => {
// Live write lands, then the host crashes before persisting it: the
// authoritative log holds no todo/write, so the resync tail response
// carries no projection — an omitted field on a tail request is the empty
// list, not a missing carrier, and the rolled-back plan must disappear.
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b'))
session.handleMuxEnvelope('r' as never, {
type: 'session/event', sessionId: SID,
event: ev.todoWrite(6, [{ content: '丢失的计划', status: 'in_progress' as const }]),
})
expect(session.getSnapshot().todos).toEqual([{ content: '丢失的计划', status: 'in_progress' }])
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.resync()
expect(session.getSnapshot().todos).toEqual([])
})
}) })
describe('paging', () => { describe('paging', () => {

View File

@@ -49,6 +49,7 @@
}, },
"devDependencies": { "devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^",

View File

@@ -9,6 +9,17 @@ import { useId, useState } from 'react'
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client' import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
// Client-side view of the todos projection key. The authoritative merge lives
// with the domain host unit (tool-todo), whose program never overlaps the
// client's, so this consumer restates the identical member through the same
// pure-type outlet (any program holding both merges rejects drift).
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/** The agent's current whole todo list (latest `todo/write` snapshot), or `null` before the first write. */
todos: TodoItem[] | null
}
}
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './TodoPanel.module.css' import css from './TodoPanel.module.css'
@@ -115,10 +126,10 @@ export function TodoPanel({ todos }: TodoPanelProps) {
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */ /** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type TodoDockProps = PropsRuntime<'conversation.input.dock'> export type TodoDockProps = PropsRuntime<'conversation.input.dock'>
/** Dock adapter: selects the plan off the session snapshot and hands the strip a plain list. */ /** Dock adapter: reads the host-computed 'todos' projection (whole list; absent or null renders nothing). */
export function TodoDock({ useSession }: TodoDockProps) { export function TodoDock({ useProjection }: TodoDockProps) {
const todos = useSession(s => s.todos) const todos = useProjection('todos')
return <TodoPanel todos={todos} /> return <TodoPanel todos={todos ?? []} />
} }
/** /**

View File

@@ -56,7 +56,7 @@ function snapshotWith(
): ConversationSnapshot { ): ConversationSnapshot {
return { return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches, sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} }

View File

@@ -27,7 +27,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot { function snapshotBase(): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} }
} }

View File

@@ -40,7 +40,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot { function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
return { return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} }
} }

View File

@@ -30,7 +30,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot { function snapshotBase(): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} }
} }

View File

@@ -19,7 +19,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot { function snapshotBase(): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} }
} }

View File

@@ -21,7 +21,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot { function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, promptError: null, blank: false, lastAgentError: null,
...overrides, ...overrides,

View File

@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({ const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: over?.running ?? false, composerPhase: 'active', pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, lastAgentError: null, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}) })

View File

@@ -110,7 +110,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
const wiring = shell const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({ const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, promptError: null, blank: false, lastAgentError: null,
}) })

View File

@@ -19,7 +19,7 @@ const SID = 's1' as SessionId
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue, todos: [], running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} }
} }

View File

@@ -48,7 +48,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot { function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, promptError: null, blank: false, lastAgentError: null,
...overrides, ...overrides,

View File

@@ -64,20 +64,23 @@ describe('TodoPanel', () => {
}) })
}) })
/** Dock props stub: the adapter reads useSession only; the rest of the owner share is unused. */ /** Dock props stub: the adapter reads the 'todos' projection only; the rest of the owner share is unused. */
function dockProps(store: ReturnType<typeof createSnapshotStore<{ todos: readonly TodoItem[] }>>): TodoDockProps { function dockProps(store: ReturnType<typeof createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>>): TodoDockProps {
return { useSession: bindSnapshotSelector(store) } as unknown as TodoDockProps const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
return { useProjection } as unknown as TodoDockProps
} }
describe('TodoDock', () => { describe('TodoDock', () => {
it('selects the plan off the session snapshot and follows later writes', () => { it('reads the host-computed todos projection and follows pushed updates', () => {
const store = createSnapshotStore<{ todos: readonly TodoItem[] }>({ todos: [] }) const store = createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>({ value: undefined })
render(<TodoDock {...dockProps(store)} />) render(<TodoDock {...dockProps(store)} />)
// Capability absent (no baseline/frame yet) renders nothing.
expect(screen.queryByTestId('todo-panel')).toBeNull() expect(screen.queryByTestId('todo-panel')).toBeNull()
act(() => { store.set({ todos: LIST }) }) act(() => { store.set({ value: LIST }) })
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy() expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
// A rollback to the empty list retires the strip (the panel owns no data). // The pre-first-write whole value (null) retires the strip (the panel owns no data).
act(() => { store.set({ todos: [] }) }) act(() => { store.set({ value: null }) })
expect(screen.queryByTestId('todo-panel')).toBeNull() expect(screen.queryByTestId('todo-panel')).toBeNull()
}) })

View File

@@ -23,6 +23,9 @@
{ {
"path": "../runtime" "path": "../runtime"
}, },
{
"path": "../../session-projection/session-projection"
},
{ {
"path": "../ui-slash" "path": "../ui-slash"
}, },