@jskit-ai/assistant-core 0.1.237 → 0.1.239
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 +1 -1
- package/src/client/conversation/messageDelivery.js +9 -2
- package/src/server/conversation/claudeTurn.js +32 -60
- package/src/shared/nativeProviders.js +1 -1
- package/test/claudeConversationSession.test.js +4 -4
- package/test/claudeTurn.test.js +120 -82
- package/test/codexHistoryAdapter.test.js +7 -5
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
12
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
13
|
-
"@jskit-ai/kernel": "0.1.
|
|
11
|
+
"@jskit-ai/assistant-core": "0.1.239",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.1.243",
|
|
13
|
+
"@jskit-ai/kernel": "0.1.245",
|
|
14
14
|
"@mdi/js": "^7.4.47",
|
|
15
15
|
"vue": "^3.5.13",
|
|
16
16
|
"vuetify": "^4.0.0",
|
|
17
|
-
"@jskit-ai/connectors-core": "0.1.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.1.
|
|
17
|
+
"@jskit-ai/connectors-core": "0.1.62",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.1.63"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.239",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"@ai-sdk/perplexity": "^4.0.43",
|
|
48
48
|
"@ai-sdk/togetherai": "^3.0.49",
|
|
49
49
|
"@ai-sdk/xai": "^4.0.58",
|
|
50
|
-
"@jskit-ai/resource-core": "0.1.
|
|
51
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
50
|
+
"@jskit-ai/resource-core": "0.1.187",
|
|
51
|
+
"@jskit-ai/resource-crud-core": "0.1.187",
|
|
52
52
|
"@mdi/js": "^7.4.47",
|
|
53
53
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
|
54
54
|
"ai": "^7.0.101",
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"ws": "^8.20.1"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
61
|
-
"@jskit-ai/kernel": "0.1.
|
|
60
|
+
"@jskit-ai/http-runtime": "0.1.243",
|
|
61
|
+
"@jskit-ai/kernel": "0.1.245",
|
|
62
62
|
"vue": "^3.5.13",
|
|
63
63
|
"vuetify": "^4.0.0"
|
|
64
64
|
},
|
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
class="assistant-transcript__message-footer assistant-transcript__message-footer--user"
|
|
176
176
|
>
|
|
177
177
|
<time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
|
|
178
|
-
<span v-if="entry.turn.optimistic?.status === 'pending'" role="status">Pending</span>
|
|
178
|
+
<span v-if="entry.turn.optimistic?.status === 'pending'" role="status">{{ entry.turn.optimistic.waiting ? `Pending: ${entry.turn.optimistic.waiting}` : "Pending" }}</span>
|
|
179
179
|
</div>
|
|
180
180
|
<slot name="user-message-actions" :message="entry.message" :turn="entry.turn" />
|
|
181
181
|
<div
|
|
@@ -96,7 +96,8 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
|
|
|
96
96
|
delivery: { messageId: message.id, error: message.error, checking: message.checking === true }
|
|
97
97
|
} : null;
|
|
98
98
|
return {
|
|
99
|
-
optimistic: { error: message.error, id: message.id, status: message.status
|
|
99
|
+
optimistic: { error: message.error, id: message.id, status: message.status,
|
|
100
|
+
...(message.status === "pending" && message.waiting ? { waiting: message.waiting } : {}) },
|
|
100
101
|
turnId: message.id,
|
|
101
102
|
[role]: authored,
|
|
102
103
|
...(status ? { system: role === "system" ? { ...authored, ...status, text: `${authored.text}\n\n${status.text}` } : status } : {})
|
|
@@ -201,6 +202,12 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
// Explains why a pending message is held locally, or clears that reason.
|
|
206
|
+
function wait(messageId, reason = "") {
|
|
207
|
+
const message = find(messageId);
|
|
208
|
+
if (message?.status === "pending") message.waiting = String(reason || "").trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
204
211
|
function rejectUnsent(messageId, error) {
|
|
205
212
|
if (find(messageId)?.status !== "uncertain") return false;
|
|
206
213
|
fail(messageId, error);
|
|
@@ -253,7 +260,7 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
|
|
|
253
260
|
state.sending = false;
|
|
254
261
|
}
|
|
255
262
|
|
|
256
|
-
return { accept, cancel, edit, find, reconcile, remove, resend, reset, restoreUncertain, rejectUnsent, send, state, turns };
|
|
263
|
+
return { accept, cancel, edit, find, reconcile, remove, resend, reset, restoreUncertain, rejectUnsent, send, state, turns, wait };
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
export { createAssistantMessageDelivery, unmatchedOptimisticMessages };
|
|
@@ -593,21 +593,18 @@ export function createClaudeConversationOwner({
|
|
|
593
593
|
deliveryTiming?.("process-ready");
|
|
594
594
|
if (command && !command.steering) command.signal.throwIfAborted();
|
|
595
595
|
const steering = command ? command.steering === true : Boolean(entry.turn?.active);
|
|
596
|
-
const sendNow = steering && native.client.supportsSendNow?.() === true;
|
|
597
596
|
if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
|
|
598
597
|
if (steering) {
|
|
599
598
|
if (command) {
|
|
600
599
|
await command.beforeDispatch({ threadId: entry.id });
|
|
601
600
|
command.signal.throwIfAborted();
|
|
602
601
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
} catch (error) {
|
|
602
|
+
// A mid-turn message is never an interrupt: Claude Code queues it and
|
|
603
|
+
// reads it after the running tool call. Only explicit Stop interrupts.
|
|
604
|
+
try { await entry.nativeTurn.prepareSteering(); }
|
|
605
|
+
catch (error) {
|
|
608
606
|
if (!command) await entry.nativeTurn.stopProcess(error.message);
|
|
609
|
-
//
|
|
610
|
-
// non-delivery so a failed interrupt cannot strand an attempted receipt.
|
|
607
|
+
// This precedes the input's pipe write: a refused steer is definitely not sent.
|
|
611
608
|
throw Object.assign(error, { delivery: "not-sent" });
|
|
612
609
|
}
|
|
613
610
|
}
|
|
@@ -688,7 +685,7 @@ export function createClaudeConversationOwner({
|
|
|
688
685
|
else await input.onPromptSending?.({ threadId: entry.id, displayAttachments: input.displayAttachments, turnMetadata: actorMetadata });
|
|
689
686
|
if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
|
|
690
687
|
await entry.nativeTurn.send(command ? entry.process.client : native.client, command ? preparation.message(entry, input) : prompt,
|
|
691
|
-
{ messageId: uuid, accept,
|
|
688
|
+
{ messageId: uuid, accept, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
|
|
692
689
|
} catch (error) {
|
|
693
690
|
if (!command) await entry.nativeTurn.stopProcess(error.message);
|
|
694
691
|
throw error;
|
|
@@ -1026,11 +1023,11 @@ export function createClaudeConversationOwner({
|
|
|
1026
1023
|
active.steer = async ({ input, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted, deliveryTiming }) => {
|
|
1027
1024
|
await ready.promise;
|
|
1028
1025
|
const nativeDispatch = { active, signal, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted, deliveryTiming,
|
|
1029
|
-
timeoutMs, steering: true,
|
|
1026
|
+
timeoutMs, steering: true, attempted: false, admitted: false };
|
|
1030
1027
|
try { await dispatch(control, { input, context, signal }, { acquire, entry, dispatch: nativeDispatch }); }
|
|
1031
1028
|
catch (error) {
|
|
1032
1029
|
if (nativeDispatch.attempted && !nativeDispatch.admitted && error.delivery !== "not-sent") error.delivery = "uncertain";
|
|
1033
|
-
if (nativeDispatch.
|
|
1030
|
+
if (nativeDispatch.attempted) completion.reject(error);
|
|
1034
1031
|
throw error;
|
|
1035
1032
|
}
|
|
1036
1033
|
};
|
|
@@ -1142,6 +1139,9 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1142
1139
|
const assistantRoles = new Map();
|
|
1143
1140
|
const admissions = new Map();
|
|
1144
1141
|
const inFlight = new Set();
|
|
1142
|
+
// Inputs Claude has begun reading. A queued steer joins the running loop, so
|
|
1143
|
+
// one result can finish several of them; queued-only input stays in flight.
|
|
1144
|
+
const startedInputs = new Set();
|
|
1145
1145
|
const tasks = new Set();
|
|
1146
1146
|
let turnId = "";
|
|
1147
1147
|
let messageId = "";
|
|
@@ -1153,7 +1153,6 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1153
1153
|
let generation = 0;
|
|
1154
1154
|
let stopped = false;
|
|
1155
1155
|
let cancelling = false;
|
|
1156
|
-
let steering;
|
|
1157
1156
|
let maximumOutput = 4 * 1024 * 1024;
|
|
1158
1157
|
let inputOwner = null;
|
|
1159
1158
|
let acceptedInput = null;
|
|
@@ -1418,11 +1417,13 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1418
1417
|
if (frame.type === "user" && !inFlight.has(uuid) && !admissions.has(uuid)) return;
|
|
1419
1418
|
// Native notifications have their own lifecycle UUIDs, including work
|
|
1420
1419
|
// folded into this input. They must not steal its receipt/result owner.
|
|
1421
|
-
if ((frame.state === "started" || frame.type === "user") && inFlight.has(uuid))
|
|
1422
|
-
|
|
1423
|
-
|
|
1420
|
+
if ((frame.state === "started" || frame.type === "user") && inFlight.has(uuid)) {
|
|
1421
|
+
currentCommandId = uuid;
|
|
1422
|
+
startedInputs.add(uuid);
|
|
1423
|
+
}
|
|
1424
|
+
if (frame.state === "completed") {
|
|
1424
1425
|
inFlight.delete(uuid);
|
|
1425
|
-
|
|
1426
|
+
startedInputs.delete(uuid);
|
|
1426
1427
|
}
|
|
1427
1428
|
if (frame.state === "queued" || frame.type === "user") {
|
|
1428
1429
|
const pending = admissions.get(uuid);
|
|
@@ -1559,10 +1560,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1559
1560
|
} : {}) });
|
|
1560
1561
|
}
|
|
1561
1562
|
inFlight.delete(currentCommandId);
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
return;
|
|
1565
|
-
}
|
|
1563
|
+
for (const uuid of startedInputs) inFlight.delete(uuid);
|
|
1564
|
+
startedInputs.clear();
|
|
1566
1565
|
if (cancelling) return;
|
|
1567
1566
|
outcome = { status: interrupted ? "interrupted" : failed ? "failed" : "completed", error: message };
|
|
1568
1567
|
// The reply is final, but native background tasks still own the turn
|
|
@@ -1601,6 +1600,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1601
1600
|
}
|
|
1602
1601
|
admissions.clear();
|
|
1603
1602
|
inFlight.clear();
|
|
1603
|
+
startedInputs.clear();
|
|
1604
1604
|
tasks.clear();
|
|
1605
1605
|
// Verified process cleanup can precede Claude's terminal generation frame.
|
|
1606
1606
|
// Retain already displayed text for the existing transcript reader, with
|
|
@@ -1616,7 +1616,6 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1616
1616
|
}
|
|
1617
1617
|
pendingAssistantMessages.clear();
|
|
1618
1618
|
assistantRoles.clear();
|
|
1619
|
-
steering?.reject(failure(reason));
|
|
1620
1619
|
if (active) outcome = { status: "interrupted", error: reason };
|
|
1621
1620
|
active = false;
|
|
1622
1621
|
phase = "";
|
|
@@ -1670,7 +1669,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1670
1669
|
acceptedInput = null;
|
|
1671
1670
|
clearFinalAssistantResult();
|
|
1672
1671
|
},
|
|
1673
|
-
async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000
|
|
1672
|
+
async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000 } = {}) {
|
|
1674
1673
|
if (!active || stopped || cancelling) throw Object.assign(
|
|
1675
1674
|
failure("Claude has no active turn for this message.", !active && !stopped && !cancelling
|
|
1676
1675
|
? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
|
|
@@ -1691,7 +1690,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1691
1690
|
)), timeoutMs);
|
|
1692
1691
|
admissions.set(uuid, { ...admitted, accept, timer, receiptOwner });
|
|
1693
1692
|
try {
|
|
1694
|
-
await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId
|
|
1693
|
+
await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId }), admitted.promise]);
|
|
1695
1694
|
} catch (error) {
|
|
1696
1695
|
if (inputOwner === receiptOwner) invalidateFinalAssistantResult();
|
|
1697
1696
|
throw error;
|
|
@@ -1699,49 +1698,22 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1699
1698
|
clearTimeout(timer);
|
|
1700
1699
|
}
|
|
1701
1700
|
},
|
|
1702
|
-
async
|
|
1703
|
-
if (!active ||
|
|
1704
|
-
const completed = !active && !
|
|
1701
|
+
async prepareSteering() {
|
|
1702
|
+
if (!active || cancelling) {
|
|
1703
|
+
const completed = !active && !stopped && !cancelling && outcome?.status === "completed";
|
|
1705
1704
|
throw Object.assign(failure("This Claude turn cannot accept steering now.", completed
|
|
1706
1705
|
? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
|
|
1707
1706
|
{ delivery: "not-sent", ...(completed ? { statusCode: 409 } : {}) });
|
|
1708
1707
|
}
|
|
1709
|
-
//
|
|
1710
|
-
//
|
|
1711
|
-
//
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
outcome = null;
|
|
1716
|
-
acceptedInput = null;
|
|
1717
|
-
clearFinalAssistantResult();
|
|
1718
|
-
await publish({ type: "diagnostic", stage: sendNow ? "steering_send_now" : "steering_background_only", backgroundTasks: tasks.size });
|
|
1719
|
-
return;
|
|
1720
|
-
}
|
|
1721
|
-
// Streaming user input otherwise queues behind the entire running turn.
|
|
1722
|
-
// Claude's supported steering path interrupts generation, then continues
|
|
1723
|
-
// the same native history with the new instruction.
|
|
1708
|
+
// Claude Code acknowledges streamed input as queued and folds it into the
|
|
1709
|
+
// running turn after the current tool call, or starts it once the main
|
|
1710
|
+
// loop is idle. Interrupting instead would kill a running foreground tool.
|
|
1711
|
+
// The next input owns completion now: a result or task finishing before
|
|
1712
|
+
// it starts must not settle this turn with the previous answer.
|
|
1713
|
+
outcome = null;
|
|
1724
1714
|
acceptedInput = null;
|
|
1725
1715
|
clearFinalAssistantResult();
|
|
1726
|
-
|
|
1727
|
-
let timer;
|
|
1728
|
-
try {
|
|
1729
|
-
await publish({ type: "diagnostic", stage: "steering_requested", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
1730
|
-
await Promise.all([client.interrupt(), Promise.race([
|
|
1731
|
-
steering.promise,
|
|
1732
|
-
new Promise((_, reject) => {
|
|
1733
|
-
timer = setTimeout(() => reject(Object.assign(failure(
|
|
1734
|
-
"Claude did not confirm the interrupted generation. This instruction was not sent; retry after reconnecting.",
|
|
1735
|
-
"assistant_claude_interrupt_timeout"
|
|
1736
|
-
), { statusCode: 503 })), timeoutMs);
|
|
1737
|
-
})
|
|
1738
|
-
])]);
|
|
1739
|
-
await publish({ type: "diagnostic", stage: "steering_confirmed", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
1740
|
-
} catch (error) {
|
|
1741
|
-
await publish({ type: "diagnostic", stage: "steering_failed", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
1742
|
-
throw error;
|
|
1743
|
-
}
|
|
1744
|
-
finally { clearTimeout(timer); steering = null; }
|
|
1716
|
+
await publish({ type: "diagnostic", stage: "steering_queued", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
1745
1717
|
},
|
|
1746
1718
|
async cancel(client, stopProcess) {
|
|
1747
1719
|
try {
|
|
@@ -10,7 +10,7 @@ const NATIVE_AI_PROVIDERS = Object.freeze([
|
|
|
10
10
|
claudeAutoCompactWindow: 786432, webSearch: false,
|
|
11
11
|
models: Object.freeze([
|
|
12
12
|
model("deepseek-flash", 1048576, ["low", "high", "max"], { images: true, freeformPatch: true, codexHistoryRouting: true }),
|
|
13
|
-
model("deepseek-v4-pro", 1048576, ["low", "high", "max"], { freeformPatch: true })
|
|
13
|
+
model("deepseek-v4-pro", 1048576, ["low", "high", "max"], { freeformPatch: true, codexHistoryRouting: true })
|
|
14
14
|
])
|
|
15
15
|
}),
|
|
16
16
|
Object.freeze({
|
|
@@ -472,7 +472,7 @@ test("Claude reports common work and compaction phases and clears phase after se
|
|
|
472
472
|
assert.deepEqual(phases, ["preparing", "working", "compacting", "working", ""]);
|
|
473
473
|
});
|
|
474
474
|
|
|
475
|
-
test("Claude steering
|
|
475
|
+
test("Claude steering queues a distinct message on the running process without interrupting it", { timeout: 30_000 }, async t => {
|
|
476
476
|
const f = await fixture(t);
|
|
477
477
|
const started = Promise.withResolvers();
|
|
478
478
|
const events = [];
|
|
@@ -493,7 +493,7 @@ test("Claude steering interrupts generation and admits a distinct message withou
|
|
|
493
493
|
const trace = await f.trace();
|
|
494
494
|
assert.equal(trace.filter(row => row.args?.includes("--print")).length, 1);
|
|
495
495
|
assert.equal(trace.filter(row => row.frame?.type === "user").length, 2);
|
|
496
|
-
assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length,
|
|
496
|
+
assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length, 0, "Steering queues behind running work and never interrupts it");
|
|
497
497
|
assert.equal((await f.conversation.send(steering)).duplicate, true);
|
|
498
498
|
for (const turn of state.conversationLog) assert.equal(turn.assistant.messageId,
|
|
499
499
|
events.find(event => event.role === "assistant" && event.status === "complete" && event.turnId === turn.turnId).messageId);
|
|
@@ -1193,7 +1193,7 @@ test("Claude accepts a goal during an active turn through the existing steering
|
|
|
1193
1193
|
assert.equal(trace.filter(row => row.args?.includes("--print")).length, 1);
|
|
1194
1194
|
assert.deepEqual(trace.filter(row => row.frame?.type === "user").map(row => row.frame.message.content),
|
|
1195
1195
|
["wait", "/goal Finish the current work"]);
|
|
1196
|
-
assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length,
|
|
1196
|
+
assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length, 0, "Steering queues behind running work and never interrupts it");
|
|
1197
1197
|
});
|
|
1198
1198
|
|
|
1199
1199
|
test("Claude rejects reserved goals and waits for ordinary Send to deliver a replacement briefing", async t => {
|
|
@@ -2290,7 +2290,7 @@ test("completed Claude native-tool tracking preserves ordinary native steering i
|
|
|
2290
2290
|
const trace = await f.trace();
|
|
2291
2291
|
assert.equal(trace.filter(row => row.args?.includes("--print")).length, 1);
|
|
2292
2292
|
assert.equal(trace.filter(row => row.frame?.type === "user").length, 2);
|
|
2293
|
-
assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length,
|
|
2293
|
+
assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length, 0, "Steering queues behind running work and never interrupts it");
|
|
2294
2294
|
assert.equal(f.effects(), 0);
|
|
2295
2295
|
} finally { await f.close(); }
|
|
2296
2296
|
});
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -163,30 +163,64 @@ test("a pipe write is not admission; queued and replayed acknowledgements persis
|
|
|
163
163
|
turn.stop();
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
-
test(
|
|
166
|
+
for (const lifecycle of [true, false]) test(`steering queues behind the running tool without an interrupt and one joined result completes both inputs, lifecycle=${lifecycle}`, async () => {
|
|
167
167
|
const { turn, send, client, events } = fixture();
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
168
|
+
const first = claudeNativeMessageId("first");
|
|
169
|
+
const followUp = claudeNativeMessageId("follow-up");
|
|
170
|
+
const writes = [];
|
|
171
|
+
let interrupts = 0;
|
|
172
|
+
client.interrupt = async () => { interrupts++; };
|
|
173
|
+
// Claude Code 2.1.307 acknowledges streamed input as queued at once and
|
|
174
|
+
// replays it only when its loop reads it; older CLIs only replay it.
|
|
175
|
+
client.send = async (_message, input) => {
|
|
176
|
+
writes.push(input);
|
|
177
|
+
if (lifecycle) await turn.receive({ type: "command_lifecycle", command_uuid: input.messageId, state: "queued" });
|
|
178
|
+
};
|
|
179
|
+
const sending = send();
|
|
180
|
+
if (lifecycle) await turn.receive({ type: "command_lifecycle", command_uuid: first, state: "started" });
|
|
181
|
+
await turn.receive({ type: "user", uuid: first });
|
|
182
|
+
await sending;
|
|
183
|
+
await turn.receive({ type: "assistant", uuid: "tool-call", message: { id: "tool-api", stop_reason: "tool_use",
|
|
184
|
+
content: [{ type: "tool_use", id: "deploy", name: "Bash", input: { command: "eb deploy" } }] } });
|
|
185
|
+
await turn.prepareSteering();
|
|
186
|
+
// Without lifecycle frames the replay is the only receipt, so it waits for the tool.
|
|
187
|
+
const steering = send("follow-up");
|
|
188
|
+
if (lifecycle) await steering;
|
|
189
|
+
assert.equal(interrupts, 0, "A mid-turn message never interrupts the running tool");
|
|
190
|
+
assert.deepEqual(writes.map(input => Object.keys(input).sort()), [["messageId", "sessionId"], ["messageId", "sessionId"]],
|
|
191
|
+
"The follow-up is a plain user message, never Send now");
|
|
192
|
+
assert.equal(turn.read().active, true);
|
|
193
|
+
// Captured order: the tool finishes, Claude reads the queued input inside
|
|
194
|
+
// the same loop, answers both, then completes the queued and original input.
|
|
195
|
+
await turn.receive({ type: "user", uuid: "tool-result", message: { content: [{ type: "tool_result", tool_use_id: "deploy", content: "Deployed" }] } });
|
|
196
|
+
await turn.receive({ type: "user", uuid: followUp });
|
|
174
197
|
await steering;
|
|
175
|
-
|
|
176
|
-
await
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
198
|
+
if (lifecycle) await turn.receive({ type: "command_lifecycle", command_uuid: followUp, state: "started" });
|
|
199
|
+
await turn.receive({ type: "assistant", uuid: "answer", message: { id: "answer-api", stop_reason: "end_turn",
|
|
200
|
+
content: [{ type: "text", text: "Deployed; still on track." }] } });
|
|
201
|
+
if (lifecycle) {
|
|
202
|
+
await turn.receive({ type: "command_lifecycle", command_uuid: followUp, state: "completed" });
|
|
203
|
+
assert.equal(turn.read().active, true, "The queued input's completion cannot settle the running loop");
|
|
204
|
+
}
|
|
205
|
+
await turn.receive({ type: "result", subtype: "success", terminal_reason: "completed", result: "Deployed; still on track." });
|
|
206
|
+
if (lifecycle) await turn.receive({ type: "command_lifecycle", command_uuid: first, state: "completed" });
|
|
207
|
+
assert.deepEqual([turn.read().active, turn.read().status, turn.read().text, turn.read().pendingCommands],
|
|
208
|
+
[false, "completed", "Deployed; still on track.", 0]);
|
|
209
|
+
assert.equal(events.filter(event => event.type === "settled").length, 1);
|
|
210
|
+
const answer = events.findLast(event => event.type === "message" && event.message.complete && event.message.role === "assistant");
|
|
211
|
+
assert.equal(answer.message.userId, followUp, "The joined answer belongs to the newest accepted input");
|
|
212
|
+
assert.equal(interrupts, 0);
|
|
180
213
|
});
|
|
181
214
|
|
|
182
|
-
test("
|
|
215
|
+
test("Claude output before a queued follow-up keeps its input identity after the follow-up is accepted", async t => {
|
|
183
216
|
const f = await publishedClaudeFixture(t);
|
|
184
217
|
await f.owner.send(f.entry, { messageId: "first", message: "First request" });
|
|
185
218
|
const native = f.processes.at(-1);
|
|
186
219
|
await native.options.onEvent({ type: "stream_event", event: { type: "message_start", message: { id: "first-api" } } });
|
|
187
220
|
await native.options.onEvent({ type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } });
|
|
188
221
|
await native.options.onEvent({ type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { text: "FIRST-PARTIAL" } } });
|
|
189
|
-
await native.options.onEvent({ type: "assistant", uuid: "first-output", message: { id: "first-api", content: [{ type: "text", text: "FIRST-PARTIAL" }] } });
|
|
222
|
+
await native.options.onEvent({ type: "assistant", uuid: "first-output", message: { id: "first-api", stop_reason: "tool_use", content: [{ type: "text", text: "FIRST-PARTIAL" }] } });
|
|
223
|
+
native.client.interrupt = async () => assert.fail("A follow-up is queued, never an interrupt");
|
|
190
224
|
await f.owner.send(f.entry, { messageId: "second", message: "Second request" });
|
|
191
225
|
await native.options.onEvent({ type: "assistant", uuid: "second-output", message: { id: "second-api", content: [{ type: "text", text: "SECOND-OK" }], stop_reason: "end_turn" } });
|
|
192
226
|
await native.options.onEvent({ type: "result", subtype: "success", result: "SECOND-OK" });
|
|
@@ -337,22 +371,34 @@ for (const background of [false, true]) test(`folded native command cannot stran
|
|
|
337
371
|
assert.equal(events.filter(event => event.type === "settled").length, 1);
|
|
338
372
|
});
|
|
339
373
|
|
|
340
|
-
test("
|
|
341
|
-
const { turn, send, client } = fixture();
|
|
374
|
+
test("a steer queued during the final reply runs next; neither that result nor folded work settles it", async () => {
|
|
375
|
+
const { turn, send, client, events } = fixture();
|
|
376
|
+
client.interrupt = async () => assert.fail("A mid-turn message never interrupts");
|
|
377
|
+
client.send = async (_message, input) => {
|
|
378
|
+
await turn.receive({ type: "command_lifecycle", command_uuid: input.messageId, state: "queued" });
|
|
379
|
+
};
|
|
380
|
+
const first = claudeNativeMessageId("first");
|
|
381
|
+
const goal = claudeNativeMessageId("goal");
|
|
342
382
|
await send();
|
|
383
|
+
await turn.receive({ type: "command_lifecycle", command_uuid: first, state: "started" });
|
|
384
|
+
await turn.receive({ type: "user", uuid: first });
|
|
343
385
|
await turn.receive({ type: "command_lifecycle", command_uuid: "internal-notification", state: "started" });
|
|
344
|
-
let confirmed = false;
|
|
345
|
-
const steering = turn.interruptGeneration(client).then(() => { confirmed = true; });
|
|
346
386
|
await turn.receive({ type: "command_lifecycle", command_uuid: "internal-notification", state: "completed" });
|
|
347
|
-
await
|
|
348
|
-
|
|
349
|
-
await turn.receive({ type: "result", subtype: "success", terminal_reason: "aborted_streaming", result: "" });
|
|
350
|
-
await steering;
|
|
387
|
+
await turn.receive({ type: "assistant", uuid: "story", message: { id: "story-api", stop_reason: "end_turn", content: [{ type: "text", text: "Story" }] } });
|
|
388
|
+
await turn.prepareSteering();
|
|
351
389
|
await send("goal");
|
|
352
|
-
|
|
353
|
-
await turn.receive({ type: "
|
|
390
|
+
// Captured 2.1.307 order when input arrives after the last model request.
|
|
391
|
+
await turn.receive({ type: "result", subtype: "success", terminal_reason: "completed", result: "Story" });
|
|
392
|
+
await turn.receive({ type: "command_lifecycle", command_uuid: first, state: "completed" });
|
|
393
|
+
assert.equal(turn.read().active, true, "The queued input still needs its own run");
|
|
394
|
+
await turn.receive({ type: "command_lifecycle", command_uuid: goal, state: "started" });
|
|
395
|
+
await turn.receive({ type: "user", uuid: goal });
|
|
396
|
+
await turn.receive({ type: "assistant", uuid: "goal-answer", message: { id: "goal-api", stop_reason: "end_turn", content: [{ type: "text", text: "Goal accepted" }] } });
|
|
397
|
+
await turn.receive({ type: "result", subtype: "success", terminal_reason: "completed", result: "Goal accepted" });
|
|
398
|
+
await turn.receive({ type: "command_lifecycle", command_uuid: goal, state: "completed" });
|
|
354
399
|
assert.equal(turn.read().pendingCommands, 0);
|
|
355
|
-
assert.
|
|
400
|
+
assert.deepEqual([turn.read().status, turn.read().text], ["completed", "Goal accepted"]);
|
|
401
|
+
assert.equal(events.filter(event => event.type === "settled").length, 1);
|
|
356
402
|
});
|
|
357
403
|
|
|
358
404
|
test("late input completion cannot settle a newer native continuation", async () => {
|
|
@@ -451,7 +497,7 @@ test("Claude can steer background-only work without waiting for an already compl
|
|
|
451
497
|
await turn.receive({ type: "system", subtype: "task_started", task_id: "build", task_type: "local_bash" });
|
|
452
498
|
await turn.receive({ type: "result", subtype: "success", result: "Build started" });
|
|
453
499
|
client.interrupt = () => assert.fail("There is no active generation to interrupt");
|
|
454
|
-
await turn.
|
|
500
|
+
await turn.prepareSteering();
|
|
455
501
|
await send("follow-up");
|
|
456
502
|
await turn.receive({ type: "system", subtype: "task_notification", task_id: "build", status: "completed" });
|
|
457
503
|
assert.equal(turn.read().active, true, "The new human input still needs its own result");
|
|
@@ -483,7 +529,7 @@ test("Claude steering survives background completion while the follow-up is bein
|
|
|
483
529
|
await turn.receive({ type: "system", subtype: "task_started", task_id: "build", task_type: "local_bash" });
|
|
484
530
|
await turn.receive({ type: "result", subtype: "success", result: "Build started" });
|
|
485
531
|
client.interrupt = () => assert.fail("The generation has already completed");
|
|
486
|
-
await turn.
|
|
532
|
+
await turn.prepareSteering();
|
|
487
533
|
await turn.receive({ type: "system", subtype: "task_notification", task_id: "build", status: "completed" });
|
|
488
534
|
assert.equal(events.some(event => event.type === "settled"), false, "The reserved steering input still needs admission");
|
|
489
535
|
await send("follow-up");
|
|
@@ -1504,24 +1550,20 @@ for (const tools of [true, false]) test(`Claude supplied common runtime checkpoi
|
|
|
1504
1550
|
if (tools) f.admissions.at(-1).assertCompletionCurrent();
|
|
1505
1551
|
});
|
|
1506
1552
|
|
|
1507
|
-
for (const
|
|
1553
|
+
for (const worker of [false, true]) test(`Claude supplied common completion joins accepted steering and checkpoints only its own final, worker=${worker}`, { timeout: 10_000 }, async t => {
|
|
1508
1554
|
const f = await suppliedCommonFixture(t);
|
|
1509
1555
|
await sendCommon(f, "common-first");
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
await f.frame({ type: "command_lifecycle", command_uuid: input.messageId, state: "started" });
|
|
1522
|
-
await send(message, input);
|
|
1523
|
-
};
|
|
1524
|
-
}
|
|
1556
|
+
const native = f.entry.process;
|
|
1557
|
+
const send = native.client.send;
|
|
1558
|
+
native.client.supportsSendNow = () => true;
|
|
1559
|
+
native.client.interrupt = async () => { throw Error("Steering must not interrupt running tools or workers"); };
|
|
1560
|
+
if (worker) await f.frame({ type: "system", subtype: "task_started", task_id: "common-worker" });
|
|
1561
|
+
native.client.send = async (message, input) => {
|
|
1562
|
+
assert.equal(input.sendNow, undefined, "Steering is a plain queued user message");
|
|
1563
|
+
await f.frame({ type: "command_lifecycle", command_uuid: input.messageId, state: "queued" });
|
|
1564
|
+
await f.frame({ type: "command_lifecycle", command_uuid: input.messageId, state: "started" });
|
|
1565
|
+
await send(message, input);
|
|
1566
|
+
};
|
|
1525
1567
|
await f.loadContract("first-contract");
|
|
1526
1568
|
const first = f.admissions.at(-1);
|
|
1527
1569
|
const legacyCompletion = f.entry.completion.promise;
|
|
@@ -1535,7 +1577,7 @@ for (const sendNow of [false, true]) test(`Claude supplied common completion joi
|
|
|
1535
1577
|
await f.loadContract("successor-contract");
|
|
1536
1578
|
assert.throws(() => first.assertCompletionCurrent(), { code: "conversation_tool_request_retired" });
|
|
1537
1579
|
await f.frame({ type: "result", subtype: "success", result: "The successor question", uuid: "successor-final" });
|
|
1538
|
-
if (
|
|
1580
|
+
if (worker) {
|
|
1539
1581
|
assert.equal(f.checkpoints.length, 0, "Foreground completion cannot checkpoint live background work");
|
|
1540
1582
|
await f.frame({ type: "system", subtype: "task_notification", task_id: "common-worker", status: "completed" });
|
|
1541
1583
|
assert.equal(f.checkpoints.length, 0);
|
|
@@ -1758,25 +1800,18 @@ test("only natural completion marks a rejected steer eligible for ordinary Send;
|
|
|
1758
1800
|
}
|
|
1759
1801
|
});
|
|
1760
1802
|
|
|
1761
|
-
test("
|
|
1762
|
-
for (const ending of ["complete", "stop", "cancel", "idle"
|
|
1803
|
+
test("a late steer distinguishes completed work from Stop, cancellation and idle state", async () => {
|
|
1804
|
+
for (const ending of ["complete", "stop", "cancel", "idle"]) {
|
|
1763
1805
|
const f = fixture();
|
|
1764
1806
|
await f.send();
|
|
1765
|
-
let firstInterrupt;
|
|
1766
1807
|
if (ending === "complete" || ending === "idle") {
|
|
1767
1808
|
await f.turn.receive({ type: "result", subtype: "success", result: "Done" });
|
|
1768
1809
|
if (ending === "idle") f.turn.reset();
|
|
1769
1810
|
} else if (ending === "stop") f.turn.stop();
|
|
1770
|
-
else
|
|
1771
|
-
else {
|
|
1772
|
-
const began = Promise.withResolvers();
|
|
1773
|
-
f.client.interrupt = async () => { began.resolve(); };
|
|
1774
|
-
firstInterrupt = f.turn.interruptGeneration(f.client).catch(() => {});
|
|
1775
|
-
await began.promise;
|
|
1776
|
-
}
|
|
1811
|
+
else await f.turn.interrupt(f.client);
|
|
1777
1812
|
let interrupts = 0;
|
|
1778
1813
|
f.client.interrupt = async () => { interrupts += 1; };
|
|
1779
|
-
await assert.rejects(f.turn.
|
|
1814
|
+
await assert.rejects(f.turn.prepareSteering(), error => {
|
|
1780
1815
|
assert.equal(error.delivery, "not-sent");
|
|
1781
1816
|
assert.equal(error.code, ending === "complete" ? "conversation_steer_turn_finished" : "assistant_claude_turn_failed");
|
|
1782
1817
|
assert.equal(error.statusCode, ending === "complete" ? 409 : undefined);
|
|
@@ -1784,10 +1819,23 @@ test("interruption before a late steer distinguishes completed work from Stop, c
|
|
|
1784
1819
|
});
|
|
1785
1820
|
assert.equal(interrupts, 0);
|
|
1786
1821
|
f.turn.stop();
|
|
1787
|
-
await firstInterrupt;
|
|
1788
1822
|
}
|
|
1789
1823
|
});
|
|
1790
1824
|
|
|
1825
|
+
test("consecutive steers queue together without waiting for or sending an interrupt", async () => {
|
|
1826
|
+
const { turn, send, client, events } = fixture();
|
|
1827
|
+
client.interrupt = async () => assert.fail("Steering never interrupts");
|
|
1828
|
+
await send();
|
|
1829
|
+
for (const id of ["second", "third"]) {
|
|
1830
|
+
await turn.prepareSteering();
|
|
1831
|
+
await send(id);
|
|
1832
|
+
}
|
|
1833
|
+
assert.equal(turn.read().pendingCommands, 3);
|
|
1834
|
+
await turn.receive({ type: "result", subtype: "success", result: "All three handled" });
|
|
1835
|
+
assert.deepEqual([turn.read().status, turn.read().pendingCommands], ["completed", 0]);
|
|
1836
|
+
assert.deepEqual(events.filter(event => event.type === "diagnostic" && event.stage === "steering_queued").length, 2);
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1791
1839
|
test("folded internal command preserves the original published answer owner", async t => {
|
|
1792
1840
|
const f = await publishedClaudeFixture(t);
|
|
1793
1841
|
await f.send();
|
|
@@ -1800,28 +1848,21 @@ test("folded internal command preserves the original published answer owner", as
|
|
|
1800
1848
|
});
|
|
1801
1849
|
|
|
1802
1850
|
|
|
1803
|
-
test("
|
|
1851
|
+
test("a steer never depends on the interrupt control: an unresponsive interrupt cannot fail or stop it", async t => {
|
|
1804
1852
|
const f = await publishedClaudeFixture(t);
|
|
1805
1853
|
await f.send("original-input");
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
await
|
|
1810
|
-
|
|
1811
|
-
assert.equal(
|
|
1812
|
-
assert.equal(f.entry.process,
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
test("interrupt confirmation timeout is a recoverable typed failure, not a generic server error", async () => {
|
|
1816
|
-
const { turn, send, client } = fixture();
|
|
1817
|
-
await send();
|
|
1818
|
-
await assert.rejects(turn.interruptGeneration(client, { timeoutMs: 5 }), error =>
|
|
1819
|
-
error.code === "assistant_claude_interrupt_timeout" && error.statusCode === 503 && /not sent/.test(error.message));
|
|
1820
|
-
turn.stop();
|
|
1854
|
+
const native = f.entry.process;
|
|
1855
|
+
let interrupts = 0;
|
|
1856
|
+
native.client.interrupt = async () => { interrupts++; throw new Error("Interrupt was not acknowledged"); };
|
|
1857
|
+
const receipt = await f.send("steer-input");
|
|
1858
|
+
assert.deepEqual([receipt.delivered, receipt.deliveryMode], [true, "steer"]);
|
|
1859
|
+
assert.equal(interrupts, 0);
|
|
1860
|
+
assert.equal(f.entry.process, native, "Steering keeps the running process and its tools");
|
|
1861
|
+
assert.equal(f.entry.nativeTurn.read().active, true);
|
|
1821
1862
|
});
|
|
1822
1863
|
|
|
1823
1864
|
|
|
1824
|
-
test("failed cleanup after interrupt failure retains the cleanup error and execution custody", async t => {
|
|
1865
|
+
test("failed Stop cleanup after interrupt failure retains the cleanup error and execution custody", async t => {
|
|
1825
1866
|
const f = await publishedClaudeFixture(t);
|
|
1826
1867
|
await f.send("original-input");
|
|
1827
1868
|
const native = f.entry.process;
|
|
@@ -1829,8 +1870,7 @@ test("failed cleanup after interrupt failure retains the cleanup error and execu
|
|
|
1829
1870
|
native.client.interrupt = async () => { throw new Error("Interrupt was not acknowledged"); };
|
|
1830
1871
|
native.stop = async () => ({ scopeEmpty: false, exited: false });
|
|
1831
1872
|
try {
|
|
1832
|
-
await assert.rejects(f.
|
|
1833
|
-
error.delivery !== "not-sent");
|
|
1873
|
+
await assert.rejects(f.owner.interrupt(f.entry), error => error.code === "claude_stop_unconfirmed");
|
|
1834
1874
|
assert.equal(f.entry.process, native);
|
|
1835
1875
|
assert.ok(f.entry.executionId);
|
|
1836
1876
|
} finally { native.stop = originalStop; }
|
|
@@ -2194,7 +2234,7 @@ test("Claude goal command proof survives only evaluations of its own condition",
|
|
|
2194
2234
|
assert.equal(changed.goalCommandId, null, "an old command cannot prove a different goal");
|
|
2195
2235
|
});
|
|
2196
2236
|
|
|
2197
|
-
for (const action of ["steer", "set", "resume"]) test(`Claude ${action}
|
|
2237
|
+
for (const action of ["steer", "set", "resume"]) test(`Claude ${action} queues a plain message without Stop or Send now, preserving the running tool and background workers`, async t => {
|
|
2198
2238
|
const f = await publishedClaudeFixture(t);
|
|
2199
2239
|
if (action === "resume") await f.owner.updateGoal(f.entry, { action: "set", objective: "Finish checks", messageId: "first" });
|
|
2200
2240
|
else await f.send("first");
|
|
@@ -2205,13 +2245,11 @@ for (const action of ["steer", "set", "resume"]) test(`Claude ${action} uses tar
|
|
|
2205
2245
|
let stops = 0;
|
|
2206
2246
|
native.client.interrupt = async () => { stops++; throw Error("A steer must not send Stop"); };
|
|
2207
2247
|
native.client.send = async (message, input) => {
|
|
2208
|
-
assert.equal(input.sendNow,
|
|
2209
|
-
//
|
|
2210
|
-
//
|
|
2248
|
+
assert.equal(input.sendNow, undefined, "An advertised Send now capability is still not used for steering");
|
|
2249
|
+
// Claude 2.1.307 acknowledges the input as queued and reads it after the
|
|
2250
|
+
// running tool call. Its background task remains alive throughout.
|
|
2211
2251
|
await f.frame({ type: "command_lifecycle", command_uuid: input.messageId, state: "queued" });
|
|
2212
|
-
|
|
2213
|
-
terminal_reason: "aborted_tools", user_message_uuid: claudeNativeMessageId("first") });
|
|
2214
|
-
assert.equal(f.entry.nativeTurn.read().active, true, "Old result cannot finish a queued input or its worker");
|
|
2252
|
+
assert.equal(f.entry.nativeTurn.read().active, true, "Queued input keeps the running turn and its worker");
|
|
2215
2253
|
await f.frame({ type: "command_lifecycle", command_uuid: input.messageId, state: "started" });
|
|
2216
2254
|
await send(message, input);
|
|
2217
2255
|
};
|
|
@@ -267,7 +267,7 @@ async function compactedFixture(t, { maxRequestBytes } = {}) {
|
|
|
267
267
|
test("mixed Codex recovery keeps exact text and tool facts without changing native history or current instructions", async (t) => {
|
|
268
268
|
const fixture = await compactedFixture(t);
|
|
269
269
|
const native = await readFile(fixture.historyPath);
|
|
270
|
-
for (const [destination, model] of [["deepseek", "deepseek-flash"], ["zai-coding-plan", "glm-5.3"]]) {
|
|
270
|
+
for (const [destination, model] of [["deepseek", "deepseek-flash"], ["deepseek", "deepseek-v4-pro"], ["zai-coding-plan", "glm-5.3"]]) {
|
|
271
271
|
const current = { type: "message", role: "developer", content: [{ type: "input_text", text: "CURRENT_INSTRUCTIONS" }] };
|
|
272
272
|
const body = { model, input: [current, fixture.compacted, foreign], tools: [{ type: "function", name: "exec_command" }] };
|
|
273
273
|
const original = structuredClone(body);
|
|
@@ -289,7 +289,7 @@ test("mixed Codex recovery keeps exact text and tool facts without changing nati
|
|
|
289
289
|
assert.deepEqual(body, original);
|
|
290
290
|
}
|
|
291
291
|
assert.deepEqual(await readFile(fixture.historyPath), native);
|
|
292
|
-
assert.equal(fixture.calls.length,
|
|
292
|
+
assert.equal(fixture.calls.length, 3, "one upstream request per user request");
|
|
293
293
|
});
|
|
294
294
|
|
|
295
295
|
test("compacted native agent messages retain attribution, readable content and explicit opaque-content markers", async (t) => {
|
|
@@ -358,9 +358,11 @@ test("compacted screenshots stay visible and associated with their original mess
|
|
|
358
358
|
assert.match(content[3].text, /Archived image 2/);
|
|
359
359
|
assert.equal(content.at(-1).text, "[/Archived conversation]");
|
|
360
360
|
assert.deepEqual(await readFile(fixture.historyPath), native);
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
361
|
+
for (const [model, destination] of [["glm-5.3", "zai-coding-plan"], ["deepseek-v4-pro", "deepseek"]]) {
|
|
362
|
+
const unsupported = await fixture.send({ model, input: [fixture.compacted] }, destination);
|
|
363
|
+
assert.equal(unsupported.status, 422);
|
|
364
|
+
assert.match((await unsupported.json()).error.message, /does not support images/);
|
|
365
|
+
}
|
|
364
366
|
assert.equal(fixture.calls.length, 1);
|
|
365
367
|
});
|
|
366
368
|
|