Skip to content

Commit a796ab5

Browse files
committed
Keep resumed sessions consistent about their task list
Two surfaces disagreed with each other after a resume. The transcript dropped its manage_tasks rows in favor of one aggregated task block, but only when the rows were stripped alongside submit_plan's — without that, every call reappeared as its own row beneath the summary. And the chrome panel reads the director's in-memory list, which a freshly constructed director leaves empty until the model happens to call manage_tasks again, so a resumed session showed tasks in its scrollback and none in its panel. Restoring the derived list into the director closes both: one source of task state, painted the same way everywhere.
1 parent 1134a43 commit a796ab5

5 files changed

Lines changed: 55 additions & 3 deletions

File tree

‎src/agent/director.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,15 @@ class ChatDirectorImpl extends DefaultDirector {
404404
return [...this.tasks];
405405
}
406406

407+
// A resumed session's task list lives in the transcript, not in the freshly
408+
// constructed director. Without this the chrome panel would read an empty
409+
// list until the model happened to call manage_tasks again, disagreeing
410+
// with the task block already painted in the transcript.
411+
restoreTasks(tasks: Task[]): void {
412+
this.tasks = [...tasks];
413+
this.onTasksChange?.(this.tasks);
414+
}
415+
407416
// The status bar's context meter falls back to this when a provider omits
408417
// or zeroes usage on the latest turn — a local lower-then-corrected bound
409418
// beats displaying a number the provider never actually reported.
@@ -828,5 +837,6 @@ export interface ChatDirector extends ReactorDirector {
828837
setGoalGovernor(goal: GoalGovernor | undefined): void;
829838
getGoalGovernor(): GoalGovernor | undefined;
830839
getTasks(): Task[];
840+
restoreTasks(tasks: Task[]): void;
831841
getContextEstimate(): { tokens: number; isEstimate: boolean };
832842
}

‎src/director.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,19 @@ describe("onTasksChange live wiring", () => {
791791
expect(updates[0]).toEqual([{ id: "t1", title: "work", status: "doing" }]);
792792
});
793793

794+
test("restoreTasks seeds a resumed session's task list and notifies the consumer", () => {
795+
const updates: Array<Array<{ id: string; title: string; status: string }>> = [];
796+
const director = createChatDirector("base", [], {
797+
onTasksChange: (tasks) => updates.push(tasks),
798+
});
799+
800+
const restored = [{ id: "t1", title: "from transcript", status: "doing" as const }];
801+
director.restoreTasks(restored);
802+
803+
expect(director.getTasks()).toEqual(restored);
804+
expect(updates).toEqual([restored]);
805+
});
806+
794807
test("onTasksChange is not invoked for tool calls that are not manage_tasks", async () => {
795808
const updates: unknown[] = [];
796809
const director = createChatDirector("base", [], {

‎src/tui/runner.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2272,7 +2272,10 @@ export async function runTUI(initialConfig: Config): Promise<number> {
22722272
.then((turns) => {
22732273
const blocks = turnsToContentBlocks(turns, { maxBlocks: RESUME_TRANSCRIPT_BLOCK_LIMIT });
22742274
const tasks = hydrateTasksFromTurns(turns);
2275-
if (tasks.length > 0) blocks.unshift({ type: "tasks", tasks });
2275+
if (tasks.length > 0) {
2276+
blocks.unshift({ type: "tasks", tasks });
2277+
directorHolder.instance?.restoreTasks(tasks);
2278+
}
22762279
if (blocks.length > 0) emitter.emit("history.hydrate", blocks);
22772280
})
22782281
.catch((err: unknown) => {

‎src/tui/turns-to-blocks.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ describe("turnsToContentBlocks no longer derives tasks", () => {
3434
const blocks = turnsToContentBlocks(turns);
3535
expect(blocks.some((b) => b.type === "tasks")).toBe(false);
3636
});
37+
38+
// The aggregated task block is unshifted separately on resume, so leaving
39+
// the raw rows in would show every manage_tasks call twice over.
40+
test("manage_tasks call and result rows are stripped from the resumed transcript", () => {
41+
const turns = [
42+
manageTasksTurn("m1", "todo"),
43+
toolResultTurn("m1", false),
44+
manageTasksTurn("m2", "doing"),
45+
toolResultTurn("m2", false),
46+
];
47+
const blocks = turnsToContentBlocks(turns);
48+
expect(blocks.some((b) => b.type === "tool_call" && b.name === "manage_tasks")).toBe(false);
49+
expect(blocks.some((b) => b.type === "tool_result" && b.name === "manage_tasks")).toBe(false);
50+
});
3751
});
3852

3953
describe("hydrateTasksFromTurns", () => {

‎src/tui/turns-to-blocks.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,12 @@ function upsertResumeBlock(
9090
return next;
9191
}
9292

93-
/** Mirror live-stream tool.done handling for plan (submit_plan) when hydrating a session. */
93+
/**
94+
* Mirror live-stream tool.done handling when hydrating a session: submit_plan
95+
* collapses into a single plan block, and manage_tasks rows are dropped
96+
* entirely because the resumed task list is rendered as one aggregated block
97+
* (see hydrateTasksFromTurns) rather than as one row per call.
98+
*/
9499
function finalizeResumeToolBlocks(blocks: ContentBlockData[]): ContentBlockData[] {
95100
const callIdToCallIndex = new Map<string, number>();
96101
for (let i = 0; i < blocks.length; i += 1) {
@@ -109,7 +114,14 @@ function finalizeResumeToolBlocks(blocks: ContentBlockData[]): ContentBlockData[
109114
const callIndex = callIdToCallIndex.get(result.callId);
110115
if (callIndex === undefined) continue;
111116
const call = blocks[callIndex];
112-
if (call?.type !== "tool_call" || call.name !== "submit_plan") continue;
117+
if (call?.type !== "tool_call") continue;
118+
119+
if (call.name === "manage_tasks") {
120+
indicesToRemove.add(callIndex);
121+
indicesToRemove.add(i);
122+
continue;
123+
}
124+
if (call.name !== "submit_plan") continue;
113125

114126
indicesToRemove.add(callIndex);
115127
indicesToRemove.add(i);

0 commit comments

Comments
 (0)