@truefoundry/assistant-ui-runtime 0.1.0-rc.1

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.
Files changed (58) hide show
  1. package/README.md +567 -0
  2. package/dist/index.d.ts +300 -0
  3. package/dist/index.js +4440 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +70 -0
  6. package/src/agentSessionModule.d.ts +25 -0
  7. package/src/agentSpec.ts +44 -0
  8. package/src/askUserQuestion.ts +37 -0
  9. package/src/attachmentAdapter.test.ts +56 -0
  10. package/src/attachmentAdapter.ts +58 -0
  11. package/src/bindDraftAgentSession.test.ts +55 -0
  12. package/src/bindDraftAgentSession.ts +66 -0
  13. package/src/buildEditedUserMessageContent.test.ts +61 -0
  14. package/src/collectPending.test.ts +69 -0
  15. package/src/collectPending.ts +165 -0
  16. package/src/constants.ts +2 -0
  17. package/src/convertTurnMessages.test.ts +1991 -0
  18. package/src/convertTurnMessages.ts +1251 -0
  19. package/src/createSubAgent.ts +8 -0
  20. package/src/draftAgentConfig.test.ts +88 -0
  21. package/src/draftSessionBridge.ts +28 -0
  22. package/src/extractTurnUserText.ts +21 -0
  23. package/src/foldPeerThreads.test.ts +386 -0
  24. package/src/foldPeerThreads.ts +587 -0
  25. package/src/hooks.ts +123 -0
  26. package/src/index.ts +68 -0
  27. package/src/lastUserMessageText.ts +19 -0
  28. package/src/loadSessionSnapshot.test.ts +57 -0
  29. package/src/loadSessionSnapshot.ts +35 -0
  30. package/src/mcpAuth.ts +44 -0
  31. package/src/messageCustomMetadata.ts +37 -0
  32. package/src/modelMessageContent.ts +135 -0
  33. package/src/modelMessageImageContent.test.ts +109 -0
  34. package/src/modelMessageImageContent.ts +193 -0
  35. package/src/requiredActionInputs.test.ts +394 -0
  36. package/src/requiredActionInputs.ts +65 -0
  37. package/src/requiredActionsFromActiveUpdate.test.ts +95 -0
  38. package/src/sessionListStartTimestamp.ts +6 -0
  39. package/src/sessionSnapshot.ts +107 -0
  40. package/src/sessions.ts +36 -0
  41. package/src/streamTurn.test.ts +243 -0
  42. package/src/streamTurn.ts +119 -0
  43. package/src/toolApproval.test.ts +137 -0
  44. package/src/toolApproval.ts +459 -0
  45. package/src/toolResponse.test.ts +136 -0
  46. package/src/toolResponse.ts +427 -0
  47. package/src/truefoundryDraftThreadListAdapter.test.ts +140 -0
  48. package/src/truefoundryDraftThreadListAdapter.ts +63 -0
  49. package/src/truefoundryExtras.ts +45 -0
  50. package/src/truefoundryThreadListAdapter.test.ts +103 -0
  51. package/src/truefoundryThreadListAdapter.ts +59 -0
  52. package/src/turnEventHelpers.ts +98 -0
  53. package/src/turnStreamUpdate.ts +11 -0
  54. package/src/types.ts +92 -0
  55. package/src/useDraftAgentSpec.ts +173 -0
  56. package/src/useTrueFoundryAgentMessages.test.tsx +723 -0
  57. package/src/useTrueFoundryAgentMessages.ts +757 -0
  58. package/src/useTrueFoundryAgentRuntime.ts +268 -0
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@truefoundry/assistant-ui-runtime",
3
+ "version": "0.1.0-rc.1",
4
+ "description": "TrueFoundry Gateway agent runtime adapter for assistant-ui",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/truefoundry/truefoundry-agents-assistant-ui-runtime"
9
+ },
10
+ "homepage": "https://github.com/truefoundry/truefoundry-agents-assistant-ui-runtime#readme",
11
+ "keywords": [
12
+ "assistant-ui",
13
+ "truefoundry",
14
+ "ai",
15
+ "agent",
16
+ "react",
17
+ "llm",
18
+ "chat"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "type": "module",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "files": [
34
+ "dist",
35
+ "src",
36
+ "README.md",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "sideEffects": false,
40
+ "scripts": {
41
+ "prepare": "tsup",
42
+ "build": "tsup",
43
+ "test": "vitest run",
44
+ "typecheck": "tsc --noEmit"
45
+ },
46
+ "dependencies": {
47
+ "@assistant-ui/core": "^0.2.19",
48
+ "@assistant-ui/store": "^0.2.19"
49
+ },
50
+ "peerDependencies": {
51
+ "@types/react": "*",
52
+ "react": "^18 || ^19",
53
+ "truefoundry-gateway-sdk": "^0.2.0"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "@types/react": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "devDependencies": {
61
+ "@testing-library/react": "^16.3.2",
62
+ "@types/node": "^20",
63
+ "@types/react": "^19.2.17",
64
+ "jsdom": "^29.1.1",
65
+ "react": "^19.2.4",
66
+ "tsup": "^8.5.0",
67
+ "typescript": "^5.9.3",
68
+ "vitest": "^4.1.9"
69
+ }
70
+ }
@@ -0,0 +1,25 @@
1
+ declare module "truefoundry-gateway-sdk/dist/esm/agents/AgentSession.mjs" {
2
+ import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
3
+ import type * as TrueFoundryGatewayApi from "truefoundry-gateway-sdk";
4
+ import type { PreparedTurn } from "truefoundry-gateway-sdk/agents";
5
+ import type * as core from "truefoundry-gateway-sdk/core";
6
+
7
+ export class AgentSession implements TrueFoundryGatewayApi.Session {
8
+ readonly id: string;
9
+ readonly agentName: string;
10
+ readonly title?: string;
11
+ readonly createdBySubject: TrueFoundryGatewayApi.Subject;
12
+ readonly createdAt: string;
13
+ readonly updatedAt: string;
14
+ constructor(session: TrueFoundryGatewayApi.Session, client: TrueFoundryGateway);
15
+ prepareTurn(opts?: {
16
+ input?: TrueFoundryGatewayApi.TurnInputItem[];
17
+ previousTurnId?: TrueFoundryGatewayApi.PreviousTurnIdInput;
18
+ }): PreparedTurn;
19
+ listTurns(
20
+ opts?: TrueFoundryGatewayApi.agents.SessionsListTurnsRequest,
21
+ ): Promise<core.Page<unknown, TrueFoundryGatewayApi.ListTurnsResponse>>;
22
+ getTurn(opts: { turnId: string }): Promise<unknown>;
23
+ cancel(): Promise<void>;
24
+ }
25
+ }
@@ -0,0 +1,44 @@
1
+ import type { TruefoundryGatewayApi } from "truefoundry-gateway-sdk";
2
+
3
+ export type AgentSpec = TruefoundryGatewayApi.AgentSpec;
4
+ export type DraftSession = TruefoundryGatewayApi.DraftSession;
5
+
6
+ export type AgentSpecUpdate = {
7
+ instructions?: string;
8
+ model?: Partial<AgentSpec["model"]> & {
9
+ params?: Partial<NonNullable<AgentSpec["model"]["params"]>>;
10
+ };
11
+ mcpServers?: AgentSpec["mcpServers"];
12
+ skills?: AgentSpec["skills"];
13
+ messages?: AgentSpec["messages"];
14
+ variables?: AgentSpec["variables"];
15
+ responseFormat?: AgentSpec["responseFormat"];
16
+ config?: AgentSpec["config"];
17
+ };
18
+
19
+ export function mergeAgentSpec(base: AgentSpec, update: AgentSpecUpdate): AgentSpec {
20
+ const { model: modelUpdate, ...rest } = update;
21
+
22
+ const next: AgentSpec = {
23
+ ...base,
24
+ ...rest,
25
+ };
26
+
27
+ if (modelUpdate != null) {
28
+ next.model = {
29
+ ...base.model,
30
+ ...modelUpdate,
31
+ name: modelUpdate.name ?? base.model.name,
32
+ params:
33
+ modelUpdate.params != null
34
+ ? { ...base.model.params, ...modelUpdate.params }
35
+ : base.model.params,
36
+ };
37
+ }
38
+
39
+ return next;
40
+ }
41
+
42
+ export function draftSessionTitle(draft: DraftSession): string {
43
+ return draft.title ?? draft.agentSpec.model.name;
44
+ }
@@ -0,0 +1,37 @@
1
+ import type { ToolCall } from "truefoundry-gateway-sdk/agents";
2
+
3
+ export function isAskUserQuestionToolCall(
4
+ toolCall: Pick<ToolCall, "toolInfo">,
5
+ ): boolean {
6
+ return (
7
+ toolCall.toolInfo.type === "truefoundry-system" &&
8
+ toolCall.toolInfo.name === "ask_user_question"
9
+ );
10
+ }
11
+
12
+ export type AskUserQuestionArgs = {
13
+ question?: string;
14
+ options?: string[];
15
+ };
16
+
17
+ export function parseAskUserQuestionArgs(
18
+ argsText: string | undefined,
19
+ ): AskUserQuestionArgs {
20
+ if (!argsText) {
21
+ return {};
22
+ }
23
+ try {
24
+ const parsed: unknown = JSON.parse(argsText);
25
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
26
+ return {};
27
+ }
28
+ const record = parsed as Record<string, unknown>;
29
+ const question = typeof record.question === "string" ? record.question : undefined;
30
+ const options = Array.isArray(record.options)
31
+ ? record.options.filter((item): item is string => typeof item === "string")
32
+ : undefined;
33
+ return { question, options };
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { PendingAttachment } from "@assistant-ui/core";
3
+
4
+ import { trueFoundryAttachmentAdapter } from "./attachmentAdapter.js";
5
+
6
+ async function addFile(file: File): Promise<PendingAttachment> {
7
+ const result = await trueFoundryAttachmentAdapter.add({ file });
8
+ if (Symbol.asyncIterator in result) {
9
+ throw new Error("expected PendingAttachment");
10
+ }
11
+ return result;
12
+ }
13
+
14
+ describe("trueFoundryAttachmentAdapter", () => {
15
+ it("accepts all file types", () => {
16
+ expect(trueFoundryAttachmentAdapter.accept).toBe("*");
17
+ });
18
+
19
+ it("add returns a pending attachment awaiting composer send", async () => {
20
+ const file = new File(["hello"], "notes.txt", { type: "text/plain" });
21
+ const pending = await addFile(file);
22
+
23
+ expect(pending).toMatchObject({
24
+ type: "file",
25
+ name: "notes.txt",
26
+ contentType: "text/plain",
27
+ file,
28
+ content: [],
29
+ status: { type: "requires-action", reason: "composer-send" },
30
+ });
31
+ expect(pending.id).toBeTruthy();
32
+ });
33
+
34
+ it("send reads the file into a data URI file part", async () => {
35
+ const file = new File(["hello"], "notes.txt", { type: "text/plain" });
36
+ const pending = await addFile(file);
37
+ const complete = await trueFoundryAttachmentAdapter.send(pending);
38
+
39
+ expect(complete.status).toEqual({ type: "complete" });
40
+ expect(complete.content).toHaveLength(1);
41
+ expect(complete.content[0]).toMatchObject({
42
+ type: "file",
43
+ mimeType: "text/plain",
44
+ filename: "notes.txt",
45
+ });
46
+ expect(complete.content[0]).toMatchObject({
47
+ data: expect.stringMatching(/^data:text\/plain;base64,/),
48
+ });
49
+ });
50
+
51
+ it("remove is a no-op", async () => {
52
+ const file = new File(["hello"], "notes.txt", { type: "text/plain" });
53
+ const pending = await addFile(file);
54
+ await expect(trueFoundryAttachmentAdapter.remove(pending)).resolves.toBeUndefined();
55
+ });
56
+ });
@@ -0,0 +1,58 @@
1
+ import {
2
+ generateId,
3
+ type AttachmentAdapter,
4
+ type PendingAttachment,
5
+ } from "@assistant-ui/core";
6
+
7
+ const bytesToBase64 = (bytes: Uint8Array): string =>
8
+ globalThis.Buffer.from(bytes).toString("base64");
9
+
10
+ const getFileDataURL = async (file: File): Promise<string> => {
11
+ if (typeof FileReader === "undefined") {
12
+ const buffer = await file.arrayBuffer();
13
+ return `data:${file.type};base64,${bytesToBase64(new Uint8Array(buffer))}`;
14
+ }
15
+ return new Promise((resolve, reject) => {
16
+ const reader = new FileReader();
17
+ reader.onload = () => resolve(reader.result as string);
18
+ reader.onerror = (error) => reject(error);
19
+ reader.readAsDataURL(file);
20
+ });
21
+ };
22
+
23
+ /**
24
+ * Reads composer files into `CompleteAttachment` `file` parts for send.
25
+ * Wire via `adapters: { attachments: trueFoundryAttachmentAdapter }`.
26
+ */
27
+ export const trueFoundryAttachmentAdapter: AttachmentAdapter = {
28
+ accept: "*",
29
+ async add({ file }) {
30
+ return {
31
+ id: generateId(),
32
+ type: "file",
33
+ name: file.name,
34
+ file,
35
+ contentType: file.type,
36
+ content: [],
37
+ status: {
38
+ type: "requires-action",
39
+ reason: "composer-send",
40
+ },
41
+ };
42
+ },
43
+ async send(attachment: PendingAttachment) {
44
+ return {
45
+ ...attachment,
46
+ status: { type: "complete" },
47
+ content: [
48
+ {
49
+ type: "file",
50
+ mimeType: attachment.contentType ?? "",
51
+ filename: attachment.name,
52
+ data: await getFileDataURL(attachment.file),
53
+ },
54
+ ],
55
+ };
56
+ },
57
+ async remove() {},
58
+ };
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
4
+ import type { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
5
+
6
+ import { bindDraftAgentSession } from "./bindDraftAgentSession.js";
7
+
8
+ const draft = {
9
+ id: "draft-1",
10
+ agentSpec: { model: { name: "anthropic/claude-sonnet-4-6" } },
11
+ createdBySubject: { type: "user" as const, id: "u1" },
12
+ createdAt: "2026-06-30T10:00:00.000Z",
13
+ updatedAt: "2026-06-30T10:00:00.000Z",
14
+ };
15
+
16
+ describe("bindDraftAgentSession", () => {
17
+ it("validates the draft and binds turns to the draft session id", async () => {
18
+ const get = vi.fn().mockResolvedValue({ data: draft });
19
+ const gateway = {
20
+ agents: { private: { draftSessions: { get } } },
21
+ } as unknown as TrueFoundryGateway;
22
+ const client = {
23
+ client: gateway,
24
+ } as unknown as AgentSessionClient;
25
+
26
+ const session = await bindDraftAgentSession(client, gateway, "draft-1");
27
+
28
+ expect(get).toHaveBeenCalledWith("draft-1");
29
+ expect(session.id).toBe("draft-1");
30
+ });
31
+ });
32
+
33
+ describe("createDraftSessionBridge", () => {
34
+ it("syncs agent spec via draftSessions.update", async () => {
35
+ const { createDraftSessionBridge } = await import("./draftSessionBridge.js");
36
+ const update = vi.fn().mockResolvedValue({ data: draft });
37
+ const gateway = {
38
+ agents: {
39
+ private: {
40
+ draftSessions: {
41
+ get: vi.fn().mockResolvedValue({ data: draft }),
42
+ update,
43
+ },
44
+ },
45
+ },
46
+ } as unknown as TrueFoundryGateway;
47
+
48
+ const bridge = createDraftSessionBridge(gateway);
49
+ await bridge.syncAgentSpec("draft-1", draft.agentSpec);
50
+
51
+ expect(update).toHaveBeenCalledWith("draft-1", {
52
+ agentSpec: draft.agentSpec,
53
+ });
54
+ });
55
+ });
@@ -0,0 +1,66 @@
1
+ import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
2
+ import type { AgentSession, AgentSessionClient } from "truefoundry-gateway-sdk/agents";
3
+ // SDK does not publicly export the AgentSession class; bind draft ids for turn routes.
4
+ import { AgentSession as AgentSessionClass } from "truefoundry-gateway-sdk/dist/esm/agents/AgentSession.mjs";
5
+
6
+ import type { DraftSession } from "./agentSpec.js";
7
+
8
+ type SessionRecord = {
9
+ id: string;
10
+ agentName: string;
11
+ title?: string;
12
+ createdBySubject: DraftSession["createdBySubject"];
13
+ createdAt: string;
14
+ updatedAt: string;
15
+ };
16
+
17
+ const inflightByDraftId = new Map<string, Promise<AgentSession>>();
18
+
19
+ export function getGatewayFromSessionClient(
20
+ client: AgentSessionClient,
21
+ ): TrueFoundryGateway {
22
+ const internal = client as unknown as { client: TrueFoundryGateway };
23
+ if (internal.client == null) {
24
+ throw new Error("AgentSessionClient is missing an internal gateway client.");
25
+ }
26
+ return internal.client;
27
+ }
28
+
29
+ function draftToSessionRecord(draft: DraftSession): SessionRecord {
30
+ return {
31
+ id: draft.id,
32
+ agentName: draft.agentName ?? "",
33
+ title: draft.title,
34
+ createdBySubject: draft.createdBySubject,
35
+ createdAt: draft.createdAt,
36
+ updatedAt: draft.updatedAt,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Binds an `AgentSession` for turn APIs at `/agents/sessions/{draftSessionId}/turns`
42
+ * after validating the draft via `GET draft-sessions/{draftSessionId}`.
43
+ * Does not call `GET` or `POST` on `/agents/sessions` root.
44
+ */
45
+ export async function bindDraftAgentSession(
46
+ client: AgentSessionClient,
47
+ gateway: TrueFoundryGateway,
48
+ draftSessionId: string,
49
+ ): Promise<AgentSession> {
50
+ let inflight = inflightByDraftId.get(draftSessionId);
51
+ if (inflight == null) {
52
+ inflight = (async () => {
53
+ const response = await gateway.agents.private.draftSessions.get(draftSessionId);
54
+ return new AgentSessionClass(
55
+ draftToSessionRecord(response.data),
56
+ getGatewayFromSessionClient(client),
57
+ ) as AgentSession;
58
+ })().finally(() => {
59
+ if (inflightByDraftId.get(draftSessionId) === inflight) {
60
+ inflightByDraftId.delete(draftSessionId);
61
+ }
62
+ });
63
+ inflightByDraftId.set(draftSessionId, inflight);
64
+ }
65
+ return inflight;
66
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ buildEditedUserMessageContent,
5
+ parseTurnIdFromMessageId,
6
+ resolveBranchPreviousTurnId,
7
+ } from "./convertTurnMessages.js";
8
+ import type { SessionTurnRecord } from "./sessionSnapshot.js";
9
+
10
+ describe("user message branch helpers", () => {
11
+ it("parseTurnIdFromMessageId strips the user suffix", () => {
12
+ expect(parseTurnIdFromMessageId("turn-abc-user")).toBe("turn-abc");
13
+ });
14
+
15
+ it("resolveBranchPreviousTurnId returns null for the first turn", () => {
16
+ const turns: SessionTurnRecord[] = [
17
+ { id: "a", createdAt: "", state: { status: "done", requiredActions: [], completedAt: "" } },
18
+ ];
19
+ expect(resolveBranchPreviousTurnId(turns, "a")).toBeNull();
20
+ });
21
+
22
+ it("resolveBranchPreviousTurnId returns the prior turn id", () => {
23
+ const turns: SessionTurnRecord[] = [
24
+ { id: "a", createdAt: "", state: { status: "done", requiredActions: [], completedAt: "" } },
25
+ { id: "b", createdAt: "", state: { status: "done", requiredActions: [], completedAt: "" } },
26
+ ];
27
+ expect(resolveBranchPreviousTurnId(turns, "b")).toBe("a");
28
+ });
29
+ });
30
+
31
+ describe("buildEditedUserMessageContent", () => {
32
+ it("returns plain string when there are no attachments", () => {
33
+ expect(
34
+ buildEditedUserMessageContent("updated", [
35
+ { type: "user.message", content: "original" },
36
+ ]),
37
+ ).toBe("updated");
38
+ });
39
+
40
+ it("preserves file parts from the original turn input", () => {
41
+ const filePart = {
42
+ type: "file" as const,
43
+ name: "report.pdf",
44
+ data: "data:application/pdf;base64,AAAA",
45
+ };
46
+ expect(
47
+ buildEditedUserMessageContent("updated question", [
48
+ {
49
+ type: "user.message",
50
+ content: [
51
+ { type: "text", text: "original question" },
52
+ filePart,
53
+ ],
54
+ },
55
+ ]),
56
+ ).toEqual([
57
+ { type: "text", text: "updated question" },
58
+ filePart,
59
+ ]);
60
+ });
61
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ThreadMessage } from "@assistant-ui/core";
3
+
4
+ import { deriveSandboxId } from "./collectPending.js";
5
+
6
+ function assistantMessage(
7
+ id: string,
8
+ custom: Record<string, unknown> = {},
9
+ ): ThreadMessage {
10
+ return {
11
+ id,
12
+ role: "assistant" as const,
13
+ content: [],
14
+ status: { type: "complete" as const, reason: "stop" as const },
15
+ createdAt: new Date(),
16
+ metadata: {
17
+ unstable_state: null,
18
+ unstable_annotations: [],
19
+ unstable_data: [],
20
+ steps: [],
21
+ custom,
22
+ },
23
+ };
24
+ }
25
+
26
+ function userMessage(id: string): ThreadMessage {
27
+ return {
28
+ id,
29
+ role: "user" as const,
30
+ content: [{ type: "text", text: "hi" }],
31
+ attachments: [],
32
+ createdAt: new Date(),
33
+ metadata: { custom: {} },
34
+ };
35
+ }
36
+
37
+ describe("deriveSandboxId", () => {
38
+ it("returns undefined when no message ever carried a sandboxId", () => {
39
+ const messages = [userMessage("u1"), assistantMessage("a1")];
40
+ expect(deriveSandboxId(messages)).toBeUndefined();
41
+ });
42
+
43
+ it("returns the sandboxId from the most recent message that has one", () => {
44
+ const messages = [
45
+ userMessage("u1"),
46
+ assistantMessage("a1", { sandboxId: "sbx-1" }),
47
+ userMessage("u2"),
48
+ assistantMessage("a2"),
49
+ ];
50
+ expect(deriveSandboxId(messages)).toBe("sbx-1");
51
+ });
52
+
53
+ it("prefers a later sandboxId over an earlier one", () => {
54
+ const messages = [
55
+ assistantMessage("a1", { sandboxId: "sbx-1" }),
56
+ assistantMessage("a2", { sandboxId: "sbx-2" }),
57
+ ];
58
+ expect(deriveSandboxId(messages)).toBe("sbx-2");
59
+ });
60
+
61
+ it("keeps returning the sandboxId from earlier turns even when later assistant messages lack it", () => {
62
+ const messages = [
63
+ assistantMessage("a1", { sandboxId: "sbx-1" }),
64
+ assistantMessage("a2"),
65
+ assistantMessage("a3"),
66
+ ];
67
+ expect(deriveSandboxId(messages)).toBe("sbx-1");
68
+ });
69
+ });