@jskit-ai/assistant-core 0.1.225 → 0.1.227

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.
Files changed (37) hide show
  1. package/examples/conversation/package.json +1 -1
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +5 -3
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +9 -0
  4. package/package.json +1 -1
  5. package/src/client/conversation/AssistantConversationElement.vue +7 -4
  6. package/src/client/conversation/AssistantGoalControl.vue +30 -16
  7. package/src/client/conversation/AssistantTranscript.vue +14 -7
  8. package/src/client/conversation/useAssistantAttachments.js +6 -3
  9. package/src/server/conversation/claudeClient.js +22 -5
  10. package/src/server/conversation/claudeHistory.js +62 -5
  11. package/src/server/conversation/claudeProcess.js +13 -10
  12. package/src/server/conversation/claudeTurn.js +292 -60
  13. package/src/server/conversation/codexConfiguration.js +24 -1
  14. package/src/server/conversation/codexGoalCommands.js +8 -3
  15. package/src/server/conversation/codexTurnRecovery.js +18 -8
  16. package/src/server/conversation/continuity.js +54 -17
  17. package/src/server/conversation/providers/claudeDriver.js +6 -5
  18. package/src/server/conversation/providers/codexDriver.js +3 -3
  19. package/src/server/conversation/providers/scoped.js +31 -10
  20. package/src/server/conversation/runtime.js +48 -13
  21. package/src/server/conversation/streams.js +7 -3
  22. package/src/server/conversation/transcript.js +2 -1
  23. package/src/server/hosts/codexConfiguration.js +1 -0
  24. package/src/shared/conversation/longTextBlocks.js +0 -4
  25. package/test/assistantScroll.browser.test.js +74 -3
  26. package/test/claudeClient.test.js +73 -0
  27. package/test/claudeConversationSession.test.js +139 -4
  28. package/test/claudeProcess.test.js +61 -0
  29. package/test/claudeTurn.test.js +1330 -10
  30. package/test/codexConversationSession.test.js +9 -1
  31. package/test/codexGoal.test.js +47 -0
  32. package/test/codexModelCatalog.test.js +55 -0
  33. package/test/codexTurn.test.js +228 -0
  34. package/test/conversationAttachments.test.js +17 -0
  35. package/test/conversationRuntime.test.js +40 -0
  36. package/test/conversationStreaming.test.js +18 -0
  37. package/test/messageDelivery.browser.test.js +30 -0
@@ -1341,7 +1341,12 @@ test("Codex steering admits once and preserves original unfinished-reply groupin
1341
1341
  assert.equal(trace.filter(row => row.method === "turn/steer").length, 1);
1342
1342
  assert.equal(trace.find(row => row.method === "turn/steer").params.clientUserMessageId, "after");
1343
1343
  assert.equal((await f.conversation.send(steering)).duplicate, true);
1344
- await assert.rejects(f.conversation.send({ ...steering, messageId: "idle" }), { code: "conversation_not_steerable" });
1344
+ await assert.rejects(f.conversation.send({ ...steering, messageId: "idle" }), { code: "conversation_steer_turn_finished", delivery: "not-sent" });
1345
+ const next = { messageId: "idle", text: steering.text };
1346
+ await f.conversation.send(next);
1347
+ await f.conversation.wait();
1348
+ assert.equal((await f.conversation.send(next)).duplicate, true);
1349
+ assert.equal((await f.trace()).filter(row => row.method === "turn/start").length, 2);
1345
1350
  });
1346
1351
 
