feat(gui): fold todo/write into ConversationSnapshot.todos

Session consumes the todo/write session event as a per-event side effect
(last write wins), rebuilds it on window replay/paging/resync, and exposes
snapshot.todos. TodoItem re-exported through the runtime surface.
This commit is contained in:
Chinesezjc
2026-07-22 13:02:44 +08:00
parent b97b1b4a3f
commit a0c269b0fb
7 changed files with 36 additions and 4 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ export type {
export type { export type {
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot, AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
RunningToolCall, SteeringMessageNode, RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts' } from './sessions/conversation.ts'
// PendingWait is a value export: tests construct fixture waits directly. // PendingWait is a value export: tests construct fixture waits directly.
export { PendingWait } from './sessions/pending.ts' export { PendingWait } from './sessions/pending.ts'
@@ -4,9 +4,12 @@
// string here (narrow to real brands when convenient). // string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts' import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
/** Assistant content blocks sorted by what the UI cares about /** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */ * (text body / collapsible reasoning / tool-call card head / other fallback). */
export type AssistantBlock = export type AssistantBlock =
@@ -174,4 +177,6 @@ export interface ConversationSnapshot {
loadingOlder: boolean loadingOlder: boolean
promptError: PromptError | null promptError: PromptError | null
lastAgentError: string | null lastAgentError: string | null
/** Latest `todo/write` whole-list snapshot in the window (last write wins); empty = no plan. */
todos: readonly TodoItem[]
} }
@@ -4,7 +4,7 @@
// subscribe/getSnapshot. // subscribe/getSnapshot.
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types'
import type { import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, ToolEventView, SessionId, ToolEventView,
@@ -65,6 +65,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private frozenRev = 0 private frozenRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
/** Latest todo/write whole-list snapshot in the window (last write wins on replay). */
private todos: readonly TodoItem[] = []
private running = false private running = false
private removed = false private removed = false
private promptError: PromptError | null = null private promptError: PromptError | null = null
@@ -444,6 +446,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++ if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
return return
} }
case 'todo/write': {
this.todos = event.data.todos
return
}
case 'turn/end': { case 'turn/end': {
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it. // into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
@@ -495,6 +501,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.callsRev++ this.callsRev++
this.frozenNodes = [] this.frozenNodes = []
this.frozenRev++ this.frozenRev++
this.todos = []
for (let i = 0; i < this.events.length; i++) { for (let i = 0; i < this.events.length; i++) {
const event = this.events[i] const event = this.events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
@@ -542,6 +549,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
loadingOlder: this.loadingOlder, loadingOlder: this.loadingOlder,
promptError: this.promptError, promptError: this.promptError,
lastAgentError: this.lastAgentError, lastAgentError: this.lastAgentError,
todos: this.todos,
} }
} }
} }
@@ -30,6 +30,8 @@ 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 } }),
} }
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */ /** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */
@@ -153,6 +153,23 @@ 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('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')]
@@ -25,7 +25,7 @@ function snapshotBase(): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null, pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, todos: [],
} as ConversationSnapshot } as ConversationSnapshot
} }
@@ -29,7 +29,7 @@ function snapshotBase(): ConversationSnapshot {
return { return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null, pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, todos: [],
} as ConversationSnapshot } as ConversationSnapshot
} }