@jskit-ai/assistant-core 0.1.170 → 0.1.172

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/examples/conversation/App.vue +62 -10
  2. package/examples/conversation/README.md +29 -4
  3. package/examples/conversation/attachments.js +45 -0
  4. package/examples/conversation/integrations.json +28 -0
  5. package/examples/conversation/package.json +6 -4
  6. package/examples/conversation/server.js +61 -10
  7. package/fixtures/responsive-assistant/ConversationFixture.vue +54 -1
  8. package/package.json +21 -7
  9. package/src/client/conversation/AssistantAttachmentPreview.vue +38 -0
  10. package/src/client/conversation/AssistantAttachmentQueue.vue +563 -0
  11. package/src/client/conversation/AssistantComposerSupport.vue +235 -0
  12. package/src/client/conversation/AssistantConversationElement.vue +99 -8
  13. package/src/client/conversation/AssistantGoalControl.vue +81 -0
  14. package/src/client/conversation/AssistantMessageAttachments.vue +114 -0
  15. package/src/client/conversation/AssistantModelControl.vue +334 -0
  16. package/src/client/conversation/AssistantQuestionInputs.vue +124 -0
  17. package/src/client/conversation/AssistantTranscript.vue +2 -1
  18. package/src/client/conversation/index.js +9 -0
  19. package/src/client/conversation/useAssistantAttachments.js +991 -0
  20. package/src/client/conversation/useAssistantSuggestions.js +99 -0
  21. package/src/server/index.js +1 -0
  22. package/src/server/lib/aiConnectionClient.js +90 -0
  23. package/src/server/lib/providers/anthropicClient.js +6 -21
  24. package/src/shared/assistantResource.js +7 -0
  25. package/src/shared/conversation/attachments.js +13 -0
  26. package/src/shared/conversation/index.js +1 -0
  27. package/test/aiConnectionClient.test.js +159 -0
  28. package/test/assistantScroll.browser.test.js +144 -0
  29. package/test/suggestions.test.js +114 -0
@@ -0,0 +1,99 @@
1
+ import { computed, onScopeDispose, ref, toValue, unref, watch } from "vue";
2
+
3
+ /** Suggestion requests use an application-supplied agent; credentials stay on its server. */
4
+ export function useAssistantSuggestions({
5
+ active = true, requestKey = "", draft = "", configuration = {},
6
+ generate, onSelect = () => false, debounceMs = 750
7
+ } = {}) {
8
+ const items = ref([]);
9
+ const loading = ref(false);
10
+ const preview = ref("");
11
+ const error = ref("");
12
+ const dismissedKey = ref(null);
13
+ const composerFocused = ref(false);
14
+ const currentDraft = computed(() => String(toValue(draft) ?? "").trim());
15
+ const configurationKey = computed(() => JSON.stringify(toValue(configuration) ?? {}));
16
+ const key = computed(() => JSON.stringify([toValue(requestKey), currentDraft.value, configurationKey.value]));
17
+ const eligible = computed(() => toValue(active) !== false && typeof unref(generate) === "function" && dismissedKey.value !== key.value);
18
+ const visible = computed(() => eligible.value && (loading.value || items.value.length > 0));
19
+ let timer;
20
+ let request;
21
+
22
+ function cancel() {
23
+ clearTimeout(timer);
24
+ const previous = request;
25
+ request = null;
26
+ previous?.abort();
27
+ loading.value = false;
28
+ }
29
+
30
+ watch([eligible, key, () => unref(generate)], () => {
31
+ cancel();
32
+ items.value = [];
33
+ preview.value = "";
34
+ error.value = "";
35
+ if (!eligible.value) return;
36
+ const controller = new AbortController();
37
+ request = controller;
38
+ const input = {
39
+ draft: currentDraft.value,
40
+ configuration: JSON.parse(configurationKey.value),
41
+ signal: controller.signal
42
+ };
43
+ const run = unref(generate);
44
+ loading.value = true;
45
+ timer = setTimeout(async () => {
46
+ try {
47
+ const result = await run(input);
48
+ if (request !== controller) return;
49
+ if (!Array.isArray(result) || result.some(item => !item?.label?.trim() || !item?.prompt?.trim())) {
50
+ throw new TypeError("Suggestions must contain a label and prompt.");
51
+ }
52
+ items.value = result.map(({ label, prompt }) => ({ label: label.trim(), prompt: prompt.trim() }));
53
+ } catch (failure) {
54
+ if (request === controller && !controller.signal.aborted) error.value = String(failure.message || failure);
55
+ } finally {
56
+ if (request === controller) {
57
+ request = null;
58
+ loading.value = false;
59
+ }
60
+ }
61
+ }, Math.max(0, Number(toValue(debounceMs)) || 0));
62
+ }, { immediate: true, flush: "sync" });
63
+
64
+ function currentSuggestion(value) {
65
+ return items.value.find(item => item.label === value?.label && item.prompt === value?.prompt);
66
+ }
67
+ function previewSuggestion(value) {
68
+ preview.value = currentDraft.value ? "" : currentSuggestion(value)?.prompt || "";
69
+ return Boolean(preview.value);
70
+ }
71
+ function select(value) {
72
+ const suggestion = currentSuggestion(value);
73
+ if (!suggestion || !eligible.value) return false;
74
+ preview.value = "";
75
+ return onSelect(suggestion.prompt) !== false;
76
+ }
77
+ function dismiss() {
78
+ preview.value = "";
79
+ dismissedKey.value = key.value;
80
+ }
81
+ function focus() {
82
+ if (toValue(active) === false || composerFocused.value) return;
83
+ dismissedKey.value = null;
84
+ composerFocused.value = true;
85
+ }
86
+ function blur() {
87
+ preview.value = "";
88
+ composerFocused.value = false;
89
+ }
90
+ watch(() => toValue(active), value => {
91
+ if (value === false) {
92
+ composerFocused.value = false;
93
+ dismissedKey.value = null;
94
+ }
95
+ }, { flush: "sync" });
96
+ watch(currentDraft, () => { preview.value = ""; }, { flush: "sync" });
97
+ onScopeDispose(cancel);
98
+ return { items, loading, preview, error, visible, composerFocused, previewSuggestion, select, dismiss, focus, blur };
99
+ }
@@ -1,4 +1,5 @@
1
1
  export { createAiClient, SUPPORTED_AI_PROVIDERS, DEFAULT_AI_PROVIDER } from "./lib/aiClient.js";
