Skip to content

Commit 4c4dd25

Browse files
committed
fix: preserve tool output and session history
1 parent bd0deee commit 4c4dd25

74 files changed

Lines changed: 2440 additions & 797 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/clear-ravens-wave.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Fix context usage updates in interactive clients.

‎.changeset/kind-turtles-grow.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Fix session history after steering or undoing a turn.

‎.changeset/tidy-spiders-fix.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Fix loss of large tool outputs in long conversations.

‎apps/pythinker-code/src/tui/controllers/session-event-handler.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -721,9 +721,15 @@ export class SessionEventHandler {
721721
this.host.state.appState.dynamicWorkflowMode &&
722722
this.host.state.dynamicWorkflowModeEntry === 'task';
723723
const patch: Partial<AppState> = {};
724-
if (event.contextUsage !== undefined) patch.contextUsage = event.contextUsage;
725724
if (event.contextTokens !== undefined) patch.contextTokens = event.contextTokens;
726725
if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens;
726+
if (event.contextUsage !== undefined) {
727+
patch.contextUsage = event.contextUsage;
728+
} else if (event.contextTokens !== undefined || event.maxContextTokens !== undefined) {
729+
const tokens = patch.contextTokens ?? this.host.state.appState.contextTokens;
730+
const max = patch.maxContextTokens ?? this.host.state.appState.maxContextTokens;
731+
patch.contextUsage = max > 0 ? tokens / max : 0;
732+
}
727733
if (event.planMode !== undefined) patch.planMode = event.planMode;
728734
if (event.dynamicWorkflowMode !== undefined) patch.dynamicWorkflowMode = event.dynamicWorkflowMode;
729735
if (event.towerMode !== undefined) patch.towerMode = event.towerMode;

‎apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5041,6 +5041,86 @@ command = "vim"
50415041
expect(stripSgr(renderTranscript(driver))).toContain('LLM not set');
50425042
});
50435043

