@jskit-ai/assistant-core 0.1.199 → 0.1.201

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 (164) hide show
  1. package/examples/conversation/README.md +8 -1
  2. package/examples/conversation/package.json +5 -5
  3. package/fixtures/responsive-assistant/App.vue +4 -1
  4. package/package.json +99 -36
  5. package/src/client/conversation/AssistantConversationElement.vue +27 -4
  6. package/src/client/conversation/AssistantConversationStatus.vue +24 -0
  7. package/src/client/conversation/AssistantGoalControl.vue +8 -5
  8. package/src/client/conversation/AssistantTranscript.vue +10 -3
  9. package/src/client/conversation/index.js +2 -0
  10. package/src/client/conversation/messageDelivery.js +115 -32
  11. package/src/client/conversation/retainedConversation.js +32 -0
  12. package/src/client/conversation/useAssistantQuestions.js +76 -0
  13. package/src/client/lib/assistantApi.js +72 -2
  14. package/src/server/conversation/agentRun.js +222 -0
  15. package/src/server/conversation/attachments.js +93 -0
  16. package/src/server/conversation/claudeClient.js +221 -0
  17. package/src/server/conversation/claudeHistory.js +256 -0
  18. package/src/server/conversation/claudeProcess.js +327 -0
  19. package/src/server/conversation/claudeTools.js +42 -0
  20. package/src/server/conversation/claudeTurn.js +1082 -0
  21. package/src/server/conversation/codexConfiguration.js +1183 -0
  22. package/src/server/conversation/codexConversations.js +1082 -0
  23. package/src/server/conversation/codexDetachedTurn.js +332 -0
  24. package/src/server/conversation/codexEvents.js +194 -1
  25. package/src/server/conversation/codexGoalCommands.js +124 -0
  26. package/src/server/conversation/codexHelperExecution.js +306 -0
  27. package/src/server/conversation/codexHelperThreadLedger.js +804 -0
  28. package/src/server/conversation/codexHelperThreadRecovery.js +395 -0
  29. package/src/server/conversation/codexHelperThreads.js +638 -0
  30. package/src/server/conversation/codexHistoryAdapter.js +369 -0
  31. package/src/server/conversation/codexMessageCommands.js +663 -0
  32. package/src/server/conversation/codexMessageDelivery.js +211 -0
  33. package/src/server/conversation/codexNativeHistoryExport.js +95 -0
  34. package/src/server/conversation/codexNotificationObserver.js +482 -0
  35. package/src/server/conversation/codexNotificationQueue.js +103 -0
  36. package/src/server/conversation/codexObservationControl.js +174 -0
  37. package/src/server/conversation/codexOutput.js +931 -0
  38. package/src/server/conversation/codexProcess.js +1559 -0
  39. package/src/server/conversation/codexProcessLeader.js +14 -0
  40. package/src/server/conversation/codexProvider.js +2710 -0
  41. package/src/server/conversation/codexProviderOwner.js +1058 -0
  42. package/src/server/conversation/codexProviderSelection.js +218 -0
  43. package/src/server/conversation/codexReasoning.js +314 -0
  44. package/src/server/conversation/codexRenewalCommands.js +290 -0
  45. package/src/server/conversation/codexResultSettlement.js +409 -0
  46. package/src/server/conversation/codexRunJournal.js +658 -0
  47. package/src/server/conversation/codexRuntimeLifecycle.js +288 -0
  48. package/src/server/conversation/codexSessionCleanup.js +96 -0
  49. package/src/server/conversation/codexSessionReconciliation.js +284 -0
  50. package/src/server/conversation/codexThreadReadiness.js +397 -0
  51. package/src/server/conversation/codexThreadStatus.js +354 -0
  52. package/src/server/conversation/codexTurn.js +701 -255
  53. package/src/server/conversation/codexTurnControl.js +317 -0
  54. package/src/server/conversation/codexTurnRecovery.js +990 -0
  55. package/src/server/conversation/codexTurnState.js +549 -0
  56. package/src/server/conversation/commandHook.js +18 -0
  57. package/src/server/conversation/commandWrapper.js +37 -0
  58. package/src/server/conversation/configuration.js +43 -0
  59. package/src/server/conversation/continuity.js +289 -0
  60. package/src/server/conversation/fileStorage.js +55 -0
  61. package/src/server/conversation/hookBridge.js +19 -0
  62. package/src/server/conversation/index.js +13 -0
  63. package/src/server/conversation/localExecution.js +665 -0
  64. package/src/server/conversation/memoryStorage.js +7 -72
  65. package/src/server/conversation/nativeHistoryExport.js +90 -0
  66. package/src/server/conversation/nativeStorage.js +23 -0
  67. package/src/server/conversation/openCodeClient.js +209 -19
  68. package/src/server/conversation/openCodePlugin.js +84 -0
  69. package/src/server/conversation/openCodeProcess.js +476 -0
  70. package/src/server/conversation/openCodeRuntime.js +1578 -0
  71. package/src/server/conversation/openCodeTools.js +60 -0
  72. package/src/server/conversation/openCodeTurn.js +557 -0
  73. package/src/server/conversation/provider.js +30 -0
  74. package/src/server/conversation/providers/api.js +136 -0
  75. package/src/server/conversation/providers/claude.js +63 -0
  76. package/src/server/conversation/providers/claudeDriver.js +425 -0
  77. package/src/server/conversation/providers/codex.js +248 -0
  78. package/src/server/conversation/providers/codexDriver.js +1078 -0
  79. package/src/server/conversation/providers/opencode.js +49 -0
  80. package/src/server/conversation/providers/opencodeDriver.js +650 -0
  81. package/src/server/conversation/providers/scoped.js +312 -0
  82. package/src/server/conversation/runtime.js +2220 -0
  83. package/src/server/conversation/runtimeStateUpgrade.js +125 -0
  84. package/src/server/conversation/storage.js +184 -0
  85. package/src/server/conversation/streams.js +7 -2
  86. package/src/server/conversation/structuredOutput.js +240 -0
  87. package/src/server/conversation/systemPrompt.js +54 -0
  88. package/src/server/conversation/tools.js +84 -0
  89. package/src/server/conversation/transcript.js +5 -4
  90. package/src/server/hosts/claudeHistory.js +5 -0
  91. package/src/server/hosts/claudeProcess.js +11 -0
  92. package/src/server/hosts/claudeTurn.js +4 -0
  93. package/src/server/hosts/codexConfiguration.js +20 -0
  94. package/src/server/hosts/codexEvents.js +13 -0
  95. package/src/server/hosts/codexProcess.js +12 -0
  96. package/src/server/hosts/codexProvider.js +23 -0
  97. package/src/server/hosts/codexTurn.js +20 -0
  98. package/src/server/hosts/nativeHistory.js +3 -0
  99. package/src/server/hosts/openCodeClient.js +3 -0
  100. package/src/server/hosts/openCodeProcess.js +23 -0
  101. package/src/server/hosts/openCodeTurn.js +7 -0
  102. package/src/server/index.js +2 -0
  103. package/src/server/lib/aiConnectionClient.js +13 -6
  104. package/src/server/lib/assistantCompletion.js +190 -0
  105. package/src/server/lib/assistantToolLoop.js +314 -0
  106. package/src/server/lib/serviceToolCatalog.js +25 -10
  107. package/src/shared/assistantResponseText.js +29 -0
  108. package/src/shared/conversation/attachments.js +19 -0
  109. package/src/shared/conversation/index.js +3 -0
  110. package/src/shared/conversation/pagination.js +78 -0
  111. package/src/shared/conversation/patches.js +150 -0
  112. package/src/shared/conversation/questions.js +48 -0
  113. package/src/shared/conversation/streaming.js +7 -4
  114. package/src/shared/index.js +2 -0
  115. package/src/shared/nativeProviders.js +45 -0
  116. package/src/testing/conversationStorageContract.js +31 -1
  117. package/src/testing/nativeCodex.js +11 -0
  118. package/src/testing/nativeOpenCode.js +3 -0
  119. package/test/aiConnectionClient.test.js +2 -0
  120. package/test/apiConversationDriver.test.js +73 -0
  121. package/test/assistantApiSurfaceHeader.test.js +85 -0
  122. package/test/assistantBoundedToolLoop.test.js +285 -0
  123. package/test/assistantScroll.browser.test.js +16 -0
  124. package/test/claudeClient.test.js +189 -0
  125. package/test/claudeConversationSession.test.js +1173 -0
  126. package/test/claudeInstructionResume.test.js +82 -0
  127. package/test/claudeProcess.test.js +204 -0
  128. package/test/claudeTurn.test.js +253 -0
  129. package/test/codexConversationSession.test.js +2122 -0
  130. package/test/codexGoal.test.js +37 -0
  131. package/test/codexHelperDeletion.test.js +163 -0
  132. package/test/codexHelperThreadLedger.test.js +494 -0
  133. package/test/codexHistoryAdapter.test.js +541 -0
  134. package/test/codexInteractiveArguments.test.js +30 -0
  135. package/test/codexIsolation.test.js +756 -0
  136. package/test/codexModelCatalog.test.js +362 -0
  137. package/test/codexNativeHistoryExport.test.js +81 -0
  138. package/test/codexThreadSelection.test.js +136 -0
  139. package/test/codexTurn.test.js +2158 -0
  140. package/test/conversation.test.js +110 -0
  141. package/test/conversationAgentRun.test.js +326 -0
  142. package/test/conversationApiFacilities.test.js +90 -0
  143. package/test/conversationAttachments.test.js +238 -0
  144. package/test/conversationCommandWrapper.test.js +48 -0
  145. package/test/conversationConfiguration.test.js +174 -0
  146. package/test/conversationNativeStorage.test.js +25 -0
  147. package/test/conversationReplacement.test.js +172 -0
  148. package/test/conversationRuntime.test.js +241 -0
  149. package/test/conversationRuntimeStateUpgrade.test.js +144 -0
  150. package/test/conversationSession.test.js +1080 -0
  151. package/test/conversationStreaming.test.js +68 -0
  152. package/test/conversationSystemPrompt.test.js +84 -0
  153. package/test/conversationTools.test.js +416 -0
  154. package/test/fileConversationStorage.test.js +73 -0
  155. package/test/fixtures/codexHelperThreadLedger.js +39 -0
  156. package/test/localConversationExecution.test.js +90 -0
  157. package/test/messageDelivery.test.js +234 -1
  158. package/test/nativeConversationRetirement.test.js +215 -0
  159. package/test/openCodeClient.test.js +515 -0
  160. package/test/openCodeProcess.test.js +177 -0
  161. package/test/openCodeRuntime.test.js +322 -0
  162. package/test/openCodeTools.test.js +287 -0
  163. package/test/opencodeConversationSession.test.js +789 -0
  164. package/test/serviceToolCatalog.test.js +24 -0
