Skip to content

Commit d3fa3d7

Browse files
committed
WIP chat.store primitive
1 parent 746e09a commit d3fa3d7

File tree

3 files changed

+512
-0
lines changed

3 files changed

+512
-0
lines changed

.changeset/chat-store-primitive.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Add `chat.store` — a typed, bidirectional shared data slot on `chat.agent`. Agent-side foundation for TRI-8602. Independent of AG-UI — the same primitive will back the AG-UI `STATE_SNAPSHOT` / `STATE_DELTA` translator later.
7+
8+
**New on the agent:**
9+
- `chat.store.set(value)` — replace, emits a `store-snapshot` chunk on the existing chat output stream.
10+
- `chat.store.patch([...])` — RFC 6902 JSON Patch, emits a `store-delta` chunk.
11+
- `chat.store.get()` — read the current value (scoped to the run).
12+
- `chat.store.onChange((value, ops) => ...)` — subscribe to changes.
13+
- `hydrateStore?: (event) => value` config on `chat.agent` — mirrors `hydrateMessages`; restore the store after a continuation from your own persistence layer.
14+
- `ChatTaskWirePayload.incomingStore` — optional wire field applied at turn start before `run()` fires (last-write-wins over `hydrateStore`).
15+
16+
**New in core:**
17+
- `store-snapshot` / `store-delta` chunk types and `applyChatStorePatch` helper exported from `@trigger.dev/core/v3/chat-client`.
18+
19+
The store lives in memory for the lifetime of the run and is persisted by the existing chat output stream plus the `hydrateStore` hook across continuations — no new infrastructure.
20+
21+
Client-side pieces (transport `getStore` / `setStore` / `applyStorePatch` / listeners, `AgentChat` accessors, `useChatStore` React hook, reference demo, docs) land in a follow-up.

packages/core/src/v3/chat-client.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,176 @@ export const CHAT_MESSAGES_STREAM_ID = "chat-messages";
1111

