@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
@@ -0,0 +1,150 @@
1
+ import { normalizeThinkingMessageText } from "./thinkingText.js";
2
+ import { normalizeConversationLogPagination } from "./pagination.js";
3
+
4
+ function isRecord(value) {
5
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
6
+ }
7
+
8
+ function normalizeConversationMessage(message = {}, { normalizeAttachments = value => Array.isArray(value) ? value : [] } = {}) {
9
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
10
+ return null;
11
+ }
12
+ const role = String(message.role || "").trim();
13
+ const text = role === "thinking"
14
+ ? normalizeThinkingMessageText(message.text)
15
+ : String(message.text || "").trim();
16
+ if (!role || !text) {
17
+ return null;
18
+ }
19
+ const attachments = normalizeAttachments(message.attachments);
20
+ return {
21
+ at: String(message.at || "").trim(),
22
+ ...(attachments.length ? { attachments } : {}),
23
+ ...(String(message.messageId || "").trim()
24
+ ? { messageId: String(message.messageId).trim() }
25
+ : {}),
26
+ role,
27
+ text,
28
+ ...(message.receipt === false ? { receipt: false } : {})
29
+ };
30
+ }
31
+
32
+ function chronologicalConversationActivity(messages = []) {
33
+ return [...messages].sort((left, right) => (
34
+ String(left?.at || "").localeCompare(String(right?.at || ""))
35
+ ));
36
+ }
37
+
38
+ function normalizeConversationTurn(turn = {}, index = 0, options = {}) {
39
+ if (!turn || typeof turn !== "object" || Array.isArray(turn)) {
40
+ return null;
41
+ }
42
+ const user = normalizeConversationMessage(turn.user, options);
43
+ const assistant = normalizeConversationMessage(turn.assistant, options);
44
+ const normalizedCommentary = Array.isArray(turn.commentary)
45
+ ? turn.commentary.map(message => normalizeConversationMessage(message, options)).filter(Boolean)
46
+ : [];
47
+ const system = normalizeConversationMessage(turn.system, options);
48
+ const normalizedThinking = Array.isArray(turn.thinking)
49
+ ? turn.thinking.map(message => normalizeConversationMessage(message, options)).filter(Boolean)
50
+ : [];
51
+ const activityFromMessages = Array.isArray(turn.messages)
52
+ ? turn.messages
53
+ .map(message => normalizeConversationMessage(message, options))
54
+ .filter((message) => ["commentary", "thinking"].includes(message?.role))
55
+ : [];
56
+ const activity = activityFromMessages.length
57
+ ? activityFromMessages
58
+ : chronologicalConversationActivity([...normalizedThinking, ...normalizedCommentary]);
59
+ const commentary = activity.filter((message) => message.role === "commentary");
60
+ const thinking = activity.filter((message) => message.role === "thinking");
61
+ if (!system && !user && !assistant && !activity.length) {
62
+ return null;
63
+ }
64
+ return {
65
+ assistant,
66
+ commentary,
67
+ messages: [system, user, ...activity, assistant].filter(Boolean),
68
+ ...(isRecord(turn.metadata) ? { metadata: turn.metadata } : {}),
69
+ ...(system ? { system } : {}),
70
+ thinking,
71
+ turnId: String(turn.turnId || index + 1).trim(),
72
+ user
73
+ };
74
+ }
75
+
76
+ function applyConversationLogPatch(payload = {}, patch = null, options = {}) {
77
+ if (patch?.type !== "upsert-turn" || !isRecord(patch.turn)) {
78
+ return null;
79
+ }
80
+ const source = isRecord(payload) ? payload : {};
81
+ const turns = Array.isArray(source.conversationLog) ? source.conversationLog : [];
82
+ const turnId = String(patch.turn.turnId || "").trim();
83
+ if (!turnId) {
84
+ return null;
85
+ }
86
+ const existingIndex = turns.findIndex((turn) => String(turn?.turnId || "").trim() === turnId);
87
+ const existing = turns[existingIndex];
88
+ const updated = { ...existing, ...patch.turn };
89
+ if (existing) {
90
+ // An upsert adds delivered messages. An older partial turn must not remove
91
+ // a saved answer or progress; authoritative history arrives through a fresh read.
92
+ for (const role of ["system", "user", "assistant"]) {
93
+ if (existing[role] && !updated[role]) {
94
+ updated[role] = existing[role];
95
+ }
96
+ }
97
+ const existingTurn = normalizeConversationTurn(existing, 0, options);
98
+ const patchedTurn = normalizeConversationTurn(patch.turn, 0, options);
99
+ for (const role of ["thinking", "commentary"]) {
100
+ const messages = new Map();
101
+ for (const message of [...(existingTurn?.[role] || []), ...(patchedTurn?.[role] || [])]) {
102
+ // Timestamped progress without a message ID updates one saved file as
103
+ // its text grows. Match that identity instead of retaining each version.
104
+ const key = message.messageId || JSON.stringify(message.at
105
+ ? [message.role, message.at]
106
+ : [message.role, "", message.text]);
107
+ messages.set(key, message);
108
+ }
109
+ updated[role] = chronologicalConversationActivity([...messages.values()]);
110
+ }
111
+ updated.messages = [
112
+ updated.system,
113
+ updated.user,
114
+ ...chronologicalConversationActivity([...updated.thinking, ...updated.commentary]),
115
+ updated.assistant
116
+ ].filter(Boolean);
117
+ }
118
+ const nextTurns = (existingIndex >= 0
119
+ ? turns.map((turn, index) => index === existingIndex ? updated : turn)
120
+ : [...turns, patch.turn]
121
+ ).sort((left, right) => String(left?.turnId || "").localeCompare(
122
+ String(right?.turnId || ""),
123
+ undefined,
124
+ {
125
+ numeric: true
126
+ }
127
+ ));
128
+ const limit = Number.parseInt(String(options.limit || ""), 10);
129
+ const limitedTurns = Number.isFinite(limit) && limit > 0
130
+ ? nextTurns.slice(-limit)
131
+ : nextTurns;
132
+ const wasTrimmed = limitedTurns.length < nextTurns.length;
133
+ const pagination = normalizeConversationLogPagination(source.pagination);
134
+ const hasMoreBefore = pagination.hasMoreBefore || wasTrimmed;
135
+ const oldestTurnId = String(limitedTurns[0]?.turnId || "").trim();
136
+ return {
137
+ ...source,
138
+ conversationLog: limitedTurns,
139
+ pagination: {
140
+ ...pagination,
141
+ count: limitedTurns.length,
142
+ hasMoreBefore,
143
+ newestTurnId: String(limitedTurns.at(-1)?.turnId || "").trim(),
144
+ nextBeforeTurnId: hasMoreBefore ? oldestTurnId : "",
145
+ oldestTurnId
146
+ }
147
+ };
148
+ }
149
+
150
+ export { normalizeConversationMessage, normalizeConversationTurn, applyConversationLogPatch };
@@ -0,0 +1,48 @@
1
+ function messageText(value = "") {
2
+ return String(value || "").trim();
3
+ }
4
+
5
+ function conversationTurns(conversationLog = {}) {
6
+ if (Array.isArray(conversationLog?.turns)) {
7
+ return conversationLog.turns;
8
+ }
9
+ return Array.isArray(conversationLog) ? conversationLog : [];
10
+ }
11
+
12
+ function latestAssistantMessageAwaitingUserReply(conversationLog = {}) {
13
+ const turns = conversationTurns(conversationLog);
14
+ for (let turnIndex = turns.length - 1; turnIndex >= 0; turnIndex -= 1) {
15
+ const turn = turns[turnIndex];
16
+ const messages = Array.isArray(turn?.messages) ? turn.messages : [];
17
+ if (messages.length) {
18
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
19
+ const message = messages[index];
20
+ const text = messageText(message?.text);
21
+ if (!text) {
22
+ continue;
23
+ }
24
+ if (message?.role === "user") {
25
+ return "";
26
+ }
27
+ if (message?.role === "assistant") {
28
+ if (message.status === "inProgress") return "";
29
+ return text;
30
+ }
31
+ }
32
+ continue;
33
+ }
34
+ if (messageText(turn?.user?.text)) {
35
+ return "";
36
+ }
37
+ const assistantText = messageText(turn?.assistant?.text);
38
+ if (assistantText) {
39
+ if (turn.assistant.status === "inProgress") return "";
40
+ return assistantText;
41
+ }
42
+ }
43
+ return "";
44
+ }
45
+
46
+ export {
47
+ latestAssistantMessageAwaitingUserReply
48
+ };
@@ -16,10 +16,13 @@ export function mergeConversationStream(turns = [], snapshot = {}) {
16
16
  const result = [...turns];
17
17
  for (const message of messages) {
18
18
  const tail = result.at(-1);
19
- const turn = tail && !tail.assistant && !tail.system
20
- ? { ...tail, messages: [...messagesForTurn(tail)] }
21
- : { turnId: `stream:${message.messageId}`, messages: [] };
22
- if (turn.turnId === tail?.turnId) result[result.length - 1] = turn;
19
+ const authoredIndex = message.turnId ? result.findIndex(turn => turn.turnId === message.turnId) : -1;
20
+ const existing = authoredIndex >= 0 ? result[authoredIndex] : !message.turnId && tail && !tail.assistant && !tail.system ? tail : null;
21
+ const turn = existing
22
+ ? { ...existing, messages: [...messagesForTurn(existing)] }
23
+ : { turnId: message.turnId || `stream:${message.messageId}`, messages: [] };
24
+ if (authoredIndex >= 0) result[authoredIndex] = turn;
25
+ else if (existing) result[result.length - 1] = turn;
23
26
  else result.push(turn);
24
27
  turn.messages.push(message);
25
28
  turn.pending = true;
@@ -54,3 +54,5 @@ export {
54
54
 
55
55
  export { parseJsonObject } from "./support/jsonObject.js";
56
56
  export { toPositiveInteger } from "./support/positiveInteger.js";
57
+
58
+ export { isAssistantProgressOnlyText } from "./assistantResponseText.js";
@@ -0,0 +1,45 @@
1
+ // Native integration facts, separate from an application's offered models,
2
+ // recommended thinking levels, account permissions and billing presentation.
3
+ const model = (id, contextWindow, variants, options = {}) => Object.freeze({
4
+ id, contextWindow, variants: Object.freeze(variants), ...options
5
+ });
6
+
7
+ const NATIVE_AI_PROVIDERS = Object.freeze([
8
+ Object.freeze({
9
+ id: "deepseek", baseUrl: "https://api.deepseek.com/", claudeBaseUrl: "https://api.deepseek.com/anthropic",
10
+ claudeAutoCompactWindow: 786432, webSearch: false,
11
+ models: Object.freeze([
12
+ model("deepseek-flash", 1048576, ["low", "high", "max"], { images: true, freeformPatch: true, codexHistoryRouting: true }),
13
+ model("deepseek-v4-pro", 1048576, ["low", "high", "max"], { freeformPatch: true })
14
+ ])
15
+ }),
16
+ Object.freeze({
17
+ id: "zai-coding-plan", baseUrl: "https://api.z.ai/api/v1", claudeBaseUrl: "https://api.z.ai/api/anthropic",
18
+ claudeAutoCompactWindow: 1000000, webSearch: false,
19
+ models: Object.freeze([
20
+ model("glm-5.3", 1048576, ["low", "high", "max"], { freeformPatch: true, codexHistoryRouting: true })
21
+ ])
22
+ }),
23
+ Object.freeze({
24
+ id: "zai", baseUrl: "https://api.z.ai/api/v1", claudeBaseUrl: "https://api.z.ai/api/anthropic",
25
+ claudeAutoCompactWindow: 1000000, webSearch: false,
26
+ models: Object.freeze([
27
+ model("glm-5.3", 1048576, ["low", "high", "max"], { freeformPatch: true, codexHistoryRouting: true })
28
+ ])
29
+ })
30
+ ]);
31
+
32
+ function nativeAiProvider(id) {
33
+ return NATIVE_AI_PROVIDERS.find(provider => provider.id === id) || null;
34
+ }
35
+
36
+ function nativeAiModel(id, providerId = "") {
37
+ for (const provider of NATIVE_AI_PROVIDERS) {
38
+ if (providerId && provider.id !== providerId) continue;
39
+ const model = provider.models.find(candidate => candidate.id === id);
40
+ if (model) return { ...model, modelProviderId: provider.id };
41
+ }
42
+ return null;
43
+ }
44
+
45
+ export { NATIVE_AI_PROVIDERS, nativeAiProvider, nativeAiModel };
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { createConversationTranscript } from "../server/conversation/transcript.js";
3
3
 
4
4
  /** Run against an app's isolated adapter fixture; never point this at production data. */
5
- export async function verifyConversationStorageContract(storage) {
5
+ export async function verifyConversationStorageContract(storage, { runtime = false } = {}) {
6
6
  const transcript = createConversationTranscript({ storage, clock: () => new Date("2026-01-01T00:00:00Z") });
7
7
  const scope = "actor-a:workspace-a:conversation-a";
8
8
  const other = "actor-b:workspace-a:conversation-a";
@@ -31,5 +31,35 @@ export async function verifyConversationStorageContract(storage) {
31
31
  assert.deepEqual(older.conversationLog[0].messages.map((message) => message.role), ["user", "thinking", "commentary", "assistant"]);
32
32
  older.conversationLog[0].user.text = "Client mutation";
33
33
  assert.equal((await transcript.readConversationLog(scope))[0].user.text, "First question", "Reads must be detached snapshots.");
34
+ if (runtime) {
35
+ const retainedMetadata = (await storage.read(scope, transaction => transaction.readTurn(first.turnId))).metadata || {};
36
+ await storage.write(scope, async transaction => {
37
+ for (const method of ["readMetadata", "writeMetadata", "updateTurnMetadata"]) {
38
+ assert.equal(typeof transaction[method], "function", `Runtime storage requires ${method}().`);
39
+ }
40
+ await transaction.writeMetadata({ runtime: { receipt: "accepted" } });
41
+ await transaction.updateTurnMetadata(first.turnId, { product: { retained: true } });
42
+ await transaction.updateTurnMetadata(first.turnId, { runtime: { status: "complete" } });
43
+ });
44
+ const before = await storage.read(scope, async transaction => ({
45
+ metadata: await transaction.readMetadata(), turn: await transaction.readTurn(first.turnId), ids: await transaction.listTurnIds()
46
+ }));
47
+ assert.deepEqual(before.metadata, { runtime: { receipt: "accepted" } });
48
+ assert.deepEqual(before.turn.metadata, { ...retainedMetadata, product: { retained: true }, runtime: { status: "complete" } });
49
+ assert.deepEqual(await storage.read(other, transaction => transaction.readMetadata()), {}, "Metadata must remain scoped.");
50
+ const detached = await storage.read(scope, transaction => transaction.readMetadata());
51
+ detached.runtime.receipt = "client edit";
52
+ assert.deepEqual(await storage.read(scope, transaction => transaction.readMetadata()), before.metadata);
53
+ await assert.rejects(storage.write(scope, async transaction => {
54
+ await transaction.writeMetadata({ runtime: { receipt: "uncommitted" } });
55
+ await transaction.updateTurnMetadata(first.turnId, { runtime: { status: "uncommitted" } });
56
+ await transaction.appendMessage(await transaction.nextTurnId(), { role: "user", messageId: "uncommitted", text: "Uncommitted", at: "2026-01-01T00:00:00Z" });
57
+ throw new Error("contract-rollback");
58
+ }), /contract-rollback/);
59
+ const after = await storage.read(scope, async transaction => ({
60
+ metadata: await transaction.readMetadata(), turn: await transaction.readTurn(first.turnId), ids: await transaction.listTurnIds()
61
+ }));
62
+ assert.deepEqual(after, before, "Failed runtime writes must preserve transcript and metadata together.");
63
+ }
34
64
  return { scope, other, transcript };
35
65
  }
@@ -0,0 +1,11 @@
1
+ // Original native probes for host integration tests. The test supplies its
2
+ // executable or endpoint, authorization and cleanup.
3
+ export { CodexAppServerJsonRpcClient } from "../server/conversation/codexClient.js";
4
+ export { startCodexHistoryAdapter } from "../server/conversation/codexHistoryAdapter.js";
5
+ export {
6
+ classifyCodexAppServerEvent,
7
+ codexAppServerErrorText,
8
+ codexAppServerNotificationUsageLimitExceeded,
9
+ codexAppServerOutputOwnerTurnId
10
+ } from "../server/conversation/codexEvents.js";
11
+ export { prepareCodexModelCatalog } from "../server/conversation/codexConfiguration.js";
@@ -0,0 +1,3 @@
1
+ // Original loopback client for host integration tests. The test supplies its
2
+ // endpoint, authorization and cleanup.
3
+ export { createOpenCodeServerClient } from "../server/conversation/openCodeClient.js";
@@ -42,6 +42,7 @@ test("the configured public AI integration streams its first chunk before comple
42
42
  assert.equal((await second).value.choices[0].delta.content, " there");
43
43
  await writer.write(new TextEncoder().encode('data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n'));
44
44
  await writer.close();
45
+ assert.equal((await iterator.next()).value.choices[0].finish_reason, "stop");
45
46
  assert.equal((await iterator.next()).done, true);
46
47
  });
47
48
 
@@ -120,6 +121,7 @@ test("legacy Anthropic configuration streams through the native Messages protoco
120
121
  await send({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 2 } });
121
122
  await send({ type: "message_stop" });
122
123
  await writer.close();
124
+ assert.equal((await iterator.next()).value.choices[0].finish_reason, "stop");
123
125
  assert.equal((await iterator.next()).done, true);
124
126
  });
125
127
 
@@ -0,0 +1,73 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createApiConversationDriver } from "../src/server/conversation/providers/api.js";
4
+
5
+ const frame = (delta, finish_reason) => `data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason }] })}\n\n`;
6
+ 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 = [];
10
+ let accepted = false;
11
+ const driver = createApiConversationDriver({ limits, connections: { resolve: async () => {
12
+ await authorize();
13
+ return { providerId: "test", model: "exact-model", sdkPackage: "@ai-sdk/openai-compatible",
14
+ apiKey: "test", baseURL: "http://test.invalid/v1" };
15
+ } }, 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" } });
21
+ } });
22
+ return { requests, executions, messages, run: (text = "Complete the task.") => driver.run({
23
+ configuration: { systemPrompt: "Use the supplied actions.", integrationId: "assistant" },
24
+ context: { subjectId: "owner" }, input: { messageId: "request", text }, history: [],
25
+ signal: new AbortController().signal, beforeDispatch: async () => {}, accept: async () => { accepted = true; },
26
+ onMessage: async message => { messages.push(message); },
27
+ tools: { descriptors, schemas: descriptors.map(schema), maximumArgumentBytes: 32768,
28
+ async execute(input) {
29
+ assert.equal(accepted, true, "Preflight and ordinary actions require prior admission");
30
+ executions.push(input);
31
+ return execute?.(input, executions.length) ?? { ok: true, result: { sequence: executions.length } };
32
+ }
33
+ }
34
+ }) };
35
+ }
36
+
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." } });
41
+ 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" } }) });
54
+ 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, []);
60
+ 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, []);
64
+ });
65
+
66
+ test("unknown tool outcomes never enter ordinary failure recovery or request another action", async () => {
67
+ const f = fixture({ response: () => ({ call: "action_execute" }), execute: () => {
68
+ throw Object.assign(new Error("Inspect the interrupted action"), { code: "conversation_tool_outcome_unknown" });
69
+ } });
70
+ await assert.rejects(f.run(), { code: "conversation_tool_outcome_unknown" });
71
+ assert.equal(f.executions.length, 1);
72
+ assert.equal(f.requests.length, 1);
73
+ });
@@ -100,3 +100,88 @@ test("assistant api omits surface header when surface id is empty", async () =>
100
100
  assert.equal(observed.length, 1);
101
101
  assert.equal(Object.hasOwn(observed[0].options || {}, "headers"), false);
102
102
  });
