Skip to content

Commit ffe25ac

Browse files
committed
Seed turnsUsed and mcpServers when resuming a session
Resuming a session unconditionally reset the run sink's turn counter to zero and connectedMcpServers to empty, both in memory and by immediately persisting those zeroed values to run.json before the reactor even started. resolveResumeSeed folds a picked session's run.json into a single concrete seed once, at the resume boundary, so createRunSink, connectedMcpServers, and the immediate post-resume saveState all read a trusted value instead of each repeating its own `?? 0` / `?? []` default.
1 parent cc1f635 commit ffe25ac

5 files changed

Lines changed: 104 additions & 7 deletions

File tree

src/session/hooks.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ export type TurnContextCollectorOptions = {
174174
// standing copy of recent history, so callers with nothing to hand it to
175175
// (no lifecycle hook) can opt out of retaining it.
176176
retainHistory?: boolean;
177+
// Resuming a session should continue the persisted run.json turn count
178+
// rather than restart it at zero.
179+
initialTurnCount?: number;
177180
};
178181

179182
export function createTurnContextCollector(
@@ -193,7 +196,7 @@ export function createTurnContextCollector(
193196
} {
194197
const retainHistory = options.retainHistory ?? true;
195198
const turns: TurnContext[] = [];
196-
let turnCount = 0;
199+
let turnCount = options.initialTurnCount ?? 0;
197200
let pending: PendingTurn | null = null;
198201
let cycleStartedAt = now();
199202
let tokenUsage: TokenUsage = { ...emptyUsage };

src/session/run-sink.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,24 @@ describe("createRunSink", () => {
7777
expect(runSink.getTokenUsage()).toEqual({ input: 1, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 });
7878
});
7979

80+
test("seeds the turn count from a resumed session's prior turnsUsed", () => {
81+
const runSink = createRunSink({
82+
emitter: new EventEmitter(),
83+
hookManager: stubHookManager([]),
84+
initialTurnCount: 7,
85+
});
86+
87+
expect(runSink.getTurnCount()).toBe(7);
88+
89+
runSink.sink(event("inference.done", {
90+
turn: { role: "assistant", content: [], model: "test", timestamp: 0 },
91+
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 },
92+
source: { provider: "test", model: "test" },
93+
}));
94+
95+
expect(runSink.getTurnCount()).toBe(8);
96+
});
97+
8098
test("getLastTurnUsage reports the latest turn alone, not the running sum", () => {
8199
const runSink = createRunSink({
82100
emitter: new EventEmitter(),

src/session/run-sink.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export type RunSinkArgs = {
1919
// turn actually ran against, so consumers report per-turn provider/model
2020
// even if the live selection changed mid-run.
2121
onTurnComplete?: (ctx: import("./hooks.js").TurnContext) => void;
22+
// Continues a resumed session's persisted run.json turn count instead of
23+
// restarting the collector at zero.
24+
initialTurnCount?: number;
2225
};
2326

2427
export type RunSink = {
@@ -66,7 +69,7 @@ export function resolveExecRunStatus(args: {
6669
}
6770

6871
export function createRunSink(args: RunSinkArgs): RunSink {
69-
const { emitter, hookManager, onTurnComplete } = args;
72+
const { emitter, hookManager, onTurnComplete, initialTurnCount } = args;
7073

7174
function hasConfiguredHooks(): boolean {
7275
return hookManager.getStatuses().length > 0;
@@ -82,15 +85,19 @@ export function createRunSink(args: RunSinkArgs): RunSink {
8285
onTurnComplete?.(ctx);
8386
};
8487

85-
function createCollector(): TurnCollector {
88+
// The initial seed only applies to the run's first collector (a resumed
89+
// session's prior turnsUsed); a later reset() starts a fresh sub-session
90+
// and should count from zero, not re-seed.
91+
function createCollector(seedTurnCount?: number): TurnCollector {
8692
return createTurnContextCollector(handleTurn, Date.now, {
8793
retainHistory: hasConfiguredHooks(),
94+
...(seedTurnCount !== undefined ? { initialTurnCount: seedTurnCount } : {}),
8895
});
8996
}
9097

9198
let runCompleted = false;
9299
let runError: string | undefined;
93-
let turnCollector = createCollector();
100+
let turnCollector = createCollector(initialTurnCount);
94101
// Always-on local PerfTrace: not gated by lifecycle hooks.
95102
let perfObserver = createPerfReactorObserver();
96103

src/tui/resume-seed.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { resolveResumeSeed } from "./runner.js";
3+
import type { RunState } from "../session/state.js";
4+
5+
function pickedState(overrides: Partial<RunState>): RunState {
6+
return {
7+
status: "running",
8+
turnsUsed: 0,
9+
task: "task",
10+
startedAt: 1,
11+
...overrides,
12+
};
13+
}
14+
15+
describe("resolveResumeSeed", () => {
16+
test("a fresh (non-resumed) run seeds zero turns and no servers", () => {
17+
expect(resolveResumeSeed(null)).toEqual({ turnsUsed: 0, mcpServers: [] });
18+
});
19+
20+
test("carries forward a resumed session's non-zero turnsUsed and non-empty mcpServers", () => {
21+
const seed = resolveResumeSeed(
22+
pickedState({
23+
turnsUsed: 12,
24+
mcpServers: [{ name: "filesystem", toolCount: 5 }, { name: "search", toolCount: 2 }],
25+
}),
26+
);
27+
28+
expect(seed.turnsUsed).toBe(12);
29+
expect(seed.mcpServers).toEqual([
30+
{ name: "filesystem", toolCount: 5 },
31+
{ name: "search", toolCount: 2 },
32+
]);
33+
});
34+
35+
test("defaults mcpServers to empty when a resumed record predates that field", () => {
36+
const seed = resolveResumeSeed(pickedState({ turnsUsed: 3 }));
37+
38+
expect(seed.turnsUsed).toBe(3);
39+
expect(seed.mcpServers).toEqual([]);
40+
});
41+
});

src/tui/runner.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,29 @@ export function resumeTranscriptLoadErrorBlock(err: unknown): {
211211
return { type: "error", message: `Could not load prior session transcript: ${message}` };
212212
}
213213

214+
export type ResumeSeed = {
215+
turnsUsed: number;
216+
mcpServers: ConnectedMcpServer[];
217+
};
218+
219+
const FRESH_RESUME_SEED: ResumeSeed = { turnsUsed: 0, mcpServers: [] };
220+
221+
/**
222+
* Fold a resumed session's run.json into a concrete seed once, at the
223+
* resume boundary, so every downstream reader (the run sink, the
224+
* connected-servers list, the immediate post-resume saveState) trusts a
225+
* fully-populated value instead of each repeating its own `?? 0` / `?? []`
226+
* default. A fresh (non-resumed) run gets the same shape via
227+
* FRESH_RESUME_SEED, so callers never branch on "was this a resume."
228+
*/
229+
export function resolveResumeSeed(pickedState: RunState | null): ResumeSeed {
230+
if (pickedState === null) return FRESH_RESUME_SEED;
231+
return {
232+
turnsUsed: pickedState.turnsUsed,
233+
mcpServers: pickedState.mcpServers ?? [],
234+
};
235+
}
236+
214237
const GRANT_SCOPE_LABEL: Record<GrantScope, string> = {
215238
session: "This session",
216239
project: "This project",
@@ -406,13 +429,17 @@ export async function runTUI(initialConfig: Config): Promise<number> {
406429
let resumeSkipInitialTask = config.skipInitialTask === true;
407430
let startedAt = Date.now();
408431
let runTaskTitle = config.task;
432+
// Resolved once at the resume boundary so turnsUsed/mcpServers reads
433+
// downstream never repeat their own omission-handling default.
434+
let resumeSeed: ResumeSeed = FRESH_RESUME_SEED;
409435

410436
if (config.resumePicker) {
411437
const picked = await pickSession(config.cwd, { includeCompleted: config.force });
412438
if (picked === null) return 0;
413439
sessionId = picked.sessionId;
414440
resumeSkipInitialTask = true;
415441
const pickedState = await loadState(config.cwd, sessionId);
442+
resumeSeed = resolveResumeSeed(pickedState);
416443
if (pickedState !== null) {
417444
startedAt = pickedState.startedAt;
418445
runTaskTitle = pickedState.task;
@@ -435,11 +462,11 @@ export async function runTUI(initialConfig: Config): Promise<number> {
435462
// run.json at all.
436463
await saveState(config.cwd, sessionId, {
437464
status: "running",
438-
turnsUsed: 0,
465+
turnsUsed: resumeSeed.turnsUsed,
439466
task: runTaskTitle.trim().length > 0 ? runTaskTitle.trim() : "(conversation)",
440467
startedAt,
441468
model: `${config.providerName}:${config.model}`,
442-
mcpServers: [],
469+
mcpServers: resumeSeed.mcpServers,
443470
});
444471

445472
// Crash guard: if anything from setup onward throws all the way out of
@@ -1313,6 +1340,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13131340
const runSink = createRunSink({
13141341
emitter,
13151342
hookManager,
1343+
initialTurnCount: resumeSeed.turnsUsed,
13161344
onTurnComplete: (ctx) => {
13171345
// provider_id is the canonical provider kind, never ctx.source.sourceId:
13181346
// sourceId is the user-typed label from onboarding/settings, and free
@@ -1332,7 +1360,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13321360

13331361
// MCP servers connected so far, keyed by name so a reconnect after a failure
13341362
// replaces rather than duplicates the entry.
1335-
let connectedMcpServers: ConnectedMcpServer[] = [];
1363+
let connectedMcpServers: ConnectedMcpServer[] = resumeSeed.mcpServers;
13361364
// Every configured server's latest state, for the /mcp surface. Unlike
13371365
// `connectedMcpServers` (persisted run metadata) this keeps the ones that
13381366
// failed or are still waiting on authorization.

0 commit comments

Comments
 (0)