@jskit-ai/assistant-core 0.2.7 → 0.2.8

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 (214) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/package.json +18 -7
  3. package/src/client/browser.js +21 -0
  4. package/src/client/conversation/AssistantAttachmentQueue.vue +25 -108
  5. package/src/client/conversation/AssistantComposerActions.vue +1 -1
  6. package/src/client/conversation/AssistantConversationElement.vue +14 -52
  7. package/src/client/conversation/AssistantGoalControl.vue +13 -10
  8. package/src/client/conversation/AssistantPromptInput.vue +6 -91
  9. package/src/client/conversation/AssistantTranscript.css +505 -0
  10. package/src/client/conversation/AssistantTranscript.vue +24 -1197
  11. package/src/client/conversation/attachmentFiles.js +158 -0
  12. package/src/client/conversation/attachmentQueueItems.js +121 -0
  13. package/src/client/conversation/attachmentUploadRows.js +265 -0
  14. package/src/client/conversation/conversationDisplay.js +54 -0
  15. package/src/client/conversation/messageDelivery.js +13 -9
  16. package/src/client/conversation/retainedConversation.js +14 -3
  17. package/src/client/conversation/submitText.js +13 -2
  18. package/src/client/conversation/transcriptEntries.js +225 -0
  19. package/src/client/conversation/useAssistantAttachments.js +86 -388
  20. package/src/client/conversation/useAssistantQuestions.js +16 -3
  21. package/src/client/conversation/useAssistantSuggestions.js +16 -2
  22. package/src/client/conversation/useScrollToBottom.js +17 -19
  23. package/src/client/conversation/useTextareaAutoGrow.js +115 -0
  24. package/src/client/conversation/useTranscriptScrollFollow.js +525 -0
  25. package/src/client/lib/assistantApi.js +192 -161
  26. package/src/client/types.js +110 -0
  27. package/src/server/conversation/attachments.js +21 -6
  28. package/src/server/conversation/boundedToolLoop.js +1 -0
  29. package/src/server/conversation/claudeClient.js +71 -20
  30. package/src/server/conversation/claudeCommandRun.js +191 -0
  31. package/src/server/conversation/claudeHistory.js +7 -0
  32. package/src/server/conversation/claudeNativeTurn.js +708 -0
  33. package/src/server/conversation/claudeProcess.js +186 -112
  34. package/src/server/conversation/claudeProcessStart.js +214 -0
  35. package/src/server/conversation/claudePromptDelivery.js +183 -0
  36. package/src/server/conversation/claudeSessionCleanup.js +248 -0
  37. package/src/server/conversation/claudeTools.js +15 -3
  38. package/src/server/conversation/claudeTurn.js +212 -1388
  39. package/src/server/conversation/codexAccountReader.js +168 -0
  40. package/src/server/conversation/codexAuthPreflight.js +5 -4
  41. package/src/server/conversation/codexClient.js +18 -6
  42. package/src/server/conversation/codexConfiguration.js +8 -844
  43. package/src/server/conversation/codexConversationStops.js +377 -0
  44. package/src/server/conversation/codexConversationStore.js +119 -85
  45. package/src/server/conversation/codexConversationTurns.js +378 -0
  46. package/src/server/conversation/codexConversations.js +134 -866
  47. package/src/server/conversation/codexDetachedConversation.js +203 -0
  48. package/src/server/conversation/codexDetachedTurn.js +145 -104
  49. package/src/server/conversation/codexEvents.js +5 -200
  50. package/src/server/conversation/codexGoalCommands.js +14 -4
  51. package/src/server/conversation/codexHelperExecution.js +57 -57
  52. package/src/server/conversation/codexHelperThreadLedger.js +30 -288
  53. package/src/server/conversation/codexHelperThreadRecord.js +304 -0
  54. package/src/server/conversation/codexHelperThreadRecovery.js +54 -76
  55. package/src/server/conversation/codexHelperThreads.js +94 -93
  56. package/src/server/conversation/codexHistoryAdapter.js +13 -8
  57. package/src/server/conversation/codexIsolation.js +672 -0
  58. package/src/server/conversation/codexLiveItems.js +379 -0
  59. package/src/server/conversation/codexMessageCommands.js +68 -286
  60. package/src/server/conversation/codexMessageDelivery.js +46 -37
  61. package/src/server/conversation/codexModelCatalog.js +86 -7
  62. package/src/server/conversation/codexNotificationObserver.js +81 -88
  63. package/src/server/conversation/codexNotificationQueue.js +21 -10
  64. package/src/server/conversation/codexObservationControl.js +105 -89
  65. package/src/server/conversation/codexOutput.js +84 -419
  66. package/src/server/conversation/codexProcess.js +29 -1053
  67. package/src/server/conversation/codexProcessLeader.js +1 -0
  68. package/src/server/conversation/codexPromptDispatch.js +241 -0
  69. package/src/server/conversation/codexProvider.js +147 -1807
  70. package/src/server/conversation/codexProviderAuth.js +348 -0
  71. package/src/server/conversation/codexProviderInvalidation.js +371 -0
  72. package/src/server/conversation/codexProviderInventories.js +437 -0
  73. package/src/server/conversation/codexProviderOwner.js +149 -957
  74. package/src/server/conversation/codexProviderRelease.js +465 -0
  75. package/src/server/conversation/codexProviderSelection.js +26 -15
  76. package/src/server/conversation/codexProviderThreads.js +698 -0
  77. package/src/server/conversation/codexProviderTurns.js +453 -0
  78. package/src/server/conversation/codexReasoning.js +32 -23
  79. package/src/server/conversation/codexRenewalCommands.js +18 -6
  80. package/src/server/conversation/codexRenewalThreads.js +7 -22
  81. package/src/server/conversation/codexResultSettlement.js +70 -88
  82. package/src/server/conversation/codexRunJournal.js +91 -97
  83. package/src/server/conversation/codexRuntime.js +426 -0
  84. package/src/server/conversation/codexRuntimeInventory.js +113 -0
  85. package/src/server/conversation/codexRuntimeLifecycle.js +39 -13
  86. package/src/server/conversation/codexRuntimeStart.js +431 -0
  87. package/src/server/conversation/codexRuntimeStop.js +324 -0
  88. package/src/server/conversation/codexSessionCleanup.js +25 -10
  89. package/src/server/conversation/codexSessionReconciliation.js +21 -7
  90. package/src/server/conversation/codexThreadReadiness.js +49 -52
  91. package/src/server/conversation/codexThreadSnapshot.js +210 -0
  92. package/src/server/conversation/codexThreadStatus.js +48 -62
  93. package/src/server/conversation/codexTurn.js +171 -55
  94. package/src/server/conversation/codexTurnActivity.js +210 -0
  95. package/src/server/conversation/codexTurnControl.js +44 -48
  96. package/src/server/conversation/codexTurnEndings.js +610 -0
  97. package/src/server/conversation/codexTurnRecovery.js +97 -848
  98. package/src/server/conversation/codexTurnState.js +138 -111
  99. package/src/server/conversation/completedResponses.js +227 -0
  100. package/src/server/conversation/configuration.js +2 -2
  101. package/src/server/conversation/continuity.js +43 -6
  102. package/src/server/conversation/conversationDelivery.js +394 -0
  103. package/src/server/conversation/conversationEntries.js +365 -0
  104. package/src/server/conversation/conversationEvents.js +192 -0
  105. package/src/server/conversation/conversationGoals.js +187 -0
  106. package/src/server/conversation/conversationHandles.js +237 -0
  107. package/src/server/conversation/conversationReads.js +206 -0
  108. package/src/server/conversation/conversationRefusals.js +58 -0
  109. package/src/server/conversation/conversationReplacement.js +254 -0
  110. package/src/server/conversation/conversationSending.js +273 -0
  111. package/src/server/conversation/conversationStopping.js +121 -0
  112. package/src/server/conversation/conversationTurnDelivery.js +413 -0
  113. package/src/server/conversation/conversationTurns.js +511 -0
  114. package/src/server/conversation/deliveryTiming.js +25 -3
  115. package/src/server/conversation/fileStorage.js +11 -6
  116. package/src/server/conversation/hookBridge.js +14 -3
  117. package/src/server/conversation/index.js +1 -1
  118. package/src/server/conversation/localExecution.js +82 -625
  119. package/src/server/conversation/nativeCommandCallbacks.js +10 -0
  120. package/src/server/conversation/nativeConversationLifecycle.js +379 -0
  121. package/src/server/conversation/nativeHistoryExport.js +27 -18
  122. package/src/server/conversation/normalize.js +9 -0
  123. package/src/server/conversation/openCodeClient.js +295 -366
  124. package/src/server/conversation/openCodeNativeHistory.js +141 -0
  125. package/src/server/conversation/openCodeNativeSession.js +79 -0
  126. package/src/server/conversation/openCodeProcess.js +109 -53
  127. package/src/server/conversation/openCodeProcessOwner.js +565 -0
  128. package/src/server/conversation/openCodeRuntime.js +111 -1814
  129. package/src/server/conversation/openCodeSessionTurns.js +547 -0
  130. package/src/server/conversation/openCodeTemporaryConversations.js +394 -0
  131. package/src/server/conversation/openCodeTools.js +28 -14
  132. package/src/server/conversation/openCodeTurn.js +80 -74
  133. package/src/server/conversation/openCodeTurnMonitor.js +388 -0
  134. package/src/server/conversation/processIdentity.js +582 -0
  135. package/src/server/conversation/provider.js +21 -6
  136. package/src/server/conversation/providers/api.js +268 -107
  137. package/src/server/conversation/providers/claude.js +29 -6
  138. package/src/server/conversation/providers/claudeDriver.js +325 -250
  139. package/src/server/conversation/providers/codex.js +69 -33
  140. package/src/server/conversation/providers/codexDriver.js +503 -1081
  141. package/src/server/conversation/providers/codexDriverConnection.js +331 -0
  142. package/src/server/conversation/providers/codexDriverTurns.js +464 -0
  143. package/src/server/conversation/providers/opencode.js +30 -4
  144. package/src/server/conversation/providers/opencodeDriver.js +321 -659
  145. package/src/server/conversation/providers/opencodeStandalone.js +475 -0
  146. package/src/server/conversation/providers/scoped.js +64 -19
  147. package/src/server/conversation/runtime.js +171 -2863
  148. package/src/server/conversation/storage.js +91 -50
  149. package/src/server/conversation/streams.js +15 -2
  150. package/src/server/conversation/structuredOutput.js +7 -11
  151. package/src/server/conversation/systemPrompt.js +13 -1
  152. package/src/server/conversation/tools.js +47 -15
  153. package/src/server/conversation/transcript.js +49 -20
  154. package/src/server/hosts/claudeTurn.js +2 -4
  155. package/src/server/hosts/codexConfiguration.js +6 -8
  156. package/src/server/hosts/codexEvents.js +4 -2
  157. package/src/server/hosts/codexProcess.js +1 -1
  158. package/src/server/hosts/codexProvider.js +11 -9
  159. package/src/server/hosts/codexTurn.js +3 -3
  160. package/src/server/hosts/openCodeClient.js +4 -2
  161. package/src/server/index.js +0 -2
  162. package/src/server/lib/aiConnectionClient.js +35 -25
  163. package/src/server/lib/ndjson.js +1 -0
  164. package/src/server/lib/providers/anthropicClient.js +15 -6
  165. package/src/server/lib/providers/common.js +10 -6
  166. package/src/server/lib/providers/openAiCompatibleClient.js +40 -23
  167. package/src/server/lib/serviceToolCatalog.js +29 -283
  168. package/src/server/lib/serviceToolEntries.js +270 -0
  169. package/src/server/types.js +78 -0
  170. package/src/shared/assistantResource.js +0 -23
  171. package/src/shared/conversation/answerChoiceSugar.js +2 -14
  172. package/src/shared/conversation/messageInputSugar.js +18 -0
  173. package/src/shared/conversation/numberedQuestionSugar.js +6 -16
  174. package/src/shared/conversation/pagination.js +1 -1
  175. package/src/shared/conversation/questions.js +1 -0
  176. package/src/shared/conversation/streaming.js +1 -0
  177. package/src/shared/index.js +0 -1
  178. package/src/testing/conversationStorageContract.js +20 -0
  179. package/test/aiConnectionClient.test.js +42 -1
  180. package/test/apiConversationDriver.test.js +217 -38
  181. package/test/assistantBoundedToolLoop.test.js +1 -1
  182. package/test/claudeConversationSession.test.js +15 -11
  183. package/test/claudeInstructionResume.test.js +1 -1
  184. package/test/claudeProcess.test.js +9 -9
  185. package/test/claudeTurn.test.js +21 -8
  186. package/test/codexAccountChange.test.js +5 -3
  187. package/test/codexConversationSession.test.js +22 -21
  188. package/test/codexEvents.test.js +3 -1
  189. package/test/codexHelperDeletion.test.js +3 -7
  190. package/test/codexIsolation.test.js +6 -6
  191. package/test/codexModelCatalog.test.js +6 -7
  192. package/test/codexProcess.test.js +3 -3
  193. package/test/codexThreadSelection.test.js +4 -1
  194. package/test/codexTurn.test.js +29 -20
  195. package/test/conversationApiFacilities.test.js +11 -9
  196. package/test/conversationAttachments.test.js +3 -3
  197. package/test/conversationConfiguration.test.js +4 -4
  198. package/test/conversationNativeStorage.test.js +11 -10
  199. package/test/conversationReplacement.test.js +13 -13
  200. package/test/conversationRuntime.test.js +9 -5
  201. package/test/conversationSession.test.js +78 -22
  202. package/test/conversationTools.test.js +47 -26
  203. package/test/fileConversationStorage.test.js +10 -3
  204. package/test/fixtures/codexHelperThreadLedger.js +3 -3
  205. package/test/frameworkActions.test.js +33 -18
  206. package/test/localConversationExecution.test.js +4 -4
  207. package/test/localExecution.test.js +1 -1
  208. package/test/openCodeConnectionChange.test.js +3 -2
  209. package/test/openCodeRuntime.test.js +8 -7
  210. package/test/openCodeTools.test.js +1 -1
  211. package/test/opencodeConversationSession.test.js +4 -3
  212. package/test/serviceToolCatalog.test.js +3 -8
  213. package/src/server/lib/assistantCompletion.js +0 -190
  214. package/src/server/lib/assistantToolLoop.js +0 -259
