@jskit-ai/assistant-core 0.2.50 → 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 CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - A message lost on its way to an OpenCode conversation bound to its host no longer makes every later message fail as unconfirmed (`conversation_delivery_uncertain`, or the host's own changeover code). When the host's prepared-message inspection proves that the prompt never arrived (`admission: "not-admitted"`, for example a prompt absent from complete native history whose receiving server was replaced), the OpenCode driver passes that proof to the runtime as `notAdmitted`, as the Claude driver does, instead of dropping it so that the runtime read it as unknown. `inspectDelivery({ messageId })` then reports the message `not-sent`, the conversation is ready again and the next message is sent; the lost prompt is never sent again. An inspection that answers `unknown` stays unknown.
5
6
  - A Claude conversation keeps working when a Stop, a crashed Claude process or the next message arrives while Claude's previous turn still waits on its host (publishing, saving, storing an admission). The shared Claude turn now gives each native frame a lease on its turn: a Stop, a reset or the next turn's start supersedes it, and a superseded frame ends at that wait before it reads, changes or publishes anything of the turn that replaced it. Such a frame used to fail with "Cannot read properties of null (reading 'status')", publish the old turn's message, phase or diagnostic into the next turn, or complete the next turn with the old turn's result; a finished turn's goal refresh now resolves that turn's own completion. A steer to the same turn still lands, and a frame decides from the state it finds after each wait. A message being prepared for a turn that a Stop or the next message replaces meanwhile is no longer written to the stopped Claude, and its failure no longer stops the Claude that replaced it: it fails as not sent (`conversation_claude_turn_superseded`).
6
7
  - A message lost with a failed Claude process no longer makes every later message fail as unconfirmed (`conversation_delivery_uncertain`, or the host's own changeover code). When the next message checks the earlier attempt, a Claude history without it and no live process that it was written to prove that it never arrived (`not-admitted`), and the conversation runtime now passes that proof to the changeover instead of reading every answer but `accepted` as unknown. A message Claude did record is accepted and never sent again, a prompt whose pipe write failed is reported `delivery: "not-sent"`, and a prompt written to a process still running stays unknown until that process settles it. The server records which process it wrote each prompt to, so a prompt it has no record of was written, if at all, by an earlier server, and only that server's Claude can still record it while its exit is unproven: a conversation already holding such an attempt clears it with its next message.
7
8
  - A Claude goal's running time no longer counts the time a server was stopped mid-turn. When Claude starts again after a request that was cut off, it saves a "No response requested." placeholder just before the goal continues, and the history reader counted the wait up to that placeholder as work: a restart in the middle of a turn added the whole downtime to the goal's clock. The wait before that placeholder now counts like the wait before an authored message; the cut-off request's unsaved part is not counted.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.50",
11
+ "@jskit-ai/assistant-core": "0.2.51",
12
12
  "@jskit-ai/http-runtime": "0.2.21",
13
13
  "@jskit-ai/kernel": "0.2.21",
14
14
  "@jskit-ai/material-web": "0.1.6",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.50",
3
+ "version": "0.2.51",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "jskit test"
@@ -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."), {
@@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import { setTimeout as delay } from "node:timers/promises";
7
- import { createConversationRuntime, createFileConversationStorage } from "../src/server/conversation/index.js";
7
+ import { createConversationRuntime, createConversationTranscript, createFileConversationStorage, createMemoryConversationStorage } from "../src/server/conversation/index.js";
8
8
  import { createLocalConversationExecution } from "../src/server/conversation/localExecution.js";
9
9
  import { createOpenCodeConversationDriver } from "../src/server/conversation/providers/opencodeDriver.js";
10
10
  import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
@@ -686,6 +686,71 @@ test("unavailable or absent native admission remains unknown without replaying t
686
686
  }
687
687
  });
688
688
 
