@jskit-ai/assistant-core 0.2.50 → 0.2.52

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,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - New `latestAssistantQuestionsAwaitingUserReply(conversationLog, { lookback = 10 })` (from `/shared/conversation`) finds the questions the AI is still waiting for an answer to when it added a short message after them, such as "No files have changed. I'll build it once you've answered those three." `latestAssistantMessageAwaitingUserReply` returns only that last message, so a question form fed by it disappeared. The new helper starts where that one does, at the AI's newest completed reply (nothing while it is in progress, nothing after the person's latest message), reads back over that reply and the AI's earlier replies and commentary since the person's latest message, at most `lookback` of them (thinking does not count), and returns the text of the newest one with numbered questions or answer choices, or "". Questions the AI wrote before a tool call are saved as commentary, so they count. `latestAssistantMessageAwaitingUserReply` is unchanged and now reads the same walk.
6
+ - 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
7
  - 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
8
  - 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
9
  - 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.52",
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.52",
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."), {
@@ -1,3 +1,8 @@
1
+ import { parseAnswerChoicePrompt } from "./answerChoiceSugar.js";
2
+ import { parseNumberedQuestionPrompt } from "./numberedQuestionSugar.js";
3
+
4
+ const ASSISTANT_QUESTION_LOOKBACK = 10;
5
+
1
6
  function messageText(value = "") {
2
7
  return String(value || "").trim();
3
8
  }
@@ -10,40 +15,88 @@ function conversationTurns(conversationLog = {}) {
10
15
  return Array.isArray(conversationLog) ? conversationLog : [];
11
16
  }
12
17
 
