Skip to content

Commit c12f3a3

Browse files
committed
fix(resume,status): show prior transcript on resume + honest baseline on context bar
Two related defects in how a session reloads. Resume only seeded pi-agent-core's internal message list (so the model had context) but never re-populated the UI reducer's state.messages. The user dropped back into what looked like an empty chat — only the "↻ Resumed from 3h ago" badge hinted that anything had been loaded. Expose the resumed transcript through the agent bundle and seed the reducer with it so the screen matches the model's actual context. The context-fill bar showed 0% on a fresh session because we only counted in-transcript message chars, ignoring the system prompt and tool schemas — ~3k tokens of static context every turn carries. Add that as a baseline in the fallback estimator so the bar starts at a small honest non-zero and grows visibly with each turn. The accurate turnUsage path is unchanged when the provider does report usage.
1 parent 42053b5 commit c12f3a3

7 files changed

Lines changed: 66 additions & 14 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-cli",
3-
"version": "2.0.0-pre.42",
3+
"version": "2.0.0-pre.43",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",

src/agent/agent.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { homedir } from "node:os";
22
import { isAbsolute, join, resolve } from "node:path";
3-
import { Agent, type AgentEvent } from "@earendil-works/pi-agent-core";
3+
import { Agent, type AgentEvent, type AgentMessage } from "@earendil-works/pi-agent-core";
44
import type { Model } from "@earendil-works/pi-ai";
55
import { defaultOAuthConfig } from "../auth/cli.js";
66
import { CredentialsStore } from "../auth/credentials.js";
@@ -88,6 +88,13 @@ export interface AgentBundle {
8888
* "Resumed from 3h ago · 47 messages". Undefined for fresh sessions.
8989
*/
9090
resumedFrom?: { updatedAt: number; messageCount: number };
91+
/**
92+
* The transcript loaded from a resumed session. Pi-agent-core already
93+
* has these in its internal state — this exposes them so the UI's
94+
* reducer can show the prior conversation on screen, not just in the
95+
* model's context. Empty when starting fresh.
96+
*/
97+
resumedMessages: AgentMessage[];
9198
}
9299

93100
export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
@@ -314,5 +321,6 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
314321
diagnostics,
315322
subscribe,
316323
resumedFrom: resumed ? { updatedAt: resumed.updatedAt, messageCount: resumed.messages.length } : undefined,
324+
resumedMessages: resumed?.messages ?? [],
317325
};
318326
}

src/agent/events.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,28 @@ describe("initialState", () => {
3535
expect(s.usage).toEqual(EMPTY_USAGE);
3636
expect(s.model).toEqual(MODEL);
3737
});
38+
39+
it("seeds messages from a resumed session so the UI shows prior turns", () => {
40+
const prior: AgentMessage[] = [
41+
{ role: "user", content: "from before", timestamp: 1 } as AgentMessage,
42+
{
43+
role: "assistant",
44+
content: [{ type: "text", text: "hi" }],
45+
api: "chat",
46+
provider: "p",
47+
model: "m",
48+
usage: EMPTY_USAGE,
49+
stopReason: "stop",
50+
timestamp: 2,
51+
} as AgentMessage,
52+
];
53+
const s = initialState(MODEL, prior);
54+
expect(s.messages).toHaveLength(2);
55+
expect(s.status).toBe("idle");
56+
// Copy, not reference — caller mutating the source array must not
57+
// reach into our state.
58+
expect(s.messages).not.toBe(prior);
59+
});
3860
});
3961