689
+ test("a bound OpenCode message its host proves never arrived is not sent and no longer blocks the next one", async t => {
690
+ for (const admission of ["unknown", "not-admitted"]) {
691
+ const f = await boundMainFixture(t, admission);
692
+ await assert.rejects(f.conversation.send({ messageId: "lost-id", text: "lost" }), /acknowledgement was lost/);
693
+ assert.equal((await f.conversation.read()).status, "unconfirmed");
694
+ if (admission === "unknown") {
695
+ await assert.rejects(f.conversation.inspectDelivery({ messageId: "lost-id" }), { code: "conversation_delivery_uncertain" });
696
+ assert.equal((await f.conversation.read()).status, "unconfirmed");
697
+ await assert.rejects(f.conversation.send({ messageId: "retry", text: "Hello" }), /uncertain delivery/);
698
+ assert.deepEqual(f.dispatched, ["lost-id"]);
699
+ continue;
700
+ }
701
+ const receipt = await f.conversation.inspectDelivery({ messageId: "lost-id" });
702
+ assert.equal(receipt.status, "not-sent");
703
+ assert.equal(receipt.messageId, "lost-id");
704
+ assert.equal((await f.conversation.read()).status, "ready");
705
+ assert.deepEqual(f.dispatched, ["lost-id"], "Inspection never resends the lost prompt");
706
+ await f.conversation.send({ messageId: "retry", text: "Hello" });
707
+ assert.deepEqual(f.dispatched, ["lost-id", "retry"]);
708
+ }
709
+ });
710
+
711
+ // A host-bound OpenCode Main through the common runtime. The host's prepared
712
+ // inspection projects the native observation to `admission`; the stand-in native
713
+ // owner keeps the shared runtime's prepared-message seam, finds the prompt absent
714
+ // from complete history, and records each dispatch in place of /prompt_async.
715
+ async function boundMainFixture(t, admission) {
716
+ const storage = createMemoryConversationStorage();
717
+ const transcript = createConversationTranscript({ storage });
718
+ const threadId = "ses_bound_main";
719
+ const dispatched = [];
720
+ let delivery;
721
+ const owner = { turns: new Map(), processes: new Map(), monitors: new Map(),
722
+ readSessionState: async () => ({ ok: true, thread: { id: threadId } }),
723
+ async sendPreparedMessage(prepared) {
724
+ const { messageId, message } = prepared.input;
725
+ dispatched.push(messageId);
726
+ await prepared.input.onPromptSending({ threadId });
727
+ if (messageId === "lost-id") throw new Error("The OpenCode prompt acknowledgement was lost.");
728
+ return prepared.project({ delivered: true, conversationTurn: await transcript.writeConversationUserMessage("main", { messageId, text: message }) });
729
+ },
730
+ inspectPreparedMessage: async prepared => prepared.project({ accepted: false, messages: [] }),
731
+ interruptPreparedTurn: async () => ({ ok: true }),
732
+ closeSession: async () => ({ ok: true }) };
733
+ const binding = { sessionId: "main", namespace: "bound-main", engine: "opencode", runtime: { store: {} },
734
+ native: { owner, acquire: async context => ({ key: "bound-main", options: context || {} }), preparation: {
735
+ cleanup: () => ({ sessionId: "main", options: {}, application: {} }),
736
+ interruption: async () => ({}),
737
+ message: async input => ({ key: "bound-main", input, project: value => value }),
738
+ inspection: async input => ({ threadId: input.threadId, inputMessageId: `msg_${input.messageId}`,
739
+ project: observation => ({ ok: true, admission: observation?.accepted ? "accepted" : admission,
740
+ messageId: input.messageId, threadId: input.threadId }) })
741
+ } },
742
+ admission() {}, prepareInput: async input => input,
743
+ read: async () => ({ threadId, configuration: { model: "native" }, turn: { active: false }, delivery }),
744
+ readStream: async () => ({ messages: [] }),
745
+ state: { read: async () => delivery, write: async value => { delivery = structuredClone(value); } },
746
+ transcript: { history: async () => [], readConversationLog: () => transcript.readConversationLog("main"),
747
+ hasMessage: id => transcript.conversationMessageIdExists("main", id),
748
+ writeUserMessage: input => transcript.writeConversationUserMessage("main", input) } };
749
+ const runtime = createConversationRuntime({ authorize: () => true, host: { conversation: () => binding } });
750
+ t.after(() => runtime.close());
751
+ return { conversation: await runtime.open({ id: "main", context: { actor: { id: "owner" } } }), dispatched };
752
+ }
753
+
689
754
  test("accepted OpenCode output can be recovered after restart without repeating inference", async t => {
690
755
  const f = await fixture(t);
691
756
  await f.conversation.send(input);