@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.
- package/examples/conversation/README.md +8 -1
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/App.vue +4 -1
- package/package.json +99 -36
- package/src/client/conversation/AssistantConversationElement.vue +27 -4
- package/src/client/conversation/AssistantConversationStatus.vue +24 -0
- package/src/client/conversation/AssistantGoalControl.vue +8 -5
- package/src/client/conversation/AssistantTranscript.vue +10 -3
- package/src/client/conversation/index.js +2 -0
- package/src/client/conversation/messageDelivery.js +115 -32
- package/src/client/conversation/retainedConversation.js +32 -0
- package/src/client/conversation/useAssistantQuestions.js +76 -0
- package/src/client/lib/assistantApi.js +72 -2
- package/src/server/conversation/agentRun.js +222 -0
- package/src/server/conversation/attachments.js +93 -0
- package/src/server/conversation/claudeClient.js +221 -0
- package/src/server/conversation/claudeHistory.js +256 -0
- package/src/server/conversation/claudeProcess.js +327 -0
- package/src/server/conversation/claudeTools.js +42 -0
- package/src/server/conversation/claudeTurn.js +1082 -0
- package/src/server/conversation/codexConfiguration.js +1183 -0
- package/src/server/conversation/codexConversations.js +1082 -0
- package/src/server/conversation/codexDetachedTurn.js +332 -0
- package/src/server/conversation/codexEvents.js +194 -1
- package/src/server/conversation/codexGoalCommands.js +124 -0
- package/src/server/conversation/codexHelperExecution.js +306 -0
- package/src/server/conversation/codexHelperThreadLedger.js +804 -0
- package/src/server/conversation/codexHelperThreadRecovery.js +395 -0
- package/src/server/conversation/codexHelperThreads.js +638 -0
- package/src/server/conversation/codexHistoryAdapter.js +369 -0
- package/src/server/conversation/codexMessageCommands.js +663 -0
- package/src/server/conversation/codexMessageDelivery.js +211 -0
- package/src/server/conversation/codexNativeHistoryExport.js +95 -0
- package/src/server/conversation/codexNotificationObserver.js +482 -0
- package/src/server/conversation/codexNotificationQueue.js +103 -0
- package/src/server/conversation/codexObservationControl.js +174 -0
- package/src/server/conversation/codexOutput.js +931 -0
- package/src/server/conversation/codexProcess.js +1559 -0
- package/src/server/conversation/codexProcessLeader.js +14 -0
- package/src/server/conversation/codexProvider.js +2710 -0
- package/src/server/conversation/codexProviderOwner.js +1058 -0
- package/src/server/conversation/codexProviderSelection.js +218 -0
- package/src/server/conversation/codexReasoning.js +314 -0
- package/src/server/conversation/codexRenewalCommands.js +290 -0
- package/src/server/conversation/codexResultSettlement.js +409 -0
- package/src/server/conversation/codexRunJournal.js +658 -0
- package/src/server/conversation/codexRuntimeLifecycle.js +288 -0
- package/src/server/conversation/codexSessionCleanup.js +96 -0
- package/src/server/conversation/codexSessionReconciliation.js +284 -0
- package/src/server/conversation/codexThreadReadiness.js +397 -0
- package/src/server/conversation/codexThreadStatus.js +354 -0
- package/src/server/conversation/codexTurn.js +701 -255
- package/src/server/conversation/codexTurnControl.js +317 -0
- package/src/server/conversation/codexTurnRecovery.js +990 -0
- package/src/server/conversation/codexTurnState.js +549 -0
- package/src/server/conversation/commandHook.js +18 -0
- package/src/server/conversation/commandWrapper.js +37 -0
- package/src/server/conversation/configuration.js +43 -0
- package/src/server/conversation/continuity.js +289 -0
- package/src/server/conversation/fileStorage.js +55 -0
- package/src/server/conversation/hookBridge.js +19 -0
- package/src/server/conversation/index.js +13 -0
- package/src/server/conversation/localExecution.js +665 -0
- package/src/server/conversation/memoryStorage.js +7 -72
- package/src/server/conversation/nativeHistoryExport.js +90 -0
- package/src/server/conversation/nativeStorage.js +23 -0
- package/src/server/conversation/openCodeClient.js +209 -19
- package/src/server/conversation/openCodePlugin.js +84 -0
- package/src/server/conversation/openCodeProcess.js +476 -0
- package/src/server/conversation/openCodeRuntime.js +1578 -0
- package/src/server/conversation/openCodeTools.js +60 -0
- package/src/server/conversation/openCodeTurn.js +557 -0
- package/src/server/conversation/provider.js +30 -0
- package/src/server/conversation/providers/api.js +136 -0
- package/src/server/conversation/providers/claude.js +63 -0
- package/src/server/conversation/providers/claudeDriver.js +425 -0
- package/src/server/conversation/providers/codex.js +248 -0
- package/src/server/conversation/providers/codexDriver.js +1078 -0
- package/src/server/conversation/providers/opencode.js +49 -0
- package/src/server/conversation/providers/opencodeDriver.js +650 -0
- package/src/server/conversation/providers/scoped.js +312 -0
- package/src/server/conversation/runtime.js +2220 -0
- package/src/server/conversation/runtimeStateUpgrade.js +125 -0
- package/src/server/conversation/storage.js +184 -0
- package/src/server/conversation/streams.js +7 -2
- package/src/server/conversation/structuredOutput.js +240 -0
- package/src/server/conversation/systemPrompt.js +54 -0
- package/src/server/conversation/tools.js +84 -0
- package/src/server/conversation/transcript.js +5 -4
- package/src/server/hosts/claudeHistory.js +5 -0
- package/src/server/hosts/claudeProcess.js +11 -0
- package/src/server/hosts/claudeTurn.js +4 -0
- package/src/server/hosts/codexConfiguration.js +20 -0
- package/src/server/hosts/codexEvents.js +13 -0
- package/src/server/hosts/codexProcess.js +12 -0
- package/src/server/hosts/codexProvider.js +23 -0
- package/src/server/hosts/codexTurn.js +20 -0
- package/src/server/hosts/nativeHistory.js +3 -0
- package/src/server/hosts/openCodeClient.js +3 -0
- package/src/server/hosts/openCodeProcess.js +23 -0
- package/src/server/hosts/openCodeTurn.js +7 -0
- package/src/server/index.js +2 -0
- package/src/server/lib/aiConnectionClient.js +13 -6
- package/src/server/lib/assistantCompletion.js +190 -0
- package/src/server/lib/assistantToolLoop.js +314 -0
- package/src/server/lib/serviceToolCatalog.js +25 -10
- package/src/shared/assistantResponseText.js +29 -0
- package/src/shared/conversation/attachments.js +19 -0
- package/src/shared/conversation/index.js +3 -0
- package/src/shared/conversation/pagination.js +78 -0
- package/src/shared/conversation/patches.js +150 -0
- package/src/shared/conversation/questions.js +48 -0
- package/src/shared/conversation/streaming.js +7 -4
- package/src/shared/index.js +2 -0
- package/src/shared/nativeProviders.js +45 -0
- package/src/testing/conversationStorageContract.js +31 -1
- package/src/testing/nativeCodex.js +11 -0
- package/src/testing/nativeOpenCode.js +3 -0
- package/test/aiConnectionClient.test.js +2 -0
- package/test/apiConversationDriver.test.js +73 -0
- package/test/assistantApiSurfaceHeader.test.js +85 -0
- package/test/assistantBoundedToolLoop.test.js +285 -0
- package/test/assistantScroll.browser.test.js +16 -0
- package/test/claudeClient.test.js +189 -0
- package/test/claudeConversationSession.test.js +1173 -0
- package/test/claudeInstructionResume.test.js +82 -0
- package/test/claudeProcess.test.js +204 -0
- package/test/claudeTurn.test.js +253 -0
- package/test/codexConversationSession.test.js +2122 -0
- package/test/codexGoal.test.js +37 -0
- package/test/codexHelperDeletion.test.js +163 -0
- package/test/codexHelperThreadLedger.test.js +494 -0
- package/test/codexHistoryAdapter.test.js +541 -0
- package/test/codexInteractiveArguments.test.js +30 -0
- package/test/codexIsolation.test.js +756 -0
- package/test/codexModelCatalog.test.js +362 -0
- package/test/codexNativeHistoryExport.test.js +81 -0
- package/test/codexThreadSelection.test.js +136 -0
- package/test/codexTurn.test.js +2158 -0
- package/test/conversation.test.js +110 -0
- package/test/conversationAgentRun.test.js +326 -0
- package/test/conversationApiFacilities.test.js +90 -0
- package/test/conversationAttachments.test.js +238 -0
- package/test/conversationCommandWrapper.test.js +48 -0
- package/test/conversationConfiguration.test.js +174 -0
- package/test/conversationNativeStorage.test.js +25 -0
- package/test/conversationReplacement.test.js +172 -0
- package/test/conversationRuntime.test.js +241 -0
- package/test/conversationRuntimeStateUpgrade.test.js +144 -0
- package/test/conversationSession.test.js +1080 -0
- package/test/conversationStreaming.test.js +68 -0
- package/test/conversationSystemPrompt.test.js +84 -0
- package/test/conversationTools.test.js +416 -0
- package/test/fileConversationStorage.test.js +73 -0
- package/test/fixtures/codexHelperThreadLedger.js +39 -0
- package/test/localConversationExecution.test.js +90 -0
- package/test/messageDelivery.test.js +234 -1
- package/test/nativeConversationRetirement.test.js +215 -0
- package/test/openCodeClient.test.js +515 -0
- package/test/openCodeProcess.test.js +177 -0
- package/test/openCodeRuntime.test.js +322 -0
- package/test/openCodeTools.test.js +287 -0
- package/test/opencodeConversationSession.test.js +789 -0
- package/test/serviceToolCatalog.test.js +24 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
|
|
4
|
+
import { createSchema } from "@jskit-ai/kernel/shared/validators";
|
|
5
|
+
|
|
6
|
+
import { runBoundedAssistantToolLoop } from "../src/server/lib/assistantToolLoop.js";
|
|
7
|
+
import { createServiceToolCatalog } from "../src/server/lib/serviceToolCatalog.js";
|
|
8
|
+
import { parseConversationOutput, validateConversationOutputSchema } from "../src/server/conversation/structuredOutput.js";
|
|
9
|
+
|
|
10
|
+
const policy = { maximumResponses: 4, timeoutMs: 90_000 };
|
|
11
|
+
const outputSchema = { type: "object", additionalProperties: false, required: ["answer"],
|
|
12
|
+
properties: { answer: { type: "string", maxLength: 100 } } };
|
|
13
|
+
const finalResponse = result => ({ ok: true, text: JSON.stringify({ response: { kind: "final", result } }) });
|
|
14
|
+
const toolResponse = value => ({ ok: true, text: JSON.stringify({ response: {
|
|
15
|
+
kind: "tool", name: "bounded_read", arguments: { value: String(value) }
|
|
16
|
+
} }) });
|
|
17
|
+
|
|
18
|
+
function promptValue(prompt) {
|
|
19
|
+
const marker = "UNTRUSTED_APPLICATION_TOOL_RESULT_JSON_BEGIN\n";
|
|
20
|
+
if (!prompt.includes(marker)) return prompt.split("\n\n")[0];
|
|
21
|
+
return JSON.parse(prompt.split(marker)[1].split("\nUNTRUSTED_APPLICATION_TOOL_RESULT_JSON_END")[0]).result.prompt;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function boundedTools(execute) {
|
|
25
|
+
const actions = createActionCatalogue();
|
|
26
|
+
actions.register({ contributorId: "bounded.tools", domain: "bounded", actions: [{
|
|
27
|
+
id: "bounded.read", version: 1, kind: "query", channels: ["automation"], surfaces: ["app"],
|
|
28
|
+
permission: { require: "none" }, idempotency: "none",
|
|
29
|
+
input: { schema: createSchema({ value: { type: "string", enum: ["1", "2", "3", "4"], required: true } }), mode: "replace" },
|
|
30
|
+
output: { schema: createSchema({ prompt: { type: "string", required: true } }), mode: "replace" },
|
|
31
|
+
execute({ value }, context) { return execute({ value: Number(value) }, context); }
|
|
32
|
+
}] });
|
|
33
|
+
return createServiceToolCatalog(actions);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function responseOptions(options) {
|
|
37
|
+
assert.ok(options.outputSchema.properties.response.anyOf.length > 1);
|
|
38
|
+
assert.doesNotThrow(() => validateConversationOutputSchema(options.outputSchema, { maxOutputCharacters: 1000 }));
|
|
39
|
+
return { timeoutMs: options.timeoutMs };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("bounded response policy accepts the fourth final response with the original per-response deadline", async () => {
|
|
43
|
+
const trace = [];
|
|
44
|
+
const responses = [{ step: 1 }, { step: 2 }, { step: 3 }, { answer: "Done" }];
|
|
45
|
+
let completed = 0;
|
|
46
|
+
const result = await runBoundedAssistantToolLoop({
|
|
47
|
+
prompt: "Initial request", outputSchema, toolContext: { surface: "app" },
|
|
48
|
+
toolCatalog: boundedTools(({ value }) => {
|
|
49
|
+
trace.push({ operation: value });
|
|
50
|
+
return { prompt: `Result ${value}` };
|
|
51
|
+
}),
|
|
52
|
+
policy,
|
|
53
|
+
limitError: new Error("Limit reached"),
|
|
54
|
+
async complete(prompt, options) {
|
|
55
|
+
trace.push({ prompt: promptValue(prompt), options: responseOptions(options) });
|
|
56
|
+
const response = responses[completed++];
|
|
57
|
+
return response.answer ? finalResponse(response) : toolResponse(response.step);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
assert.deepEqual(result, responses[3]);
|
|
61
|
+
assert.equal(completed, 4);
|
|
62
|
+
assert.deepEqual(trace, [
|
|
63
|
+
{ prompt: "Initial request", options: { timeoutMs: 90_000 } },
|
|
64
|
+
{ operation: 1 },
|
|
65
|
+
{ prompt: "Result 1", options: { timeoutMs: 90_000 } },
|
|
66
|
+
{ operation: 2 },
|
|
67
|
+
{ prompt: "Result 2", options: { timeoutMs: 90_000 } },
|
|
68
|
+
{ operation: 3 },
|
|
69
|
+
{ prompt: "Result 3", options: { timeoutMs: 90_000 } }
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("bounded response policy completes operation four before throwing the exact limit error without response five", async () => {
|
|
74
|
+
const trace = [];
|
|
75
|
+
const limitError = Object.assign(new Error("Original limit"), { code: "product_limit" });
|
|
76
|
+
let completed = 0;
|
|
77
|
+
await assert.rejects(runBoundedAssistantToolLoop({
|
|
78
|
+
prompt: "Initial request", outputSchema, toolContext: { surface: "app" },
|
|
79
|
+
toolCatalog: boundedTools(async ({ value }) => {
|
|
80
|
+
trace.push(`operation:${value}`);
|
|
81
|
+
await Promise.resolve();
|
|
82
|
+
trace.push(`prompt:${value}`);
|
|
83
|
+
return { prompt: `Result ${value}` };
|
|
84
|
+
}),
|
|
85
|
+
policy,
|
|
86
|
+
limitError,
|
|
87
|
+
async complete() {
|
|
88
|
+
trace.push(`response:${++completed}`);
|
|
89
|
+
return toolResponse(completed);
|
|
90
|
+
}
|
|
91
|
+
}), error => error === limitError);
|
|
92
|
+
assert.equal(completed, 4);
|
|
93
|
+
assert.deepEqual(trace, [
|
|
94
|
+
"response:1", "operation:1", "prompt:1",
|
|
95
|
+
"response:2", "operation:2", "prompt:2",
|
|
96
|
+
"response:3", "operation:3", "prompt:3",
|
|
97
|
+
"response:4", "operation:4", "prompt:4"
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("bounded response policy preserves the first response exception without action or recovery", async () => {
|
|
102
|
+
const failure = new Error("Original provider failure");
|
|
103
|
+
let completed = 0;
|
|
104
|
+
await assert.rejects(runBoundedAssistantToolLoop({
|
|
105
|
+
prompt: "Initial request", outputSchema, toolContext: { surface: "app" },
|
|
106
|
+
toolCatalog: boundedTools(() => assert.fail("A failed response must not execute an action")),
|
|
107
|
+
policy,
|
|
108
|
+
limitError: new Error("Limit reached"),
|
|
109
|
+
async complete() {
|
|
110
|
+
completed++;
|
|
111
|
+
throw failure;
|
|
112
|
+
}
|
|
113
|
+
}), error => error === failure);
|
|
114
|
+
assert.equal(completed, 1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("bounded response policy preserves action and result-prompt errors even on response four", async () => {
|
|
118
|
+
for (const failureRound of [1, 4]) {
|
|
119
|
+
const failure = new Error("Original action or result-bound failure");
|
|
120
|
+
let completed = 0;
|
|
121
|
+
let operations = 0;
|
|
122
|
+
await assert.rejects(runBoundedAssistantToolLoop({
|
|
123
|
+
prompt: "Initial request", outputSchema, toolContext: { surface: "app" },
|
|
124
|
+
toolCatalog: boundedTools(({ value }) => {
|
|
125
|
+
operations++;
|
|
126
|
+
if (value === failureRound) throw failure;
|
|
127
|
+
return { prompt: `Result ${value}` };
|
|
128
|
+
}),
|
|
129
|
+
policy,
|
|
130
|
+
limitError: new Error("Limit reached"),
|
|
131
|
+
async complete() { return toolResponse(++completed); }
|
|
132
|
+
}), error => error === failure);
|
|
133
|
+
assert.equal(completed, failureRound);
|
|
134
|
+
assert.equal(operations, failureRound);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("bounded response policy checks abort before inference and after its identity capture before response policy", async () => {
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const reason = new Error("Cancelled");
|
|
141
|
+
const trace = [];
|
|
142
|
+
await assert.rejects(runBoundedAssistantToolLoop({
|
|
143
|
+
prompt: "Initial request", outputSchema, toolContext: { surface: "app" },
|
|
144
|
+
toolCatalog: boundedTools(() => assert.fail("Abort must precede response parsing or action execution")),
|
|
145
|
+
signal: controller.signal,
|
|
146
|
+
policy,
|
|
147
|
+
limitError: new Error("Limit reached"),
|
|
148
|
+
async complete() {
|
|
149
|
+
trace.push("native identity captured");
|
|
150
|
+
controller.abort(reason);
|
|
151
|
+
return { ok: false };
|
|
152
|
+
}
|
|
153
|
+
}), error => error === reason);
|
|
154
|
+
assert.deepEqual(trace, ["native identity captured"]);
|
|
155
|
+
await assert.rejects(runBoundedAssistantToolLoop({
|
|
156
|
+
prompt: "Initial request", outputSchema, toolContext: { surface: "app" },
|
|
157
|
+
toolCatalog: boundedTools(() => assert.fail("An aborted request must not execute an action")),
|
|
158
|
+
signal: controller.signal,
|
|
159
|
+
policy,
|
|
160
|
+
limitError: new Error("Limit reached"),
|
|
161
|
+
async complete() { assert.fail("An aborted request must not start inference"); }
|
|
162
|
+
}), error => error === reason);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("bounded response policy retains the original action-abort versus exhaustion precedence", async () => {
|
|
166
|
+
for (const abortRound of [1, 4]) {
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const reason = new Error("Cancelled during action");
|
|
169
|
+
const limitError = new Error("Original limit");
|
|
170
|
+
let completed = 0;
|
|
171
|
+
await assert.rejects(runBoundedAssistantToolLoop({
|
|
172
|
+
prompt: "Initial request", outputSchema, toolContext: { surface: "app" },
|
|
173
|
+
toolCatalog: boundedTools(({ value }) => {
|
|
174
|
+
if (value === abortRound) controller.abort(reason);
|
|
175
|
+
return { prompt: `Result ${value}` };
|
|
176
|
+
}),
|
|
177
|
+
signal: controller.signal,
|
|
178
|
+
policy,
|
|
179
|
+
limitError,
|
|
180
|
+
async complete() { return toolResponse(++completed); }
|
|
181
|
+
}), error => error === (abortRound === 4 ? limitError : reason));
|
|
182
|
+
assert.equal(completed, abortRound);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("bounded tools use the existing catalogue once per response and never execute for a final response", async () => {
|
|
187
|
+
const trace = [];
|
|
188
|
+
const actor = { id: "captured-actor" };
|
|
189
|
+
const catalog = boundedTools(async ({ value }, context) => {
|
|
190
|
+
assert.deepEqual(context.actor, actor);
|
|
191
|
+
assert.equal(context.channel, "automation");
|
|
192
|
+
trace.push(`operation:${value}`);
|
|
193
|
+
return { prompt: `Result ${value}` };
|
|
194
|
+
});
|
|
195
|
+
let toolSets = 0;
|
|
196
|
+
let completed = 0;
|
|
197
|
+
const result = await runBoundedAssistantToolLoop({ prompt: "Initial", outputSchema, policy, limitError: new Error("Limit"),
|
|
198
|
+
toolContext: { actor, surface: "app" },
|
|
199
|
+
toolCatalog: { ...catalog,
|
|
200
|
+
resolveToolSet(...args) { toolSets++; return catalog.resolveToolSet(...args); },
|
|
201
|
+
async executeToolCall(...args) {
|
|
202
|
+
const result = await catalog.executeToolCall(...args);
|
|
203
|
+
assert.equal(result.ok, true);
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
async complete(prompt, options) {
|
|
208
|
+
assert.deepEqual(responseOptions(options), { timeoutMs: 90_000 });
|
|
209
|
+
trace.push(`response:${++completed}:${promptValue(prompt)}`);
|
|
210
|
+
return completed === 4 ? finalResponse({ answer: "4" }) : toolResponse(completed);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
assert.deepEqual(result, { answer: "4" });
|
|
214
|
+
assert.equal(toolSets, 4);
|
|
215
|
+
assert.deepEqual(trace, ["response:1:Initial", "operation:1", "response:2:Result 1", "operation:2",
|
|
216
|
+
"response:3:Result 2", "operation:3", "response:4:Result 3"]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("bounded tools preserve a fourth-operation host exception without another response", async () => {
|
|
220
|
+
const failure = Object.assign(new Error("Original query failure"), { statusCode: 503 });
|
|
221
|
+
let completed = 0;
|
|
222
|
+
let executions = 0;
|
|
223
|
+
const catalog = boundedTools(async ({ value }) => {
|
|
224
|
+
executions++;
|
|
225
|
+
if (value === 4) throw failure;
|
|
226
|
+
return { prompt: `Result ${value}` };
|
|
227
|
+
});
|
|
228
|
+
await assert.rejects(runBoundedAssistantToolLoop({ prompt: "Initial", outputSchema, policy, limitError: new Error("Limit"),
|
|
229
|
+
toolCatalog: catalog, toolContext: { surface: "app" },
|
|
230
|
+
async complete() { return toolResponse(++completed); }
|
|
231
|
+
}), error => error === failure);
|
|
232
|
+
assert.equal(completed, 4);
|
|
233
|
+
assert.equal(executions, 4);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("bounded tools retain the original successful-operation abort versus exhaustion precedence", async () => {
|
|
237
|
+
for (const abortRound of [1, 4]) {
|
|
238
|
+
const controller = new AbortController();
|
|
239
|
+
const reason = new Error("Cancelled during operation");
|
|
240
|
+
const limitError = new Error("Original limit");
|
|
241
|
+
let completed = 0;
|
|
242
|
+
let executions = 0;
|
|
243
|
+
const catalog = boundedTools(async ({ value }) => {
|
|
244
|
+
executions++;
|
|
245
|
+
if (value === abortRound) controller.abort(reason);
|
|
246
|
+
return { prompt: `Result ${value}` };
|
|
247
|
+
});
|
|
248
|
+
await assert.rejects(runBoundedAssistantToolLoop({ prompt: "Initial", outputSchema, policy, signal: controller.signal, limitError,
|
|
249
|
+
toolCatalog: catalog, toolContext: { surface: "app" },
|
|
250
|
+
async complete() { return toolResponse(++completed); }
|
|
251
|
+
}), error => error === (abortRound === 4 ? limitError : reason));
|
|
252
|
+
assert.equal(completed, abortRound);
|
|
253
|
+
assert.equal(executions, abortRound);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("bounded structured alternatives keep exact output bounds and reject open or ambiguous replies", () => {
|
|
258
|
+
const branch = (kind, maxLength) => ({ type: "object", additionalProperties: false, required: ["kind", "value"],
|
|
259
|
+
properties: { kind: { type: "string", enum: [kind] }, value: { type: "string", maxLength } } });
|
|
260
|
+
const schema = { type: "object", additionalProperties: false, required: ["response"],
|
|
261
|
+
properties: { response: { anyOf: [branch("first", 1), branch("second", 2)] } } };
|
|
262
|
+
const maximum = JSON.stringify({ response: { kind: "second", value: "\u0000\u0000" } }).length;
|
|
263
|
+
assert.doesNotThrow(() => validateConversationOutputSchema(schema, { maxOutputCharacters: maximum }));
|
|
264
|
+
assert.throws(() => validateConversationOutputSchema(schema, { maxOutputCharacters: maximum - 1 }),
|
|
265
|
+
/exceed the resolved output limit/u);
|
|
266
|
+
assert.deepEqual(parseConversationOutput('{"response":{"kind":"first","value":"x"}}', schema),
|
|
267
|
+
{ response: { kind: "first", value: "x" } });
|
|
268
|
+
for (const value of [
|
|
269
|
+
{ response: { kind: "first", value: "xx" } },
|
|
270
|
+
{ response: { kind: "unknown", value: "x" } },
|
|
271
|
+
{ response: { kind: "first", value: "x", result: "ambiguous" } }
|
|
272
|
+
]) assert.throws(() => parseConversationOutput(JSON.stringify(value), schema), /invalid structured response/u);
|
|
273
|
+
const open = structuredClone(schema);
|
|
274
|
+
open.properties.response.anyOf[1].additionalProperties = true;
|
|
275
|
+
assert.throws(() => validateConversationOutputSchema(open, { maxOutputCharacters: maximum }), /reject additional properties/u);
|
|
276
|
+
const empty = structuredClone(schema);
|
|
277
|
+
empty.properties.response.anyOf = [];
|
|
278
|
+
assert.throws(() => validateConversationOutputSchema(empty, { maxOutputCharacters: maximum }), /closed object alternatives/u);
|
|
279
|
+
assert.throws(() => validateConversationOutputSchema(schema.properties.response, { maxOutputCharacters: maximum }),
|
|
280
|
+
/below its root/u);
|
|
281
|
+
let deep = schema;
|
|
282
|
+
for (let index = 0; index < 8; index++) deep = { type: "object", additionalProperties: false,
|
|
283
|
+
required: ["nested"], properties: { nested: deep } };
|
|
284
|
+
assert.throws(() => validateConversationOutputSchema(deep, { maxOutputCharacters: 16_000 }), /nested too deeply/u);
|
|
285
|
+
});
|
|
@@ -270,6 +270,22 @@ test("conversation scrolling preserves history anchors and resets expansion on s
|
|
|
270
270
|
await page.getByRole("button", { name: "Change conversation", exact: true }).click();
|
|
271
271
|
await expect(page.getByRole("button", { name: "Show less", exact: true })).toHaveCount(0);
|
|
272
272
|
await expect(page.getByRole("button", { name: "Read more", exact: true })).toBeAttached();
|
|
273
|
+
await expect(body).toBeVisible();
|
|
274
|
+
await expect(page.locator(".assistant-transcript__settling")).toHaveCount(0);
|
|
275
|
+
await page.evaluate(() => {
|
|
276
|
+
window.revealPlaceholders = 0;
|
|
277
|
+
new MutationObserver(records => {
|
|
278
|
+
for (const record of records) for (const node of record.addedNodes) {
|
|
279
|
+
if (node.nodeType === 1 && node.matches(".assistant-transcript__settling")) window.revealPlaceholders += 1;
|
|
280
|
+
}
|
|
281
|
+
}).observe(document.querySelector(".assistant-transcript"), { childList: true, subtree: true });
|
|
282
|
+
});
|
|
283
|
+
await page.getByRole("button", { name: "Toggle visibility", exact: true }).click();
|
|
284
|
+
await page.getByRole("button", { name: "Append reply", exact: true }).click();
|
|
285
|
+
await page.getByRole("button", { name: "Toggle visibility", exact: true }).click();
|
|
286
|
+
await expect(body).toBeVisible();
|
|
287
|
+
await expect.poll(() => body.evaluate(element => element.scrollHeight - element.scrollTop - element.clientHeight)).toBeLessThan(2);
|
|
288
|
+
assert.equal(await page.evaluate(() => window.revealPlaceholders), 0, "reopening loaded text does not flash a loading placeholder");
|
|
273
289
|
} finally {
|
|
274
290
|
await browser.close();
|
|
275
291
|
await stopProcess(vite);
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { Readable, Duplex, PassThrough } from "node:stream";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { createClaudeJsonClient, readClaudeJsonFrames } from "../src/server/conversation/claudeClient.js";
|
|
5
|
+
|
|
6
|
+
async function frames(chunks, options) {
|
|
7
|
+
const result = [];
|
|
8
|
+
for await (const frame of readClaudeJsonFrames(Readable.from(chunks), options)) result.push(frame);
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test("Claude JSON frames survive every byte boundary, including Unicode and escaped newlines", async () => {
|
|
13
|
+
const expected = [{ type: "assistant", text: "Hello 🌏\n世界" }, { type: "result", is_error: false }];
|
|
14
|
+
const bytes = Buffer.from(`${expected.map((frame) => JSON.stringify(frame)).join("\r\n")}\n`);
|
|
15
|
+
assert.deepEqual(await frames([...bytes].map((byte) => Buffer.from([byte]))), expected);
|
|
16
|
+
assert.deepEqual(await frames([bytes]), expected);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("Claude JSON framing bounds fragmented and complete frames and rejects malformed data without echoing it", async () => {
|
|
20
|
+
await assert.rejects(frames([Buffer.from('{"type":"secret"}\n')], { maxFrameBytes: 8 }), /size limit/u);
|
|
21
|
+
await assert.rejects(frames([Buffer.alloc(4, 32), Buffer.alloc(5, 32)], { maxFrameBytes: 8 }), /size limit/u);
|
|
22
|
+
await assert.rejects(frames([Buffer.from("secret token\n")]), (error) => !error.message.includes("secret token"));
|
|
23
|
+
await assert.rejects(frames([Buffer.from('{"type":"user","text":"'), Buffer.from([255]), Buffer.from('"}\n')]), /invalid UTF-8/u);
|
|
24
|
+
await assert.rejects(frames([Buffer.from('[]\n')]), /event type/u);
|
|
25
|
+
await assert.rejects(frames([Buffer.from('{"type":"result"}')]), /incomplete/u);
|
|
26
|
+
assert.deepEqual(await frames([Buffer.from('{"type":"result"}')], { allowIncompleteTail: true }), []);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function fakeStream(onWrite) {
|
|
30
|
+
return new Duplex({ read() {}, write(chunk, _encoding, callback) {
|
|
31
|
+
Promise.resolve().then(() => onWrite(JSON.parse(String(chunk)), this)).then(() => callback(), callback);
|
|
32
|
+
} });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("Claude controls correlate out-of-order replies and events apply backpressure", async () => {
|
|
36
|
+
const requests = [];
|
|
37
|
+
const observed = [];
|
|
38
|
+
const stream = fakeStream((frame, socket) => {
|
|
39
|
+
requests.push(frame);
|
|
40
|
+
if (requests.length === 2) for (const request of [...requests].reverse()) {
|
|
41
|
+
socket.push(`${JSON.stringify({ type: "control_response", response: { subtype: "success", request_id: request.request_id, response: { name: request.request.subtype } } })}\n`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
const client = createClaudeJsonClient({ stream, onEvent: async (frame) => {
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
46
|
+
observed.push(frame.number);
|
|
47
|
+
} });
|
|
48
|
+
assert.deepEqual(await Promise.all([client.request({ subtype: "one" }), client.request({ subtype: "two" })]), [{ name: "one" }, { name: "two" }]);
|
|
49
|
+
stream.push('{"type":"test","number":1}\n{"type":"test","number":2}\n');
|
|
50
|
+
stream.push(null);
|
|
51
|
+
await client.completion;
|
|
52
|
+
assert.deepEqual(observed, [1, 2]);
|
|
53
|
+
client.close();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("Claude control replies bypass slow event persistence while events remain ordered", async () => {
|
|
57
|
+
const entered = Promise.withResolvers();
|
|
58
|
+
const release = Promise.withResolvers();
|
|
59
|
+
const observed = [];
|
|
60
|
+
const stream = fakeStream((frame, socket) => socket.push(`${JSON.stringify({
|
|
61
|
+
type: "control_response", response: { subtype: "success", request_id: frame.request_id, response: {} }
|
|
62
|
+
})}\n`));
|
|
63
|
+
const client = createClaudeJsonClient({ stream, onEvent: async (frame) => {
|
|
64
|
+
if (frame.number === 1) { entered.resolve(); await release.promise; }
|
|
65
|
+
observed.push(frame.number);
|
|
66
|
+
} });
|
|
67
|
+
stream.push('{"type":"event","number":1}\n{"type":"event","number":2}\n');
|
|
68
|
+
await entered.promise;
|
|
69
|
+
try {
|
|
70
|
+
await client.request({ subtype: "interrupt" }, { timeoutMs: 100 });
|
|
71
|
+
assert.deepEqual(observed, []);
|
|
72
|
+
release.resolve();
|
|
73
|
+
stream.push(null);
|
|
74
|
+
await client.completion;
|
|
75
|
+
assert.deepEqual(observed, [1, 2]);
|
|
76
|
+
} finally {
|
|
77
|
+
release.resolve();
|
|
78
|
+
client.close();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("Claude interrupt uses the common 30-second control deadline", async (t) => {
|
|
83
|
+
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
84
|
+
const client = createClaudeJsonClient({ stream: fakeStream(() => {}) });
|
|
85
|
+
const pending = client.interrupt();
|
|
86
|
+
let settled = false;
|
|
87
|
+
const rejected = assert.rejects(pending, /interrupt timed out/u).then(() => { settled = true; });
|
|
88
|
+
t.mock.timers.tick(29_999);
|
|
89
|
+
await Promise.resolve();
|
|
90
|
+
assert.equal(settled, false);
|
|
91
|
+
t.mock.timers.tick(1);
|
|
92
|
+
await rejected;
|
|
93
|
+
client.close();
|
|
94
|
+
await client.completion;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("Claude control timeouts and pipe closure reject pending work promptly", async () => {
|
|
98
|
+
const stream = fakeStream(() => {});
|
|
99
|
+
const client = createClaudeJsonClient({ stream, timeoutMs: 10 });
|
|
100
|
+
await assert.rejects(client.initialize(), /timed out/u);
|
|
101
|
+
const pending = client.request({ subtype: "interrupt" }, { timeoutMs: 10_000 });
|
|
102
|
+
stream.push(null);
|
|
103
|
+
await assert.rejects(pending, /ended unexpectedly/u);
|
|
104
|
+
await client.completion;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("native startup ending before initialization reports failure without an unhandled stream error", async t => {
|
|
108
|
+
const writable = new PassThrough();
|
|
109
|
+
t.after(() => writable.destroy());
|
|
110
|
+
const stream = Duplex.from({ readable: Readable.from([]), writable });
|
|
111
|
+
const observed = [];
|
|
112
|
+
const client = createClaudeJsonClient({ stream, onFailure: error => observed.push(error) });
|
|
113
|
+
await assert.rejects(client.initialize(), /ended unexpectedly/u);
|
|
114
|
+
await client.completion;
|
|
115
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
116
|
+
assert.equal(stream.destroyed, true);
|
|
117
|
+
assert.equal(observed.length, 1);
|
|
118
|
+
assert.equal(observed[0].code, "assistant_claude_observation_lost");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("incoming tools wait for admission while outgoing interrupts remain responsive", async () => {
|
|
122
|
+
const admitted = Promise.withResolvers();
|
|
123
|
+
const invoked = Promise.withResolvers();
|
|
124
|
+
const finishTool = Promise.withResolvers();
|
|
125
|
+
const written = [];
|
|
126
|
+
const stream = fakeStream((frame, socket) => {
|
|
127
|
+
written.push(frame);
|
|
128
|
+
if (frame.type === "control_request") socket.push(`${JSON.stringify({ type: "control_response",
|
|
129
|
+
response: { subtype: "success", request_id: frame.request_id, response: {} } })}\n`);
|
|
130
|
+
});
|
|
131
|
+
const client = createClaudeJsonClient({ stream, onEvent: () => admitted.promise,
|
|
132
|
+
onControlRequest: async request => { invoked.resolve(request); return finishTool.promise; } });
|
|
133
|
+
stream.push('{"type":"user"}\n{"type":"control_request","request_id":"tool","request":{"subtype":"mcp_message"}}\n');
|
|
134
|
+
await client.interrupt();
|
|
135
|
+
assert.equal(written.filter(frame => frame.type === "control_response").length, 0);
|
|
136
|
+
admitted.resolve();
|
|
137
|
+
assert.equal((await invoked.promise).subtype, "mcp_message");
|
|
138
|
+
await client.interrupt();
|
|
139
|
+
finishTool.resolve({ mcp_response: { result: { content: [] } } });
|
|
140
|
+
await client.whenRequestsIdle();
|
|
141
|
+
assert.equal(written.find(frame => frame.response?.request_id === "tool").response.subtype, "success");
|
|
142
|
+
client.close();
|
|
143
|
+
await client.completion;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("closing the native connection cancels incoming requests but drains invoked application work", async () => {
|
|
147
|
+
const invoked = Promise.withResolvers();
|
|
148
|
+
const finishTool = Promise.withResolvers();
|
|
149
|
+
const written = [];
|
|
150
|
+
const stream = fakeStream(frame => written.push(frame));
|
|
151
|
+
const client = createClaudeJsonClient({ stream, onControlRequest: async (_request, { signal }) => {
|
|
152
|
+
invoked.resolve(signal);
|
|
153
|
+
return finishTool.promise;
|
|
154
|
+
} });
|
|
155
|
+
stream.push('{"type":"control_request","request_id":"tool","request":{"subtype":"mcp_message"}}\n');
|
|
156
|
+
const signal = await invoked.promise;
|
|
157
|
+
client.close();
|
|
158
|
+
assert.equal(signal.aborted, true);
|
|
159
|
+
let drained = false;
|
|
160
|
+
const drain = client.whenRequestsIdle().then(() => { drained = true; });
|
|
161
|
+
await Promise.resolve();
|
|
162
|
+
assert.equal(drained, false);
|
|
163
|
+
finishTool.resolve({});
|
|
164
|
+
await drain;
|
|
165
|
+
await client.completion;
|
|
166
|
+
assert.equal(written.length, 0, "A cancelled control has no late response");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("unregistered headless controls are denied and repeated active control IDs fail closed", async () => {
|
|
170
|
+
const response = Promise.withResolvers();
|
|
171
|
+
const stream = fakeStream(frame => response.resolve(frame));
|
|
172
|
+
const client = createClaudeJsonClient({ stream });
|
|
173
|
+
stream.push('{"type":"control_request","request_id":"permission","request":{"subtype":"can_use_tool"}}\n');
|
|
174
|
+
assert.equal((await response.promise).response.subtype, "error");
|
|
175
|
+
client.close();
|
|
176
|
+
await client.completion;
|
|
177
|
+
|
|
178
|
+
const failure = Promise.withResolvers();
|
|
179
|
+
const other = fakeStream(() => {});
|
|
180
|
+
const second = createClaudeJsonClient({ stream: other, onControlRequest: (_request, { signal }) => new Promise(resolve => {
|
|
181
|
+
signal.addEventListener("abort", resolve, { once: true });
|
|
182
|
+
}), onFailure: failure.resolve });
|
|
183
|
+
const duplicate = '{"type":"control_request","request_id":"same","request":{"subtype":"mcp_message"}}\n';
|
|
184
|
+
other.push(duplicate + duplicate);
|
|
185
|
+
assert.match((await failure.promise).message, /reused an active request/);
|
|
186
|
+
await second.whenRequestsIdle();
|
|
187
|
+
second.close();
|
|
188
|
+
await second.completion;
|
|
189
|
+
});
|