1347
1352
  test("Codex steering rejection leaves the active turn available and checks authorization before dispatch", async t => {
@@ -1718,6 +1723,9 @@ test("Codex cancellation drains owned work, and a deadline saves a failed turn",
1718
1723
  const f = await fixture(t, { limits: { timeoutMs: 2000 } });
1719
1724
  await f.conversation.send({ messageId: "cancel", text: "wait" });
1720
1725
  assert.deepEqual(await f.conversation.cancel(), { stopped: true });
1726
+ await assert.rejects(f.conversation.send({ messageId: "late-after-stop", text: "Do more", steer: true }), {
1727
+ code: "conversation_not_steerable", delivery: "not-sent"
1728
+ });
1721
1729
  await f.conversation.send({ messageId: "timeout", text: "wait" });
1722
1730
  const state = await f.conversation.wait();
1723
1731
  assert.equal(state.conversationLog[0].metadata.runtime.status, "cancelled");
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { CodexAppServerAgentProvider } from "../src/server/conversation/codexProvider.js";
4
+ import { createCodexGoalCommands } from "../src/server/conversation/codexGoalCommands.js";
4
5
  import { createCodexAppServerProviderOwner } from "../src/server/conversation/codexProviderOwner.js";
5
6
 
6
7
  test("cached goal reads retain the original session and interactive-provider boundary", async t => {
@@ -187,3 +188,49 @@ for (const pause of [false, true]) {
187
188
  assert.equal(requests.some(row => row.method === "turn/interrupt" || row.method === "turn/start"), false);
188
189
  });
189
190
  }
191
+
192
+ for (const action of ["set", "resume"]) test(`cancelled Codex goal ${action} cannot dispatch after native goal read`, async () => {
193
+ const controller = new AbortController();
194
+ const entered = Promise.withResolvers();
195
+ const release = Promise.withResolvers();
196
+ let writes = 0;
197
+ const goal = { status: "paused", objective: "Finish the checks", createdAt: 1 };
198
+ const owner = createCodexGoalCommands({ turnState: () => ({ status: "interrupted" }),
199
+ subscribeCodexAppServerEvents() {}, reconcileCodexAppServerGoalUpdated: async () => {}, publish: async () => {} });
200
+ const provider = {
201
+ async readGoal() { entered.resolve(); await release.promise; return { goal: action === "set" ? null : goal }; },
202
+ async setGoal() { writes++; return { goal }; }, async setGoalStatus() { writes++; return { goal }; }
203
+ };
204
+ const operation = owner.updateCodexAppServerGoal("session", { action, threadId: "native", ...goal },
205
+ { context: { threadId: "native", provider, session: {} }, signal: controller.signal });
206
+ void operation.catch(() => {});
207
+ await entered.promise;
208
+ controller.abort(new Error("Goal stopped"));
209
+ release.resolve();
210
+ await assert.rejects(operation, /Goal stopped/);
211
+ assert.equal(writes, 0);
212
+ });
213
+
214
+ for (const action of ["set", "resume"]) test(`Codex goal ${action} forwards cancellation through provider preparation`, async t => {
215
+ const controller = new AbortController();
216
+ const entered = Promise.withResolvers();
217
+ const release = Promise.withResolvers();
218
+ const provider = new CodexAppServerAgentProvider({}, { credentials: { assertCurrent() {} } });
219
+ t.after(() => provider.close());
220
+ const goal = { status: "paused", objective: "Finish the checks", createdAt: 1 };
221
+ const writes = [];
222
+ provider.readGoal = async () => ({ goal: action === "set" ? null : goal });
223
+ provider.activeClient = async () => { entered.resolve(); await release.promise; return {
224
+ async request(method, input, { signal }) { signal.throwIfAborted(); writes.push({ method, input }); return { goal }; }
225
+ }; };
226
+ const owner = createCodexGoalCommands({ turnState: () => ({ status: "interrupted" }),
227
+ subscribeCodexAppServerEvents() {}, reconcileCodexAppServerGoalUpdated: async () => {}, publish: async () => {} });
228
+ const operation = owner.updateCodexAppServerGoal("session", { action, threadId: "native", ...goal },
229
+ { context: { threadId: "native", provider, session: {} }, signal: controller.signal });
230
+ void operation.catch(() => {});
231
+ await entered.promise;
232
+ controller.abort(new Error("Goal stopped"));
233
+ release.resolve();
234
+ await assert.rejects(operation, /Goal stopped/);
235
+ assert.deepEqual(writes, []);
236
+ });
@@ -360,3 +360,58 @@ test("catalog cancellation during shared acquisition still awaits startup and cl
360
360
  assert.equal(captures.stopRuntimes, 1);
361
361
  assert.equal(captures.closes, 1);
362
362
  });
363
+
364
+ test("native context window uses the exact runtime model's normal window without extending it", async t => {
365
+ const { readCodexModelContextWindow } = await import("../src/server/conversation/codexConfiguration.js");
366
+ const first = await fixture(t, "");
367
+ const second = await fixture(t, "");
368
+ const catalogue = { models: [
369
+ { slug: "astra", context_window: 272000, max_context_window: 872000, effective_context_window_percent: 95 },
370
+ { slug: "sol", context_window: 500000, max_context_window: 872000, effective_context_window_percent: 95 }
371
+ ] };
372
+ const firstPath = path.join(first.runtimeDir, "models.json");
373
+ await writeFile(firstPath, JSON.stringify(catalogue));
374
+ await writeFile(path.join(second.runtimeDir, "models.json"), JSON.stringify({ models: [
375
+ { slug: "astra", context_window: 128000, max_context_window: 872000, effective_context_window_percent: 90 }
376
+ ] }));
377
+ const before = await readFile(firstPath, "utf8");
378
+ assert.deepEqual(await readCodexModelContextWindow({ runtimeDir: first.runtimeDir, model: "astra" }),
379
+ { model: "astra", windowTokens: 272000, effectiveWindowTokens: 258400 });
380
+ assert.deepEqual(await readCodexModelContextWindow({ runtimeDir: first.runtimeDir, model: "sol" }),
381
+ { model: "sol", windowTokens: 500000, effectiveWindowTokens: 475000 });
382
+ assert.deepEqual(await readCodexModelContextWindow({ runtimeDir: second.runtimeDir, model: "astra" }),
383
+ { model: "astra", windowTokens: 128000, effectiveWindowTokens: 115200 });
384
+ assert.equal(await readFile(firstPath, "utf8"), before);
385
+ catalogue.models[0].context_window = 200000;
386
+ await writeFile(firstPath, JSON.stringify(catalogue));
387
+ assert.equal((await readCodexModelContextWindow({ runtimeDir: first.runtimeDir, model: "astra" })).windowTokens, 200000,
388
+ "a new runtime catalogue is not hidden by an earlier read");
389
+ });
390
+
391
+ test("native context window refuses missing or invalid normal limits without leaking catalogue content", async t => {
392
+ const { readCodexModelContextWindow } = await import("../src/server/conversation/codexConfiguration.js");
393
+ const f = await fixture(t, "");
394
+ const valid = { slug: "astra", context_window: 272000, max_context_window: 872000, effective_context_window_percent: 95 };
395
+ const target = path.join(f.runtimeDir, "models.json");
396
+ for (const contents of [
397
+ "secret-provider-key", JSON.stringify({ models: [] }),
398
+ ...[
399
+ { ...valid, context_window: undefined }, { ...valid, context_window: "272000" },
400
+ { ...valid, context_window: 0 }, { ...valid, context_window: 1.5 },
401
+ { ...valid, effective_context_window_percent: 0 }, { ...valid, effective_context_window_percent: 101 },
402
+ { ...valid, effective_context_window_percent: undefined }, { ...valid, slug: "another-model" }
403
+ ].map(row => JSON.stringify({ models: [row], secret: "secret-provider-key" })),
404
+ JSON.stringify({ models: [valid, valid] })
405
+ ]) {
406
+ await writeFile(target, contents);
407
+ await assert.rejects(readCodexModelContextWindow({ runtimeDir: f.runtimeDir, model: "astra" }), error => {
408
+ assert.match(error.message, /normal context window/);
409
+ assert.doesNotMatch(error.message, /secret-provider-key/);
410
+ return true;
411
+ });
412
+ }
413
+ await rm(target);
414
+ await assert.rejects(readCodexModelContextWindow({ runtimeDir: f.runtimeDir, model: "astra" }), /normal context window/);
415
+ await assert.rejects(readCodexModelContextWindow({ runtimeDir: "", model: "astra" }), /runtime.*model/);
416
+ await assert.rejects(readCodexModelContextWindow({ runtimeDir: f.runtimeDir, model: "" }), /runtime.*model/);
417
+ });
@@ -2686,6 +2686,36 @@ test("native failure detail grace rejects invalid duration", () => {
2686
2686
  }
2687
2687
  });
2688
2688
 
2689
+ for (const phase of ["final_answer", "", "commentary"]) {
2690
+ test(`a provider usage limit remains visible after recovering ${phase || "unmarked"} assistant output`, async t => {
2691
+ const notices = [];
2692
+ const detail = "You've hit your usage limit. Try again at Oct 22nd, 2026 11:37 PM.";
2693
+ const f = await receiptFixture({ failureDetailGraceMs: 0,
2694
+ checkpoint: async (_id, input) => f.checkpoints.push(input),
2695
+ outcomeNotice: async (_runtime, _id, _thread, _turn, outcome, error, options) =>
2696
+ notices.push({ outcome, error, ...options })
2697
+ });
2698
+ t.after(() => f.owner.clearSessionRecoveryTimers(f.sessionId));
2699
+ f.run.providerStatus = "failed";
2700
+ f.history = [{ id: f.turnId, status: "failed", items: [outputItem("saved-output", "Work saved so far.", phase)] }];
2701
+ f.observe();
2702
+ const notification = { method: "turn/completed", params: { threadId: f.threadId,
2703
+ turn: { id: f.turnId, status: "failed", error: { message: detail, codexErrorInfo: "usageLimitExceeded" } } } };
2704
+ f.emit(notification);
2705
+ await f.drain();
2706
+ assert.deepEqual(notices, [{ outcome: "provider_failure", error: `${detail} usageLimitExceeded`, usageLimitExceeded: true }]);
2707
+ assert.equal(f.run.state, "failed");
2708
+ assert.match(f.run.error, /hit your usage limit/);
2709
+ assert.equal(f.checkpoints.length, 1);
2710
+ assert.equal(f.checkpoints[0].turnOutcome, "provider_failure");
2711
+ assert.equal((await f.replies()).length, phase === "commentary" ? 0 : 1);
2712
+ f.emit(notification);
2713
+ await f.drain();
2714
+ assert.equal(notices.length, 1, "Repeated terminal events do not duplicate the failure notice");
2715
+ assert.equal(f.checkpoints.length, 1);
2716
+ });
2717
+ }
2718
+
2689
2719
  for (const change of ["detail", "expiry", "repeat", "cleanup", "successor", "foreign-error", "user-stop"]) {
2690
2720
  test(`native failure detail grace preserves the existing notification owner for ${change}`, { timeout: 2000 }, async t => {
2691
2721
  const settled = Promise.withResolvers();
@@ -3185,3 +3215,201 @@ test("completed envelope returning corrections remain full while the current bat
3185
3215
  assert.deepEqual(carried.removedMessageIds, ["removed"]);
3186
3216
  assert.deepEqual(value.engines.codex.seen, { "old-row": "edited", "current-row": "current" });
3187
3217
  });
3218
+
3219
+
3220
+ test("a provisional row retries only with the exact retained no-attempt evidence after reload", async () => {
3221
+ const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3222
+ for (const proof of ["rejected", "attempted", "missing", "other-input"]) {
3223
+ let saved = { lastEngine: "claude", engines: { claude: { seen: {}, ...(proof !== "missing" ? { pending: {
3224
+ messageId: proof === "other-input" ? "other" : "followup", message: "Exact prepared input",
3225
+ displayMessage: "Follow-up", displayAttachments: [], attachmentIds: [], seen: {}, attempted: proof === "attempted"
3226
+ } } : {}) } } };
3227
+ let sends = 0;
3228
+ const changeover = createConversationChangeover({
3229
+ state: { async read() { return structuredClone(saved); }, async write(value) { saved = structuredClone(value); } },
3230
+ transcript: { async hasMessage() { return true; }, async writeUserMessage() { assert.fail("The existing authored row must not be copied"); } },
3231
+ agent: {
3232
+ async inspectMessageAdmission() { return { admission: "unknown" }; },
3233
+ async sendMessage(input) {
3234
+ sends += 1;
3235
+ assert.equal(input.messageId, "followup");
3236
+ assert.equal(input.message, "Exact prepared input");
3237
+ await input.onPromptSending({ threadId: "retained-native" });
3238
+ return { ok: true, delivered: true };
3239
+ }
3240
+ }
3241
+ });
3242
+ const result = await changeover.send({ engineId: "claude", messages: [{ id: "row", messageId: "followup",
3243
+ role: "user", text: "Follow-up", receipt: false, version: "v1" }], input: { messageId: "followup", message: "Follow-up" } });
3244
+ assert.equal(result.delivered, proof === "rejected");
3245
+ assert.equal(sends, proof === "rejected" ? 1 : 0);
3246
+ if (proof === "rejected") assert.equal(saved.engines.claude.pending, undefined);
3247
+ }
3248
+ });
3249
+
3250
+
3251
+ test("ordinary same-engine input reserves the existing pending proof without adding context", async () => {
3252
+ const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3253
+ let saved = { lastEngine: "claude", engines: { claude: { seen: {} } } };
3254
+ const messages = [];
3255
+ let sends = 0;
3256
+ let failed = true;
3257
+ const create = () => createConversationChangeover({
3258
+ state: { async read() { return structuredClone(saved); }, async write(value) { saved = structuredClone(value); } },
3259
+ transcript: { async hasMessage() { return messages.length > 0; } },
3260
+ captureContext: true,
3261
+ agent: {
3262
+ async inspectMessageAdmission() { return { admission: "unknown" }; },
3263
+ async sendMessage(input) {
3264
+ sends++;
3265
+ assert.equal(input.message, "Unchanged authored words");
3266
+ assert.equal(input.contextText, undefined);
3267
+ assert.equal(saved.engines.claude.pending.attempted, false);
3268
+ await input.onPromptSending({ threadId: "exact-native" });
3269
+ assert.equal(saved.engines.claude.pending.attempted, true);
3270
+ if (failed) {
3271
+ messages.push({ id: "provisional", messageId: "same", role: "user", receipt: false, text: input.message, version: "v1" });
3272
+ await input.onPromptRejected();
3273
+ throw Object.assign(new Error("Ended before write"), { delivery: "not-sent" });
3274
+ }
3275
+ return { ok: true, delivered: true };
3276
+ }
3277
+ }
3278
+ });
3279
+ const input = { messageId: "same", message: "Unchanged authored words" };
3280
+ await assert.rejects(create().send({ engineId: "claude", messages, input }), { delivery: "not-sent" });
3281
+ assert.equal(saved.engines.claude.pending.attempted, false);
3282
+ failed = false;
3283
+ assert.equal((await create().send({ engineId: "claude", messages, input })).delivered, true);
3284
+ assert.equal(saved.engines.claude.pending, undefined);
3285
+ assert.equal(sends, 2);
3286
+ assert.equal(messages.length, 1);
3287
+ });
3288
+
3289
+ test("native history byte budget carries all fitting messages and trims complete oldest messages", async () => {
3290
+ const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3291
+ const messages = Array.from({ length: 80 }, (_, index) => ({
3292
+ id: `history-${index}`, messageId: `authored-${index}`, role: index % 2 ? "assistant" : "user",
3293
+ text: `${index}: 漢字 🐈 \\"\n`.repeat(20), version: `v-${index}`, engineId: "claude",
3294
+ at: new Date(2026, 0, 1, 0, index).toISOString(), model: "original-model",
3295
+ ...(index === 0 ? { attachments: [{ attachmentId: "old-file", name: "old.txt" }] } : {}),
3296
+ ...(index === 79 ? { attachments: [{ attachmentId: "new-file", name: "new.txt" }] } : {})
3297
+ }));
3298
+ const original = structuredClone(messages);
3299
+ const rows = messages.map(({ version, ...message }) => message);
3300
+ const size = selected => Buffer.byteLength(JSON.stringify({ messages: selected, removedMessageIds: [] }));
3301
+ const limits = [size(rows), size(rows.slice(40)), size(rows.slice(40)) - 1, size([])];
3302
+ for (const maximumHistoryBytes of limits) {
3303
+ let saved = { lastEngine: "claude", engines: { codex: { seen: {} } } };
3304
+ let sent;
3305
+ const changeover = createConversationChangeover({
3306
+ maximumInitialMessages: Infinity, maximumHistoryBytes, captureContext: true,
3307
+ state: { async read() { return saved; }, async write(value) { saved = structuredClone(value); } },
3308
+ transcript: { async hasMessage() { return false; } },
3309
+ agent: { async sendMessage(input) { sent = input; return { ok: true, delivered: true }; } }
3310
+ });
3311
+ await changeover.send({ engineId: "codex", messages, input: { messageId: "new-request", message: "My full current request." } });
3312
+ const encoded = sent.contextText.split("\n").find(line => line.startsWith("{"));
3313
+ const carried = JSON.parse(encoded);
3314
+ const expected = maximumHistoryBytes === size(rows) ? rows : maximumHistoryBytes === size(rows.slice(40))
3315
+ ? rows.slice(40) : maximumHistoryBytes === size([]) ? [] : rows.slice(41);
3316
+ assert.deepEqual(carried.messages, expected);
3317
+ assert.ok(Buffer.byteLength(encoded) <= maximumHistoryBytes);
3318
+ assert.deepEqual(sent.contextAttachments, expected.flatMap(message => message.attachments || []));
3319
+ assert.ok(sent.message.endsWith("My full current request."));
3320
+ assert.deepEqual(saved.engines.codex.seen, Object.fromEntries(messages.map(message => [message.id, message.version])));
3321
+ assert.deepEqual(messages, original);
3322
+ }
3323
+ });
3324
+
3325
+ test("native history byte budget bounds returning corrections and retains exact retry after reload", async () => {
3326
+ const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3327
+ const messages = [
3328
+ { id: "old", messageId: "old-input", role: "user", engineId: "claude", text: "Older correction", version: "edited-old" },
3329
+ { id: "middle", messageId: "middle-input", role: "user", engineId: "claude", text: "Middle correction", version: "edited-middle" },
3330
+ { id: "new", messageId: "new-reply", role: "assistant", engineId: "claude", text: "Latest missed reply", version: "new" }
3331
+ ];
3332
+ const expected = messages.slice(1).map(({ version, ...message }) => ({ ...message,
3333
+ ...(message.id === "middle" ? { corrected: true } : {}) }));
3334
+ const maximumHistoryBytes = Buffer.byteLength(JSON.stringify({ messages: expected, removedMessageIds: ["deleted"] }));
3335
+ let saved = { lastEngine: "claude", engines: { codex: { seen: { old: "v0", middle: "v1", deleted: "v2" } } } };
3336
+ const attempts = [];
3337
+ const state = { async read() { return structuredClone(saved); }, async write(value) { saved = structuredClone(value); } };
3338
+ const transcript = { async hasMessage() { return false; }, async writeUserMessage() {} };
3339
+ const input = { messageId: "stable-input", message: "My request" };
3340
+ const first = createConversationChangeover({ state, transcript, maximumHistoryBytes, captureContext: true,
3341
+ agent: { async sendMessage(value) { attempts.push(value); return { ok: false, delivered: false }; } } });
3342
+ await first.send({ engineId: "codex", messages, input });
3343
+ const encoded = attempts[0].contextText.split("\n").find(line => line.startsWith("{"));
3344
+ assert.deepEqual(JSON.parse(encoded), { messages: expected, removedMessageIds: ["deleted"] });
3345
+ assert.equal(Buffer.byteLength(encoded), maximumHistoryBytes);
3346
+ // A later budget or transcript change cannot rewrite a retained exact input.
3347
+ const reloaded = createConversationChangeover({ state, transcript, maximumHistoryBytes: 1,
3348
+ agent: { async sendMessage(value) { attempts.push(value); return { ok: true, delivered: true }; } } });
3349
+ await reloaded.send({ engineId: "codex", messages: [], input });
3350
+ assert.equal(attempts[1].message, attempts[0].message);
3351
+ assert.equal(saved.engines.codex.pending, undefined);
3352
+ });
3353
+
3354
+ test("native history byte budget rejects invalid bounds and an oversized removal envelope before dispatch", async () => {
3355
+ const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3356
+ for (const maximumHistoryBytes of [0, -1, 1.5, "100", NaN]) {
3357
+ assert.throws(() => createConversationChangeover({ maximumHistoryBytes }), /history.*byte.*limit/i);
3358
+ }
3359
+ assert.throws(() => createConversationChangeover({ maximumInitialMessages: Infinity }), /bounded.*history|history.*byte.*limit/i);
3360
+ let writes = 0;
3361
+ let sends = 0;
3362
+ const changeover = createConversationChangeover({ maximumHistoryBytes: 10,
3363
+ state: { async read() { return { lastEngine: "claude", engines: { codex: { seen: { removed: "v1" } } } }; },
3364
+ async write() { writes++; } },
3365
+ transcript: { async hasMessage() { return false; } },
3366
+ agent: { async sendMessage() { sends++; } }
3367
+ });
3368
+ await assert.rejects(changeover.send({ engineId: "codex", messages: [], input: { messageId: "too-small", message: "Words" } }),
3369
+ /history.*budget/i);
3370
+ assert.equal(writes, 0);
3371
+ assert.equal(sends, 0);
3372
+ });
3373
+
3374
+ test("native history budget resolves only for new catch-up and preserves the retry's original budget", async () => {
3375
+ const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3376
+ const message = { id: "retained", role: "user", text: "Original words", version: "v1", engineId: "claude" };
3377
+ let saved = { lastEngine: "claude", engines: { codex: { seen: {} } } };
3378
+ const state = { async read() { return structuredClone(saved); }, async write(value) { saved = structuredClone(value); } };
3379
+ const sent = [];
3380
+ const calls = [];
3381
+ const input = { messageId: "bounded-input", message: "Current words" };
3382
+ const create = maximumHistoryBytes => createConversationChangeover({
3383
+ state, maximumInitialMessages: Infinity, maximumHistoryBytes,
3384
+ transcript: { async hasMessage() { return false; } },
3385
+ agent: { async sendMessage(value) { sent.push(value); return { ok: false, delivered: false }; } }
3386
+ });
3387
+ await create(async query => { calls.push(query); return 4096; }).send({ engineId: "codex", messages: [message], input });
3388
+ assert.deepEqual(calls, [{ engineId: "codex", returning: false, input, handover: "" }]);
3389
+ assert.match(sent[0].message, /Original words/);
3390
+ await create(() => { throw new Error("A retry must not prepare another native budget"); })
3391
+ .send({ engineId: "codex", messages: [], input });
3392
+ assert.equal(sent[1].message, sent[0].message);
3393
+ saved = { lastEngine: "codex", engines: { codex: { seen: { retained: "v1" } } } };
3394
+ await create(() => { throw new Error("Ordinary input has no history to budget"); })
3395
+ .send({ engineId: "codex", messages: [message], input: { messageId: "ordinary-input", message: "Just these words" } });
3396
+ assert.equal(sent[2].message, "Just these words");
3397
+ });
3398
+
3399
+ test("native history budget rejects unavailable dynamic limits before pending delivery", async () => {
3400
+ const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3401
+ for (const budget of [0, -1, NaN, Infinity, "4096", undefined]) {
3402
+ let writes = 0;
3403
+ let sends = 0;
3404
+ const changeover = createConversationChangeover({ maximumInitialMessages: Infinity,
3405
+ maximumHistoryBytes: async () => budget,
3406
+ state: { async read() { return { lastEngine: "claude", engines: {} }; }, async write() { writes++; } },
3407
+ transcript: { async hasMessage() { return false; } },
3408
+ agent: { async sendMessage() { sends++; } }
3409
+ });
3410
+ await assert.rejects(changeover.send({ engineId: "codex", messages: [], input: { messageId: "unavailable", message: "Words" } }),
3411
+ /history.*byte.*limit/i);
3412
+ assert.equal(writes, 0);
3413
+ assert.equal(sends, 0);
3414
+ }
3415
+ });
@@ -1,3 +1,4 @@
1
+ import { useAssistantAttachments } from "../src/client/conversation/useAssistantAttachments.js";
1
2
  import assert from "node:assert/strict";
2
3
  import test from "node:test";
3
4
  import { createConversationRuntime, createMemoryConversationStorage } from "../src/server/conversation/index.js";
@@ -236,3 +237,19 @@ test("authorization revoked during file resolution prevents dispatch", async t =
236
237
  allowed = true;
237
238
  assert.equal((await f.conversation.read()).conversationLog.length, 0);
238
239
  });
240
+
241
+
242
+ test("attachment validation displays the application's configured binary or decimal limit", async () => {
243
+ for (const [maxBytes, label] of [[500 * 1024 * 1024, "500 MiB"], [100_000_000, "100 MB"]]) {
244
+ let uploaded = false;
245
+ const attachments = useAssistantAttachments({ sessionId: "one", maxBytes,
246
+ uploadAttachment: async () => { uploaded = true; } });
247
+ try {
248
+ await attachments.uploadFiles([{ name: "oversized.bin", size: maxBytes + 1 }]);
249
+ assert.equal(uploaded, false);
250
+ assert.match(attachments.queueItems.value[0].error, new RegExp(label));
251
+ } finally {
252
+ await attachments.disposeAttachments();
253
+ }
254
+ }
255
+ });
@@ -7,6 +7,46 @@ import { createConversationHookBridge, createConversationRuntime } from "../src/
7
7
  import { createClaudeConversationTurn } from "../src/server/conversation/claudeTurn.js";
8
8
  import { createCodexConversationAdapter } from "../src/server/conversation/providers/codex.js";
9
9
  import { createOpenCodeConversationAdapter } from "../src/server/conversation/providers/opencode.js";
10
+ import { readPersistentConversation } from "../src/server/conversation/providers/scoped.js";
11
+ import { createConversationTranscript, createMemoryConversationStorage } from "../src/server/conversation/index.js";
12
+
13
+ test("scoped reads retain each request's output when an accepted steer interrupts the earlier reply", async () => {
14
+ const transcript = createConversationTranscript({ storage: createMemoryConversationStorage() });
15
+ const store = Object.fromEntries(Object.entries(transcript).map(([name, operation]) =>
16
+ [name, (scope, ...args) => operation(JSON.stringify([scope.sessionId, scope.conversationId]), ...args)]));
17
+ store.readConversationStream = () => ({ messages: [] });
18
+ const scope = { sessionId: "session", conversationId: "chat" };
19
+ await store.writeConversationUserMessage(scope, { messageId: "first", text: "First request" });
20
+ await store.writeConversationUserMessage(scope, { messageId: "second", text: "Second request" });
21
+ const record = { conversationId: "chat", providerConversationId: "native", messageId: "second", status: "completed" };
22
+ const observed = [];
23
+ const options = {
24
+ ...scope, record, context: {}, store,
25
+ operations: { async readConversation(_sessionId, input) {
26
+ observed.push(input.requestMessageIds);
27
+ return { status: "completed", messages: [
28
+ { id: "partial-first", requestMessageId: "first", role: "commentary", text: "FIRST-PARTIAL", complete: true },
29
+ { id: "answer-second", requestMessageId: "second", role: "assistant", text: "SECOND-OK", complete: true },
30
+ { id: "late-first", requestMessageId: "first", role: "commentary", text: "FIRST-TAIL", complete: true },
31
+ { id: "unmatched", requestMessageId: "", role: "assistant", text: "Unmatched native input", complete: true }
32
+ ] };
33
+ } },
34
+ records: { input: (record, input) => ({ ...record, ...input }), save: async (_context, record, patch) => ({ ...record, ...patch }) },
35
+ receipts: { persisted: () => true },
36
+ projection: { context: context => context, messageScope: (_context, scope) => scope,
37
+ message: message => message, messageMetadata: () => ({}), snapshot: value => value },
38
+ publish: async () => {}
39
+ };
40
+ await readPersistentConversation(options);
41
+ await readPersistentConversation(options);
42
+ const [first, second] = await store.readConversationLog(scope);
43
+ assert.deepEqual(first.commentary.map(message => message.text), ["FIRST-PARTIAL", "FIRST-TAIL"]);
44
+ assert.equal(first.assistant, null);
45
+ assert.equal(second.assistant.text, "SECOND-OK");
46
+ assert.deepEqual(second.commentary, []);
47
+ assert.equal((await store.readConversationLog(scope)).length, 2);
48
+ assert.deepEqual(observed, [["first", "second"], ["first", "second"]]);
49
+ });
10
50
 
11
51
  function claudeHost() {
12
52
  let process = null;
@@ -238,3 +238,21 @@ test("completed-envelope completion keeps its captured scalar while resolving on
238
238
  messageId: "successor", turnId: "000002", origin: "application", completedEnvelope: true
239
239
  } }).completedMessages, undefined);
