@jskit-ai/assistant-core 0.2.49 → 0.2.50
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/CHANGELOG.md +2 -0
- package/examples/conversation/package.json +3 -3
- package/package.json +1 -1
- package/src/server/conversation/claudeEntryStop.js +84 -0
- package/src/server/conversation/claudeNativeTurn.js +120 -117
- package/src/server/conversation/claudePromptDelivery.js +24 -3
- package/src/server/conversation/claudeTurn.js +20 -2
- package/src/server/conversation/conversationDelivery.js +10 -2
- package/src/server/conversation/providers/claudeDriver.js +1 -1
- package/test/claudeConversationSession.test.js +7 -4
- package/test/claudeTurn.test.js +427 -0
- package/test/fixtures/hostWaits.js +39 -0
package/test/claudeTurn.test.js
CHANGED
|
@@ -6,6 +6,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
6
6
|
import { runClaudeRenewalTurn, waitForClaudeConversationTurn } from "../src/server/conversation/claudeTurn.js";
|
|
7
7
|
import { createClaudeConversationTurn } from "../src/server/conversation/claudeNativeTurn.js";
|
|
8
8
|
import { claudeNativeMessageId, readClaudeHistory } from "../src/server/conversation/claudeHistory.js";
|
|
9
|
+
import { exploreHostWaits } from "./fixtures/hostWaits.js";
|
|
9
10
|
|
|
10
11
|
function fixture() {
|
|
11
12
|
const events = [];
|
|
@@ -694,6 +695,182 @@ test("Claude steering survives background completion while the follow-up is bein
|
|
|
694
695
|
assert.equal(turn.read().text, "Revised answer");
|
|
695
696
|
});
|
|
696
697
|
|
|
698
|
+
// A turn whose host holds one publication, as its storage or checkpoint does,
|
|
699
|
+
// so the test can steer, stop or replace the turn while a frame waits on it.
|
|
700
|
+
function heldPublicationFixture() {
|
|
701
|
+
const events = [];
|
|
702
|
+
let hold = null;
|
|
703
|
+
const turn = createClaudeConversationTurn({ conversationId: "native-session", async onEvent(event) {
|
|
704
|
+
events.push(event);
|
|
705
|
+
if (!hold?.matches(event)) return;
|
|
706
|
+
const current = hold;
|
|
707
|
+
hold = null;
|
|
708
|
+
current.entered.resolve(event);
|
|
709
|
+
await current.release.promise;
|
|
710
|
+
} });
|
|
711
|
+
const client = { async send() {}, async interrupt() {} };
|
|
712
|
+
function holdAt(matches) {
|
|
713
|
+
hold = { matches, entered: Promise.withResolvers(), release: Promise.withResolvers() };
|
|
714
|
+
return hold;
|
|
715
|
+
}
|
|
716
|
+
async function deliver(id, options = {}) {
|
|
717
|
+
const uuid = claudeNativeMessageId(id);
|
|
718
|
+
const sending = turn.send(client, id, { messageId: uuid, accept: async () => {}, ...options });
|
|
719
|
+
await turn.receive({ type: "command_lifecycle", command_uuid: uuid, state: "queued" });
|
|
720
|
+
await sending;
|
|
721
|
+
await turn.receive({ type: "command_lifecycle", command_uuid: uuid, state: "started" });
|
|
722
|
+
}
|
|
723
|
+
turn.begin({ id: "turn-1" });
|
|
724
|
+
return { turn, events, holdAt, deliver };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
test("a steer prepared while a background-only result is being published neither breaks that frame nor settles the turn with it", async () => {
|
|
728
|
+
const f = heldPublicationFixture();
|
|
729
|
+
await f.deliver("first");
|
|
730
|
+
await f.turn.receive({ type: "system", subtype: "task_started", task_id: "build", task_type: "local_bash" });
|
|
731
|
+
const held = f.holdAt(event => event.type === "phase" && event.phase === "background");
|
|
732
|
+
const receiving = f.turn.receive({ type: "result", subtype: "success", result: "Build started" });
|
|
733
|
+
await held.entered.promise;
|
|
734
|
+
// A person's message arrives while the host is still saving the background phase.
|
|
735
|
+
await f.turn.prepareSteering();
|
|
736
|
+
held.release.resolve();
|
|
737
|
+
await receiving;
|
|
738
|
+
assert.deepEqual([f.turn.read().active, f.turn.read().phase], [true, "background"]);
|
|
739
|
+
assert.equal(f.events.some(event => event.type === "settled"), false, "The steer owns this turn's completion now");
|
|
740
|
+
await f.deliver("steer", { steer: true });
|
|
741
|
+
await f.turn.receive({ type: "system", subtype: "task_notification", task_id: "build", status: "completed" });
|
|
742
|
+
assert.equal(f.turn.read().active, true);
|
|
743
|
+
await f.turn.receive({ type: "result", subtype: "success", result: "Steered answer" });
|
|
744
|
+
assert.deepEqual([f.turn.read().active, f.turn.read().status, f.turn.read().text], [false, "completed", "Steered answer"]);
|
|
745
|
+
assert.equal(f.events.filter(event => event.type === "settled").length, 1);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
for (const [name, setup, frame, matches] of [
|
|
749
|
+
["a background task's native event", [], { type: "system", subtype: "task_started", task_id: "late", task_type: "local_bash" },
|
|
750
|
+
event => event.type === "provider-event"],
|
|
751
|
+
["a background-only result's phase", [{ type: "system", subtype: "task_started", task_id: "build", task_type: "local_bash" }],
|
|
752
|
+
{ type: "result", subtype: "success", result: "Build started" }, event => event.type === "phase"],
|
|
753
|
+
["an answer's first block", [], { type: "assistant", uuid: "old-answer", message: { content: [
|
|
754
|
+
{ type: "text", text: "Old one" }, { type: "text", text: "Old two" }] } }, event => event.type === "message"],
|
|
755
|
+
["a final answer's first flushed block", [], { type: "assistant", uuid: "old-final", message: { id: "old-api", stop_reason: "end_turn",
|
|
756
|
+
content: [{ type: "text", text: "Old one" }, { type: "text", text: "Old two" }] } }, event => event.type === "message"]
|
|
757
|
+
]) test(`a frame resuming after Stop and the next turn's start leaves that turn alone: ${name}`, async () => {
|
|
758
|
+
const f = heldPublicationFixture();
|
|
759
|
+
await f.deliver("first");
|
|
760
|
+
for (const earlier of setup) await f.turn.receive(earlier);
|
|
761
|
+
const held = f.holdAt(matches);
|
|
762
|
+
const receiving = f.turn.receive(frame);
|
|
763
|
+
await held.entered.promise;
|
|
764
|
+
// The host stops this turn and begins the next one while the frame waits.
|
|
765
|
+
f.turn.stop("Stopped.");
|
|
766
|
+
f.turn.begin({ id: "turn-2" });
|
|
767
|
+
const begun = f.events.length;
|
|
768
|
+
held.release.resolve();
|
|
769
|
+
await receiving;
|
|
770
|
+
assert.deepEqual(f.events.slice(begun), [], "Nothing of the stopped turn is published into the next one");
|
|
771
|
+
const next = f.turn.read();
|
|
772
|
+
assert.deepEqual([next.turnId, next.active, next.status, next.text, next.phase, next.messages, next.pendingCommands, next.backgroundTasks],
|
|
773
|
+
["turn-2", true, "inProgress", "", "", [], 0, 0]);
|
|
774
|
+
await f.deliver("next");
|
|
775
|
+
await f.turn.receive({ type: "result", subtype: "success", result: "Next answer" });
|
|
776
|
+
assert.deepEqual([f.turn.read().active, f.turn.read().status, f.turn.read().text], [false, "completed", "Next answer"]);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// Claude's frames for a turn that answers at once, and for one that starts a
|
|
780
|
+
// build in the background, answers, and answers again once the build is done.
|
|
781
|
+
const claudeTurnScripts = {
|
|
782
|
+
foreground: first => [
|
|
783
|
+
{ type: "assistant", uuid: "answer", message: { id: "answer-api", stop_reason: "end_turn", content: [{ type: "text", text: "Done" }] } },
|
|
784
|
+
{ type: "result", subtype: "success", result: "Done" },
|
|
785
|
+
{ type: "command_lifecycle", command_uuid: first, state: "completed" }
|
|
786
|
+
],
|
|
787
|
+
background: first => [
|
|
788
|
+
{ type: "system", subtype: "task_started", task_id: "build", task_type: "local_bash" },
|
|
789
|
+
{ type: "assistant", uuid: "started", message: { id: "started-api", stop_reason: "end_turn", content: [{ type: "text", text: "Build started" }] } },
|
|
790
|
+
{ type: "result", subtype: "success", result: "Build started" },
|
|
791
|
+
{ type: "system", subtype: "status", status: null },
|
|
792
|
+
{ type: "system", subtype: "task_notification", task_id: "build", status: "completed" },
|
|
793
|
+
{ type: "user", uuid: "build-notice", origin: { kind: "task-notification" } },
|
|
794
|
+
{ type: "assistant", uuid: "passed", message: { id: "passed-api", stop_reason: "end_turn", content: [{ type: "text", text: "Build passed" }] } },
|
|
795
|
+
{ type: "result", subtype: "success", result: "Build passed" },
|
|
796
|
+
{ type: "command_lifecycle", command_uuid: first, state: "completed" }
|
|
797
|
+
]
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
for (const [script, frames] of Object.entries(claudeTurnScripts)) test(`a steer, Stop, interruption or the next turn inside any of a Claude turn's host waits breaks nothing: ${script}`, async () => {
|
|
801
|
+
const waits = await exploreHostWaits(async ({ intervention, wait }) => {
|
|
802
|
+
const events = [];
|
|
803
|
+
const messages = [];
|
|
804
|
+
const client = { async send() {}, async interrupt() {} };
|
|
805
|
+
let reader = { open: true };
|
|
806
|
+
let superseded = null;
|
|
807
|
+
let steer = null;
|
|
808
|
+
// Only the script's waits are explored; the checks after it make none.
|
|
809
|
+
let exploring = true;
|
|
810
|
+
const turn = createClaudeConversationTurn({ conversationId: "native-session", async onEvent(event) {
|
|
811
|
+
events.push(event);
|
|
812
|
+
if (exploring) await wait(event.type, intervene);
|
|
813
|
+
} });
|
|
814
|
+
function send(id, options = {}) {
|
|
815
|
+
const uuid = claudeNativeMessageId(id);
|
|
816
|
+
const settled = turn.send(client, id, { messageId: uuid, ...options,
|
|
817
|
+
accept: async () => { if (exploring) await wait("accept", intervene); } })
|
|
818
|
+
.then(() => ({ id, delivered: true }), error => ({ id, error }));
|
|
819
|
+
messages.push(settled);
|
|
820
|
+
return { uuid, settled };
|
|
821
|
+
}
|
|
822
|
+
// The process's reader: once the host stops the process, it hands over no more frames.
|
|
823
|
+
const feed = async (frame, from = reader) => { if (from.open) await turn.receive(frame); };
|
|
824
|
+
async function intervene() {
|
|
825
|
+
if (intervention === "steer") {
|
|
826
|
+
try { await turn.prepareSteering(); }
|
|
827
|
+
catch (error) { messages.push(Promise.resolve({ id: "steer", error })); return; }
|
|
828
|
+
steer = send("steer", { steer: true });
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (intervention === "interrupt") await turn.interrupt(client);
|
|
832
|
+
reader.open = false;
|
|
833
|
+
turn.stop("Stopped.");
|
|
834
|
+
if (intervention === "next turn") turn.begin({ id: "turn-2" });
|
|
835
|
+
superseded = events.length;
|
|
836
|
+
}
|
|
837
|
+
turn.begin({ id: "turn-1" });
|
|
838
|
+
const first = send("first");
|
|
839
|
+
await feed({ type: "command_lifecycle", command_uuid: first.uuid, state: "queued" });
|
|
840
|
+
await feed({ type: "command_lifecycle", command_uuid: first.uuid, state: "started" });
|
|
841
|
+
for (const frame of frames(first.uuid)) await feed(frame);
|
|
842
|
+
exploring = false;
|
|
843
|
+
if (superseded !== null) {
|
|
844
|
+
assert.deepEqual(events.slice(superseded), [], "A stopped turn publishes nothing more");
|
|
845
|
+
if (intervention === "next turn") {
|
|
846
|
+
const next = turn.read();
|
|
847
|
+
assert.deepEqual([next.turnId, next.active, next.text, next.phase, next.messages, next.pendingCommands, next.backgroundTasks],
|
|
848
|
+
["turn-2", true, "", "", [], 0, 0], "The next turn starts clean");
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
if (steer) {
|
|
852
|
+
await feed({ type: "command_lifecycle", command_uuid: steer.uuid, state: "queued" });
|
|
853
|
+
await feed({ type: "command_lifecycle", command_uuid: steer.uuid, state: "started" });
|
|
854
|
+
await feed({ type: "result", subtype: "success", result: "Steered answer" });
|
|
855
|
+
assert.deepEqual([turn.read().active, turn.read().status, turn.read().text], [false, "completed", "Steered answer"]);
|
|
856
|
+
}
|
|
857
|
+
// Every message is delivered, or refused with Claude's own reason.
|
|
858
|
+
for (const outcome of await Promise.all(messages)) {
|
|
859
|
+
if (outcome.error) assert.ok(outcome.error.code || outcome.error.delivery, `${outcome.id}: ${outcome.error.stack}`);
|
|
860
|
+
}
|
|
861
|
+
// The next message always works.
|
|
862
|
+
reader = { open: true };
|
|
863
|
+
if (!turn.read().active) turn.begin({ id: "turn-last" });
|
|
864
|
+
const last = send("last");
|
|
865
|
+
await feed({ type: "command_lifecycle", command_uuid: last.uuid, state: "queued" });
|
|
866
|
+
await feed({ type: "command_lifecycle", command_uuid: last.uuid, state: "started" });
|
|
867
|
+
await feed({ type: "result", subtype: "success", result: "Last answer" });
|
|
868
|
+
assert.deepEqual(await last.settled, { id: "last", delivered: true });
|
|
869
|
+
assert.deepEqual([turn.read().active, turn.read().status, turn.read().text], [false, "completed", "Last answer"]);
|
|
870
|
+
}, ["steer", "stop", "interrupt", "next turn"]);
|
|
871
|
+
assert.ok(waits.includes("accept") && waits.includes("settled") && waits.includes(script === "background" ? "phase" : "message"), waits.join());
|
|
872
|
+
});
|
|
873
|
+
|
|
697
874
|
test("lost acknowledgement stays uncertain and stop rejects pending admission", async () => {
|
|
698
875
|
const { turn, client, send } = fixture();
|
|
699
876
|
client.send = async () => {};
|
|
@@ -1045,6 +1222,7 @@ async function publishedClaudeFixture(t, { publication = true, providerId = "ant
|
|
|
1045
1222
|
}
|
|
1046
1223
|
},
|
|
1047
1224
|
async onEvent(entry, event) {
|
|
1225
|
+
await behavior.event?.(entry, event);
|
|
1048
1226
|
if (event.type === "before-state") await beforeState?.(entry, event);
|
|
1049
1227
|
if (event.type === "admit-message" && canonicalAdmission) {
|
|
1050
1228
|
await behavior.beforeAdmit?.();
|
|
@@ -1073,6 +1251,20 @@ async function publishedClaudeFixture(t, { publication = true, providerId = "ant
|
|
|
1073
1251
|
async reopen() { entry = await owner.open(context, { mainId: conversationId }); return entry; } };
|
|
1074
1252
|
}
|
|
1075
1253
|
|
|
1254
|
+
test("a prompt this server never wrote can still arrive only through an earlier server's Claude whose exit is unproven", async t => {
|
|
1255
|
+
const f = await publishedClaudeFixture(t);
|
|
1256
|
+
// After a restart, a message whose delivery was never settled was written,
|
|
1257
|
+
// if at all, by the earlier server.
|
|
1258
|
+
const inspect = async () => (await f.owner.inspectAdmission(f.entry, { messageId: "from-earlier-server" })).admission;
|
|
1259
|
+
f.entry.executionId = "execution-of-earlier-server";
|
|
1260
|
+
assert.equal(await inspect(), "unknown", "The earlier server's Claude may still record it");
|
|
1261
|
+
f.entry.executionId = "";
|
|
1262
|
+
// The Claude this server starts (a context read, say) never received it.
|
|
1263
|
+
await f.owner.ensureProcess(f.entry);
|
|
1264
|
+
assert.ok(f.entry.process);
|
|
1265
|
+
assert.equal(await inspect(), "not-admitted");
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1076
1268
|
test("Claude proves a prompt was not admitted only once no process can still record it", async t => {
|
|
1077
1269
|
const f = await publishedClaudeFixture(t);
|
|
1078
1270
|
const { inspectPersistentConversationAdmission } = await import("../src/server/conversation/providers/scoped.js");
|
|
@@ -1091,6 +1283,162 @@ test("Claude proves a prompt was not admitted only once no process can still rec
|
|
|
1091
1283
|
assert.equal((await persistent()).admission, "not-admitted");
|
|
1092
1284
|
});
|
|
1093
1285
|
|
|
1286
|
+
test("Claude's background phase saved for a stopped turn is not published into the next turn", async t => {
|
|
1287
|
+
const f = await publishedClaudeFixture(t);
|
|
1288
|
+
await f.send("first");
|
|
1289
|
+
await f.frame({ type: "system", subtype: "task_started", task_id: "build", task_type: "local_bash" });
|
|
1290
|
+
const entered = Promise.withResolvers();
|
|
1291
|
+
const release = Promise.withResolvers();
|
|
1292
|
+
f.setBeforeState(async (entry, event) => {
|
|
1293
|
+
if (event.state !== "active" || entry.turn.phase !== "background") return;
|
|
1294
|
+
f.setBeforeState(null);
|
|
1295
|
+
entered.resolve();
|
|
1296
|
+
await release.promise;
|
|
1297
|
+
});
|
|
1298
|
+
const receiving = f.frame({ type: "result", subtype: "success", result: "Build started" });
|
|
1299
|
+
await entered.promise;
|
|
1300
|
+
await f.owner.stopConversation(f.entry, "Stopped.");
|
|
1301
|
+
await f.send("next");
|
|
1302
|
+
const published = [];
|
|
1303
|
+
f.behavior.event = (_entry, event) => { published.push(event); };
|
|
1304
|
+
release.resolve();
|
|
1305
|
+
await receiving;
|
|
1306
|
+
assert.deepEqual(published.filter(event => event.type === "phase"), []);
|
|
1307
|
+
assert.deepEqual([f.entry.turn.id, f.entry.turn.active, f.owner.snapshot(f.entry).phase], [claudeNativeMessageId("next"), true, ""]);
|
|
1308
|
+
await f.frame({ type: "result", subtype: "success", result: "Next answer" });
|
|
1309
|
+
assert.equal((await f.entry.completion.promise).text, "Next answer");
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
test("a finished Claude turn's goal refresh cannot complete the next turn", async t => {
|
|
1313
|
+
const f = await publishedClaudeFixture(t);
|
|
1314
|
+
await f.send("first");
|
|
1315
|
+
const entered = Promise.withResolvers();
|
|
1316
|
+
const release = Promise.withResolvers();
|
|
1317
|
+
f.behavior.event = async (_entry, event) => {
|
|
1318
|
+
if (event.type !== "provider-update" || event.change !== "goal") return;
|
|
1319
|
+
f.behavior.event = null;
|
|
1320
|
+
entered.resolve();
|
|
1321
|
+
await release.promise;
|
|
1322
|
+
};
|
|
1323
|
+
const settling = f.frame({ type: "result", subtype: "success", result: "First answer" });
|
|
1324
|
+
await entered.promise;
|
|
1325
|
+
// The person's next message begins the next turn while the host refreshes the goal.
|
|
1326
|
+
await f.send("next");
|
|
1327
|
+
const next = f.entry.completion.promise;
|
|
1328
|
+
release.resolve();
|
|
1329
|
+
await settling;
|
|
1330
|
+
assert.equal(await Promise.race([next.then(() => "completed"), delay(20).then(() => "working")]), "working");
|
|
1331
|
+
await f.frame({ type: "result", subtype: "success", result: "Next answer" });
|
|
1332
|
+
assert.deepEqual([(await next).status, (await next).text], ["completed", "Next answer"]);
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
for (const [script, frames] of Object.entries(claudeTurnScripts)) test(`a person's message or Stop inside any host wait of a Claude conversation leaves every message decided and the next one working: ${script}`, { timeout: 120_000 }, async t => {
|
|
1336
|
+
const waits = await exploreHostWaits(async ({ intervention, wait }) => {
|
|
1337
|
+
const f = await publishedClaudeFixture(t);
|
|
1338
|
+
const undelivered = [];
|
|
1339
|
+
let person = null;
|
|
1340
|
+
let written = null;
|
|
1341
|
+
// What the host hears of the script's turn once Stop has ended it.
|
|
1342
|
+
let afterStop = null;
|
|
1343
|
+
const outcome = (id, sending) => sending.then(() => ({ id, delivered: true }), error => ({ id, error }));
|
|
1344
|
+
// The checks wait for the intervention: a Stop inside the first message's
|
|
1345
|
+
// own wait ends that message's send while the intervention still runs.
|
|
1346
|
+
let intervened = null;
|
|
1347
|
+
function intervene() {
|
|
1348
|
+
intervened = act();
|
|
1349
|
+
return intervened;
|
|
1350
|
+
}
|
|
1351
|
+
async function act() {
|
|
1352
|
+
if (intervention !== "message") {
|
|
1353
|
+
await f.owner.stopConversation(f.entry, "Stopped.");
|
|
1354
|
+
afterStop = [];
|
|
1355
|
+
}
|
|
1356
|
+
if (intervention === "stop") return;
|
|
1357
|
+
// A running Claude acknowledges a message on its reader later, not inside
|
|
1358
|
+
// the write. Without a process the message starts one and is delivered
|
|
1359
|
+
// inside this wait, unless this wait is inside a process start: messages
|
|
1360
|
+
// queue behind a start in progress, so it lands once this wait is over.
|
|
1361
|
+
const starting = await stillPending(f.entry.nativeTurn.whenIdle());
|
|
1362
|
+
if (f.entry.process) written = holdClaudeWrites(f.entry.process);
|
|
1363
|
+
person = outcome("person", f.owner.send(f.entry, { message: "Also check the logs.", messageId: "person" }));
|
|
1364
|
+
if (written) await Promise.race([written, person]);
|
|
1365
|
+
else if (!starting) await person;
|
|
1366
|
+
}
|
|
1367
|
+
f.behavior.event = (_entry, event) => {
|
|
1368
|
+
if (afterStop && (event.type === "provider-event" ? event.event.script : ["message", "message-complete", "phase", "settled"].includes(event.type) ||
|
|
1369
|
+
event.type === "diagnostic" && ["native_result", "background_tasks"].includes(event.stage))) afterStop.push(event);
|
|
1370
|
+
return wait(event.type, intervene);
|
|
1371
|
+
};
|
|
1372
|
+
const first = await outcome("first", f.send("first"));
|
|
1373
|
+
// The script is Claude's answer on the process the first message started;
|
|
1374
|
+
// once that process is stopped its reader hands over no more frames.
|
|
1375
|
+
const [reader] = f.processes;
|
|
1376
|
+
const feed = async frame => { if (reader && f.entry.process === reader) await reader.options.onEvent({ session_id: f.conversationId, ...frame, script: true }); };
|
|
1377
|
+
for (const frame of frames(claudeNativeMessageId("first"))) await feed(frame);
|
|
1378
|
+
await intervened;
|
|
1379
|
+
// Only the script's waits are explored; the checks after it make none.
|
|
1380
|
+
f.behavior.event = null;
|
|
1381
|
+
assert.deepEqual(afterStop || [], [], "A stopped turn publishes nothing more");
|
|
1382
|
+
if (written && f.entry.process === reader) {
|
|
1383
|
+
const uuid = await written;
|
|
1384
|
+
await feed({ type: "command_lifecycle", command_uuid: uuid, state: "queued" });
|
|
1385
|
+
await feed({ type: "command_lifecycle", command_uuid: uuid, state: "started" });
|
|
1386
|
+
await feed({ type: "result", subtype: "success", result: "Logs checked" });
|
|
1387
|
+
}
|
|
1388
|
+
for (const result of [first, ...(person ? [await person] : [])]) {
|
|
1389
|
+
if (!result.error) continue;
|
|
1390
|
+
assert.ok(result.error.code || result.error.delivery, `${result.id}: ${result.error.stack}`);
|
|
1391
|
+
if (result.error.delivery !== "not-sent") undelivered.push(result.id);
|
|
1392
|
+
}
|
|
1393
|
+
// Once its process is stopped, every undelivered message is decided.
|
|
1394
|
+
await f.owner.stopConversation(f.entry, "Checked.");
|
|
1395
|
+
for (const id of undelivered) {
|
|
1396
|
+
assert.notEqual((await f.owner.inspectAdmission(f.entry, { messageId: id })).admission, "unknown", id);
|
|
1397
|
+
}
|
|
1398
|
+
// The next message always works.
|
|
1399
|
+
await f.send("last");
|
|
1400
|
+
await f.frame({ type: "result", subtype: "success", result: "Last answer" });
|
|
1401
|
+
const last = await f.entry.completion.promise;
|
|
1402
|
+
assert.deepEqual([last.status, last.text], ["completed", "Last answer"]);
|
|
1403
|
+
}, ["message", "stop", "stop, then a message"]);
|
|
1404
|
+
assert.ok(waits.includes("admit-message") && waits.includes("before-state") && waits.includes("provider-update"), waits.join());
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test("a message whose turn a Stop and the next message replace while it is prepared is not sent, and leaves the next turn running", async t => {
|
|
1408
|
+
const f = await publishedClaudeFixture(t);
|
|
1409
|
+
let person;
|
|
1410
|
+
f.behavior.event = async (_entry, event) => {
|
|
1411
|
+
if (event.type !== "before-state" || event.state !== "starting") return;
|
|
1412
|
+
// While the host saves the first message's starting turn, the person
|
|
1413
|
+
// presses Stop and sends another message, which starts a new Claude.
|
|
1414
|
+
f.behavior.event = null;
|
|
1415
|
+
await f.owner.stopConversation(f.entry, "Stopped.");
|
|
1416
|
+
person = f.owner.send(f.entry, { message: "Also check the logs.", messageId: "person" });
|
|
1417
|
+
await person;
|
|
1418
|
+
};
|
|
1419
|
+
await assert.rejects(f.send("first"), error => error.code === "conversation_claude_turn_superseded" && error.delivery === "not-sent");
|
|
1420
|
+
assert.equal((await person).delivered, true);
|
|
1421
|
+
assert.deepEqual(f.behavior.nativeInputs, ["Also check the logs."], "The first message is never written to the stopped Claude");
|
|
1422
|
+
assert.equal(f.entry.process, f.processes[1], "The new Claude keeps running");
|
|
1423
|
+
assert.equal(f.entry.turn.active, true);
|
|
1424
|
+
await f.frame({ type: "result", subtype: "success", result: "Logs checked" });
|
|
1425
|
+
assert.deepEqual([(await f.entry.completion.promise).status, (await f.entry.completion.promise).text], ["completed", "Logs checked"]);
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
test("a Stop while the host saves a message's admission leaves that turn stopped and the next message a new turn", async t => {
|
|
1429
|
+
const f = await publishedClaudeFixture(t);
|
|
1430
|
+
f.behavior.event = async (_entry, event) => {
|
|
1431
|
+
if (event.type !== "admit-message") return;
|
|
1432
|
+
f.behavior.event = null;
|
|
1433
|
+
await f.owner.stopConversation(f.entry, "Stopped.");
|
|
1434
|
+
};
|
|
1435
|
+
await assert.rejects(f.send("first"), /Stopped/);
|
|
1436
|
+
assert.deepEqual([f.entry.turn.active, f.entry.turn.state], [false, "interrupted"], "The stopped turn stays stopped");
|
|
1437
|
+
assert.equal((await f.send("next")).deliveryMode, "new_turn");
|
|
1438
|
+
await f.frame({ type: "result", subtype: "success", result: "Next answer" });
|
|
1439
|
+
assert.deepEqual([(await f.entry.completion.promise).status, (await f.entry.completion.promise).text], ["completed", "Next answer"]);
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1094
1442
|
test("Claude status retains active instructions and applies changed guidance once idle", async t => {
|
|
1095
1443
|
const f = await publishedClaudeFixture(t);
|
|
1096
1444
|
await f.send();
|
|
@@ -1954,6 +2302,85 @@ for (const cancelled of [false, true]) test(`Claude supplied native settlement r
|
|
|
1954
2302
|
assert.throws(() => first.assertCompletionCurrent());
|
|
1955
2303
|
});
|
|
1956
2304
|
|
|
2305
|
+
// Claude writes a person's message into its pipe; its acknowledgement and every
|
|
2306
|
+
// later frame arrive in order on the process's reader, never inside the write.
|
|
2307
|
+
// Whether a promise is still pending, read without waiting for it: a settled
|
|
2308
|
+
// promise's reaction runs before the probe's own.
|
|
2309
|
+
async function stillPending(promise) {
|
|
2310
|
+
const settled = Symbol("settled");
|
|
2311
|
+
const probe = Promise.resolve(promise).then(() => settled, () => settled);
|
|
2312
|
+
return await Promise.race([probe, Promise.resolve().then(() => "pending")]) !== settled;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
function holdClaudeWrites(native) {
|
|
2316
|
+
const send = native.client.send;
|
|
2317
|
+
const written = Promise.withResolvers();
|
|
2318
|
+
native.client.send = async (_message, input) => {
|
|
2319
|
+
native.client.send = send;
|
|
2320
|
+
written.resolve(input.messageId);
|
|
2321
|
+
};
|
|
2322
|
+
return written.promise;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
test("a person's message while Claude waits on background tasks is delivered, and so is the next one", { timeout: 10_000 }, async t => {
|
|
2326
|
+
const f = await suppliedCommonFixture(t);
|
|
2327
|
+
await sendCommon(f, "background-first");
|
|
2328
|
+
f.setBeforeState(() => {});
|
|
2329
|
+
await f.frame({ type: "system", subtype: "task_started", task_id: "build", task_type: "local_bash" });
|
|
2330
|
+
const entered = Promise.withResolvers();
|
|
2331
|
+
const release = Promise.withResolvers();
|
|
2332
|
+
f.setBeforeState(async (entry, event) => {
|
|
2333
|
+
if (event.state !== "active" || entry.turn.phase !== "background") return;
|
|
2334
|
+
f.setBeforeState(() => {});
|
|
2335
|
+
entered.resolve();
|
|
2336
|
+
await release.promise;
|
|
2337
|
+
});
|
|
2338
|
+
const written = holdClaudeWrites(f.entry.process);
|
|
2339
|
+
const result = f.frame({ type: "result", subtype: "success", result: "Build started" });
|
|
2340
|
+
await entered.promise;
|
|
2341
|
+
const steering = f.conversation.send({ messageId: "while-waiting", text: "Also check the logs.", steer: true });
|
|
2342
|
+
void steering.catch(() => {});
|
|
2343
|
+
const uuid = await written;
|
|
2344
|
+
release.resolve();
|
|
2345
|
+
await result;
|
|
2346
|
+
await f.frame({ type: "command_lifecycle", command_uuid: uuid, state: "queued" });
|
|
2347
|
+
assert.equal((await steering).status, "accepted");
|
|
2348
|
+
await f.frame({ type: "system", subtype: "task_notification", task_id: "build", status: "completed" });
|
|
2349
|
+
await f.frame({ type: "command_lifecycle", command_uuid: uuid, state: "started" });
|
|
2350
|
+
await f.frame({ type: "result", subtype: "success", result: "Logs checked" });
|
|
2351
|
+
await f.conversation.wait();
|
|
2352
|
+
assert.equal(f.entry.turn.state, "completed");
|
|
2353
|
+
assert.equal((await f.conversation.send({ messageId: "after-background", text: "Next question" })).status, "accepted");
|
|
2354
|
+
});
|
|
2355
|
+
|
|
2356
|
+
for (const [recorded, restarted] of [[false, false], [true, false], [false, true]]) test(`a message lost with its failed Claude process never blocks the next message, recorded=${recorded}${restarted ? ", Claude restarted" : ""}`, { timeout: 10_000 }, async t => {
|
|
2357
|
+
const f = await suppliedCommonFixture(t);
|
|
2358
|
+
await sendCommon(f, "failing-first");
|
|
2359
|
+
f.setBeforeState(() => {});
|
|
2360
|
+
const native = f.entry.process;
|
|
2361
|
+
const written = holdClaudeWrites(native);
|
|
2362
|
+
const steering = f.conversation.send({ messageId: "lost-with-process", text: "Also check the logs.", steer: true });
|
|
2363
|
+
void steering.catch(() => {});
|
|
2364
|
+
const uuid = await written;
|
|
2365
|
+
if (recorded) {
|
|
2366
|
+
// Claude saved the message in its history before its process ended.
|
|
2367
|
+
const directory = path.join(f.context.workdir, "projects", f.context.workdir.replace(/[^a-zA-Z0-9]/gu, "-"));
|
|
2368
|
+
await mkdir(directory, { recursive: true });
|
|
2369
|
+
await appendFile(path.join(directory, `${f.conversationId}.jsonl`),
|
|
2370
|
+
`${JSON.stringify({ type: "user", uuid, message: { content: "Also check the logs." } })}\n`);
|
|
2371
|
+
}
|
|
2372
|
+
// The process's reader reports its failure, as when Claude exits or one of its frames cannot be handled.
|
|
2373
|
+
await native.options.onFailure(new Error("Claude exited unexpectedly."));
|
|
2374
|
+
await assert.rejects(steering);
|
|
2375
|
+
assert.equal(f.entry.process, null);
|
|
2376
|
+
// A new Claude process can never record a prompt written to the one that failed.
|
|
2377
|
+
if (restarted) assert.notEqual(await f.owner.ensureProcess(f.entry), native);
|
|
2378
|
+
assert.equal((await f.conversation.send({ messageId: "after-failure", text: "Next question" })).status, "accepted");
|
|
2379
|
+
const sent = (await f.binding.transcript.readConversationLog()).flatMap(turn => turn.messages).map(message => message.messageId);
|
|
2380
|
+
assert.equal(sent.includes("lost-with-process"), recorded, "Only Claude's own record makes the lost message delivered");
|
|
2381
|
+
assert.ok(sent.includes("after-failure"));
|
|
2382
|
+
});
|
|
2383
|
+
|
|
1957
2384
|
test("Claude supplied common checkpoint failure preserves original refusal and native cleanup", { timeout: 10_000 }, async t => {
|
|
1958
2385
|
const f = await suppliedCommonFixture(t);
|
|
1959
2386
|
await sendCommon(f, "refused-common-question");
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Explores a turn owner's interleavings with the work that can arrive while it
|
|
2
|
+
// waits on its host: a Stop, a person's steer, the next turn. A turn owner's
|
|
3
|
+
// only interleavings are those waits (publishing, saving, admission storage);
|
|
4
|
+
// everything else runs to completion in one step.
|
|
5
|
+
//
|
|
6
|
+
// `scenario({ intervention, wait })` drives one fresh owner through a script and
|
|
7
|
+
// asserts its own invariants. Its host calls `await wait(label, intervene)` at
|
|
8
|
+
// every wait of the script under test, where `intervene` applies `intervention`
|
|
9
|
+
// to that scenario's state; the checks that follow the script make no waits.
|
|
10
|
+
// The explorer runs the script once with no intervention to list its waits,
|
|
11
|
+
// then once per intervention and wait, applying the intervention inside that
|
|
12
|
+
// wait. A run that has not finished within `limitMs` fails as hung. It returns
|
|
13
|
+
// the labels of the waits it explored.
|
|
14
|
+
export async function exploreHostWaits(scenario, interventions, { limitMs = 10_000 } = {}) {
|
|
15
|
+
const waits = [];
|
|
16
|
+
await scenario({ intervention: null, wait: async label => { waits.push(label); } });
|
|
17
|
+
for (const intervention of interventions) {
|
|
18
|
+
for (let index = 0; index < waits.length; index += 1) {
|
|
19
|
+
let seen = 0;
|
|
20
|
+
let timer;
|
|
21
|
+
try {
|
|
22
|
+
await Promise.race([
|
|
23
|
+
scenario({ intervention, async wait(_label, intervene) {
|
|
24
|
+
if (seen++ === index) await intervene();
|
|
25
|
+
} }),
|
|
26
|
+
new Promise((_resolve, reject) => {
|
|
27
|
+
timer = setTimeout(() => reject(new Error(`hung for ${limitMs} ms`)), limitMs);
|
|
28
|
+
})
|
|
29
|
+
]);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
error.message = `${intervention} inside wait ${index} (${waits[index]}): ${error.message}`;
|
|
32
|
+
throw error;
|
|
33
|
+
} finally {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return waits;
|
|
39
|
+
}
|