feat: implement todo plan clearance on turn start
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user