2
+ export { createAiConnectionClient } from "./lib/aiConnectionClient.js";
2
3
  export {
3
4
  DEFAULT_AI_TIMEOUT_MS,
4
5
  normalizeOptionalHttpUrl,
@@ -0,0 +1,90 @@
1
+ import { generateText, jsonSchema, streamText } from "ai";
2
+ import { createOpenAI } from "@ai-sdk/openai";
3
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
4
+ import { createAnthropic } from "@ai-sdk/anthropic";
5
+ import { createGoogle } from "@ai-sdk/google";
6
+ import { createGroq } from "@ai-sdk/groq";
7
+ import { createOpenRouter } from "@openrouter/ai-sdk-provider";
8
+ import { createCerebras } from "@ai-sdk/cerebras";
9
+ import { createMistral } from "@ai-sdk/mistral";
10
+ import { createPerplexity } from "@ai-sdk/perplexity";
11
+ import { createTogetherAI } from "@ai-sdk/togetherai";
12
+ import { createXai } from "@ai-sdk/xai";
13
+
14
+ // These adapters are installed with the package. The catalogue selects a
15
+ // protocol; it cannot cause dynamic imports or dependency installation.
16
+ const providers = {
17
+ "@ai-sdk/openai": createOpenAI,
18
+ "@ai-sdk/openai-compatible": createOpenAICompatible,
19
+ "@ai-sdk/anthropic": createAnthropic,
20
+ "@ai-sdk/google": createGoogle,
21
+ "@ai-sdk/groq": createGroq,
22
+ "@openrouter/ai-sdk-provider": createOpenRouter,
23
+ "@ai-sdk/cerebras": createCerebras,
24
+ "@ai-sdk/mistral": createMistral,
25
+ "@ai-sdk/perplexity": createPerplexity,
26
+ "@ai-sdk/togetherai": createTogetherAI,
27
+ "@ai-sdk/xai": createXai
28
+ };
29
+
30
+ function sdkMessages(messages) {
31
+ const toolNames = new Map();
32
+ return messages.map(message => {
33
+ if (message.role === "assistant" && message.tool_calls?.length) {
34
+ const content = message.content ? [{ type: "text", text: message.content }] : [];
35
+ for (const call of message.tool_calls) {
36
+ toolNames.set(call.id, call.function.name);
37
+ content.push({ type: "tool-call", toolCallId: call.id, toolName: call.function.name, input: JSON.parse(call.function.arguments) });
38
+ }
39
+ return { role: "assistant", content };
40
+ }
41
+ if (message.role === "tool") {
42
+ return { role: "tool", content: [{ type: "tool-result", toolCallId: message.tool_call_id,
43
+ toolName: toolNames.get(message.tool_call_id), output: { type: "text", value: message.content } }] };
44
+ }
45
+ return { role: message.role, content: message.content };
46
+ });
47
+ }
48
+
49
+ /** Accept only the server-side result of an authorized AI connection resolver. */
50
+ export function createAiConnectionClient(connection, { fetch, timeoutMs = 120_000, maxOutputTokens } = {}) {
51
+ if (!connection?.apiKey || !connection.model || !Object.hasOwn(providers, connection.sdkPackage)) {
52
+ throw new TypeError("An authorized AI connection with a supported SDK and model is required.");
53
+ }
54
+ const provider = providers[connection.sdkPackage]({
55
+ name: connection.providerId, apiKey: connection.apiKey, baseURL: connection.baseURL, fetch
56
+ });
57
+ const model = provider(connection.model);
58
+ function options({ messages = [], tools = [], signal, temperature } = {}) {
59
+ return {
60
+ model, messages: sdkMessages(messages), allowSystemInMessages: true,
61
+ tools: Object.fromEntries(tools.map(({ function: tool }) => [tool.name, {
62
+ description: tool.description, inputSchema: jsonSchema(tool.parameters)
63
+ }])),
64
+ abortSignal: signal, timeout: timeoutMs, maxRetries: 0, temperature, maxOutputTokens
65
+ };
66
+ }
67
+ return Object.freeze({
68
+ enabled: true, supportsAttachments: true, provider: connection.providerId, defaultModel: connection.model,
69
+ async createChatCompletion(input) {
70
+ const result = await generateText(options(input));
71
+ return { choices: [{ message: { role: "assistant", content: result.text,
72
+ tool_calls: result.toolCalls.map(call => ({ id: call.toolCallId, type: "function",
73
+ function: { name: call.toolName, arguments: JSON.stringify(call.input) } })) } }] };
74
+ },
75
+ async *createChatCompletionStream(input) {
76
+ const result = streamText({ ...options(input), onError() {} });
77
+ let toolIndex = 0;
78
+ for await (const part of result.fullStream) {
79
+ if (part.type === "error") throw part.error;
80
+ if (part.type === "abort") throw input?.signal?.reason || new Error("Assistant request was aborted.");
81
+ if (part.type === "text-delta") yield { choices: [{ delta: { content: part.text } }] };
82
+ if (part.type === "tool-call") {
83
+ if (part.invalid) throw part.error;
84
+ yield { choices: [{ delta: { tool_calls: [{ index: toolIndex++, id: part.toolCallId,
85
+ function: { name: part.toolName, arguments: JSON.stringify(part.input) } }] } }] };
86
+ }
87
+ }
88
+ }
89
+ });
90
+ }
@@ -1,3 +1,4 @@
1
+ import { createAiConnectionClient } from "../aiConnectionClient.js";
1
2
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
3
  import {
3
4
  createDisabledClient,
@@ -201,12 +202,6 @@ function mapAnthropicContentToOpenAiDelta(content = []) {
201
202
  return delta;
202
203
  }
203
204
 
204
- function createSingleChunkStream(chunk) {
205
- return (async function* singleChunkGenerator() {
206
- yield chunk;
207
- })();
208
- }
209
-
210
205
  async function fetchAnthropicMessage({
211
206
  apiKey,
212
207
  baseUrl,
@@ -349,21 +344,11 @@ function createAnthropicClient({
349
344
  provider: "anthropic",
350
345
  defaultModel: normalizedModel,
351
346
  createChatCompletion: createCompletion,
352
- async createChatCompletionStream({ messages = [], tools = [], temperature = 0.2, signal } = {}) {
353
- const completion = await createCompletion({
354
- messages,
355
- tools,
356
- temperature,
357
- signal
358
- });
359
-
360
- return createSingleChunkStream({
361
- choices: [
362
- {
363
- delta: completion.__openAiLikeDelta || {}
364
- }
365
- ]
366
- });
347
+ createChatCompletionStream(input) {
348
+ return createAiConnectionClient({
349
+ providerId: "anthropic", sdkPackage: "@ai-sdk/anthropic", apiKey: normalizedApiKey,
350
+ model: normalizedModel, baseURL: `${normalizedBaseUrl}/v1`
351
+ }, { timeoutMs, maxOutputTokens: DEFAULT_ANTHROPIC_MAX_OUTPUT_LIMIT }).createChatCompletionStream({ temperature: 0.2, ...input });
367
352
  }
368
353
  });
369
354
  }
@@ -10,7 +10,12 @@ const MAX_HISTORY_MESSAGES = 20;
10
10
  const MAX_PAGE_SIZE = 200;
11
11
  const MAX_MESSAGE_PAGE_SIZE = 500;
12
12
 
13
+ const attachmentIdsField = {
14
+ type: "array", required: false, maxItems: 10,
15
+ items: { type: "string", minLength: 1, maxLength: 256 }
16
+ };
13
17
  const historyMessageSchema = createSchema({
18
+ attachmentIds: attachmentIdsField,
14
19
  role: {
15
20
  type: "string",
16
21
  required: true,
@@ -38,6 +43,8 @@ const clientContextSchema = createSchema({
38
43
  });
39
44
 
40
45
  const chatStreamBodySchema = createSchema({
46
+ attachmentIds: attachmentIdsField,
47
+ integrationId: { type: "string", required: false, minLength: 1, maxLength: 128 },
41
48
  messageId: {
42
49
  type: "string",
43
50
  required: true,
@@ -0,0 +1,13 @@
1
+ export function attachmentSizeLabel(size) {
2
+ const bytes = Number(size);
3
+ if (!Number.isFinite(bytes) || bytes < 0) {
4
+ return "";
5
+ }
6
+ if (bytes < 1024) {
7
+ return `${bytes} B`;
8
+ }
9
+ if (bytes < 1024 * 1024) {
10
+ return `${(bytes / 1024).toFixed(1)} KB`;
11
+ }
12
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
13
+ }
@@ -4,3 +4,4 @@ export * from "./numberedQuestionSugar.js";
4
4
  export * from "./answerChoiceSugar.js";
5
5
  export * from "./thinkingText.js";
6
6
  export * from "./scrollFollowState.js";
7
+ export * from "./attachments.js";
@@ -0,0 +1,159 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createAiConnectionResolver } from "../../connectors-catalog/src/server/ai.js";
4
+ import { createAiConnectionClient } from "../src/server/lib/aiConnectionClient.js";
5
+ import { createAiClient } from "../src/server/lib/aiClient.js";
6
+
7
+ function publicConnection(model = "opencode/big-pickle") {
8
+ return createAiConnectionResolver({
9
+ configuration: { schemaVersion: 1, registrations: {}, integrations: {
10
+ assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model } }
11
+ } },
12
+ authorize: () => ({ applicationId: "example", subjectId: "user" })
13
+ }).resolve({ context: {}, integrationId: "assistant" });
14
+ }
15
+
16
+ function eventsResponse(events) {
17
+ return new Response(events.map(data => `data: ${JSON.stringify(data)}\n\n`).join(""), {
18
+ headers: { "content-type": "text/event-stream" }
19
+ });
20
+ }
21
+
22
+ test("the configured public AI integration streams its first chunk before completion", async () => {
23
+ const connection = await publicConnection();
24
+ const source = new TransformStream();
25
+ const writer = source.writable.getWriter();
26
+ const client = createAiConnectionClient(connection, {
27
+ async fetch(url, options) {
28
+ assert.equal(String(url), "https://opencode.ai/zen/v1/chat/completions");
29
+ assert.equal(new Headers(options.headers).get("authorization"), "Bearer public");
30
+ const body = JSON.parse(options.body);
31
+ assert.equal(body.model, "big-pickle");
32
+ assert.equal(body.stream, true);
33
+ return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
34
+ }
35
+ });
36
+ const iterator = client.createChatCompletionStream({ messages: [{ role: "user", content: "Hello" }] });
37
+ const first = iterator.next();
38
+ await writer.write(new TextEncoder().encode('data: {"choices":[{"index":0,"delta":{"content":"Hello"}}]}\n\n'));
39
+ assert.equal((await first).value.choices[0].delta.content, "Hello");
40
+ const second = iterator.next();
41
+ await writer.write(new TextEncoder().encode('data: {"choices":[{"index":0,"delta":{"content":" there"}}]}\n\n'));
42
+ assert.equal((await second).value.choices[0].delta.content, " there");
43
+ await writer.write(new TextEncoder().encode('data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n'));
44
+ await writer.close();
45
+ assert.equal((await iterator.next()).done, true);
46
+ });
47
+
48
+ test("tools remain application executed and their results return through the same SDK protocol", async () => {
49
+ let round = 0;
50
+ const client = createAiConnectionClient(await publicConnection(), {
51
+ async fetch(_url, options) {
52
+ const body = JSON.parse(options.body);
53
+ assert.equal(body.tools[0].function.name, "lookup");
54
+ if (++round === 1) return eventsResponse([{ choices: [{ index: 0, delta: { tool_calls: [
55
+ { index: 0, id: "call_1", type: "function", function: { name: "lookup", arguments: '{"query":"records"}' } }
56
+ ] }, finish_reason: "tool_calls" }] }]);
57
+ assert.equal(body.messages.at(-1).role, "tool");
58
+ assert.equal(body.messages.at(-1).tool_call_id, "call_1");
59
+ assert.equal(body.messages.at(-1).content, "Three records.");
60
+ return eventsResponse([{ choices: [{ index: 0, delta: { content: "There are three records." }, finish_reason: "stop" }] }]);
61
+ }
62
+ });
63
+ const tools = [{ type: "function", function: { name: "lookup", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } } }];
64
+ const messages = [{ role: "system", content: "Use the available actions." }, { role: "user", content: "Find records" }];
65
+ const first = await Array.fromAsync(client.createChatCompletionStream({ messages, tools }));
66
+ const call = first[0].choices[0].delta.tool_calls[0];
67
+ assert.deepEqual(call.function, { name: "lookup", arguments: '{"query":"records"}' });
68
+ assert.equal(round, 1, "The SDK must not start another tool round on its own");
69
+ const result = await Array.fromAsync(client.createChatCompletionStream({ tools, messages: [
70
+ ...messages, { role: "assistant", content: "", tool_calls: [{ ...call, type: "function" }] },
71
+ { role: "tool", tool_call_id: call.id, content: "Three records." }
72
+ ] }));
73
+ assert.equal(result[0].choices[0].delta.content, "There are three records.");
74
+ });
75
+
76
+ test("the integration preserves the OpenAI Responses route for Zen Muse models", async () => {
77
+ const client = createAiConnectionClient(await publicConnection("opencode/muse-spark-1.3-contributor-free"), {
78
+ async fetch(url, options) {
79
+ assert.equal(String(url), "https://opencode.ai/zen/v1/responses");
80
+ assert.equal(JSON.parse(options.body).model, "muse-spark-1.3-contributor-free");
81
+ return eventsResponse([
82
+ { type: "response.output_item.added", output_index: 0, item: { type: "message", id: "item_1", role: "assistant", content: [], status: "in_progress" } },
83
+ { type: "response.output_text.delta", item_id: "item_1", output_index: 0, content_index: 0, delta: "Live response" },
84
+ { type: "response.completed", response: { id: "resp_1", created_at: 1, model: "muse-spark-1.3-contributor-free", status: "completed", output: [], usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 } } }
85
+ ]);
86
+ }
87
+ });
88
+ const chunks = await Array.fromAsync(client.createChatCompletionStream({ messages: [{ role: "user", content: "Hi" }] }));
89
+ assert.equal(chunks[0].choices[0].delta.content, "Live response");
90
+ });
91
+
92
+ test("provider failures propagate without retries or fallback models", async () => {
93
+ let requests = 0;
94
+ const client = createAiConnectionClient(await publicConnection(), {
95
+ fetch() { requests++; return new Response('{"error":{"message":"Capacity unavailable"}}', { status: 429 }); }
96
+ });
97
+ await assert.rejects(Array.fromAsync(client.createChatCompletionStream({ messages: [{ role: "user", content: "Hi" }] })), /Capacity unavailable/u);
98
+ assert.equal(requests, 1);
99
+ });
100
+
101
+ test("legacy Anthropic configuration streams through the native Messages protocol", async t => {
102
+ const source = new TransformStream();
103
+ const writer = source.writable.getWriter();
104
+ t.mock.method(globalThis, "fetch", async (url, options) => {
105
+ assert.equal(String(url), "https://anthropic.example/v1/messages");
106
+ assert.equal(JSON.parse(options.body).max_tokens, 4096);
107
+ assert.equal(new Headers(options.headers).get("x-api-key"), "test-key");
108
+ assert.equal(JSON.parse(options.body).stream, true);
109
+ return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
110
+ });
111
+ const client = createAiClient({ provider: "anthropic", apiKey: "test-key", baseUrl: "https://anthropic.example", model: "claude-test" });
112
+ const iterator = client.createChatCompletionStream({ messages: [{ role: "user", content: "Hi" }] });
113
+ const first = iterator.next();
114
+ const send = data => writer.write(new TextEncoder().encode(`event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`));
115
+ await send({ type: "message_start", message: { id: "msg_1", type: "message", role: "assistant", content: [], model: "claude-test", stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } });
116
+ await send({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } });
117
+ await send({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Streaming now" } });
118
+ assert.equal((await first).value.choices[0].delta.content, "Streaming now");
119
+ await send({ type: "content_block_stop", index: 0 });
120
+ await send({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 2 } });
121
+ await send({ type: "message_stop" });
122
+ await writer.close();
123
+ assert.equal((await iterator.next()).done, true);
124
+ });
125
+
126
+ test("Google connections use streaming generateContent and preserve cancellation", async () => {
127
+ const abort = new AbortController();
128
+ const requestStarted = Promise.withResolvers();
129
+ const client = createAiConnectionClient({ providerId: "google", sdkPackage: "@ai-sdk/google", apiKey: "test-key", model: "gemini-test" }, {
130
+ fetch(url, options) {
131
+ assert.match(String(url), /models\/gemini-test:streamGenerateContent/u);
132
+ assert.equal(new Headers(options.headers).get("x-goog-api-key"), "test-key");
133
+ assert.equal(JSON.parse(options.body).contents[0].parts[0].text, "Hi");
134
+ requestStarted.resolve();
135
+ return new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true }));
136
+ }
137
+ });
138
+ const result = Array.fromAsync(client.createChatCompletionStream({ messages: [{ role: "user", content: "Hi" }], signal: abort.signal }));
139
+ await requestStarted.promise;
140
+ abort.abort(new Error("Stopped by the user"));
141
+ await assert.rejects(result, /Stopped by the user/u);
142
+ });
143
+
144
+
145
+ test("authorized image bytes use the selected provider protocol", async () => {
146
+ const client = createAiConnectionClient(await publicConnection(), {
147
+ async fetch(_url, options) {
148
+ const content = JSON.parse(options.body).messages[0].content;
149
+ assert.equal(content[0].text, "Describe this");
150
+ assert.equal(content[1].type, "image_url");
151
+ assert.equal(content[1].image_url.url, "data:image/png;base64,AQID");
152
+ return eventsResponse([{ choices: [{ index: 0, delta: { content: "An image" }, finish_reason: "stop" }] }]);
153
+ }
154
+ });
155
+ const chunks = await Array.fromAsync(client.createChatCompletionStream({ messages: [{ role: "user", content: [
156
+ { type: "text", text: "Describe this" }, { type: "image", image: new Uint8Array([1, 2, 3]), mediaType: "image/png" }
157
+ ] }] }));
158
+ assert.equal(chunks[0].choices[0].delta.content, "An image");
159
+ });
@@ -167,3 +167,147 @@ test("conversation composer preserves typing and reacts to external state and pa
167
167
  await stopProcess(vite);
168
168
  }
169
169
  });
