Skip to content

Commit 437259b

Browse files
committed
perf(web): a thread loaded from the cache holds each item once
Web and desktop now write the compact record mobile already uses, so a saved thread takes about half the space. Decoding also shares equal item copies and message bodies again, including in records saved before this change and in records that could not be compacted, so a thread from the cache costs what a thread from the server costs. A pending question's card survives the round trip whether it sits inside the visible window, older than it, or older with newer items after it, and a record with no card is still fetched again.
1 parent 337c585 commit 437259b

5 files changed

Lines changed: 429 additions & 14 deletions

File tree

‎apps/web/src/connection/storage.test.ts‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {
22
ConnectionTransientError,
33
PrimaryConnectionTarget,
44
} from "@t3tools/client-runtime/connection";
5-
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
5+
import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts";
66
import { ConnectionCatalogDocument, Persistence } from "@t3tools/client-runtime/platform";
77
import { describe, expect, it } from "@effect/vitest";
88
import * as Effect from "effect/Effect";
@@ -12,6 +12,7 @@ import * as Schema from "effect/Schema";
1212
import * as Stream from "effect/Stream";
1313
import { afterEach, vi } from "vite-plus/test";
1414

15+
import { makeThreadProjectionFixture } from "../test-fixtures";
1516
import * as ConnectionStorage from "./storage";
1617

1718
const emptyCatalog = {
@@ -197,6 +198,95 @@ describe("IndexedDB connection closed without a close event", () => {
197198
);
198199
});
199200

