@jskit-ai/assistant-core 0.2.49 → 0.2.51
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 +3 -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/src/server/conversation/providers/opencodeDriver.js +2 -1
- package/test/claudeConversationSession.test.js +7 -4
- package/test/claudeTurn.test.js +427 -0
- package/test/fixtures/hostWaits.js +39 -0
- package/test/opencodeConversationSession.test.js +66 -1
|
@@ -3,6 +3,15 @@ import { claudeNativeMessageId } from "./claudeHistory.js";
|
|
|
3
3
|
import { normalizeNullableText } from "./normalize.js";
|
|
4
4
|
import { ignorePromptSending, ignoreReceipt } from "./nativeCommandCallbacks.js";
|
|
5
5
|
|
|
6
|
+
// The process each recent prompt was written to. Only that process, while it
|
|
7
|
+
// lives, can still record a prompt its history lacks (see promptCanStillArrive).
|
|
8
|
+
function rememberPromptProcess(entry, uuid) {
|
|
9
|
+
entry.promptProcesses ||= new Map();
|
|
10
|
+
entry.promptProcesses.delete(uuid);
|
|
11
|
+
entry.promptProcesses.set(uuid, { process: entry.process, executionId: entry.executionId });
|
|
12
|
+
if (entry.promptProcesses.size > 64) entry.promptProcesses.delete(entry.promptProcesses.keys().next().value);
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
/**
|
|
7
16
|
* Delivers an authored input to an entry's Claude process: duplicate checks,
|
|
8
17
|
* preparation, steering, the new turn, and the native and persisted receipts.
|
|
@@ -78,6 +87,9 @@ export function createClaudePromptDelivery({
|
|
|
78
87
|
if (input.signal?.aborted) {
|
|
79
88
|
throw Object.assign(createError("The goal command was stopped before delivery.", { code: "conversation_claude_command_stopped" }), { delivery: "not-sent" });
|
|
80
89
|
}
|
|
90
|
+
// The turn this input is for. A Stop or the next turn that replaces it while
|
|
91
|
+
// the input is prepared leaves the input unsent and that turn untouched.
|
|
92
|
+
let lease;
|
|
81
93
|
if (steering) {
|
|
82
94
|
if (command) {
|
|
83
95
|
await command.beforeDispatch({ threadId: entry.id });
|
|
@@ -85,7 +97,7 @@ export function createClaudePromptDelivery({
|
|
|
85
97
|
}
|
|
86
98
|
// A mid-turn message is never an interrupt: Claude Code queues it and
|
|
87
99
|
// reads it after the running tool call. Only explicit Stop interrupts.
|
|
88
|
-
try { await entry.nativeTurn.prepareSteering(); }
|
|
100
|
+
try { lease = await entry.nativeTurn.prepareSteering(); }
|
|
89
101
|
catch (error) {
|
|
90
102
|
if (!command) await entry.nativeTurn.stopProcess(error.message);
|
|
91
103
|
// This precedes the input's pipe write: a refused steer is definitely not sent.
|
|
@@ -100,7 +112,7 @@ export function createClaudePromptDelivery({
|
|
|
100
112
|
}
|
|
101
113
|
if (!steering) {
|
|
102
114
|
entry.renewal = renewal;
|
|
103
|
-
entry.nativeTurn.begin({ id: uuid, maxOutputCharacters: command ? command.maximumOutput : entry.profile?.limits.maxOutputCharacters });
|
|
115
|
+
lease = entry.nativeTurn.begin({ id: uuid, maxOutputCharacters: command ? command.maximumOutput : entry.profile?.limits.maxOutputCharacters });
|
|
104
116
|
entry.observationError = "";
|
|
105
117
|
entry.turn = { id: uuid, active: true, state: "starting", startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), error: "" };
|
|
106
118
|
if (!command) entry.completion = Promise.withResolvers();
|
|
@@ -135,6 +147,9 @@ export function createClaudePromptDelivery({
|
|
|
135
147
|
if (admission) conversationTurn = await admission;
|
|
136
148
|
deliveryTiming?.("receipt-written");
|
|
137
149
|
}
|
|
150
|
+
// A Stop or the next turn while the host saved this admission has already
|
|
151
|
+
// refused it: the turn's state now belongs to what replaced it.
|
|
152
|
+
if (!entry.nativeTurn.holds(lease)) return;
|
|
138
153
|
if (!command) {
|
|
139
154
|
await entry.nativeTurn.updateState("active");
|
|
140
155
|
deliveryTiming?.("state-active");
|
|
@@ -170,10 +185,16 @@ export function createClaudePromptDelivery({
|
|
|
170
185
|
if (input.signal?.aborted) {
|
|
171
186
|
throw Object.assign(createError("The goal command was stopped before delivery.", { code: "conversation_claude_command_stopped" }), { delivery: "not-sent" });
|
|
172
187
|
}
|
|
188
|
+
if (!entry.nativeTurn.holds(lease)) {
|
|
189
|
+
throw Object.assign(createError("A Stop or another message replaced this message's turn before it was sent.",
|
|
190
|
+
{ code: "conversation_claude_turn_superseded" }), { delivery: "not-sent" });
|
|
191
|
+
}
|
|
192
|
+
rememberPromptProcess(entry, uuid);
|
|
173
193
|
await entry.nativeTurn.send(command ? entry.process.client : native.client, command ? preparation.message(entry, input) : prompt,
|
|
174
194
|
{ messageId: uuid, accept, steer: steering, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
|
|
175
195
|
} catch (error) {
|
|
176
|
-
|
|
196
|
+
// Only this input's own turn is cleaned up; one that replaced it is not.
|
|
197
|
+
if (!command && entry.nativeTurn.holds(lease)) await entry.nativeTurn.stopProcess(error.message);
|
|
177
198
|
throw error;
|
|
178
199
|
}
|
|
179
200
|
return deliveryResult();
|
|
@@ -19,6 +19,23 @@ function nativeWorkSettled(entry) {
|
|
|
19
19
|
return !entry.sending && !entry.process && !entry.executionId && !entry.command && !entry.stopPending;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Whether Claude can still record a prompt its history lacks. A send or stop in
|
|
23
|
+
// progress can, and so can the process the prompt was written to while that
|
|
24
|
+
// process lives or its exit is unproven: a prompt written to a process since
|
|
25
|
+
// proved stopped never arrives, even once another process runs. This server
|
|
26
|
+
// records each prompt's process just before writing it (the latest 64; Claude
|
|
27
|
+
// reads its input in order), so a prompt without a record was written, if at
|
|
28
|
+
// all, by an earlier server: only that server's execution, while its exit is
|
|
29
|
+
// unproven, can still record it.
|
|
30
|
+
function promptCanStillArrive(entry, uuid) {
|
|
31
|
+
if (nativeWorkSettled(entry)) return false;
|
|
32
|
+
if (entry.sending || entry.stopPending) return true;
|
|
33
|
+
const written = entry.promptProcesses?.get(uuid);
|
|
34
|
+
if (!written) return Boolean(entry.executionId) && !runsNativeWork(entry);
|
|
35
|
+
return Boolean(written.process && written.process === entry.process ||
|
|
36
|
+
written.executionId && written.executionId === entry.executionId);
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
// Work this server is running: a send, command or stop in progress, a live
|
|
23
40
|
// process, or a startup still lending cleanup. A restored execution with none
|
|
24
41
|
// of these belongs to an earlier server; no Claude process outlives the
|
|
@@ -542,7 +559,7 @@ export function createClaudeConversationOwner({
|
|
|
542
559
|
return { ...entry.nativeTurn.readResult(), ok: true,
|
|
543
560
|
messages: [...messages.values()].map(message => ({ ...message,
|
|
544
561
|
...(message.userId ? { requestMessageId: requestIds.get(message.userId) || "" } : {}) })), text: current.text || history.text,
|
|
545
|
-
admitted, notAdmitted: !admitted && Boolean(input.messageId) &&
|
|
562
|
+
admitted, notAdmitted: !admitted && Boolean(input.messageId) && !promptCanStillArrive(entry, claudeNativeMessageId(input.messageId)) };
|
|
546
563
|
}
|
|
547
564
|
|
|
548
565
|
async function inspectAdmission(entry, input) {
|
|
@@ -550,7 +567,7 @@ export function createClaudeConversationOwner({
|
|
|
550
567
|
const history = await readHistory(entry);
|
|
551
568
|
const turnId = claudeNativeMessageId(input.messageId);
|
|
552
569
|
const accepted = history.userIds.includes(turnId);
|
|
553
|
-
const admission = accepted ? "accepted" :
|
|
570
|
+
const admission = accepted ? "accepted" : promptCanStillArrive(entry, turnId) ? "unknown" : "not-admitted";
|
|
554
571
|
return { ok: true, admission, messageId: input.messageId,
|
|
555
572
|
threadId: entry.id, turnId: accepted ? turnId : "" };
|
|
556
573
|
}
|
|
@@ -682,6 +699,7 @@ export function createClaudeConversationOwner({
|
|
|
682
699
|
readFinalAssistantResult,
|
|
683
700
|
readHistory,
|
|
684
701
|
nativeWorkSettled,
|
|
702
|
+
promptCanStillArrive,
|
|
685
703
|
ensureReady,
|
|
686
704
|
ensureProcess,
|
|
687
705
|
readContextUsage,
|
|
@@ -6,6 +6,14 @@ import { createConversationChangeover, conversationNativeMessages } from "./cont
|
|
|
6
6
|
import { deliveryFailure, failure, stateErrorFields } from "./conversationRefusals.js";
|
|
7
7
|
import { pendingSuccessor, retainedSegments, submittedMessage } from "./conversationReads.js";
|
|
8
8
|
|
|
9
|
+
// An engine's receipt for an attempted message: accepted, proved never
|
|
10
|
+
// admitted, or unknown. A proved non-admission settles the attempt; dropping it
|
|
11
|
+
// would leave that one message to refuse every later one as unconfirmed.
|
|
12
|
+
function nativeAdmission(observation) {
|
|
13
|
+
if (observation.accepted) return "accepted";
|
|
14
|
+
return observation.notAdmitted === true || observation.admission === "not-admitted" ? "not-admitted" : "unknown";
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
/** @typedef {import("./conversationEntries.js").ConversationEntry} ConversationEntry */
|
|
10
18
|
/** @typedef {import("./conversationSending.js").ConversationRequest} ConversationRequest */
|
|
11
19
|
|
|
@@ -146,13 +154,13 @@ function createConversationDelivery({ storage, transcript, events, entries, read
|
|
|
146
154
|
if (entry.conversation) {
|
|
147
155
|
observation = await entry.provider.inspectAdmission({ messageId, threadId, context });
|
|
148
156
|
await events.access(context, entry.id, request?.operation || "inspectDelivery", entry);
|
|
149
|
-
return { admission: observation
|
|
157
|
+
return { admission: nativeAdmission(observation) };
|
|
150
158
|
}
|
|
151
159
|
const { runtime } = await storage.read(entry.id, transaction => transaction.readMetadata());
|
|
152
160
|
if (!threadId || threadId !== conversationNativeIdentity(runtime) || runtime.request?.messageId !== messageId) return null;
|
|
153
161
|
observation = await entry.provider.inspectAdmission({ ...runtime.request, context, configuration: runtime.configuration });
|
|
154
162
|
await events.access(context, entry.id, request?.operation || "inspectDelivery", entry);
|
|
155
|
-
return { admission: observation
|
|
163
|
+
return { admission: nativeAdmission(observation) };
|
|
156
164
|
}
|
|
157
165
|
}
|
|
158
166
|
});
|
|
@@ -508,7 +508,7 @@ export function createClaudeConversationDriver({
|
|
|
508
508
|
const history = await owner.readHistory(entry);
|
|
509
509
|
const nativeId = claudeNativeMessageId(input.messageId);
|
|
510
510
|
const accepted = history.userIds.includes(nativeId);
|
|
511
|
-
return { accepted, notAdmitted: !accepted && owner.
|
|
511
|
+
return { accepted, notAdmitted: !accepted && !owner.promptCanStillArrive(entry, nativeId),
|
|
512
512
|
messages: history.messages.filter(message => message.userId === nativeId) };
|
|
513
513
|
},
|
|
514
514
|
steer: command => owner.steer(control, command, commandOptions),
|
|
@@ -273,7 +273,8 @@ export function createOpenCodeConversationDriver({
|
|
|
273
273
|
async inspectAdmission({ context, representation, ...input }) {
|
|
274
274
|
const acquired = await supplied.acquire(context);
|
|
275
275
|
const result = await supplied.owner.inspectPreparedMessage(await supplied.preparation.inspection(input, acquired.options));
|
|
276
|
-
return representation === "native" ? result
|
|
276
|
+
return representation === "native" ? result
|
|
277
|
+
: { accepted: result.admission === "accepted", notAdmitted: result.admission === "not-admitted" };
|
|
277
278
|
},
|
|
278
279
|
async run(command) {
|
|
279
280
|
if (disposed || current) throw Object.assign(new Error("This OpenCode conversation is closed or already working."), {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { mkdir, mkdtemp, rename, rm, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { chmod, mkdir, mkdtemp, rename, rm, readFile, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { createConversationRuntime, createFileConversationStorage, upgradeConversationRuntimeState } from "../src/server/conversation/index.js";
|
|
@@ -818,10 +818,13 @@ test("uncertain native delivery is reconciled from history without a second infe
|
|
|
818
818
|
const binding = (await f.storage.read("conversation", transaction => transaction.readMetadata())).runtime.binding;
|
|
819
819
|
const historyPath = path.join(f.directory, "claude", "projects", f.directory.replace(/[^a-zA-Z0-9]/gu, "-"), `${binding.conversationId}.jsonl`);
|
|
820
820
|
// A production Send checks the previous receipt first. Keep that receipt
|
|
821
|
-
// genuinely unavailable while proving the unknown-delivery refusal
|
|
822
|
-
|
|
821
|
+
// genuinely unavailable while proving the unknown-delivery refusal: Claude's
|
|
822
|
+
// history is there but cannot be read. (An absent history is Claude's proof
|
|
823
|
+
// that it never recorded the prompt, as for the dropped prompt below.)
|
|
824
|
+
const { mode } = await stat(historyPath);
|
|
825
|
+
await chmod(historyPath, 0o000);
|
|
823
826
|
try { await assert.rejects(f.conversation.send(input), { code: "conversation_delivery_uncertain" }); }
|
|
824
|
-
finally { await
|
|
827
|
+
finally { await chmod(historyPath, mode); }
|
|
825
828
|
const receipt = await f.conversation.inspectDelivery({ messageId: "lost-ack" });
|
|
826
829
|
assert.equal(receipt.status, "accepted");
|
|
827
830
|
assert.equal(receipt.recovered, true);
|