240
240
  });
241
+
242
+ test("live message metadata retains its captured attribution across deltas without sharing mutable reads", () => {
243
+ const streams = createConversationStreams();
244
+ const input = { turnId: "native-turn", messageId: "reply", text: "First", metadata: { model: { id: "original" } } };
245
+ streams.update("one", input);
246
+ input.metadata.model.id = "later composer choice";
247
+ streams.update("one", { ...input, delta: " chunk" });
248
+ const first = streams.read("one");
249
+ assert.equal(first.messages[0].metadata.model.id, "original");
250
+ first.messages[0].metadata.model.id = "read mutation";
251
+ const next = streams.update("one", { turnId: "native-turn", messageId: "reply", delta: " again" });
252
+ assert.equal(next.messages[0].metadata.model.id, "original");
253
+ assert.equal(mergeConversationStream([], next)[0].assistant.metadata.model.id, "original");
254
+ streams.complete("one", "reply");
255
+ assert.equal(streams.update("one", { ...input, delta: "late" }), null);
256
+ const other = streams.update("two", { ...input, metadata: { model: { id: "other" } } });
257
+ assert.equal(other.messages[0].metadata.model.id, "other");
258
+ });
@@ -134,3 +134,33 @@ test("chronological messages and application Cancel work on phone and desktop",
134
134
  }
