From 8b0727c0e5ff21483b1b939c6810a841145630dd Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 19 Mar 2026 12:44:11 -0400 Subject: [PATCH] fix: accumulate session cost independently of message array (#1423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSessionStats() calculated cost by summing usage from assistant messages in state.messages. After auto-compaction, pre-compaction messages are replaced by a compactionSummary with no usage field — dropping the cost. Fix: Added cumulative accumulators (_cumulativeCost, _cumulativeInputTokens, _cumulativeOutputTokens, _cumulativeToolCalls) that are incremented on every assistant message event, independent of the message array. getSessionStats() now returns max(array-sum, cumulative) to ensure monotonically non-decreasing values. Fixes #1423 --- .../pi-coding-agent/src/core/agent-session.ts | 22 ++++++++++++++----- src/resource-loader.ts | 4 +++- .../gsd/tests/repo-identity-worktree.test.ts | 8 +++---- src/tests/file-watcher.test.ts | 11 ++++++---- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/pi-coding-agent/src/core/agent-session.ts b/packages/pi-coding-agent/src/core/agent-session.ts index 28e27049c..220c68b41 100644 --- a/packages/pi-coding-agent/src/core/agent-session.ts +++ b/packages/pi-coding-agent/src/core/agent-session.ts @@ -246,6 +246,12 @@ export class AgentSession { private _retryPromise: Promise | undefined = undefined; private _retryResolve: (() => void) | undefined = undefined; + // Cumulative session stats — survives compaction (#1423) + private _cumulativeCost = 0; + private _cumulativeInputTokens = 0; + private _cumulativeOutputTokens = 0; + private _cumulativeToolCalls = 0; + // Bash execution state private _bashAbortController: AbortController | undefined = undefined; private _pendingBashMessages: BashExecutionMessage[] = []; @@ -438,7 +444,13 @@ export class AgentSession { if (event.message.role === "assistant") { this._lastAssistantMessage = event.message; + // Accumulate session stats that survive compaction (#1423) const assistantMsg = event.message as AssistantMessage; + this._cumulativeCost += assistantMsg.usage?.cost?.total ?? 0; + this._cumulativeInputTokens += assistantMsg.usage?.input ?? 0; + this._cumulativeOutputTokens += assistantMsg.usage?.output ?? 0; + this._cumulativeToolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length; + if (assistantMsg.stopReason !== "error") { this._overflowRecoveryAttempted = false; } @@ -3221,17 +3233,17 @@ export class AgentSession { sessionId: this.sessionId, userMessages, assistantMessages, - toolCalls, + toolCalls: Math.max(toolCalls, this._cumulativeToolCalls), toolResults, totalMessages: state.messages.length, tokens: { - input: totalInput, - output: totalOutput, + input: Math.max(totalInput, this._cumulativeInputTokens), + output: Math.max(totalOutput, this._cumulativeOutputTokens), cacheRead: totalCacheRead, cacheWrite: totalCacheWrite, - total: totalInput + totalOutput + totalCacheRead + totalCacheWrite, + total: Math.max(totalInput + totalOutput, this._cumulativeInputTokens + this._cumulativeOutputTokens) + totalCacheRead + totalCacheWrite, }, - cost: totalCost, + cost: Math.max(totalCost, this._cumulativeCost), }; } diff --git a/src/resource-loader.ts b/src/resource-loader.ts index d06dd50a7..2e1a2c688 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -19,7 +19,9 @@ import { loadRegistry, readManifestFromEntryPath, isExtensionEnabled, ensureRegi const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const distResources = join(packageRoot, 'dist', 'resources') const srcResources = join(packageRoot, 'src', 'resources') -const resourcesDir = existsSync(distResources) ? distResources : srcResources +const resourcesDir = (existsSync(distResources) && existsSync(join(distResources, 'agents'))) + ? distResources + : srcResources const bundledExtensionsDir = join(resourcesDir, 'extensions') const resourceVersionManifestName = 'managed-resources.json' diff --git a/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts b/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts index 1719fe264..b5b894382 100644 --- a/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts @@ -13,8 +13,8 @@ function run(command: string, cwd: string): string { } async function main(): Promise { - const base = mkdtempSync(join(tmpdir(), "gsd-repo-identity-")); - const stateDir = mkdtempSync(join(tmpdir(), "gsd-state-")); + const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-repo-identity-"))); + const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-"))); try { process.env.GSD_STATE_DIR = stateDir; @@ -38,7 +38,7 @@ async function main(): Promise { assertEq(worktreeState, expectedExternalState, "worktree symlink target matches main repo external state dir"); assertTrue(existsSync(join(worktreePath, ".gsd")), "worktree .gsd exists"); assertTrue(lstatSync(join(worktreePath, ".gsd")).isSymbolicLink(), "worktree .gsd is a symlink"); - assertEq(realpathSync(join(worktreePath, ".gsd")), expectedExternalState, "worktree .gsd symlink resolves to main repo external state dir"); + assertEq(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "worktree .gsd symlink resolves to main repo external state dir"); console.log("\n=== ensureGsdSymlink heals stale worktree symlinks ==="); const staleState = join(stateDir, "projects", "stale-worktree-state"); @@ -47,7 +47,7 @@ async function main(): Promise { symlinkSync(staleState, join(worktreePath, ".gsd"), "junction"); const healedState = ensureGsdSymlink(worktreePath); assertEq(healedState, expectedExternalState, "stale worktree symlink is repaired to canonical external state dir"); - assertEq(realpathSync(join(worktreePath, ".gsd")), expectedExternalState, "healed worktree symlink resolves to canonical external state dir"); + assertEq(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "healed worktree symlink resolves to canonical external state dir"); console.log("\n=== ensureGsdSymlink preserves worktree .gsd directories ==="); rmSync(join(worktreePath, ".gsd"), { recursive: true, force: true }); diff --git a/src/tests/file-watcher.test.ts b/src/tests/file-watcher.test.ts index e8dc7fd00..38040cdc6 100644 --- a/src/tests/file-watcher.test.ts +++ b/src/tests/file-watcher.test.ts @@ -54,10 +54,11 @@ test("settings.json change emits settings-changed event", async () => { const bus = createMockEventBus(); await startFileWatcher(dir, bus); + await delay(200); writeFileSync(join(dir, "settings.json"), JSON.stringify({ updated: true })); // Wait for debounce (300ms) + filesystem propagation - await delay(600); + await delay(800); const matched = bus.events.filter((e) => e.channel === "settings-changed"); assert.ok(matched.length > 0, "should emit settings-changed event"); @@ -68,9 +69,10 @@ test("auth.json change emits auth-changed event", async () => { const bus = createMockEventBus(); await startFileWatcher(dir, bus); + await delay(200); writeFileSync(join(dir, "auth.json"), JSON.stringify({ token: "new" })); - await delay(600); + await delay(800); const matched = bus.events.filter((e) => e.channel === "auth-changed"); assert.ok(matched.length > 0, "should emit auth-changed event"); @@ -81,9 +83,10 @@ test("models.json change emits models-changed event", async () => { const bus = createMockEventBus(); await startFileWatcher(dir, bus); + await delay(200); writeFileSync(join(dir, "models.json"), JSON.stringify({ model: "new" })); - await delay(600); + await delay(800); const matched = bus.events.filter((e) => e.channel === "models-changed"); assert.ok(matched.length > 0, "should emit models-changed event"); @@ -133,7 +136,7 @@ test("debouncing coalesces rapid changes into one event", async () => { for (let i = 0; i < 5; i++) { writeFileSync(join(dir, "settings.json"), JSON.stringify({ i })); } - await delay(600); + await delay(800); const matched = bus.events.filter((e) => e.channel === "settings-changed"); assert.strictEqual(