103
+
104
+ test("canonical operations reuse the request owner, surface header and encoded conversation identity", async () => {
105
+ const calls = [];
106
+ const receipt = { status: "accepted", messageId: "question", turnId: "turn" };
107
+ const controller = new AbortController();
108
+ const api = createAssistantApi({
109
+ resolveBasePath: () => "/api/w/one/assistant/admin",
110
+ resolveSurfaceId: () => "AdMiN",
111
+ async request(url, options) { calls.push({ url, options }); return receipt; }
112
+ });
113
+ const id = "chat:one/two";
114
+ assert.equal(await api.readConversation(id, { signal: controller.signal }), receipt);
115
+ assert.equal(await api.sendConversationMessage(id, {
116
+ messageId: "question", text: "", attachmentIds: ["file-one"], steer: false,
117
+ actor: { id: "injected" }, context: { forged: true }, host: { nativeTools: true }, engine: "codex"
118
+ }), receipt);
119
+ assert.equal(await api.cancelConversation(id), receipt);
120
+ assert.equal(await api.inspectConversationDelivery(id, "question"), receipt);
121
+ const base = "/api/w/one/assistant/admin/conversations/chat%3Aone%2Ftwo";
122
+ assert.deepEqual(calls.map(call => call.url), [base, `${base}/messages`, `${base}/cancel`, `${base}/deliveries/question/inspect`]);
123
+ assert.deepEqual(calls.map(call => call.options.method), ["GET", "POST", "POST", "POST"]);
124
+ assert.ok(calls.every(call => call.options.headers["x-jskit-surface"] === "admin"));
125
+ assert.equal(calls[0].options.signal, controller.signal);
126
+ assert.deepEqual(calls[1].options.body, { messageId: "question", text: "", attachmentIds: ["file-one"], steer: false });
127
+ assert.equal(Object.hasOwn(calls[2].options, "body"), false);
128
+ assert.equal(Object.hasOwn(calls[3].options, "body"), false, "Inspection supplies only the stable path identity");
129
+
130
+ await api.sendConversationMessage(id, { messageId: "plain", text: "Exact\n authored text" });
131
+ assert.deepEqual(calls[4].options.body, { messageId: "plain", text: "Exact\n authored text" });
132
+ const goal = { action: "set", expectedSegmentId: "current", expectedGoalId: null,
133
+ messageId: "goal-one", objective: "Finish this", tokenBudget: 1000, attachmentIds: ["file-one"] };
134
+ await api.readConversationGoal(id);
135
+ await api.updateConversationGoal(id, { ...goal, actor: { id: "injected" }, engine: "codex",
136
+ attachments: [{ path: "/private/secret" }], host: { nativeTools: true } }, { signal: controller.signal });
137
+ assert.deepEqual(calls.slice(5).map(call => [call.url, call.options.method]), [[`${base}/goal`, "GET"], [`${base}/goal`, "POST"]]);
138
+ assert.deepEqual(calls[6].options.body, goal);
139
+ assert.equal(calls[6].options.signal, controller.signal);
140
+ await assert.rejects(api.streamChat({ input: "Legacy stream" }), /streamChat requires requestStream/);
141
+
142
+ const data = { clientId: "browser-one", focus: { sessionId: "authored-target" } };
143
+ await api.sendConversationMessage(id, { messageId: "with-data", text: "Use this capture", data,
144
+ actor: { id: "injected" }, context: { forged: true }, host: { nativeTools: true } });
145
+ assert.deepEqual(calls[7].options.body, { messageId: "with-data", text: "Use this capture", data });
146
+ assert.equal(calls[7].url, `${base}/messages`);
147
+ assert.equal(calls[7].options.headers["x-jskit-surface"], "admin");
148
+
149
+ const configuration = { effort: "high" };
150
+ const selection = { modelChoice: "catalogue-choice" };
151
+ await api.configureConversation(id, configuration, { signal: controller.signal });
152
+ await api.selectConversation(id, selection, { signal: controller.signal });
153
+ assert.deepEqual(calls.slice(8).map(call => [call.url, call.options.method, call.options.body]), [
154
+ [`${base}/configuration`, "PATCH", { configuration }],
155
+ [`${base}/selection`, "POST", { selection }]
156
+ ]);
157
+ assert.ok(calls.slice(8).every(call => call.options.signal === controller.signal &&
158
+ call.options.headers["x-jskit-surface"] === "admin"));
159
+
160
+ await api.readConversation(id, { beforeTurnId: "turn:one/two & three", limit: 20, signal: controller.signal,
161
+ actor: { id: "injected" }, context: { forged: true }, host: { nativeTools: true } });
162
+ assert.equal(calls[10].url, `${base}?beforeTurnId=turn%3Aone%2Ftwo+%26+three&limit=20`);
163
+ assert.equal(calls[10].options.method, "GET");
164
+ assert.equal(calls[10].options.signal, controller.signal);
165
+ assert.deepEqual(calls[10].options.headers, { "x-jskit-surface": "admin" });
166
+ assert.equal(Object.hasOwn(calls[10].options, "body"), false);
167
+ await api.readConversation(id, { beforeTurnId: "", limit: 0 });
168
+ assert.equal(calls[11].url, `${base}?limit=0`, "Explicit zero retains the original unbounded page query");
169
+ });
170
+
171
+ test("declared replacement client retains the surface, exact choice and abort signal", async () => {
172
+ const calls = [];
173
+ const api = createAssistantApi({
174
+ request: async (url, options) => { calls.push({ url, options }); return { ok: true }; },
175
+ resolveBasePath: () => "/api/assistant/app",
176
+ resolveSurfaceId: () => "app"
177
+ });
178
+ const controller = new AbortController();
179
+ const replacement = { operationId: "renew-one", expectedSegmentId: "observed-segment", choice: "renew" };
180
+ await api.replaceConversation("chat:one/two", replacement, { signal: controller.signal });
181
+ assert.equal(calls.length, 1);
182
+ assert.equal(calls[0].url, "/api/assistant/app/conversations/chat%3Aone%2Ftwo/replacement");
183
+ assert.equal(calls[0].options.method, "POST");
184
+ assert.deepEqual(calls[0].options.body, { replacement });
185
+ assert.deepEqual(calls[0].options.headers, { "x-jskit-surface": "app" });
186
+ assert.equal(calls[0].options.signal, controller.signal);
187
+ });