135
135
  } finally { await browser.close(); await stopProcess(vite); }
136
136
  });
137
+
138
+ test("accepted local messages precede later replies before their history page arrives", {
139
+ skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
140
+ }, async () => {
141
+ const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
142
+ const browser = await chromium.launch(createChromiumLaunchOptions());
143
+ try {
144
+ for (const width of [390, 1365]) {
145
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
146
+ const errors = [];
147
+ page.on("pageerror", error => errors.push(error.message));
148
+ await page.goto(`${vite.baseURL}/?delivery=1&accepted-history=1`);
149
+ const rows = page.locator(".assistant-transcript__turn");
150
+ const checkOrder = async () => {
151
+ await expect(rows).toHaveCount(3);
152
+ for (const [index, text] of ["Earlier accepted message", "Later assistant reply", "Still queued"].entries()) {
153
+ await expect(rows.nth(index)).toContainText(text);
154
+ }
155
+ await expect(page.getByText("Pending", { exact: true })).toHaveCount(1);
156
+ };
157
+ await checkOrder();
158
+ await page.getByRole("button", { name: "Load accepted history", exact: true }).click();
159
+ await checkOrder();
160
+ await expect(page.getByText("Earlier accepted message", { exact: true })).toHaveCount(1);
161
+ await page.screenshot({ path: `/tmp/sas35-accepted-order-${width}.png` });
162
+ assert.deepEqual(errors, []);
163
+ await page.close();
164
+ }
165
+ } finally { await browser.close(); await stopProcess(vite); }
166
+ });