@jskit-ai/assistant-core 0.2.10 → 0.2.11

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.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.10",
12
- "@jskit-ai/http-runtime": "0.2.4",
13
- "@jskit-ai/kernel": "0.2.4",
11
+ "@jskit-ai/assistant-core": "0.2.11",
12
+ "@jskit-ai/http-runtime": "0.2.5",
13
+ "@jskit-ai/kernel": "0.2.5",
14
14
  "@mdi/js": "^7.4.47",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.2.5",
18
- "@jskit-ai/connectors-catalog": "0.2.5"
17
+ "@jskit-ai/connectors-core": "0.2.6",
18
+ "@jskit-ai/connectors-catalog": "0.2.6"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -51,8 +51,8 @@
51
51
  "@ai-sdk/togetherai": "^3.0.49",
52
52
  "@ai-sdk/xai": "^4.0.58",
53
53
  "@jskit-ai/agent": "0.1.0",
54
- "@jskit-ai/resource-core": "0.1.195",
55
- "@jskit-ai/resource-crud-core": "0.1.195",
54
+ "@jskit-ai/resource-core": "0.1.196",
55
+ "@jskit-ai/resource-crud-core": "0.1.196",
56
56
  "@mdi/js": "^7.4.47",
57
57
  "@openrouter/ai-sdk-provider": "^3.0.0",
58
58
  "ai": "^7.0.152",
@@ -61,8 +61,8 @@
61
61
  "ws": "^8.20.1"
62
62
  },
63
63
  "peerDependencies": {
64
- "@jskit-ai/http-runtime": "0.2.4",
65
- "@jskit-ai/kernel": "0.2.4",
64
+ "@jskit-ai/http-runtime": "0.2.5",
65
+ "@jskit-ai/kernel": "0.2.5",
66
66
  "vue": "^3.5.13",
67
67
  "vuetify": "^4.0.0"
68
68
  },
@@ -4,6 +4,7 @@ import { isCompletedEnvelopeTurn } from "./transcript.js";
4
4
  import { ignorePromptRejection, ignorePromptSending } from "./nativeCommandCallbacks.js";
5
5
 
6
6
  // A final answer keeps its identity when an application edits its visible text.
7
+ // A turn's later replies repeat it here; distinctMessages tells them apart.
7
8
  export function conversationMessageIdentity(turnId, message) {
8
9
  return `${turnId}/${message.role}/${["user", "assistant", "system"].includes(message.role)
9
10
  ? "" : message.messageId || message.at}`;
@@ -16,6 +17,24 @@ export function conversationMessageVersion(message, { includeData = false } = {}
16
17
  })).digest("hex");
17
18
  }
18
19
 
20
+ /**
21
+ * Native continuations add later replies to a turn, and those repeat its identity. The first keeps
22
+ * it, because saved cursors and an edited final answer use it, and each later one adds its own id.
23
+ * The original version saved for a repeated identity names only the last reply written, so it is dropped.
24
+ * @param {object[]} messages
25
+ * @returns {object[]}
26
+ */
27
+ function distinctMessages(messages) {
28
+ const repeats = Map.groupBy(messages, message => message.id);
29
+ return messages.map(message => {
30
+ const repeated = repeats.get(message.id);
31
+ if (repeated.length === 1) return message;
32
+ const { originalVersion: _lastWritten, ...distinct } = message;
33
+ const position = repeated.indexOf(message);
34
+ return position ? { ...distinct, id: `${message.id}${message.messageId || position + 1}` } : distinct;
35
+ });
36
+ }
37
+
19
38
  // A changeover whose owner keeps no delivery log.
20
39
  function logNothing() {}
21
40
 
