@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.
- package/examples/conversation/package.json +5 -5
- package/package.json +18 -7
- package/src/client/browser.js +21 -0
- package/src/client/conversation/AssistantAttachmentQueue.vue +25 -108
- package/src/client/conversation/AssistantComposerActions.vue +1 -1
- package/src/client/conversation/AssistantConversationElement.vue +14 -52
- package/src/client/conversation/AssistantGoalControl.vue +13 -10
- package/src/client/conversation/AssistantPromptInput.vue +6 -91
- package/src/client/conversation/AssistantTranscript.css +505 -0
- package/src/client/conversation/AssistantTranscript.vue +24 -1197
- package/src/client/conversation/attachmentFiles.js +158 -0
- package/src/client/conversation/attachmentQueueItems.js +121 -0
- package/src/client/conversation/attachmentUploadRows.js +265 -0
- package/src/client/conversation/conversationDisplay.js +54 -0
- package/src/client/conversation/messageDelivery.js +13 -9
- package/src/client/conversation/retainedConversation.js +14 -3
- package/src/client/conversation/submitText.js +13 -2
- package/src/client/conversation/transcriptEntries.js +225 -0
- package/src/client/conversation/useAssistantAttachments.js +86 -388
- package/src/client/conversation/useAssistantQuestions.js +16 -3
- package/src/client/conversation/useAssistantSuggestions.js +16 -2
- package/src/client/conversation/useScrollToBottom.js +17 -19
- package/src/client/conversation/useTextareaAutoGrow.js +115 -0
- package/src/client/conversation/useTranscriptScrollFollow.js +525 -0
- package/src/client/lib/assistantApi.js +192 -161
- package/src/client/types.js +110 -0
- package/src/server/conversation/attachments.js +21 -6
- package/src/server/conversation/boundedToolLoop.js +1 -0
- package/src/server/conversation/claudeClient.js +71 -20
- package/src/server/conversation/claudeCommandRun.js +191 -0
- package/src/server/conversation/claudeHistory.js +7 -0
- package/src/server/conversation/claudeNativeTurn.js +708 -0
- package/src/server/conversation/claudeProcess.js +186 -112
- package/src/server/conversation/claudeProcessStart.js +214 -0
- package/src/server/conversation/claudePromptDelivery.js +183 -0
- package/src/server/conversation/claudeSessionCleanup.js +248 -0
- package/src/server/conversation/claudeTools.js +15 -3
- package/src/server/conversation/claudeTurn.js +212 -1388
- package/src/server/conversation/codexAccountReader.js +168 -0
- package/src/server/conversation/codexAuthPreflight.js +5 -4
- package/src/server/conversation/codexClient.js +18 -6
- package/src/server/conversation/codexConfiguration.js +8 -844
- package/src/server/conversation/codexConversationStops.js +377 -0
- package/src/server/conversation/codexConversationStore.js +119 -85
- package/src/server/conversation/codexConversationTurns.js +378 -0
- package/src/server/conversation/codexConversations.js +134 -866
- package/src/server/conversation/codexDetachedConversation.js +203 -0
- package/src/server/conversation/codexDetachedTurn.js +145 -104
- package/src/server/conversation/codexEvents.js +5 -200
- package/src/server/conversation/codexGoalCommands.js +14 -4
- package/src/server/conversation/codexHelperExecution.js +57 -57
- package/src/server/conversation/codexHelperThreadLedger.js +30 -288
- package/src/server/conversation/codexHelperThreadRecord.js +304 -0
- package/src/server/conversation/codexHelperThreadRecovery.js +54 -76
- package/src/server/conversation/codexHelperThreads.js +94 -93
- package/src/server/conversation/codexHistoryAdapter.js +13 -8
- package/src/server/conversation/codexIsolation.js +672 -0
- package/src/server/conversation/codexLiveItems.js +379 -0
- package/src/server/conversation/codexMessageCommands.js +68 -286
- package/src/server/conversation/codexMessageDelivery.js +46 -37
- package/src/server/conversation/codexModelCatalog.js +86 -7
- package/src/server/conversation/codexNotificationObserver.js +81 -88
- package/src/server/conversation/codexNotificationQueue.js +21 -10
- package/src/server/conversation/codexObservationControl.js +105 -89
- package/src/server/conversation/codexOutput.js +84 -419
- package/src/server/conversation/codexProcess.js +29 -1053
- package/src/server/conversation/codexProcessLeader.js +1 -0
- package/src/server/conversation/codexPromptDispatch.js +241 -0
- package/src/server/conversation/codexProvider.js +147 -1807
- package/src/server/conversation/codexProviderAuth.js +348 -0
- package/src/server/conversation/codexProviderInvalidation.js +371 -0
- package/src/server/conversation/codexProviderInventories.js +437 -0
- package/src/server/conversation/codexProviderOwner.js +149 -957
- package/src/server/conversation/codexProviderRelease.js +465 -0
- package/src/server/conversation/codexProviderSelection.js +26 -15
- package/src/server/conversation/codexProviderThreads.js +698 -0
- package/src/server/conversation/codexProviderTurns.js +453 -0
- package/src/server/conversation/codexReasoning.js +32 -23
- package/src/server/conversation/codexRenewalCommands.js +18 -6
- package/src/server/conversation/codexRenewalThreads.js +7 -22
- package/src/server/conversation/codexResultSettlement.js +70 -88
- package/src/server/conversation/codexRunJournal.js +91 -97
- package/src/server/conversation/codexRuntime.js +426 -0
- package/src/server/conversation/codexRuntimeInventory.js +113 -0
- package/src/server/conversation/codexRuntimeLifecycle.js +39 -13
- package/src/server/conversation/codexRuntimeStart.js +431 -0
- package/src/server/conversation/codexRuntimeStop.js +324 -0
- package/src/server/conversation/codexSessionCleanup.js +25 -10
- package/src/server/conversation/codexSessionReconciliation.js +21 -7
- package/src/server/conversation/codexThreadReadiness.js +49 -52
- package/src/server/conversation/codexThreadSnapshot.js +210 -0
- package/src/server/conversation/codexThreadStatus.js +48 -62
- package/src/server/conversation/codexTurn.js +171 -55
- package/src/server/conversation/codexTurnActivity.js +210 -0
- package/src/server/conversation/codexTurnControl.js +44 -48
- package/src/server/conversation/codexTurnEndings.js +610 -0
- package/src/server/conversation/codexTurnRecovery.js +97 -848
- package/src/server/conversation/codexTurnState.js +138 -111
- package/src/server/conversation/completedResponses.js +227 -0
- package/src/server/conversation/configuration.js +2 -2
- package/src/server/conversation/continuity.js +43 -6
- package/src/server/conversation/conversationDelivery.js +394 -0
- package/src/server/conversation/conversationEntries.js +365 -0
- package/src/server/conversation/conversationEvents.js +192 -0
- package/src/server/conversation/conversationGoals.js +187 -0
- package/src/server/conversation/conversationHandles.js +237 -0
- package/src/server/conversation/conversationReads.js +206 -0
- package/src/server/conversation/conversationRefusals.js +58 -0
- package/src/server/conversation/conversationReplacement.js +254 -0
- package/src/server/conversation/conversationSending.js +273 -0
- package/src/server/conversation/conversationStopping.js +121 -0
- package/src/server/conversation/conversationTurnDelivery.js +413 -0
- package/src/server/conversation/conversationTurns.js +511 -0
- package/src/server/conversation/deliveryTiming.js +25 -3
- package/src/server/conversation/fileStorage.js +11 -6
- package/src/server/conversation/hookBridge.js +14 -3
- package/src/server/conversation/index.js +1 -1
- package/src/server/conversation/localExecution.js +82 -625
- package/src/server/conversation/nativeCommandCallbacks.js +10 -0
- package/src/server/conversation/nativeConversationLifecycle.js +379 -0
- package/src/server/conversation/nativeHistoryExport.js +27 -18
- package/src/server/conversation/normalize.js +9 -0
- package/src/server/conversation/openCodeClient.js +295 -366
- package/src/server/conversation/openCodeNativeHistory.js +141 -0
- package/src/server/conversation/openCodeNativeSession.js +79 -0
- package/src/server/conversation/openCodeProcess.js +109 -53
- package/src/server/conversation/openCodeProcessOwner.js +565 -0
- package/src/server/conversation/openCodeRuntime.js +111 -1814
- package/src/server/conversation/openCodeSessionTurns.js +547 -0
- package/src/server/conversation/openCodeTemporaryConversations.js +394 -0
- package/src/server/conversation/openCodeTools.js +28 -14
- package/src/server/conversation/openCodeTurn.js +80 -74
- package/src/server/conversation/openCodeTurnMonitor.js +388 -0
- package/src/server/conversation/processIdentity.js +582 -0
- package/src/server/conversation/provider.js +21 -6
- package/src/server/conversation/providers/api.js +268 -107
- package/src/server/conversation/providers/claude.js +29 -6
- package/src/server/conversation/providers/claudeDriver.js +325 -250
- package/src/server/conversation/providers/codex.js +69 -33
- package/src/server/conversation/providers/codexDriver.js +503 -1081
- package/src/server/conversation/providers/codexDriverConnection.js +331 -0
- package/src/server/conversation/providers/codexDriverTurns.js +464 -0
- package/src/server/conversation/providers/opencode.js +30 -4
- package/src/server/conversation/providers/opencodeDriver.js +321 -659
- package/src/server/conversation/providers/opencodeStandalone.js +475 -0
- package/src/server/conversation/providers/scoped.js +64 -19
- package/src/server/conversation/runtime.js +171 -2863
- package/src/server/conversation/storage.js +91 -50
- package/src/server/conversation/streams.js +15 -2
- package/src/server/conversation/structuredOutput.js +7 -11
- package/src/server/conversation/systemPrompt.js +13 -1
- package/src/server/conversation/tools.js +47 -15
- package/src/server/conversation/transcript.js +49 -20
- package/src/server/hosts/claudeTurn.js +2 -4
- package/src/server/hosts/codexConfiguration.js +6 -8
- package/src/server/hosts/codexEvents.js +4 -2
- package/src/server/hosts/codexProcess.js +1 -1
- package/src/server/hosts/codexProvider.js +11 -9
- package/src/server/hosts/codexTurn.js +3 -3
- package/src/server/hosts/openCodeClient.js +4 -2
- package/src/server/index.js +0 -2
- package/src/server/lib/aiConnectionClient.js +35 -25
- package/src/server/lib/ndjson.js +1 -0
- package/src/server/lib/providers/anthropicClient.js +15 -6
- package/src/server/lib/providers/common.js +10 -6
- package/src/server/lib/providers/openAiCompatibleClient.js +40 -23
- package/src/server/lib/serviceToolCatalog.js +29 -283
- package/src/server/lib/serviceToolEntries.js +270 -0
- package/src/server/types.js +78 -0
- package/src/shared/assistantResource.js +0 -23
- package/src/shared/conversation/answerChoiceSugar.js +2 -14
- package/src/shared/conversation/messageInputSugar.js +18 -0
- package/src/shared/conversation/numberedQuestionSugar.js +6 -16
- package/src/shared/conversation/pagination.js +1 -1
- package/src/shared/conversation/questions.js +1 -0
- package/src/shared/conversation/streaming.js +1 -0
- package/src/shared/index.js +0 -1
- package/src/testing/conversationStorageContract.js +20 -0
- package/test/aiConnectionClient.test.js +42 -1
- package/test/apiConversationDriver.test.js +217 -38
- package/test/assistantBoundedToolLoop.test.js +1 -1
- package/test/claudeConversationSession.test.js +15 -11
- package/test/claudeInstructionResume.test.js +1 -1
- package/test/claudeProcess.test.js +9 -9
- package/test/claudeTurn.test.js +21 -8
- package/test/codexAccountChange.test.js +5 -3
- package/test/codexConversationSession.test.js +22 -21
- package/test/codexEvents.test.js +3 -1
- package/test/codexHelperDeletion.test.js +3 -7
- package/test/codexIsolation.test.js +6 -6
- package/test/codexModelCatalog.test.js +6 -7
- package/test/codexProcess.test.js +3 -3
- package/test/codexThreadSelection.test.js +4 -1
- package/test/codexTurn.test.js +29 -20
- package/test/conversationApiFacilities.test.js +11 -9
- package/test/conversationAttachments.test.js +3 -3
- package/test/conversationConfiguration.test.js +4 -4
- package/test/conversationNativeStorage.test.js +11 -10
- package/test/conversationReplacement.test.js +13 -13
- package/test/conversationRuntime.test.js +9 -5
- package/test/conversationSession.test.js +78 -22
- package/test/conversationTools.test.js +47 -26
- package/test/fileConversationStorage.test.js +10 -3
- package/test/fixtures/codexHelperThreadLedger.js +3 -3
- package/test/frameworkActions.test.js +33 -18
- package/test/localConversationExecution.test.js +4 -4
- package/test/localExecution.test.js +1 -1
- package/test/openCodeConnectionChange.test.js +3 -2
- package/test/openCodeRuntime.test.js +8 -7
- package/test/openCodeTools.test.js +1 -1
- package/test/opencodeConversationSession.test.js +4 -3
- package/test/serviceToolCatalog.test.js +3 -8
- package/src/server/lib/assistantCompletion.js +0 -190
- 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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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, "
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
56
|
-
assert.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
assert.
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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({
|
|
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" },
|
|
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: "
|
|
226
|
-
await assert.rejects(f.conversation.replace({ ...operation, briefing: "Changed" }), { code: "
|
|
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"] },
|
|
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: "
|
|
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: "
|
|
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 === "
|
|
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/
|
|
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 === "
|
|
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 === "
|
|
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
|
|
2665
|
-
|
|
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/
|
|
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
|
} });
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -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 {
|
|
7
|
-
|
|
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" },
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
|
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
|
|
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 }) },
|