@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.
@@ -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
- if (!command) await entry.nativeTurn.stopProcess(error.message);
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) && nativeWorkSettled(entry) };
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" : nativeWorkSettled(entry) ? "not-admitted" : "unknown";
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.accepted ? "accepted" : "unknown" };
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.accepted ? "accepted" : "unknown" };
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.nativeWorkSettled(entry),
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 : { accepted: result.admission === "accepted" };
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
- await rename(historyPath, `${historyPath}.unavailable`);
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 rename(`${historyPath}.unavailable`, historyPath); }
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);