@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.
- package/examples/conversation/package.json +1 -1
- package/fixtures/responsive-assistant/ConversationFixture.vue +5 -3
- package/fixtures/responsive-assistant/DeliveryFixture.vue +9 -0
- package/package.json +1 -1
- package/src/client/conversation/AssistantConversationElement.vue +7 -4
- package/src/client/conversation/AssistantGoalControl.vue +30 -16
- package/src/client/conversation/AssistantTranscript.vue +14 -7
- package/src/client/conversation/useAssistantAttachments.js +6 -3
- package/src/server/conversation/claudeClient.js +22 -5
- package/src/server/conversation/claudeHistory.js +62 -5
- package/src/server/conversation/claudeProcess.js +13 -10
- package/src/server/conversation/claudeTurn.js +292 -60
- package/src/server/conversation/codexConfiguration.js +24 -1
- package/src/server/conversation/codexGoalCommands.js +8 -3
- package/src/server/conversation/codexTurnRecovery.js +18 -8
- package/src/server/conversation/continuity.js +54 -17
- package/src/server/conversation/providers/claudeDriver.js +6 -5
- package/src/server/conversation/providers/codexDriver.js +3 -3
- package/src/server/conversation/providers/scoped.js +31 -10
- package/src/server/conversation/runtime.js +48 -13
- package/src/server/conversation/streams.js +7 -3
- package/src/server/conversation/transcript.js +2 -1
- package/src/server/hosts/codexConfiguration.js +1 -0
- package/src/shared/conversation/longTextBlocks.js +0 -4
- package/test/assistantScroll.browser.test.js +74 -3
- package/test/claudeClient.test.js +73 -0
- package/test/claudeConversationSession.test.js +139 -4
- package/test/claudeProcess.test.js +61 -0
- package/test/claudeTurn.test.js +1330 -10
- package/test/codexConversationSession.test.js +9 -1
- package/test/codexGoal.test.js +47 -0
- package/test/codexModelCatalog.test.js +55 -0
- package/test/codexTurn.test.js +228 -0
- package/test/conversationAttachments.test.js +17 -0
- package/test/conversationRuntime.test.js +40 -0
- package/test/conversationStreaming.test.js +18 -0
- 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: "
|
|
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");
|
package/test/codexGoal.test.js
CHANGED
|
@@ -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
|
+
});
|
package/test/codexTurn.test.js
CHANGED
|
@@ -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
|
+
});
|