feat: implement todo plan clearance on turn start

This commit is contained in:
07akioni
2026-07-28 17:55:47 +08:00
parent d85fe78f7d
commit 9e94082f4d
28 changed files with 211 additions and 61 deletions

View File

@@ -99,8 +99,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
private frozenRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
/** Current whole-list todo/write projection: each tail history response replaces it (an omitted
* field is the authoritative empty list) and every live write overwrites it. */
/** Current plan strip: the latest `todo/write` not followed by a later `turn/start`. Tail history
* responses replace it (an omitted field is the authoritative empty list); live writes overwrite
* it; each new `turn/start` clears it so a finished plan does not linger into the next turn. */
private todos: readonly TodoItem[] = []
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
@@ -511,13 +512,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
// Session-level projection from the tail page (full-log latest todo/write,
// independent of the window); an in-window write below re-derives the same
// value, and later live events keep overwriting it. Every caller here is a
// tail request (no beforeSeq), which the host answers with the projection
// or omits it only when the full log holds no todo/write — so an absent
// field is the authoritative empty list, not a missing carrier. Assigning
// it clears a plan the log never kept (a write lost to a host crash).
// Tail-page projection (full-log current plan, independent of the window);
// an in-window write or turn/start below re-derives the same value, and later
// live events keep overwriting or clearing it. Every caller here is a tail
// request (no beforeSeq), which the host answers with the projection or omits
// it when the log has no standing plan — so an absent field is the
// authoritative empty list, not a missing carrier. Assigning it clears a plan
// the log never kept (a write lost to a host crash).
this.todos = todos ?? []
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
this.rebuildDerivedFromWindow()
@@ -693,6 +694,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.todos = event.data.todos
return
}
case 'turn/start': {
// The plan strip is turn-scoped for display: a new turn retires the previous
// list until the model writes again. turn/end keeps it visible so the finished
// checklist remains while the user reads the answer.
this.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.
@@ -738,10 +746,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text).
* todos is deliberately NOT reset: it is session-level (seeded by the tail page's full-log
* projection, not derivable from an arbitrary window). The window always extends to the log
* tail, so an in-window todo/write can only overwrite it with the same latest value. */
* The standing plan starts empty for the sweep; when the window itself never determines it
* (no todo/write and no turn/start — the write still precedes the page), the tail-page seed is
* restored. A contiguous tail window that contains a turn/start after the standing write will
* determine empty, matching the host projection. */
private rebuildDerivedFromWindow(): void {
const seededTodos = this.todos
this.partial = null
this.openCalls.clear()
this.callsRev++
@@ -749,11 +759,16 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.frozenRev++
this.codeDispatches = new Map()
this.dispatchesRev++
this.todos = []
let todosDetermined = false
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. */
if (event !== undefined) this.applyEventSideEffects(event, this.views[i])
if (event === undefined) continue
if (event.type === 'todo/write' || event.type === 'turn/start') todosDetermined = true
this.applyEventSideEffects(event, this.views[i])
}
if (!todosDetermined) this.todos = seededTodos
}
private windowTailSeq(): number | null {

View File

@@ -177,23 +177,62 @@ describe('live event path', () => {
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.
// Cold open: same-turn page with no todo/write and no turn/start (a later turn/start
// would mean the host projection is empty). The standing plan rides the response.
const tailPage = [
ev.user(100, '问'),
ev.stepStart(101, 9),
ev.assistant(102, 9, '答'),
ev.stepEnd(103, 9),
ev.turnEnd(104, 9),
]
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list)
api.onHistory = () => histResponse(tailPage, 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)
// Older same-turn slice (still no determiner) must keep the seeded plan.
api.onHistory = () => histResponse([
ev.user(95, '旧问'),
ev.stepStart(96, 9),
ev.assistant(97, 9, '旧答'),
ev.stepEnd(98, 9),
ev.turnEnd(99, 9),
], false)
await session.loadOlder()
expect(session.getSnapshot().todos).toEqual(list)
// A later live write still overrides the seeded projection.
// Contiguous live write (tail is 104) still overrides the seeded projection.
session.handleMuxEnvelope('r' as never, {
type: 'session/event', sessionId: SID,
event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]),
event: ev.todoWrite(105, [{ content: '新计划', status: 'pending' as const }]),
})
expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }])
})
it('clears the plan on turn/start (live and on window replay)', async () => {
const list = [{ content: '上一轮计划', status: 'completed' as const }]
const { session } = await opened()
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
feed(ev.todoWrite(6, list))
expect(session.getSnapshot().todos).toEqual(list)
// turn/end keeps the finished checklist visible while the user reads.
feed(ev.turnEnd(7, 0))
expect(session.getSnapshot().todos).toEqual(list)
feed(ev.turnStart(8, 1))
expect(session.getSnapshot().todos).toEqual([])
// Replay converges: a turn/start after the latest write yields an empty plan.
const replayed = makeSession()
replayed.api.onHistory = () => histResponse([
...plainTurn(0, 0, 'a', 'b'),
ev.todoWrite(6, list),
ev.turnStart(7, 1),
ev.user(8, '下一问'),
])
await replayed.session.open()
expect(replayed.session.getSnapshot().todos).toEqual([])
})
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')]
@@ -211,11 +250,18 @@ describe('live event path', () => {
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.
// Missed todo/write still stands (no later turn/start); the repulled tail page
// omits that write and any determiner, so the response projection is the 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') })
const tailPage = [
ev.user(8, 'c'),
ev.stepStart(9, 1),
ev.assistant(10, 1, 'd'),
ev.stepEnd(11, 1),
ev.turnEnd(12, 1),
]
api.onHistory = () => histResponse(tailPage, false, current)
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(10, 1, 'd') })
await vi.waitFor(() => {
expect(api.callsOf('session.history').length).toBe(2)
})