@jskit-ai/assistant-core 0.1.212 → 0.1.214
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 +5 -5
- package/fixtures/responsive-assistant/ConversationFixture.vue +7 -1
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +5 -1
- package/src/client/conversation/AssistantTranscript.vue +10 -1
- package/src/server/conversation/agentRun.js +38 -11
- package/src/server/conversation/claudeHistory.js +40 -5
- package/src/server/conversation/claudeProcess.js +28 -4
- package/src/server/conversation/claudeTools.js +11 -2
- package/src/server/conversation/claudeTurn.js +213 -23
- package/src/server/conversation/codexConfiguration.js +1 -1
- package/src/server/conversation/codexHistoryAdapter.js +11 -4
- package/src/server/conversation/codexNotificationQueue.js +11 -2
- package/src/server/conversation/codexObservationControl.js +8 -8
- package/src/server/conversation/codexOutput.js +20 -15
- package/src/server/conversation/codexProvider.js +40 -3
- package/src/server/conversation/codexProviderOwner.js +110 -1
- package/src/server/conversation/codexTurn.js +3 -0
- package/src/server/conversation/continuity.js +24 -6
- package/src/server/conversation/index.js +1 -1
- package/src/server/conversation/nativeHistoryExport.js +97 -0
- package/src/server/conversation/openCodeClient.js +47 -1
- package/src/server/conversation/openCodeRuntime.js +183 -28
- package/src/server/conversation/openCodeTurn.js +65 -24
- package/src/server/conversation/providers/api.js +7 -0
- package/src/server/conversation/providers/claudeDriver.js +52 -7
- package/src/server/conversation/providers/codex.js +19 -2
- package/src/server/conversation/providers/codexDriver.js +92 -14
- package/src/server/conversation/providers/opencodeDriver.js +31 -9
- package/src/server/conversation/runtime.js +424 -65
- package/src/server/conversation/runtimeStateUpgrade.js +77 -2
- package/src/server/conversation/streams.js +25 -5
- package/src/server/conversation/tools.js +2 -2
- package/src/server/conversation/transcript.js +23 -8
- package/src/server/hosts/claudeHistory.js +1 -0
- package/src/server/hosts/claudeProcess.js +1 -0
- package/src/server/hosts/codexProvider.js +3 -0
- package/src/server/hosts/nativeHistory.js +1 -0
- package/src/server/hosts/openCodeClient.js +1 -0
- package/src/server/hosts/openCodeTurn.js +1 -0
- package/src/server/index.js +1 -1
- package/src/server/lib/assistantToolLoop.js +99 -2
- package/test/aiConnectionClient.test.js +57 -0
- package/test/assistantBoundedToolLoop.test.js +332 -0
- package/test/assistantScroll.browser.test.js +151 -0
- package/test/claudeConversationSession.test.js +1417 -3
- package/test/claudeProcess.test.js +175 -0
- package/test/claudeTurn.test.js +466 -0
- package/test/codexConversationSession.test.js +1257 -12
- package/test/codexHistoryAdapter.test.js +74 -1
- package/test/codexTurn.test.js +282 -1
- package/test/conversation.test.js +83 -0
- package/test/conversationAgentRun.test.js +181 -0
- package/test/conversationRuntime.test.js +95 -0
- package/test/conversationRuntimeStateUpgrade.test.js +89 -0
- package/test/conversationStreaming.test.js +85 -0
- package/test/conversationTools.test.js +165 -1
- package/test/openCodeClient.test.js +186 -0
- package/test/openCodeRuntime.test.js +372 -0
- package/test/opencodeConversationSession.test.js +637 -5
|
@@ -283,3 +283,335 @@ test("bounded structured alternatives keep exact output bounds and reject open o
|
|
|
283
283
|
required: ["nested"], properties: { nested: deep } };
|
|
284
284
|
assert.throws(() => validateConversationOutputSchema(deep, { maxOutputCharacters: 16_000 }), /nested too deeply/u);
|
|
285
285
|
});
|
|
286
|
+
|
|
287
|
+
// Original fixed-envelope policy: Public 869ebb69 protocol.js:7–39 and
|
|
288
|
+
// colleague/service.js:221–224,364–431. Existing default assertions above stay intact.
|
|
289
|
+
import { readAssistantResponseEnvelope, readPartialAssistantReply } from "../src/server/lib/assistantToolLoop.js";
|
|
290
|
+
import { createConversationTools } from "../src/server/conversation/tools.js";
|
|
291
|
+
|
|
292
|
+
const envelopeReply = text => JSON.stringify({ kind: "reply", text, toolName: "", arguments: "" });
|
|
293
|
+
const envelopeTool = (text = "") => JSON.stringify({ kind: "tool", text, toolName: "bounded_read", arguments: '{"value":"1"}' });
|
|
294
|
+
const envelopeCall = value => JSON.stringify({ kind: "tool", text: "", toolName: "bounded_read", arguments: JSON.stringify({ value }) });
|
|
295
|
+
const envelopePolicy = { maximumResponses: 24, timeoutMs: 90_000 };
|
|
296
|
+
const useEnvelope = ({ phase }) => phase === "before-parse" ? "use" : "end";
|
|
297
|
+
|
|
298
|
+
function durableEnvelopeTools({ effect = () => ({ prompt: "Verified" }), authorize = () => {}, prepareContext,
|
|
299
|
+
save = () => {}, emit = () => {}, propagateErrors = false, previousCalls = [] } = {}) {
|
|
300
|
+
return createConversationTools({ catalog: boundedTools(effect), context: { actor: { id: "captured" }, surface: "app" },
|
|
301
|
+
signal: new AbortController().signal, authorize, prepareContext, save, emit, propagateErrors, previousCalls });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function envelopeLoop(options) {
|
|
305
|
+
let completed = 0;
|
|
306
|
+
return runBoundedAssistantToolLoop({ prompt: "Product-owned prompt", policy: envelopePolicy,
|
|
307
|
+
completedEnvelope: true, settle: useEnvelope, limitError: new Error("Original 24-response limit"),
|
|
308
|
+
invalidResponseError: new Error("Original invalid response"), ...options,
|
|
309
|
+
async complete(...args) {
|
|
310
|
+
// Fixture identities stand for distinct verified completed native responses.
|
|
311
|
+
const result = await options.complete(...args);
|
|
312
|
+
return { toolCallId: `completed-response-${++completed}`, ...result };
|
|
313
|
+
} });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
test("fixed envelope mode requires its original lifetime policy and explicit application settlement", async () => {
|
|
317
|
+
for (const options of [{ policy }, { settle: undefined }]) {
|
|
318
|
+
await assert.rejects(envelopeLoop({ ...options, complete() { assert.fail("Do not infer with an invalid mode contract"); } }),
|
|
319
|
+
/24 responses and application settlement/u);
|
|
320
|
+
}
|
|
321
|
+
await assert.rejects(envelopeLoop({ completedEnvelope: "yes", complete() { assert.fail("Do not infer"); } }), /must be a boolean/u);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("fixed envelopes preserve decoded reply, progress, name and string-argument bounds", () => {
|
|
325
|
+
assert.equal(readAssistantResponseEnvelope(envelopeReply("x".repeat(16000))).text.length, 16000);
|
|
326
|
+
assert.throws(() => readAssistantResponseEnvelope(envelopeReply("x".repeat(16001))), /Invalid assistant response/u);
|
|
327
|
+
assert.equal(readAssistantResponseEnvelope(envelopeTool("x".repeat(280))).text.length, 280);
|
|
328
|
+
assert.throws(() => readAssistantResponseEnvelope(envelopeTool("x".repeat(281))), /progress text/u);
|
|
329
|
+
const value = { kind: "tool", text: "", toolName: "x".repeat(256), arguments: "x".repeat(262144) };
|
|
330
|
+
assert.equal(readAssistantResponseEnvelope(JSON.stringify(value)).arguments.length, 262144);
|
|
331
|
+
for (const invalid of [{ ...value, toolName: "x".repeat(257) }, { ...value, arguments: "x".repeat(262145) },
|
|
332
|
+
{ ...value, arguments: {} }, { ...value, extra: true }, [], { kind: "reply", text: "" },
|
|
333
|
+
{ kind: "reply", text: "hello", toolName: "hidden" }]) {
|
|
334
|
+
assert.throws(() => readAssistantResponseEnvelope(JSON.stringify(invalid)));
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Body moved from frozen vibe64Colleague.unit.test.js:161–173; generic names only.
|
|
339
|
+
test("fixed envelopes decode only reply text, including every split inside JSON escapes", () => {
|
|
340
|
+
const text = 'Hello "there"!\n\\path 🐈 café';
|
|
341
|
+
const encoded = envelopeReply(text).replace("🐈", "\\ud83d\\udc08").replace("é", "\\u00e9");
|
|
342
|
+
for (let end = 0; end <= encoded.length; end += 1) {
|
|
343
|
+
const partial = readPartialAssistantReply(encoded.slice(0, end));
|
|
344
|
+
assert.ok(text.startsWith(partial), JSON.stringify(partial));
|
|
345
|
+
assert.doesNotMatch(partial, /[\uD800-\uDBFF]$/);
|
|
346
|
+
}
|
|
347
|
+
assert.equal(readPartialAssistantReply(encoded), text);
|
|
348
|
+
for (const hidden of [envelopeCall("private arguments"), '{"kind":"tool","text":"secret', 'Thinking first', '{"text":"unknown kind']) {
|
|
349
|
+
assert.equal(readPartialAssistantReply(hidden), "");
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("fixed envelope mode passes finite fixed wire fields and actual prior tool results to the product completion", async () => {
|
|
354
|
+
const traces = [];
|
|
355
|
+
let calls = 0;
|
|
356
|
+
const tools = durableEnvelopeTools({ effect({ value }, context) {
|
|
357
|
+
assert.equal(context.actor.id, "captured");
|
|
358
|
+
return { prompt: `Result ${value}` };
|
|
359
|
+
}, save(records) { traces.push(records.map(record => record.status)); } });
|
|
360
|
+
const result = await envelopeLoop({ async complete(prompt, options) {
|
|
361
|
+
assert.equal(prompt, "Product-owned prompt");
|
|
362
|
+
assert.equal(options.timeoutMs, 90_000);
|
|
363
|
+
const properties = options.outputSchema.properties;
|
|
364
|
+
assert.equal(properties.text.maxLength, 16000);
|
|
365
|
+
assert.equal(properties.toolName.maxLength, 256);
|
|
366
|
+
assert.equal(properties.arguments.maxLength, 262144);
|
|
367
|
+
if (++calls === 1) {
|
|
368
|
+
assert.equal(options.previousResponse, undefined);
|
|
369
|
+
return { text: envelopeTool(), tools };
|
|
370
|
+
}
|
|
371
|
+
assert.equal(options.previousResponse.kind, "tool");
|
|
372
|
+
assert.equal(options.previousResponse.arguments, '{"value":"1"}');
|
|
373
|
+
assert.equal(options.previousResponse.result.ok, true);
|
|
374
|
+
assert.equal(options.previousResponse.result.result.prompt, "Result 1");
|
|
375
|
+
return { text: envelopeReply("Done") };
|
|
376
|
+
} });
|
|
377
|
+
assert.equal(result, "Done");
|
|
378
|
+
assert.deepEqual(traces, [["running"], ["complete"]]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("fixed envelope response 24 executes its effect before the exact limit error and never starts response 25", async () => {
|
|
382
|
+
let responses = 0;
|
|
383
|
+
let effects = 0;
|
|
384
|
+
const limitError = new Error("Original limit");
|
|
385
|
+
await assert.rejects(envelopeLoop({ limitError, async complete() {
|
|
386
|
+
responses++;
|
|
387
|
+
return { text: envelopeTool(), tools: durableEnvelopeTools({ effect() { effects++; return { prompt: "Done" }; } }) };
|
|
388
|
+
} }), error => error === limitError);
|
|
389
|
+
assert.equal(responses, 24);
|
|
390
|
+
assert.equal(effects, 24);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("superseded malformed completed responses consume response slots before parsing without spending invalid allowance", async () => {
|
|
394
|
+
let responses = 0;
|
|
395
|
+
let effects = 0;
|
|
396
|
+
const limitError = new Error("Original limit");
|
|
397
|
+
await assert.rejects(envelopeLoop({ limitError, settle: () => "continue", async complete() {
|
|
398
|
+
responses++;
|
|
399
|
+
return { text: "malformed obsolete response", tools: durableEnvelopeTools({ effect() { effects++; } }) };
|
|
400
|
+
} }), error => error === limitError);
|
|
401
|
+
assert.equal(responses, 24);
|
|
402
|
+
assert.equal(effects, 0);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("invalid allowance is cumulative across valid effects and discarded pending responses", async () => {
|
|
406
|
+
const replies = ["malformed", envelopeTool(), "obsolete malformed", "malformed again", envelopeTool(), "third malformed"];
|
|
407
|
+
let responses = 0;
|
|
408
|
+
let effects = 0;
|
|
409
|
+
const invalidResponseError = new Error("Original third-invalid failure");
|
|
410
|
+
await assert.rejects(envelopeLoop({ invalidResponseError,
|
|
411
|
+
settle: ({ phase }) => phase === "before-parse" && responses === 3 ? "continue" : "use",
|
|
412
|
+
async complete(_prompt, options) {
|
|
413
|
+
if ([2, 5].includes(responses + 1)) assert.deepEqual(options.previousResponse, { kind: "invalid", nativeToolAttempt: false });
|
|
414
|
+
return { text: replies[responses++], tools: durableEnvelopeTools({ effect() { effects++; return { prompt: "Verified" }; } }) };
|
|
415
|
+
}
|
|
416
|
+
}), error => error === invalidResponseError);
|
|
417
|
+
assert.equal(responses, 6);
|
|
418
|
+
assert.equal(effects, 2);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("native application-tool misroutes invalidate replies but permit corrected completed tool envelopes and StructuredOutput replies", async () => {
|
|
422
|
+
let responses = 0;
|
|
423
|
+
let effects = 0;
|
|
424
|
+
const result = await envelopeLoop({ async complete(_prompt, options) {
|
|
425
|
+
responses++;
|
|
426
|
+
if (responses === 1) return { text: envelopeReply("False outage"), nativeToolAttempt: true };
|
|
427
|
+
if (responses === 2) {
|
|
428
|
+
assert.deepEqual(options.previousResponse, { kind: "invalid", nativeToolAttempt: true });
|
|
429
|
+
return { text: envelopeTool(), nativeToolAttempt: true,
|
|
430
|
+
tools: durableEnvelopeTools({ effect() { effects++; return { prompt: "Verified" }; } }) };
|
|
431
|
+
}
|
|
432
|
+
return { text: envelopeReply("Verified answer"), nativeToolAttempt: false };
|
|
433
|
+
} });
|
|
434
|
+
assert.equal(result, "Verified answer");
|
|
435
|
+
assert.equal(effects, 1);
|
|
436
|
+
assert.equal(responses, 3);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("late pending admission after decoded final persistence continues inside the same response budget", async t => {
|
|
440
|
+
const admission = Promise.withResolvers();
|
|
441
|
+
const entered = Promise.withResolvers();
|
|
442
|
+
t.after(() => admission.resolve());
|
|
443
|
+
const saved = [];
|
|
444
|
+
let responses = 0;
|
|
445
|
+
const run = envelopeLoop({ async complete() {
|
|
446
|
+
return { text: envelopeReply(++responses === 1 ? "First answer" : "Latest answer") };
|
|
447
|
+
}, async settle({ phase, response }) {
|
|
448
|
+
if (phase === "before-parse") return "use";
|
|
449
|
+
saved.push(response.text);
|
|
450
|
+
if (saved.length === 1) { entered.resolve(); await admission.promise; return "continue"; }
|
|
451
|
+
return "end";
|
|
452
|
+
} });
|
|
453
|
+
await entered.promise;
|
|
454
|
+
assert.equal(responses, 1, "The next native response waits for the original app admission tail");
|
|
455
|
+
admission.resolve();
|
|
456
|
+
assert.equal(await run, "Latest answer");
|
|
457
|
+
assert.equal(responses, 2);
|
|
458
|
+
assert.deepEqual(saved, ["First answer", "Latest answer"]);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("settlement refuses unknown decisions before effects and final return", async () => {
|
|
462
|
+
for (const final of [false, true]) {
|
|
463
|
+
let effects = 0;
|
|
464
|
+
await assert.rejects(envelopeLoop({ async complete() {
|
|
465
|
+
return { text: final ? envelopeReply("Do not finish") : envelopeTool(),
|
|
466
|
+
tools: durableEnvelopeTools({ effect() { effects++; } }) };
|
|
467
|
+
}, settle: ({ phase }) => final && phase === "before-parse" ? "use" : "unexpected" }), /Invalid completed-response settlement/u);
|
|
468
|
+
assert.equal(effects, 0);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("completed envelope mode preserves abort before settlement and effects", async () => {
|
|
473
|
+
for (const stage of ["completion", "settlement"]) {
|
|
474
|
+
const controller = new AbortController();
|
|
475
|
+
const reason = new Error("Original cancellation");
|
|
476
|
+
let effects = 0;
|
|
477
|
+
await assert.rejects(envelopeLoop({ signal: controller.signal, async complete() {
|
|
478
|
+
if (stage === "completion") controller.abort(reason);
|
|
479
|
+
return { text: envelopeTool(), tools: durableEnvelopeTools({ effect() { effects++; } }) };
|
|
480
|
+
}, settle() {
|
|
481
|
+
assert.equal(stage, "settlement", "Completion cancellation precedes any settlement");
|
|
482
|
+
controller.abort(reason);
|
|
483
|
+
return "use";
|
|
484
|
+
} }), error => error === reason);
|
|
485
|
+
assert.equal(effects, 0);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("completed operations require supplied tools and retain sealed request refusal without effect or new reservation", async () => {
|
|
490
|
+
await assert.rejects(envelopeLoop({ async complete() { return { text: envelopeTool() }; } }), /current durable tools/u);
|
|
491
|
+
const retired = new Error("The original request is sealed");
|
|
492
|
+
let effects = 0;
|
|
493
|
+
let saves = 0;
|
|
494
|
+
const tools = durableEnvelopeTools({ prepareContext() { throw retired; }, effect() { effects++; }, save() { saves++; } });
|
|
495
|
+
await assert.rejects(envelopeLoop({ async complete() { return { text: envelopeTool(), tools }; } }), error => error === retired);
|
|
496
|
+
assert.equal(effects, 0);
|
|
497
|
+
assert.equal(saves, 0);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("completed effect preserves the original host error and rechecks fresh authorization after durable reservation", async () => {
|
|
501
|
+
const failure = new Error("Original effect error");
|
|
502
|
+
await assert.rejects(envelopeLoop({ async complete() {
|
|
503
|
+
return { text: envelopeTool(), tools: durableEnvelopeTools({ effect() { throw failure; }, propagateErrors: true }) };
|
|
504
|
+
} }), error => error === failure);
|
|
505
|
+
const revoked = new Error("Original actor no longer authorized");
|
|
506
|
+
let eligible = true;
|
|
507
|
+
let effects = 0;
|
|
508
|
+
const statuses = [];
|
|
509
|
+
const tools = durableEnvelopeTools({ authorize() { if (!eligible) throw revoked; }, effect() { effects++; },
|
|
510
|
+
save(records) { statuses.push(records.at(-1).status); eligible = false; } });
|
|
511
|
+
await assert.rejects(envelopeLoop({ async complete() { return { text: envelopeTool(), tools }; } }), error => error === revoked);
|
|
512
|
+
assert.equal(effects, 0);
|
|
513
|
+
assert.deepEqual(statuses, ["running", "not-executed"]);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("unknown completed effect stops the bounded loop and its durable receipt cannot replay the effect", async () => {
|
|
517
|
+
let responses = 0;
|
|
518
|
+
let effects = 0;
|
|
519
|
+
const tools = durableEnvelopeTools({ effect() {
|
|
520
|
+
effects++;
|
|
521
|
+
throw Object.assign(new Error("Receipt was lost after execution"), { statusCode: 503 });
|
|
522
|
+
} });
|
|
523
|
+
await assert.rejects(envelopeLoop({ async complete() { responses++; return { text: envelopeTool(), tools }; } }),
|
|
524
|
+
error => error.code === "conversation_tool_outcome_unknown");
|
|
525
|
+
assert.equal(responses, 1);
|
|
526
|
+
assert.equal(effects, 1);
|
|
527
|
+
const receipt = tools.records()[0];
|
|
528
|
+
assert.equal(receipt.status, "unknown");
|
|
529
|
+
await assert.rejects(tools.execute({ id: receipt.id, name: receipt.name, arguments: receipt.arguments }),
|
|
530
|
+
error => error.code === "conversation_tool_outcome_unknown");
|
|
531
|
+
assert.equal(effects, 1);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
test("oversized completed tool progress is corrected before any reservation and valid progress executes once", async () => {
|
|
536
|
+
let responses = 0;
|
|
537
|
+
let effects = 0;
|
|
538
|
+
let reservations = 0;
|
|
539
|
+
const result = await envelopeLoop({ async complete(_prompt, options) {
|
|
540
|
+
responses++;
|
|
541
|
+
if (responses === 3) return { text: envelopeReply("Done") };
|
|
542
|
+
if (responses === 2) assert.deepEqual(options.previousResponse, { kind: "invalid", nativeToolAttempt: false });
|
|
543
|
+
return { text: envelopeTool("x".repeat(responses === 1 ? 281 : 280)),
|
|
544
|
+
tools: durableEnvelopeTools({ effect() { effects++; return { prompt: "Verified" }; },
|
|
545
|
+
save(records) { if (records.at(-1).status === "running") reservations++; } }) };
|
|
546
|
+
} });
|
|
547
|
+
assert.equal(result, "Done");
|
|
548
|
+
assert.equal(effects, 1);
|
|
549
|
+
assert.equal(reservations, 1);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
test("third invalid envelope selects the product error from the current native fact only once", async () => {
|
|
554
|
+
const malformed = new Error("Original malformed-response failure");
|
|
555
|
+
const misrouted = new Error("Original native-tool-response failure");
|
|
556
|
+
for (const nativeToolAttempt of [false, true]) {
|
|
557
|
+
let responses = 0;
|
|
558
|
+
const selectedFacts = [];
|
|
559
|
+
await assert.rejects(envelopeLoop({ invalidResponseError(fact) {
|
|
560
|
+
selectedFacts.push(fact);
|
|
561
|
+
return fact.nativeToolAttempt ? misrouted : malformed;
|
|
562
|
+
}, async complete() {
|
|
563
|
+
responses++;
|
|
564
|
+
return responses === 3
|
|
565
|
+
? { text: nativeToolAttempt ? envelopeReply("False outage") : "malformed", nativeToolAttempt }
|
|
566
|
+
: { text: "malformed", nativeToolAttempt: !nativeToolAttempt };
|
|
567
|
+
} }), error => error === (nativeToolAttempt ? misrouted : malformed));
|
|
568
|
+
assert.equal(responses, 3);
|
|
569
|
+
assert.deepEqual(selectedFacts, [{ nativeToolAttempt }]);
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("opt-in third-invalid error factory must return an Error", async () => {
|
|
574
|
+
let calls = 0;
|
|
575
|
+
await assert.rejects(envelopeLoop({ invalidResponseError() { calls++; return "not an Error"; },
|
|
576
|
+
async complete() { return { text: "malformed" }; } }), /Invalid response error must be an Error/u);
|
|
577
|
+
assert.equal(calls, 1);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
test("completed application operations refuse absent or invalid verified response call ids before reservation", async () => {
|
|
582
|
+
for (const toolCallId of [undefined, "", "x".repeat(257), 42]) {
|
|
583
|
+
let effects = 0;
|
|
584
|
+
let saves = 0;
|
|
585
|
+
const tools = durableEnvelopeTools({ effect() { effects++; }, save() { saves++; } });
|
|
586
|
+
await assert.rejects(envelopeLoop({ async complete() { return { text: envelopeTool(), tools, toolCallId }; } }),
|
|
587
|
+
/verified response call id/u);
|
|
588
|
+
assert.equal(effects, 0);
|
|
589
|
+
assert.equal(saves, 0);
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("recovered completed response reuses its restored durable call id without repeating the effect or accepting changed arguments", async () => {
|
|
594
|
+
const toolCallId = "verified-native-response-1:application-operation";
|
|
595
|
+
let effects = 0;
|
|
596
|
+
const initialTools = durableEnvelopeTools({ effect() { effects++; return { prompt: "Verified once" }; } });
|
|
597
|
+
const run = tools => {
|
|
598
|
+
let responses = 0;
|
|
599
|
+
return envelopeLoop({ async complete() {
|
|
600
|
+
return ++responses === 1 ? { text: envelopeTool(), tools, toolCallId } : { text: envelopeReply("Done") };
|
|
601
|
+
} });
|
|
602
|
+
};
|
|
603
|
+
assert.equal(await run(initialTools), "Done");
|
|
604
|
+
const previousCalls = initialTools.records();
|
|
605
|
+
assert.equal(previousCalls[0].id, toolCallId);
|
|
606
|
+
assert.equal(previousCalls[0].status, "complete");
|
|
607
|
+
const restoredTools = durableEnvelopeTools({ previousCalls,
|
|
608
|
+
effect() { assert.fail("Recovery must use the saved verified result"); } });
|
|
609
|
+
assert.equal(await run(restoredTools), "Done");
|
|
610
|
+
assert.equal(effects, 1);
|
|
611
|
+
const changedTools = durableEnvelopeTools({ previousCalls,
|
|
612
|
+
effect() { assert.fail("A response id cannot authorize changed arguments"); } });
|
|
613
|
+
const changed = JSON.stringify({ kind: "tool", text: "", toolName: "bounded_read", arguments: '{"value":"2"}' });
|
|
614
|
+
await assert.rejects(envelopeLoop({ async complete() { return { text: changed, tools: changedTools, toolCallId }; } }),
|
|
615
|
+
/already belongs to different arguments/u);
|
|
616
|
+
assert.equal(effects, 1);
|
|
617
|
+
});
|
|
@@ -1149,3 +1149,154 @@ test("pending history keeps following the reader when a new reply changes transc
|
|
|
1149
1149
|
await stopProcess(vite);
|
|
1150
1150
|
}
|
|
1151
1151
|
});
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
test("connection warnings keep avatar controls and Reload reachable without replacing history", {
|
|
1155
|
+
skip: !RUN_BROWSER_TEST, timeout: 90_000
|
|
1156
|
+
}, async () => {
|
|
1157
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
1158
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
1159
|
+
try {
|
|
1160
|
+
for (const width of [320, 800]) {
|
|
1161
|
+
const page = await browser.newPage({ viewport: { width, height: 1000 } });
|
|
1162
|
+
await page.goto(`${vite.baseURL}/?conversation=1&avatar=1&avatarTools=1&update-errors=1`);
|
|
1163
|
+
const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
|
|
1164
|
+
await external("Resize height");
|
|
1165
|
+
const conversation = page.locator(".assistant-conversation");
|
|
1166
|
+
const avatar = page.locator(".assistant-conversation__avatar");
|
|
1167
|
+
const body = page.locator(".assistant-transcript__body");
|
|
1168
|
+
const draft = page.getByRole("textbox", { name: "Message AI assistant", exact: true });
|
|
1169
|
+
const banner = page.locator(".assistant-transcript__error");
|
|
1170
|
+
const reload = banner.getByRole("button", { name: "Reload chat", exact: true });
|
|
1171
|
+
await draft.fill("Keep this draft and the loaded history.");
|
|
1172
|
+
await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
|
|
1173
|
+
await body.hover();
|
|
1174
|
+
await page.mouse.wheel(0, -1000);
|
|
1175
|
+
await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeGreaterThan(50);
|
|
1176
|
+
await body.evaluate(element => { element.scrollTop = 420; window.errorAvatarBody = element; });
|
|
1177
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
|
|
1178
|
+
const history = await body.locator(".assistant-transcript__turn").allTextContents();
|
|
1179
|
+
let reloads = 0;
|
|
1180
|
+
for (const size of ["standard", "large", "hidden"]) {
|
|
1181
|
+
await page.getByRole("combobox", { name: "Avatar preference", exact: true }).selectOption(size);
|
|
1182
|
+
await external("Show update error");
|
|
1183
|
+
await expect(conversation).toHaveClass(/assistant-conversation--error/u);
|
|
1184
|
+
await expect(avatar).toHaveCSS("position", "relative");
|
|
1185
|
+
await expect(banner).toContainText("Conversation updates are temporarily unavailable.");
|
|
1186
|
+
await expect(reload).toBeVisible();
|
|
1187
|
+
const avatarBox = await avatar.boundingBox();
|
|
1188
|
+
const bannerBox = await banner.boundingBox();
|
|
1189
|
+
assert.ok(avatarBox.y + avatarBox.height <= bannerBox.y, "The entire avatar and its controls stay above the warning");
|
|
1190
|
+
const controls = size === "hidden"
|
|
1191
|
+
? [page.getByRole("button", { name: "Retained avatar control", exact: true }), page.getByRole("button", { name: "Show avatar", exact: true })]
|
|
1192
|
+
: [page.getByRole("button", { name: "Avatar tool", exact: true }), page.getByRole("button", { name: "Minimise avatar", exact: true })];
|
|
1193
|
+
for (const target of [reload, ...controls]) {
|
|
1194
|
+
await expect(target).toBeVisible();
|
|
1195
|
+
assert.equal(await target.evaluate(element => {
|
|
1196
|
+
const rect = element.getBoundingClientRect();
|
|
1197
|
+
return element.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2));
|
|
1198
|
+
}), true, "The ordinary pointer reaches each recovery and avatar control");
|
|
1199
|
+
}
|
|
1200
|
+
assert.equal(await body.evaluate(element => element === window.errorAvatarBody), true);
|
|
1201
|
+
assert.deepEqual(await body.locator(".assistant-transcript__turn").allTextContents(), history);
|
|
1202
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
|
|
1203
|
+
await expect(draft).toHaveValue("Keep this draft and the loaded history.");
|
|
1204
|
+
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
1205
|
+
await reload.click();
|
|
1206
|
+
await expect(banner).toHaveCount(0);
|
|
1207
|
+
await expect(avatar).toHaveCSS("position", "absolute");
|
|
1208
|
+
await expect(page.getByText(`Error reloads: ${++reloads}`, { exact: true })).toBeVisible();
|
|
1209
|
+
assert.equal(await body.evaluate(element => element === window.errorAvatarBody), true);
|
|
1210
|
+
assert.deepEqual(await body.locator(".assistant-transcript__turn").allTextContents(), history);
|
|
1211
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
|
|
1212
|
+
await expect(draft).toHaveValue("Keep this draft and the loaded history.");
|
|
1213
|
+
}
|
|
1214
|
+
await page.close();
|
|
1215
|
+
}
|
|
1216
|
+
} finally {
|
|
1217
|
+
await browser.close();
|
|
1218
|
+
await stopProcess(vite);
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
test("short connection warnings preserve full text and recovery targets at phone widths", {
|
|
1224
|
+
skip: !RUN_BROWSER_TEST, timeout: 90_000
|
|
1225
|
+
}, async () => {
|
|
1226
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
1227
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
1228
|
+
try {
|
|
1229
|
+
for (const width of [320, 390]) {
|
|
1230
|
+
const page = await browser.newPage({ viewport: { width, height: 768 } });
|
|
1231
|
+
await page.goto(`${vite.baseURL}/?conversation=1&avatar=1&avatarTools=1&update-errors=1`);
|
|
1232
|
+
await page.locator(".fixture").evaluate(element => {
|
|
1233
|
+
element.style.height = "368px";
|
|
1234
|
+
element.style.width = "100%";
|
|
1235
|
+
});
|
|
1236
|
+
await page.getByRole("combobox", { name: "Avatar preference", exact: true }).selectOption("standard");
|
|
1237
|
+
const conversation = page.locator(".assistant-conversation");
|
|
1238
|
+
const body = page.locator(".assistant-transcript__body");
|
|
1239
|
+
const draft = page.getByRole("textbox", { name: "Message AI assistant", exact: true });
|
|
1240
|
+
const banner = page.locator(".assistant-transcript__error");
|
|
1241
|
+
const reload = banner.getByRole("button", { name: "Reload chat", exact: true });
|
|
1242
|
+
await draft.fill("Keep this short-view draft.");
|
|
1243
|
+
const originalBody = await body.elementHandle();
|
|
1244
|
+
const history = await body.locator(".assistant-transcript__turn").allTextContents();
|
|
1245
|
+
await page.getByRole("button", { name: "Show update error", exact: true }).evaluate(element => element.click());
|
|
1246
|
+
await expect(banner).toContainText("the assistant may still be working.");
|
|
1247
|
+
const bounds = await banner.evaluate(element => {
|
|
1248
|
+
const box = node => {
|
|
1249
|
+
const rect = node.getBoundingClientRect();
|
|
1250
|
+
return { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right };
|
|
1251
|
+
};
|
|
1252
|
+
return { banner: box(element), content: box(element.querySelector(".v-alert__content")),
|
|
1253
|
+
words: box(element.querySelector(".assistant-transcript__error-content > span")),
|
|
1254
|
+
button: box(element.querySelector("button")) };
|
|
1255
|
+
});
|
|
1256
|
+
for (const target of [bounds.words, bounds.button]) {
|
|
1257
|
+
assert.ok(target.top >= bounds.content.top - 1 && target.bottom <= bounds.content.bottom + 1,
|
|
1258
|
+
"The error content reserves the full wrapped text and Reload height");
|
|
1259
|
+
assert.ok(target.top >= bounds.banner.top - 1 && target.bottom <= bounds.banner.bottom + 1,
|
|
1260
|
+
"The warning does not clip its text or recovery button");
|
|
1261
|
+
}
|
|
1262
|
+
const transcriptBounds = await page.locator(".assistant-transcript").evaluate(element => ({
|
|
1263
|
+
top: element.getBoundingClientRect().top,
|
|
1264
|
+
bottom: element.getBoundingClientRect().bottom,
|
|
1265
|
+
scrollTop: element.scrollTop
|
|
1266
|
+
}));
|
|
1267
|
+
assert.ok(bounds.banner.top >= transcriptBounds.top - 1 && bounds.banner.bottom <= transcriptBounds.bottom + 1,
|
|
1268
|
+
"The parent allocates the complete warning height instead of clipping its own error row");
|
|
1269
|
+
assert.equal(transcriptBounds.scrollTop, 0, "Reading the warning does not require scrolling the hidden transcript container");
|
|
1270
|
+
await expect(conversation).toHaveCSS("overflow-y", "auto");
|
|
1271
|
+
await reload.scrollIntoViewIfNeeded();
|
|
1272
|
+
assert.equal(await page.locator(".assistant-transcript").evaluate(element => element.scrollTop), 0,
|
|
1273
|
+
"The ordinary recovery target scrolls through the error conversation, not its clipped transcript");
|
|
1274
|
+
assert.equal(await reload.evaluate(element => {
|
|
1275
|
+
const rect = element.getBoundingClientRect();
|
|
1276
|
+
return [2, rect.height / 2, rect.height - 2].every(offset =>
|
|
1277
|
+
element.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + offset)));
|
|
1278
|
+
}), true, "The pointer reaches the top, centre and bottom of Reload");
|
|
1279
|
+
await reload.focus();
|
|
1280
|
+
await reload.press("Tab");
|
|
1281
|
+
assert.equal(await body.evaluate(element => document.activeElement === element), true,
|
|
1282
|
+
"The original transcript remains keyboard reachable after Reload");
|
|
1283
|
+
await draft.focus();
|
|
1284
|
+
assert.equal(await draft.evaluate(element => document.activeElement === element), true);
|
|
1285
|
+
await expect(draft).toHaveValue("Keep this short-view draft.");
|
|
1286
|
+
assert.equal(await body.evaluate((element, original) => element === original, originalBody), true);
|
|
1287
|
+
assert.deepEqual(await body.locator(".assistant-transcript__turn").allTextContents(), history);
|
|
1288
|
+
await reload.click();
|
|
1289
|
+
await expect(banner).toHaveCount(0);
|
|
1290
|
+
await expect(conversation).not.toHaveClass(/assistant-conversation--error/u);
|
|
1291
|
+
await expect(page.getByText("Error reloads: 1", { exact: true })).toBeVisible();
|
|
1292
|
+
assert.equal(await body.evaluate((element, original) => element === original, originalBody), true);
|
|
1293
|
+
assert.deepEqual(await body.locator(".assistant-transcript__turn").allTextContents(), history);
|
|
1294
|
+
await expect(draft).toHaveValue("Keep this short-view draft.");
|
|
1295
|
+
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
1296
|
+
await page.close();
|
|
1297
|
+
}
|
|
1298
|
+
} finally {
|
|
1299
|
+
await browser.close();
|
|
1300
|
+
await stopProcess(vite);
|
|
1301
|
+
}
|
|
1302
|
+
});
|