@@ -1,73 +1,252 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { checkTexts } from "@jskit-ai/agent/server";
3
4
  import { createApiConversationDriver } from "../src/server/conversation/providers/api.js";
4
5
 
6
+ const CHECKS = checkTexts("the app");
5
7
  const frame = (delta, finish_reason) => `data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason }] })}\n\n`;
6
8
  const schema = tool => ({ type: "function", function: { name: tool.name, parameters: tool.parameters } });
7
- function fixture({ response, execute, descriptors = [{ name: "action_execute", parameters: { type: "object" } }],
8
- authorize = () => {}, limits } = {}) {
9
- const requests = [], executions = [], messages = [];
9
+ const lastText = body => body.messages.at(-1).content;
10
+
11
+ // One step of a streamed reply: words, an action call or both. Calls the engine
12
+ // makes without streaming (accounts, checks, summaries) answer with `once`.
13
+ function fixture({ reply = () => ({ text: "Done." }), once = () => "Done.", execute, failure,
14
+ descriptors = [{ name: "action_execute", parameters: { type: "object" } }], authorize = () => {}, limits } = {}) {
15
+ const requests = [], streamed = [], executions = [], messages = [], order = [];
10
16
  let accepted = false;
11
17
  const driver = createApiConversationDriver({ limits, connections: { resolve: async () => {
12
18
  await authorize();
13
19
  return { providerId: "test", model: "exact-model", sdkPackage: "@ai-sdk/openai-compatible",
14
20
  apiKey: "test", baseURL: "http://test.invalid/v1" };
15
21
  } }, fetch: (_url, request) => {
16
- requests.push(JSON.parse(request.body));
17
- const value = response(requests.length);
18
- return new Response(frame(value.call ? { tool_calls: [{ index: 0, id: `call_${requests.length}`,
19
- function: { name: value.call, arguments: "{}" } }] } : { content: value.text }, value.call ? "tool_calls" : "stop"),
20
- { headers: { "content-type": "text/event-stream" } });
22
+ const body = JSON.parse(request.body);
23
+ requests.push(body);
24
+ order.push("model");
25
+ if (failure) return failure(requests.length, request);
26
+ if (!body.stream) {
27
+ return Response.json({ choices: [{ index: 0, message: { role: "assistant", content: once(body) }, finish_reason: "stop" }],
28
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } });
29
+ }
30
+ streamed.push(body);
31
+ const value = reply(streamed.length, body, request);
32
+ if (value instanceof Promise) return value;
33
+ const call = value.call ? frame({ tool_calls: [{ index: 0, id: value.id || `call_${streamed.length}`,
34
+ function: { name: value.call, arguments: JSON.stringify(value.input || {}) } }] }, "tool_calls") : "";
35
+ return new Response((value.text ? frame({ content: value.text }, call ? undefined : "stop") : "") + call,
36
+ { headers: { "content-type": "text/event-stream" } });
21
37
  } });
22
- return { requests, executions, messages, run: (text = "Complete the task.") => driver.run({
38
+ return { requests, streamed, executions, messages, order, run: (text = "Complete the task.", options = {}) => driver.run({
23
39
  configuration: { systemPrompt: "Use the supplied actions.", integrationId: "assistant" },
24
40
  context: { subjectId: "owner" }, input: { messageId: "request", text }, history: [],
25
- signal: new AbortController().signal, beforeDispatch: async () => {}, accept: async () => { accepted = true; },
41
+ signal: new AbortController().signal, beforeDispatch: async () => {}, accept: async () => { accepted = true; order.push("accept"); },
26
42
  onMessage: async message => { messages.push(message); },
27
- tools: { descriptors, schemas: descriptors.map(schema), maximumArgumentBytes: 32768,
43
+ tools: descriptors && { descriptors, schemas: descriptors.map(schema), maximumArgumentBytes: 32768,
28
44
  async execute(input) {
29
- assert.equal(accepted, true, "Preflight and ordinary actions require prior admission");
45
+ assert.equal(accepted, true, "Actions require prior admission");
30
46
  executions.push(input);
31
47
  return execute?.(input, executions.length) ?? { ok: true, result: { sequence: executions.length } };
32
48
  }
33
- }
49
+ },
50
+ ...options
34
51
  }) };
35
52
  }
36
53
 
37
- // Original assistant-runtime exhaustion assertions, using the actual API driver
38
- // and confirmed provider framing instead of its former private loop harness.
39
- test("the API owner preserves sixteen tool rounds and three recovery passes", async () => {
40
- const f = fixture({ response: round => round <= 16 ? { call: "action_execute" } : { text: "Let me prepare the answer." } });
54
+ test("each step streams as reply-N, a step with actions ends as commentary and the last step answers", async () => {
55
+ const f = fixture({ reply: round => round === 1 ? { text: "Checking the bookings.", call: "action_execute" } : { text: "You have 3 bookings." } });
56
+ const result = await f.run();
57
+ assert.deepEqual(f.messages.map(({ id, role, complete }) => [id, role, complete]), [
58
+ ["reply-1", "assistant", false], ["reply-1", "commentary", true], ["reply-2", "assistant", false], ["reply-2", "assistant", true]
59
+ ]);
60
+ // The answer leaves out the words already shown as commentary.
61
+ assert.equal(f.messages.at(-1).text, "You have 3 bookings.");
62
+ const quiet = fixture({ reply: round => round === 1 ? { text: "Checking the bookings.", call: "action_execute" } : { text: " " } });
63
+ await quiet.run();
64
+ assert.equal(quiet.messages.at(-1).text, "Checking the bookings.", "Commentary that is all there is stays the answer");
65
+ assert.equal(f.streamed[1].messages.find(message => message.role === "tool").content, JSON.stringify({ sequence: 1 }));
66
+ assert.deepEqual(result, { metadata: {} });
67
+ });
68
+
69
+ test("a step's words complete as commentary before any of its actions runs, and only once", async () => {
70
+ const shownBefore = [];
71
+ const f = fixture({ reply: round => round === 1 ? { text: "Checking the bookings.", call: "action_execute" } : { text: "You have 3 bookings." },
72
+ execute: () => { shownBefore.push(f.messages.map(({ id, role, text, complete }) => [id, role, text, complete]).at(-1)); } });
73
+ await f.run();
74
+ assert.deepEqual(shownBefore, [["reply-1", "commentary", "Checking the bookings.", true]]);
75
+ assert.deepEqual(f.messages.map(({ id, role, complete }) => [id, role, complete]), [
76
+ ["reply-1", "assistant", false], ["reply-1", "commentary", true], ["reply-2", "assistant", false], ["reply-2", "assistant", true]
77
+ ]);
78
+ });
79
+
80
+ test("a step's words past the progress or output limit fail the reply before any of its actions runs", async () => {
81
+ for (const [limits, error] of [
82
+ [{ maxApiToolProgressCharacters: 21 }, /Application tool progress text exceeds the configured API limit/],
83
+ [{ maxOutputCharacters: 21 }, /The assistant exceeded the configured response limit/]
84
+ ]) {
85
+ const f = fixture({ limits, reply: () => ({ text: "Checking the bookings.", call: "action_execute" }) });
86
+ await assert.rejects(f.run(), error);
87
+ assert.deepEqual(f.executions, [], "Nothing runs");
88
+ assert.equal(f.requests.length, 1, "Nothing else is asked of the model");
89
+ }
90
+ const bounded = fixture({ limits: { maxApiToolProgressCharacters: 22 },
91
+ reply: round => round === 1 ? { text: "Checking the bookings.", call: "action_execute" } : { text: "You have 3 bookings." } });
92
+ await bounded.run();
93
+ assert.equal(bounded.executions.length, 1, "Progress at its limit runs");
94
+ });
95
+
96
+ test("every action goes through the application receipt with a per-step id and its arguments", async () => {
97
+ const f = fixture({ reply: round => round < 3 ? { call: "action_execute", id: "call_0", input: { round } } : { text: "Both are saved." },
98
+ once: () => JSON.stringify({ unsupported: [] }) });
41
99
  await f.run();
42
- assert.equal(f.executions.length, 16);
43
- assert.equal(f.requests.length, 19);
44
- assert.deepEqual(f.messages.filter(message => message.complete && message.role === "assistant").map(message => message.text),
45
- ["Limit reached. Start a new conversation."]);
46
- assert.equal(new Set(f.messages.filter(message => message.complete).map(message => message.id)).size, 4,
47
- "Recovery commentary and the final answer retain independent identities");
48
- });
49
-
50
- test("clock preflight is admitted before execution and precedes model inference", async () => {
51
- const descriptors = [{ name: "workspace_clock", parameters: { type: "object", properties: {} }, preflight: ["current-time"] }];
52
- const f = fixture({ descriptors, response: () => ({ text: "It is Tuesday." }),
53
- execute: () => ({ ok: true, result: { timeZone: "Australia/Perth" } }) });
100
+ assert.deepEqual(f.executions, [
101
+ { id: "reply-1:call_0", name: "action_execute", arguments: JSON.stringify({ round: 1 }) },
102
+ { id: "reply-2:call_0", name: "action_execute", arguments: JSON.stringify({ round: 2 }) }
103
+ ]);
104
+ const failed = fixture({ reply: round => round === 1 ? { call: "action_execute" } : { text: "It could not run." },
105
+ execute: () => ({ ok: false, error: { code: "action_failed", message: "Not allowed" } }) });
106
+ await failed.run();
107
+ assert.equal(failed.streamed[1].messages.find(message => message.role === "tool").content,
108
+ JSON.stringify({ error: { code: "action_failed", message: "Not allowed" } }), "A failed action reaches the model as its error");
109
+ });
110
+
111
+ test("admission precedes every model call, the state note gives the time, and a denied connection calls nothing", async () => {
112
+ const f = fixture();
54
113
  await f.run("What day is it today?");
55
- assert.deepEqual(f.executions.map(call => call.name), ["workspace_clock"]);
56
- assert.equal(f.requests[0].messages.some(message => message.role === "tool" && /Australia\/Perth/.test(message.content)), true);
57
- const denied = fixture({ descriptors, authorize: () => { throw new Error("Connection access revoked"); } });
58
- await assert.rejects(denied.run("What time is it now?"), /Connection access revoked/);
59
- assert.deepEqual(denied.executions, []);
114
+ assert.deepEqual(f.order, ["accept", "model"]);
115
+ assert.deepEqual(f.executions, [], "No action runs before the model asks for one");
116
+ assert.match(f.requests[0].messages[0].content, /^Use the supplied actions\.\n\nCurrent state, from the system\. It is the truth; earlier messages may be out of date:\n- The time: .+ UTC\. The person may be in another time zone\.$/u);
117
+ const denied = fixture({ authorize: () => { throw new Error("Connection access revoked"); } });
118
+ await assert.rejects(denied.run(), /Connection access revoked/);
60
119
  assert.deepEqual(denied.requests, []);
61
- const bounded = fixture({ descriptors, limits: { maxHistoryCharacters: 10 } });
62
- await assert.rejects(bounded.run("What time is it now?"), /history limit/);
63
- assert.deepEqual(bounded.executions, []);
120
+ let calls = 0;
121
+ const revoked = fixture({ authorize: () => { if (++calls > 1) throw new Error("Connection access revoked"); },
122
+ reply: () => ({ call: "action_execute" }) });
123
+ await assert.rejects(revoked.run(), /Connection access revoked/);
124
+ assert.equal(revoked.requests.length, 1, "Each later model call authorizes the connection again");
125
+ assert.equal(revoked.executions.length, 1);
64
126
  });
65
127
 
66
128
  test("unknown tool outcomes never enter ordinary failure recovery or request another action", async () => {
67
- const f = fixture({ response: () => ({ call: "action_execute" }), execute: () => {
129
+ const f = fixture({ reply: () => ({ call: "action_execute" }), execute: () => {
68
130
  throw Object.assign(new Error("Inspect the interrupted action"), { code: "conversation_tool_outcome_unknown" });
69
131
  } });
70
132
  await assert.rejects(f.run(), { code: "conversation_tool_outcome_unknown" });
71
133
  assert.equal(f.executions.length, 1);
72
134
  assert.equal(f.requests.length, 1);
73
135
  });
136
+
137
+ test("cancelling ends the reply with the signal's reason and no answer", async () => {
138
+ const controller = new AbortController();
139
+ const f = fixture({ reply: (_round, _body, request) => {
140
+ queueMicrotask(() => controller.abort(Object.assign(new Error("Stopped by the person"), { code: "stopped" })));
141
+ return new Promise((_resolve, reject) => request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true }));
142
+ } });
143
+ await assert.rejects(f.run("Work on it.", { signal: controller.signal }), { code: "stopped" });
144
+ assert.equal(f.requests.length, 1);
145
+ assert.equal(f.messages.some(message => message.complete), false);
146
+ });
147
+
148
+ test("raw markup in a reply carries on once, told that nothing in it ran", async () => {
149
+ const f = fixture({ reply: () => ({ text: "<invoke name=\"action_execute\"></invoke>" }) });
150
+ const result = await f.run();
151
+ assert.equal(f.streamed.length, 2, "The check sends the reply back once");
152
+ assert.equal(lastText(f.streamed[1]), CHECKS.markup);
153
+ assert.equal(f.messages.some(message => message.complete && message.role === "assistant"), false, "People never see the markup");
154
+ assert.equal(result.metadata.check, "markup");
155
+ });
156
+
157
+ test("a reply that stops at announcing its next step carries on once", async () => {
158
+ const f = fixture({ reply: round => ({ text: round === 1 ? "Let me check that." : "Here is the result: 3 bookings." }) });
159
+ await f.run();
160
+ assert.equal(f.streamed.length, 2);
161
+ assert.equal(lastText(f.streamed[1]), CHECKS.stoppedShort);
162
+ assert.equal(f.streamed[1].messages.some(message => message.content === "Let me check that."), false, "Its closing words are dropped");
163
+ assert.deepEqual(f.messages.filter(message => message.complete).map(message => [message.id, message.text]),
164
+ [["reply-2", "Here is the result: 3 bookings."]]);
165
+ });
166
+
167
+ test("a claim no action supports carries on once with what was claimed", async () => {
168
+ const f = fixture({ reply: round => round === 1 ? { text: "I've created the booking." } : round === 2 ? { call: "action_execute" } : { text: "The booking is in." },
169
+ once: body => body.messages[0].content.startsWith("You check an assistant's reply") ? JSON.stringify({ unsupported: ["I've created the booking"] }) : "" });
170
+ const result = await f.run();
171
+ assert.equal(f.requests.filter(body => !body.stream).length, 1, "One claim check");
172
+ assert.equal(lastText(f.streamed[1]), CHECKS.unsupported(["I've created the booking"]));
173
+ assert.equal(f.executions.length, 1);
174
+ assert.equal(f.messages.at(-1).text, "The booking is in.");
175
+ assert.equal(result.metadata.check, "unsupported");
176
+ });
177
+
178
+ test("at the step limit the reply ends with the engine's account of where it got to", async () => {
179
+ const account = "I looked twice and stopped there; say the word and I'll carry on.";
180
+ const f = fixture({ limits: { maxSteps: 2 }, reply: () => ({ call: "action_execute" }), once: () => account });
181
+ const result = await f.run();
182
+ assert.equal(f.executions.length, 2);
183
+ const [summing] = f.requests.filter(body => !body.stream);
184
+ assert.match(summing.messages[0].content, /^You are the assistant, a personal assistant bot\. You were working on the person's request but stopped early because I reached the most steps I take in one go\./u);
185
+ assert.deepEqual(f.messages.filter(message => message.complete && message.role === "assistant").map(message => [message.id, message.text]),
186
+ [["reply-3", account]]);
187
+ assert.deepEqual(result, { metadata: { unfinished: "I reached the most steps I take in one go" } });
188
+ });
189
+
190
+ test("the engine's own account ends the reply and is not sent back as stopping short", async () => {
191
+ // An empty answer to the account call leaves the engine's fallback account, which ends "…and I'll pick it up…".
192
+ const f = fixture({ limits: { maxSteps: 2 }, reply: () => ({ call: "action_execute" }), once: () => "" });
193
+ const result = await f.run();
194
+ assert.equal(f.executions.length, 2, "No second pass runs more actions after the step limit");
195
+ assert.equal(f.streamed.some(body => lastText(body) === CHECKS.stoppedShort), false);
196
+ const answers = f.messages.filter(message => message.complete && message.role === "assistant");
197
+ assert.equal(answers.length, 1);
198
+ assert.match(answers[0].text, /^I stopped before finishing, because I reached the most steps I take in one go\. Ask me to carry on/u);
199
+ assert.deepEqual(result, { metadata: { unfinished: "I reached the most steps I take in one go" } });
200
+ });
201
+
202
+ test("a provider failure before anything was done is the reply's error, after the engine's retries", async () => {
203
+ const f = fixture({ failure: () => new Response('{"error":{"message":"Capacity unavailable"}}',
204
+ { status: 429, headers: { "content-type": "application/json", "retry-after-ms": "0" } }) });
205
+ await assert.rejects(f.run(), /Capacity unavailable/);
206
+ assert.equal(f.requests.length, 7, "Six retries, then the error");
207
+ assert.equal(f.messages.length, 0);
208
+ });
209
+
210
+ test("a long history is compacted: the summary and its last turn are saved, and only later turns are read", async () => {
211
+ const turns = Array.from({ length: 6 }, (_, index) => ({ turnId: String(index + 1).padStart(6, "0"),
212
+ user: { role: "user", messageId: `m${index}`, text: `Question ${index} ${"x".repeat(4000)}` },
213
+ assistant: { role: "assistant", text: `Answer ${index}` }, messages: [] }));
214
+ const saved = [], phases = [];
215
+ const f = fixture({ descriptors: null, limits: { contextTokens: 10_000 }, once: () => "The person asked six long questions." });
216
+ await f.run("Next question", { history: turns, summary: "Earlier: hello.",
217
+ saveSummary: async value => { saved.push(value); }, onEvent: async event => { phases.push(event.phase); } });
218
+ assert.equal(saved.length, 1);
219
+ assert.equal(saved[0].text, "The person asked six long questions.");
220
+ const summarizing = f.requests.find(body => !body.stream);
221
+ assert.match(summarizing.messages.at(-1).content, /The summary so far, of everything before the messages below:\n<<summary>>\nEarlier: hello\./u);
222
+ const kept = turns.slice(turns.findIndex(turn => turn.turnId === saved[0].throughTurnId) + 1);
223
+ assert.ok(kept.length > 0 && kept.length < turns.length);
224
+ const [request] = f.streamed;
225
+ assert.match(request.messages[0].content, /Summary of the earlier part of this conversation[^]*The person asked six long questions\./u);
226
+ assert.deepEqual(request.messages.filter(message => message.role === "user").map(message => message.content),
227
+ [...kept.map(turn => turn.user.text), "Next question"]);
228
+ assert.deepEqual(phases, ["compacting", "working"]);
229
+ });
230
+
231
+ test("history replays each recorded action under the provider's own call id", async () => {
232
+ const f = fixture({ descriptors: null });
233
+ const call = { id: "reply-2:toolu_01", name: "action_execute", arguments: JSON.stringify({ day: "today" }),
234
+ result: { ok: true, result: { bookings: 3 } } };
235
+ await f.run("And tomorrow?", { history: [{ turnId: "000001", user: { role: "user", messageId: "m1", text: "Bookings today?" },
236
+ assistant: { role: "assistant", text: "You have 3." }, metadata: { applicationTools: [call] }, messages: [] }] });
237
+ const [request] = f.requests;
238
+ assert.deepEqual(request.messages.filter(message => message.tool_calls).flatMap(message => message.tool_calls.map(({ id }) => id)), ["toolu_01"]);
239
+ assert.deepEqual(request.messages.filter(message => message.role === "tool").map(message => [message.tool_call_id, message.content]),
240
+ [["toolu_01", JSON.stringify({ bookings: 3 })]]);
241
+ });
242
+
243
+ test("a short history is read whole with its stored summary, and nothing is saved", async () => {
244
+ const saved = [];
245
+ const f = fixture({ descriptors: null });
246
+ await f.run("Next question", { summary: "Earlier: hello.", saveSummary: async value => { saved.push(value); },
247
+ history: [{ turnId: "000003", user: { role: "user", messageId: "m3", text: "Hi" }, assistant: { role: "assistant", text: "Hello" }, messages: [] }] });
248
+ assert.deepEqual(saved, []);
249
+ assert.equal(f.requests.length, 1);
250
+ assert.match(f.requests[0].messages[0].content, /Summary of the earlier part of this conversation[^]*Earlier: hello\./u);
251
+ assert.deepEqual(f.requests[0].messages.slice(1).map(message => message.content), ["Hi", "Hello", "Next question"]);
252
+ });
@@ -25,7 +25,7 @@ function boundedTools(execute) {
25
25
  const actions = createActionCatalogue();
26
26
  actions.register({ contributorId: "bounded.tools", domain: "bounded", actions: [{
27
27
  id: "bounded.read", version: 1, kind: "query", channels: ["automation"], surfaces: ["app"],
28
- permission: { require: "none" }, idempotency: "none",
28
+ permission: { require: "none" },
29
29
  input: { schema: createSchema({ value: { type: "string", enum: ["1", "2", "3", "4"], required: true } }), mode: "replace" },
30
30
  output: { schema: createSchema({ prompt: { type: "string", required: true } }), mode: "replace" },
31
31
  execute({ value }, context) { return execute({ value: Number(value) }, context); }
@@ -222,8 +222,8 @@ test("native replacement saves preparation before close and retries the same ine
222
222
  assert.equal(successor.binding.sent, false);
223
223
  assert.equal(successor.binding.executionId, "");
224
224
  assert.equal(nativeIds.size, 1, "Opening a successor handle must not start a native process");
225
- await assert.rejects(f.conversation.send({ messageId: "early", text: "Too early" }), { code: "conversation_replacement_pending" });
226
- await assert.rejects(f.conversation.replace({ ...operation, briefing: "Changed" }), { code: "conversation_replacement_pending" });
225
+ await assert.rejects(f.conversation.send({ messageId: "early", text: "Too early" }), { code: "conversation_replacement_pending_before_send" });
226
+ await assert.rejects(f.conversation.replace({ ...operation, briefing: "Changed" }), { code: "conversation_replacement_unfinished" });
227
227
  await f.first.close();
228
228
  failCommit = false;
229
229
  const resumed = await f.runtime().open({ id: "conversation" });
@@ -686,7 +686,7 @@ function applicationActions(execute) {
686
686
  const actions = createActionCatalogue();
687
687
  actions.register({ contributorId: "test.tools", domain: "numbers", actions: [{
688
688
  id: "numbers.read", version: 1, kind: "query", channels: ["automation"], surfaces: ["app"],
689
- permission: { require: "all", permissions: ["numbers.read"] }, idempotency: "none",
689
+ permission: { require: "all", permissions: ["numbers.read"] },
690
690
  input: { schema: createSchema({}), mode: "replace" },
691
691
  output: { schema: createSchema({ value: { type: "number", required: true } }), mode: "replace" }, execute
692
692
  }] });
@@ -1177,7 +1177,7 @@ test("Claude native goal commands use ordinary admission, pause retains the goal
1177
1177
  const goal = await f.conversation.readGoal();
1178
1178
  assert.equal(goal.status, "active");
1179
1179
  assert.equal((await f.conversation.updateGoal(start)).duplicate, true);
1180
- await assert.rejects(f.conversation.updateGoal({ ...start, objective: "Different" }), { code: "conversation_message_conflict" });
1180
+ await assert.rejects(f.conversation.updateGoal({ ...start, objective: "Different" }), { code: "conversation_message_id_content_conflict" });
1181
1181
  const action = { expectedSegmentId: segmentId, expectedGoalId: goal.id };
1182
1182
  await assert.rejects(f.conversation.updateGoal({ ...action, action: "pause", expectedGoalId: "old-goal" }), /goal changed/u);
1183
1183
  await assert.rejects(f.conversation.updateGoal({ ...action, action: "cancel" }), /messageId/u);
@@ -1245,7 +1245,7 @@ test("Claude rejects reserved goals and waits for ordinary Send to deliver a rep
1245
1245
  await f.conversation.replace({ operationId: "renew-goal", reason: "renewal", expectedSegmentId: state.segmentId });
1246
1246
  state = await f.conversation.read();
1247
1247
  const goal = { action: "set", objective: "Tests pass", messageId: "goal-after-renewal", expectedSegmentId: state.segmentId };
1248
- await assert.rejects(f.conversation.updateGoal(goal), { code: "conversation_replacement_briefing_pending" });
1248
+ await assert.rejects(f.conversation.updateGoal(goal), { code: "conversation_briefing_before_goal" });
1249
1249
  await f.conversation.wait();
1250
1250
  assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
1251
1251
  await f.conversation.send({ messageId: "catch-up", text: "Continue" });
@@ -2374,7 +2374,7 @@ test("completed Claude native-tool tracking propagates its receipt write failure
2374
2374
  assert.equal(saved.metadata.runtime.nativeToolAttempt, false);
2375
2375
  assert.equal(attempts, 1, "The awaited native callback does not swallow or repeat a failed receipt write");
2376
2376
  await assert.rejects(f.prepare({ messageId: receipt.messageId, turnId: receipt.turnId }),
2377
- error => error.code === "conversation_completed_response_unavailable");
2377
+ error => error.code === "conversation_completed_response_no_receipt");
2378
2378
  assert.equal(f.effects(), 0);
2379
2379
  } finally { await f.close(); }
2380
2380
  });
@@ -2448,7 +2448,7 @@ test("completed Claude wake exclusions retain all product fingerprints only afte
2448
2448
  const state = await f.conversation.wait();
2449
2449
  assert.equal(state.status, "ready");
2450
2450
  assert.deepEqual(state.conversationLog.map(turn => turn.user.messageId), ["batch-a", "batch-b"]);
2451
- const { claudeNativeMessageId } = await import("../src/server/conversation/claudeTurn.js");
2451
+ const { claudeNativeMessageId } = await import("../src/server/conversation/claudeHistory.js");
2452
2452
  const nativeMessageId = claudeNativeMessageId(receipt.messageId);
2453
2453
  const frames = (await f.trace()).filter(row => row.frame?.type === "user" && row.frame.uuid === nativeMessageId);
2454
2454
  assert.equal(frames.length, 1, "The original Claude ID mapper identifies exactly one actual admitted native input");
@@ -2489,7 +2489,7 @@ test("completed Claude retained wake exclusions cannot change on a same-ID retry
2489
2489
  assert.deepEqual(retained.runtime.request.seen, before.runtime.request.seen);
2490
2490
  assert.equal(retained.runtime.request.message, before.runtime.request.message);
2491
2491
  await assert.rejects(f.conversation.wake({ messageId: "lost-exclusions", text: "lost" }, options),
2492
- error => error.code === "conversation_message_conflict");
2492
+ error => error.code === "conversation_message_id_exclusions_conflict");
2493
2493
  assert.deepEqual((await f.storage.read("conversation", tx => tx.readMetadata())).runtime.request, retained.runtime.request);
2494
2494
  assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
2495
2495
  await assert.rejects(f.conversation.wake({ messageId: "different-exclusions", text: "Read a number" }, { excludedMessageIds: ["batch-a"] }),
@@ -2503,7 +2503,7 @@ test("completed Claude retained wake exclusions cannot change on a same-ID retry
2503
2503
  });
2504
2504
  const old = await f.storage.read("conversation", tx => tx.readMetadata());
2505
2505
  await assert.rejects(f.conversation.wake({ messageId: "old-exclusions", text: "Read a number" }, { excludedMessageIds: ["batch-a"] }),
2506
- error => error.code === "conversation_message_conflict");
2506
+ error => error.code === "conversation_message_id_exclusions_conflict");
2507
2507
  assert.deepEqual((await f.storage.read("conversation", tx => tx.readMetadata())).runtime.request, old.runtime.request);
2508
2508
  assert.equal(f.effects(), 0);
2509
2509
  } finally { await f.close(); }
@@ -2661,8 +2661,12 @@ test("completed Claude prepared reply refuses foreign targets, existing finals a
2661
2661
  });
2662
2662
  const prepared = await f.prepare(receipt);
2663
2663
  const before = await f.storage.read("conversation", tx => tx.readMetadata());
2664
- for (const turnId of ["missing", receipt.turnId, populated.turnId, consumed.turnId, owned.turnId]) {
2665
- await assert.rejects(prepared.publishReply({ turnId }), error => error.code === "conversation_tool_receipt_conflict");
2664
+ for (const [turnId, code] of [
2665
+ ["missing", "conversation_completed_reply_no_target"], [receipt.turnId, "conversation_completed_reply_no_target"],
2666
+ [populated.turnId, "conversation_reply_target_used"], [consumed.turnId, "conversation_reply_target_used"],
2667
+ [owned.turnId, "conversation_completed_reply_no_target"]
2668
+ ]) {
2669
+ await assert.rejects(prepared.publishReply({ turnId }), error => error.code === code);
2666
2670
  }
2667
2671
  assert.equal((await f.storage.read("conversation", tx => tx.readTurn(populated.turnId))).assistant.text, "Unassociated final");
2668
2672
  assert.equal((await f.storage.read("conversation", tx => tx.readTurn(consumed.turnId))).assistant, null);
@@ -7,7 +7,7 @@ import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { promisify } from "node:util";
9
9
  import test from "node:test";
10
- import { createClaudeConversationTurn } from "../src/server/conversation/claudeTurn.js";
10
+ import { createClaudeConversationTurn } from "../src/server/conversation/claudeNativeTurn.js";
11
11
 
12
12
  const execute = promisify(execFile);
13
13
 
@@ -46,7 +46,7 @@ async function fixture(t, body) {
46
46
  t.after(() => rm(directory, { recursive: true, force: true }));
47
47
  const command = join(directory, "claude-fixture.mjs");
48
48
  await writeFile(command, `#!${process.execPath}\n${body}`, { mode: 0o700 });
49
- return { command, workdir: directory };
49
+ return { command, workdir: directory, env: process.env };
50
50
  }
51
51
 
52
52
  test("standalone Claude startup uses the supplied execution host and carries its native protocol", { skip: process.platform === "win32" }, async (t) => {
@@ -122,7 +122,7 @@ test("Claude model configuration maps background calls and resets external setti
122
122
  });
123
123
 
124
124
  test("Claude status reads native JSON and returns only public account details", async () => {
125
- const status = await readClaudeCodeAuthStatus({ credentialHome: { home: "/home/fixture-json" }, commandRunner: async (input) => {
125
+ const status = await readClaudeCodeAuthStatus({ env: process.env, credentialHome: { home: "/home/fixture-json" }, commandRunner: async (input) => {
126
126
  assert.deepEqual(input.args, ["auth", "status", "--json"]);
127
127
  assert.equal(input.timeout, 30_000);
128
128
  assert.equal(input.credentialHome.home, "/home/fixture-json");
@@ -137,7 +137,7 @@ test("Claude signed-out JSON is normal while failed and malformed status respons
137
137
  // Reads of one account share an entry, so each response gets its own account.
138
138
  let account = 0;
139
139
  const readStatus = (result) => readClaudeCodeAuthStatus({
140
- credentialHome: { home: `/home/fixture-signed-out-${account += 1}` }, commandRunner: async () => result
140
+ env: process.env, credentialHome: { home: `/home/fixture-signed-out-${account += 1}` }, commandRunner: async () => result
141
141
  });
142
142
  const signedOut = { ok: false, exitCode: 1, stdout: JSON.stringify({ loggedIn: false, authMethod: "none" }) };
143
143
  assert.deepEqual(await readStatus(signedOut), {
@@ -322,7 +322,7 @@ test("Claude preserves the execution host's failed-start identity and cleanup pr
322
322
  async start() { throw failure; },
323
323
  async stop() { stops += 1; return { scopeEmpty: true }; }
324
324
  };
325
- await assert.rejects(createClaudeCodeProcess({ execution }), error => {
325
+ await assert.rejects(createClaudeCodeProcess({ execution, env: process.env }), error => {
326
326
  assert.equal(error, failure);
327
327
  assert.equal(error.executionId, "admitted-execution");
328
328
  assert.equal(error.stopProof, stopProof);
@@ -380,7 +380,7 @@ test("Claude observes parent exit after initialization even while a child retain
380
380
  done();
381
381
  } });
382
382
  let failures = 0;
383
- const native = await createClaudeCodeProcess({ execution: {
383
+ const native = await createClaudeCodeProcess({ env: process.env, execution: {
384
384
  async start() { return { id: "parent-exit", stdin, stdout, exited: exited.promise }; },
385
385
  async stop() { return { scopeEmpty: true }; }
386
386
  }, onFailure(error) { failures++; failed.resolve(error); } });
@@ -405,7 +405,7 @@ test("Claude reports a task-limit exit that arrives after its stream ended", { t
405
405
  stdout.write(JSON.stringify({ type: "control_response", response: { subtype: "success", request_id: frame.request_id, response: {} } }) + "\n");
406
406
  done();
407
407
  } });
408
- const native = await createClaudeCodeProcess({ execution: {
408
+ const native = await createClaudeCodeProcess({ env: process.env, execution: {
409
409
  async start() { return { id: "task-limit", stdin, stdout, exited: exited.promise }; },
410
410
  async stop() { return { scopeEmpty: true }; }
411
411
  }, onFailure(error) { failed.resolve(error); } });
@@ -429,7 +429,7 @@ test("Claude host startup budget allows slow initialization and still bounds fai
429
429
  requested.resolve(JSON.parse(chunk.toString())); done();
430
430
  } });
431
431
  const stops = [];
432
- const pending = createClaudeCodeProcess({ initializationTimeoutMs: 90_000, execution: {
432
+ const pending = createClaudeCodeProcess({ env: process.env, initializationTimeoutMs: 90_000, execution: {
433
433
  async start() { return { id: "cold-start", stdin, stdout }; },
434
434
  async stop(id) { stops.push(id); return { scopeEmpty: true }; }
435
435
  } });
@@ -475,7 +475,7 @@ test("Claude initialization rejects confirmed execution exit before half-open pr
475
475
  return { scopeEmpty: cleanup === "verified" };
476
476
  }
477
477
  };
478
- const pending = createClaudeCodeProcess({ execution });
478
+ const pending = createClaudeCodeProcess({ execution, env: process.env });
479
479
  let failure;
480
480
  void pending.catch(error => { failure = error; });
481
481
  try {
@@ -509,7 +509,7 @@ test("Claude initialization preserves rejected execution observation and exact c
509
509
  const stdin = new Writable({ write(_chunk, _encoding, done) { requested.resolve(); done(); } });
510
510
  const failure = new Error("Execution observation failed");
511
511
  const stops = [];
512
- const pending = createClaudeCodeProcess({ execution: {
512
+ const pending = createClaudeCodeProcess({ env: process.env, execution: {
513
513
  async start() { return { id: "observed-startup", stdin, stdout, exited: exited.promise }; },
514
514
  async stop(id) { stops.push(id); return { scopeEmpty: true }; }
515
515
  } });
@@ -3,9 +3,9 @@ import test from "node:test";
3
3
  import { Duplex } from "node:stream";
4
4
  import { createClaudeJsonClient } from "../src/server/conversation/claudeClient.js";
5
5
  import { setTimeout as delay } from "node:timers/promises";
6
- import { createClaudeConversationTurn, claudeNativeMessageId, runClaudeRenewalTurn,
7
- waitForClaudeConversationTurn } from "../src/server/conversation/claudeTurn.js";
8
- import { readClaudeHistory } from "../src/server/conversation/claudeHistory.js";
6
+ import { runClaudeRenewalTurn, waitForClaudeConversationTurn } from "../src/server/conversation/claudeTurn.js";
7
+ import { createClaudeConversationTurn } from "../src/server/conversation/claudeNativeTurn.js";
8
+ import { claudeNativeMessageId, readClaudeHistory } from "../src/server/conversation/claudeHistory.js";
9
9
 
10
10
  function fixture() {
11
11
  const events = [];
@@ -664,6 +664,19 @@ test("renewal refuses changed or unrelated successor history before native work
664
664
  assert.match(f.events.at(-1)[1], /will not be submitted again/);
665
665
  });
666
666
 
667
+ test("a refusal hands the caller's createError its message and its own code", async () => {
668
+ const createError = (message, { code }) => Object.assign(new Error(message), { code, builtByCaller: true });
669
+ const f = renewalFixture();
670
+ await assert.rejects(runClaudeRenewalTurn(f.conversation, { ...f.input, expectedThreadId: "different" }, { createError }),
671
+ { message: /expected native history/, code: "conversation_claude_renewal_history_mismatch", builtByCaller: true });
672
+ f.history.userIds.push("unrelated");
673
+ await assert.rejects(runClaudeRenewalTurn(f.conversation, { ...f.input, requireFreshHistory: true }, { createError }),
674
+ { message: /unrelated messages/, code: "conversation_claude_renewal_unrelated_history", builtByCaller: true });
675
+ const state = { process: {}, turn: { active: true }, completion: Promise.withResolvers() };
676
+ await assert.rejects(waitForClaudeConversationTurn({ state, async interrupt() {} }, {}, { defaultTimeoutMs: 5, createError }),
677
+ { message: /within the time limit/, code: "conversation_claude_turn_timeout", builtByCaller: true });
678
+ });
679
+
667
680
  test("renewal dispatches once with the original schema and waits before confirmed cleanup", async () => {
668
681
  const f = renewalFixture();
669
682
  const outputSchema = { type: "object" };
@@ -1451,7 +1464,7 @@ async function suppliedCommonFixture(t, { tools = true, onDeliveryTiming, hostDe
1451
1464
  const actions = createActionCatalogue();
1452
1465
  actions.register({ contributorId: "lesson", domain: "lesson", actions: [{
1453
1466
  id: "lesson.inspect", version: 1, kind: "query", channels: ["automation"], surfaces: ["app"],
1454
- permission: { require: "authenticated" }, idempotency: "none",
1467
+ permission: { require: "authenticated" },
1455
1468
  input: { schema: createSchema({}), mode: "replace" },
1456
1469
  output: { schema: createSchema({}), mode: "replace" }, execute: executeTool
1457
1470
  }] });
@@ -1618,7 +1631,7 @@ for (const tools of [true, false]) test(`Claude supplied common runtime checkpoi
1618
1631
  assert.equal(typeof f.entry.command.finishIfCurrent, "function");
1619
1632
  if (tools) {
1620
1633
  await f.loadContract();
1621
- assert.throws(() => f.admissions.at(-1).assertCompletionCurrent(), { code: "conversation_tool_request_retired" });
1634
+ assert.throws(() => f.admissions.at(-1).assertCompletionCurrent(), { code: "conversation_native_completion_message_stale" });
1622
1635
  } else assert.equal(f.entry.command.tools, undefined);
1623
1636
  await f.frame({ type: "result", subtype: "success", result: "Where would you try the application?", uuid: "question-final" });
1624
1637
  await f.conversation.wait();
@@ -1655,7 +1668,7 @@ for (const worker of [false, true]) test(`Claude supplied common completion join
1655
1668
  await new Promise(resolve => setImmediate(resolve));
1656
1669
  }
1657
1670
  await f.loadContract("successor-contract");
1658
- assert.throws(() => first.assertCompletionCurrent(), { code: "conversation_tool_request_retired" });
1671
+ assert.throws(() => first.assertCompletionCurrent(), { code: "conversation_native_completion_message_stale" });
1659
1672
  await f.frame({ type: "result", subtype: "success", result: "The successor question", uuid: "successor-final" });
1660
1673
  if (worker) {
1661
1674
  assert.equal(f.checkpoints.length, 0, "Foreground completion cannot checkpoint live background work");
@@ -1668,7 +1681,7 @@ for (const worker of [false, true]) test(`Claude supplied common completion join
1668
1681
  assert.equal(f.checkpoints.length, 1);
1669
1682
  assert.equal(f.checkpoints[0].final.inputMessageId, claudeNativeMessageId("common-successor"));
1670
1683
  assert.equal(f.checkpoints[0].admission.messageId, "common-successor");
1671
- assert.throws(() => first.assertCompletionCurrent(), { code: "conversation_tool_request_retired" });
1684
+ assert.throws(() => first.assertCompletionCurrent(), { code: "conversation_native_completion_message_stale" });
1672
1685
  });
1673
1686
 
1674
1687
  for (const preceding of [null, "task_updated", "background_tasks_changed"])
@@ -1741,7 +1754,7 @@ for (const cancelled of [false, true]) test(`Claude supplied native settlement r
1741
1754
  assert.equal(f.entry.nativeTurn.read().active, false);
1742
1755
  assert.equal(f.entry.turn.active, true);
1743
1756
  assert.equal(f.checkpoints.length, 0, "Native queue does not await the reserved common admission");
1744
- assert.throws(() => first.assertCompletionCurrent(), { code: "conversation_tool_request_retired" });
1757
+ assert.throws(() => first.assertCompletionCurrent(), { code: "conversation_native_completion_message_stale" });
1745
1758
  if (cancelled) cancellation = f.conversation.cancel();
1746
1759
  } finally { held.resolve(); }
1747
1760
  await cancellation;
@@ -4,9 +4,11 @@ import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import test from "node:test";
7
- import { CodexAppServerAgentProvider, ensureCodexAppServerThread } from "../src/server/conversation/codexProvider.js";
7
+ import { CodexAppServerAgentProvider } from "../src/server/conversation/codexProvider.js";
8
+ import { ensureCodexAppServerThread } from "../src/server/conversation/codexProviderThreads.js";
8
9
  import { writeCodexAppServerMetadata } from "../src/server/conversation/codexProcess.js";
9
- import { createCodexAppServerProviderOwner, codexAppServerOwnedRuntimeKey, releaseSupersededCodexRuntime } from "../src/server/conversation/codexProviderOwner.js";
10
+ import { createCodexAppServerProviderOwner } from "../src/server/conversation/codexProviderOwner.js";
11
+ import { codexAppServerOwnedRuntimeKey, releaseSupersededCodexRuntime } from "../src/server/conversation/codexRuntimeInventory.js";
10
12
  import { createCodexProviderSelection } from "../src/server/conversation/codexProviderSelection.js";
11
13
  import { createCodexRuntimeLifecycle } from "../src/server/conversation/codexRuntimeLifecycle.js";
12
14
 
@@ -261,7 +263,7 @@ test("recording a session's runtime for a new sign-in removes the previous sign-
261
263
  const running = await runtimeDirectory(root, "running", "running");
262
264
  const bind = superseded => ensureCodexAppServerThread({
263
265
  observeThread: async () => {},
264
- provider: { ensureRuntime: async () => ({ runtimeDir: path.join(root, "codex-app-server-current") }),
266
+ provider: { ensureAvailable: async () => ({}), ensureRuntime: async () => ({ runtimeDir: path.join(root, "codex-app-server-current") }),
265
267
  startThread: async () => ({ id: "thread-1" }) },
266
268
  settings: async () => ({ threadSettings: {}, threadStartSettings: {} }),
267
269
  identity: { read: () => "", write: async () => ({ supersededRuntimeDir: superseded }) },