@jskit-ai/assistant-core 0.1.202 → 0.1.204

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.202",
12
- "@jskit-ai/http-runtime": "0.1.222",
13
- "@jskit-ai/kernel": "0.1.224",
11
+ "@jskit-ai/assistant-core": "0.1.204",
12
+ "@jskit-ai/http-runtime": "0.1.224",
13
+ "@jskit-ai/kernel": "0.1.226",
14
14
  "@mdi/js": "^7.4.47",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.1.41",
18
- "@jskit-ai/connectors-catalog": "0.1.41"
17
+ "@jskit-ai/connectors-core": "0.1.43",
18
+ "@jskit-ai/connectors-catalog": "0.1.43"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -4,12 +4,22 @@ import AssistantConversationElement from "../../src/client/conversation/Assistan
4
4
  import { useAssistantAttachments } from "../../src/client/conversation/useAssistantAttachments.js";
5
5
  import { useAssistantSuggestions } from "../../src/client/conversation/useAssistantSuggestions.js";
6
6
  const phase = ref("idle");
7
+ const avatarFixture = new URLSearchParams(location.search).has("avatar");
8
+ const showAvatar = ref(avatarFixture);
9
+ const avatarSize = ref("compact");
10
+ const short = ref(avatarFixture);
11
+ const showQuestions = ref(false);
12
+ const questionState = reactive({
13
+ questions: [{ name: "next", number: 1, label: "What should happen next?", choices: [] }], answers: {},
14
+ setAnswers(value) { questionState.answers = value; },
15
+ dismiss() { showQuestions.value = false; }
16
+ });
7
17
  const attribution = new URLSearchParams(location.search).has("attribution");
8
18
  const assistantLabel = ref("Current assistant");
9
19
  const draft = ref("");
10
20
  const narrow = ref(false);
11
21
  const submissions = ref(0);
12
- const feedback = ref(false);
22
+ const feedback = ref(avatarFixture);
13
23
  const supportEnabled = new URLSearchParams(location.search).has("support");
14
24
  const suggestionModel = ref("small-model");
15
25
  const customActivity = ref(false);
@@ -61,13 +71,17 @@ const adapter = reactive({
61
71
  { turnId: "two", assistantLabel: "Second assistant", assistantDetails: "Second model",
62
72
  assistant: { role: "assistant", text: "Second answer", assistantLabel: "Message assistant", assistantDetails: "Message model" } },
63
73
  { turnId: "three", assistant: { role: "assistant", text: "Unattributed answer" } }
64
- ] : [],
74
+ ] : avatarFixture ? Array.from({ length: 20 }, (_, index) => ({
75
+ turnId: `help-${index}`, user: { messageId: `question-${index}`, text: `Support question ${index + 1}: How can I recover my account?` },
76
+ assistant: { messageId: `answer-${index}`, outputId: `output-${index}`, role: "assistant",
77
+ text: `Support answer ${index + 1}: Keep your recovery email available.\n\nRead the **account instructions** and follow the [recovery link](https://example.com/help). A long message wraps inside this narrow conversation without taking space from the composer.` }
78
+ })) : [],
65
79
  visible: true, scrollKey: "fixture", welcomeMessage: "Composer responsiveness fixture"
66
80
  },