13
- function latestAssistantMessageAwaitingUserReply(conversationLog = {}) {
18
+ /**
19
+ * The conversation's messages, newest first. A turn without a message list
20
+ * offers its person's message first, then its reply, then its commentary.
21
+ */
22
+ function* messagesNewestFirst(conversationLog) {
14
23
  const turns = conversationTurns(conversationLog);
15
24
  for (let turnIndex = turns.length - 1; turnIndex >= 0; turnIndex -= 1) {
16
25
  const turn = turns[turnIndex];
17
26
  const messages = Array.isArray(turn?.messages) ? turn.messages : [];
18
27
  if (messages.length) {
19
- for (let index = messages.length - 1; index >= 0; index -= 1) {
20
- const message = messages[index];
21
- const text = messageText(message?.text);
22
- if (!text) {
23
- continue;
24
- }
25
- if (message?.role === "user") {
26
- return "";
27
- }
28
- if (message?.role === "assistant") {
29
- if (message.status === "inProgress") return "";
30
- return text;
31
- }
32
- }
28
+ for (let index = messages.length - 1; index >= 0; index -= 1) yield messages[index];
33
29
  continue;
34
30
  }
35
- if (messageText(turn?.user?.text)) {
31
+ if (turn?.user) yield { ...turn.user, role: "user" };
32
+ if (turn?.assistant) yield { ...turn.assistant, role: "assistant" };
33
+ const commentary = Array.isArray(turn?.commentary) ? turn.commentary : [];
34
+ for (let index = commentary.length - 1; index >= 0; index -= 1) yield { ...commentary[index], role: "commentary" };
35
+ }
36
+ }
37
+
38
+ /** The same messages for which the standard composer offers answer fields. */
39
+ function asksPerson(text) {
40
+ return parseNumberedQuestionPrompt(text).questions.length > 0 || parseAnswerChoicePrompt(text).choices.length > 0;
41
+ }
42
+
43
+ function latestAssistantMessageAwaitingUserReply(conversationLog = {}) {
44
+ for (const message of messagesNewestFirst(conversationLog)) {
45
+ const text = messageText(message?.text);
46
+ if (!text) {
47
+ continue;
48
+ }
49
+ if (message?.role === "user") {
36
50
  return "";
37
51
  }
38
- const assistantText = messageText(turn?.assistant?.text);
39
- if (assistantText) {
40
- if (turn.assistant.status === "inProgress") return "";
41
- return assistantText;
52
+ if (message?.role === "assistant") {
53
+ return message.status === "inProgress" ? "" : text;
54
+ }
55
+ }
56
+ return "";
57
+ }
58
+
59
+ /**
60
+ * The text of the newest AI message that asks the person numbered questions or
61
+ * offers answer choices and still awaits their reply, or "". The search starts at
62
+ * the AI's newest completed reply, the message latestAssistantMessageAwaitingUserReply
63
+ * returns, so nothing is offered while the AI is still writing or working before it.
64
+ * From there it reads that reply and the AI's earlier replies and commentary, at most
65
+ * `lookback` of them, and stops at the person's latest message. A short message the
66
+ * AI adds after its questions therefore no longer hides them.
67
+ * @param {object | object[]} conversationLog
68
+ * @param {{ lookback?: number }} [options]
69
+ */
70
+ function latestAssistantQuestionsAwaitingUserReply(conversationLog = {}, { lookback = ASSISTANT_QUESTION_LOOKBACK } = {}) {
71
+ let replied = false;
72
+ let read = 0;
73
+ for (const message of messagesNewestFirst(conversationLog)) {
74
+ const text = messageText(message?.text);
75
+ if (!text) {
76
+ continue;
77
+ }
78
+ if (message?.role === "user") {
79
+ return "";
80
+ }
81
+ if (message?.role === "assistant" && !replied) {
82
+ if (message.status === "inProgress") return "";
83
+ replied = true;
84
+ }
85
+ if (!replied || !["assistant", "commentary"].includes(message?.role)) {
86
+ continue;
87
+ }
88
+ if (asksPerson(text)) {
89
+ return text;
90
+ }
91
+ read += 1;
92
+ if (read >= lookback) {
93
+ return "";
42
94
  }
43
95
  }
44
96
  return "";
45
97
  }
46
98
 
47
99
  export {
48
- latestAssistantMessageAwaitingUserReply
100
+ latestAssistantMessageAwaitingUserReply,
101
+ latestAssistantQuestionsAwaitingUserReply
49
102
  };
@@ -0,0 +1,97 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ latestAssistantMessageAwaitingUserReply,
5
+ latestAssistantQuestionsAwaitingUserReply
6
+ } from "../src/shared/conversation/questions.js";
7
+
8
+ const questions = "Before I build it:\n[1] Which page should hold the form?\n[2] Should the email field be required?";
9
+ const laterQuestions = "One more thing:\n[1] Which colour should the button use?";
10
+ const choices = "Which layout should I use?\nPossible answers:\n- Cards\n- Table";
11
+ const followUp = "No files have changed. I'll build it once you've answered those questions.";
12
+
13
+ const user = (text = "Add a sign-up form.") => ({ role: "user", text });
14
+ const reply = (text, extra = {}) => ({ role: "assistant", text, ...extra });
15
+ const commentary = text => ({ role: "commentary", text });
16
+ const thinking = text => ({ role: "thinking", text });
17
+ const turn = (...messages) => ({ turnId: "000001", messages });
18
+
19
+ test("questions followed by a short closing message in the same turn are still found", () => {
20
+ const log = { turns: [turn(user(), reply(questions), reply(followUp))] };
21
+ assert.equal(latestAssistantQuestionsAwaitingUserReply(log), questions);
22
+ // The plain reader still answers with the closing message alone.
23
+ assert.equal(latestAssistantMessageAwaitingUserReply(log), followUp);
24
+ });
25
+
26
+ test("questions the AI wrote before a tool call (commentary) are found behind its final reply", () => {
27
+ const log = [turn(user(), thinking("Checking the files."), commentary(questions), thinking("Nothing changed."), reply(followUp))];
28
+ assert.equal(latestAssistantQuestionsAwaitingUserReply(log), questions);
29
+ });
30
+
31
+ test("answer choices count as questions", () => {
32
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([turn(user(), reply(choices), reply(followUp))]), choices);
33
+ });
34
+
35
+ test("the search reads at most the lookback's AI messages, and thinking does not count", () => {
36
+ const between = count => Array.from({ length: count }, (_, index) => [thinking(`Step ${index}`), commentary(`Note ${index}`)]).flat();
37
+ // The questions are the tenth AI message from the end: found.
38
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([turn(user(), reply(questions), ...between(8), reply(followUp))]), questions);
39
+ // The eleventh: not found.
40
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([turn(user(), reply(questions), ...between(9), reply(followUp))]), "");
41
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([turn(user(), reply(questions), reply(followUp))], { lookback: 1 }), "");
42
+ });
43
+
44
+ test("the person's message after the questions ends the search", () => {
45
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([
46
+ turn(user(), reply(questions)),
47
+ turn(user("Page two, and yes."), reply("Done."))
48
+ ]), "");
49
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([turn(user(), reply(questions)), turn(user("Page two."))]), "");
50
+ });
51
+
52
+ test("only the latest set of questions is offered", () => {
53
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([
54
+ turn(user(), reply(questions), commentary(laterQuestions), reply(followUp))
55
+ ]), laterQuestions);
56
+ });
57
+
58
+ test("nothing is offered while the AI is still writing or still working", () => {
59
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([
60
+ turn(user(), reply(questions), reply("No files have", { status: "inProgress" }))
61
+ ]), "");
62
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([turn(user(), reply(questions, { status: "inProgress" }))]), "");
63
+ // Questions in commentary before the AI's final reply exists.
64
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([turn(user(), commentary(questions), thinking("Reading files."))]), "");
65
+ });
66
+
67
+ test("the search crosses turns without a person's message and reads turns without a message list", () => {
68
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([
69
+ turn(user(), reply(questions)),
70
+ { turnId: "000002", system: { text: "Review started." }, messages: [{ role: "system", text: "Review started." }] },
71
+ turn(reply(followUp))
72
+ ]), questions);
73
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([
74
+ { user: { text: "Add a sign-up form." } },
75
+ { assistant: { text: questions } },
76
+ { assistant: { text: followUp } }
77
+ ]), questions);
78
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([
79
+ { user: { text: "Add a sign-up form." } },
80
+ { assistant: { text: followUp }, commentary: [{ text: questions }] }
81
+ ]), questions);
82
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([
83
+ { assistant: { text: questions } },
84
+ { user: { text: "Page two." } }
85
+ ]), "");
86
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([
87
+ { assistant: { text: questions } },
88
+ { assistant: { text: "No files", status: "inProgress" } }
89
+ ]), "");
90
+ });
91
+
92
+ test("a reply without questions, or no reply, offers nothing", () => {
93
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([turn(user(), reply("Done."))]), "");
94
+ assert.equal(latestAssistantQuestionsAwaitingUserReply([]), "");
95
+ assert.equal(latestAssistantQuestionsAwaitingUserReply(null), "");
96
+ assert.equal(latestAssistantQuestionsAwaitingUserReply({ turns: [turn(user())] }), "");
97
+ });
@@ -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);