@tanstack/ai-sandbox 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { evaluateCommand } from "./policy.js";
2
- import { EventType } from "@tanstack/ai";
2
+ import { EventType, withTanstackMetadata } from "@tanstack/ai";
3
3
  //#region src/approvals.ts
4
4
  /**
5
5
  * Shared interactive-approval logic for harness adapters.
@@ -51,7 +51,7 @@ function resolveApproval(input) {
51
51
  }
52
52
  /** Build the AG-UI `approval-requested` CUSTOM event for a harness action. */
53
53
  function buildApprovalRequestedEvent(input) {
54
- return {
54
+ return withTanstackMetadata({
55
55
  type: EventType.CUSTOM,
56
56
  name: APPROVAL_REQUESTED_EVENT,
57
57
  value: {
@@ -59,10 +59,11 @@ function buildApprovalRequestedEvent(input) {
59
59
  title: input.title,
60
60
  ...input.detail ?? {}
61
61
  },
62
- timestamp: Date.now(),
62
+ timestamp: Date.now()
63
+ }, {
63
64
  threadId: input.threadId,
64
65
  runId: input.runId
65
- };
66
+ });
66
67
  }
67
68
  //#endregion
68
69
  export { APPROVAL_REQUESTED_EVENT, approvalId, buildApprovalRequestedEvent, resolveApproval };
@@ -1 +1 @@
1
- {"version":3,"file":"approvals.js","names":[],"sources":["../../src/approvals.ts"],"sourcesContent":["/**\n * Shared interactive-approval logic for harness adapters.\n *\n * Flow (rides chat()'s existing resume-based approval mechanism):\n * 1. The agent (inside the sandbox) asks to run a risky action; the harness's\n * host-side permission callback fires.\n * 2. `resolveApproval` evaluates the sandbox policy: `allow`/`deny` are final;\n * `ask` consults the client's approval decisions (threaded via\n * `TextOptions.approvals`, keyed by a stable `approvalId`).\n * 3. On `ask` with no decision yet, the adapter emits an `approval-requested`\n * CUSTOM event (carrying the `approvalId`) and denies the action this turn.\n * The client shows UI, then re-runs chat() with the decision in the message;\n * the engine surfaces it as `approvals`, and the next run allows it.\n *\n * `approvalId` is stable for a given (provider, kind, target) so a client grant\n * matches the same action on the resumed run.\n */\nimport { EventType } from '@tanstack/ai'\nimport { evaluateCommand } from './policy'\nimport type { SandboxPolicy } from './policy'\nimport type { StreamChunk } from '@tanstack/ai'\n\n/** CUSTOM event name emitted when a harness action needs client approval. */\nexport const APPROVAL_REQUESTED_EVENT = 'approval-requested'\n\n/** A stable, opaque approval id for a harness action. */\nexport function approvalId(input: {\n provider: string\n kind: 'command' | 'fileWrite' | 'network' | 'tool'\n target: string\n}): string {\n return `${input.provider}:${input.kind}:${input.target}`\n}\n\nexport interface ResolveApprovalInput {\n policy: SandboxPolicy | undefined\n /** Client approval decisions, keyed by `approvalId`. */\n approvals: ReadonlyMap<string, boolean> | undefined\n /** Precomputed approval id for this action. */\n id: string\n /** A shell command to match against `policy.commands`. */\n command?: string\n /** Named workspace scripts for policy alias resolution. */\n scripts?: Record<string, string>\n /** A coarse capability to match against `policy.capabilities`. */\n capability?: 'fileWrite' | 'network'\n}\n\nexport interface ApprovalOutcome {\n decision: 'allow' | 'deny'\n /** True when policy said `ask` and the client hasn't decided yet. */\n needsApproval: boolean\n}\n\n/** Resolve a harness permission request against policy + client approvals. */\nexport function resolveApproval(input: ResolveApprovalInput): ApprovalOutcome {\n const base =\n input.command !== undefined\n ? evaluateCommand(input.command, input.policy, input.scripts)\n : input.capability !== undefined\n ? (input.policy?.capabilities?.[input.capability] ??\n input.policy?.default ??\n 'ask')\n : (input.policy?.default ?? 'ask')\n\n if (base === 'allow') return { decision: 'allow', needsApproval: false }\n if (base === 'deny') return { decision: 'deny', needsApproval: false }\n\n // base === 'ask' — consult the client's decision.\n const granted = input.approvals?.get(input.id)\n if (granted === true) return { decision: 'allow', needsApproval: false }\n if (granted === false) return { decision: 'deny', needsApproval: false }\n return { decision: 'deny', needsApproval: true }\n}\n\n/** Build the AG-UI `approval-requested` CUSTOM event for a harness action. */\nexport function buildApprovalRequestedEvent(input: {\n approvalId: string\n title: string\n threadId: string\n runId: string\n detail?: Record<string, unknown>\n}): StreamChunk {\n return {\n type: EventType.CUSTOM,\n name: APPROVAL_REQUESTED_EVENT,\n value: {\n approvalId: input.approvalId,\n title: input.title,\n ...(input.detail ?? {}),\n },\n timestamp: Date.now(),\n threadId: input.threadId,\n runId: input.runId,\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAuBA,IAAa,2BAA2B;;AAGxC,SAAgB,WAAW,OAIhB;CACT,OAAO,GAAG,MAAM,SAAS,GAAG,MAAM,KAAK,GAAG,MAAM;AAClD;;AAuBA,SAAgB,gBAAgB,OAA8C;CAC5E,MAAM,OACJ,MAAM,YAAY,KAAA,IACd,gBAAgB,MAAM,SAAS,MAAM,QAAQ,MAAM,OAAO,IAC1D,MAAM,eAAe,KAAA,IAClB,MAAM,QAAQ,eAAe,MAAM,eACpC,MAAM,QAAQ,WACd,QACC,MAAM,QAAQ,WAAW;CAElC,IAAI,SAAS,SAAS,OAAO;EAAE,UAAU;EAAS,eAAe;CAAM;CACvE,IAAI,SAAS,QAAQ,OAAO;EAAE,UAAU;EAAQ,eAAe;CAAM;CAGrE,MAAM,UAAU,MAAM,WAAW,IAAI,MAAM,EAAE;CAC7C,IAAI,YAAY,MAAM,OAAO;EAAE,UAAU;EAAS,eAAe;CAAM;CACvE,IAAI,YAAY,OAAO,OAAO;EAAE,UAAU;EAAQ,eAAe;CAAM;CACvE,OAAO;EAAE,UAAU;EAAQ,eAAe;CAAK;AACjD;;AAGA,SAAgB,4BAA4B,OAM5B;CACd,OAAO;EACL,MAAM,UAAU;EAChB,MAAM;EACN,OAAO;GACL,YAAY,MAAM;GAClB,OAAO,MAAM;GACb,GAAI,MAAM,UAAU,CAAC;EACvB;EACA,WAAW,KAAK,IAAI;EACpB,UAAU,MAAM;EAChB,OAAO,MAAM;CACf;AACF"}
1
+ {"version":3,"file":"approvals.js","names":[],"sources":["../../src/approvals.ts"],"sourcesContent":["/**\n * Shared interactive-approval logic for harness adapters.\n *\n * Flow (rides chat()'s existing resume-based approval mechanism):\n * 1. The agent (inside the sandbox) asks to run a risky action; the harness's\n * host-side permission callback fires.\n * 2. `resolveApproval` evaluates the sandbox policy: `allow`/`deny` are final;\n * `ask` consults the client's approval decisions (threaded via\n * `TextOptions.approvals`, keyed by a stable `approvalId`).\n * 3. On `ask` with no decision yet, the adapter emits an `approval-requested`\n * CUSTOM event (carrying the `approvalId`) and denies the action this turn.\n * The client shows UI, then re-runs chat() with the decision in the message;\n * the engine surfaces it as `approvals`, and the next run allows it.\n *\n * `approvalId` is stable for a given (provider, kind, target) so a client grant\n * matches the same action on the resumed run.\n */\nimport { EventType, withTanstackMetadata } from '@tanstack/ai'\nimport { evaluateCommand } from './policy'\nimport type { SandboxPolicy } from './policy'\nimport type { StreamChunk } from '@tanstack/ai'\n\n/** CUSTOM event name emitted when a harness action needs client approval. */\nexport const APPROVAL_REQUESTED_EVENT = 'approval-requested'\n\n/** A stable, opaque approval id for a harness action. */\nexport function approvalId(input: {\n provider: string\n kind: 'command' | 'fileWrite' | 'network' | 'tool'\n target: string\n}): string {\n return `${input.provider}:${input.kind}:${input.target}`\n}\n\nexport interface ResolveApprovalInput {\n policy: SandboxPolicy | undefined\n /** Client approval decisions, keyed by `approvalId`. */\n approvals: ReadonlyMap<string, boolean> | undefined\n /** Precomputed approval id for this action. */\n id: string\n /** A shell command to match against `policy.commands`. */\n command?: string\n /** Named workspace scripts for policy alias resolution. */\n scripts?: Record<string, string>\n /** A coarse capability to match against `policy.capabilities`. */\n capability?: 'fileWrite' | 'network'\n}\n\nexport interface ApprovalOutcome {\n decision: 'allow' | 'deny'\n /** True when policy said `ask` and the client hasn't decided yet. */\n needsApproval: boolean\n}\n\n/** Resolve a harness permission request against policy + client approvals. */\nexport function resolveApproval(input: ResolveApprovalInput): ApprovalOutcome {\n const base =\n input.command !== undefined\n ? evaluateCommand(input.command, input.policy, input.scripts)\n : input.capability !== undefined\n ? (input.policy?.capabilities?.[input.capability] ??\n input.policy?.default ??\n 'ask')\n : (input.policy?.default ?? 'ask')\n\n if (base === 'allow') return { decision: 'allow', needsApproval: false }\n if (base === 'deny') return { decision: 'deny', needsApproval: false }\n\n // base === 'ask' — consult the client's decision.\n const granted = input.approvals?.get(input.id)\n if (granted === true) return { decision: 'allow', needsApproval: false }\n if (granted === false) return { decision: 'deny', needsApproval: false }\n return { decision: 'deny', needsApproval: true }\n}\n\n/** Build the AG-UI `approval-requested` CUSTOM event for a harness action. */\nexport function buildApprovalRequestedEvent(input: {\n approvalId: string\n title: string\n threadId: string\n runId: string\n detail?: Record<string, unknown>\n}): StreamChunk {\n return withTanstackMetadata(\n {\n type: EventType.CUSTOM,\n name: APPROVAL_REQUESTED_EVENT,\n value: {\n approvalId: input.approvalId,\n title: input.title,\n ...(input.detail ?? {}),\n },\n timestamp: Date.now(),\n },\n { threadId: input.threadId, runId: input.runId },\n ) as StreamChunk\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAuBA,IAAa,2BAA2B;;AAGxC,SAAgB,WAAW,OAIhB;CACT,OAAO,GAAG,MAAM,SAAS,GAAG,MAAM,KAAK,GAAG,MAAM;AAClD;;AAuBA,SAAgB,gBAAgB,OAA8C;CAC5E,MAAM,OACJ,MAAM,YAAY,KAAA,IACd,gBAAgB,MAAM,SAAS,MAAM,QAAQ,MAAM,OAAO,IAC1D,MAAM,eAAe,KAAA,IAClB,MAAM,QAAQ,eAAe,MAAM,eACpC,MAAM,QAAQ,WACd,QACC,MAAM,QAAQ,WAAW;CAElC,IAAI,SAAS,SAAS,OAAO;EAAE,UAAU;EAAS,eAAe;CAAM;CACvE,IAAI,SAAS,QAAQ,OAAO;EAAE,UAAU;EAAQ,eAAe;CAAM;CAGrE,MAAM,UAAU,MAAM,WAAW,IAAI,MAAM,EAAE;CAC7C,IAAI,YAAY,MAAM,OAAO;EAAE,UAAU;EAAS,eAAe;CAAM;CACvE,IAAI,YAAY,OAAO,OAAO;EAAE,UAAU;EAAQ,eAAe;CAAM;CACvE,OAAO;EAAE,UAAU;EAAQ,eAAe;CAAK;AACjD;;AAGA,SAAgB,4BAA4B,OAM5B;CACd,OAAO,qBACL;EACE,MAAM,UAAU;EAChB,MAAM;EACN,OAAO;GACL,YAAY,MAAM;GAClB,OAAO,MAAM;GACb,GAAI,MAAM,UAAU,CAAC;EACvB;EACA,WAAW,KAAK,IAAI;CACtB,GACA;EAAE,UAAU,MAAM;EAAU,OAAO,MAAM;CAAM,CACjD;AACF"}
@@ -1,4 +1,4 @@
1
- import { EventType } from "@tanstack/ai";
1
+ import { EventType, withTanstackMetadata } from "@tanstack/ai";
2
2
  //#region src/bridge-events.ts
3
3
  /**
4
4
  * Helpers that let a harness adapter surface custom events emitted by BRIDGED
@@ -34,15 +34,16 @@ function createBridgeEventChannel(meta) {
34
34
  return {
35
35
  emitCustomEvent(eventName, value) {
36
36
  if (closed) return;
37
- buffer.push({
37
+ buffer.push(withTanstackMetadata({
38
38
  type: EventType.CUSTOM,
39
39
  name: eventName,
40
40
  value,
41
- timestamp: Date.now(),
41
+ timestamp: Date.now()
42
+ }, {
42
43
  model: meta.model,
43
- ...meta.threadId !== void 0 && { threadId: meta.threadId },
44
- ...meta.runId !== void 0 && { runId: meta.runId }
45
- });
44
+ ...meta.threadId !== void 0 ? { threadId: meta.threadId } : {},
45
+ ...meta.runId !== void 0 ? { runId: meta.runId } : {}
46
+ }));
46
47
  notify?.();
47
48
  },
48
49
  close() {
@@ -1 +1 @@
1
- {"version":3,"file":"bridge-events.js","names":[],"sources":["../../src/bridge-events.ts"],"sourcesContent":["/**\n * Helpers that let a harness adapter surface custom events emitted by BRIDGED\n * tools (via {@link ToolBridgeCoreOptions.emitCustomEvent}) on its live output\n * stream.\n *\n * The bridge runs out-of-band from the harness's own event stream, so a bridged\n * tool's progress/console events have no path to the client on their own. An\n * adapter creates a {@link BridgeEventChannel}, hands its `emitCustomEvent` to\n * the bridge provisioner, and {@link mergeChunkStreams | merges} the channel's\n * stream into its translated output — so events interleave live while the agent\n * runs (e.g. code mode's `code_mode:console` logs during a long execution).\n */\nimport { EventType } from '@tanstack/ai'\nimport type { StreamChunk } from '@tanstack/ai'\n\nexport interface BridgeEventChannel {\n /** Pass as the bridge's `emitCustomEvent`; buffers a CUSTOM chunk for the stream. */\n emitCustomEvent: (eventName: string, value: Record<string, unknown>) => void\n /** Live CUSTOM-chunk stream; ends after {@link close} once drained. */\n stream: AsyncIterable<StreamChunk>\n /** Stop the stream (call when the run's main output is done). */\n close: () => void\n}\n\n/** Create a channel whose emitted events become CUSTOM {@link StreamChunk}s. */\nexport function createBridgeEventChannel(meta: {\n model: string\n threadId?: string\n runId?: string\n}): BridgeEventChannel {\n const buffer: Array<StreamChunk> = []\n let notify: (() => void) | null = null\n let closed = false\n\n async function* stream(): AsyncIterable<StreamChunk> {\n for (;;) {\n const next = buffer.shift()\n if (next !== undefined) {\n yield next\n continue\n }\n if (closed) return\n await new Promise<void>((resolve) => {\n notify = resolve\n })\n notify = null\n }\n }\n\n return {\n emitCustomEvent(eventName, value) {\n if (closed) return\n buffer.push({\n type: EventType.CUSTOM,\n name: eventName,\n value,\n timestamp: Date.now(),\n model: meta.model,\n ...(meta.threadId !== undefined && { threadId: meta.threadId }),\n ...(meta.runId !== undefined && { runId: meta.runId }),\n })\n notify?.()\n },\n close() {\n closed = true\n notify?.()\n },\n stream: stream(),\n }\n}\n\n/**\n * Merge a `side` chunk stream into a `base` chunk stream, yielding from whichever\n * settles first. Terminates when `base` ends (the run is over), then releases the\n * side iterator — so a never-ending channel (until closed) doesn't hang the merge.\n */\nexport async function* mergeChunkStreams(\n base: AsyncIterable<StreamChunk>,\n side: AsyncIterable<StreamChunk>,\n): AsyncIterable<StreamChunk> {\n const baseIt = base[Symbol.asyncIterator]()\n const sideIt = side[Symbol.asyncIterator]()\n let baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r }))\n let sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r }))\n let sideLive = true\n try {\n for (;;) {\n const winner = await Promise.race(\n sideLive ? [baseNext, sideNext] : [baseNext],\n )\n if (winner.from === 'base') {\n if (winner.r.done) return\n yield winner.r.value\n baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r }))\n } else if (winner.r.done) {\n sideLive = false\n } else {\n yield winner.r.value\n sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r }))\n }\n }\n } finally {\n // Fire-and-forget: do NOT await the side return. The channel generator is\n // suspended on a promise that only `close()` resolves, and `close()` runs in\n // the adapter's `finally` AFTER this merge completes — awaiting here would\n // deadlock. The adapter's `close()` lets the generator unwind afterwards.\n const baseReturn = baseIt.return?.(undefined)\n if (baseReturn) void baseReturn.catch(() => {})\n const sideReturn = sideIt.return?.(undefined)\n if (sideReturn) void sideReturn.catch(() => {})\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAyBA,SAAgB,yBAAyB,MAIlB;CACrB,MAAM,SAA6B,CAAC;CACpC,IAAI,SAA8B;CAClC,IAAI,SAAS;CAEb,gBAAgB,SAAqC;EACnD,SAAS;GACP,MAAM,OAAO,OAAO,MAAM;GAC1B,IAAI,SAAS,KAAA,GAAW;IACtB,MAAM;IACN;GACF;GACA,IAAI,QAAQ;GACZ,MAAM,IAAI,SAAe,YAAY;IACnC,SAAS;GACX,CAAC;GACD,SAAS;EACX;CACF;CAEA,OAAO;EACL,gBAAgB,WAAW,OAAO;GAChC,IAAI,QAAQ;GACZ,OAAO,KAAK;IACV,MAAM,UAAU;IAChB,MAAM;IACN;IACA,WAAW,KAAK,IAAI;IACpB,OAAO,KAAK;IACZ,GAAI,KAAK,aAAa,KAAA,KAAa,EAAE,UAAU,KAAK,SAAS;IAC7D,GAAI,KAAK,UAAU,KAAA,KAAa,EAAE,OAAO,KAAK,MAAM;GACtD,CAAC;GACD,SAAS;EACX;EACA,QAAQ;GACN,SAAS;GACT,SAAS;EACX;EACA,QAAQ,OAAO;CACjB;AACF;;;;;;AAOA,gBAAuB,kBACrB,MACA,MAC4B;CAC5B,MAAM,SAAS,KAAK,OAAO,cAAc,CAAC;CAC1C,MAAM,SAAS,KAAK,OAAO,cAAc,CAAC;CAC1C,IAAI,WAAW,OAAO,KAAK,CAAC,CAAC,MAAM,OAAO;EAAE,MAAM;EAAiB;CAAE,EAAE;CACvE,IAAI,WAAW,OAAO,KAAK,CAAC,CAAC,MAAM,OAAO;EAAE,MAAM;EAAiB;CAAE,EAAE;CACvE,IAAI,WAAW;CACf,IAAI;EACF,SAAS;GACP,MAAM,SAAS,MAAM,QAAQ,KAC3B,WAAW,CAAC,UAAU,QAAQ,IAAI,CAAC,QAAQ,CAC7C;GACA,IAAI,OAAO,SAAS,QAAQ;IAC1B,IAAI,OAAO,EAAE,MAAM;IACnB,MAAM,OAAO,EAAE;IACf,WAAW,OAAO,KAAK,CAAC,CAAC,MAAM,OAAO;KAAE,MAAM;KAAiB;IAAE,EAAE;GACrE,OAAO,IAAI,OAAO,EAAE,MAClB,WAAW;QACN;IACL,MAAM,OAAO,EAAE;IACf,WAAW,OAAO,KAAK,CAAC,CAAC,MAAM,OAAO;KAAE,MAAM;KAAiB;IAAE,EAAE;GACrE;EACF;CACF,UAAU;EAKR,MAAM,aAAa,OAAO,SAAS,KAAA,CAAS;EAC5C,IAAI,YAAY,WAAgB,YAAY,CAAC,CAAC;EAC9C,MAAM,aAAa,OAAO,SAAS,KAAA,CAAS;EAC5C,IAAI,YAAY,WAAgB,YAAY,CAAC,CAAC;CAChD;AACF"}
1
+ {"version":3,"file":"bridge-events.js","names":[],"sources":["../../src/bridge-events.ts"],"sourcesContent":["/**\n * Helpers that let a harness adapter surface custom events emitted by BRIDGED\n * tools (via {@link ToolBridgeCoreOptions.emitCustomEvent}) on its live output\n * stream.\n *\n * The bridge runs out-of-band from the harness's own event stream, so a bridged\n * tool's progress/console events have no path to the client on their own. An\n * adapter creates a {@link BridgeEventChannel}, hands its `emitCustomEvent` to\n * the bridge provisioner, and {@link mergeChunkStreams | merges} the channel's\n * stream into its translated output — so events interleave live while the agent\n * runs (e.g. code mode's `code_mode:console` logs during a long execution).\n */\nimport { EventType, withTanstackMetadata } from '@tanstack/ai'\nimport type { StreamChunk } from '@tanstack/ai'\n\nexport interface BridgeEventChannel {\n /** Pass as the bridge's `emitCustomEvent`; buffers a CUSTOM chunk for the stream. */\n emitCustomEvent: (eventName: string, value: Record<string, unknown>) => void\n /** Live CUSTOM-chunk stream; ends after {@link close} once drained. */\n stream: AsyncIterable<StreamChunk>\n /** Stop the stream (call when the run's main output is done). */\n close: () => void\n}\n\n/** Create a channel whose emitted events become CUSTOM {@link StreamChunk}s. */\nexport function createBridgeEventChannel(meta: {\n model: string\n threadId?: string\n runId?: string\n}): BridgeEventChannel {\n const buffer: Array<StreamChunk> = []\n let notify: (() => void) | null = null\n let closed = false\n\n async function* stream(): AsyncIterable<StreamChunk> {\n for (;;) {\n const next = buffer.shift()\n if (next !== undefined) {\n yield next\n continue\n }\n if (closed) return\n await new Promise<void>((resolve) => {\n notify = resolve\n })\n notify = null\n }\n }\n\n return {\n emitCustomEvent(eventName, value) {\n if (closed) return\n buffer.push(\n withTanstackMetadata(\n {\n type: EventType.CUSTOM,\n name: eventName,\n value,\n timestamp: Date.now(),\n },\n {\n model: meta.model,\n ...(meta.threadId !== undefined ? { threadId: meta.threadId } : {}),\n ...(meta.runId !== undefined ? { runId: meta.runId } : {}),\n },\n ) as StreamChunk,\n )\n notify?.()\n },\n close() {\n closed = true\n notify?.()\n },\n stream: stream(),\n }\n}\n\n/**\n * Merge a `side` chunk stream into a `base` chunk stream, yielding from whichever\n * settles first. Terminates when `base` ends (the run is over), then releases the\n * side iterator — so a never-ending channel (until closed) doesn't hang the merge.\n */\nexport async function* mergeChunkStreams(\n base: AsyncIterable<StreamChunk>,\n side: AsyncIterable<StreamChunk>,\n): AsyncIterable<StreamChunk> {\n const baseIt = base[Symbol.asyncIterator]()\n const sideIt = side[Symbol.asyncIterator]()\n let baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r }))\n let sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r }))\n let sideLive = true\n try {\n for (;;) {\n const winner = await Promise.race(\n sideLive ? [baseNext, sideNext] : [baseNext],\n )\n if (winner.from === 'base') {\n if (winner.r.done) return\n yield winner.r.value\n baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r }))\n } else if (winner.r.done) {\n sideLive = false\n } else {\n yield winner.r.value\n sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r }))\n }\n }\n } finally {\n // Fire-and-forget: do NOT await the side return. The channel generator is\n // suspended on a promise that only `close()` resolves, and `close()` runs in\n // the adapter's `finally` AFTER this merge completes — awaiting here would\n // deadlock. The adapter's `close()` lets the generator unwind afterwards.\n const baseReturn = baseIt.return?.(undefined)\n if (baseReturn) void baseReturn.catch(() => {})\n const sideReturn = sideIt.return?.(undefined)\n if (sideReturn) void sideReturn.catch(() => {})\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAyBA,SAAgB,yBAAyB,MAIlB;CACrB,MAAM,SAA6B,CAAC;CACpC,IAAI,SAA8B;CAClC,IAAI,SAAS;CAEb,gBAAgB,SAAqC;EACnD,SAAS;GACP,MAAM,OAAO,OAAO,MAAM;GAC1B,IAAI,SAAS,KAAA,GAAW;IACtB,MAAM;IACN;GACF;GACA,IAAI,QAAQ;GACZ,MAAM,IAAI,SAAe,YAAY;IACnC,SAAS;GACX,CAAC;GACD,SAAS;EACX;CACF;CAEA,OAAO;EACL,gBAAgB,WAAW,OAAO;GAChC,IAAI,QAAQ;GACZ,OAAO,KACL,qBACE;IACE,MAAM,UAAU;IAChB,MAAM;IACN;IACA,WAAW,KAAK,IAAI;GACtB,GACA;IACE,OAAO,KAAK;IACZ,GAAI,KAAK,aAAa,KAAA,IAAY,EAAE,UAAU,KAAK,SAAS,IAAI,CAAC;IACjE,GAAI,KAAK,UAAU,KAAA,IAAY,EAAE,OAAO,KAAK,MAAM,IAAI,CAAC;GAC1D,CACF,CACF;GACA,SAAS;EACX;EACA,QAAQ;GACN,SAAS;GACT,SAAS;EACX;EACA,QAAQ,OAAO;CACjB;AACF;;;;;;AAOA,gBAAuB,kBACrB,MACA,MAC4B;CAC5B,MAAM,SAAS,KAAK,OAAO,cAAc,CAAC;CAC1C,MAAM,SAAS,KAAK,OAAO,cAAc,CAAC;CAC1C,IAAI,WAAW,OAAO,KAAK,CAAC,CAAC,MAAM,OAAO;EAAE,MAAM;EAAiB;CAAE,EAAE;CACvE,IAAI,WAAW,OAAO,KAAK,CAAC,CAAC,MAAM,OAAO;EAAE,MAAM;EAAiB;CAAE,EAAE;CACvE,IAAI,WAAW;CACf,IAAI;EACF,SAAS;GACP,MAAM,SAAS,MAAM,QAAQ,KAC3B,WAAW,CAAC,UAAU,QAAQ,IAAI,CAAC,QAAQ,CAC7C;GACA,IAAI,OAAO,SAAS,QAAQ;IAC1B,IAAI,OAAO,EAAE,MAAM;IACnB,MAAM,OAAO,EAAE;IACf,WAAW,OAAO,KAAK,CAAC,CAAC,MAAM,OAAO;KAAE,MAAM;KAAiB;IAAE,EAAE;GACrE,OAAO,IAAI,OAAO,EAAE,MAClB,WAAW;QACN;IACL,MAAM,OAAO,EAAE;IACf,WAAW,OAAO,KAAK,CAAC,CAAC,MAAM,OAAO;KAAE,MAAM;KAAiB;IAAE,EAAE;GACrE;EACF;CACF,UAAU;EAKR,MAAM,aAAa,OAAO,SAAS,KAAA,CAAS;EAC5C,IAAI,YAAY,WAAgB,YAAY,CAAC,CAAC;EAC9C,MAAM,aAAa,OAAO,SAAS,KAAA,CAAS;EAC5C,IAAI,YAAY,WAAgB,YAAY,CAAC,CAAC;CAChD;AACF"}
@@ -11,23 +11,6 @@ import { StreamChunk } from '@tanstack/ai';
11
11
  * seeded fresh for every call to this factory.
12
12
  */
13
13
  export declare function createRunScopedIdGen(runId: string): () => string;
14
- /**
15
- * A stable, order-independent identity for a chunk, excluding wall-clock
16
- * fields. Used to recognize the chunks a previous host already appended.
17
- *
18
- * - **Key-order independent**: object keys are sorted before stringifying, so
19
- * a JSON round trip through the journal (which does not preserve key order)
20
- * cannot spuriously diverge.
21
- * - **Recurses into nested arrays and objects**: tool-call arguments are
22
- * nested, and a shallow fingerprint would miss a changed argument.
23
- * - **Excludes exactly `VOLATILE_FIELDS`** (`timestamp`) — everything else
24
- * participates, including fields whose value is `undefined`.
25
- * - **Distinguishes present-but-`undefined` from absent**: `undefined` is
26
- * encoded as the sentinel string `"__undefined__"` rather than dropped, so
27
- * `{a: undefined}` and `{}` do not collide. A translator emitting an
28
- * explicit `undefined` is a different chunk shape and must fingerprint
29
- * differently.
30
- */
31
14
  export declare function chunkFingerprint(chunk: StreamChunk): string;
32
15
  /**
33
16
  * {@link chunkFingerprint} with the chunk's own `threadId` also excluded.
@@ -1,3 +1,4 @@
1
+ import { isSpecTopLevelKey, tanstackMetadata } from "@tanstack/ai/adapter-internals";
1
2
  //#region src/chunk-identity.ts
2
3
  /**
3
4
  * A deterministic id generator scoped to one run.
@@ -59,16 +60,36 @@ function stableStringify(value, dropped) {
59
60
  * cannot spuriously diverge.
60
61
  * - **Recurses into nested arrays and objects**: tool-call arguments are
61
62
  * nested, and a shallow fingerprint would miss a changed argument.
62
- * - **Excludes exactly `VOLATILE_FIELDS`** (`timestamp`) — everything else
63
- * participates, including fields whose value is `undefined`.
63
+ * - **Excludes `timestamp` and leftover adapter extras.** Spec keys
64
+ * participate, including `undefined` values. `metadata.tanstack` is dropped
65
+ * so stored spec chunks match live adapter yields.
64
66
  * - **Distinguishes present-but-`undefined` from absent**: `undefined` is
65
67
  * encoded as the sentinel string `"__undefined__"` rather than dropped, so
66
68
  * `{a: undefined}` and `{}` do not collide. A translator emitting an
67
69
  * explicit `undefined` is a different chunk shape and must fingerprint
68
70
  * differently.
69
71
  */
72
+ function fingerprintableChunk(chunk) {
73
+ const out = {};
74
+ for (const [key, value] of Object.entries(chunk)) {
75
+ if (key === "timestamp") continue;
76
+ if (!isSpecTopLevelKey(chunk.type, key)) continue;
77
+ if (key === "metadata" && value != null && typeof value === "object") {
78
+ const rest = {};
79
+ for (const [metaKey, metaValue] of Object.entries(value)) {
80
+ if (metaKey === "tanstack") continue;
81
+ rest[metaKey] = metaValue;
82
+ }
83
+ if (Object.keys(rest).length === 0) continue;
84
+ out.metadata = rest;
85
+ continue;
86
+ }
87
+ out[key] = value;
88
+ }
89
+ return out;
90
+ }
70
91
  function chunkFingerprint(chunk) {
71
- return stableStringify(chunk, VOLATILE_FIELDS);
92
+ return stableStringify(fingerprintableChunk(chunk), VOLATILE_FIELDS);
72
93
  }
73
94
  /**
74
95
  * {@link chunkFingerprint} with the chunk's own `threadId` also excluded.
@@ -82,7 +103,7 @@ function chunkFingerprint(chunk) {
82
103
  * `JournalReplayThreadIdMismatchError` in `align.ts`).
83
104
  */
84
105
  function chunkFingerprintIgnoringThreadId(chunk) {
85
- return stableStringify(chunk, VOLATILE_AND_THREAD_ID);
106
+ return stableStringify(fingerprintableChunk(chunk), VOLATILE_AND_THREAD_ID);
86
107
  }
87
108
  /**
88
109
  * A chunk's own `threadId`, or `undefined` when it carries none.
@@ -94,7 +115,9 @@ function chunkFingerprintIgnoringThreadId(chunk) {
94
115
  */
95
116
  function chunkThreadId(chunk) {
96
117
  const value = chunk[THREAD_ID_FIELD];
97
- return typeof value === "string" ? value : void 0;
118
+ if (typeof value === "string") return value;
119
+ const nested = tanstackMetadata(chunk)?.threadId;
120
+ return typeof nested === "string" ? nested : void 0;
98
121
  }
99
122
  //#endregion
100
123
  export { chunkFingerprint, chunkFingerprintIgnoringThreadId, chunkThreadId, createRunScopedIdGen };
@@ -1 +1 @@
1
- {"version":3,"file":"chunk-identity.js","names":[],"sources":["../../src/chunk-identity.ts"],"sourcesContent":["/**\n * Deterministic chunk identity — the prerequisite that makes journal replay\n * exact.\n *\n * The design premise is that re-translating a journal prefix reproduces the\n * chunks a previous host already delivered, so a successor can recognize and\n * skip them (see `align.ts`, a later task). Two things in the default path\n * break that premise:\n *\n * 1. `ChatAdapter.generateId()` — `packages/ai/src/activities/chat/adapter.ts:227`,\n * read directly for this task — is:\n *\n * ```ts\n * protected generateId(): string {\n * return `${this.name}-${Date.now()}-${Math.random().toString(36).substring(7)}`\n * }\n * ```\n *\n * and every harness translator mints message ids through it (wired as\n * `genId` in the Grok Build, Claude Code, and Codex text adapters). Both\n * `Date.now()` and `Math.random()` are non-reproducible: replaying the same\n * journal bytes through a second `generateId()` call produces a different\n * id every time, so \"same bytes ⇒ same chunks\" is false on the journaled\n * path today. {@link createRunScopedIdGen} replaces it with a run-scoped\n * counter that has neither a clock nor randomness, so two generators built\n * from the same `runId` always produce the same sequence.\n * 2. Chunks also carry `timestamp: Date.now()`, which cannot be reproduced at\n * all, deterministic id or not. {@link chunkFingerprint} therefore excludes\n * exactly that field — nothing downstream keys on a chunk's timestamp, so\n * leaving it wall-clock is safe, but every other field must participate in\n * the comparison or a real divergence would go undetected.\n */\nimport type { StreamChunk } from '@tanstack/ai'\n\n/**\n * A deterministic id generator scoped to one run.\n *\n * Passed as the harness translators' `genId`, so translating the same journal\n * prefix twice mints the same message ids. The counter is per-generator, so a\n * replay must create a fresh one and start from the journal's first byte —\n * which is exactly what the alignment step (a later task) assumes.\n *\n * No clock, no `Math.random`, no crypto: `next` is the only state, and it is\n * seeded fresh for every call to this factory.\n */\nexport function createRunScopedIdGen(runId: string): () => string {\n let next = 0\n return () => {\n const id = `${runId}-${next}`\n next += 1\n return id\n }\n}\n\n/**\n * Fields excluded from a fingerprint because they are wall-clock and therefore\n * unreproducible. Kept as an explicit set so adding one is a deliberate,\n * reviewable act rather than a silent loosening of the comparison.\n */\nconst VOLATILE_FIELDS: ReadonlySet<string> = new Set(['timestamp'])\n\n/**\n * The conversation id every chunk carries. Excluded from\n * {@link chunkFingerprintIgnoringThreadId} — and ONLY from that variant — so\n * alignment can tell an id-only mismatch from a real content divergence.\n */\nconst THREAD_ID_FIELD = 'threadId'\n\nconst VOLATILE_AND_THREAD_ID: ReadonlySet<string> = new Set([\n ...VOLATILE_FIELDS,\n THREAD_ID_FIELD,\n])\n\n/**\n * `dropped` applies at the TOP LEVEL only (nested calls pass `undefined`).\n * A `threadId` nested inside, say, a tool call's arguments is real content and\n * must keep participating in the comparison.\n */\nfunction stableStringify(\n value: unknown,\n dropped: ReadonlySet<string> | undefined,\n): string {\n if (value === null) return 'null'\n if (Array.isArray(value)) {\n return `[${value.map((item) => stableStringify(item, undefined)).join(',')}]`\n }\n if (typeof value === 'object') {\n const record: Record<string, unknown> = value as Record<string, unknown>\n const keys = Object.keys(record)\n .filter((key) => dropped === undefined || !dropped.has(key))\n .sort()\n const parts = keys.map((key) => {\n const entry = record[key]\n const encoded =\n entry === undefined\n ? '\"__undefined__\"'\n : stableStringify(entry, undefined)\n return `${JSON.stringify(key)}:${encoded}`\n })\n return `{${parts.join(',')}}`\n }\n const encoded = JSON.stringify(value)\n return encoded === undefined ? 'null' : encoded\n}\n\n/**\n * A stable, order-independent identity for a chunk, excluding wall-clock\n * fields. Used to recognize the chunks a previous host already appended.\n *\n * - **Key-order independent**: object keys are sorted before stringifying, so\n * a JSON round trip through the journal (which does not preserve key order)\n * cannot spuriously diverge.\n * - **Recurses into nested arrays and objects**: tool-call arguments are\n * nested, and a shallow fingerprint would miss a changed argument.\n * - **Excludes exactly `VOLATILE_FIELDS`** (`timestamp`) — everything else\n * participates, including fields whose value is `undefined`.\n * - **Distinguishes present-but-`undefined` from absent**: `undefined` is\n * encoded as the sentinel string `\"__undefined__\"` rather than dropped, so\n * `{a: undefined}` and `{}` do not collide. A translator emitting an\n * explicit `undefined` is a different chunk shape and must fingerprint\n * differently.\n */\nexport function chunkFingerprint(chunk: StreamChunk): string {\n return stableStringify(chunk, VOLATILE_FIELDS)\n}\n\n/**\n * {@link chunkFingerprint} with the chunk's own `threadId` also excluded.\n *\n * NOT an alternative identity — never use it to decide that two chunks are the\n * same. Its single purpose is DIAGNOSIS: when a replay diverges from the stored\n * log, comparing both fingerprints answers \"did the agent behave differently, or\n * did only the conversation id move?\". Two chunks that match here but not under\n * {@link chunkFingerprint} differ in `threadId` and nothing else, which is a\n * misconfigured attach route rather than a determinism regression (see\n * `JournalReplayThreadIdMismatchError` in `align.ts`).\n */\nexport function chunkFingerprintIgnoringThreadId(chunk: StreamChunk): string {\n return stableStringify(chunk, VOLATILE_AND_THREAD_ID)\n}\n\n/**\n * A chunk's own `threadId`, or `undefined` when it carries none.\n *\n * Reads the field structurally rather than narrowing on `chunk.type`: nearly\n * every member of the `StreamChunk` union declares `threadId?: string`, and an\n * exhaustive switch would have to be revisited for each new member while adding\n * nothing — a chunk with no `threadId` is exactly the `undefined` case.\n */\nexport function chunkThreadId(chunk: StreamChunk): string | undefined {\n const record: Record<string, unknown> = chunk as Record<string, unknown>\n const value = record[THREAD_ID_FIELD]\n return typeof value === 'string' ? value : undefined\n}\n"],"mappings":";;;;;;;;;;;;AA6CA,SAAgB,qBAAqB,OAA6B;CAChE,IAAI,OAAO;CACX,aAAa;EACX,MAAM,KAAK,GAAG,MAAM,GAAG;EACvB,QAAQ;EACR,OAAO;CACT;AACF;;;;;;AAOA,IAAM,kCAAuC,IAAI,IAAI,CAAC,WAAW,CAAC;;;;;;AAOlE,IAAM,kBAAkB;AAExB,IAAM,yCAA8C,IAAI,IAAI,CAC1D,GAAG,iBACH,eACF,CAAC;;;;;;AAOD,SAAS,gBACP,OACA,SACQ;CACR,IAAI,UAAU,MAAM,OAAO;CAC3B,IAAI,MAAM,QAAQ,KAAK,GACrB,OAAO,IAAI,MAAM,KAAK,SAAS,gBAAgB,MAAM,KAAA,CAAS,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE;CAE7E,IAAI,OAAO,UAAU,UAAU;EAC7B,MAAM,SAAkC;EAYxC,OAAO,IAXM,OAAO,KAAK,MAAM,CAAC,CAC7B,QAAQ,QAAQ,YAAY,KAAA,KAAa,CAAC,QAAQ,IAAI,GAAG,CAAC,CAAC,CAC3D,KACW,CAAA,CAAK,KAAK,QAAQ;GAC9B,MAAM,QAAQ,OAAO;GACrB,MAAM,UACJ,UAAU,KAAA,IACN,sBACA,gBAAgB,OAAO,KAAA,CAAS;GACtC,OAAO,GAAG,KAAK,UAAU,GAAG,EAAE,GAAG;EACnC,CACW,CAAA,CAAM,KAAK,GAAG,EAAE;CAC7B;CACA,MAAM,UAAU,KAAK,UAAU,KAAK;CACpC,OAAO,YAAY,KAAA,IAAY,SAAS;AAC1C;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,iBAAiB,OAA4B;CAC3D,OAAO,gBAAgB,OAAO,eAAe;AAC/C;;;;;;;;;;;;AAaA,SAAgB,iCAAiC,OAA4B;CAC3E,OAAO,gBAAgB,OAAO,sBAAsB;AACtD;;;;;;;;;AAUA,SAAgB,cAAc,OAAwC;CAEpE,MAAM,QAAQ,MAAO;CACrB,OAAO,OAAO,UAAU,WAAW,QAAQ,KAAA;AAC7C"}
1
+ {"version":3,"file":"chunk-identity.js","names":[],"sources":["../../src/chunk-identity.ts"],"sourcesContent":["/**\n * Deterministic chunk identity — the prerequisite that makes journal replay\n * exact.\n *\n * The design premise is that re-translating a journal prefix reproduces the\n * chunks a previous host already delivered, so a successor can recognize and\n * skip them (see `align.ts`, a later task). Two things in the default path\n * break that premise:\n *\n * 1. `ChatAdapter.generateId()` — `packages/ai/src/activities/chat/adapter.ts:227`,\n * read directly for this task — is:\n *\n * ```ts\n * protected generateId(): string {\n * return `${this.name}-${Date.now()}-${Math.random().toString(36).substring(7)}`\n * }\n * ```\n *\n * and every harness translator mints message ids through it (wired as\n * `genId` in the Grok Build, Claude Code, and Codex text adapters). Both\n * `Date.now()` and `Math.random()` are non-reproducible: replaying the same\n * journal bytes through a second `generateId()` call produces a different\n * id every time, so \"same bytes ⇒ same chunks\" is false on the journaled\n * path today. {@link createRunScopedIdGen} replaces it with a run-scoped\n * counter that has neither a clock nor randomness, so two generators built\n * from the same `runId` always produce the same sequence.\n * 2. Chunks also carry `timestamp: Date.now()`, which cannot be reproduced at\n * all, deterministic id or not. {@link chunkFingerprint} therefore excludes\n * exactly that field — nothing downstream keys on a chunk's timestamp, so\n * leaving it wall-clock is safe, but every other field must participate in\n * the comparison or a real divergence would go undetected.\n * 3. Adapter yields still carry leftover TanStack extras (`content`, `args`,\n * `finishReason`). The durability log stores spec chunks. Fingerprints keep\n * only AG-UI spec keys and drop `metadata.tanstack`, so a live adapter yield\n * matches the stored spec chunk.\n */\nimport type { StreamChunk } from '@tanstack/ai'\nimport {\n isSpecTopLevelKey,\n tanstackMetadata,\n} from '@tanstack/ai/adapter-internals'\n\n/**\n * A deterministic id generator scoped to one run.\n *\n * Passed as the harness translators' `genId`, so translating the same journal\n * prefix twice mints the same message ids. The counter is per-generator, so a\n * replay must create a fresh one and start from the journal's first byte —\n * which is exactly what the alignment step (a later task) assumes.\n *\n * No clock, no `Math.random`, no crypto: `next` is the only state, and it is\n * seeded fresh for every call to this factory.\n */\nexport function createRunScopedIdGen(runId: string): () => string {\n let next = 0\n return () => {\n const id = `${runId}-${next}`\n next += 1\n return id\n }\n}\n\n/**\n * Fields excluded from a fingerprint because they are wall-clock and therefore\n * unreproducible. Kept as an explicit set so adding one is a deliberate,\n * reviewable act rather than a silent loosening of the comparison.\n */\nconst VOLATILE_FIELDS: ReadonlySet<string> = new Set(['timestamp'])\n\n/**\n * The conversation id every chunk carries. Excluded from\n * {@link chunkFingerprintIgnoringThreadId} — and ONLY from that variant — so\n * alignment can tell an id-only mismatch from a real content divergence.\n */\nconst THREAD_ID_FIELD = 'threadId'\n\nconst VOLATILE_AND_THREAD_ID: ReadonlySet<string> = new Set([\n ...VOLATILE_FIELDS,\n THREAD_ID_FIELD,\n])\n\n/**\n * `dropped` applies at the TOP LEVEL only (nested calls pass `undefined`).\n * A `threadId` nested inside, say, a tool call's arguments is real content and\n * must keep participating in the comparison.\n */\nfunction stableStringify(\n value: unknown,\n dropped: ReadonlySet<string> | undefined,\n): string {\n if (value === null) return 'null'\n if (Array.isArray(value)) {\n return `[${value.map((item) => stableStringify(item, undefined)).join(',')}]`\n }\n if (typeof value === 'object') {\n const record: Record<string, unknown> = value as Record<string, unknown>\n const keys = Object.keys(record)\n .filter((key) => dropped === undefined || !dropped.has(key))\n .sort()\n const parts = keys.map((key) => {\n const entry = record[key]\n const encoded =\n entry === undefined\n ? '\"__undefined__\"'\n : stableStringify(entry, undefined)\n return `${JSON.stringify(key)}:${encoded}`\n })\n return `{${parts.join(',')}}`\n }\n const encoded = JSON.stringify(value)\n return encoded === undefined ? 'null' : encoded\n}\n\n/**\n * A stable, order-independent identity for a chunk, excluding wall-clock\n * fields. Used to recognize the chunks a previous host already appended.\n *\n * - **Key-order independent**: object keys are sorted before stringifying, so\n * a JSON round trip through the journal (which does not preserve key order)\n * cannot spuriously diverge.\n * - **Recurses into nested arrays and objects**: tool-call arguments are\n * nested, and a shallow fingerprint would miss a changed argument.\n * - **Excludes `timestamp` and leftover adapter extras.** Spec keys\n * participate, including `undefined` values. `metadata.tanstack` is dropped\n * so stored spec chunks match live adapter yields.\n * - **Distinguishes present-but-`undefined` from absent**: `undefined` is\n * encoded as the sentinel string `\"__undefined__\"` rather than dropped, so\n * `{a: undefined}` and `{}` do not collide. A translator emitting an\n * explicit `undefined` is a different chunk shape and must fingerprint\n * differently.\n */\nfunction fingerprintableChunk(chunk: StreamChunk): Record<string, unknown> {\n const out: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(chunk)) {\n if (key === 'timestamp') continue\n if (!isSpecTopLevelKey(chunk.type, key)) continue\n if (key === 'metadata' && value != null && typeof value === 'object') {\n const rest: Record<string, unknown> = {}\n for (const [metaKey, metaValue] of Object.entries(value)) {\n if (metaKey === 'tanstack') continue\n rest[metaKey] = metaValue\n }\n if (Object.keys(rest).length === 0) continue\n out.metadata = rest\n continue\n }\n out[key] = value\n }\n return out\n}\n\nexport function chunkFingerprint(chunk: StreamChunk): string {\n return stableStringify(fingerprintableChunk(chunk), VOLATILE_FIELDS)\n}\n\n/**\n * {@link chunkFingerprint} with the chunk's own `threadId` also excluded.\n *\n * NOT an alternative identity — never use it to decide that two chunks are the\n * same. Its single purpose is DIAGNOSIS: when a replay diverges from the stored\n * log, comparing both fingerprints answers \"did the agent behave differently, or\n * did only the conversation id move?\". Two chunks that match here but not under\n * {@link chunkFingerprint} differ in `threadId` and nothing else, which is a\n * misconfigured attach route rather than a determinism regression (see\n * `JournalReplayThreadIdMismatchError` in `align.ts`).\n */\nexport function chunkFingerprintIgnoringThreadId(chunk: StreamChunk): string {\n return stableStringify(fingerprintableChunk(chunk), VOLATILE_AND_THREAD_ID)\n}\n\n/**\n * A chunk's own `threadId`, or `undefined` when it carries none.\n *\n * Reads the field structurally rather than narrowing on `chunk.type`: nearly\n * every member of the `StreamChunk` union declares `threadId?: string`, and an\n * exhaustive switch would have to be revisited for each new member while adding\n * nothing — a chunk with no `threadId` is exactly the `undefined` case.\n */\nexport function chunkThreadId(chunk: StreamChunk): string | undefined {\n const record: Record<string, unknown> = chunk as Record<string, unknown>\n const value = record[THREAD_ID_FIELD]\n if (typeof value === 'string') return value\n const nested = tanstackMetadata(chunk)?.threadId\n return typeof nested === 'string' ? nested : undefined\n}\n"],"mappings":";;;;;;;;;;;;;AAqDA,SAAgB,qBAAqB,OAA6B;CAChE,IAAI,OAAO;CACX,aAAa;EACX,MAAM,KAAK,GAAG,MAAM,GAAG;EACvB,QAAQ;EACR,OAAO;CACT;AACF;;;;;;AAOA,IAAM,kCAAuC,IAAI,IAAI,CAAC,WAAW,CAAC;;;;;;AAOlE,IAAM,kBAAkB;AAExB,IAAM,yCAA8C,IAAI,IAAI,CAC1D,GAAG,iBACH,eACF,CAAC;;;;;;AAOD,SAAS,gBACP,OACA,SACQ;CACR,IAAI,UAAU,MAAM,OAAO;CAC3B,IAAI,MAAM,QAAQ,KAAK,GACrB,OAAO,IAAI,MAAM,KAAK,SAAS,gBAAgB,MAAM,KAAA,CAAS,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE;CAE7E,IAAI,OAAO,UAAU,UAAU;EAC7B,MAAM,SAAkC;EAYxC,OAAO,IAXM,OAAO,KAAK,MAAM,CAAC,CAC7B,QAAQ,QAAQ,YAAY,KAAA,KAAa,CAAC,QAAQ,IAAI,GAAG,CAAC,CAAC,CAC3D,KACW,CAAA,CAAK,KAAK,QAAQ;GAC9B,MAAM,QAAQ,OAAO;GACrB,MAAM,UACJ,UAAU,KAAA,IACN,sBACA,gBAAgB,OAAO,KAAA,CAAS;GACtC,OAAO,GAAG,KAAK,UAAU,GAAG,EAAE,GAAG;EACnC,CACW,CAAA,CAAM,KAAK,GAAG,EAAE;CAC7B;CACA,MAAM,UAAU,KAAK,UAAU,KAAK;CACpC,OAAO,YAAY,KAAA,IAAY,SAAS;AAC1C;;;;;;;;;;;;;;;;;;;AAoBA,SAAS,qBAAqB,OAA6C;CACzE,MAAM,MAA+B,CAAC;CACtC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,GAAG;EAChD,IAAI,QAAQ,aAAa;EACzB,IAAI,CAAC,kBAAkB,MAAM,MAAM,GAAG,GAAG;EACzC,IAAI,QAAQ,cAAc,SAAS,QAAQ,OAAO,UAAU,UAAU;GACpE,MAAM,OAAgC,CAAC;GACvC,KAAK,MAAM,CAAC,SAAS,cAAc,OAAO,QAAQ,KAAK,GAAG;IACxD,IAAI,YAAY,YAAY;IAC5B,KAAK,WAAW;GAClB;GACA,IAAI,OAAO,KAAK,IAAI,CAAC,CAAC,WAAW,GAAG;GACpC,IAAI,WAAW;GACf;EACF;EACA,IAAI,OAAO;CACb;CACA,OAAO;AACT;AAEA,SAAgB,iBAAiB,OAA4B;CAC3D,OAAO,gBAAgB,qBAAqB,KAAK,GAAG,eAAe;AACrE;;;;;;;;;;;;AAaA,SAAgB,iCAAiC,OAA4B;CAC3E,OAAO,gBAAgB,qBAAqB,KAAK,GAAG,sBAAsB;AAC5E;;;;;;;;;AAUA,SAAgB,cAAc,OAAwC;CAEpE,MAAM,QAAQ,MAAO;CACrB,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,MAAM,SAAS,iBAAiB,KAAK,CAAC,EAAE;CACxC,OAAO,OAAO,WAAW,WAAW,SAAS,KAAA;AAC/C"}
@@ -96,7 +96,7 @@ function createToolHistoryRecorder() {
96
96
  return {
97
97
  observe(chunk, target) {
98
98
  if (chunk.type === EventType.TOOL_CALL_START) {
99
- const name = chunk.toolCallName ?? chunk.toolName;
99
+ const name = chunk.toolCallName;
100
100
  if (!name) return;
101
101
  open.set(chunk.toolCallId, {
102
102
  name,
@@ -107,20 +107,19 @@ function createToolHistoryRecorder() {
107
107
  if (chunk.type === EventType.TOOL_CALL_ARGS) {
108
108
  const call = open.get(chunk.toolCallId);
109
109
  if (!call) return;
110
- call.args = chunk.args ?? call.args + chunk.delta;
110
+ call.args += chunk.delta;
111
111
  return;
112
112
  }
113
113
  if (chunk.type === EventType.TOOL_CALL_END) {
114
114
  const call = open.get(chunk.toolCallId);
115
115
  if (!call) return;
116
116
  open.delete(chunk.toolCallId);
117
- const args = chunk.input !== void 0 ? JSON.stringify(chunk.input) : call.args;
118
117
  recorded.push({
119
118
  id: chunk.toolCallId,
120
119
  name: call.name,
121
- args
120
+ args: call.args
122
121
  });
123
- appendCall(target, chunk.toolCallId, call.name, args);
122
+ appendCall(target, chunk.toolCallId, call.name, call.args);
124
123
  return;
125
124
  }
126
125
  if (chunk.type === EventType.TOOL_CALL_RESULT) {
@@ -1 +1 @@
1
- {"version":3,"file":"tool-history.js","names":[],"sources":["../../src/tool-history.ts"],"sourcesContent":["/**\n * Turn a harness's PASSTHROUGH tool-call chunks into transcript messages, so a\n * finished run's tool cards survive a reload.\n *\n * Why this is needed at all: a harness executes its tools INSIDE the sandbox, so\n * `chat()` only relays its `TOOL_CALL_*` chunks — it never writes an assistant\n * message for them (`addAssistantToolCallMessage` is gated on the engine having\n * executed the tool itself). Chat persistence stores `ctx.messages`, so the whole\n * tool history existed only in the delivery log. Replaying that log is what makes\n * \"switch away and come back\" show everything; a FINISHED thread has no run to\n * rejoin, hydrates from the message store instead, and so came back as nothing but\n * the prompt and the final answer.\n *\n * Recording the calls as ordinary `toolCalls` + `role: 'tool'` messages needs no new\n * wire format and no client change: `modelMessagesToUIMessages` already merges a tool\n * result into the call it belongs to and marks the part complete, and\n * `reconstructChat` already runs that converter.\n */\nimport { EventType } from '@tanstack/ai'\nimport type { ModelMessage, StreamChunk } from '@tanstack/ai'\n\n/**\n * Metadata key set on every tool call recorded here.\n *\n * INTERNAL, and deliberately not exported: an app asks {@link isSandboxToolCall}\n * instead of knowing the key. Renaming it is a storage-visible change, because it ends\n * up inside stored `toolCalls[].metadata`, so the recorder test pins the literal.\n */\nconst SANDBOX_OBSERVED = 'sandboxObserved'\n\n/**\n * What the recorder writes into. `ChatMiddlewareContext.messages` is a\n * `ReadonlyArray`, so the transcript grows by REPLACING the array — the same way the\n * engine itself syncs `middlewareCtx.messages`.\n */\ninterface TranscriptTarget {\n messages: ReadonlyArray<ModelMessage>\n}\n\ninterface OpenCall {\n name: string\n /** Accumulated `TOOL_CALL_ARGS` deltas; superseded by `input` when the adapter sends it. */\n args: string\n}\n\nexport interface ToolHistoryRecorder {\n /** Feed every chunk. Observes only — never transforms or drops. */\n observe: (chunk: StreamChunk, target: TranscriptTarget) => void\n /**\n * Re-append anything missing from the transcript.\n *\n * The engine reassigns `middlewareCtx.messages` from its own array whenever it\n * syncs config (once per agent iteration), which discards writes made during the\n * previous iteration's stream. Reconciling at each iteration boundary and again at\n * finish makes the result independent of that, and independent of where this\n * middleware sits relative to persistence in the middleware array.\n */\n reconcile: (target: TranscriptTarget) => void\n}\n\n/**\n * True when this tool call was executed by the HARNESS inside the sandbox, and\n * recorded into the transcript for display, rather than executed by the agent loop.\n *\n * Use it to decide what your own `MessageStore` keeps — these calls are display\n * history, so dropping or capping them is safe (they are already stripped from the\n * request to the model on the next turn). Also works on a `tool-call` UI part, whose\n * `metadata` is copied straight from the model message.\n *\n * `metadata` is `unknown` on both, so the key can only be read behind a typeof/`in`\n * check; this mirrors the core `isProviderExecutedToolCall` convention.\n *\n * ```ts\n * import { isSandboxToolCall } from '@tanstack/ai-sandbox'\n *\n * const kept = messages.filter(\n * (message) => !message.toolCalls?.every(isSandboxToolCall),\n * )\n * ```\n */\nexport function isSandboxToolCall(\n toolCall: { metadata?: unknown } | null | undefined,\n): boolean {\n const metadata = toolCall?.metadata\n return (\n typeof metadata === 'object' &&\n metadata !== null &&\n SANDBOX_OBSERVED in metadata &&\n metadata[SANDBOX_OBSERVED] === true\n )\n}\n\n/** Does the transcript already carry this tool call, from any source? */\nfunction hasCall(messages: ReadonlyArray<ModelMessage>, id: string): boolean {\n return messages.some((message) =>\n message.toolCalls?.some((call) => call.id === id),\n )\n}\n\n/** Does the transcript already carry this tool result? */\nfunction hasResult(messages: ReadonlyArray<ModelMessage>, id: string): boolean {\n return messages.some(\n (message) => message.role === 'tool' && message.toolCallId === id,\n )\n}\n\nfunction callMessage(id: string, name: string, args: string): ModelMessage {\n return {\n role: 'assistant',\n content: null,\n toolCalls: [\n {\n id,\n type: 'function',\n function: { name, arguments: args },\n metadata: { [SANDBOX_OBSERVED]: true },\n },\n ],\n }\n}\n\nfunction resultMessage(id: string, content: string): ModelMessage {\n return { role: 'tool', toolCallId: id, content }\n}\n\nexport function createToolHistoryRecorder(): ToolHistoryRecorder {\n const open = new Map<string, OpenCall>()\n /** Completed calls in the order they ran — the order `reconcile` restores. */\n const recorded: Array<{ id: string; name: string; args: string }> = []\n const results = new Map<string, string>()\n\n function appendCall(\n target: TranscriptTarget,\n id: string,\n name: string,\n args: string,\n ): void {\n // An id already present is either the engine's own (it executed the tool itself)\n // or a chunk seen before — a journal replay on takeover re-emits the whole\n // stream. Either way a second write would duplicate the card.\n if (hasCall(target.messages, id)) return\n target.messages = [...target.messages, callMessage(id, name, args)]\n }\n\n function appendResult(\n target: TranscriptTarget,\n id: string,\n content: string,\n ): void {\n if (hasResult(target.messages, id)) return\n target.messages = [...target.messages, resultMessage(id, content)]\n }\n\n const recorder: ToolHistoryRecorder = {\n // An if/else chain rather than a `switch`: only four of the ~20 chunk types are\n // interesting here, and a `switch` on `chunk.type` has to enumerate all of them\n // to satisfy the exhaustiveness lint.\n observe(chunk, target) {\n if (chunk.type === EventType.TOOL_CALL_START) {\n // `toolCallName` is the AG-UI field; `toolName` is its deprecated alias, and\n // that alias is what several harness adapters still emit.\n const name = chunk.toolCallName ?? chunk.toolName\n if (!name) return\n open.set(chunk.toolCallId, { name, args: '' })\n return\n }\n if (chunk.type === EventType.TOOL_CALL_ARGS) {\n const call = open.get(chunk.toolCallId)\n if (!call) return\n // `args` is the accumulated-so-far field. Prefer it over stitching deltas:\n // an adapter that sends both would otherwise double the arguments.\n call.args = chunk.args ?? call.args + chunk.delta\n return\n }\n if (chunk.type === EventType.TOOL_CALL_END) {\n const call = open.get(chunk.toolCallId)\n if (!call) return\n open.delete(chunk.toolCallId)\n // `input` is the final PARSED input, so it beats the streamed string, which\n // can be a truncated fragment if the arguments stream was cut short.\n const args =\n chunk.input !== undefined ? JSON.stringify(chunk.input) : call.args\n recorded.push({ id: chunk.toolCallId, name: call.name, args })\n appendCall(target, chunk.toolCallId, call.name, args)\n return\n }\n if (chunk.type === EventType.TOOL_CALL_RESULT) {\n // AG-UI types `content` as a string; anything else is not a result we can\n // store as a `role: 'tool'` message.\n if (typeof chunk.content !== 'string') return\n results.set(chunk.toolCallId, chunk.content)\n appendResult(target, chunk.toolCallId, chunk.content)\n }\n },\n\n reconcile(target) {\n for (const { id, name, args } of recorded) {\n appendCall(target, id, name, args)\n const result = results.get(id)\n // The result goes straight after its own call, so a restored transcript reads\n // in the order the tools actually ran.\n if (result !== undefined) appendResult(target, id, result)\n }\n },\n }\n return recorder\n}\n\n/**\n * Drop recorded harness tool calls from a list of messages bound for the model.\n *\n * A stored transcript becomes the history for the NEXT turn. These calls name tools\n * the provider was never given, and one triage-sized run is hundreds of kilobytes of\n * tool output — so replaying them is wasteful at best and rejected at worst. They stay\n * in `ctx.messages` (which is what gets stored and rendered); only the request to the\n * model loses them.\n *\n * An assistant message is dropped only when EVERY call on it is observed, so a mixed\n * message — one engine tool call plus one harness tool call — is left alone rather than\n * silently losing the engine's half.\n */\nexport function stripObservedToolCalls(\n messages: ReadonlyArray<ModelMessage>,\n): Array<ModelMessage> {\n const dropped = new Set<string>()\n const kept: Array<ModelMessage> = []\n for (const message of messages) {\n const calls = message.toolCalls\n if (calls && calls.length > 0 && calls.every(isSandboxToolCall)) {\n for (const call of calls) dropped.add(call.id)\n continue\n }\n // Orphaning a result is worse than keeping it: a provider rejects a tool result\n // whose call is not in the history.\n if (\n message.role === 'tool' &&\n message.toolCallId !== undefined &&\n dropped.has(message.toolCallId)\n ) {\n continue\n }\n kept.push(message)\n }\n return kept\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tool-history.js","names":[],"sources":["../../src/tool-history.ts"],"sourcesContent":["/**\n * Turn a harness's PASSTHROUGH tool-call chunks into transcript messages, so a\n * finished run's tool cards survive a reload.\n *\n * Why this is needed at all: a harness executes its tools INSIDE the sandbox, so\n * `chat()` only relays its `TOOL_CALL_*` chunks — it never writes an assistant\n * message for them (`addAssistantToolCallMessage` is gated on the engine having\n * executed the tool itself). Chat persistence stores `ctx.messages`, so the whole\n * tool history existed only in the delivery log. Replaying that log is what makes\n * \"switch away and come back\" show everything; a FINISHED thread has no run to\n * rejoin, hydrates from the message store instead, and so came back as nothing but\n * the prompt and the final answer.\n *\n * Recording the calls as ordinary `toolCalls` + `role: 'tool'` messages needs no new\n * wire format and no client change: `modelMessagesToUIMessages` already merges a tool\n * result into the call it belongs to and marks the part complete, and\n * `reconstructChat` already runs that converter.\n */\nimport { EventType } from '@tanstack/ai'\nimport type { ModelMessage, StreamChunk } from '@tanstack/ai'\n\n/**\n * Metadata key set on every tool call recorded here.\n *\n * INTERNAL, and deliberately not exported: an app asks {@link isSandboxToolCall}\n * instead of knowing the key. Renaming it is a storage-visible change, because it ends\n * up inside stored `toolCalls[].metadata`, so the recorder test pins the literal.\n */\nconst SANDBOX_OBSERVED = 'sandboxObserved'\n\n/**\n * What the recorder writes into. `ChatMiddlewareContext.messages` is a\n * `ReadonlyArray`, so the transcript grows by REPLACING the array — the same way the\n * engine itself syncs `middlewareCtx.messages`.\n */\ninterface TranscriptTarget {\n messages: ReadonlyArray<ModelMessage>\n}\n\ninterface OpenCall {\n name: string\n /** Accumulated `TOOL_CALL_ARGS` deltas. */\n args: string\n}\n\nexport interface ToolHistoryRecorder {\n /** Feed every chunk. Observes only — never transforms or drops. */\n observe: (chunk: StreamChunk, target: TranscriptTarget) => void\n /**\n * Re-append anything missing from the transcript.\n *\n * The engine reassigns `middlewareCtx.messages` from its own array whenever it\n * syncs config (once per agent iteration), which discards writes made during the\n * previous iteration's stream. Reconciling at each iteration boundary and again at\n * finish makes the result independent of that, and independent of where this\n * middleware sits relative to persistence in the middleware array.\n */\n reconcile: (target: TranscriptTarget) => void\n}\n\n/**\n * True when this tool call was executed by the HARNESS inside the sandbox, and\n * recorded into the transcript for display, rather than executed by the agent loop.\n *\n * Use it to decide what your own `MessageStore` keeps — these calls are display\n * history, so dropping or capping them is safe (they are already stripped from the\n * request to the model on the next turn). Also works on a `tool-call` UI part, whose\n * `metadata` is copied straight from the model message.\n *\n * `metadata` is `unknown` on both, so the key can only be read behind a typeof/`in`\n * check; this mirrors the core `isProviderExecutedToolCall` convention.\n *\n * ```ts\n * import { isSandboxToolCall } from '@tanstack/ai-sandbox'\n *\n * const kept = messages.filter(\n * (message) => !message.toolCalls?.every(isSandboxToolCall),\n * )\n * ```\n */\nexport function isSandboxToolCall(\n toolCall: { metadata?: unknown } | null | undefined,\n): boolean {\n const metadata = toolCall?.metadata\n return (\n typeof metadata === 'object' &&\n metadata !== null &&\n SANDBOX_OBSERVED in metadata &&\n metadata[SANDBOX_OBSERVED] === true\n )\n}\n\n/** Does the transcript already carry this tool call, from any source? */\nfunction hasCall(messages: ReadonlyArray<ModelMessage>, id: string): boolean {\n return messages.some((message) =>\n message.toolCalls?.some((call) => call.id === id),\n )\n}\n\n/** Does the transcript already carry this tool result? */\nfunction hasResult(messages: ReadonlyArray<ModelMessage>, id: string): boolean {\n return messages.some(\n (message) => message.role === 'tool' && message.toolCallId === id,\n )\n}\n\nfunction callMessage(id: string, name: string, args: string): ModelMessage {\n return {\n role: 'assistant',\n content: null,\n toolCalls: [\n {\n id,\n type: 'function',\n function: { name, arguments: args },\n metadata: { [SANDBOX_OBSERVED]: true },\n },\n ],\n }\n}\n\nfunction resultMessage(id: string, content: string): ModelMessage {\n return { role: 'tool', toolCallId: id, content }\n}\n\nexport function createToolHistoryRecorder(): ToolHistoryRecorder {\n const open = new Map<string, OpenCall>()\n /** Completed calls in the order they ran — the order `reconcile` restores. */\n const recorded: Array<{ id: string; name: string; args: string }> = []\n const results = new Map<string, string>()\n\n function appendCall(\n target: TranscriptTarget,\n id: string,\n name: string,\n args: string,\n ): void {\n // An id already present is either the engine's own (it executed the tool itself)\n // or a chunk seen before — a journal replay on takeover re-emits the whole\n // stream. Either way a second write would duplicate the card.\n if (hasCall(target.messages, id)) return\n target.messages = [...target.messages, callMessage(id, name, args)]\n }\n\n function appendResult(\n target: TranscriptTarget,\n id: string,\n content: string,\n ): void {\n if (hasResult(target.messages, id)) return\n target.messages = [...target.messages, resultMessage(id, content)]\n }\n\n const recorder: ToolHistoryRecorder = {\n // An if/else chain rather than a `switch`: only four of the ~20 chunk types are\n // interesting here, and a `switch` on `chunk.type` has to enumerate all of them\n // to satisfy the exhaustiveness lint.\n observe(chunk, target) {\n if (chunk.type === EventType.TOOL_CALL_START) {\n const name = chunk.toolCallName\n if (!name) return\n open.set(chunk.toolCallId, { name, args: '' })\n return\n }\n if (chunk.type === EventType.TOOL_CALL_ARGS) {\n const call = open.get(chunk.toolCallId)\n if (!call) return\n call.args += chunk.delta\n return\n }\n if (chunk.type === EventType.TOOL_CALL_END) {\n const call = open.get(chunk.toolCallId)\n if (!call) return\n open.delete(chunk.toolCallId)\n recorded.push({\n id: chunk.toolCallId,\n name: call.name,\n args: call.args,\n })\n appendCall(target, chunk.toolCallId, call.name, call.args)\n return\n }\n if (chunk.type === EventType.TOOL_CALL_RESULT) {\n // AG-UI types `content` as a string; anything else is not a result we can\n // store as a `role: 'tool'` message.\n if (typeof chunk.content !== 'string') return\n results.set(chunk.toolCallId, chunk.content)\n appendResult(target, chunk.toolCallId, chunk.content)\n }\n },\n\n reconcile(target) {\n for (const { id, name, args } of recorded) {\n appendCall(target, id, name, args)\n const result = results.get(id)\n // The result goes straight after its own call, so a restored transcript reads\n // in the order the tools actually ran.\n if (result !== undefined) appendResult(target, id, result)\n }\n },\n }\n return recorder\n}\n\n/**\n * Drop recorded harness tool calls from a list of messages bound for the model.\n *\n * A stored transcript becomes the history for the NEXT turn. These calls name tools\n * the provider was never given, and one triage-sized run is hundreds of kilobytes of\n * tool output — so replaying them is wasteful at best and rejected at worst. They stay\n * in `ctx.messages` (which is what gets stored and rendered); only the request to the\n * model loses them.\n *\n * An assistant message is dropped only when EVERY call on it is observed, so a mixed\n * message — one engine tool call plus one harness tool call — is left alone rather than\n * silently losing the engine's half.\n */\nexport function stripObservedToolCalls(\n messages: ReadonlyArray<ModelMessage>,\n): Array<ModelMessage> {\n const dropped = new Set<string>()\n const kept: Array<ModelMessage> = []\n for (const message of messages) {\n const calls = message.toolCalls\n if (calls && calls.length > 0 && calls.every(isSandboxToolCall)) {\n for (const call of calls) dropped.add(call.id)\n continue\n }\n // Orphaning a result is worse than keeping it: a provider rejects a tool result\n // whose call is not in the history.\n if (\n message.role === 'tool' &&\n message.toolCallId !== undefined &&\n dropped.has(message.toolCallId)\n ) {\n continue\n }\n kept.push(message)\n }\n return kept\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-sandbox",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -60,8 +60,8 @@
60
60
  "peerDependencies": {
61
61
  "@ngrok/ngrok": "^1.0.0",
62
62
  "vitest": "^4.1.10",
63
- "@tanstack/ai": "^0.47.1",
64
- "@tanstack/ai-persistence": "^0.4.0"
63
+ "@tanstack/ai": "^0.48.0",
64
+ "@tanstack/ai-persistence": "^0.5.0"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "@ngrok/ngrok": {
@@ -78,8 +78,8 @@
78
78
  "@ngrok/ngrok": "^1.7.0",
79
79
  "@vitest/coverage-v8": "4.1.10",
80
80
  "vitest": "^4.1.10",
81
- "@tanstack/ai": "0.47.1",
82
- "@tanstack/ai-persistence": "0.4.0"
81
+ "@tanstack/ai": "0.48.0",
82
+ "@tanstack/ai-persistence": "0.5.0"
83
83
  },
84
84
  "scripts": {
85
85
  "build": "vite build",
package/src/approvals.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * `approvalId` is stable for a given (provider, kind, target) so a client grant
16
16
  * matches the same action on the resumed run.
17
17
  */
18
- import { EventType } from '@tanstack/ai'
18
+ import { EventType, withTanstackMetadata } from '@tanstack/ai'
19
19
  import { evaluateCommand } from './policy'
20
20
  import type { SandboxPolicy } from './policy'
21
21
  import type { StreamChunk } from '@tanstack/ai'
@@ -81,16 +81,17 @@ export function buildApprovalRequestedEvent(input: {
81
81
  runId: string
82
82
  detail?: Record<string, unknown>
83
83
  }): StreamChunk {
84
- return {
85
- type: EventType.CUSTOM,
86
- name: APPROVAL_REQUESTED_EVENT,
87
- value: {
88
- approvalId: input.approvalId,
89
- title: input.title,
90
- ...(input.detail ?? {}),
84
+ return withTanstackMetadata(
85
+ {
86
+ type: EventType.CUSTOM,
87
+ name: APPROVAL_REQUESTED_EVENT,
88
+ value: {
89
+ approvalId: input.approvalId,
90
+ title: input.title,
91
+ ...(input.detail ?? {}),
92
+ },
93
+ timestamp: Date.now(),
91
94
  },
92
- timestamp: Date.now(),
93
- threadId: input.threadId,
94
- runId: input.runId,
95
- }
95
+ { threadId: input.threadId, runId: input.runId },
96
+ ) as StreamChunk
96
97
  }
@@ -10,7 +10,7 @@
10
10
  * stream into its translated output — so events interleave live while the agent
11
11
  * runs (e.g. code mode's `code_mode:console` logs during a long execution).
12
12
  */
13
- import { EventType } from '@tanstack/ai'
13
+ import { EventType, withTanstackMetadata } from '@tanstack/ai'
14
14
  import type { StreamChunk } from '@tanstack/ai'
15
15
 
16
16
  export interface BridgeEventChannel {
@@ -50,15 +50,21 @@ export function createBridgeEventChannel(meta: {
50
50
  return {
51
51
  emitCustomEvent(eventName, value) {
52
52
  if (closed) return
53
- buffer.push({
54
- type: EventType.CUSTOM,
55
- name: eventName,
56
- value,
57
- timestamp: Date.now(),
58
- model: meta.model,
59
- ...(meta.threadId !== undefined && { threadId: meta.threadId }),
60
- ...(meta.runId !== undefined && { runId: meta.runId }),
61
- })
53
+ buffer.push(
54
+ withTanstackMetadata(
55
+ {
56
+ type: EventType.CUSTOM,
57
+ name: eventName,
58
+ value,
59
+ timestamp: Date.now(),
60
+ },
61
+ {
62
+ model: meta.model,
63
+ ...(meta.threadId !== undefined ? { threadId: meta.threadId } : {}),
64
+ ...(meta.runId !== undefined ? { runId: meta.runId } : {}),
65
+ },
66
+ ) as StreamChunk,
67
+ )
62
68
  notify?.()
63
69
  },
64
70
  close() {
@@ -29,8 +29,16 @@
29
29
  * exactly that field — nothing downstream keys on a chunk's timestamp, so
30
30
  * leaving it wall-clock is safe, but every other field must participate in
31
31
  * the comparison or a real divergence would go undetected.
32
+ * 3. Adapter yields still carry leftover TanStack extras (`content`, `args`,
33
+ * `finishReason`). The durability log stores spec chunks. Fingerprints keep
34
+ * only AG-UI spec keys and drop `metadata.tanstack`, so a live adapter yield
35
+ * matches the stored spec chunk.
32
36
  */
33
37
  import type { StreamChunk } from '@tanstack/ai'
38
+ import {
39
+ isSpecTopLevelKey,
40
+ tanstackMetadata,
41
+ } from '@tanstack/ai/adapter-internals'
34
42
 
35
43
  /**
36
44
  * A deterministic id generator scoped to one run.
@@ -112,16 +120,37 @@ function stableStringify(
112
120
  * cannot spuriously diverge.
113
121
  * - **Recurses into nested arrays and objects**: tool-call arguments are
114
122
  * nested, and a shallow fingerprint would miss a changed argument.
115
- * - **Excludes exactly `VOLATILE_FIELDS`** (`timestamp`) — everything else
116
- * participates, including fields whose value is `undefined`.
123
+ * - **Excludes `timestamp` and leftover adapter extras.** Spec keys
124
+ * participate, including `undefined` values. `metadata.tanstack` is dropped
125
+ * so stored spec chunks match live adapter yields.
117
126
  * - **Distinguishes present-but-`undefined` from absent**: `undefined` is
118
127
  * encoded as the sentinel string `"__undefined__"` rather than dropped, so
119
128
  * `{a: undefined}` and `{}` do not collide. A translator emitting an
120
129
  * explicit `undefined` is a different chunk shape and must fingerprint
121
130
  * differently.
122
131
  */
132
+ function fingerprintableChunk(chunk: StreamChunk): Record<string, unknown> {
133
+ const out: Record<string, unknown> = {}
134
+ for (const [key, value] of Object.entries(chunk)) {
135
+ if (key === 'timestamp') continue
136
+ if (!isSpecTopLevelKey(chunk.type, key)) continue
137
+ if (key === 'metadata' && value != null && typeof value === 'object') {
138
+ const rest: Record<string, unknown> = {}
139
+ for (const [metaKey, metaValue] of Object.entries(value)) {
140
+ if (metaKey === 'tanstack') continue
141
+ rest[metaKey] = metaValue
142
+ }
143
+ if (Object.keys(rest).length === 0) continue
144
+ out.metadata = rest
145
+ continue
146
+ }
147
+ out[key] = value
148
+ }
149
+ return out
150
+ }
151
+
123
152
  export function chunkFingerprint(chunk: StreamChunk): string {
124
- return stableStringify(chunk, VOLATILE_FIELDS)
153
+ return stableStringify(fingerprintableChunk(chunk), VOLATILE_FIELDS)
125
154
  }
126
155
 
127
156
  /**
@@ -136,7 +165,7 @@ export function chunkFingerprint(chunk: StreamChunk): string {
136
165
  * `JournalReplayThreadIdMismatchError` in `align.ts`).
137
166
  */
138
167
  export function chunkFingerprintIgnoringThreadId(chunk: StreamChunk): string {
139
- return stableStringify(chunk, VOLATILE_AND_THREAD_ID)
168
+ return stableStringify(fingerprintableChunk(chunk), VOLATILE_AND_THREAD_ID)
140
169
  }
141
170
 
142
171
  /**
@@ -150,5 +179,7 @@ export function chunkFingerprintIgnoringThreadId(chunk: StreamChunk): string {
150
179
  export function chunkThreadId(chunk: StreamChunk): string | undefined {
151
180
  const record: Record<string, unknown> = chunk as Record<string, unknown>
152
181
  const value = record[THREAD_ID_FIELD]
153
- return typeof value === 'string' ? value : undefined
182
+ if (typeof value === 'string') return value
183
+ const nested = tanstackMetadata(chunk)?.threadId
184
+ return typeof nested === 'string' ? nested : undefined
154
185
  }
@@ -39,7 +39,7 @@ interface TranscriptTarget {
39
39
 
40
40
  interface OpenCall {
41
41
  name: string
42
- /** Accumulated `TOOL_CALL_ARGS` deltas; superseded by `input` when the adapter sends it. */
42
+ /** Accumulated `TOOL_CALL_ARGS` deltas. */
43
43
  args: string
44
44
  }
45
45
 
@@ -157,9 +157,7 @@ export function createToolHistoryRecorder(): ToolHistoryRecorder {
157
157
  // to satisfy the exhaustiveness lint.
158
158
  observe(chunk, target) {
159
159
  if (chunk.type === EventType.TOOL_CALL_START) {
160
- // `toolCallName` is the AG-UI field; `toolName` is its deprecated alias, and
161
- // that alias is what several harness adapters still emit.
162
- const name = chunk.toolCallName ?? chunk.toolName
160
+ const name = chunk.toolCallName
163
161
  if (!name) return
164
162
  open.set(chunk.toolCallId, { name, args: '' })
165
163
  return
@@ -167,21 +165,19 @@ export function createToolHistoryRecorder(): ToolHistoryRecorder {
167
165
  if (chunk.type === EventType.TOOL_CALL_ARGS) {
168
166
  const call = open.get(chunk.toolCallId)
169
167
  if (!call) return
170
- // `args` is the accumulated-so-far field. Prefer it over stitching deltas:
171
- // an adapter that sends both would otherwise double the arguments.
172
- call.args = chunk.args ?? call.args + chunk.delta
168
+ call.args += chunk.delta
173
169
  return
174
170
  }
175
171
  if (chunk.type === EventType.TOOL_CALL_END) {
176
172
  const call = open.get(chunk.toolCallId)
177
173
  if (!call) return
178
174
  open.delete(chunk.toolCallId)
179
- // `input` is the final PARSED input, so it beats the streamed string, which
180
- // can be a truncated fragment if the arguments stream was cut short.
181
- const args =
182
- chunk.input !== undefined ? JSON.stringify(chunk.input) : call.args
183
- recorded.push({ id: chunk.toolCallId, name: call.name, args })
184
- appendCall(target, chunk.toolCallId, call.name, args)
175
+ recorded.push({
176
+ id: chunk.toolCallId,
177
+ name: call.name,
178
+ args: call.args,
179
+ })
180
+ appendCall(target, chunk.toolCallId, call.name, call.args)
185
181
  return
186
182
  }
187
183
  if (chunk.type === EventType.TOOL_CALL_RESULT) {