67
81
  composer: {
68
82
  draft,
69
83
  rows: 2,
70
- density: computed(() => narrow.value ? "compact" : "default"),
84
+ density: computed(() => narrow.value || avatarFixture ? "compact" : "default"),
71
85
  submitOnEnter: true,
72
86
  canSend: computed(() => Boolean(draft.value.trim()) && ["idle", "active", "stopped"].includes(phase.value)),
73
87
  canStop: computed(() => ["active", "stopping"].includes(phase.value)),
@@ -77,6 +91,7 @@ const adapter = reactive({
77
91
  suggestions, goal: goalState,
78
92
  attachments: capabilitiesEnabled ? attachments : undefined,
79
93
  models: capabilitiesEnabled ? models : undefined,
94
+ questions: computed(() => showQuestions.value ? questionState : undefined),
80
95
  actions: {
81
96
  setDraft(value) { draft.value = value; },
82
97
  submit(payload) { attachmentReceipts.value = payload.attachments; attachments.clearAttachments(); submissions.value += 1; draft.value = ""; phase.value = "active"; },
@@ -96,11 +111,19 @@ const adapter = reactive({
96
111
  <button v-if="supportEnabled" @click="customActivity = !customActivity">Custom activity</button>
97
112
  <button v-if="capabilitiesEnabled" @click="finishUploads">Finish uploads</button>
98
113
  <button @click="goalEnabled = !goalEnabled">Toggle goals</button>
114
+ <template v-if="avatarFixture">
115
+ <button @click="showAvatar = !showAvatar">Toggle avatar slot</button>
116
+ <button @click="short = !short">Resize height</button>
117
+ <button @click="showQuestions = !showQuestions">Toggle questions</button>
118
+ <button @click="attachments.status.value = 'An upload failed. Retry or remove it.'">Show attachment error</button>
119
+ <output>Avatar requested: {{ avatarSize }}</output>
120
+ </template>
99
121
  <output>{{ phase }}; submitted {{ submissions }}</output>
100
122
  <output v-if="capabilitiesEnabled">Model: {{ appliedModel }}; sent files: {{ attachmentReceipts.map(item => item.fileName).join(', ') }}</output>
101
123
  </div>
102
- <main class="fixture" :class="{ narrow }">
103
- <AssistantConversationElement :adapter="adapter">
124
+ <main class="fixture" :class="{ narrow, 'fixture--avatar': avatarFixture, 'fixture--short': short }">
125
+ <AssistantConversationElement v-model:avatar-size="avatarSize" :adapter="adapter">
126
+ <template v-if="showAvatar" #avatar><svg viewBox="0 0 100 100" role="img" aria-label="Support assistant avatar"><circle cx="50" cy="50" r="45" fill="#6750a4" /><path d="M30 62Q50 80 70 62M35 40h1m28 0h1" fill="none" stroke="white" stroke-width="6" stroke-linecap="round" /></svg></template>
104
127
  <template v-if="customActivity" #activity="{ activity }"><strong>Custom activity: {{ activity.label }}</strong></template>
105
128
  <template #composer-feedback><p v-if="feedback" class="feedback" role="status">Describe what you want to change.</p></template>
106
129
  </AssistantConversationElement>
@@ -113,5 +136,7 @@ const adapter = reactive({
113
136
  .controls button { border: 1px solid; padding: 4px; }
114
137
  .fixture { height: 70vh; width: min(900px, 100%); margin: auto; }
115
138
  .fixture.narrow { width: min(320px, 100%); }
139
+ .fixture--avatar { width: min(320px, 100%); }
140
+ .fixture--short { height: 420px; }
116
141
  .feedback { flex: 1 1 24rem; min-width: 0; margin: 0; }
117
142
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.202",
3
+ "version": "0.1.204",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -45,8 +45,8 @@
45
45
  "@ai-sdk/perplexity": "^4.0.43",
46
46
  "@ai-sdk/togetherai": "^3.0.49",
47
47
  "@ai-sdk/xai": "^4.0.58",
48
- "@jskit-ai/resource-core": "0.1.166",
49
- "@jskit-ai/resource-crud-core": "0.1.166",
48
+ "@jskit-ai/resource-core": "0.1.168",
49
+ "@jskit-ai/resource-crud-core": "0.1.168",
50
50
  "@mdi/js": "^7.4.47",
51
51
  "@openrouter/ai-sdk-provider": "^3.0.0",
52
52
  "ai": "^7.0.101",
@@ -55,8 +55,8 @@
55
55
  "ws": "^8.20.1"
56
56
  },
57
57
  "peerDependencies": {
58
- "@jskit-ai/http-runtime": "0.1.222",
59
- "@jskit-ai/kernel": "0.1.224",
58
+ "@jskit-ai/http-runtime": "0.1.224",
59
+ "@jskit-ai/kernel": "0.1.226",
60
60
  "vue": "^3.5.13",
61
61
  "vuetify": "^4.0.0"
62
62
  },
@@ -1,5 +1,24 @@
1
1
  <template>
2
- <section class="assistant-conversation" :aria-label="label">
2
+ <section ref="container" class="assistant-conversation" :aria-label="label">
3
+ <section v-if="$slots.avatar" class="assistant-conversation__avatar" aria-label="Conversation avatar">
4
+ <div v-if="$slots.composer || !adapter.composer" ref="avatarControls" class="assistant-conversation__avatar-controls">
5
+ <v-btn
6
+ ref="avatarControl" class="assistant-conversation__avatar-size" aria-label="Avatar size" :title="`Avatar size: ${avatarSize}`"
7
+ aria-haspopup="menu" :aria-expanded="avatarMenuOpen" :icon="mdiAccountCircleOutline" size="small" variant="text"
8
+ />
9
+ </div>
10
+ <v-menu v-model="avatarMenuOpen" :activator="avatarControl?.$el">
11
+ <v-list role="menu" aria-label="Avatar size">
12
+ <v-list-item
13
+ v-for="size in avatarSizes" :key="size.value" role="menuitem" :title="size.title"
14
+ :active="size.value === avatarSize" @click="$emit('update:avatarSize', size.value)"
15
+ />
16
+ </v-list>
17
+ </v-menu>
18
+ <div v-if="avatarSize !== 'hidden'" class="assistant-conversation__avatar-visual" :style="{ height: `${avatarHeight}px` }">
19
+ <slot name="avatar" :size="avatarSize" :height="avatarHeight" />
20
+ </div>
21
+ </section>
3
22
  <AssistantTranscript
4
23
  v-bind="conversation" :working="working" class="assistant-conversation__transcript"
5
24
  @load-more="adapter.actions?.loadMore?.($event)" @reload="adapter.actions?.reload?.()"
@@ -80,6 +99,10 @@
80
99
  @update:model-value="updateConfiguration({ ...configuration, [field.name]: $event })"
81
100
  />
82
101
  </slot>
102
+ <v-btn
103
+ v-if="$slots.avatar" ref="avatarControl" class="assistant-conversation__avatar-size" aria-label="Avatar size" :title="`Avatar size: ${avatarSize}`"
104
+ aria-haspopup="menu" :aria-expanded="avatarMenuOpen" :icon="mdiAccountCircleOutline" size="small" variant="text"
105
+ />
83
106
  <slot name="composer-tools" :adapter="adapter" />
84
107
  <template #feedback>
85
108
  <p v-if="attachmentsEnabled && adapter.attachments.status" role="alert" class="assistant-conversation__attachment-error">{{ adapter.attachments.status }}</p>
@@ -92,8 +115,8 @@
92
115
  </section>
93
116
  </template>
94
117
  <script setup>
95
- import { computed, ref, useId, useSlots, watch } from "vue";
96
- import { mdiPaperclip } from "@mdi/js";
118
+ import { computed, onBeforeUnmount, onMounted, onUpdated, ref, useId, useSlots, watch } from "vue";
119
+ import { mdiAccountCircleOutline, mdiPaperclip } from "@mdi/js";
97
120
  import AssistantTranscript from "./AssistantTranscript.vue";
98
121
  import AssistantPromptInput from "./AssistantPromptInput.vue";
99
122
  import AssistantComposerActions from "./AssistantComposerActions.vue";
@@ -104,12 +127,69 @@ import AssistantQuestionInputs from "./AssistantQuestionInputs.vue";
104
127
  import AssistantModelControl from "./AssistantModelControl.vue";
105
128
  const props = defineProps({
106
129
  adapter: { type: Object, required: true },
130
+ avatarSize: { type: String, default: "compact", validator: (value) => ["hidden", "compact", "standard", "large"].includes(value) },
107
131
  label: { type: String, default: "Assistant conversation" },
108
132
  configuration: { type: Object, default: () => ({}) },
109
133
  configurationFields: { type: Array, default: () => [] },
110
134
  configurationMode: { type: String, default: "hidden", validator: (value) => ["hidden", "readonly", "editable"].includes(value) }
111
135
  });
136
+ defineEmits(["update:avatarSize"]);
112
137
  const slots = useSlots();
138
+ const container = ref(null);
139
+ const avatarControls = ref(null);
140
+ const avatarControl = ref(null);
141
+ const avatarMenuOpen = ref(false);
142
+ const avatarSizes = [
143
+ { title: "Hidden", value: "hidden", height: 0 },
144
+ { title: "Compact", value: "compact", height: 64 },
145
+ { title: "Standard", value: "standard", height: 112 },
146
+ { title: "Large", value: "large", height: 176 }
147
+ ];
148
+ const avatarSpace = ref(0);
149
+ const avatarHeight = computed(() => Math.min(avatarSpace.value, avatarSizes.find(size => size.value === props.avatarSize).height));
150
+ let avatarObserver;
151
+ const observedAvatarElements = new Set();
152
+ function measureAvatarSpace() {
153
+ const root = container.value;
154
+ if (!root || !slots.avatar) return;
155
+ const rootStyle = getComputedStyle(root);
156
+ let occupied = parseFloat(rootStyle.paddingTop) + parseFloat(rootStyle.paddingBottom);
157
+ for (const element of observedAvatarElements) {
158
+ if (element === root) continue;
159
+ const style = getComputedStyle(element);
160
+ occupied += element.getBoundingClientRect().height + parseFloat(style.marginTop) + parseFloat(style.marginBottom);
161
+ }
162
+ // Shrink artwork before the composer, feedback, or the readable transcript.
163
+ avatarSpace.value = Math.max(0, root.clientHeight - occupied - 120);
164
+ }
165
+ function observeAvatarSpace() {
166
+ if (!container.value || !slots.avatar) {
167
+ avatarObserver?.disconnect();
168
+ observedAvatarElements.clear();
169
+ return;
170
+ }
171
+ avatarObserver ||= new ResizeObserver(measureAvatarSpace);
172
+ const fixedElements = Array.from(container.value.children).filter(element =>
173
+ !element.classList.contains("assistant-conversation__avatar") && !element.classList.contains("assistant-conversation__transcript"));
174
+ const elements = new Set([container.value, ...fixedElements]);
175
+ if (avatarControls.value) elements.add(avatarControls.value);
176
+ for (const element of observedAvatarElements) {
177
+ if (!elements.has(element)) {
178
+ avatarObserver.unobserve(element);
179
+ observedAvatarElements.delete(element);
180
+ }
181
+ }
182
+ for (const element of elements) {
183
+ if (!observedAvatarElements.has(element)) {
184
+ avatarObserver.observe(element);
185
+ observedAvatarElements.add(element);
186
+ }
187
+ }
188
+ measureAvatarSpace();
189
+ }
190
+ onMounted(observeAvatarSpace);
191
+ onUpdated(observeAvatarSpace);
192
+ onBeforeUnmount(() => avatarObserver?.disconnect());
113
193
  const transcriptSlots = computed(() => ["welcome", "attachments", "system-message", "message-actions"].filter((name) => slots[name]));
114
194
  const input = ref(null);
115
195
  const fileInput = ref(null);
@@ -248,6 +328,11 @@ defineExpose({ focus: () => input.value?.focus(), submit, stop });
248
328
  </script>
249
329
  <style scoped>
250
330
  .assistant-conversation { display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; container: assistant-conversation / inline-size; }
331
+ .assistant-conversation__avatar { flex: 0 0 auto; min-width: 0; }
332
+ .assistant-conversation__avatar-controls { display: flex; align-items: center; justify-content: flex-end; min-height: 48px; }
333
+ .assistant-conversation__avatar-size { min-width: 48px; min-height: 48px; }
334
+ .assistant-conversation__avatar-visual { display: flex; justify-content: center; min-height: 0; overflow: hidden; }
335
+ .assistant-conversation__avatar-visual :deep(> *) { max-width: 100%; max-height: 100%; }
251
336
  .assistant-conversation__goal { display: flex; justify-content: flex-end; flex: 0 0 auto; }
252
337
  .assistant-conversation__transcript { flex: 1 1 auto; min-height: 0; }
253
338
  .assistant-conversation__composer { flex: 0 0 auto; }
@@ -50,6 +50,10 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
50
50
 
51
51
  async function captureAccountStatus({ command, args, cwd, baseEnv, signal, timeout, maxBuffer }) {
52
52
  signal.throwIfAborted();
53
+ if (typeof host.execution?.run === "function") {
54
+ return host.execution.run({ command, args, cwd, baseEnv, signal, timeout, maxBuffer,
55
+ mode: "capture", limits: host.limits });
56
+ }
53
57
  const native = await execution.start({ command, args, cwd, env: baseEnv, stream: true, limits: host.limits });
54
58
  const cancelled = Promise.withResolvers();
55
59
  const abort = () => cancelled.reject(signal.reason);
@@ -522,9 +522,11 @@ test("optional goals show running and paused lights, count only active time, and
522
522
  try {
523
523
  const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
524
524
  await page.clock.install();
525
+ await page.clock.pauseAt(new Date());
525
526
  await page.goto(`${vite.baseURL}/?conversation=1&goal=1`);
526
527
  const input = page.getByRole("textbox", { name: "Message AI assistant" });
527
528
  await page.getByRole("button", { name: "Set goal", exact: true }).click();
529
+ await page.clock.runFor(250);
528
530
  await page.getByRole("textbox", { name: "Goal objective" }).fill("Complete the shared assistant.");
529
531
  await page.getByRole("spinbutton", { name: "Token budget (optional)" }).fill("10000");
530
532
  await page.getByRole("button", { name: "Start goal", exact: true }).click();
@@ -541,6 +543,7 @@ test("optional goals show running and paused lights, count only active time, and
541
543
  await expect(input).toBeFocused();
542
544
  assert.deepEqual(await input.evaluate(element => [element.selectionStart, element.selectionEnd]), [5, 9]);
543
545
  await page.getByRole("button", { name: "Goal running", exact: true }).click();
546
+ await page.clock.runFor(250);
544
547
  await page.getByRole("button", { name: "Pause goal", exact: true }).click();
545
548
  await expect(light).toHaveCSS("background-color", "rgb(239, 108, 0)");
546
549
  await expect(light).toHaveCSS("animation-name", "none");
@@ -558,6 +561,7 @@ test("optional goals show running and paused lights, count only active time, and
558
561
  await expect(elapsed).toBeHidden();
559
562
  await expect(light).toBeVisible();
560
563
  await page.getByRole("button", { name: "Goal running", exact: true }).click();
564
+ await page.clock.runFor(250);
561
565
  await expect(page.getByText("Running time: 1:10", { exact: true })).toBeVisible();
562
566
  await page.getByRole("button", { name: "Toggle goals" }).evaluate(element => element.click());
563
567
  await expect(page.locator(".assistant-goal")).toHaveCount(0);
@@ -617,3 +621,103 @@ test("shared model choices and file delivery preserve a responsive composer", {
617
621
  await stopProcess(vite);
618
622
  }
619
623
  });
624
+
625
+ test("an optional avatar clamps in a short conversation without consuming its preference or draft", {
626
+ skip: !RUN_BROWSER_TEST, timeout: 90_000
627
+ }, async () => {
628
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
629
+ const browser = await chromium.launch(createChromiumLaunchOptions());
630
+ try {
631
+ for (const width of [390, 800, 1365]) {
632
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
633
+ await page.goto(`${vite.baseURL}/?conversation=1&avatar=1&capabilities=1`);
634
+ const panel = page.locator(".fixture");
635
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
636
+ const body = page.locator(".assistant-transcript__body");
637
+ const artwork = page.locator(".assistant-conversation__avatar-visual");
638
+ const sizeControl = page.getByRole("button", { name: "Avatar size", exact: true });
639
+ const selectSize = async name => {
640
+ await sizeControl.focus();
641
+ await sizeControl.press("Enter");
642
+ await page.getByRole("menuitem", { name, exact: true }).click();
643
+ await expect(page.getByRole("menu", { name: "Avatar size", exact: true })).toBeHidden();
644
+ await expect(page.getByText(`Avatar requested: ${name.toLowerCase()}`, { exact: true })).toBeVisible();
645
+ };
646
+ const assertComposerFits = async () => {
647
+ const bounds = await panel.boundingBox();
648
+ const send = await page.getByRole("button", { name: "Send", exact: true }).boundingBox();
649
+ const feedback = await page.getByText("Describe what you want to change.", { exact: true }).boundingBox();
650
+ const avatarButton = await sizeControl.boundingBox();
651
+ assert.ok(send.y + send.height <= bounds.y + bounds.height + 1, "Send stays inside the supplied container");
652
+ assert.ok(feedback.y + feedback.height <= bounds.y + bounds.height + 1, "Feedback stays inside the supplied container");
653
+ assert.ok((await input.boundingBox()).height >= 40);
654
+ assert.ok(avatarButton.width >= 48 && avatarButton.height >= 48, "Avatar sizing retains a reachable touch target");
655
+ await expect.poll(async () => (await body.boundingBox()).height, { message: "The short container retains readable history" }).toBeGreaterThanOrEqual(100);
656
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
657
+ };
658
+ assert.equal(Math.round((await panel.boundingBox()).width), 320);
659
+ assert.equal(Math.round((await panel.boundingBox()).height), 420);
660
+ await expect(artwork).toHaveCSS("height", "64px");
661
+ await expect(page.locator('[data-message-role="assistant"]')).toHaveCount(20);
662
+ await page.evaluate(() => {
663
+ window.avatarElements = [document.querySelector(".assistant-conversation"), document.querySelector("textarea"), document.querySelector(".assistant-transcript__body")];
664
+ });
665
+ await input.fill("Keep my typed support question.");
666
+ await input.evaluate(element => element.setSelectionRange(5, 9));
667
+ await body.evaluate(element => { element.scrollTop = 100; });
668
+ const before = await body.evaluate(element => element.scrollTop);
669
+ await selectSize("Large");
670
+ await expect.poll(async () => (await artwork.boundingBox()).height).toBeLessThan(176);
671
+ await assertComposerFits();
672
+ assert.equal(await body.evaluate(element => element.scrollTop), before, "Avatar resizing leaves a scrolled-up reader in place");
673
+ await selectSize("Hidden");
674
+ await expect(artwork).toHaveCount(0);
675
+ await expect(sizeControl).toBeVisible();
676
+ await selectSize("Standard");
677
+ await expect.poll(async () => (await artwork.boundingBox()).height).toBeGreaterThan(0);
678
+ await selectSize("Large");
679
+ await input.focus();
680
+ await input.evaluate(element => element.setSelectionRange(5, 9));
681
+ await expect(input).toBeFocused();
682
+ const resize = () => page.getByRole("button", { name: "Resize height", exact: true }).evaluate(element => element.click());
683
+ await resize();
684
+ await expect(artwork).toHaveCSS("height", "176px");
685
+ await resize();
686
+ await expect.poll(async () => (await artwork.boundingBox()).height).toBeLessThan(176);
687
+ await expect(input).toBeFocused();
688
+ await expect(input).toHaveValue("Keep my typed support question.");
689
+ assert.deepEqual(await input.evaluate(element => [element.selectionStart, element.selectionEnd]), [5, 9]);
690
+ await expect(page.getByText("Avatar requested: large", { exact: true })).toBeVisible();
691
+ assert.equal(await body.evaluate(element => element.scrollTop), before);
692
+ await page.getByRole("button", { name: "Toggle questions", exact: true }).evaluate(element => element.click());
693
+ const answer = page.getByRole("textbox", { name: "[1] What should happen next?", exact: true });
694
+ await expect(answer).toBeVisible();
695
+ await answer.fill("Recover my account.");
696
+ await assertComposerFits();
697
+ await page.getByRole("button", { name: "Answer normally instead", exact: true }).click();
698
+ await expect(answer).toHaveCount(0);
699
+ await input.fill("Keep my typed support question.");
700
+ await page.locator('input[type="file"]').setInputFiles({ name: "account.txt", mimeType: "text/plain", buffer: Buffer.from("Account recovery notes") });
701
+ await page.getByRole("button", { name: "Finish uploads", exact: true }).click();
702
+ await expect(page.getByText("1 of 1 ready", { exact: true })).toBeVisible();
703
+ await page.getByRole("button", { name: "Show attachment error", exact: true }).click();
704
+ await expect(page.getByText("An upload failed. Retry or remove it.", { exact: true })).toBeVisible();
705
+ await assertComposerFits();
706
+ await expect(input).toHaveValue("Keep my typed support question.");
707
+ await page.getByRole("button", { name: "Remove account.txt", exact: true }).click();
708
+ await page.getByRole("button", { name: "Toggle avatar slot", exact: true }).evaluate(element => element.click());
709
+ await expect(sizeControl).toHaveCount(0);
710
+ await expect(page.getByRole("region", { name: "Conversation avatar", exact: true })).toHaveCount(0);
711
+ await page.getByRole("button", { name: "Toggle avatar slot", exact: true }).evaluate(element => element.click());
712
+ await expect(sizeControl).toBeVisible();
713
+ await expect(page.getByText("Avatar requested: large", { exact: true })).toBeVisible();
714
+ assert.equal(await page.evaluate(() => window.avatarElements.every(element => element.isConnected)), true, "Sizing and slot changes retain the same conversation, input and transcript nodes");
715
+ await expect(page.locator('[data-message-role="assistant"]')).toHaveCount(20);
716
+ await expect(page.getByText("idle; submitted 0", { exact: true })).toBeVisible();
717
+ await page.close();
718
+ }
719
+ } finally {
720
+ await browser.close();
721
+ await stopProcess(vite);
722
+ }
723
+ });
@@ -12,6 +12,34 @@ import { createSchema } from "json-rest-schema";
12
12
  const configuration = { systemPrompt: "Keep these instructions fresh.", model: "test-model", effort: "high" };
13
13
  const input = { messageId: "first", text: "Hello" };
14
14
 
15
+ test("Claude account checks use the supplied finite capture policy without starting a conversation service", async t => {
16
+ const local = createLocalConversationExecution();
17
+ const checks = [];
18
+ const f = await fixture(t, { execution: {
19
+ async run(request) {
20
+ checks.push(request);
21
+ assert.equal(request.mode, "capture");
22
+ assert.equal(request.cwd, request.baseEnv.HOME);
23
+ assert.deepEqual(request.args, ["auth", "status", "--json"]);
24
+ assert.equal(request.timeout, 30_000);
25
+ assert.equal(request.maxBuffer, 64 * 1024);
26
+ assert.equal(request.signal.aborted, false);
27
+ return { ok: true, exitCode: 0, stdout: JSON.stringify({ loggedIn: true,
28
+ authMethod: "claude.ai", email: "owner@example.test" }) };
29
+ },
30
+ start(request) {
31
+ assert.equal(request.args.includes("auth"), false, "Account checks must not borrow the native-turn execution policy");
32
+ return local.start(request);
33
+ },
34
+ stop: local.stop
35
+ } });
36
+ await f.conversation.send(input);
37
+ const result = await f.conversation.wait();
38
+ assert.equal(result.conversationLog[0].metadata.runtime.status, "complete", result.error);
39
+ assert.equal(result.conversationLog[0].assistant.text, "Answer: Hello");
40
+ assert.ok(checks.length > 0);
41
+ });
42
+
15
43
  test("Claude exposes the durable native identity before dispatch and respects a rejected gate", async t => {
16
44
  const f = await fixture(t);
17
45
  await f.first.close();
@@ -142,7 +142,7 @@ test("Codex goal controls preserve native scheduling without authored command me
142
142
  const events = [];
143
143
  await f.conversation.subscribe(event => events.push(event));
144
144
  assert.equal((await f.conversation.updateGoal(command)).objective, command.objective);
145
- const completed = await settledNativeGoal(f.conversation);
145
+ const completed = await settledNativeGoal(f);
146
146
  assert.equal(completed.error, "");
147
147
  assert.equal(completed.goal.status, "budgetLimited");
148
148
  assert.equal(completed.goal.tokensUsed, 45);
@@ -739,10 +739,16 @@ function apiOptions() {
739
739
  } };
740
740
  }
741
741
 
742
- async function settledNativeGoal(conversation) {
742
+ async function settledNativeGoal(f) {
743
743
  for (let attempt = 0; attempt < 500; attempt++) {
744
- const state = await conversation.read();
745
- if (state.goal && state.goal.status !== "active" && state.status !== "working") return state;
744
+ const state = await f.conversation.read();
745
+ if (state.goal && state.goal.status !== "active" && state.status !== "working") {
746
+ // Original goal publication precedes durable run reconciliation. Wait for
747
+ // both owners before asserting their settled state; neither can be skipped.
748
+ const run = await f.storage.read("conversation", async transaction =>
749
+ (await transaction.readMetadata()).runtime.binding.codexAppServerRun);
750
+ if (run?.providerGoalStatus === state.goal.status) return state;
751
+ }
746
752
  await new Promise(resolve => setTimeout(resolve, 10));
747
753
  }
748
754
  assert.fail("The original native goal owner did not publish its settled state.");