5044+
it('recomputes context usage when a status update carries context tokens without it', async () => {
5045+
const { driver } = await makeDriver();
5046+
driver.state.appState.contextTokens = 0;
5047+
driver.state.appState.maxContextTokens = 1_000_000;
5048+
driver.state.appState.contextUsage = 0.74;
5049+
5050+
driver.sessionEventHandler.handleEvent(
5051+
{
5052+
type: 'agent.status.updated',
5053+
agentId: 'main',
5054+
sessionId: 'ses-1',
5055+
contextTokens: 180_000,
5056+
} as Event,
5057+
vi.fn(),
5058+
);
5059+
5060+
expect(driver.state.appState.contextTokens).toBe(180_000);
5061+
expect(driver.state.appState.contextUsage).toBeCloseTo(0.18);
5062+
});
5063+
5064+
it('recomputes context usage when a status update carries max context tokens without it', async () => {
5065+
const { driver } = await makeDriver();
5066+
driver.state.appState.contextTokens = 180_000;
5067+
driver.state.appState.maxContextTokens = 256_000;
5068+
driver.state.appState.contextUsage = 180_000 / 256_000;
5069+
5070+
driver.sessionEventHandler.handleEvent(
5071+
{
5072+
type: 'agent.status.updated',
5073+
agentId: 'main',
5074+
sessionId: 'ses-1',
5075+
maxContextTokens: 1_000_000,
5076+
} as Event,
5077+
vi.fn(),
5078+
);
5079+
5080+
expect(driver.state.appState.maxContextTokens).toBe(1_000_000);
5081+
expect(driver.state.appState.contextUsage).toBeCloseTo(0.18);
5082+
});
5083+
5084+
it('keeps an explicit context usage from status updates', async () => {
5085+
const { driver } = await makeDriver();
5086+
driver.state.appState.contextTokens = 100;
5087+
driver.state.appState.maxContextTokens = 1_000_000;
5088+
driver.state.appState.contextUsage = 0;
5089+
5090+
driver.sessionEventHandler.handleEvent(
5091+
{
5092+
type: 'agent.status.updated',
5093+
agentId: 'main',
5094+
sessionId: 'ses-1',
5095+
contextTokens: 180_000,
5096+
maxContextTokens: 1_000_000,
5097+
contextUsage: 0.42,
5098+
} as Event,
5099+
vi.fn(),
5100+
);
5101+
5102+
expect(driver.state.appState.contextUsage).toBe(0.42);
5103+
});
5104+
5105+
it('zeroes context usage when no context window is known', async () => {
5106+
const { driver } = await makeDriver();
5107+
driver.state.appState.contextTokens = 180_000;
5108+
driver.state.appState.maxContextTokens = 0;
5109+
driver.state.appState.contextUsage = 0.74;
5110+
5111+
driver.sessionEventHandler.handleEvent(
5112+
{
5113+
type: 'agent.status.updated',
5114+
agentId: 'main',
5115+
sessionId: 'ses-1',
5116+
contextTokens: 190_000,
5117+
} as Event,
5118+
vi.fn(),
5119+
);
5120+
5121+
expect(driver.state.appState.contextUsage).toBe(0);
5122+
});
5123+
50445124
it('applies the effective thinking effort from status updates', async () => {
50455125
const { driver } = await makeDriver();
50465126

‎apps/vscode/src/runtime/event-adapter.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,8 @@ function mapStatusUpdate(
367367
sdkEvent: Extract<Event, { type: 'agent.status.updated' }>,
368368
): MappedLegacyWireEvent {
369369
const payload: StatusUpdate = {};
370-
if (sdkEvent.contextUsage !== undefined) payload.context_usage = sdkEvent.contextUsage;
370+
const contextUsage = contextUsageRatio(sdkEvent);
371+
if (contextUsage !== undefined) payload.context_usage = contextUsage;
371372
if (sdkEvent.planMode !== undefined) payload.plan_mode = sdkEvent.planMode;
372373
const thinkingLevel = (sdkEvent as any).thinkingLevel ?? (sdkEvent as any).thinkingEffort;
373374
if (thinkingLevel !== undefined) payload.thinking_effort = thinkingLevel;
@@ -419,6 +420,22 @@ function mapSubagentStatus(
419420
return { state, event: { type: 'SubagentStatus', payload } };
420421
}
421422

423+
function contextUsageRatio(
424+
sdkEvent: Extract<Event, { type: 'agent.status.updated' }>,
425+
): number | undefined {
426+
if (sdkEvent.contextUsage !== undefined) return sdkEvent.contextUsage;
427+
const { contextTokens, maxContextTokens } = sdkEvent;
428+
if (
429+
typeof contextTokens !== 'number' ||
430+
typeof maxContextTokens !== 'number' ||
431+
!Number.isFinite(contextTokens) ||
432+
!Number.isFinite(maxContextTokens)
433+
) {
434+
return undefined;
435+
}
436+
return maxContextTokens > 0 ? contextTokens / maxContextTokens : undefined;
437+
}
438+
422439
function usageDelta(current: AdapterTokenUsage, previous: AdapterTokenUsage | undefined): TokenUsage {
423440
return {
424441
input_other: delta(current.inputOther, previous?.inputOther),

‎apps/vscode/src/runtime/session-runtime.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ export class SessionRuntime {
172172
model: status.model,
173173
thinking_effort: status.thinkingEffort,
174174
plan_mode: status.planMode,
175+
context_usage: status.contextUsage,
175176
permission: status.permission,
176177
},
177178
_sessionId: this.id,

‎apps/vscode/test/event-adapter.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,50 @@ describe('event adapter (projects SDK events into the legacy Webview contract)',
295295
});
296296
});
297297

298+
it('derives context usage from the v2 context token pair', () => {
299+
const result = adaptSdkEvent(createEventAdapterState(), {
300+
type: 'agent.status.updated',
301+
sessionId: 'session-1',
302+
agentId: 'main',
303+
contextTokens: 25_600,
304+
maxContextTokens: 256_000,
305+
});
306+
307+
expect(result.event).toEqual({
308+
type: 'StatusUpdate',
309+
payload: { context_usage: 0.1 },
310+
_sessionId: 'session-1',
311+
});
312+
});
313+
314+
it('preserves an explicit context usage over the context token pair', () => {
315+
const result = adaptSdkEvent(createEventAdapterState(), {
316+
type: 'agent.status.updated',
317+
sessionId: 'session-1',
318+
agentId: 'main',
319+
contextUsage: 0,
320+
contextTokens: 25_600,
321+
maxContextTokens: 256_000,
322+
});
323+
324+
expect(result.event).toMatchObject({
325+
type: 'StatusUpdate',
326+
payload: { context_usage: 0 },
327+
});
328+
});
329+
330+
it('does not derive a ratio from an invalid context capacity', () => {
331+
const result = adaptSdkEvent(createEventAdapterState(), {
332+
type: 'agent.status.updated',
333+
sessionId: 'session-1',
334+
agentId: 'main',
335+
contextTokens: 25_600,
336+
maxContextTokens: 0,
337+
});
338+
339+
expect(result.event).toBeUndefined();
340+
});
341+
298342
it('emits only new token usage when SDK status carries cumulative turn usage', () => {
299343
const first = adaptSdkEvent(createEventAdapterState(), {
300344
type: 'agent.status.updated',

‎apps/vscode/test/pythinker-runtime.test.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,13 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => {
428428
type: "StatusUpdate",
429429
// The permission mode rides along: the chat badge is the only place the
430430
// user can see which mode a toggle command just landed on.
431-
payload: { model: "kimi-test", thinking_effort: "max", plan_mode: true, permission: "manual" },
431+
payload: {
432+
model: "kimi-test",
433+
thinking_effort: "max",
434+
plan_mode: true,
435+
permission: "manual",
436+
context_usage: 0,
437+
},
432438
_sessionId: "saved-1",
433439
},
434440
webviewId: "view-1",
@@ -459,7 +465,13 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => {
459465
event: Events.StreamEvent,
460466
data: {
461467
type: "StatusUpdate",
462-
payload: { model: "kimi-test", thinking_effort: "off", plan_mode: false, permission: "yolo" },
468+
payload: {
469+
model: "kimi-test",
470+
thinking_effort: "off",
471+
plan_mode: false,
472+
permission: "yolo",
473+
context_usage: 0,
474+
},
463475
_sessionId: "saved-1",
464476
},
465477
webviewId: "view-1",

‎apps/vscode/test/session-runtime.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,24 @@ function turnEnded(
218218
}
219219

220220
describe("session runtime (adapts one SDK session for subscribed Webviews)", () => {
221+
it("announces the current context usage to a subscribed Webview", async () => {
222+
const { runtime, broadcasts } = createRuntime();
223+
224+
await runtime.announceStatus("view-1");
225+
226+
expect(streamData(broadcasts)).toContainEqual({
227+
type: "StatusUpdate",
228+
payload: {
229+
model: undefined,
230+
thinking_effort: "off",
231+
plan_mode: false,
232+
permission: "manual",
233+
context_usage: 0,
234+
},
235+
_sessionId: "session-1",
236+
});
237+
});
238+
221239
it("renders a host-only command without making it a forkable core turn", () => {
222240
const { runtime, broadcasts } = createRuntime();
223241

0 commit comments

Comments
 (0)