1212
/** Input stream ID for sending stop signals to abort the current generation. */
1313
export const CHAT_STOP_STREAM_ID = "chat-stop";
14+
15+
// ─── chat.store chunk types ────────────────────────────────────────
16+
//
17+
// First-class chunk types for `chat.store` — bidirectional shared data
18+
// between a chat.agent and its clients. Emitted on the same S2 output
19+
// stream as UIMessageChunks but intercepted by the transport (not
20+
// passed to the AI SDK).
21+
22+
/**
23+
* An RFC 6902 JSON Patch operation used by `chat.store.patch()` and
24+
* emitted inside {@link ChatStoreDeltaChunk}.
25+
*
26+
* @see https://tools.ietf.org/html/rfc6902
27+
*/
28+
export type ChatStorePatchOperation =
29+
| { op: "add"; path: string; value: unknown }
30+
| { op: "remove"; path: string }
31+
| { op: "replace"; path: string; value: unknown }
32+
| { op: "move"; path: string; from: string }
33+
| { op: "copy"; path: string; from: string }
34+
| { op: "test"; path: string; value: unknown };
35+
36+
/** Full-value snapshot — emitted by `chat.store.set(...)`. */
37+
export type ChatStoreSnapshotChunk = {
38+
type: "store-snapshot";
39+
value: unknown;
40+
};
41+
42+
/** Incremental update — emitted by `chat.store.patch([...])`. */
43+
export type ChatStoreDeltaChunk = {
44+
type: "store-delta";
45+
operations: ChatStorePatchOperation[];
46+
};
47+
48+
export type ChatStoreChunk = ChatStoreSnapshotChunk | ChatStoreDeltaChunk;
49+
50+
// ─── RFC 6902 JSON Patch applier ───────────────────────────────────
51+
//
52+
// Minimal in-process implementation so we don't pull a runtime dep
53+
// into the SDK or webapp. Handles the six RFC 6902 ops with RFC 6901
54+
// JSON Pointer paths. Used by `chat.store.patch()` on the agent and
55+
// the matching client-side `applyStorePatch` on the transport.
56+
57+
function parseJsonPointer(path: string): string[] {
58+
if (path === "") return [];
59+
if (!path.startsWith("/")) {
60+
throw new Error(`Invalid JSON Pointer (must start with "/"): ${path}`);
61+
}
62+
return path
63+
.slice(1)
64+
.split("/")
65+
.map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
66+
}
67+
68+
function cloneValue<T>(value: T): T {
69+
if (value === undefined || value === null) return value;
70+
if (typeof structuredClone === "function") {
71+
try {
72+
return structuredClone(value);
73+
} catch {
74+
// Fall through for values that can't be structured-cloned
75+
}
76+
}
77+
return JSON.parse(JSON.stringify(value));
78+
}
79+
80+
function getParentAndKey(
81+
doc: unknown,
82+
tokens: string[]
83+
): { parent: any; lastToken: string } {
84+
if (tokens.length === 0) {
85+
throw new Error("Cannot get parent of root");
86+
}
87+
let parent: any = doc;
88+
for (let i = 0; i < tokens.length - 1; i++) {
89+
if (parent == null || typeof parent !== "object") {
90+
throw new Error(`Path traversal failed at segment "${tokens[i]}"`);
91+
}
92+
const key = Array.isArray(parent) ? Number(tokens[i]) : tokens[i];
93+
parent = (parent as any)[key as any];
94+
}
95+
return { parent, lastToken: tokens[tokens.length - 1]! };
96+
}
97+
98+
function readPointer(doc: unknown, tokens: string[]): unknown {
99+
if (tokens.length === 0) return doc;
100+
let cursor: any = doc;
101+
for (const token of tokens) {
102+
if (cursor == null) return undefined;
103+
const key = Array.isArray(cursor) ? Number(token) : token;
104+
cursor = cursor[key];
105+
}
106+
return cursor;
107+
}
108+
109+
function removeAt(parent: any, lastToken: string): void {
110+
if (Array.isArray(parent)) {
111+
parent.splice(Number(lastToken), 1);
112+
} else if (parent && typeof parent === "object") {
113+
delete parent[lastToken];
114+
} else {
115+
throw new Error("Cannot remove: parent is not a container");
116+
}
117+
}
118+
119+
function insertAt(parent: any, lastToken: string, value: unknown, op: "add" | "replace"): void {
120+
if (Array.isArray(parent)) {
121+
const idx = lastToken === "-" ? parent.length : Number(lastToken);
122+
if (op === "add") parent.splice(idx, 0, value);
123+
else parent[idx] = value;
124+
} else if (parent && typeof parent === "object") {
125+
parent[lastToken] = value;
126+
} else {
127+
throw new Error("Cannot insert: parent is not a container");
128+
}
129+
}
130+
131+
/**
132+
* Apply an RFC 6902 JSON Patch to a document and return the new value.
133+
* Never mutates the input.
134+
*/
135+
export function applyChatStorePatch(
136+
doc: unknown,
137+
operations: readonly ChatStorePatchOperation[]
138+
): unknown {
139+
let result: any = doc === undefined ? undefined : cloneValue(doc);
140+
141+
for (const op of operations) {
142+
const tokens = parseJsonPointer(op.path);
143+
144+
if (op.op === "test") {
145+
const actual = readPointer(result, tokens);
146+
if (JSON.stringify(actual) !== JSON.stringify(op.value)) {
147+
throw new Error(`JSON Patch test failed at path "${op.path}"`);
148+
}
149+
continue;
150+
}
151+
152+
if (op.op === "remove") {
153+
if (tokens.length === 0) {
154+
result = undefined;
155+
continue;
156+
}
157+
const { parent, lastToken } = getParentAndKey(result, tokens);
158+
removeAt(parent, lastToken);
159+
continue;
160+
}
161+
162+
// add / replace / move / copy all insert a value at `path`
163+
let valueToInsert: unknown;
164+
if (op.op === "add" || op.op === "replace") {
165+
valueToInsert = cloneValue(op.value);
166+
} else {
167+
// move / copy — source must exist
168+
const fromTokens = parseJsonPointer(op.from);
169+
valueToInsert = cloneValue(readPointer(result, fromTokens));
170+
if (op.op === "move" && fromTokens.length > 0) {
171+
const { parent: fromParent, lastToken: fromLast } = getParentAndKey(result, fromTokens);
172+
removeAt(fromParent, fromLast);
173+
}
174+
}
175+
176+
if (tokens.length === 0) {
177+
result = valueToInsert;
178+
continue;
179+
}
180+
181+
const { parent, lastToken } = getParentAndKey(result, tokens);
182+
insertAt(parent, lastToken, valueToInsert, op.op === "replace" ? "replace" : "add");
183+
}
184+
185+
return result;
186+
}

0 commit comments

Comments
 (0)