Skip to content

Commit 23f560c

Browse files
authored
Merge pull request #257 from code-yeongyu/perf/render-hotpath-cache
perf(coding-agent): cache materialized session views behind mutation counter
2 parents 6d606e2 + 9bc68d9 commit 23f560c

8 files changed

Lines changed: 478 additions & 34 deletions

File tree

packages/coding-agent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
### Changed
1212

1313
### Fixed
14+
- Fixed the interactive render hot path re-materializing the entire session every frame: `SessionManager.getEntries()`, no-arg `getBranch()`, and `getSessionName()` are now memoized behind a monotonic mutation counter, eliminating repeated deep copies of large sessions at frame rate.
1415

1516
### Removed
1617

packages/coding-agent/src/core/changes.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# changes
22

3+
## Memoized materialized session views (2026-07-21)
4+
5+
### What changed
6+
7+
- `session-manager.ts`: added a monotonic `mutationCount` bumped by every mutator (`_appendEntry`, `branch()`,
8+
`resetLeaf()`, `setSessionFile`, `newSession`, `createBranchedSession`). `getEntries()` is memoized on
9+
`mutationCount`, no-arg `getBranch()` on `(leafId, mutationCount)` (explicit `fromId` bypasses), and
10+
`getSessionName()` is O(1) via a cached value maintained on `appendSessionInfo`/`_buildIndex` (empty name still
11+
clears the title). `getEntries()` now returns a shared cached array callers must not mutate.
12+
13+
### Why extension system couldn't handle this alone
14+
15+
- The mutation surface and resident-store materialization are private to `SessionManager`; external wrappers cannot
16+
observe every invalidation point.
17+
18+
### Expected merge conflict zones
19+
20+
- LOW: private fields and the listed getters; upstream rarely touches `SessionManager` internals.
21+
322
## Smooth streaming settings (2026-07-20)
423

524
### What changed

packages/coding-agent/src/core/session-manager.ts

Lines changed: 117 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ import {
5050
createCustomMessage,
5151
} from "./messages.ts";
5252

53+
export interface UsageTotals {
54+
input: number;
55+
output: number;
56+
cacheRead: number;
57+
cacheWrite: number;
58+
cost: number;
59+
latestCacheHitRate: number | undefined;
60+
}
61+
5362
export const CURRENT_SESSION_VERSION = 3;
5463