201+
describe("thread cache records in IndexedDB", () => {
202+
const environmentId = EnvironmentId.make("environment-1");
203+
const output = "a command output that should reach the disk once";
204+
205+
const stubDatabase = (values: Map<IDBValidKey, unknown>) => {
206+
const settle = <T extends EventTarget>(request: T) => {
207+
queueMicrotask(() => request.dispatchEvent(new Event("success")));
208+
return request;
209+
};
210+
const database = Object.assign(new EventTarget(), {
211+
close: vi.fn(),
212+
transaction: () => {
213+
const transaction = new EventTarget();
214+
return Object.assign(transaction, {
215+
error: null,
216+
objectStore: () => ({
217+
get: (key: IDBValidKey) =>
218+
settle(Object.assign(new EventTarget(), { result: values.get(key), error: null })),
219+
put: (value: unknown, key: IDBValidKey) => {
220+
values.set(key, value);
221+
queueMicrotask(() => transaction.dispatchEvent(new Event("complete")));
222+
},
223+
}),
224+
});
225+
},
226+
}) as unknown as IDBDatabase;
227+
vi.stubGlobal("window", {});
228+
vi.stubGlobal("indexedDB", {
229+
open: () => {
230+
const request = Object.assign(new EventTarget(), { result: database, error: null });
231+
queueMicrotask(() => request.dispatchEvent(new Event("success")));
232+
return request;
233+
},
234+
});
235+
};
236+
237+
it.effect("saves each item once and loads it as one object", () =>
238+
Effect.gen(function* () {
239+
const values = new Map<IDBValidKey, unknown>();
240+
stubDatabase(values);
241+
const fixture = makeThreadProjectionFixture();
242+
const threadId = fixture.thread.id;
243+
const at = fixture.updatedAt;
244+
const item = {
245+
id: TurnItemId.make("command"),
246+
type: "command_execution" as const,
247+
threadId,
248+
runId: null,
249+
nodeId: null,
250+
providerThreadId: null,
251+
providerTurnId: null,
252+
nativeItemRef: null,
253+
parentItemId: null,
254+
ordinal: 1,
255+
status: "completed" as const,
256+
title: null,
257+
input: "pwd",
258+
output,
259+
exitCode: 0,
260+
startedAt: at,
261+
completedAt: at,
262+
updatedAt: at,
263+
};
264+
const projection = {
265+
...fixture,
266+
turnItems: [item],
267+
visibleTurnItems: [
268+
{
269+
position: 0,
270+
visibility: "local" as const,
271+
sourceThreadId: threadId,
272+
sourceItemId: item.id,
273+
item,
274+
},
275+
],
276+
};
277+
yield* Effect.gen(function* () {
278+
const cache = yield* Persistence.EnvironmentCacheStore;
279+
yield* cache.saveThread(environmentId, { snapshotSequence: 2, projection });
280+
const raw = [...values.values()][0] as string;
281+
expect(raw.split(output).length - 1).toBe(1);
282+
const loaded = Option.getOrThrow(yield* cache.loadThread(environmentId, threadId));
283+
expect(loaded.projection.turnItems).toEqual([item]);
284+
expect(loaded.projection.visibleTurnItems[0]!.item).toBe(loaded.projection.turnItems[0]);
285+
}).pipe(Effect.provide(ConnectionStorage.layer));
286+
}),
287+
);
288+
});
289+
200290
describe("browser GitHub routing permissions", () => {
201291
it.effect("revokes across runtimes before storage events and resists stale catalog writes", () =>
202292
Effect.gen(function* () {

‎apps/web/src/connection/storage.ts‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import {
33
type ConnectionCatalogDocument as ConnectionCatalogDocumentType,
44
EMPTY_CONNECTION_CATALOG_DOCUMENT,
55
ORCHESTRATION_CACHE_SCHEMA_VERSION,
6-
ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
76
StoredOrchestrationShellSnapshot,
8-
StoredOrchestrationThreadSnapshot,
97
decodeOrDiscardOrchestrationCache,
8+
decodeStoredThreadSnapshot,
9+
encodeStoredThreadSnapshot,
1010
putRemoteDpopTokenInCatalog,
1111
registerConnectionInCatalog,
1212
removeCatalogValue,
@@ -50,8 +50,6 @@ const VCS_REFS_STORE_NAME = "vcs-refs";
5050
const CATALOG_KEY = "document";
5151
const StoredShellSnapshot = StoredOrchestrationShellSnapshot;
5252
const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);
53-
const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot;
54-
const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot);
5553
const StoredServerConfig = Schema.Struct({
5654
schemaVersion: Schema.Literal(1),
5755
environmentId: EnvironmentId,
@@ -69,8 +67,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc
6967
const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson);
7068
const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);
7169
const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson);
72-
const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson);
73-
const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson);
7470
const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson);
7571
const encodeStoredServerConfig = Schema.encodeEffect(StoredServerConfigJson);
7672
const decodeStoredVcsRefs = Schema.decodeUnknownEffect(StoredVcsRefsJson);
@@ -845,7 +841,6 @@ export const layer = Layer.effectContext(
845841
saveThread: Effect.fn("storage.saveThread")(
846842
function* (environmentId, snapshot) {
847843
const encoded = yield* encodeStoredThreadSnapshot({
848-
schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
849844
environmentId,
850845
threadId: snapshot.projection.thread.id,
851846
snapshot,
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import {
2+
EnvironmentId,
3+
MessageId,
4+
TurnItemId,
5+
type OrchestrationV2ThreadDetailSnapshot,
6+
type OrchestrationV2ThreadProjection,
7+
} from "@t3tools/contracts";
8+
import { describe, expect, it } from "@effect/vitest";
9+
import * as Effect from "effect/Effect";
10+
import * as Schema from "effect/Schema";
11+
12+
import { v2Now, v2Projection, v2ThreadId } from "../state/orchestrationV2TestFixtures.ts";
13+
import {
14+
ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
15+
StoredOrchestrationThreadSnapshot,
16+
} from "./orchestrationCache.ts";
17+
import {
18+
COMPACT_THREAD_CACHE_SCHEMA_VERSION,
19+
decodeStoredThreadSnapshot,
20+
encodeStoredThreadSnapshot,
21+
} from "./threadCacheEncoding.ts";
22+
23+
const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
24+
const BODY = "a body long enough to count ".repeat(4);
25+
const COMMAND_OUTPUT = "only the items carry this output";
26+
27+
const base = (id: string, ordinal: number) => ({
28+
id: TurnItemId.make(id),
29+
threadId: v2ThreadId,
30+
runId: null,
31+
nodeId: null,
32+
providerThreadId: null,
33+
providerTurnId: null,
34+
nativeItemRef: null,
35+
parentItemId: null,
36+
ordinal,
37+
status: "completed" as const,
38+
title: null,
39+
startedAt: v2Now,
40+
completedAt: v2Now,
41+
updatedAt: v2Now,
42+
});
43+
44+
const messageId = MessageId.make("message-1");
45+
const answer = {
46+
...base("answer", 1),
47+
type: "assistant_message" as const,
48+
messageId,
49+
text: BODY,
50+
streaming: false,
51+
};
52+
const command = {
53+
...base("command", 2),
54+
type: "command_execution" as const,
55+
input: "pwd",
56+
output: COMMAND_OUTPUT,
57+
exitCode: 0,
58+
};
59+
const rowOf = <Item extends { readonly id: TurnItemId }>(item: Item, position: number) => ({
60+
position,
61+
visibility: "local" as const,
62+
sourceThreadId: v2ThreadId,
63+
sourceItemId: item.id,
64+
item,
65+
});
66+
const projection: OrchestrationV2ThreadProjection = {
67+
...v2Projection,
68+
messages: [
69+
{
70+
id: messageId,
71+
threadId: v2ThreadId,
72+
runId: null,
73+
nodeId: null,
74+
role: "assistant",
75+
text: BODY,
76+
attachments: [],
77+
streaming: false,
78+
createdBy: "agent",
79+
creationSource: "provider",
80+
createdAt: v2Now,
81+
updatedAt: v2Now,
82+
},
83+
],
84+
turnItems: [answer, command],
85+
visibleTurnItems: [rowOf(answer, 0), rowOf(command, 1)],
86+
};
87+
const snapshot: OrchestrationV2ThreadDetailSnapshot = { snapshotSequence: 3, projection };
88+
const stored = { environmentId: ENVIRONMENT_ID, threadId: v2ThreadId, snapshot };
89+
const encodeFull = Schema.encodeEffect(Schema.fromJsonString(StoredOrchestrationThreadSnapshot));
90+
const occurrences = (text: string, part: string) => text.split(part).length - 1;
91+
92+
describe("thread cache records", () => {
93+
it.effect("writes each item once", () =>
94+
Effect.gen(function* () {
95+
const raw = yield* encodeStoredThreadSnapshot(stored);
96+
expect(JSON.parse(raw).schemaVersion).toBe(COMPACT_THREAD_CACHE_SCHEMA_VERSION);
97+
expect(occurrences(raw, COMMAND_OUTPUT)).toBe(1);
98+
}),
99+
);
100+
101+
it.effect("decodes to the same projection, with one object per item", () =>
102+
Effect.gen(function* () {
103+
const decoded = yield* decodeStoredThreadSnapshot(yield* encodeStoredThreadSnapshot(stored));
104+
const { projection: loaded } = decoded.snapshot;
105+
expect(loaded).toEqual(projection);
106+
expect(loaded.visibleTurnItems[0]!.item).toBe(loaded.turnItems[0]);
107+
expect(loaded.visibleTurnItems[1]!.item).toBe(loaded.turnItems[1]);
108+
}),
109+
);
110+
111+
it.effect("shares the copies of a record that could not be compacted", () =>
112+
Effect.gen(function* () {
113+
// A hidden item between two visible ones: `turnItems` no longer starts with the visible rows.
114+
const hidden = {
115+
...base("hidden", 3),
116+
type: "reasoning" as const,
117+
text: "not shown",
118+
streaming: false,
119+
};
120+
const uncompacted = {
121+
...projection,
122+
turnItems: [answer, hidden, command],
123+
};
124+
const raw = yield* encodeStoredThreadSnapshot({
125+
...stored,
126+
snapshot: { ...snapshot, projection: uncompacted },
127+
});
128+
expect(JSON.parse(raw).snapshot.turnItemsOmitLocalVisible).toBe(false);
129+
const { projection: loaded } = (yield* decodeStoredThreadSnapshot(raw)).snapshot;
130+
expect(loaded).toEqual(uncompacted);
131+
expect(loaded.visibleTurnItems[0]!.item).toBe(loaded.turnItems[0]);
132+
expect(loaded.visibleTurnItems[1]!.item).toBe(loaded.turnItems[2]);
133+
}),
134+
);
135+
136+
it.effect("shares the matching copies of a full record", () =>
137+
Effect.gen(function* () {
138+
const raw = yield* encodeFull({
139+
schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
140+
...stored,
141+
});
142+
expect(occurrences(raw, COMMAND_OUTPUT)).toBe(2);
143+
const { projection: loaded } = (yield* decodeStoredThreadSnapshot(raw)).snapshot;
144+
expect(loaded).toEqual(projection);
145+
expect(loaded.visibleTurnItems[1]!.item).toBe(loaded.turnItems[1]);
146+
}),
147+
);
148+
149+
it.effect("keeps both copies of a full record when they differ", () =>
150+
Effect.gen(function* () {
151+
const stale = { ...command, output: "an older output" };
152+
const raw = yield* encodeFull({
153+
schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
154+
...stored,
155+
snapshot: {
156+
...snapshot,
157+
projection: {
158+
...projection,
159+
visibleTurnItems: [rowOf(answer, 0), rowOf(stale, 1)],
160+
},
161+
},
162+
});
163+
const { projection: loaded } = (yield* decodeStoredThreadSnapshot(raw)).snapshot;
164+
expect(loaded.turnItems[1]).toMatchObject({ output: COMMAND_OUTPUT });
165+
expect(loaded.visibleTurnItems[1]!.item).toMatchObject({ output: "an older output" });
166+
}),
167+
);
168+
});

0 commit comments

Comments
 (0)