170
+
171
+ test("working state appears before output and suggestion agents and activity rendering are replaceable", {
172
+ skip: !RUN_BROWSER_TEST,
173
+ timeout: 60_000
174
+ }, async () => {
175
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
176
+ const browser = await chromium.launch(createChromiumLaunchOptions());
177
+ try {
178
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
179
+ await page.goto(`${vite.baseURL}/?conversation=1&support=1`);
180
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
181
+ await page.getByRole("button", { name: "Use suggestion: Suggestion from small-model", exact: true }).hover();
182
+ await expect(input).toHaveAttribute("placeholder", "Suggestion from small-model");
183
+ await expect(input).toHaveValue("");
184
+ await page.getByRole("button", { name: "Change suggestion model" }).click();
185
+ await page.getByRole("button", { name: "Use suggestion: Suggestion from other-model", exact: true }).click();
186
+ await expect(input).toHaveValue("Suggestion from other-model");
187
+ await expect(input).toBeFocused();
188
+ await input.press("Enter");
189
+ const activity = page.locator(".assistant-composer-support__assistant-status");
190
+ await expect(activity).toHaveText("Assistant is working…");
191
+ await expect(page.locator(".assistant-composer-support__sr-status")).toHaveText("Assistant is working…");
192
+ await input.fill("Keep typing while waiting");
193
+ await input.evaluate(element => element.setSelectionRange(5, 11));
194
+ await page.getByRole("button", { name: "Custom activity", exact: true }).evaluate(element => element.click());
195
+ await expect(activity).toHaveText("Custom activity: Assistant is working…");
196
+ await expect(input).toBeFocused();
197
+ assert.deepEqual(await input.evaluate(element => [element.selectionStart, element.selectionEnd]), [5, 11]);
198
+ await page.getByRole("button", { name: "External stopping", exact: true }).evaluate(element => element.click());
199
+ await expect(activity).toHaveText("Custom activity: Stopping…");
200
+ await page.getByRole("button", { name: "External stopped", exact: true }).evaluate(element => element.click());
201
+ await expect(activity).toHaveCount(0);
202
+ await expect(input).toHaveValue("Keep typing while waiting");
203
+ } finally {
204
+ await browser.close();
205
+ await stopProcess(vite);
206
+ }
207
+ });
208
+
209
+
210
+ test("optional goals show running and paused lights, count only active time, and adapt to pane width", {
211
+ skip: !RUN_BROWSER_TEST,
212
+ timeout: 60_000
213
+ }, async () => {
214
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
215
+ const browser = await chromium.launch(createChromiumLaunchOptions());
216
+ try {
217
+ const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
218
+ await page.clock.install();
219
+ await page.goto(`${vite.baseURL}/?conversation=1&goal=1`);
220
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
221
+ await page.getByRole("button", { name: "Set goal", exact: true }).click();
222
+ await page.getByRole("textbox", { name: "Goal objective" }).fill("Complete the shared assistant.");
223
+ await page.getByRole("spinbutton", { name: "Token budget (optional)" }).fill("10000");
224
+ await page.getByRole("button", { name: "Start goal", exact: true }).click();
225
+ await expect(page.getByText("Complete the shared assistant.", { exact: true })).toBeVisible();
226
+ await input.click();
227
+ await input.fill("Preserve this draft and caret");
228
+ await input.evaluate(element => element.setSelectionRange(5, 9));
229
+ const light = page.locator(".assistant-goal__light");
230
+ await expect(light).toHaveCSS("background-color", "rgb(211, 47, 47)");
231
+ await expect(light).toHaveCSS("animation-name", /^assistant-goal-flash/u);
232
+ const elapsed = page.locator(".assistant-goal__elapsed");
233
+ await page.clock.fastForward(65_000);
234
+ await expect(elapsed).toHaveText("1:05");
235
+ await expect(input).toBeFocused();
236
+ assert.deepEqual(await input.evaluate(element => [element.selectionStart, element.selectionEnd]), [5, 9]);
237
+ await page.getByRole("button", { name: "Goal running", exact: true }).click();
238
+ await page.getByRole("button", { name: "Pause goal", exact: true }).click();
239
+ await expect(light).toHaveCSS("background-color", "rgb(239, 108, 0)");
240
+ await expect(light).toHaveCSS("animation-name", "none");
241
+ const pausedTime = await elapsed.textContent();
242
+ await page.clock.fastForward(120_000);
243
+ await expect(elapsed).toHaveText(pausedTime);
244
+ await page.getByRole("button", { name: "Resume goal", exact: true }).click();
245
+ await page.clock.fastForward(5_000);
246
+ await expect(elapsed).toHaveText("1:10");
247
+ await page.emulateMedia({ reducedMotion: "reduce" });
248
+ await expect(light).toHaveCSS("animation-name", "none");
249
+ await expect(light).toHaveCSS("background-color", "rgb(211, 47, 47)");
250
+ await input.click();
251
+ await page.getByRole("button", { name: "Resize pane" }).click();
252
+ await expect(elapsed).toBeHidden();
253
+ await expect(light).toBeVisible();
254
+ await page.getByRole("button", { name: "Goal running", exact: true }).click();
255
+ await expect(page.getByText("Running time: 1:10", { exact: true })).toBeVisible();
256
+ await page.getByRole("button", { name: "Toggle goals" }).evaluate(element => element.click());
257
+ await expect(page.locator(".assistant-goal")).toHaveCount(0);
258
+ await expect(page.getByRole("button", { name: "Pause goal", exact: true })).toBeHidden();
259
+ await expect(input).toHaveValue("Preserve this draft and caret");
260
+ } finally {
261
+ await browser.close();
262
+ await stopProcess(vite);
263
+ }
264
+ });
265
+
266
+
267
+ test("shared model choices and file delivery preserve a responsive composer", {
268
+ skip: !RUN_BROWSER_TEST,
269
+ timeout: 60_000
270
+ }, async () => {
271
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
272
+ const browser = await chromium.launch(createChromiumLaunchOptions());
273
+ try {
274
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
275
+ await page.goto(`${vite.baseURL}/?conversation=1&capabilities=1`);
276
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
277
+ await page.getByRole("button", { name: "Choose AI", exact: true }).click();
278
+ const model = page.getByRole("combobox", { name: "Choose model", exact: true });
279
+ await model.click();
280
+ await model.press("ControlOrMeta+A");
281
+ await model.press("Backspace");
282
+ await model.pressSequentially("Model 3");
283
+ await page.getByRole("option", { name: "Model 3", exact: true }).click();
284
+ await page.getByRole("button", { name: "Apply", exact: true }).click();
285
+ await expect(page.getByText("Model: model-2; sent files:", { exact: true })).toBeVisible();
286
+ await input.click();
287
+ await input.fill("Read these files");
288
+ await page.locator('input[type="file"]').setInputFiles([
289
+ { name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("First file") },
290
+ { name: "tasks.txt", mimeType: "text/plain", buffer: Buffer.from("Second file") }
291
+ ]);
292
+ await expect(page.getByText("0 of 2 ready · 50%", { exact: true })).toBeVisible();
293
+ await expect(page.getByRole("button", { name: "Send", exact: true })).toBeDisabled();
294
+ await input.fill("Keep editing while files upload");
295
+ await input.evaluate(element => element.setSelectionRange(5, 9));
296
+ await page.getByRole("button", { name: "Finish uploads", exact: true }).evaluate(element => element.click());
297
+ await expect(page.getByText("2 of 2 ready", { exact: true })).toBeVisible();
298
+ await expect(input).toBeFocused();
299
+ assert.deepEqual(await input.evaluate(element => [element.selectionStart, element.selectionEnd]), [5, 9]);
300
+ await page.getByRole("button", { name: "Remove notes.txt", exact: true }).click();
301
+ await page.getByRole("button", { name: "Send", exact: true }).click();
302
+ await expect(page.getByText("Model: model-2; sent files: tasks.txt", { exact: true })).toBeVisible();
303
+ await expect(page.getByRole("region", { name: "Message attachments", exact: true })).toHaveCount(0);
304
+ await page.getByRole("button", { name: "Choose AI", exact: true }).click();
305
+ await expect(model).toBeDisabled();
306
+ await expect(page.getByText("AI choices are view-only while the assistant is working.", { exact: true })).toBeVisible();
307
+ await input.fill("The next message stays editable");
308
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
309
+ } finally {
310
+ await browser.close();
311
+ await stopProcess(vite);
312
+ }
313
+ });