5564
export interface SessionHeader {
@@ -873,6 +882,24 @@ export class SessionManager {
873882
private labelTimestampsById: Map<string, string> = new Map();
874883
private leafId: string | null = null;
875884
private residentStore = new ResidentStringStore();
885+
// Monotonic counter bumped by every mutator; memoized materialized views are
886+
// keyed on it so read hot paths (footer, RPC) never re-materialize unchanged sessions.
887+
private mutationCount = 0;
888+
private entriesCache: { mutation: number; entries: SessionEntry[] } | null = null;
889+
private branchCache: { leafId: string | null; mutation: number; entries: SessionEntry[] } | null = null;
890+
private sessionNameCache: string | undefined = undefined;
891+
// Running usage totals over ALL entries (not branch-scoped), maintained
892+
// incrementally on assistant-message append and rebuilt from scratch in
893+
// _buildIndex()/newSession. Usage fields are numeric and unaffected by
894+
// string externalization, so the resident form can be read directly.
895+
private usageTotals: UsageTotals = {
896+
input: 0,
897+
output: 0,
898+
cacheRead: 0,
899+
cacheWrite: 0,
900+
cost: 0,
901+
latestCacheHitRate: undefined,
902+
};
876903

877904
private constructor(
878905
cwd: string,
@@ -925,6 +952,7 @@ export class SessionManager {
925952

926953
this.fileEntries = this.fileEntries.map((entry) => this.residentStore.externalize(entry));
927954
this._buildIndex();
955+
this.mutationCount++;
928956
this.flushed = true;
929957
} else {
930958
const explicitPath = this.sessionFile;
@@ -953,6 +981,16 @@ export class SessionManager {
953981
this.labelsById.clear();
954982
this.labelTimestampsById.clear();
955983
this.leafId = null;
984+
this.sessionNameCache = undefined;
985+
this.usageTotals = {
986+
input: 0,
987+
output: 0,
988+
cacheRead: 0,
989+
cacheWrite: 0,
990+
cost: 0,
991+
latestCacheHitRate: undefined,
992+
};
993+
this.mutationCount++;
956994
this.flushed = false;
957995

958996
if (this.persist) {
@@ -967,10 +1005,24 @@ export class SessionManager {
9671005
this.labelsById.clear();
9681006
this.labelTimestampsById.clear();
9691007
this.leafId = null;
1008+
this.sessionNameCache = undefined;
1009+
this.usageTotals = {
1010+
input: 0,
1011+
output: 0,
1012+
cacheRead: 0,
1013+
cacheWrite: 0,
1014+
cost: 0,
1015+
latestCacheHitRate: undefined,
1016+
};
9701017
for (const entry of this.fileEntries) {
9711018
if (entry.type === "session") continue;
9721019
this.byId.set(entry.id, entry);
9731020
this.leafId = entry.id;
1021+
this._accumulateUsage(entry);
1022+
if (entry.type === "session_info") {
1023+
// Empty names explicitly clear the session title.
1024+
this.sessionNameCache = entry.name?.trim() || undefined;
1025+
}
9741026
if (entry.type === "label") {
9751027
if (entry.label) {
9761028
this.labelsById.set(entry.targetId, entry.label);
@@ -1058,9 +1110,39 @@ export class SessionManager {
10581110
this.fileEntries.push(residentEntry);
10591111
this.byId.set(residentEntry.id, residentEntry);
10601112
this.leafId = residentEntry.id;
1113+
this._accumulateUsage(residentEntry);
1114+
this.mutationCount++;
10611115
this._persist(residentEntry);
10621116
}
10631117

1118+
/**
1119+
* Fold one entry into the running usage totals. Totals iterate ALL entries
1120+
* (not branch-scoped), matching the footer hot path's historical semantics.
1121+
*/
1122+
private _accumulateUsage(entry: SessionEntry): void {
1123+
if (entry.type !== "message" || entry.message.role !== "assistant") return;
1124+
const usage = entry.message.usage;
1125+
// Assistant messages persisted without usage (e.g. aborted/error turns in
1126+
// older session files) contribute nothing to the running totals.
1127+
if (!usage) return;
1128+
this.usageTotals.input += usage.input;
1129+
this.usageTotals.output += usage.output;
1130+
this.usageTotals.cacheRead += usage.cacheRead;
1131+
this.usageTotals.cacheWrite += usage.cacheWrite;
1132+
this.usageTotals.cost += usage.cost?.total ?? 0;
1133+
const latestPromptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
1134+
this.usageTotals.latestCacheHitRate =
1135+
latestPromptTokens > 0 ? (usage.cacheRead / latestPromptTokens) * 100 : undefined;
1136+
}
1137+
1138+
/**
1139+
* O(1) running usage totals across ALL session entries (not branch-scoped).
1140+
* Maintained incrementally; identical to summing usage over getEntries().
1141+
*/
1142+
getUsageTotals(): UsageTotals {
1143+
return this.usageTotals;
1144+
}
1145+
10641146
/** Append a message as child of current leaf, then advance leaf. Returns entry id.
10651147
* Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
10661148
* Reason: we want these to be top-level entries in the session, not message session entries,
@@ -1162,22 +1244,17 @@ export class SessionManager {
11621244
timestamp: new Date().toISOString(),
11631245
name: sanitizedName,
11641246
};
1247+
this.sessionNameCache = sanitizedName || undefined;
11651248
this._appendEntry(entry);
11661249
return entry.id;
11671250
}
11681251

1169-
/** Get the current session name from the latest session_info entry, if any. */
1252+
/**
1253+
* Get the current session name from the latest session_info entry, if any.
1254+
* O(1): the value is maintained incrementally on appendSessionInfo() and index rebuilds.
1255+
*/
11701256
getSessionName(): string | undefined {
1171-
// Walk entries in reverse to find the latest session_info entry.
1172-
// Empty names explicitly clear the session title.
1173-
const entries = this.getEntries();
1174-
for (let i = entries.length - 1; i >= 0; i--) {
1175-
const entry = entries[i];
1176-
if (entry.type === "session_info") {
1177-
return entry.name?.trim() || undefined;
1178-
}
1179-
}
1180-
return undefined;
1257+
return this.sessionNameCache;
11811258
}
11821259

11831260
/**
@@ -1280,13 +1357,26 @@ export class SessionManager {
12801357
* Use buildSessionContext() to get the resolved messages for the LLM.
12811358
*/
12821359
getBranch(fromId?: string): SessionEntry[] {
1360+
// No-arg reads (the common hot path) are memoized on (leafId, mutationCount);
1361+
// explicit fromId lookups bypass the cache.
1362+
if (
1363+
fromId === undefined &&
1364+
this.branchCache !== null &&
1365+
this.branchCache.leafId === this.leafId &&
1366+
this.branchCache.mutation === this.mutationCount
1367+
) {
1368+
return this.branchCache.entries;
1369+
}
12831370
const path: SessionEntry[] = [];
12841371
const startId = fromId ?? this.leafId;
12851372
let current = startId ? this.byId.get(startId) : undefined;
12861373
while (current) {
12871374
path.unshift(this.residentStore.materialize(current));
12881375
current = current.parentId ? this.byId.get(current.parentId) : undefined;
12891376
}
1377+
if (fromId === undefined) {
1378+
this.branchCache = { leafId: this.leafId, mutation: this.mutationCount, entries: path };
1379+
}
12901380
return path;
12911381
}
12921382

@@ -1346,14 +1436,24 @@ export class SessionManager {
13461436
}
13471437

13481438
/**
1349-
* Get all session entries (excludes header). Returns a shallow copy.
1439+
* Get all session entries (excludes header).
13501440
* The session is append-only: use appendXXX() to add entries, branch() to
13511441
* change the leaf pointer. Entries cannot be modified or deleted.
1442+
*
1443+
* The result is memoized behind the session mutation counter: repeated calls
1444+
* without an intervening mutation return the SAME shared array instance.
1445+
* Callers must not mutate the returned array or its entries; copy first if
1446+
* you need to filter/reorder.
13521447
*/
13531448
getEntries(): SessionEntry[] {
1354-
return this.fileEntries
1449+
if (this.entriesCache !== null && this.entriesCache.mutation === this.mutationCount) {
1450+
return this.entriesCache.entries;
1451+
}
1452+
const entries = this.fileEntries
13551453
.filter((e): e is SessionEntry => e.type !== "session")
13561454
.map((entry) => this.residentStore.materialize(entry));
1455+
this.entriesCache = { mutation: this.mutationCount, entries };
1456+
return entries;
13571457
}
13581458

13591459
/**
@@ -1416,6 +1516,7 @@ export class SessionManager {
14161516
throw new Error(`Entry ${branchFromId} not found`);
14171517
}
14181518
this.leafId = branchFromId;
1519+
this.mutationCount++;
14191520
}
14201521

14211522
/**
@@ -1425,6 +1526,7 @@ export class SessionManager {
14251526
*/
14261527
resetLeaf(): void {
14271528
this.leafId = null;
1529+
this.mutationCount++;
14281530
}
14291531

14301532
/**
@@ -1523,6 +1625,7 @@ export class SessionManager {
15231625
this.sessionId = newSessionId;
15241626
this.sessionFile = newSessionFile;
15251627
this._buildIndex();
1628+
this.mutationCount++;
15261629

15271630
// Only write the file now if it contains an assistant message.
15281631
// Otherwise defer to _persist(), which creates the file on the
@@ -1561,6 +1664,7 @@ export class SessionManager {
15611664
);
15621665
this.sessionId = newSessionId;
15631666
this._buildIndex();
1667+
this.mutationCount++;
15641668
return undefined;
15651669
}
15661670

packages/coding-agent/src/modes/interactive/components/footer.ts

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -106,27 +106,15 @@ export class FooterComponent implements Component {
106106
render(width: number): string[] {
107107
const state = this.session.state;
108108

109-
let totalInput = 0;
110-
let totalOutput = 0;
111-
let totalCacheRead = 0;
112-
let totalCacheWrite = 0;
113-
let totalCost = 0;
114-
let latestCacheHitRate: number | undefined;
115-
116-
for (const entry of this.session.sessionManager.getEntries()) {
117-
if (entry.type === "message" && entry.message.role === "assistant") {
118-
totalInput += entry.message.usage.input;
119-
totalOutput += entry.message.usage.output;
120-
totalCacheRead += entry.message.usage.cacheRead;
121-
totalCacheWrite += entry.message.usage.cacheWrite;
122-
totalCost += entry.message.usage.cost.total;
123-
124-
const latestPromptTokens =
125-
entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite;
126-
latestCacheHitRate =
127-
latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined;
128-
}
129-
}
109+
// O(1) running totals maintained by SessionManager (identical to summing
110+
// usage over all entries; totals are not branch-scoped).
111+
const usageTotals = this.session.sessionManager.getUsageTotals();
112+
const totalInput = usageTotals.input;
113+
const totalOutput = usageTotals.output;
114+
const totalCacheRead = usageTotals.cacheRead;
115+
const totalCacheWrite = usageTotals.cacheWrite;
116+
const totalCost = usageTotals.cost;
117+
const latestCacheHitRate = usageTotals.latestCacheHitRate;
130118

131119
// Calculate context usage from session (handles compaction correctly).
132120
// After compaction, tokens are unknown until the next LLM response.

packages/coding-agent/test/footer-token-format.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ function createSession(): unknown {
3535
},
3636
},
3737
],
38+
getUsageTotals: () => ({
39+
input: 49,
40+
output: 6_800,
41+
cacheRead: 1_500_000,
42+
cacheWrite: 44_000,
43+
cost: 0,
44+
latestCacheHitRate: (1_500_000 / (49 + 1_500_000 + 44_000)) * 100,
45+
}),
3846
getSessionName: () => "",
3947
getCwd: () => "/tmp/project",
4048
},

packages/coding-agent/test/footer-width.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,30 @@ function createSession(options: {
4848
},
4949
sessionManager: {
5050
getEntries: () => entries,
51+
getUsageTotals: () => {
52+
const totals = {
53+
input: 0,
54+
output: 0,
55+
cacheRead: 0,
56+
cacheWrite: 0,
57+
cost: 0,
58+
latestCacheHitRate: undefined as number | undefined,
59+
};
60+
for (const entry of entries) {
61+
if (entry.type === "message" && entry.message.role === "assistant") {
62+
totals.input += entry.message.usage.input;
63+
totals.output += entry.message.usage.output;
64+
totals.cacheRead += entry.message.usage.cacheRead;
65+
totals.cacheWrite += entry.message.usage.cacheWrite;
66+
totals.cost += entry.message.usage.cost.total;
67+
const latestPromptTokens =
68+
entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite;
69+
totals.latestCacheHitRate =
70+
latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined;
71+
}
72+
}
73+
return totals;
74+
},
5175
getSessionName: () => options.sessionName,
5276
getCwd: () => "/tmp/project",
5377
},

0 commit comments

Comments
 (0)