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

View File

@@ -33,7 +33,7 @@ export type {
export type {
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
// PendingWait is a value export: tests construct fixture waits directly.
export { PendingWait } from './sessions/pending.ts'

View File

@@ -4,9 +4,12 @@
// string here (narrow to real brands when convenient).
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 { PendingInteraction } from './pending.ts'
export type { TodoItem }
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
export type AssistantBlock =
@@ -174,4 +177,6 @@ export interface ConversationSnapshot {
loadingOlder: boolean
promptError: PromptError | null
lastAgentError: string | null
/** Latest `todo/write` whole-list snapshot in the window (last write wins); empty = no plan. */
todos: readonly TodoItem[]
}

View File

@@ -4,7 +4,7 @@
// subscribe/getSnapshot.
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 {
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, ToolEventView,
@@ -65,6 +65,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private frozenRev = 0
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 removed = false
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++
return
}
case 'todo/write': {
this.todos = event.data.todos
return
}
case 'turn/end': {
// 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.
@@ -495,6 +501,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.callsRev++
this.frozenNodes = []
this.frozenRev++
this.todos = []
for (let i = 0; i < this.events.length; 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. */
@@ -542,6 +549,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
loadingOlder: this.loadingOlder,
promptError: this.promptError,
lastAgentError: this.lastAgentError,
todos: this.todos,
}
}
}

View File

@@ -30,6 +30,8 @@ export const ev = {
at(seq, { type: 'step/end', data: { turn, step } }),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
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. */

View File

@@ -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 () => {
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')]