4062
describe("reducer · user-prompt", () => {

src/agent/events.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ export type Action =
1010
| { type: "error"; message: string }
1111
| { type: "reset" };
1212

13-
export function initialState(model: ChatState["model"]): ChatState {
13+
export function initialState(model: ChatState["model"], messages: AgentMessage[] = []): ChatState {
1414
return {
15-
messages: [],
15+
messages: [...messages],
1616
tools: new Map(),
1717
status: "idle",
1818
usage: EMPTY_USAGE,

src/ui/App.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,10 @@ interface ChatAppProps {
8080
function ChatApp({ bundle, onExit }: ChatAppProps) {
8181
const [state, dispatch] = useReducer(
8282
reducer,
83-
initialState({ provider: bundle.model.provider, id: bundle.model.id, name: bundle.model.name }),
83+
initialState(
84+
{ provider: bundle.model.provider, id: bundle.model.id, name: bundle.model.name },
85+
bundle.resumedMessages,
86+
),
8487
);
8588
const [permRequest, setPermRequest] = useState<PermissionRequest | undefined>(bundle.permissions.current());
8689
const [userQuery, setUserQuery] = useState<UserQuery | undefined>(bundle.userQueries.current());

src/ui/Status.test.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ function baseState(): ChatState {
1616
}
1717

1818
describe("estimateContextTokens", () => {
19-
it("returns 0 for an empty conversation", () => {
20-
expect(estimateContextTokens(baseState())).toBe(0);
19+
it("returns the static-context baseline for an empty conversation", () => {
20+
// The system prompt + tool schemas are always in context even with
21+
// zero messages; the bar reflects that with a small non-zero seed.
22+
expect(estimateContextTokens(baseState())).toBeGreaterThan(0);
23+
expect(estimateContextTokens(baseState())).toBeLessThan(10_000);
2124
});
2225

2326
it("prefers turnUsage.input + cacheRead when the provider reports them", () => {
@@ -52,9 +55,13 @@ describe("estimateContextTokens", () => {
5255
} as AgentMessage,
5356
],
5457
};
55-
// 11 ("hello world") + 400 (x's) = 411 chars; 411 / 4 = ~103 tokens
56-
expect(estimateContextTokens(state)).toBeGreaterThan(95);
57-
expect(estimateContextTokens(state)).toBeLessThan(115);
58+
// 11 ("hello world") + 400 (x's) = 411 chars; 411 / 4 = ~103 tokens.
59+
// Plus the static-context baseline. Verify the delta from the baseline
60+
// is in the right neighborhood rather than asserting an exact total.
61+
const baseline = estimateContextTokens(baseState());
62+
const delta = estimateContextTokens(state) - baseline;
63+
expect(delta).toBeGreaterThan(95);
64+
expect(delta).toBeLessThan(115);
5865
});
5966

6067
it("falls back when turnUsage is present but reports zero (proxy stripped usage)", () => {
@@ -70,7 +77,9 @@ describe("estimateContextTokens", () => {
7077
},
7178
messages: [{ role: "user", content: "x".repeat(400), timestamp: 1 } as AgentMessage],
7279
};
73-
expect(estimateContextTokens(state)).toBeGreaterThan(95);
80+
// Above the baseline by ~100 tokens (the 400-char message).
81+
const baseline = estimateContextTokens({ ...baseState(), turnUsage: state.turnUsage });
82+
expect(estimateContextTokens(state) - baseline).toBeGreaterThan(95);
7483
});
7584

7685
it("includes streaming content so the bar fills mid-turn, not just at message_end", () => {
@@ -133,7 +142,7 @@ describe("estimateContextTokens", () => {
133142
} as unknown as AgentMessage,
134143
],
135144
};
136-
// 800 / 4 = 200 tokens
137-
expect(estimateContextTokens(state)).toBe(200);
145+
// 800 / 4 = 200 tokens above the baseline.
146+
expect(estimateContextTokens(state) - estimateContextTokens(baseState())).toBe(200);
138147
});
139148
});

src/ui/Status.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ import { Throbber } from "./Throbber.js";
99
* fallback when the provider doesn't return usage info on message_end. */
1010
const CHARS_PER_TOKEN = 4;
1111

12+
/**
13+
* Approximate static-context tokens the model sees on every turn but
14+
* that aren't in state.messages: system prompt, MEMORY.md addendum, and
15+
* the tool-schema definitions. Used to seed the context bar so it reads
16+
* 1-2% on a fresh session instead of misleadingly showing 0%. The real
17+
* input + cacheRead from turnUsage always wins when available — this is
18+
* only the fallback's baseline.
19+
*/
20+
const STATIC_CONTEXT_TOKENS = 3000;
21+
1222
interface StatusProps {
1323
state: ChatState;
1424
cwd?: string;
@@ -217,7 +227,7 @@ export function estimateContextTokens(state: ChatState): number {
217227
let chars = 0;
218228
for (const msg of state.messages) chars += messageChars(msg);
219229
if (state.streaming) chars += messageChars(state.streaming);
220-
return Math.round(chars / CHARS_PER_TOKEN);
230+
return STATIC_CONTEXT_TOKENS + Math.round(chars / CHARS_PER_TOKEN);
221231
}
222232

223233
function messageChars(message: AgentMessage): number {

0 commit comments

Comments
 (0)