@@ -85,3 +85,71 @@ test("OpenCode deletes an exact message without reverting project files", async
85
85
  await assert.rejects(client.deleteMessage("ses_test", ""), /requires a message id/);
86
86
  assert.equal(calls.length, 1);
87
87
  });
88
+
89
+ test("runtime streams retain authored identity and origin across application wakes and late output", () => {
90
+ const streams = createConversationStreams();
91
+ const oldUser = { messageId: "older-user", role: "user", text: "Earlier question" };
92
+ const wake = { messageId: "wake", role: "system", text: "Application update" };
93
+ const turns = [
94
+ { turnId: "older", user: oldUser, messages: [oldUser] },
95
+ { turnId: "update", system: wake, messages: [wake], metadata: { runtime: { origin: "application" } } }
96
+ ];
97
+ streams.update("chat", { turnId: "update", origin: "application", messageId: "answer", text: "Update" });
98
+ streams.update("chat", { turnId: "update", messageId: "answer", delta: " received" });
99
+ const snapshot = streams.read("chat");
100
+ assert.equal(snapshot.messages[0].turnId, "update");
101
+ assert.equal(snapshot.messages[0].origin, "application");
102
+ const live = mergeConversationStream(turns, snapshot);
103
+ assert.equal(live.length, 2);
104
+ assert.equal(live[1].turnId, "update");
105
+ assert.equal(live[1].assistant.origin, "application");
106
+ assert.equal(live[1].assistant.text, "Update received");
107
+ assert.equal(turns[1].messages.length, 1, "Projection must not mutate saved history");
108
+ const hiddenWake = mergeConversationStream([], snapshot);
109
+ assert.equal(hiddenWake[0].turnId, "update", "Hiding a system prompt must not change the reply identity");
110
+ const late = mergeConversationStream(turns, { messages: [
111
+ { messageId: "late-answer", turnId: "older", origin: "user", role: "assistant", text: "Earlier reply" }
112
+ ] });
113
+ assert.equal(late.length, 2);
114
+ assert.equal(late[0].assistant.text, "Earlier reply");
115
+ assert.equal(late[1].assistant, undefined, "Late output belongs to its authored turn, not the latest wake");
116
+ });
117
+
118
+ test("native streams retain proven origin without exposing or changing their native grouping", () => {
119
+ const streams = createConversationStreams();
120
+ const nativeIdentity = { threadId: "native-thread", turnId: "native-turn" };
121
+ const input = { nativeIdentity, turnId: "native-thread:native-turn", origin: "application", messageId: "native-reply" };
122
+ streams.update("chat", { ...input, text: "First" });
123
+ streams.update("chat", { ...input, delta: " chunk" });
124
+ const snapshot = streams.read("chat");
125
+ assert.equal(snapshot.messages[0].text, "First chunk");
126
+ assert.equal(snapshot.messages[0].origin, "application");
127
+ assert.equal(Object.hasOwn(snapshot.messages[0], "turnId"), false);
128
+ assert.equal(JSON.stringify(snapshot).includes("nativeIdentity"), false);
129
+ const projected = mergeConversationStream([], snapshot);
130
+ assert.equal(projected[0].assistant.origin, "application");
131
+ assert.equal(projected[0].turnId, "stream:native-reply");
132
+ streams.complete("chat", input.messageId);
133
+ assert.equal(streams.update("chat", { ...input, delta: "late" }), null);
134
+ const next = streams.update("chat", { ...input,
135
+ nativeIdentity: { ...nativeIdentity, turnId: "next-turn" }, turnId: "native-thread:next-turn", text: "Next reply" });
136
+ assert.equal(next.messages[0].text, "Next reply");
137
+ });
138
+
139
+ test("output identity survives stream revisions and saved projection without changing native message IDs", () => {
140
+ const streams = createConversationStreams();
141
+ const input = { turnId: "authored", messageId: "native-live", outputId: "exact-output", origin: "user" };
142
+ streams.update("chat", { ...input, text: "Partial" });
143
+ const snapshot = streams.update("chat", { turnId: input.turnId, messageId: input.messageId, delta: " answer" });
144
+ assert.equal(snapshot.messages[0].outputId, "exact-output");
145
+ assert.equal(snapshot.messages[0].messageId, "native-live");
146
+ const user = { role: "user", messageId: "request", text: "Question" };
147
+ const turns = [{ turnId: "authored", user, messages: [user] }];
148
+ assert.equal(mergeConversationStream(turns, snapshot)[0].assistant.outputId, "exact-output");
149
+ const saved = { role: "assistant", messageId: "native-saved", outputId: "exact-output", text: "Partial answer" };
150
+ streams.complete("chat", "native-live");
151
+ const final = [{ ...turns[0], assistant: saved, messages: [user, saved] }];
152
+ assert.equal(mergeConversationStream(final, streams.read("chat")), final);
153
+ assert.equal(final[0].assistant.messageId, "native-saved");
154
+ assert.equal(final[0].assistant.outputId, snapshot.messages[0].outputId);
155
+ });
@@ -0,0 +1,84 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createConversationSystemPrompt } from "../src/server/conversation/systemPrompt.js";
4
+
5
+ test("retained instructions install once across messages and tool continuations", async () => {
6
+ const prompt = createConversationSystemPrompt();
7
+ const calls = [];
8
+ const input = { systemPrompt: "Use the application tools.", contextIdentity: "thread/model/account",
9
+ install: async (value) => { calls.push(value); return "installed"; } };
10
+ assert.deepEqual(await Promise.all([prompt.ensure(input), prompt.ensure(input), prompt.ensure(input)]),
11
+ ["installed", "installed", "installed"]);
12
+ assert.equal(calls.length, 1);
13
+ assert.equal(prompt.isCurrent(input), true);
14
+ await prompt.ensure({ ...input, systemPrompt: "Use the updated application tools." });
15
+ assert.equal(calls.length, 2);
16
+ assert.equal(calls[1].promptChanged, true);
17
+ assert.equal(calls[1].contextChanged, false);
18
+ assert.equal(prompt.isCurrent(input), false);
19
+ await prompt.ensure({ ...input, systemPrompt: "Use the updated application tools.", contextIdentity: "replacement/model/account" });
20
+ assert.equal(calls[2].contextChanged, true);
21
+ });
22
+
23
+ test("native context loss and controller restart require installation again", async () => {
24
+ let calls = 0;
25
+ const input = { systemPrompt: "Current instructions", contextIdentity: "native-thread",
26
+ install: async () => ++calls };
27
+ const prompt = createConversationSystemPrompt();
28
+ await prompt.ensure(input);
29
+ prompt.invalidate();
30
+ await prompt.ensure(input);
31
+ await createConversationSystemPrompt().ensure(input);
32
+ assert.equal(calls, 3);
33
+ });
34
+
35
+ test("failed installation is retried before accepting the same instructions", async () => {
36
+ const prompt = createConversationSystemPrompt();
37
+ let calls = 0;
38
+ const input = { systemPrompt: "Current instructions", contextIdentity: "native-thread",
39
+ install: async () => { if (++calls === 1) throw new Error("Provider unavailable"); } };
40
+ await assert.rejects(prompt.ensure(input), /Provider unavailable/);
41
+ await prompt.ensure(input);
42
+ await prompt.ensure(input);
43
+ assert.equal(calls, 2);
44
+ });
45
+
46
+ test("invalidation during installation leaves the replacement context unacknowledged", async () => {
47
+ const prompt = createConversationSystemPrompt();
48
+ const entered = Promise.withResolvers(), released = Promise.withResolvers();
49
+ let calls = 0;
50
+ const input = { systemPrompt: "Current instructions", contextIdentity: "native-thread",
51
+ install: async () => { if (++calls === 1) { entered.resolve(); await released.promise; } } };
52
+ const installing = prompt.ensure(input);
53
+ await entered.promise;
54
+ prompt.invalidate();
55
+ released.resolve();
56
+ await assert.rejects(installing, /context changed while installing/);
57
+ await prompt.ensure(input);
58
+ assert.equal(calls, 2);
59
+ });
60
+
61
+ test("request-scoped providers receive one system field per request without history mutation", async () => {
62
+ const prompt = createConversationSystemPrompt();
63
+ const messages = [{ role: "user", content: "Hello" }];
64
+ const requests = [];
65
+ const input = { systemPrompt: "Current instructions", contextIdentity: "api-conversation", retained: false,
66
+ install: async ({ systemPrompt }) => requests.push({ system: systemPrompt, messages }) };
67
+ await prompt.ensure(input);
68
+ await prompt.ensure(input);
69
+ assert.equal(requests.length, 2);
70
+ assert.deepEqual(messages, [{ role: "user", content: "Hello" }]);
71
+ });
72
+
73
+ test("conversation instruction bindings are isolated and preserve exact text", async () => {
74
+ const first = createConversationSystemPrompt(), second = createConversationSystemPrompt();
75
+ const calls = [];
76
+ const input = { systemPrompt: " Exact instructions.\n", contextIdentity: "same-native-id",
77
+ install: async ({ systemPrompt }) => calls.push(systemPrompt) };
78
+ await first.ensure(input);
79
+ await second.ensure(input);
80
+ assert.deepEqual(calls, [input.systemPrompt, input.systemPrompt]);
81
+ await assert.rejects(first.ensure({ ...input, systemPrompt: " " }), /non-empty string/);
82
+ await assert.rejects(first.ensure({ ...input, contextIdentity: "" }), /contextIdentity/);
83
+ await assert.rejects(first.ensure({ ...input, install: null }), /installation adapter/);
84
+ });
@@ -0,0 +1,416 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createSchema } from "json-rest-schema";
4
+ import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
5
+ import { createConversationRuntime, createMemoryConversationStorage } from "../src/server/conversation/index.js";
6
+ import { createServiceToolCatalog } from "../src/server/lib/serviceToolCatalog.js";
7
+ import { createConversationTools } from "../src/server/conversation/tools.js";
8
+
9
+ const configuration = { systemPrompt: "Use the available application operations when needed.", integrationId: "assistant" };
10
+ const context = { actor: { id: "owner" }, surface: "app", applicationId: "test", subjectId: "owner" };
11
+ const message = { messageId: "request-one", text: "Add two and three." };
12
+ const call = { id: "call_one", name: "", arguments: '{"left":2,"right":3}' };
13
+ const frame = (delta, finish_reason = null) => `data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason }] })}\n\n`;
14
+ function response({ name, id = call.id, text = "The total is five." } = {}) {
15
+ return new Response(name ? frame({ tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: call.arguments } }] }) + frame({}, "tool_calls")
16
+ : frame({ content: text }) + frame({}, "stop"), { headers: { "content-type": "text/event-stream" } });
17
+ }
18
+ function actionCatalog(execute) {
19
+ const actions = createActionCatalogue();
20
+ actions.register({ contributorId: "test.tools", domain: "test", actions: [{
21
+ id: "numbers.add", version: 1, kind: "command", channels: ["automation"], surfaces: ["app"],
22
+ permission: { require: "authenticated" }, idempotency: "none",
23
+ input: { schema: createSchema({ left: { type: "number", required: true }, right: { type: "number", required: true } }), mode: "replace" },
24
+ output: { schema: createSchema({ total: { type: "number", required: true } }), mode: "replace" }, execute
25
+ }] });
26
+ return actions;
27
+ }
28
+ async function fixture(t, options = {}) {
29
+ const observed = { requests: [], events: [], executions: [], allowed: true };
30
+ const storage = options.storage || createMemoryConversationStorage();
31
+ const actions = actionCatalog(async input => {
32
+ observed.executions.push(input);
33
+ const saved = await storage.read("one", async transaction => transaction.readTurn((await transaction.listTurnIds()).at(-1)));
34
+ assert.equal(saved.metadata.applicationTools.at(-1).status, "running", "Reservation must precede the application effect");
35
+ return options.execute ? options.execute(input) : { total: 5 };
36
+ });
37
+ const name = createServiceToolCatalog(actions).resolveToolSet(context).tools[0].name;
38
+ const runtime = createConversationRuntime({ storage, actions, toolPolicy: options.toolPolicy, limits: options.limits,
39
+ authorize: ({ operation }) => operation !== "tool" || observed.allowed,
40
+ connections: { resolve: async () => ({ providerId: "test", model: "exact-model", sdkPackage: "@ai-sdk/openai-compatible", apiKey: "test", baseURL: "http://test.invalid/v1" }) },
41
+ fetch: (_url, request) => {
42
+ const body = JSON.parse(request.body);
43
+ observed.requests.push(body);
44
+ return options.response?.(body, observed.requests.length, name) || response(observed.requests.length === 1 ? { name } : {});
45
+ }
46
+ });
47
+ t.after(() => runtime.close());
48
+ const conversation = await runtime.open({ id: "one", context, configuration });
49
+ await conversation.subscribe(event => observed.events.push(event));
50
+ return { runtime, conversation, storage, observed, name };
51
+ }
52
+
53
+ test("API tools use authorized action contracts, save results and resume the same model turn", async t => {
54
+ const f = await fixture(t);
55
+ await f.conversation.send(message);
56
+ const result = await f.conversation.wait();
57
+ assert.equal(result.capabilities.tools, true);
58
+ assert.equal(result.conversationLog.length, 1);
59
+ assert.equal(result.conversationLog[0].assistant.text, "The total is five.");
60
+ assert.deepEqual(result.conversationLog[0].metadata.applicationTools[0].result, { ok: true, result: { total: 5 } });
61
+ assert.equal(f.observed.executions.length, 1);
62
+ assert.equal(f.observed.requests.length, 2);
63
+ assert.equal(f.observed.requests[1].model, "exact-model");
64
+ assert.deepEqual(JSON.parse(f.observed.requests[1].messages.at(-1).content), { total: 5 });
65
+ assert.deepEqual(f.observed.events.filter(event => event.type === "tool").map(event => event.call.status), ["running", "complete"]);
66
+ await f.conversation.send({ messageId: "next", text: "What did you just calculate?" });
67
+ await f.conversation.wait();
68
+ assert.equal(f.observed.requests[2].messages.filter(message => message.role === "tool").length, 1, "Tool receipts survive into subsequent model context");
69
+ assert.deepEqual(JSON.parse(f.observed.requests[2].messages.find(message => message.role === "tool").content), { total: 5 });
70
+ assert.equal(f.observed.executions.length, 1);
71
+ assert.equal((await f.conversation.send(message)).duplicate, true);
72
+ });
73
+
74
+ // Carried from assistant-runtime's original split-tag lifecycle case. Only the
75
+ // action name/arguments and confirmed API framing adapt to the common fixture.
76
+ test("split internal tags and tool arguments never become common answer deltas", async t => {
77
+ const f = await fixture(t, { response: (_body, round, name) => round === 1 ? new Response([
78
+ "<th", "ink>private reasoning", "</thi", "nk>", "<|DSML|function_",
79
+ `calls><|DSML|invoke name="${name}">`, call.arguments,
80
+ "</|DSML|invoke></|DSML|function_calls>"
81
+ ].map(content => frame({ content })).join("") + frame({}, "stop"), {
82
+ headers: { "content-type": "text/event-stream" }
83
+ }) : response({ text: "The search is complete." }) });
84
+ await f.conversation.send(message);
85
+ const result = await f.conversation.wait();
86
+ assert.deepEqual(f.observed.executions, [{ left: 2, right: 3 }]);
87
+ assert.deepEqual(f.observed.events.filter(event => event.type === "message" && event.status !== "complete")
88
+ .map(event => event.text), ["The search is complete."]);
89
+ assert.equal(result.conversationLog[0].assistant.text, "The search is complete.");
90
+ });
91
+
92
+ test("text-encoded calls in separate responses retain separate durable receipts", async t => {
93
+ const f = await fixture(t, { response: (_body, round, name) => round < 3 ? new Response(frame({ content:
94
+ `<function_calls><invoke name="${name}">${round === 1 ? call.arguments : '{"left":4,"right":5}'}</invoke></function_calls>`
95
+ }) + frame({}, "stop"), { headers: { "content-type": "text/event-stream" } }) : response() });
96
+ await f.conversation.send(message);
97
+ const result = await f.conversation.wait();
98
+ assert.deepEqual(f.observed.executions, [{ left: 2, right: 3 }, { left: 4, right: 5 }]);
99
+ assert.equal(result.error, "");
100
+ assert.equal(new Set(result.conversationLog[0].metadata.applicationTools.map(call => call.id)).size, 2);
101
+ });
102
+
103
+ test("text-encoded calls still require confirmed completion and raw-output limits", async t => {
104
+ for (const oversized of [false, true]) {
105
+ const f = await fixture(t, { limits: oversized ? { maxOutputCharacters: 100 } : {},
106
+ response: (_body, _round, name) => new Response(frame({ content:
107
+ (oversized ? `<think>${"private".repeat(50)}</think>` : "") +
108
+ `<function_calls><invoke name="${name}">${call.arguments}</invoke></function_calls>`
109
+ }) + (oversized ? frame({}, "stop") : ""), { headers: { "content-type": "text/event-stream" } }) });
110
+ await f.conversation.send(message);
111
+ const result = await f.conversation.wait();
112
+ assert.equal(f.observed.executions.length, 0);
113
+ assert.equal(result.conversationLog[0].metadata.runtime.status, "failed");
114
+ assert.match(result.error, oversized ? /response limit/ : /ended without a finish reason/);
115
+ }
116
+ });
117
+
118
+ test("structured calls without a native ID are not made safe by parser-generated IDs", async t => {
119
+ const f = await fixture(t, { response: (_body, _round, name) => new Response(frame({ tool_calls: [{
120
+ index: 0, function: { name, arguments: call.arguments }
121
+ }] }) + frame({}, "tool_calls"), { headers: { "content-type": "text/event-stream" } }) });
122
+ await f.conversation.send(message);
123
+ const result = await f.conversation.wait();
124
+ assert.equal(f.observed.executions.length, 0);
125
+ assert.equal(result.conversationLog[0].metadata.runtime.status, "failed");
126
+ });
127
+
128
+ test("API answer parsing preserves code indentation and blank lines", async t => {
129
+ const text = "Example:\n\n```python\nif ready:\n run()\n```";
130
+ const f = await fixture(t, { response: () => response({ text }) });
131
+ await f.conversation.send(message);
132
+ assert.equal((await f.conversation.wait()).conversationLog[0].assistant.text, text);
133
+ });
134
+
135
+ test("revocation after model admission prevents an application operation", async t => {
136
+ const f = await fixture(t);
137
+ f.observed.allowed = false;
138
+ await f.conversation.send(message);
139
+ const result = await f.conversation.wait();
140
+ assert.equal(f.observed.executions.length, 0);
141
+ assert.equal(f.observed.requests.length, 1);
142
+ assert.equal(result.conversationLog[0].metadata.runtime.status, "failed");
143
+ assert.match(result.error, /not available to this identity/);
144
+ });
145
+
146
+ test("tool progress and the final answer keep distinct identities from streaming into saved history", async t => {
147
+ const f = await fixture(t, { response: (_body, round, name) => round === 1 ? new Response(
148
+ frame({ content: "I will check. " }) + frame({ content: "Adding the numbers." }) +
149
+ frame({ tool_calls: [{ index: 0, id: call.id, type: "function", function: { name, arguments: call.arguments } }] }) +
150
+ frame({}, "tool_calls"), { headers: { "content-type": "text/event-stream" } }) : response() });
151
+ await f.conversation.send(message);
152
+ const result = await f.conversation.wait();
153
+ const turn = result.conversationLog[0];
154
+ const messages = f.observed.events.filter(event => event.type === "message");
155
+ const progress = messages.filter(event => event.text.startsWith("I will check."));
156
+ const answer = messages.filter(event => event.text === "The total is five.");
157
+ assert.equal(new Set(progress.map(event => event.messageId)).size, 1);
158
+ assert.equal(progress.at(-1).status, "complete");
159
+ assert.equal(progress.at(-1).role, "commentary");
160
+ assert.equal(turn.commentary[0].messageId, progress[0].messageId);
161
+ assert.equal(turn.assistant.messageId, answer[0].messageId);
162
+ assert.notEqual(turn.assistant.messageId, turn.commentary[0].messageId);
163
+ assert.deepEqual([...new Set(progress.map(event => event.outputId))], [progress[0].messageId]);
164
+ assert.deepEqual([...new Set(answer.map(event => event.outputId))], [answer[0].messageId]);
165
+ assert.equal(turn.commentary[0].outputId, progress[0].outputId);
166
+ assert.equal(turn.assistant.outputId, answer[0].outputId);
167
+ assert.equal(answer.at(-1).status, "complete");
168
+ assert.equal(messages.some(event => !event.text), false, "A tool round must not clear an already spoken reply");
169
+ await f.conversation.send({ messageId: "another", text: "Repeat that." });
170
+ const next = await f.conversation.wait();
171
+ assert.notEqual(next.conversationLog[1].assistant.messageId, turn.assistant.messageId);
172
+ assert.notEqual(next.conversationLog[1].assistant.outputId, turn.assistant.outputId);
173
+ });
174
+
175
+ test("the runtime applies application tool policy to schemas and an unsolicited model call", async t => {
176
+ const f = await fixture(t, { toolPolicy: ({ kind, context: actual }) => {
177
+ assert.deepEqual(actual.actor, context.actor);
178
+ return kind === "query";
179
+ } });
180
+ await f.conversation.send(message);
181
+ const result = await f.conversation.wait();
182
+ assert.equal((f.observed.requests[0].tools || []).length, 0);
183
+ assert.equal(f.observed.executions.length, 0);
184
+ assert.equal(f.observed.requests.length, 1);
185
+ assert.equal(result.conversationLog[0].metadata.runtime.status, "failed");
186
+ assert.match(result.error, /unavailable tool|No such tool/i);
187
+ });
188
+
189
+ test("incomplete model tool streams cannot execute application actions", async t => {
190
+ const f = await fixture(t, { response: (_body, _round, name) => new Response(frame({ tool_calls: [{
191
+ index: 0, id: call.id, type: "function", function: { name, arguments: call.arguments }
192
+ }] }), { headers: { "content-type": "text/event-stream" } }) });
193
+ await f.conversation.send(message);
194
+ const result = await f.conversation.wait();
195
+ assert.equal(f.observed.executions.length, 0);
196
+ assert.equal(result.conversationLog[0].metadata.runtime.status, "failed");
197
+ });
198
+
199
+ test("tool limits bound continuation even when the model keeps requesting actions", async t => {
200
+ const f = await fixture(t, { limits: { maxToolCalls: 2 }, response: (_body, round, name) => response({ name, id: `call_${round}` }) });
201
+ await f.conversation.send(message);
202
+ const result = await f.conversation.wait();
203
+ assert.equal(f.observed.executions.length, 2);
204
+ assert.equal(f.observed.requests.length, 3);
205
+ assert.match(result.error, /tool-call limit/);
206
+ });
207
+
208
+ test("cancel waits for an admitted application action to settle and saves its result", async t => {
209
+ const entered = Promise.withResolvers(), complete = Promise.withResolvers();
210
+ const f = await fixture(t, { execute: () => { entered.resolve(); return complete.promise; } });
211
+ await f.conversation.send(message);
212
+ await entered.promise;
213
+ let stopped = false;
214
+ const stopping = f.conversation.cancel().then(() => { stopped = true; });
215
+ await new Promise(resolve => setImmediate(resolve));
216
+ assert.equal(stopped, false);
217
+ complete.resolve({ total: 5 });
218
+ await stopping;
219
+ const result = await f.conversation.read();
220
+ assert.equal(result.conversationLog[0].metadata.runtime.status, "cancelled");
221
+ assert.equal(result.conversationLog[0].metadata.applicationTools[0].result.result.total, 5);
222
+ assert.equal(f.observed.requests.length, 1);
223
+ });
224
+
225
+ test("retrySave persists an action result after disk failure without replaying it", async t => {
226
+ const memory = createMemoryConversationStorage();
227
+ let unavailable = false;
228
+ const storage = { ...memory, write(scope, operation) {
229
+ if (unavailable) return Promise.reject(new Error("Disk unavailable"));
230
+ return memory.write(scope, operation);
231
+ } };
232
+ const f = await fixture(t, { storage, execute: () => { unavailable = true; return { total: 5 }; } });
233
+ await f.conversation.send(message);
234
+ const failed = await f.conversation.wait();
235
+ assert.equal(failed.status, "unavailable");
236
+ assert.equal(failed.conversationLog[0].metadata.applicationTools[0].status, "running");
237
+ unavailable = false;
238
+ assert.equal((await f.conversation.retrySave()).saved, true);
239
+ assert.equal((await f.conversation.read()).conversationLog[0].metadata.applicationTools[0].result.result.total, 5);
240
+ assert.equal(f.observed.executions.length, 1);
241
+ assert.equal(f.observed.requests.length, 1);
242
+ });
243
+
244
+ test("repeated calls in one admitted turn reuse receipts; interrupted reservations never execute again", async () => {
245
+ let executions = 0;
246
+ const actions = actionCatalog(async () => { executions++; return { total: 5 }; });
247
+ const catalog = createServiceToolCatalog(actions);
248
+ const input = { ...call, name: catalog.resolveToolSet(context).tools[0].name };
249
+ const records = [];
250
+ const create = previousCalls => createConversationTools({ catalog, context, previousCalls, signal: new AbortController().signal,
251
+ authorize: async () => {}, save: async calls => records.push(structuredClone(calls)), emit: async () => {} });
252
+ const first = create([]);
253
+ const [one, two] = await Promise.all([first.execute(input), first.execute(input)]);
254
+ assert.deepEqual(one, two);
255
+ assert.equal(executions, 1);
256
+ await assert.rejects(first.execute({ ...input, arguments: '{}' }), /different arguments/);
257
+ const resumed = create(records[0]);
258
+ await assert.rejects(resumed.execute(input), { code: "conversation_tool_outcome_unknown" });
259
+ assert.equal(executions, 1);
260
+ const completed = create(records.at(-1));
261
+ assert.deepEqual(await completed.execute(input), one);
262
+ assert.equal(executions, 1);
263
+ assert.deepEqual(completed.records(), first.records(), "Restored receipts remain part of the turn's saved state");
264
+ });
265
+
266
+ test("a later admitted turn can reuse a model's tool id with different arguments", async t => {
267
+ const f = await fixture(t, { execute: ({ left, right }) => ({ total: left + right }),
268
+ response: (_body, round, name) => round % 2 ? new Response(frame({ tool_calls: [{
269
+ index: 0, id: call.id, type: "function", function: { name, arguments: round === 1 ? call.arguments : '{"left":4,"right":5}' }
270
+ }] }) + frame({}, "tool_calls"), { headers: { "content-type": "text/event-stream" } }) : response()
271
+ });
272
+ await f.conversation.send(message);
273
+ await f.conversation.wait();
274
+ await f.conversation.send({ messageId: "request-two", text: "Now add four and five." });
275
+ const result = await f.conversation.wait();
276
+ assert.equal(result.error, "");
277
+ assert.equal(f.observed.executions.length, 2);
278
+ assert.deepEqual(result.conversationLog.map(turn => turn.metadata.applicationTools[0].result.result.total), [5, 9]);
279
+ assert.equal((await f.conversation.send(message)).duplicate, true);
280
+ assert.equal(f.observed.executions.length, 2, "An accepted user message is still never replayed");
281
+ });
282
+
283
+ test("failed reservations and oversized arguments cannot reach application execution", async () => {
284
+ let executions = 0;
285
+ const catalog = createServiceToolCatalog(actionCatalog(async () => { executions++; return { total: 5 }; }), { maxToolArgumentBytes: 32 });
286
+ const input = { ...call, name: catalog.resolveToolSet(context).tools[0].name };
287
+ const tools = createConversationTools({ catalog, context, signal: new AbortController().signal,
288
+ authorize: async () => {}, save: async () => { throw new Error("Disk unavailable"); }, emit: async () => {} });
289
+ await assert.rejects(tools.execute({ ...input, arguments: " ".repeat(33) }), /size limit/);
290
+ await assert.rejects(tools.execute(input), /Disk unavailable/);
291
+ assert.deepEqual(tools.records(), [], "A failed reservation is not an unknown execution");
292
+ assert.equal(executions, 0);
293
+ });
294
+
295
+ test("closing a native tool connection prevents queued calls while retaining an invoked result", async () => {
296
+ const entered = Promise.withResolvers(), complete = Promise.withResolvers();
297
+ let executions = 0;
298
+ const catalog = createServiceToolCatalog(actionCatalog(async () => {
299
+ executions++;
300
+ entered.resolve();
301
+ return complete.promise;
302
+ }));
303
+ const native = new AbortController();
304
+ const tools = createConversationTools({ catalog, context, signal: new AbortController().signal,
305
+ authorize: async () => {}, save: async () => {}, emit: async () => {} });
306
+ const input = { ...call, name: catalog.resolveToolSet(context).tools[0].name };
307
+ const first = tools.execute(input, { signal: native.signal });
308
+ await entered.promise;
309
+ const queued = tools.execute({ ...input, id: "queued" }, { signal: native.signal });
310
+ native.abort(new Error("Native connection closed"));
311
+ const rejected = assert.rejects(queued, /Native connection closed/);
312
+ complete.resolve({ total: 5 });
313
+ assert.deepEqual(await first, { ok: true, result: { total: 5 } });
314
+ await rejected;
315
+ assert.equal(executions, 1);
316
+ assert.deepEqual(tools.records().map(({ id, status }) => ({ id, status })), [{ id: call.id, status: "complete" }]);
317
+ });
318
+
319
+ test("action validation failure is supplied as a tool result, never claimed as success", async t => {
320
+ const f = await fixture(t, { response: (_body, round, name) => round === 1 ? new Response(frame({ tool_calls: [{
321
+ index: 0, id: call.id, type: "function", function: { name, arguments: '{"left":"invalid","right":3}' }
322
+ }] }) + frame({}, "tool_calls"), { headers: { "content-type": "text/event-stream" } }) : response({ text: "The operation failed." }) });
323
+ await f.conversation.send(message);
324
+ const result = await f.conversation.wait();
325
+ assert.equal(f.observed.executions.length, 0);
326
+ assert.equal(result.conversationLog[0].metadata.applicationTools[0].result.ok, false);
327
+ assert.deepEqual(JSON.parse(f.observed.requests[1].messages.at(-1).content), {
328
+ error: result.conversationLog[0].metadata.applicationTools[0].result.error
329
+ });
330
+ });
331
+
332
+ test("uncertain application results stop inference and remain uncertain on a repeated native call", async t => {
333
+ const f = await fixture(t, { execute() { throw Object.assign(new Error("Acknowledgement lost"), { statusCode: 503 }); } });
334
+ await f.conversation.send(message);
335
+ const result = await f.conversation.wait();
336
+ const turn = result.conversationLog[0];
337
+ assert.equal(turn.metadata.runtime.status, "failed");
338
+ assert.match(result.error, /Inspect its target/);
339
+ assert.equal(turn.metadata.applicationTools[0].status, "unknown");
340
+ assert.equal(f.observed.executions.length, 1);
341
+ assert.equal(f.observed.requests.length, 1);
342
+ const recovered = createConversationTools({ catalog: createServiceToolCatalog(actionCatalog(() => assert.fail("Do not repeat"))),
343
+ context, previousCalls: turn.metadata.applicationTools, signal: new AbortController().signal,
344
+ authorize: async () => {}, save: async () => {}, emit: async () => {} });
345
+ await assert.rejects(recovered.execute({ ...call, name: f.name }), { code: "conversation_tool_outcome_unknown" });
346
+ });
347
+
348
+ test("API progress preceding a tool is commentary, separate from the final answer", async t => {
349
+ const progress = "I will calculate the total.";
350
+ const f = await fixture(t, { response: (_body, round, name) => round === 1 ? new Response(
351
+ frame({ content: progress }) + frame({ tool_calls: [{ index: 0, id: call.id, type: "function", function: { name, arguments: call.arguments } }] }) + frame({}, "tool_calls"),
352
+ { headers: { "content-type": "text/event-stream" } }) : response() });
353
+ await f.conversation.send(message);
354
+ const result = await f.conversation.wait();
355
+ assert.equal(result.conversationLog[0].assistant.text, "The total is five.");
356
+ assert.deepEqual(result.conversationLog[0].messages.filter(message => message.role === "commentary").map(message => message.text), [progress]);
357
+ assert.ok(f.observed.events.some(event => event.type === "message" && event.role === "commentary" && event.status === "complete" && event.text === progress));
358
+ });
359
+
360
+ test("in-memory application calls require explicit transient ownership and never restore prior calls", async () => {
361
+ let executions = 0;
362
+ const catalog = createServiceToolCatalog(actionCatalog(async () => { executions++; return { total: 5 }; }));
363
+ const options = { catalog, context, signal: new AbortController().signal };
364
+ for (const missing of ["authorize", "save", "emit"]) {
365
+ const hooks = { authorize: async () => {}, save: async () => {}, emit: async () => {} };
366
+ delete hooks[missing];
367
+ assert.throws(() => createConversationTools({ ...options, ...hooks }), /authorization, durable save and event/);
368
+ }
369
+ const tools = createConversationTools({ ...options, transient: true });
370
+ const input = { ...call, name: catalog.resolveToolSet(context).tools[0].name };
371
+ assert.deepEqual(await tools.execute(input), { ok: true, result: { total: 5 } });
372
+ assert.deepEqual(await tools.execute(input), { ok: true, result: { total: 5 } });
373
+ assert.equal(executions, 1);
374
+ assert.equal(tools.records()[0].status, "complete");
375
+ assert.throws(() => createConversationTools({ ...options, transient: true, previousCalls: tools.records() }), /cannot restore/);
376
+ });
377
+
378
+ test("strict application calls settle safe receipts before rethrowing the exact host exception", async () => {
379
+ for (const transient of [false, true]) {
380
+ for (const statusCode of [403, 503]) {
381
+ const trace = [];
382
+ let executions = 0;
383
+ const failure = Object.assign(new Error("Original host failure"), {
384
+ code: "host_failure", statusCode, privateState: "unserialized-host-state"
385
+ });
386
+ const catalog = createServiceToolCatalog(actionCatalog(async () => {
387
+ trace.push("effect");
388
+ executions++;
389
+ throw failure;
390
+ }));
391
+ const saved = [];
392
+ const tools = createConversationTools({ catalog, context, signal: new AbortController().signal,
393
+ transient, propagateErrors: true,
394
+ ...(!transient ? {
395
+ authorize: async () => { trace.push("authorize"); },
396
+ save: async calls => { saved.push(structuredClone(calls)); trace.push(`save:${calls[0].status}`); },
397
+ emit: async event => { trace.push(`emit:${event.call.status}`); }
398
+ } : {}) });
399
+ const input = { ...call, name: catalog.resolveToolSet(context).tools[0].name };
400
+ await assert.rejects(tools.execute(input), error => error === failure);
401
+ const state = statusCode >= 500 ? "unknown" : "complete";
402
+ assert.equal(tools.records()[0].status, state);
403
+ assert.equal(tools.records()[0].result.error.code, "host_failure");
404
+ assert.equal(JSON.stringify(tools.records()).includes("unserialized-host-state"), false);
405
+ assert.equal(executions, 1);
406
+ assert.deepEqual(trace, transient ? ["effect"] : [
407
+ "authorize", "save:running", "emit:running", "authorize", "effect", `save:${state}`, `emit:${state}`
408
+ ]);
409
+ assert.equal(saved.length, transient ? 0 : 2);
410
+ if (statusCode >= 500) {
411
+ await assert.rejects(tools.execute(input), { code: "conversation_tool_outcome_unknown" });
412
+ assert.equal(executions, 1);
413
+ }
414
+ }
415
+ }
416
+ });
@@ -0,0 +1,73 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { test } from "node:test";
8
+ import { createFileConversationStorage } from "../src/server/conversation/fileStorage.js";
9
+ import { createConversationTranscript } from "../src/server/conversation/transcript.js";
10
+ import { verifyConversationStorageContract } from "../src/testing/conversationStorageContract.js";
11
+
12
+ async function fixture(t) {
13
+ const directory = await mkdtemp(join(tmpdir(), "jskit-conversations-"));
14
+ t.after(() => rm(directory, { recursive: true, force: true }));
15
+ const storage = createFileConversationStorage({ directory });
16
+ return { directory, storage, transcript: createConversationTranscript({ storage }) };
17
+ }
18
+
19
+ test("file conversations satisfy the shared storage contract and survive a new process", async (t) => {
20
+ const f = await fixture(t);
21
+ const { scope, transcript } = await verifyConversationStorageContract(f.storage, { runtime: true });
22
+ const expected = await transcript.readConversationLog(scope);
23
+ const { stdout } = await promisify(execFile)(process.execPath, ["--input-type=module", "-e", `
24
+ import { createFileConversationStorage } from ${JSON.stringify(new URL("../src/server/conversation/fileStorage.js", import.meta.url).href)};
25
+ import { createConversationTranscript } from ${JSON.stringify(new URL("../src/server/conversation/transcript.js", import.meta.url).href)};
26
+ const storage = createFileConversationStorage({ directory: process.argv[1] });
27
+ const transcript = createConversationTranscript({ storage });
28
+ process.stdout.write(JSON.stringify(await transcript.readConversationLog(process.argv[2])));
29
+ `, f.directory, scope]);
30
+ assert.deepEqual(JSON.parse(stdout), expected);
31
+ assert.equal((await readdir(f.directory)).some((name) => name.endsWith(".tmp")), false);
32
+ if (process.platform !== "win32") {
33
+ const files = await readdir(f.directory);
34
+ assert.equal((await stat(join(f.directory, files[0]))).mode & 0o777, 0o600);
35
+ }
36
+ });
37
+
38
+ test("a failed file transaction preserves saved bytes and permits the next write", async (t) => {
39
+ const f = await fixture(t);
40
+ await f.transcript.writeConversationUserMessage("one", { messageId: "one", text: "Saved" });
41
+ const [filename] = await readdir(f.directory);
42
+ const before = await readFile(join(f.directory, filename));
43
+ await assert.rejects(f.storage.write("one", async (transaction) => {
44
+ await transaction.appendMessage("000002", { role: "user", text: "Uncommitted", at: "2026-01-01" });
45
+ throw new Error("transaction failed");
46
+ }), /transaction failed/);
47
+ assert.deepEqual(await readFile(join(f.directory, filename)), before);
48
+ await f.transcript.writeConversationAssistantMessage("one", { text: "Answer" });
49
+ assert.equal((await f.transcript.readConversationLog("one"))[0].assistant.text, "Answer");
50
+ });
51
+
52
+ test("damaged or unsupported stored data fails explicitly and is never overwritten", async (t) => {
53
+ const f = await fixture(t);
54
+ await f.transcript.writeConversationUserMessage("one", { text: "Saved" });
55
+ const [filename] = await readdir(f.directory);
56
+ const path = join(f.directory, filename);
57
+ for (const damaged of ["{", "null", '{"version":2}', '{"version":1,"scope":"other","turns":[]}']) {
58
+ await writeFile(path, damaged);
59
+ await assert.rejects(f.transcript.readConversationLog("one"), /Conversation storage/);
60
+ await assert.rejects(f.transcript.writeConversationUserMessage("one", { text: "Do not replace" }), /Conversation storage/);
61
+ assert.equal(await readFile(path, "utf8"), damaged);
62
+ }
63
+ });
64
+
65
+ test("application scopes cannot traverse paths and deletion remains isolated", async (t) => {
66
+ const f = await fixture(t);
67
+ await f.transcript.writeConversationUserMessage("../../other", { text: "One" });
68
+ await f.transcript.writeConversationUserMessage("other", { text: "Two" });
69
+ assert.equal((await readdir(f.directory)).length, 2);
70
+ await f.storage.deleteConversation("../../other");
71
+ assert.deepEqual(await f.transcript.readConversationLog("../../other"), []);
72
+ assert.equal((await f.transcript.readConversationLog("other"))[0].user.text, "Two");
73
+ });