Preserve and display the generating model for each assistant reply

This commit is contained in:
2026-11-02 21:18:37 -08:00
parent 9e4811b6cc
commit b9ff0a795c
13 changed files with 195 additions and 18 deletions
+5 -1
View File
@@ -180,7 +180,7 @@ Behavior notes:
Behavior notes: Behavior notes:
- With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive. - With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive.
- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments and tool-call metadata are retained. LLM call logs, star/project memberships, and other child threads are not copied. - The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments, tool-call metadata, and per-response `metadata.generation` are retained. For older replies with an exact call-log match, generation metadata is recovered before copying. LLM call logs, star/project memberships, and other child threads are not copied.
- The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings. - The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings.
- A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`. - A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`.
- A whole-chat fork starts with `Fork of <source title>` (or `Fork of Untitled chat`). A message fork starts with `Fork of '<message snippet>'`. - A whole-chat fork starts with `Fork of <source title>` (or `Fork of Untitled chat`). A message fork starts with `Fork of '<message snippet>'`.
@@ -233,6 +233,9 @@ Behavior notes:
### `GET /v1/chats/:chatId` ### `GET /v1/chats/:chatId`
- Response: `{ "chat": ChatDetail }` - Response: `{ "chat": ChatDetail }`
- Saved ordinary assistant responses can include `metadata.generation: { "provider": "openai", "model": "gpt-4.1-mini" }`. These are the provider/model identifiers used for that completion request, captured separately from the chat's mutable last-used selection. Model aliases remain the requested alias; this does not claim an underlying provider snapshot ID.
- Newly generated responses store this metadata for both streaming and non-streaming completions. It survives reloads and forks. Inline Sybil Agent replies retain their separate `metadata.agentAside` identity.
- For older assistant messages without generation metadata, this response recovers it only when `metadata.clientRequestId` matches exactly one completed, successful call in the same chat. This read does not rewrite stored history. Missing or ambiguous records, including some older forks/imports, remain unattributed; clients must not substitute the chat's current or last-used model. The web footer displays “Model not recorded” in that case.
### `GET /v1/chats/:chatId/messages` ### `GET /v1/chats/:chatId/messages`
- Requires the same authenticated owner/admin credential as other chat routes. Responses use `Cache-Control: no-store`. - Requires the same authenticated owner/admin credential as other chat routes. Responses use `Cache-Control: no-store`.
@@ -338,6 +341,7 @@ Behavior notes:
- Before the Agent is invoked, the backend paginates and supplies the complete safe visible-thread projection as required initial context; the Agent does not have to choose `read_chat_history` for the current thread. User/assistant asides and full supported attachment representations are included, while system/tool rows and unrelated metadata are excluded. Historical content remains untrusted context, not new authority. - Before the Agent is invoked, the backend paginates and supplies the complete safe visible-thread projection as required initial context; the Agent does not have to choose `read_chat_history` for the current thread. User/assistant asides and full supported attachment representations are included, while system/tool rows and unrelated metadata are excluded. Historical content remains untrusted context, not new authority.
- The initial context transfer is limited to 30 MiB. A stored text attachment marked truncated, incomplete history, or a transfer/provider context-limit failure returns an explicit error and no answer from partial history; providers may reject a complete thread that exceeds their model context window. - The initial context transfer is limited to 30 MiB. A stored text attachment marked truncated, incomplete history, or a transfer/provider context-limit failure returns an explicit error and no answer from partial history; providers may reject a complete thread that exceeds their model context window.
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates. - For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
- Saved ordinary assistant responses also store the completion's provider/model in `metadata.generation`, exposed by `GET /v1/chats/:chatId`; changing the chat selection does not relabel previous responses.
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint. - `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools. - `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap. - `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
+2
View File
@@ -146,6 +146,8 @@ Each tool invocation can emit multiple `tool_call` events with the same `toolCal
For `persist: false` streams, `chatId` and `callId` are `null`. For `persist: false` streams, `chatId` and `callId` are `null`.
When an ordinary persisted completion finishes, its assistant message stores the same request-time provider/model in `metadata.generation`, available in the canonical `GET /v1/chats/:chatId` refresh. Clients must use per-message generation metadata for historical response labels, not the current chat selection. Older messages can lack attribution; see [chat detail](rest.md#get-v1chatschatid). This adds no SSE events or fields, and does not change non-persistent Quick Questions or inline Agent identity.
### `delta` ### `delta`
```json ```json
+1
View File
@@ -85,6 +85,7 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
chatId: call.chatId, chatId: call.chatId,
role: "assistant" as any, role: "assistant" as any,
content: outText, content: outText,
metadata: { generation: { provider: req.provider, model: req.model } },
}, },
}); });
await tx.llmCall.update({ await tx.llmCall.update({
+4 -1
View File
@@ -123,7 +123,10 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
chatId, chatId,
role: "assistant" as any, role: "assistant" as any,
content: text, content: text,
metadata: req.clientRequestId ? ({ clientRequestId: req.clientRequestId } as any) : undefined, metadata: {
...(req.clientRequestId ? { clientRequestId: req.clientRequestId } : {}),
generation: { provider: req.provider, model: req.model },
},
}, },
}); });
await tx.llmCall.update({ await tx.llmCall.update({
+31
View File
@@ -0,0 +1,31 @@
import { fromPrismaProvider } from "./llm/provider-ids.js";
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
type RecordedCall = {
provider: unknown;
model: string;
request: unknown;
error: string | null;
latencyMs: number | null;
};
/** Recover older streaming replies only through an unambiguous, completed request ID. */
export function withMessageGenerations<T extends { role: string; metadata: unknown }>(messages: T[], calls: RecordedCall[]): T[] {
const byRequest = new Map<string, { provider: string; model: string } | null>();
for (const call of calls) {
const requestId = record(call.request).clientRequestId;
if (typeof requestId !== "string" || !requestId || call.error !== null || call.latencyMs === null) continue;
const provider = fromPrismaProvider(call.provider);
if (!provider || !call.model.trim()) continue;
byRequest.set(requestId, byRequest.has(requestId) ? null : { provider, model: call.model });
}
return messages.map(message => {
const metadata = record(message.metadata);
if (message.role !== "assistant" || metadata.agentAside === true || metadata.generation !== undefined) return message;
const generation = typeof metadata.clientRequestId === "string" ? byRequest.get(metadata.clientRequestId) : null;
return generation ? { ...message, metadata: { ...metadata, generation } } : message;
});
}
+4 -2
View File
@@ -7,6 +7,7 @@ import { invokeAgentInline, type AgentInlineInvoker } from "./agent-inline.js";
import { buildInlineAgentThreadContext, decodeChatHistoryCursor, decodeChatListCursor, encodeChatListCursor, loadCompleteChatHistory, readChatHistoryPage } from "./chat-history.js"; import { buildInlineAgentThreadContext, decodeChatHistoryCursor, decodeChatListCursor, encodeChatListCursor, loadCompleteChatHistory, readChatHistoryPage } from "./chat-history.js";
import { containsSybilMention, filterAgentAsideMessages } from "./agent-mention.js"; import { containsSybilMention, filterAgentAsideMessages } from "./agent-mention.js";
import { prisma } from "./db.js"; import { prisma } from "./db.js";
import { withMessageGenerations } from "./message-generation.js";
import { requireAdmin } from "./auth.js"; import { requireAdmin } from "./auth.js";
import { env } from "./env.js"; import { env } from "./env.js";
import { buildComparableAttachments } from "./llm/message-content.js"; import { buildComparableAttachments } from "./llm/message-content.js";
@@ -1173,11 +1174,12 @@ export async function registerRoutes(app: FastifyInstance, options: { agentInlin
enabledTools: true, enabledTools: true,
userId: true, userId: true,
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] }, messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
calls: { select: { provider: true, model: true, request: true, error: true, latencyMs: true } },
}, },
}); });
if (!source) return { status: "chat-not-found" as const }; if (!source) return { status: "chat-not-found" as const };
let messages = source.messages; let messages = withMessageGenerations(source.messages, source.calls);
let selectedMessage: (typeof source.messages)[number] | undefined; let selectedMessage: (typeof source.messages)[number] | undefined;
if (parsed.data.messageId) { if (parsed.data.messageId) {
const selectedIndex = messages.findIndex((message) => message.id === parsed.data.messageId); const selectedIndex = messages.findIndex((message) => message.id === parsed.data.messageId);
@@ -1680,7 +1682,7 @@ export async function registerRoutes(app: FastifyInstance, options: { agentInlin
}, },
}); });
if (!chat) return app.httpErrors.notFound("chat not found"); if (!chat) return app.httpErrors.notFound("chat not found");
return { chat: serializeChatLike(chat) }; return { chat: serializeChatLike({ ...chat, messages: withMessageGenerations(chat.messages, chat.calls) }) };
}); });
app.post("/v1/chats/:chatId/messages", async (req) => { app.post("/v1/chats/:chatId/messages", async (req) => {
+85
View File
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import Fastify from "fastify";
import sensible from "@fastify/sensible";
import { withMessageGenerations } from "../src/message-generation.js";
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-message-generation-"));
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
process.env.ADMIN_TOKEN = "message-generation-test-token";
process.env.OPENAI_API_KEY = "synthetic-openai-key";
process.env.XAI_API_KEY = "synthetic-xai-key";
writeFileSync(join(databaseDir, "test.db"), "");
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy"], {
cwd: serverRoot, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" }, stdio: "pipe",
});
const [{ registerRoutes }, { prisma }, { runMultiplex }, { runMultiplexStream }] = await Promise.all([
import("../src/routes.js"), import("../src/db.js"), import("../src/llm/multiplexer.js"), import("../src/llm/streaming.js"),
]);
const app = Fastify({ logger: false });
await app.register(sensible);
await registerRoutes(app);
await app.ready();
const auth = { authorization: "Bearer message-generation-test-token" };
test.after(async () => { await app.close(); await prisma.$disconnect(); rmSync(databaseDir, { recursive: true, force: true }); });
test("streaming and non-streaming replies retain their own model across selection changes and forks", async t => {
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
assert.ok(["https://api.openai.com/v1/chat/completions", "https://api.x.ai/v1/chat/completions"].includes(url), url);
const body = JSON.parse(input instanceof Request ? await input.text() : String(init?.body));
const content = `Reply from ${body.model}`;
return body.stream
? new Response(`data: ${JSON.stringify({ choices: [{ index: 0, delta: { content }, finish_reason: null }] })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } })
: Response.json({ choices: [{ message: { role: "assistant", content } }] });
});
const chat = await prisma.chat.create({ data: { title: "Mixed models" } });
const messages = [{ role: "user" as const, content: "Answer" }];
const events = [];
for await (const event of runMultiplexStream({ chatId: chat.id, provider: "openai", model: "model-one", clientRequestId: "first-turn", messages, enabledTools: [] })) events.push(event);
assert.equal(events.at(-1)?.type, "done");
await runMultiplex({ chatId: chat.id, provider: "xai", model: "model-two", messages, enabledTools: [] });
await prisma.chat.update({ where: { id: chat.id }, data: { lastUsedProvider: "gemini", lastUsedModel: "model-next" } });
const detail = await app.inject({ method: "GET", url: `/v1/chats/${chat.id}`, headers: auth });
assert.equal(detail.statusCode, 200);
const saved = detail.json().chat.messages;
assert.deepEqual(saved.map((m: any) => m.metadata.generation), [{ provider: "openai", model: "model-one" }, { provider: "xai", model: "model-two" }]);
assert.equal(saved[0].metadata.clientRequestId, "first-turn");
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, headers: auth, payload: { messageId: saved[1].id } });
assert.equal(fork.statusCode, 200);
const forkDetail = await app.inject({ method: "GET", url: `/v1/chats/${fork.json().chat.id}`, headers: auth });
assert.deepEqual(forkDetail.json().chat.messages.map((m: any) => m.metadata.generation), saved.map((m: any) => m.metadata.generation));
assert.equal(forkDetail.json().chat.messages[0].metadata.clientRequestId, undefined);
});
test("legacy call matches are projected without rewriting history and survive a new fork", async () => {
const chat = await prisma.chat.create({ data: {
title: "Older reply", lastUsedProvider: "gemini", lastUsedModel: "different-current-model",
messages: { create: { role: "assistant", content: "Older reply", metadata: { clientRequestId: "legacy-request", citations: ["https://example.test"] } } },
calls: { create: { provider: "anthropic", model: "original-model", request: { clientRequestId: "legacy-request" }, latencyMs: 10 } },
}, include: { messages: true } });
const detail = await app.inject({ method: "GET", url: `/v1/chats/${chat.id}`, headers: auth });
assert.deepEqual(detail.json().chat.messages[0].metadata.generation, { provider: "anthropic", model: "original-model" });
assert.deepEqual((await prisma.message.findUniqueOrThrow({ where: { id: chat.messages[0]!.id } })).metadata, chat.messages[0]!.metadata);
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, headers: auth, payload: {} });
const forkDetail = await app.inject({ method: "GET", url: `/v1/chats/${fork.json().chat.id}`, headers: auth });
assert.deepEqual(forkDetail.json().chat.messages[0].metadata, { citations: ["https://example.test"], generation: { provider: "anthropic", model: "original-model" } });
});
test("missing, ambiguous, failed and unfinished calls never guess a reply's model", () => {
const call = { provider: "openai", model: "model-one", request: { clientRequestId: "request" }, error: null, latencyMs: 10 };
const message = { role: "assistant", metadata: { clientRequestId: "request" } };
for (const calls of [[], [call, { ...call, provider: "xai" }], [{ ...call, error: "failed" }], [{ ...call, latencyMs: null }]]) {
assert.deepEqual(withMessageGenerations([message], calls), [message]);
}
const existing = { ...message, metadata: { ...message.metadata, generation: { provider: "gemini", model: "original" } } };
const aside = { ...message, metadata: { ...message.metadata, agentAside: true } };
const user = { ...message, role: "user" };
assert.deepEqual(withMessageGenerations([existing, aside, user], [call]), [existing, aside, user]);
});
+1 -1
View File
@@ -43,7 +43,7 @@ Default dev URL: `http://localhost:5173`
prefixes, so `plan` also finds `planning`. prefixes, so `plan` also finds `planning`.
- Right panel: - Right panel:
- Chat mode: transcript + provider/model controls. - Chat mode: transcript + provider/model controls.
- Saved assistant replies show their received date/time in your local timezone, with Copy and Fork actions aligned beneath the reply on mobile and desktop. Copy preserves the message's Markdown; Fork starts a conversation through that reply. - Saved assistant replies show their received date/time in your local timezone and the provider/model used for that response, with Copy and Fork actions aligned beneath the reply on mobile and desktop. The model label stays with the response when you change models; older replies without a reliable record show “Model not recorded.” Copy preserves the message's Markdown; Fork starts a conversation through that reply.
- Search mode: top AI answer block + Google-style Exa results view. - Search mode: top AI answer block + Google-style Exa results view.
- Composer adapts to the active item: - Composer adapts to the active item:
- Chat sends `POST /v1/chat-completions/stream` (SSE). - Chat sends `POST /v1/chat-completions/stream` (SSE).
+1 -9
View File
@@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import type { TargetedTouchEvent } from "preact"; import type { TargetedTouchEvent } from "preact";
import { getProviderLabel } from "@/lib/message-generation";
import { import {
Check, Check,
Bot, Bot,
@@ -551,15 +552,6 @@ function pickProviderModel(options: string[], preferred: string | null) {
return options[0] ?? ""; return options[0] ?? "";
} }
function getProviderLabel(provider: Provider | null | undefined) {
if (provider === "openai") return "OpenAI";
if (provider === "anthropic") return "Anthropic";
if (provider === "xai") return "xAI";
if (provider === "gemini") return "Gemini";
if (provider === "hermes-agent") return "Hermes Agent";
return "";
}
function getToolLabel(name: string) { function getToolLabel(name: string) {
if (name === "web_search") return "Web search"; if (name === "web_search") return "Web search";
if (name === "fetch_url") return "Fetch URL"; if (name === "fetch_url") return "Fetch URL";
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import { Check, Copy, GitFork } from "lucide-preact"; import { Check, Copy, GitFork } from "lucide-preact";
import type { Message } from "@/lib/api"; import type { Message } from "@/lib/api";
import { getMessageGenerationLabel } from "@/lib/message-generation";
const receivedFormat = new Intl.DateTimeFormat(undefined, { const receivedFormat = new Intl.DateTimeFormat(undefined, {
year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
@@ -28,12 +29,16 @@ export function AssistantMessageFooter({ message, onFork, isForking = false }: {
}; };
const received = new Date(message.createdAt); const received = new Date(message.createdAt);
const hasDate = Number.isFinite(received.getTime()); const hasDate = Number.isFinite(received.getTime());
const generation = getMessageGenerationLabel(message);
return ( return (
<div className="text-muted-foreground" data-assistant-footer={message.id} data-mobile-swipe-ignore> <div className="text-muted-foreground" data-assistant-footer={message.id} data-mobile-swipe-ignore>
{hasDate ? <time className="block text-xs leading-5" dateTime={message.createdAt} title={`Received ${received.toLocaleString()}`}> <div className="text-xs leading-5 [overflow-wrap:anywhere]">
{hasDate ? <><time className="whitespace-nowrap" dateTime={message.createdAt} title={`Received ${received.toLocaleString()}`}>
{receivedFormat.format(received)} {receivedFormat.format(received)}
</time> : null} </time><span aria-hidden="true"> · </span></> : null}
<span data-message-generation>{generation ?? "Model not recorded"}</span>
</div>
<div className="-ml-2 flex flex-wrap items-center gap-1" role="group" aria-label="Message actions"> <div className="-ml-2 flex flex-wrap items-center gap-1" role="group" aria-label="Message actions">
{message.content.trim() ? <button type="button" className={actionClass} onClick={() => void copy()} title="Copy message" aria-label="Copy message"> {message.content.trim() ? <button type="button" className={actionClass} onClick={() => void copy()} title="Copy message" aria-label="Copy message">
{copyState === "copied" ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : <Copy className="h-3.5 w-3.5" aria-hidden="true" />} {copyState === "copied" ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : <Copy className="h-3.5 w-3.5" aria-hidden="true" />}
+19
View File
@@ -0,0 +1,19 @@
import type { Message } from "./api";
export function getProviderLabel(provider: unknown) {
if (provider === "openai") return "OpenAI";
if (provider === "anthropic") return "Anthropic";
if (provider === "xai") return "xAI";
if (provider === "gemini") return "Gemini";
if (provider === "hermes-agent") return "Hermes Agent";
return "";
}
export function getMessageGenerationLabel(message: Pick<Message, "role" | "metadata">): string | null {
if (message.role !== "assistant" || !message.metadata || typeof message.metadata !== "object" || Array.isArray(message.metadata)) return null;
const metadata = message.metadata as Record<string, unknown>;
if (metadata.agentAside === true || !metadata.generation || typeof metadata.generation !== "object" || Array.isArray(metadata.generation)) return null;
const generation = metadata.generation as Record<string, unknown>;
const provider = getProviderLabel(generation.provider);
return provider && typeof generation.model === "string" && generation.model.trim() ? `${provider} · ${generation.model.trim()}` : null;
}
+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getMessageGenerationLabel } from "../src/lib/message-generation.ts";
test("reply labels use the recorded provider and model", () => {
for (const [provider, label] of [["openai", "OpenAI"], ["anthropic", "Anthropic"], ["xai", "xAI"], ["gemini", "Gemini"], ["hermes-agent", "Hermes Agent"]]) {
assert.equal(getMessageGenerationLabel({ role: "assistant", metadata: { generation: { provider, model: "original-model" } } }), `${label} · original-model`);
}
});
test("missing or malformed attribution stays unknown and Agent asides keep their identity", () => {
for (const metadata of [null, [], {}, { provider: "openai", model: "unrelated" }, { generation: { provider: "unknown", model: "model" } }, { generation: { provider: "openai", model: "" } }, { generation: { provider: "openai", model: [] } }, { generation: { provider: "openai", model: "model" }, agentAside: true }]) {
assert.equal(getMessageGenerationLabel({ role: "assistant", metadata }), null);
}
});
+19 -1
View File
@@ -26,7 +26,7 @@ const markdown = [
const chat = { id: "mobile-chat", title: "Mobile QA conversation with a long title", parentChatId: null, titleGenerationPending: false, const chat = { id: "mobile-chat", title: "Mobile QA conversation with a long title", parentChatId: null, titleGenerationPending: false,
createdAt: date, updatedAt: date, starred: false, starredAt: null, initiatedProvider: "openai", initiatedModel: "mobile-model-one", createdAt: date, updatedAt: date, starred: false, starredAt: null, initiatedProvider: "openai", initiatedModel: "mobile-model-one",
lastUsedProvider: "openai", lastUsedModel: "mobile-model-one", additionalSystemPrompt: null, enabledTools: [], lastUsedProvider: "openai", lastUsedModel: "mobile-model-one", additionalSystemPrompt: null, enabledTools: [],
messages: [{ id: "mobile-assistant", role: "assistant", content: markdown, createdAt: date, name: null, metadata: null }] }; messages: [{ id: "mobile-assistant", role: "assistant", content: markdown, createdAt: date, name: null, metadata: { generation: { provider: "openai", model: "mobile-model-one" } } }] };
const idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] }; const idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] };
const errors = [], calls = [], forkRequests = []; const errors = [], calls = [], forkRequests = [];
let forkedChat; let forkedChat;
@@ -119,6 +119,7 @@ async function checkFooterAlignment() {
assert.notEqual(layout.dateColor, layout.paragraphColor, "timestamp uses secondary color"); assert.notEqual(layout.dateColor, layout.paragraphColor, "timestamp uses secondary color");
assert.equal(await footer.locator("time").getAttribute("datetime"), date); assert.equal(await footer.locator("time").getAttribute("datetime"), date);
assert.match(await footer.locator("time").textContent(), /2026/); assert.match(await footer.locator("time").textContent(), /2026/);
assert.equal(await footer.locator("[data-message-generation]").textContent(), "OpenAI · mobile-model-one");
} }
try { try {
await page.goto(target.href, { waitUntil: "networkidle" }); await page.goto(target.href, { waitUntil: "networkidle" });
@@ -261,6 +262,23 @@ try {
const viewport = await page.locator('meta[name="viewport"]').getAttribute("content"); const viewport = await page.locator('meta[name="viewport"]').getAttribute("content");
assert.match(viewport, /user-scalable=no/); assert.match(viewport, /user-scalable=no/);
assert.match(viewport, /maximum-scale=1\.0(?:,|$)/); assert.match(viewport, /maximum-scale=1\.0(?:,|$)/);
// A mixed-model history keeps each attribution after reload, including unknown old replies.
const longModel = "claude-" + "long-model-identifier-".repeat(8);
chat.messages.push(
{ ...chat.messages[0], id: "second-assistant", content: "A reply from a different provider.", metadata: { generation: { provider: "anthropic", model: longModel } } },
{ ...chat.messages[0], id: "legacy-assistant", content: "A reply with no generation record.", metadata: null },
);
await page.reload({ waitUntil: "networkidle" });
await page.getByRole("heading", { name: chat.title, exact: true }).waitFor();
assert.deepEqual(await page.locator("main [data-message-generation]").allTextContents(), ["OpenAI · mobile-model-one", `Anthropic · ${longModel}`, "Model not recorded"]);
for (const width of [320, 390, 1280]) {
await page.setViewportSize({ width, height: 844 });
if (width < 768) await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
await page.locator('[data-assistant-footer="second-assistant"]').evaluate(el => el.scrollIntoView({ block: "center" }));
assert.ok(await page.locator('[data-assistant-footer="second-assistant"]').evaluate(el => el.scrollWidth <= el.clientWidth), "long model identifier wraps within the footer");
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `${width}px model-label overflow`);
if (artifactDir) await page.screenshot({ path: `${artifactDir}/mixed-models-${width}.png` });
}
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1); await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
await drag(300, 8, -160); await drag(300, 8, -160);