@jskit-ai/assistant-core 0.1.240 → 0.1.242
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 +5 -5
- package/src/client/conversation/AssistantTranscript.vue +26 -29
- package/src/client/conversation/LongTextPreviewBlocks.vue +11 -0
- package/src/client/conversation/messageDelivery.js +8 -2
- package/src/client/conversation/useAssistantAttachments.js +1 -1
- package/src/server/conversation/agentRun.js +12 -198
- package/src/server/conversation/attachments.js +6 -5
- package/src/server/conversation/boundedToolLoop.js +160 -0
- package/src/server/conversation/claudeHistory.js +37 -23
- package/src/server/conversation/claudeProcess.js +2 -2
- package/src/server/conversation/claudeTurn.js +41 -38
- package/src/server/conversation/codexAuthPreflight.js +104 -0
- package/src/server/conversation/codexClient.js +11 -16
- package/src/server/conversation/codexConfiguration.js +112 -98
- package/src/server/conversation/codexConversationStore.js +189 -0
- package/src/server/conversation/codexConversations.js +47 -44
- package/src/server/conversation/codexDetachedTurn.js +12 -11
- package/src/server/conversation/codexEvents.js +95 -99
- package/src/server/conversation/codexHelperExecution.js +3 -13
- package/src/server/conversation/codexHelperThreadLedger.js +7 -13
- package/src/server/conversation/codexHelperThreadRecovery.js +2 -16
- package/src/server/conversation/codexHelperThreads.js +5 -6
- package/src/server/conversation/codexHistoryAdapter.js +32 -26
- package/src/server/conversation/codexMessageCommands.js +22 -22
- package/src/server/conversation/codexMessageDelivery.js +12 -7
- package/src/server/conversation/codexModelCatalog.js +97 -0
- package/src/server/conversation/codexNotificationObserver.js +24 -40
- package/src/server/conversation/codexNotificationQueue.js +2 -3
- package/src/server/conversation/codexOutput.js +20 -27
- package/src/server/conversation/codexProcess.js +123 -127
- package/src/server/conversation/codexProcessLeader.js +3 -3
- package/src/server/conversation/codexProvider.js +31 -614
- package/src/server/conversation/codexProviderOwner.js +2 -2
- package/src/server/conversation/codexProviderSelection.js +38 -40
- package/src/server/conversation/codexRenewalCommands.js +13 -10
- package/src/server/conversation/codexRenewalThreads.js +412 -0
- package/src/server/conversation/codexRunJournal.js +24 -42
- package/src/server/conversation/codexRuntimeLifecycle.js +19 -15
- package/src/server/conversation/codexSessionCleanup.js +11 -10
- package/src/server/conversation/codexSessionReconciliation.js +6 -6
- package/src/server/conversation/codexThreadReadiness.js +19 -33
- package/src/server/conversation/codexThreadStatus.js +3 -3
- package/src/server/conversation/codexTurn.js +124 -297
- package/src/server/conversation/codexTurnRecovery.js +49 -28
- package/src/server/conversation/codexTurnState.js +34 -13
- package/src/server/conversation/commandWrapper.js +3 -0
- package/src/server/conversation/deliveryTiming.js +14 -5
- package/src/server/conversation/index.js +1 -0
- package/src/server/conversation/localExecution.js +13 -17
- package/src/server/conversation/normalize.js +8 -3
- package/src/server/conversation/openCodeClient.js +9 -10
- package/src/server/conversation/openCodeProcess.js +9 -5
- package/src/server/conversation/openCodeRuntime.js +10 -11
- package/src/server/conversation/openCodeTurn.js +43 -29
- package/src/server/conversation/providers/claudeDriver.js +8 -16
- package/src/server/conversation/providers/codexDriver.js +25 -41
- package/src/server/conversation/providers/opencodeDriver.js +17 -42
- package/src/server/conversation/providers/scoped.js +48 -4
- package/src/server/conversation/runtime.js +50 -49
- package/src/server/conversation/runtimeStateUpgrade.js +34 -22
- package/src/server/conversation/transcript.js +10 -10
- package/src/server/hosts/codexConfiguration.js +1 -0
- package/src/server/hosts/codexProvider.js +8 -6
- package/src/server/index.js +2 -1
- package/src/server/lib/assistantToolLoop.js +1 -153
- package/src/server/lib/serviceToolCatalog.js +17 -42
- package/src/shared/assistantResource.js +4 -2
- package/src/shared/conversation/attachments.js +4 -0
- package/src/shared/conversation/longTextBlocks.js +54 -19
- package/src/shared/conversation/numberedQuestionSugar.js +17 -10
- package/src/shared/conversation/pagination.js +4 -6
- package/src/shared/conversation/patches.js +1 -4
- package/src/shared/conversation/streaming.js +3 -11
- package/src/shared/conversation/turns.js +11 -0
- package/src/shared/index.js +1 -0
- package/test/assistantBoundedToolLoop.test.js +2 -2
- package/test/assistantResource.test.js +4 -4
- package/test/assistantSettingsResource.test.js +2 -2
- package/test/claudeConversationSession.test.js +11 -1
- package/test/claudeTurn.test.js +35 -7
- package/test/codexClient.test.js +20 -0
- package/test/codexConversationSession.test.js +2 -2
- package/test/codexConversationsCheckpoint.test.js +51 -0
- package/test/codexModelCatalog.test.js +1 -1
- package/test/codexProviderHome.test.js +51 -0
- package/test/codexProviderSelection.test.js +71 -0
- package/test/codexTurn.test.js +222 -6
- package/test/conversationAgentRun.test.js +2 -1
- package/test/conversationAttachments.test.js +27 -2
- package/test/conversationDeliveryTiming.test.js +21 -0
- package/test/conversationNormalize.test.js +11 -0
- package/test/fixtures/nestedListQuestionMessage.md +37 -0
- package/test/frameworkActions.test.js +2 -2
- package/test/longTextBlocks.test.js +102 -0
- package/test/messageDelivery.test.js +7 -1
- package/test/openCodeClient.test.js +20 -0
- package/test/openCodeRuntime.test.js +1 -1
- package/test/openCodeTurn.test.js +90 -0
- package/test/opencodeConversationSession.test.js +1 -1
- package/test/serviceToolCatalog.test.js +27 -0
|
@@ -3,8 +3,11 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import test from "node:test";
|
|
4
4
|
import { createConversationRuntime, createMemoryConversationStorage } from "../src/server/conversation/index.js";
|
|
5
5
|
import { codexLocalImageInput, codexTurnInput } from "../src/server/conversation/codexProvider.js";
|
|
6
|
-
import { createConversationAttachmentReader } from "../src/server/conversation/attachments.js";
|
|
7
|
-
import {
|
|
6
|
+
import { conversationAttachmentIds, createConversationAttachmentReader } from "../src/server/conversation/attachments.js";
|
|
7
|
+
import { attachmentIdsField } from "../src/shared/index.js";
|
|
8
|
+
import { ASSISTANT_ATTACHMENT_MAX_ITEMS as CLIENT_ATTACHMENT_MAX_ITEMS } from "../src/client/conversation/useAssistantAttachments.js";
|
|
9
|
+
import { ASSISTANT_ATTACHMENT_ID_MAX_LENGTH, ASSISTANT_ATTACHMENT_MAX_ITEMS, conversationAttachmentManifest,
|
|
10
|
+
prepareConversationAttachmentMessage } from "../src/shared/conversation/attachments.js";
|
|
8
11
|
|
|
9
12
|
const image = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN7sAAAAASUVORK5CYII=", "base64");
|
|
10
13
|
const receipt = { attachmentId: "picture", fileName: "picture.png", size: image.length };
|
|
@@ -253,3 +256,25 @@ test("attachment validation displays the application's configured binary or deci
|
|
|
253
256
|
}
|
|
254
257
|
}
|
|
255
258
|
});
|
|
259
|
+
|
|
260
|
+
test("one attachment-ID limit governs the schema field, the server check, continuity batches and the composer", async () => {
|
|
261
|
+
assert.equal(attachmentIdsField.maxItems, ASSISTANT_ATTACHMENT_MAX_ITEMS);
|
|
262
|
+
assert.equal(attachmentIdsField.items.maxLength, ASSISTANT_ATTACHMENT_ID_MAX_LENGTH);
|
|
263
|
+
assert.equal(CLIENT_ATTACHMENT_MAX_ITEMS, ASSISTANT_ATTACHMENT_MAX_ITEMS);
|
|
264
|
+
const ids = Array.from({ length: ASSISTANT_ATTACHMENT_MAX_ITEMS }, (_, index) => `file-${index}`);
|
|
265
|
+
assert.deepEqual(conversationAttachmentIds(ids), ids);
|
|
266
|
+
assert.throws(() => conversationAttachmentIds([...ids, "one-too-many"]), { code: "conversation_invalid_attachments" });
|
|
267
|
+
assert.deepEqual(conversationAttachmentIds(["x".repeat(ASSISTANT_ATTACHMENT_ID_MAX_LENGTH)]).length, 1);
|
|
268
|
+
assert.throws(() => conversationAttachmentIds(["x".repeat(ASSISTANT_ATTACHMENT_ID_MAX_LENGTH + 1)]), { code: "conversation_invalid_attachments" });
|
|
269
|
+
const batches = [];
|
|
270
|
+
const reader = createConversationAttachmentReader({ context, conversationId: "one", signal: new AbortController().signal,
|
|
271
|
+
authorize: async () => {}, types: ["text"], maximumBytes: 1024 * 1024,
|
|
272
|
+
attachments: { resolve: ({ attachmentIds }) => {
|
|
273
|
+
batches.push(attachmentIds.length);
|
|
274
|
+
return { attachments: attachmentIds.map(attachmentId => ({ attachmentId, fileName: `${attachmentId}.txt`, size: 1 })),
|
|
275
|
+
content: [{ type: "text", text: "x" }] };
|
|
276
|
+
} } });
|
|
277
|
+
const continued = await reader([...ids, "one-more"].map(attachmentId => ({ attachmentId })));
|
|
278
|
+
assert.deepEqual(batches, [ASSISTANT_ATTACHMENT_MAX_ITEMS, 1]);
|
|
279
|
+
assert.equal(continued.attachments.length, ASSISTANT_ATTACHMENT_MAX_ITEMS + 1);
|
|
280
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { createConversationDeliveryTiming } from "../src/server/conversation/deliveryTiming.js";
|
|
4
|
+
import { createConversationRuntime, createMemoryConversationStorage } from "../src/server/conversation/index.js";
|
|
4
5
|
|
|
5
6
|
test("native engines share bounded, content-free timing and independent message identities", () => {
|
|
6
7
|
for (const engine of ["codex", "claude", "opencode"]) {
|
|
@@ -48,3 +49,23 @@ test("diagnostic failures and unfinished asynchronous observers never block deli
|
|
|
48
49
|
await Promise.resolve();
|
|
49
50
|
pending.resolve();
|
|
50
51
|
});
|
|
52
|
+
|
|
53
|
+
test("host preparation stages are reported only when the host declares them", () => {
|
|
54
|
+
const events = [];
|
|
55
|
+
const report = event => events.push(event.stage);
|
|
56
|
+
const plain = createConversationDeliveryTiming({ engine: "claude", conversationId: "chat", messageId: "one", report });
|
|
57
|
+
plain("received"); plain("host-step");
|
|
58
|
+
assert.deepEqual(events, ["received"]);
|
|
59
|
+
const declared = createConversationDeliveryTiming({ engine: "claude", conversationId: "chat", messageId: "two", report,
|
|
60
|
+
hostStages: ["host-step"] });
|
|
61
|
+
declared("host-step"); declared("other-step");
|
|
62
|
+
assert.deepEqual(events, ["received", "host-step"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("conversation runtimes reject malformed host delivery stage names", () => {
|
|
66
|
+
for (const hostDeliveryStages of ["host-step", ["Host"], ["host step"], [7], ["-step"]]) {
|
|
67
|
+
assert.throws(() => createConversationRuntime({ authorize: () => true, hostDeliveryStages }),
|
|
68
|
+
{ name: "TypeError", message: "Host delivery stages must be lowercase hyphenated names." });
|
|
69
|
+
}
|
|
70
|
+
createConversationRuntime({ authorize: () => true, storage: createMemoryConversationStorage(), hostDeliveryStages: ["git-step"] });
|
|
71
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { normalizeNullableText } from "../src/server/conversation/normalize.js";
|
|
4
|
+
|
|
5
|
+
test("conversation text normalization blanks only null and undefined", () => {
|
|
6
|
+
assert.equal(normalizeNullableText(null), "");
|
|
7
|
+
assert.equal(normalizeNullableText(undefined), "");
|
|
8
|
+
assert.equal(normalizeNullableText(0), "0");
|
|
9
|
+
assert.equal(normalizeNullableText(false), "false");
|
|
10
|
+
assert.equal(normalizeNullableText(" id "), "id");
|
|
11
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Here's how I'd run a final sweep in three steps: I do a systematic pass on my own first, then a short session with you for the parts that need you, then I tidy up.
|
|
2
|
+
|
|
3
|
+
**Step 1: I sweep on my own (no need for you)**
|
|
4
|
+
- **Click everything.** I go through every visible control and compare it against the specs: every button, the + menu, every right-click menu (bots, messages, sections, Library items), every keyboard shortcut on the shortcuts sheet, and every settings row. Anything with no spec is a gap.
|
|
5
|
+
- **Gather the "unverified" lists.** Every spec has one, and I'll merge them all into a single gap list. Each item gets labelled as one of three kinds:
|
|
6
|
+
- **I can observe it now:** for example, editing and deleting pages, or a bot made from a template.
|
|
7
|
+
- **Needs you:** real accounts, audio, a second account, payment, your phone, links that open in a browser.
|
|
8
|
+
- **Can't be observed at all.**
|
|
9
|
+
- **Output:** one gap list, saved to your Session Dropzone, with every item marked done, observable, needs you, or impossible.
|
|
10
|
+
|
|
11
|
+
**Step 2: a session with you, for the "needs you" items and things only you know about**
|
|
12
|
+
- **You drive the reference over VNC**, using it as you normally would: your knowledge of features, the test Google account, the phone app, links opening in your browser.
|
|
13
|
+
- **I record the screen** at a low frame rate while you work, and capture stills when you tell me something interesting is happening. Just tell me what you did in plain words ("now I'm connecting Gmail", "this is the share link in my browser").
|
|
14
|
+
- **Things outside the reference machine:** put screenshots in the Session Dropzone, for example from the mobile app, the help pages, or emails it sends. I'll file them.
|
|
15
|
+
- **What I need from you:**
|
|
16
|
+
- which features you know or suspect exist that I haven't covered (I'll ask about specific suspects from my list)
|
|
17
|
+
- the test Google account, so I can see the real connected states
|
|
18
|
+
- your call on anything that sends real data or costs money
|
|
19
|
+
|
|
20
|
+
**Step 3: I tidy up**
|
|
21
|
+
- **For mercbot:**
|
|
22
|
+
- Newly discovered areas go to mercbot as additions to the feature map.
|
|
23
|
+
- Each one gets a spec, sent when you or mercbot ask for it. The phase-2 rules allow that.
|
|
24
|
+
- **For you:** a final coverage summary.
|
|
25
|
+
|
|
26
|
+
**One thing to keep in mind:** you'll see more of the original than mercbot ever should. During this phase, keep sending findings through me rather than straight to mercbot.
|
|
27
|
+
|
|
28
|
+
[1] Shall I start Step 1 (the solo sweep and gap list) now?
|
|
29
|
+
Possible answers:
|
|
30
|
+
- Yes, start now
|
|
31
|
+
- Wait until I'm ready to do Step 2 as well
|
|
32
|
+
|
|
33
|
+
[2] For Step 2, how would you like to work?
|
|
34
|
+
Possible answers:
|
|
35
|
+
- I drive over VNC while you record
|
|
36
|
+
- I'll put screenshots in the Dropzone
|
|
37
|
+
- A mix of both
|
|
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { readdir } from "node:fs/promises";
|
|
4
4
|
import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
|
|
5
|
-
import {
|
|
5
|
+
import { resolveSchemaTransportSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
|
|
6
6
|
import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
|
|
7
7
|
import { createServiceToolCatalog } from "../src/server/lib/serviceToolCatalog.js";
|
|
8
8
|
import { createAssistantActions } from "../../assistant-runtime/src/server/actions.js";
|
|
@@ -95,7 +95,7 @@ test("every account, workspace, console and assistant automation action has a co
|
|
|
95
95
|
direct++;
|
|
96
96
|
assert.ok(assistant.description, spec.id);
|
|
97
97
|
assert.equal(typeof assistant.transformResult, "function", spec.id);
|
|
98
|
-
assert.ok(
|
|
98
|
+
assert.ok(resolveSchemaTransportSchemaDefinition(assistant.output), spec.id);
|
|
99
99
|
assert.ok(fixtures[spec.id], `Add execution coverage for ${spec.id}`);
|
|
100
100
|
}
|
|
101
101
|
assert.equal(direct, 22);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { parseLongTextReviewBlocks } from "../src/shared/conversation/longTextBlocks.js";
|
|
5
|
+
import { parseNumberedQuestionPrompt } from "../src/shared/conversation/numberedQuestionSugar.js";
|
|
6
|
+
|
|
7
|
+
const message = readFileSync(new URL("./fixtures/nestedListQuestionMessage.md", import.meta.url), "utf8");
|
|
8
|
+
|
|
9
|
+
function outline(blocks = []) {
|
|
10
|
+
return blocks.map((block) => {
|
|
11
|
+
if (block.type !== "ul" && block.type !== "ol") {
|
|
12
|
+
return `${block.type}: ${block.text.slice(0, 40)}`;
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
[block.type]: block.items.map((item) => (
|
|
16
|
+
item.blocks ? { [item.text.slice(0, 40)]: outline(item.blocks) } : item.text.slice(0, 40)
|
|
17
|
+
))
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("a question message keeps its intro paragraphs and nested lists apart from the question cards", () => {
|
|
23
|
+
const prompt = parseNumberedQuestionPrompt(message);
|
|
24
|
+
assert.deepEqual(prompt.questions.map((question) => [question.label, question.choices.length]), [
|
|
25
|
+
["Shall I start Step 1 (the solo sweep and gap list) now?", 2],
|
|
26
|
+
["For Step 2, how would you like to work?", 3]
|
|
27
|
+
]);
|
|
28
|
+
assert.equal(prompt.outro, "");
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(outline(parseLongTextReviewBlocks(prompt.intro)), [
|
|
31
|
+
"paragraph: Here's how I'd run a final sweep in thre",
|
|
32
|
+
"paragraph: **Step 1: I sweep on my own (no need for",
|
|
33
|
+
{
|
|
34
|
+
ul: [
|
|
35
|
+
"**Click everything.** I go through every",
|
|
36
|
+
{
|
|
37
|
+
"**Gather the \"unverified\" lists.** Every": [
|
|
38
|
+
{ ul: ["**I can observe it now:** for example, e", "**Needs you:** real accounts, audio, a s", "**Can't be observed at all.**"] }
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"**Output:** one gap list, saved to your "
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"paragraph: **Step 2: a session with you, for the \"n",
|
|
45
|
+
{
|
|
46
|
+
ul: [
|
|
47
|
+
"**You drive the reference over VNC**, us",
|
|
48
|
+
"**I record the screen** at a low frame r",
|
|
49
|
+
"**Things outside the reference machine:*",
|
|
50
|
+
{
|
|
51
|
+
"**What I need from you:**": [
|
|
52
|
+
{ ul: ["which features you know or suspect exist", "the test Google account, so I can see th", "your call on anything that sends real da"] }
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
"paragraph: **Step 3: I tidy up**",
|
|
58
|
+
{
|
|
59
|
+
ul: [
|
|
60
|
+
{
|
|
61
|
+
"**For mercbot:**": [
|
|
62
|
+
{ ul: ["Newly discovered areas go to mercbot as ", "Each one gets a spec, sent when you or m"] }
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
"**For you:** a final coverage summary."
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
"paragraph: **One thing to keep in mind:** you'll se"
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("nested lists follow item content indentation and return to the parent level", () => {
|
|
73
|
+
assert.deepEqual(parseLongTextReviewBlocks([
|
|
74
|
+
"1. First",
|
|
75
|
+
" - child a",
|
|
76
|
+
"\t- child b",
|
|
77
|
+
" 1) grandchild",
|
|
78
|
+
"2. Second",
|
|
79
|
+
" - too shallow for the ordered item, so a sibling list item"
|
|
80
|
+
].join("\n")), [
|
|
81
|
+
{
|
|
82
|
+
items: [
|
|
83
|
+
{
|
|
84
|
+
blocks: [{
|
|
85
|
+
items: [
|
|
86
|
+
{ text: "child a" },
|
|
87
|
+
{ blocks: [{ items: [{ text: "grandchild" }], type: "ol" }], text: "child b" }
|
|
88
|
+
],
|
|
89
|
+
type: "ul"
|
|
90
|
+
}],
|
|
91
|
+
text: "First"
|
|
92
|
+
},
|
|
93
|
+
{ text: "Second" }
|
|
94
|
+
],
|
|
95
|
+
type: "ol"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
items: [{ text: "too shallow for the ordered item, so a sibling list item" }],
|
|
99
|
+
type: "ul"
|
|
100
|
+
}
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { nextTick, ref } from "vue";
|
|
4
|
-
import { createAssistantMessageDelivery, unmatchedOptimisticMessages } from "../src/client/conversation/messageDelivery.js";
|
|
4
|
+
import { createAssistantMessageDelivery, draftWithRestoredMessage, unmatchedOptimisticMessages } from "../src/client/conversation/messageDelivery.js";
|
|
5
5
|
import { normalizeConversationTurn } from "../src/shared/conversation/patches.js";
|
|
6
6
|
|
|
7
7
|
test("proven no-admission releases only the exact uncertain message without accepting or losing its payload", async () => {
|
|
@@ -429,3 +429,9 @@ test("a deliberate new message can follow known uncertainty without resending th
|
|
|
429
429
|
assert.equal(delivery.find("old").status, "uncertain");
|
|
430
430
|
assert.equal(delivery.find("new").status, "accepted");
|
|
431
431
|
});
|
|
432
|
+
|
|
433
|
+
test("a restored message leads the draft once", () => {
|
|
434
|
+
assert.equal(draftWithRestoredMessage("First", ""), "First");
|
|
435
|
+
assert.equal(draftWithRestoredMessage("First", "New draft"), "First\n\nNew draft");
|
|
436
|
+
assert.equal(draftWithRestoredMessage("First", "First\n\nNew draft"), "First\n\nNew draft");
|
|
437
|
+
});
|
|
@@ -699,3 +699,23 @@ test("stopped native WAL inspection reads newest committed OpenCode rows without
|
|
|
699
699
|
}), /Native storage changed during inspection/u);
|
|
700
700
|
await assert.rejects(lstat(path.dirname(snapshot)), { code: "ENOENT" });
|
|
701
701
|
});
|
|
702
|
+
|
|
703
|
+
test("OpenCode message reads continue with older native pages through the before cursor", async () => {
|
|
704
|
+
const requests = [];
|
|
705
|
+
const client = createOpenCodeServerClient({ baseUrl: "http://127.0.0.1:4096", password: "private",
|
|
706
|
+
fetchImpl: async (url) => {
|
|
707
|
+
requests.push(new URL(url).search);
|
|
708
|
+
const older = new URL(url).searchParams.get("before");
|
|
709
|
+
return new Response(JSON.stringify([{ info: { id: older ? "msg_old" : "msg_new", role: "user" }, parts: [] }]), {
|
|
710
|
+
headers: older ? {} : { "x-next-cursor": "opaque+/=" }
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}).forDirectory("/work/selected");
|
|
714
|
+
const newest = await client.messages("ses_paged", { limit: 1 });
|
|
715
|
+
assert.equal(newest.nextCursor, "opaque+/=");
|
|
716
|
+
assert.equal(newest.data[0].id, "msg_new");
|
|
717
|
+
const older = await client.messages("ses_paged", { limit: 1, before: newest.nextCursor });
|
|
718
|
+
assert.equal(older.data[0].id, "msg_old");
|
|
719
|
+
assert.equal("nextCursor" in older, false);
|
|
720
|
+
assert.deepEqual(requests, ["?limit=1", `?limit=1&before=${encodeURIComponent("opaque+/=")}`]);
|
|
721
|
+
});
|
|
@@ -773,7 +773,7 @@ test("credential publication refuses another provider and unowned consumers befo
|
|
|
773
773
|
await assert.rejects(runtime.invalidateRuntimes({ modelProviderId: "deepseek", publish() { published = true; } }, {
|
|
774
774
|
release: { ownsTarget: target => !unowned || target !== peer,
|
|
775
775
|
beforeRelease: async target => effects.push(target.key), onRemoved: async () => {}, failure: error => { throw error; } }
|
|
776
|
-
}), { code: "assistant_opencode_connection_in_use" });
|
|
776
|
+
}), { code: "assistant_opencode_connection_in_use", message: /^Close the other OpenCode conversations that use this connection, then retry/u });
|
|
777
777
|
assert.equal(published, false);
|
|
778
778
|
assert.deepEqual(effects, []);
|
|
779
779
|
assert.deepEqual(stops, []);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
inspectOpenCodeMessageAdmission,
|
|
6
|
+
readOpenCodeMessagesUntil,
|
|
7
|
+
runOpenCodeConversationTurn,
|
|
8
|
+
waitForOpenCodeMessages
|
|
9
|
+
} from "../src/server/conversation/openCodeTurn.js";
|
|
10
|
+
|
|
11
|
+
// Mirrors the native message route: a limited read returns the newest rows in
|
|
12
|
+
// chronological order and an opaque cursor that continues with older rows.
|
|
13
|
+
function pagedClient(history) {
|
|
14
|
+
const reads = [];
|
|
15
|
+
const prompts = [];
|
|
16
|
+
return {
|
|
17
|
+
reads,
|
|
18
|
+
prompts,
|
|
19
|
+
async messages(_sessionId, { limit, before } = {}) {
|
|
20
|
+
reads.push({ limit, before });
|
|
21
|
+
const end = before ? Number(before.slice("cursor-".length)) : history.length;
|
|
22
|
+
const start = Math.max(0, end - limit);
|
|
23
|
+
return { data: history.slice(start, end), ...(start > 0 ? { nextCursor: `cursor-${start}` } : {}) };
|
|
24
|
+
},
|
|
25
|
+
async sessionStatus() { return { type: "idle" }; },
|
|
26
|
+
async prompt(_sessionId, input) { prompts.push(input); return { id: input.id }; }
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// One user input followed by many native model steps, the last one final.
|
|
31
|
+
function longTurnHistory(steps = 150) {
|
|
32
|
+
const history = [
|
|
33
|
+
{ id: "msg_older", type: "user", time: { created: 1 }, content: [{ type: "text", text: "earlier" }] },
|
|
34
|
+
{ id: "msg_input", type: "user", time: { created: 2 }, content: [{ type: "text", text: "build it" }] }
|
|
35
|
+
];
|
|
36
|
+
for (let step = 1; step <= steps; step += 1) {
|
|
37
|
+
const final = step === steps;
|
|
38
|
+
history.push({
|
|
39
|
+
id: `msg_step_${step}`,
|
|
40
|
+
type: "assistant",
|
|
41
|
+
time: { created: 2 + step, ...(final ? { completed: 3 + step } : {}) },
|
|
42
|
+
finish: final ? "stop" : "tool-calls",
|
|
43
|
+
content: final ? [{ type: "text", text: "Done." }] : [{ type: "tool", tool: "bash", callID: `call_${step}` }]
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return history;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("a turn with more than one native page of steps after its input still completes", async () => {
|
|
50
|
+
const client = pagedClient(longTurnHistory());
|
|
51
|
+
const completion = await waitForOpenCodeMessages(client, "ses_long", "msg_input", {
|
|
52
|
+
signal: AbortSignal.timeout(5_000)
|
|
53
|
+
});
|
|
54
|
+
assert.equal(completion.result.complete, true);
|
|
55
|
+
assert.equal(completion.result.text, "Done.");
|
|
56
|
+
assert.equal(completion.result.turnId, "msg_step_150");
|
|
57
|
+
// The input sits on the second page; reading stops there instead of exhausting history.
|
|
58
|
+
assert.deepEqual(client.reads.slice(0, 2), [{ limit: 100, before: undefined }, { limit: 100, before: "cursor-52" }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("admission of an input followed by more than one native page of steps is still proven", async () => {
|
|
62
|
+
const client = pagedClient(longTurnHistory());
|
|
63
|
+
const observation = await inspectOpenCodeMessageAdmission(client, "ses_long", "msg_input");
|
|
64
|
+
assert.equal(observation.accepted, true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a bounded turn whose input left the newest native page is not dispatched again", async () => {
|
|
68
|
+
const client = pagedClient(longTurnHistory());
|
|
69
|
+
const result = await runOpenCodeConversationTurn(client, "ses_long", {
|
|
70
|
+
inputMessageId: "msg_input", prompt: "build it", timeoutMs: 5_000
|
|
71
|
+
}, { completeResult: value => value });
|
|
72
|
+
assert.deepEqual(client.prompts, []);
|
|
73
|
+
assert.equal(result.text, "Done.");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("paged native history reading stops when its rows are found or history ends", async () => {
|
|
77
|
+
const history = longTurnHistory(250);
|
|
78
|
+
const client = pagedClient(history);
|
|
79
|
+
const newest = await readOpenCodeMessagesUntil(client, "ses_long", () => true);
|
|
80
|
+
assert.equal(newest.data.length, 100);
|
|
81
|
+
assert.equal(client.reads.length, 1);
|
|
82
|
+
|
|
83
|
+
client.reads.length = 0;
|
|
84
|
+
const whole = await readOpenCodeMessagesUntil(client, "ses_long", () => false);
|
|
85
|
+
assert.deepEqual(whole.data.map(row => row.id), history.map(row => row.id));
|
|
86
|
+
assert.equal(client.reads.length, 3);
|
|
87
|
+
|
|
88
|
+
const repeating = { async messages() { return { data: [], nextCursor: "same" }; } };
|
|
89
|
+
await assert.rejects(readOpenCodeMessagesUntil(repeating, "ses_long", () => false), /repeated a native history cursor/u);
|
|
90
|
+
});
|
|
@@ -1813,7 +1813,7 @@ for (const kind of ["tool", "reply"]) test(`completed OpenCode ${kind} wire reta
|
|
|
1813
1813
|
assert.equal(saved.assistant.messageId, `${receipt.turnId}:${answer.info.id}:assistant`);
|
|
1814
1814
|
const prepared = await f.prepare(receipt);
|
|
1815
1815
|
assert.equal(prepared.text, raw);
|
|
1816
|
-
const { readAssistantResponseEnvelope } = await import("../src/server/
|
|
1816
|
+
const { readAssistantResponseEnvelope } = await import("../src/server/conversation/boundedToolLoop.js");
|
|
1817
1817
|
const decoded = readAssistantResponseEnvelope(prepared.text);
|
|
1818
1818
|
assert.deepEqual(decoded, value);
|
|
1819
1819
|
if (kind === "tool") assert.equal(JSON.parse(decoded.arguments).value, handover);
|
|
@@ -618,6 +618,33 @@ test("assistant tools reject unknown tools and return safe action failures", asy
|
|
|
618
618
|
});
|
|
619
619
|
});
|
|
620
620
|
|
|
621
|
+
test("malformed tool arguments fail without running the action", async () => {
|
|
622
|
+
let executions = 0;
|
|
623
|
+
const actions = createActions([action({
|
|
624
|
+
kind: "command",
|
|
625
|
+
input: schema({ title: { type: "string" } }),
|
|
626
|
+
execute: async () => {
|
|
627
|
+
executions += 1;
|
|
628
|
+
return { ok: true };
|
|
629
|
+
}
|
|
630
|
+
})]);
|
|
631
|
+
const catalog = createServiceToolCatalog(actions);
|
|
632
|
+
const context = { actor: { id: "7" }, surface: "admin" };
|
|
633
|
+
const toolSet = catalog.resolveToolSet(context);
|
|
634
|
+
const toolName = toolSet.tools[0].name;
|
|
635
|
+
|
|
636
|
+
for (const argumentsText of ['{"title":"x"', "[]", '"x"', "null", "7"]) {
|
|
637
|
+
assert.deepEqual(await catalog.executeToolCall({ toolName, context, toolSet, argumentsText }), {
|
|
638
|
+
ok: false,
|
|
639
|
+
error: { code: "assistant_tool_arguments_invalid", message: "Tool arguments must be a JSON object.", status: 400 }
|
|
640
|
+
}, argumentsText);
|
|
641
|
+
}
|
|
642
|
+
assert.equal(executions, 0);
|
|
643
|
+
|
|
644
|
+
assert.equal((await catalog.executeToolCall({ toolName, context, toolSet, argumentsText: " " })).ok, true);
|
|
645
|
+
assert.equal(executions, 1);
|
|
646
|
+
});
|
|
647
|
+
|
|
621
648
|
test("wildcard and empty surfaces match named surfaces without bypassing permissions", async () => {
|
|
622
649
|
for (const [surfaces, allowed] of [
|
|
623
650
|
[["*"], ["admin", "app", "console", "custom"]],
|