@@ -70,7 +89,7 @@ export function createConversationChangeover({ state, transcript, agent, identit
70
89
  lastEngine: engineId,
71
90
  engines: {
72
91
  [engineId]: {
73
- seen: Object.fromEntries(messages.map((message) => [message.id, message.originalVersion || message.version]))
92
+ seen: Object.fromEntries(distinctMessages(messages).map((message) => [message.id, message.originalVersion || message.version]))
74
93
  }
75
94
  }
76
95
  };
@@ -81,7 +100,7 @@ export function createConversationChangeover({ state, transcript, agent, identit
81
100
  const seen = value.engines[engineId].seen;
82
101
  // New replies from the last engine are already in that native conversation.
83
102
  // Preserve old fingerprints: an edited bubble still needs to be delivered.
84
- for (const message of messages) {
103
+ for (const message of distinctMessages(messages)) {
85
104
  if (message.engineId === engineId && !Object.hasOwn(seen, message.id)) {
86
105
  seen[message.id] = message.originalVersion || message.version;
87
106
  }
@@ -146,16 +165,17 @@ export function createConversationChangeover({ state, transcript, agent, identit
146
165
  code: unconfirmedCode, messageId, threadId, error: unconfirmedMessage };
147
166
  }
148
167
 
149
- async function send({ engineId, messages, input, turnMetadata, completedEnvelope = false, excludedMessageIds = [], inspectOnly = false }) {
168
+ async function send({ engineId, messages: written, input, turnMetadata, completedEnvelope = false, excludedMessageIds = [], inspectOnly = false }) {
150
169
  if (typeof completedEnvelope !== "boolean" || !Array.isArray(excludedMessageIds) ||
151
170
  excludedMessageIds.some(id => typeof id !== "string" || !id.trim() || id.length > 128) ||
152
171
  new Set(excludedMessageIds).size !== excludedMessageIds.length || excludedMessageIds.length && !completedEnvelope) {
153
172
  throw new TypeError("Native history exclusions require a tracked completed envelope and exact message IDs.");
154
173
  }
155
174
  const excluded = new Set(excludedMessageIds);
156
- const value = await readState({ engineId, messages });
175
+ const value = await readState({ engineId, messages: written });
157
176
  const binding = value.engines[engineId] ||= { seen: {} };
158
- rememberReplies(value, engineId, messages);
177
+ rememberReplies(value, engineId, written);
178
+ const messages = distinctMessages(written);
159
179
  const persist = () => state.write(value);
160
180
  async function markDelivered(seen) {
161
181
  const replacement = value.replacement;
@@ -383,7 +403,7 @@ function writtenMessages(history, native = false) {
383
403
  export const conversationNativeMessages = history => writtenMessages(history, true);
384
404
 
385
405
  export function conversationHistoryVersions(history) {
386
- return Object.fromEntries(writtenMessages(history).map(message => [message.id, message.version]));
406
+ return Object.fromEntries(distinctMessages(writtenMessages(history)).map(message => [message.id, message.version]));
387
407
  }
388
408
 
389
409
  export function conversationRequestText({ text, origin, data }) {
@@ -3685,6 +3685,160 @@ test("native history budget resolves only for new catch-up and preserves the ret
3685
3685
  assert.equal(sent[2].message, "Just these words");
3686
3686
  });
3687
3687
 
3688
+ // One saved turn that native continuations can add replies to, delivered through a changeover.
3689
+ async function continuedConversation() {
3690
+ const { createConversationChangeover, conversationNativeMessages, conversationHistoryVersions } = await import("../src/server/conversation/continuity.js");
3691
+ const storage = createMemoryConversationStorage();
3692
+ const transcript = createConversationTranscript({ storage });
3693
+ const { turnId } = await transcript.writeConversationUserMessage("chat", { messageId: "request", text: "Write a report",
3694
+ at: "2026-10-01T01:00:00.000Z", turnMetadata: { runtime: { engine: "claude" } } });
3695
+ let saved = { lastEngine: "claude", engines: { claude: { seen: {} } } };
3696
+ let sends = 0;
3697
+ let prompt = "";
3698
+ const changeover = createConversationChangeover({
3699
+ state: { async read() { return structuredClone(saved); }, async write(next) { saved = structuredClone(next); } },
3700
+ transcript: { async hasMessage() { return false; } },
3701
+ agent: { async sendMessage(input) { prompt = input.message; return { ok: true, delivered: true }; } }
3702
+ });
3703
+ const turns = () => transcript.readConversationLog("chat");
3704
+ let written = 0;
3705
+ return {
3706
+ transcript, turnId, turns,
3707
+ reply: (messageId, text) => transcript.writeConversationAssistantMessage("chat", { messageId, text, turnId,
3708
+ at: `2026-10-01T01:00:0${++written}.000Z` }),
3709
+ edit: (messageId, text) => storage.write("chat", async tx => {
3710
+ const message = (await tx.readTurn(turnId)).messages.find(row => row.messageId === messageId);
3711
+ await tx.appendMessage(turnId, { ...message, text });
3712
+ }),
3713
+ history: async () => conversationNativeMessages(await turns()),
3714
+ versions: async () => conversationHistoryVersions(await turns()),
3715
+ save(value) { saved = value; },
3716
+ seen: (engineId = "claude") => saved.engines[engineId].seen,
3717
+ async send(engineId = "claude", messages) {
3718
+ await changeover.send({ engineId, messages: messages || conversationNativeMessages(await turns()),
3719
+ input: { messageId: `follow-up-${++sends}`, message: "Next" } });
3720
+ return prompt;
3721
+ }
3722
+ };
3723
+ }
3724
+
3725
+ // What a changeover preamble carried: each message's id, text and correction mark, and the removals.
3726
+ function carriedHistory(prompt) {
3727
+ const { messages, removedMessageIds } = JSON.parse(prompt.split("\n").find(line => line.startsWith('{"messages"')));
3728
+ return { messages: messages.map(message => [message.messageId, message.text, message.corrected]), removedMessageIds };
3729
+ }
3730
+
3731
+ test("replies that share a turn are each remembered, so later messages carry no changeover preamble", async () => {
3732
+ const f = await continuedConversation();
3733
+ await f.reply("interim", "The query is still running.");
3734
+ await f.reply("report", "The report.");
3735
+ assert.equal(await f.send(), "Next");
3736
+ assert.equal(await f.send(), "Next");
3737
+ // A native continuation after that delivery is already in the engine's own conversation.
3738
+ await f.reply("late", "The stale helper stopped.");
3739
+ assert.equal(await f.send(), "Next");
3740
+ assert.deepEqual(f.seen(), await f.versions());
3741
+ assert.deepEqual(Object.keys(f.seen()), ["000001/user/", "000001/assistant/", "000001/assistant/report", "000001/assistant/late"]);
3742
+ });
3743
+
3744
+ test("replies without message ids keep separate identities in one turn", async () => {
3745
+ const f = await continuedConversation();
3746
+ await f.transcript.writeConversationAssistantMessage("chat", { text: "First", turnId: f.turnId, at: "2026-10-01T01:00:01.000Z" });
3747
+ await f.transcript.writeConversationAssistantMessage("chat", { text: "Second", turnId: f.turnId, at: "2026-10-01T01:00:02.000Z" });
3748
+ assert.equal(await f.send(), "Next");
3749
+ assert.equal(await f.send(), "Next");
3750
+ assert.deepEqual(Object.keys(f.seen()), ["000001/user/", "000001/assistant/", "000001/assistant/2"]);
3751
+ });
3752
+
3753
+ test("a corrected reply in a shared turn is delivered alone, and merged replies are reported removed", async () => {
3754
+ const f = await continuedConversation();
3755
+ await f.reply("interim", "The query is still running.");
3756
+ await f.reply("report", "The report.");
3757
+ assert.equal(await f.send(), "Next");
3758
+ await f.edit("report", "The corrected report.");
3759
+ assert.deepEqual(carriedHistory(await f.send()),
3760
+ { messages: [["report", "The corrected report.", true]], removedMessageIds: [] });
3761
+ assert.equal(await f.send(), "Next");
3762
+ await f.edit("interim", "The corrected status.");
3763
+ assert.deepEqual(carriedHistory(await f.send()),
3764
+ { messages: [["interim", "The corrected status.", true]], removedMessageIds: [] });
3765
+ assert.equal(await f.send(), "Next");
3766
+ // An application's edit replaces every reply in the turn and keeps the first one's identity.
3767
+ await f.transcript.upsertConversationAssistantMessage("chat", { turnId: f.turnId, text: "One merged answer." });
3768
+ assert.deepEqual(carriedHistory(await f.send()),
3769
+ { messages: [["interim", "One merged answer.", true]], removedMessageIds: ["000001/assistant/report"] });
3770
+ assert.equal(await f.send(), "Next");
3771
+ });
3772
+
3773
+ test("a cursor saved while replies shared one identity delivers one correction, then none", async () => {
3774
+ const f = await continuedConversation();
3775
+ await f.reply("interim", "The query is still running.");
3776
+ await f.reply("report", "The report.");
3777
+ const [user, first, last] = await f.history();
3778
+ f.save({ lastEngine: "claude", engines: { claude: { seen: { [user.id]: user.version, [first.id]: last.version } } } });
3779
+ assert.deepEqual(carriedHistory(await f.send()),
3780
+ { messages: [["interim", "The query is still running.", true]], removedMessageIds: [] });
3781
+ assert.deepEqual(f.seen(), await f.versions());
3782
+ assert.equal(await f.send(), "Next");
3783
+ });
3784
+
3785
+ test("a cursor saved with a turn's first reply is already current", async () => {
3786
+ const f = await continuedConversation();
3787
+ await f.reply("interim", "The query is still running.");
3788
+ await f.reply("report", "The report.");
3789
+ const [user, first] = await f.history();
3790
+ f.save({ lastEngine: "claude", engines: { claude: { seen: { [user.id]: user.version, [first.id]: first.version } } } });
3791
+ assert.equal(await f.send(), "Next");
3792
+ assert.deepEqual(f.seen(), await f.versions());
3793
+ assert.equal(await f.send(), "Next");
3794
+ });
3795
+
3796
+ test("a cursor saved while replies shared one identity delivers a returning engine's turn once", async () => {
3797
+ const f = await continuedConversation();
3798
+ await f.reply("interim", "The query is still running.");
3799
+ await f.reply("report", "The report.");
3800
+ const [user, first, last] = await f.history();
3801
+ await f.reply("late", "The stale helper stopped.");
3802
+ f.save({ lastEngine: "claude", engines: { codex: { seen: { [user.id]: user.version, [first.id]: last.version } } } });
3803
+ assert.deepEqual(carriedHistory(await f.send("codex")), { messages: [
3804
+ ["interim", "The query is still running.", true], ["report", "The report.", undefined],
3805
+ ["late", "The stale helper stopped.", undefined]], removedMessageIds: [] });
3806
+ assert.deepEqual(f.seen("codex"), await f.versions());
3807
+ assert.equal(await f.send("codex"), "Next");
3808
+ });
3809
+
3810
+ test("a cursor saved while replies shared one identity keeps real corrections", async () => {
3811
+ const shared = await continuedConversation();
3812
+ await shared.reply("interim", "The query is still running.");
3813
+ await shared.reply("report", "The report.");
3814
+ const [user, first] = await shared.history();
3815
+ shared.save({ lastEngine: "claude", engines: { claude: { seen: { [user.id]: user.version, [first.id]: "before-the-edit" } } } });
3816
+ assert.deepEqual(carriedHistory(await shared.send()),
3817
+ { messages: [["interim", "The query is still running.", true]], removedMessageIds: [] });
3818
+ assert.equal(await shared.send(), "Next");
3819
+
3820
+ const merged = await continuedConversation();
3821
+ await merged.reply("interim", "The query is still running.");
3822
+ await merged.reply("report", "The report.");
3823
+ const [mergedUser, , reportBeforeEdit] = await merged.history();
3824
+ merged.save({ lastEngine: "claude", engines: { claude: { seen: { [mergedUser.id]: mergedUser.version,
3825
+ [reportBeforeEdit.id]: reportBeforeEdit.version } } } });
3826
+ await merged.transcript.upsertConversationAssistantMessage("chat", { turnId: merged.turnId, text: "One merged answer." });
3827
+ assert.deepEqual(carriedHistory(await merged.send()),
3828
+ { messages: [["interim", "One merged answer.", true]], removedMessageIds: [] });
3829
+ assert.equal(await merged.send(), "Next");
3830
+ });
3831
+
3832
+ test("a host's history may repeat a turn's identity and its last written original version", async () => {
3833
+ const f = await continuedConversation();
3834
+ const reply = { id: "000001/assistant/", role: "assistant", engineId: "claude", originalVersion: "report-version" };
3835
+ const messages = [{ ...reply, messageId: "interim", text: "Running.", version: "interim-version" },
3836
+ { ...reply, messageId: "report", text: "The report.", version: "report-version" }];
3837
+ assert.equal(await f.send("claude", messages), "Next");
3838
+ assert.deepEqual(f.seen(), { "000001/assistant/": "interim-version", "000001/assistant/report": "report-version" });
3839
+ assert.equal(await f.send("claude", messages), "Next");
3840
+ });
3841
+
3688
3842
  test("native history budget rejects unavailable dynamic limits before pending delivery", async () => {
3689
3843
  const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3690
3844
  for (const budget of [0, -1, NaN, Infinity, "4096", undefined]) {