diff --git a/docs/dialog-improvement-api.md b/docs/dialog-improvement-api.md index 7214bd1a1..e43752ca2 100644 --- a/docs/dialog-improvement-api.md +++ b/docs/dialog-improvement-api.md @@ -357,7 +357,7 @@ Query 参数: | 参数 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | -| `projectKey` | string | 是 | 项目注册表中的标识 | +| `projectKey` | string | 否 | 仅兼容旧客户端;模型目录为全局配置,不按项目过滤或扫描项目 | | `query` | string | 否 | 按 provider、model、displayName 检索 | | `provider` | string | 否 | 过滤 provider | | `includeAuto` | boolean | 否 | 是否在 Router 可用时返回 auto | @@ -388,6 +388,7 @@ type ModelCatalogItem = { }; type ModelsResponse = { + defaultSelection: { mode: "model"; provider: string; model: string }; items: ModelCatalogItem[]; router: { enabled: boolean; @@ -408,6 +409,10 @@ type ModelsResponse = { - 未返回的能力表示该模型不支持对应参数。 - Router 开启且支持 auto 时,接口可返回 `{ provider: "router", model: "auto" }` 虚拟条目。 +Web Composer 使用同一浏览器、同一站点的全局模型偏好:首次使用 `defaultSelection`,用户手动选择后保存完整模型及参数,跨项目、会话、刷新和标签页复用。系统默认值变化只影响未手动选择的用户;不可用的已选模型保留并提示重新选择。旧项目和会话草稿不参与全局偏好恢复,避免无法确定时间顺序时任意继承旧选择。 + +Composer 不再请求会话模型 GET/PUT 来恢复或保存选择。每条消息仍携带提交时的 `modelSelection` 快照,运行中和排队消息不受后续选择影响。下列会话 API 为兼容已有客户端保留,不决定 Web Composer 的全局选择。模型目录由页面内共享缓存复用,配置重载或 WebSocket 重连后失效。 + ### 6.2 查询会话模型设置 `GET /api/sessions/model?sessionKey=&projectKey=` @@ -500,6 +505,7 @@ Gateway WebSocket 方法:`submit_turn` ```ts type SessionModelOverride = { + mode: "model"; provider: string; model: string; reasoning?: number; @@ -522,21 +528,22 @@ type SubmitTurnRequest = { mode?: "default" | "plan" | "bypassPermissions"; basePermissionMode?: "default" | "plan" | "bypassPermissions"; modelOverride?: SessionModelOverride; + modelSelection?: { mode: "auto" } | SessionModelOverride; runId?: string; }; ``` -`modelOverride` 仅覆盖当前 turn,不修改第 6.3 节保存的会话模型设置。未传 `modelOverride` 时使用保存的会话设置;会话未设置时,Router 开启则使用 auto,否则使用系统默认模型。 +Web Composer 始终提交 `modelSelection` 快照,可明确选择具体模型或 Auto,并随已接收输入记录。`modelOverride` 为已有客户端保留,只覆盖当前 turn;两者不能同时传入。仅当两者都未传时,才使用兼容的会话设置;会话未设置时,Router 开启则使用 auto,否则使用系统默认模型。 服务端校验: -- `modelOverride.provider/model` 必须存在且可用。 +- 显式选择的 `provider/model` 必须存在且可用;Auto 必须有可用 Router。 - reasoning、temperature、speed 必须满足模型 capabilities;speed 使用 `0..1` 的统一数值语义。 - `uploadedAttachments` 必须属于同一 `projectKey`、状态为 completed 且未过期。 - `mode` 和 `basePermissionMode` 必须属于声明枚举。 - 校验失败时不得启动模型调用。 -模型选择优先级:本轮 `modelOverride` > 已保存的会话模型 > Router auto > 系统默认模型。 +模型选择优先级:本轮 `modelSelection` 或 `modelOverride`(互斥)> 兼容会话设置 > Router auto > 系统默认模型。Web Composer 始终发送快照,所以不依赖会话回退。 响应为 Gateway 事件流,新增事件: diff --git a/docs/trd-dialog-improvement.md b/docs/trd-dialog-improvement.md index 8c81e0f9d..24c3d3852 100644 --- a/docs/trd-dialog-improvement.md +++ b/docs/trd-dialog-improvement.md @@ -213,7 +213,9 @@ type UploadedAttachmentRef = { ### 9.1 模型目录 -`GET /api/models?projectKey=&query=&provider=&includeAuto=` +`GET /api/models?query=&provider=&includeAuto=` + +模型目录直接读取全局配置,不枚举项目或会话;旧客户端传入的 `projectKey` 仅为兼容保留。返回 `defaultSelection` 明确指定系统默认模型,目录顺序不影响选择。 返回 provider、model、displayName、available 以及 reasoning(推理强度)、temperature 和可选 speed 的能力声明。对话框统一使用 0..1 的数值语义;每个模型可通过能力声明限制可用范围、步长或枚举值,后端负责把 0..1 值映射为 Provider 所需参数。temperature 和 speed 统一范围为 0..1。官方 OpenAI / Anthropic 模型默认声明 speed;自定义模型需显式 `supportsSpeed: true`,且 Google Provider 当前不支持该字段。目录将 speed 暴露为枚举 `0`(标准)与 `1`(快速)。 @@ -227,6 +229,7 @@ type UploadedAttachmentRef = { ```ts type SessionModelOverride = { + mode: "model"; provider: string; model: string; reasoning?: number; @@ -235,11 +238,16 @@ type SessionModelOverride = { }; type GatewaySubmitTurnInput = ExistingGatewaySubmitTurnInput & { modelOverride?: SessionModelOverride; + modelSelection?: { mode: "auto" } | SessionModelOverride; uploadedAttachments?: UploadedAttachmentRef[]; }; ``` -### 9.3 会话模型状态 +### 9.3 Web 全局偏好与兼容会话模型状态 + +Web Composer 只保存同一浏览器、同一站点内最近一次手动选择的模型及参数,不区分项目或会话,并同步其他标签页。未手动选择时使用系统默认模型;手动选择后,配置默认值变化、会话切换、提交确认和运行结果都不改写偏好。已选模型不可用时提示重新选择,不自动切换。 + +Composer 不再调用下列会话模型读写接口。它在准备附件之前固定每条提交的 `modelSelection`,队列、编辑重发和历史执行记录继续保留各自的快照。会话 API 保留给已有客户端,其保存值不参与 Web 全局选择恢复。 新增会话模型读写接口: @@ -249,7 +257,7 @@ type GatewaySubmitTurnInput = ExistingGatewaySubmitTurnInput & { 保存值写入 session metadata,会话恢复后继续生效。`mode=auto` 仅在 Router 开启时允许;清除设置后,Router 开启则回到 auto,Router 关闭则回到 `agent.model`。 -`submit_turn.modelOverride` 只覆盖本轮,不修改会话保存值。模型解析顺序:本轮 `modelOverride` > 会话保存模型 > Router auto/路由决策 > `agent.model` 默认模型。 +`submit_turn.modelSelection` 为 Web 提交时固定的模型快照,随已接收输入记录;`modelOverride` 只覆盖本轮,不修改会话保存值。两者互斥,均优先于兼容会话设置。只有两者都未传时才使用会话保存模型 > Router auto/路由决策 > `agent.model` 默认模型;Web Composer 不依赖这条回退路径。 `provider/model` 不存在或不可用返回 `INVALID_MODEL_OVERRIDE`;reasoning、temperature 或 speed 不满足模型能力返回 `UNSUPPORTED_MODEL_PARAMETER`。未声明支持的参数不发送给 Provider。speed 必须在 canonical request 入口通过 `0..1` 校验,再由支持 speed 的 Provider adapter 映射为原生字段;Google Provider 不声明或接收 speed。 diff --git a/src/agent/protocol/input.ts b/src/agent/protocol/input.ts index 9cb4d6298..05d0291be 100644 --- a/src/agent/protocol/input.ts +++ b/src/agent/protocol/input.ts @@ -35,4 +35,6 @@ export type AgentSubmitOptions = { */ syntheticMessages?: import("../../model/index.js").CanonicalMessage[]; modelOverride?: AgentModelOverride; + /** Submitted model snapshot, recorded for replay and legacy session clients. */ + modelSelection?: NonNullable; }; diff --git a/src/agent/session/AgentSession.ts b/src/agent/session/AgentSession.ts index 1df57dae0..92ccaa4fa 100644 --- a/src/agent/session/AgentSession.ts +++ b/src/agent/session/AgentSession.ts @@ -95,6 +95,7 @@ export class AgentSession { permissionRules: submitOptions.permissionRules, syntheticMessages: submitOptions.syntheticMessages, modelOverride: submitOptions.modelOverride, + modelSelection: submitOptions.modelSelection, abortSignal: this.state.abortController.signal, openSteerMailbox: () => this.steerMailbox.start(turnId), drainSteerMessages: () => this.steerMailbox.drain(turnId), diff --git a/src/agent/turn/TurnRunner.ts b/src/agent/turn/TurnRunner.ts index 4d7229aab..d9a935c0d 100644 --- a/src/agent/turn/TurnRunner.ts +++ b/src/agent/turn/TurnRunner.ts @@ -36,6 +36,7 @@ export type TurnRunnerOptions = { /** Synthetic messages appended after user input; stored with metadata.synthetic flag. */ syntheticMessages?: CanonicalMessage[]; modelOverride?: AgentModelOverride; + modelSelection?: NonNullable; openSteerMailbox?: () => void; drainSteerMessages?: () => AgentSteerMessage[]; drainOrCloseSteerMailbox?: () => { messages: AgentSteerMessage[]; closed: boolean }; @@ -488,12 +489,15 @@ export class TurnRunner { const snapshot = metadataStore.getSnapshot(); const prompt = allHumanText(acceptedMessages); - if (!prompt) return; + if (!prompt && !options.modelSelection) return; - const boundedPrompt = prompt.slice(0, SESSION_LISTING_PROMPT_MAX_CHARS); + const boundedPrompt = prompt?.slice(0, SESSION_LISTING_PROMPT_MAX_CHARS); await metadataStore.record(options.turnId, { - ...(snapshot.firstPrompt ? {} : { firstPrompt: boundedPrompt }), - lastPrompt: boundedPrompt, + ...(boundedPrompt ? { + ...(snapshot.firstPrompt ? {} : { firstPrompt: boundedPrompt }), + lastPrompt: boundedPrompt, + } : {}), + ...(options.modelSelection ? { modelSelection: { ...options.modelSelection } } : {}), updatedAt: this.now().toISOString(), }).catch(() => {}); } @@ -505,6 +509,8 @@ function isVisibleFailureStatus(status: AgentStatusMessageInput): boolean { function acceptedInputMetadata(options: TurnRunnerOptions): Record | undefined { const metadata: Record = {}; + // Save alongside input so a crash before the metadata snapshot cannot lose the choice. + if (options.modelSelection) metadata.modelSelection = { ...options.modelSelection }; if (options.permissionMode) { metadata.permissionMode = options.permissionMode; } diff --git a/src/cli/createLocalGateway.ts b/src/cli/createLocalGateway.ts index d220a1aa5..b78e8f7c7 100644 --- a/src/cli/createLocalGateway.ts +++ b/src/cli/createLocalGateway.ts @@ -382,8 +382,7 @@ export function createLocalGateway(options: CreateLocalGatewayOptions = {}): Cre return listCommands({ ...input, projectKey }, pilotHome); }, async modelCatalogList(input) { - const projectKey = await dialogProjects.resolveProjectKey(input.projectKey); - return listModelCatalog({ ...input, projectKey }, env); + return listModelCatalog(input, env); }, async sessionModelGet(input) { const projectKey = await dialogProjects.resolveProjectKey(input.projectKey); @@ -415,13 +414,22 @@ export function createLocalGateway(options: CreateLocalGatewayOptions = {}): Cre }, async resolveTurnModelSelection(input) { const projectKey = await dialogProjects.resolveProjectKey(input.projectKey ?? fallbackProjectRoot); + if (input.modelSelection !== undefined && input.modelOverride !== undefined) { + throw new DialogGatewayError("INVALID_MODEL_OVERRIDE", "Specify modelSelection or modelOverride, not both."); + } + if (input.modelSelection !== undefined) { + validateModelSelection(projectKey, input.modelSelection, env); + return input.modelSelection.mode === "model" + ? { selection: input.modelSelection, source: "turn" as const } + : { source: "router" as const }; + } if (input.modelOverride) { validateExplicitModelSelection(projectKey, input.modelOverride, env); return { selection: input.modelOverride, source: "turn" as const }; } const saved = await readSavedModel(projectKey, input.sessionKey); + if (saved) validateModelSelection(projectKey, saved, env); if (saved?.mode === "model") { - validateExplicitModelSelection(projectKey, saved, env); return { selection: saved, source: "session" as const }; } const config = loadPilotConfig({ projectRoot: projectKey, env }).config; diff --git a/src/gateway/client/InProcessGateway.ts b/src/gateway/client/InProcessGateway.ts index c8f08faf1..9fd33a8d2 100644 --- a/src/gateway/client/InProcessGateway.ts +++ b/src/gateway/client/InProcessGateway.ts @@ -554,10 +554,13 @@ export class InProcessGateway implements Gateway { })); const modelSelection = this.options.resolveTurnModelSelection ? await this.options.resolveTurnModelSelection(input) - : input.modelOverride - ? { selection: input.modelOverride, source: "turn" as const } - : { source: "default" as const }; + : input.modelSelection?.mode === "auto" + ? { source: "router" as const } + : input.modelSelection?.mode === "model" || input.modelOverride + ? { selection: input.modelSelection?.mode === "model" ? input.modelSelection : input.modelOverride, source: "turn" as const } + : { source: "default" as const }; let lastEmittedModel: string | undefined; + let actualRequestModel: string | undefined; if (modelSelection.selection) { const event: GatewayEvent = { type: "model_selection_changed", @@ -577,6 +580,7 @@ export class InProcessGateway implements Gateway { agentInput, { turnId: runId, + modelSelection: input.modelSelection, maxTurns: input.maxTurns, runMode, permissionMode, @@ -620,6 +624,7 @@ export class InProcessGateway implements Gateway { if (event.type === "input_accepted") { await this.commitAcceptedTurnReplacement(input.sessionKey, runId); } + if (event.type === "model_event" && event.event.type === "request_started") actualRequestModel = event.event.model; if (event.type === "model_event" && event.event.type === "request_started" && lastEmittedModel !== `${event.event.provider}\0${event.event.model}`) { const selectionEvent: GatewayEvent = { @@ -634,6 +639,10 @@ export class InProcessGateway implements Gateway { lastEmittedModel = `${event.event.provider}\0${event.event.model}`; } for (const gatewayEvent of mapAgentEvent(event, runId)) { + if (gatewayEvent.type === "assistant_text_delta" && actualRequestModel) gatewayEvent.model = actualRequestModel; + if (gatewayEvent.type === "input_accepted" && input.modelSelection) { + gatewayEvent.modelSelection = { ...input.modelSelection }; + } if (gatewayEvent.type === "context_budget") { this.recordGatewayStatusMessage({ sessionKey: input.sessionKey, diff --git a/src/gateway/dialog/modelCatalog.ts b/src/gateway/dialog/modelCatalog.ts index 974db6fbe..916df09a9 100644 --- a/src/gateway/dialog/modelCatalog.ts +++ b/src/gateway/dialog/modelCatalog.ts @@ -16,8 +16,7 @@ const REASONING_VALUES = new Map([ ]); export function listModelCatalog(input: ModelCatalogListInput, env: NodeJS.ProcessEnv = process.env): ModelCatalogListResult { - if (!input.projectKey?.trim()) throw new DialogGatewayError("PROJECT_NOT_FOUND", "projectKey is required."); - const config = loadPilotConfig({ projectRoot: input.projectKey, env }).config; + const config = loadPilotConfig({ env }).config; const query = input.query?.trim().toLocaleLowerCase() ?? ""; const items: ModelCatalogItem[] = []; for (const [providerId, provider] of Object.entries(config.model.providers)) { @@ -51,11 +50,15 @@ export function listModelCatalog(input: ModelCatalogListInput, env: NodeJS.Proce && (!query || "router auto".includes(query))) { items.unshift({ id: "router/auto", provider: "router", model: "auto", displayName: "Auto", available: true, capabilities: {} }); } - return { items, router: { enabled: routerEnabled, autoAvailable: routerEnabled } }; + return { + items, + defaultSelection: { mode: "model", provider: config.agent.model.provider, model: config.agent.model.model }, + router: { enabled: routerEnabled, autoAvailable: routerEnabled }, + }; } export function validateModelSelection(projectKey: string, selection: SessionModelSelection, env: NodeJS.ProcessEnv = process.env): void { - if (selection.mode === "auto") { + if (selection?.mode === "auto") { if (!listModelCatalog({ projectKey }, env).router.autoAvailable) { throw new DialogGatewayError("ROUTER_AUTO_UNAVAILABLE", "Router auto is not available for this project."); } diff --git a/src/gateway/protocol/types.ts b/src/gateway/protocol/types.ts index ce48e271e..7708bfe21 100644 --- a/src/gateway/protocol/types.ts +++ b/src/gateway/protocol/types.ts @@ -100,6 +100,8 @@ export type GatewaySubmitTurnInput = { uploadedAttachments?: UploadedAttachmentRef[]; /** A one-turn model override. Persisted session preferences are managed separately. */ modelOverride?: ExplicitModelSelection; + /** Submitted choice: used for this turn and recorded with accepted input; never updates the Web global preference. */ + modelSelection?: SessionModelSelection; runMode?: AgentRunMode; mode?: GatewayMode; /** The user's actual permission preference before plan-mode override. */ @@ -172,7 +174,7 @@ type GatewayTurnScopedEventMetadata = { export type GatewayEvent = GatewayTurnScopedEventMetadata & ( | { type: "turn_started"; runId: string } - | { type: "input_accepted"; runId: string } + | { type: "input_accepted"; runId: string; modelSelection?: SessionModelSelection } | { type: "steer_applied"; itemId: string; message: CanonicalMessage } | { type: "steer_unapplied"; itemId: string; reason: "turn_ended" } | { type: "model_request_started"; model?: string; provider?: string } @@ -185,7 +187,7 @@ export type GatewayEvent = GatewayTurnScopedEventMetadata & ( temperature?: number; speed?: number; } - | { type: "assistant_text_delta"; text: string } + | { type: "assistant_text_delta"; text: string; model?: string } | { type: "assistant_attachment"; attachment: GatewayOutboundAttachment } | { type: "file_artifacts"; artifacts: import("../../session/artifacts/FileArtifact.js").FileArtifact[] } | { type: "assistant_thinking_delta"; text: string } @@ -467,13 +469,15 @@ export type ModelCatalogItem = { }; export type ModelCatalogListInput = { - projectKey: string; + /** Accepted for compatibility; the model catalog is global. */ + projectKey?: string; query?: string; provider?: string; includeAuto?: boolean; }; export type ModelCatalogListResult = { + defaultSelection: ExplicitModelSelection; items: ModelCatalogItem[]; router: { enabled: boolean; autoAvailable: boolean }; }; diff --git a/src/model/protocol/canonical.ts b/src/model/protocol/canonical.ts index 390a1a39a..0b98f03a0 100644 --- a/src/model/protocol/canonical.ts +++ b/src/model/protocol/canonical.ts @@ -134,6 +134,8 @@ export type CanonicalContentBlock = | CanonicalMediaReferenceBlock; export type CanonicalMessageMetadata = { + /** Actual model that generated this assistant message. */ + model?: string; /** True for messages injected by the system (e.g. JSON self-correct prompts). */ synthetic?: boolean; /** Synthetic prompt that should be consumed by the next assistant response only. */ diff --git a/src/model/streaming/assembleModelMessage.ts b/src/model/streaming/assembleModelMessage.ts index a6d24f455..1838177ad 100644 --- a/src/model/streaming/assembleModelMessage.ts +++ b/src/model/streaming/assembleModelMessage.ts @@ -18,6 +18,7 @@ import { export type ModelMessageAssemblerState = { content: CanonicalContentBlock[]; textBuffer: string; + model?: string; thinkingBuffer: string; thinkingReasoningContentBuffer: string; thinkingSignature?: string; @@ -67,6 +68,8 @@ export function applyModelEventToAssembler( ): void { switch (event.type) { case "request_started": + state.model = event.model; + return; case "message_start": case "tool_call_start": case "tool_call_delta": @@ -151,6 +154,7 @@ export function assembleAssistantMessage(state: ModelMessageAssemblerState): Ass message: { role: "assistant", content: [...state.content], + ...(state.model ? { metadata: { model: state.model } } : {}), }, finishReason: state.finishReason ?? (state.error ? "error" : "unknown"), hasMessageEnd: state.hasMessageEnd, diff --git a/src/session/transcript/TranscriptReplay.ts b/src/session/transcript/TranscriptReplay.ts index d0ce0ffa7..0d242d23f 100644 --- a/src/session/transcript/TranscriptReplay.ts +++ b/src/session/transcript/TranscriptReplay.ts @@ -60,6 +60,12 @@ export function replayTranscriptEntries(entries: AgentTranscriptEntry[]): AgentT switch (entry.type) { case "accepted_input": + if (entry.metadata?.modelSelection) { + const choice = entry.metadata.modelSelection as SessionMetadataValue["modelSelection"]; + if (choice?.mode === "auto" || (choice?.mode === "model" && typeof choice.provider === "string" && typeof choice.model === "string")) { + metadata = mergeMetadata(metadata, { modelSelection: { ...choice } }); + } + } if (!beforeBoundary) { messages.push(...cloneMessages(entry.messages)); events.push({ diff --git a/src/web/client/protocol.ts b/src/web/client/protocol.ts index d2692c6e8..477a91a16 100644 --- a/src/web/client/protocol.ts +++ b/src/web/client/protocol.ts @@ -50,7 +50,7 @@ export type WebGatewayEvent = WebGatewayEventMetadata & ( | { type: "steer_applied"; itemId: string; message: import("../../model/index.js").CanonicalMessage } | { type: "steer_unapplied"; itemId: string; reason: "turn_ended" } | { type: "model_selection_changed"; provider: string; model: string; source: "turn" | "session" | "router" | "default"; reasoning?: number; temperature?: number; speed?: number } - | { type: "assistant_text_delta"; text: string } + | { type: "assistant_text_delta"; text: string; model?: string } | { type: "assistant_thinking_delta"; text: string } | { type: "file_artifacts"; artifacts: import("../../session/artifacts/FileArtifact.js").FileArtifact[] } | { @@ -175,6 +175,7 @@ export type WebSubmitTurnInput = { projectKey?: string; uploadedAttachments?: Array<{ uploadId: string; attachmentIds?: string[] }>; modelOverride?: WebExplicitModelSelection; + modelSelection?: { mode: "auto" } | WebExplicitModelSelection; attachments?: WebChannelAttachment[]; runMode?: WebAgentRunMode; mode?: WebGatewayMode; @@ -223,8 +224,12 @@ export type WebCommandsListInput = { projectKey: string; query?: string; cursor? export type WebCommandsListResult = { pinned: unknown[]; builtIn: unknown[]; custom: unknown[]; nextCursor?: string }; export type WebExplicitModelSelection = { mode: "model"; provider: string; model: string; reasoning?: number; temperature?: number; speed?: number }; export type WebSessionModelSelection = { mode: "auto" } | WebExplicitModelSelection; -export type WebModelCatalogListInput = { projectKey: string; query?: string; provider?: string; includeAuto?: boolean }; -export type WebModelCatalogListResult = { items: unknown[]; router: { enabled: boolean; autoAvailable: boolean } }; +export type WebModelCatalogListInput = { projectKey?: string; query?: string; provider?: string; includeAuto?: boolean }; +export type WebModelCatalogListResult = { + defaultSelection: WebExplicitModelSelection; + items: unknown[]; + router: { enabled: boolean; autoAvailable: boolean }; +}; export type WebSessionModelInput = { projectKey: string; sessionKey: string }; export type WebSessionModelResult = WebSessionModelInput & { saved?: WebSessionModelSelection; effective: { provider: string; model: string; source: "session" | "router" | "default"; reasoning?: number; temperature?: number; speed?: number } }; diff --git a/src/web/client/webMessage.ts b/src/web/client/webMessage.ts index 205e3f12a..896bdd1c0 100644 --- a/src/web/client/webMessage.ts +++ b/src/web/client/webMessage.ts @@ -114,6 +114,8 @@ export type WebMessage = { requestId?: string; ok?: boolean; text?: string; + /** Actual generating model, without the provider prefix. */ + model?: string; contentI18n?: { key: string; params?: Record }; userHintI18n?: { key: string; params?: Record }; images?: Array<{ @@ -207,7 +209,7 @@ export function applyWebGatewayEvent( ...state, messages: state.messages.map((m) => m.id === state.currentAssistantId - ? { ...m, text: `${m.text ?? ""}${event.text}` } + ? { ...m, text: `${m.text ?? ""}${event.text}`, ...(event.model ? { model: event.model } : {}) } : m, ), }; @@ -222,6 +224,7 @@ export function applyWebGatewayEvent( role: "assistant", kind: "text", text: event.text, + ...(event.model ? { model: event.model } : {}), source: "live", }; return { diff --git a/src/web/server/readSessionMessages.ts b/src/web/server/readSessionMessages.ts index bda41032f..4264fa3d6 100644 --- a/src/web/server/readSessionMessages.ts +++ b/src/web/server/readSessionMessages.ts @@ -538,6 +538,7 @@ export function flattenCanonicalMessage( role, kind: "text", text: textBuffer, + ...(role === "assistant" && typeof message.metadata?.model === "string" ? { model: message.metadata.model } : {}), ...(pendingImages.length > 0 ? { images: pendingImages } : {}), ...(context.forkUnsupportedContent ? { diff --git a/tests/gateway/dialog-model-selection.spec.ts b/tests/gateway/dialog-model-selection.spec.ts new file mode 100644 index 000000000..227d60c4f --- /dev/null +++ b/tests/gateway/dialog-model-selection.spec.ts @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import type { PilotConfigSnapshot } from '../../src/pilot/config/types.js'; +import { createLocalGateway } from '../../src/cli/createLocalGateway.js'; +import { createModelRuntime, type CanonicalModelEvent, type CanonicalModelRequest } from '../../src/model/index.js'; +import { createAgentProjectSessionStorage, readTranscript, replayTranscriptEntries } from '../../src/session/index.js'; +import { readWebSessionMessages } from '../../src/web/server/readSessionMessages.js'; +import type { GatewayEvent, GatewaySubmitTurnInput } from '../../src/gateway/protocol/types.js'; + +const A = { mode: 'model' as const, provider: 'alpha', model: 'first' }; +const B = { mode: 'model' as const, provider: 'zeta', model: 'configured', reasoning: 0.8, temperature: 0.3, speed: 1 }; +const CONFIG = ` +schemaVersion: 1 +agent: + model: zeta/configured + maxContextTokens: 65536 + maxOutputTokens: 8192 +extension: + builtinPluginsEnabled: + windows-skills: false + browser-use: false + funasr: false +memory: + enabled: false +telemetry: + enabled: false +model: + providers: + alpha: + protocol: openai + url: https://example.test/v1 + apiKey: test-key + models: + first: {} + zeta: + protocol: openai + url: https://example.test/v1 + apiKey: test-key + speedMapping: openai_service_tier + models: + configured: + capabilities: + supportsSpeed: true +router: + enabled: true + scenarios: + default: zeta/configured + fallback: + default: [alpha/first] + zeroUsageRetry: + enabled: false + transientRetry: + enabled: false +`; + +async function fixture(t: test.TestContext) { + const home = await mkdtemp(join(tmpdir(), 'pilotdeck-model-choice-')); + await writeFile(join(home, 'pilotdeck.yaml'), CONFIG); + await mkdir(join(home, 'skills'), { recursive: true }); + const requests: CanonicalModelRequest[] = []; + let failZeta = false; + const options = { + pilotHome: home, projectRoot: home, + env: { ...process.env, PILOT_HOME: home, PILOT_AGENT_MODEL: undefined, PILOTDECK_CONFIG_PATH: undefined }, + builtinSkillsRoot: join(home, 'skills'), + __testModelFactory: (snapshot: PilotConfigSnapshot) => ({ + ...createModelRuntime(snapshot.config.model), + async *stream(request: CanonicalModelRequest): AsyncIterable { + requests.push(request); + yield { type: 'request_started', provider: request.provider, model: request.model }; + if (failZeta && request.provider === 'zeta') { + yield { type: 'error', error: { provider: request.provider, protocol: 'openai', code: 'auth_error', message: 'test failure', retryable: false } }; + return; + } + yield { type: 'message_start', role: 'assistant' }; + yield { type: 'text_delta', text: 'ok' }; + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 1 } }; + yield { type: 'message_end', finishReason: 'stop' }; + }, + async complete() { return { role: 'assistant' as const, content: [{ type: 'text' as const, text: '' }], finishReason: 'stop' as const }; }, + }), + }; + let local = createLocalGateway(options); + t.after(async () => { local.dispose(); await rm(home, { recursive: true, force: true }); }); + return { + home, requests, + get gateway() { return local.gateway; }, + fail() { failZeta = true; }, + restart() { local.dispose(); local = createLocalGateway(options); }, + async submit(modelSelection?: GatewaySubmitTurnInput['modelSelection'], modelOverride?: GatewaySubmitTurnInput['modelOverride']) { + const events: GatewayEvent[] = []; + for await (const event of local.gateway.submitTurn({ + projectKey: home, sessionKey: 'web:model-choice', channelKey: 'web', message: 'hello', modelSelection, modelOverride, + })) events.push(event); + return events; + }, + async saved() { return (await local.gateway.sessionModelGet!({ projectKey: home, sessionKey: 'web:model-choice' })).saved; }, + }; +} + +test('first-turn choice and parameters are durable at acceptance and survive gateway restart', async (t) => { + const f = await fixture(t); + const catalog = await f.gateway.modelCatalogList!({ projectKey: f.home, includeAuto: true }); + assert.equal(catalog.items[0]!.id, 'router/auto'); + assert.equal(catalog.items[1]!.id, 'alpha/first'); + assert.deepEqual(catalog.defaultSelection, { mode: 'model', provider: B.provider, model: B.model }); + for await (const event of f.gateway.submitTurn({ projectKey: f.home, sessionKey: 'web:model-choice', channelKey: 'web', message: 'hello', modelSelection: B })) { + if (event.type === 'input_accepted') { + assert.deepEqual(event.modelSelection, B); + assert.deepEqual(await f.saved(), B); + } + } + assert.equal(f.requests.length, 1); + assert.equal(f.requests[0]!.provider, B.provider); + assert.equal(f.requests[0]!.temperature, B.temperature); + assert.equal(f.requests[0]!.speed, B.speed); + assert.equal(f.requests[0]!.thinking?.mode, 'high'); + const storage = createAgentProjectSessionStorage({ projectRoot: f.home, pilotHome: f.home, sessionId: 'web:model-choice' }); + const entries = (await readTranscript(storage.transcriptPath)).entries; + const acceptedOnly = entries.filter((e) => e.type === 'accepted_input'); + assert.deepEqual(replayTranscriptEntries(acceptedOnly).metadata.modelSelection, B, 'crash before metadata snapshot retains the choice'); + f.restart(); + assert.deepEqual(await f.saved(), B); + await f.submit(); + assert.equal(f.requests.at(-1)!.provider, B.provider); + assert.equal(f.requests.at(-1)!.speed, B.speed); +}); + +test('explicit Auto replaces saved concrete choice; one-turn overrides do not change saved preferences', async (t) => { + const f = await fixture(t); + await f.submit(A); + assert.equal(f.requests.at(-1)!.provider, A.provider); + await f.submit({ mode: 'auto' }); + assert.equal(f.requests.at(-1)!.provider, B.provider); + assert.deepEqual(await f.saved(), { mode: 'auto' }); + f.restart(); + await f.submit(); + assert.equal(f.requests.at(-1)!.provider, B.provider); + await f.submit(A); + await f.submit(undefined, B); + assert.equal(f.requests.at(-1)!.provider, B.provider); + assert.deepEqual(await f.saved(), A); + await f.submit(); + assert.equal(f.requests.at(-1)!.provider, A.provider); +}); + +test('concrete choices fail without silently falling back, while Auto retains fallback', async (t) => { + const f = await fixture(t); + f.fail(); + await f.submit(B); + assert.ok(f.requests.length > 0); + assert.deepEqual([...new Set(f.requests.map((r) => r.provider))], ['zeta']); + f.requests.length = 0; + f.restart(); + await f.submit({ mode: 'auto' }); + assert.deepEqual([...new Set(f.requests.map((r) => r.provider))], ['zeta', 'alpha']); + assert.deepEqual(await f.saved(), { mode: 'auto' }); +}); + +test('invalid and conflicting choices cannot execute or replace the saved preference', async (t) => { + const f = await fixture(t); + await f.submit(A); + f.requests.length = 0; + for (const input of [ + { modelSelection: { ...B, model: 'missing' } }, + { modelSelection: B, modelOverride: A }, + { modelSelection: null as unknown as GatewaySubmitTurnInput['modelSelection'] }, + ]) { + const events = await f.submit(input.modelSelection, input.modelOverride); + assert.equal(events.some((event) => event.type === 'input_accepted'), false); + } + assert.equal(f.requests.length, 0); + assert.deepEqual(await f.saved(), A); +}); + +test('global model catalog needs no project registration and ignores legacy project scope', async (t) => { + const f = await fixture(t); + const global = await f.gateway.modelCatalogList!({ includeAuto: true }); + const unregistered = await f.gateway.modelCatalogList!({ projectKey: '/not-a-registered-project', includeAuto: true }); + assert.deepEqual(unregistered, global); + assert.deepEqual(global.defaultSelection, { mode: 'model', provider: B.provider, model: B.model }); +}); + +test('a new explicit snapshot overrides an old session preference after restart', async (t) => { + const f = await fixture(t); + await f.submit(A); + f.restart(); + await f.submit(B); + assert.equal(f.requests.at(-1)!.provider, B.provider); + assert.equal(f.requests.at(-1)!.model, B.model); + assert.equal(f.requests.at(-1)!.temperature, B.temperature); +}); + + +test('response model survives transcript replay and differs from the next submitted choice', async (t) => { + const f = await fixture(t); + const aEvents = await f.submit(A); + assert.ok(aEvents.some((event) => event.type === 'assistant_text_delta' && event.model === A.model)); + await f.submit(B); + f.restart(); + const history = await readWebSessionMessages({ projectKey: f.home, sessionKey: 'web:model-choice' }, { projectRoot: f.home, pilotHome: f.home }); + assert.deepEqual(history.messages.filter((message) => message.role === 'assistant' && message.kind === 'text').map((message) => message.model), [A.model, B.model]); +}); diff --git a/ui/e2e/fixtures/model-selection.html b/ui/e2e/fixtures/model-selection.html new file mode 100644 index 000000000..c32d3b819 --- /dev/null +++ b/ui/e2e/fixtures/model-selection.html @@ -0,0 +1 @@ +Model selection fixture
diff --git a/ui/e2e/fixtures/model-selection.jsx b/ui/e2e/fixtures/model-selection.jsx new file mode 100644 index 000000000..be57a5da7 --- /dev/null +++ b/ui/e2e/fixtures/model-selection.jsx @@ -0,0 +1,90 @@ +import React, { useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import MessageRow from '../../src/components/chat-v2/MessageRowV2'; +import Composer from '../../src/components/chat-v2/ComposerV2'; +import { useChatModelSelection } from '../../src/components/chat/hooks/useChatModelSelection'; +import { useChatComposerState } from '../../src/components/chat/hooks/useChatComposerState'; +import { startSessionCommand, createUserTurnRunId } from '../../src/components/chat/utils/sessionLauncher'; +import i18n from '../../src/i18n/config'; +import '../../src/index.css'; +i18n.changeLanguage('en'); +const noop = () => {}; +const props = { + placeholder: 'Message', renderInputWithMentions: (text) => text, + onTextareaClick: noop, onTextareaKeyDown: noop, onTextareaPaste: noop, onTextareaScrollSync: noop, onTextareaInput: noop, + onAbortSession: noop, openImagePicker: noop, onAddAttachmentFiles: noop, attachedImages: [], onRemoveImage: noop, onRetryImage: noop, + documentReferences: [], onRemoveDocumentReference: noop, uploadingImages: new Map(), imageErrors: new Map(), + filteredFiles: [], selectedFileMentions: [], selectedSkills: [], selectedCommands: [], filteredCommands: [], frequentCommands: [], + getRootProps: () => ({}), getInputProps: () => ({}), pendingPermissionRequests: [], permissionMode: 'default', runMode: 'agent', + onPermissionModeChange: noop, onRunModeChange: noop, onInsertSlash: noop, onToggleCommandMenu: noop, +}; +function App() { + const [projectKey, setProject] = useState('/general'); + const [sessionId, setSession] = useState(new URLSearchParams(location.search).get('session') || undefined); + const [input, setInput] = useState('hello'); + const [loading, setLoading] = useState(false); + const [frame, setFrame] = useState(null); + const model = useChatModelSelection(); + const textareaRef = useRef(null), highlightRef = useRef(null); + const send = (event) => { + event.preventDefault(); + if (!model.isModelSelectionReady) return; + const runId = createUserTurnRunId(); + startSessionCommand({ + selectedProject: { name: 'fixture', path: projectKey }, sessionId, + command: input, modelSelection: model.modelSelection, runId, + sendMessage: (message) => { + setFrame(message); setLoading(true); setInput(''); + void fetch('/api/test-submit', { method: 'POST', body: JSON.stringify(message) }).then((r) => r.json()).then((accepted) => { + setSession(accepted.sessionId); + }); + return true; + }, + }); + }; + return
+ + + + {JSON.stringify(model.modelSelection)} + {JSON.stringify(frame)} + {frame ?
[]} />
: null} + setInput(e.target.value)} onSubmit={send} + onModelSelectionChange={(choice) => { void model.setModelSelection(choice); }} + /> +
; +} + +const commandProject = { name: 'fixture', fullPath: '/general' }; +function CommandApp() { + const [settingsOpened, setSettingsOpened] = useState(0); + const [messages, setMessages] = useState([]); + const [sent, setSent] = useState(0); + const pendingViewSessionRef = useRef(null); + const ready = new URLSearchParams(location.search).get('ready') === 'true'; + const composer = useChatComposerState({ + selectedProject: commandProject, selectedSession: null, currentSessionId: null, + model: 'missing/model', modelSelection: { mode: 'model', provider: 'missing', model: 'model' }, + isModelSelectionReady: ready, permissionMode: 'default', cycleRunMode: noop, isLoading: false, + canAbortSession: false, tokenBudget: null, sendMessage: () => { setSent((n) => n + 1); return true; }, + onShowSettings: () => setSettingsOpened((n) => n + 1), pendingViewSessionRef, scrollToBottom: noop, + addMessage: (message) => setMessages((previous) => [...previous, message]), clearMessages: noop, rewindMessages: noop, + setIsLoading: noop, setCanAbortSession: noop, setIsAborting: noop, setClaudeStatus: noop, setPilotDeckStatus: noop, + setIsUserScrolledUp: noop, pendingPermissionRequests: [], setPendingPermissionRequests: noop, + }); + return
+ {settingsOpened} + {composer.slashCommandsCount} + {sent} + {JSON.stringify(messages)} + +
; +} +createRoot(document.getElementById('root')).render(new URLSearchParams(location.search).has('commands') ? : ); diff --git a/ui/e2e/model-selection.config.mjs b/ui/e2e/model-selection.config.mjs new file mode 100644 index 000000000..2a8b9875d --- /dev/null +++ b/ui/e2e/model-selection.config.mjs @@ -0,0 +1,10 @@ +import { defineConfig } from '@playwright/test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +const uiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export default defineConfig({ + testDir: '.', testMatch: ['model-selection.spec.mjs'], outputDir: '/tmp/pilotdeck-model-selection-playwright', workers: 1, + use: { baseURL: 'http://127.0.0.1:5180', viewport: { width: 1100, height: 800 }, screenshot: 'only-on-failure' }, + webServer: { command: 'node node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5180 --strictPort', cwd: uiRoot, + url: 'http://127.0.0.1:5180/e2e/fixtures/model-selection.html', reuseExistingServer: false }, +}); diff --git a/ui/e2e/model-selection.spec.mjs b/ui/e2e/model-selection.spec.mjs new file mode 100644 index 000000000..47e509950 --- /dev/null +++ b/ui/e2e/model-selection.spec.mjs @@ -0,0 +1,180 @@ +import { test, expect } from '@playwright/test'; +const A = { mode: 'model', provider: 'alpha', model: 'first' }; +const B = { mode: 'model', provider: 'zeta', model: 'configured' }; +const items = [A, B].map((x) => ({ id: `${x.provider}/${x.model}`, provider: x.provider, model: x.model, displayName: x.model, available: true, capabilities: {} })); +const catalog = { items: [{ id: 'router/auto', provider: 'router', model: 'auto', displayName: 'Auto', available: true, capabilities: {} }, ...items], defaultSelection: B, router: { autoAvailable: true } }; +async function setup(page, { holdCatalog = false, unavailable = false } = {}) { + const saved = new Map(); + const submitted = []; + const modelRequests = []; + let release; + const gate = new Promise((r) => { release = r; }); + await page.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + let result = {}; + if (url.pathname === '/api/models' || url.pathname === '/api/sessions/model') modelRequests.push({ path: url.pathname, query: url.search, method: request.method() }); + if (url.pathname === '/api/models') { + if (holdCatalog) await gate; + result = unavailable ? { ...catalog, items: catalog.items.filter((x) => x.id !== 'zeta/configured') } : catalog; + } else if (url.pathname === '/api/sessions/model') { + if (request.method() === 'PUT') { + const data = request.postDataJSON(); saved.set(data.sessionKey, data.selection); + } + result = { saved: saved.get(url.searchParams.get('sessionKey')), effective: A }; + } else if (url.pathname === '/api/test-submit') { + const data = request.postDataJSON(); submitted.push(data); + saved.set('web:created', data.options.modelSelection); + result = { sessionId: 'web:created' }; + } + await route.fulfill({ json: result }); + }); + await page.goto('/e2e/fixtures/model-selection.html'); + return { saved, submitted, release, modelRequests }; +} +const choice = async (page) => JSON.parse(await page.getByTestId('selection').textContent()); + +test('general and project defaults match configuration and sending is blocked while loading', async ({ page }) => { + const { release, submitted } = await setup(page, { holdCatalog: true }); + await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeDisabled(); + release(); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'Project', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect.poll(() => submitted.length).toBe(1); + expect(submitted[0].options.modelSelection).toEqual(B); + await expect.poll(() => choice(page)).toEqual(B); +}); + +test('manual selection survives sending, completion and reload', async ({ page }) => { + const { submitted, modelRequests } = await setup(page); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await page.getByRole('button', { name: 'first', exact: true }).click(); + await page.getByRole('button', { name: 'Project', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(A); + expect(modelRequests).toEqual([{ path: '/api/models', query: '?includeAuto=true', method: 'GET' }]); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect.poll(() => submitted.length).toBe(1); + expect(submitted[0].options.modelSelection).toEqual(A); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(A); + await page.goto('/e2e/fixtures/model-selection.html?session=web:created'); + await expect.poll(() => choice(page)).toEqual(A); +}); + +test('explicit Auto stays selected without a composer execution banner', async ({ page }) => { + const { submitted } = await setup(page); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await page.getByRole('button', { name: 'Auto', exact: true }).click(); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect.poll(() => submitted.length).toBe(1); + expect(submitted[0].options.modelSelection).toEqual({ mode: 'auto' }); + await expect(page.getByText('Running:', { exact: false })).toHaveCount(0); + await expect.poll(() => choice(page)).toEqual({ mode: 'auto' }); +}); + +test('unavailable configured models block sending and the picker still allows recovery', async ({ page }) => { + await setup(page, { unavailable: true }); + await expect.poll(() => choice(page)).toEqual(B); + await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('zeta/configured'); + await page.getByRole('button', { name: 'first', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeEnabled(); +}); + +test('new conversations and projects share the latest choice without model reloads', async ({ page }) => { + const { submitted, modelRequests } = await setup(page); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await page.getByRole('button', { name: 'first', exact: true }).click(); + await page.getByRole('button', { name: 'Project', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(A); + expect(modelRequests).toEqual([{ path: '/api/models', query: '?includeAuto=true', method: 'GET' }]); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect.poll(() => submitted.length).toBe(1); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await page.getByRole('button', { name: 'first', exact: true }).click(); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'General', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(B); +}); + +for (const ready of [false, true]) test(`settings/help commands work with model ready=${ready}, via click and keyboard`, async ({ page }) => { + const executed = []; + await page.route('**/api/**', async (route) => { + const isExecute = new URL(route.request().url()).pathname === '/api/commands/execute'; + const name = isExecute ? route.request().postDataJSON().commandName : ''; + if (isExecute) executed.push(name); + await route.fulfill({ json: isExecute + ? { type: 'builtin', action: name.slice(1), data: { content: 'Fixture help text' } } + : { pinned: [], custom: [], builtIn: ['/config', '/help'].map((name) => ({ name, namespace: 'builtin', type: 'builtin', metadata: { type: 'builtin' } })) } }); + }); + await page.goto(`/e2e/fixtures/model-selection.html?commands=1&ready=${ready}`); + await expect(page.getByTestId('commands-loaded')).toHaveText('2'); + const input = page.getByRole('textbox', { name: 'Message', exact: true }); + const send = page.getByRole('button', { name: 'Send', exact: true }); + // A space completes the command token and closes the suggestion menu. + await input.fill('/config '); + await expect(send).toBeEnabled(); + await send.click(); + await expect.poll(() => executed).toEqual(['/config']); + await expect(page.getByTestId('settings-opened')).toHaveText('1'); + await input.fill('/help '); + await input.press('Enter'); + await expect(page.getByTestId('command-messages')).toContainText('Fixture help text'); + expect(executed).toEqual(['/config', '/help']); + await expect(page.getByTestId('model-requests')).toHaveText('0'); + if (!ready) { + await input.fill('ordinary model request'); + await expect(send).toBeDisabled(); + await input.press('Enter'); + await input.fill('/unknown '); + await expect(send).toBeDisabled(); + await input.press('Enter'); + await expect(page.getByTestId('model-requests')).toHaveText('0'); + } +}); + + +test('manual model choices synchronize between browser tabs', async ({ page, context }) => { + await setup(page); + await expect.poll(() => choice(page)).toEqual(B); + const other = await context.newPage(); + await setup(other); + await expect.poll(() => choice(other)).toEqual(B); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await page.getByRole('button', { name: 'first', exact: true }).click(); + await expect.poll(() => choice(other)).toEqual(A); + await other.getByRole('button', { name: 'first', exact: true }).click(); + await other.getByRole('button', { name: 'Auto', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual({ mode: 'auto' }); + await page.reload(); + await expect.poll(() => choice(page)).toEqual({ mode: 'auto' }); +}); + +test('response model appears before time only with the response hover actions', async ({ page }) => { + const { submitted } = await setup(page); + await expect.poll(() => choice(page)).toEqual(B); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect.poll(() => submitted.length).toBe(1); + const response = page.getByTestId('response-fixture'); + const actions = response.getByTestId('assistant-message-actions'); + const label = actions.getByTestId('assistant-message-model'); + await expect(label).toHaveText('configured'); + await expect(actions).toHaveCSS('opacity', '0'); + await response.hover(); + await expect(actions).toHaveCSS('opacity', '1'); + expect(await actions.evaluate((el) => el.firstElementChild.dataset.testid)).toBe('assistant-message-model'); + await expect(label).not.toHaveAttribute('title'); + await expect(actions).not.toContainText('zeta/'); + await page.getByRole('button', { name: 'configured', exact: true }).click(); + await page.getByRole('button', { name: 'first', exact: true }).click(); + await expect.poll(() => choice(page)).toEqual(A); + await expect(label).toHaveText('configured'); + await expect(actions).toHaveCSS('opacity', '0'); +}); diff --git a/ui/server/pilotdeck-bridge.js b/ui/server/pilotdeck-bridge.js index a2f722739..83ff74b69 100644 --- a/ui/server/pilotdeck-bridge.js +++ b/ui/server/pilotdeck-bridge.js @@ -827,7 +827,6 @@ export function gatewayEventToFrames(event, sessionId, provider) { const base = { sessionId, provider, ...(event.runId ? { runId: event.runId } : {}) }; switch (event.type) { case 'input_accepted': - return []; case 'steer_unapplied': return []; case 'steer_applied': { @@ -863,6 +862,15 @@ export function gatewayEventToFrames(event, sessionId, provider) { text: 'started', }), ]; + case 'model_selection_changed': + return [{ + type: 'model-selection-changed', + sessionId: base.sessionId, + runId: event.runId, + modelProvider: event.provider, + model: event.model, + source: event.source, + }]; case 'model_request_started': return [ createNormalizedMessage({ @@ -879,6 +887,7 @@ export function gatewayEventToFrames(event, sessionId, provider) { ...base, kind: 'stream_delta', content: event.text, + ...(event.model ? { model: event.model } : {}), }), ]; case 'assistant_thinking_delta': @@ -1506,7 +1515,7 @@ export async function runChatViaGateway( const state = ensureSessionState(sessionKey, projectKey, channelKey); const staleRunId = state.active ? state.runId : undefined; - + const runId = resolveTurnRunId(options?.runId); if (isNewSession) { writer.send( @@ -1516,11 +1525,12 @@ export async function runChatViaGateway( kind: 'session_created', newSessionId: sessionKey, sessionKey, + projectKey, + runId, }), ); } - const runId = resolveTurnRunId(options?.runId); if (!staleRunId) { setLocalActiveRun(state, runId); setPendingGatewayRun(state, runId); @@ -1568,6 +1578,7 @@ export async function runChatViaGateway( runId, ...(Array.isArray(options?.uploadedAttachments) ? { uploadedAttachments: options.uploadedAttachments } : {}), ...(options?.modelOverride ? { modelOverride: options.modelOverride } : {}), + ...(options?.modelSelection ? { modelSelection: options.modelSelection } : {}), ...(basePermissionMode ? { basePermissionMode } : {}), ...(attachments.length > 0 ? { attachments } : {}), ...(workspaceCwd ? { workspaceCwd } : {}), diff --git a/ui/server/pilotdeck-bridge.test.js b/ui/server/pilotdeck-bridge.test.js index 9b062ccfb..8857fb559 100644 --- a/ui/server/pilotdeck-bridge.test.js +++ b/ui/server/pilotdeck-bridge.test.js @@ -902,3 +902,26 @@ describe('Always-On turn notification forwarding', () => { }); }); }); + +describe('dialog model preference frames', () => { + it('keeps Auto and explicit parameter choices in persisted queued messages', () => { + for (const selection of [{ mode: 'auto' }, { mode: 'model', provider: 'chosen', model: 'selected', reasoning: 0.8, temperature: 0.3, speed: 1 }]) { + const item = { id: 'queued-model', options: { modelSelection: selection } }; + expect(hydrateQueuedInputOptions(restoreQueuedInputFromStorage(serializeQueuedInputForStorage(item)).options).modelSelection).toEqual(selection); + } + }); + + it('reports execution models without broadcasting changes to the composer preference', () => { + const sessionId = 'web:s'; + const accepted = gatewayEventToFrames({ type: 'input_accepted', runId: 'run-1', modelSelection: { mode: 'auto' } }, sessionId, 'pilotdeck'); + expect(accepted).toEqual([]); + const running = gatewayEventToFrames({ type: 'model_selection_changed', runId: 'run-1', provider: 'chosen', model: 'routed', source: 'router' }, sessionId, 'pilotdeck'); + expect(running[0]).toMatchObject({ type: 'model-selection-changed', modelProvider: 'chosen', model: 'routed', runId: 'run-1' }); + }); +}); + + +it('carries the actual model on assistant text deltas', () => { + const frames = gatewayEventToFrames({ type: 'assistant_text_delta', text: 'Hello', model: 'qwen3.8-27b', runId: 'run-model' }, 'web:model', 'pilotdeck'); + expect(frames[0]).toMatchObject({ kind: 'stream_delta', model: 'qwen3.8-27b', content: 'Hello', runId: 'run-model' }); +}); diff --git a/ui/server/routes/messages.js b/ui/server/routes/messages.js index 6ac520388..36761c45a 100644 --- a/ui/server/routes/messages.js +++ b/ui/server/routes/messages.js @@ -187,6 +187,7 @@ function mapWebMessageToNormalized(message, sessionId) { kind: 'text', role: message.role === 'user' ? 'user' : 'assistant', content: message.text || '', + ...(message.role === 'assistant' && typeof message.model === 'string' ? { model: message.model } : {}), ...(Array.isArray(message.images) && message.images.length > 0 ? { images: message.images.map((image) => image?.data).filter(Boolean) } : {}), diff --git a/ui/server/routes/models.js b/ui/server/routes/models.js index 93940b23d..3f08bc8ff 100644 --- a/ui/server/routes/models.js +++ b/ui/server/routes/models.js @@ -8,7 +8,6 @@ router.get('/', async (req, res) => { const gateway = await getPilotDeckGateway(); if (!(await hasCapability(gateway, 'model_catalog_list'))) return unavailable(res, 'model_catalog_list'); return res.json(await gateway.modelCatalogList({ - projectKey: stringParam(req.query.projectKey), query: optionalString(req.query.query), provider: optionalString(req.query.provider), includeAuto: req.query.includeAuto === undefined ? undefined : String(req.query.includeAuto) !== 'false', diff --git a/ui/server/routes/models.test.js b/ui/server/routes/models.test.js index 106a4a277..0688d952e 100644 --- a/ui/server/routes/models.test.js +++ b/ui/server/routes/models.test.js @@ -9,6 +9,30 @@ afterEach(() => { }); describe('model routes', () => { + it('serves the global catalog without passing project scope to the gateway', async () => { + const modelCatalogList = vi.fn(async () => ({ items: [], defaultSelection: { mode: 'auto' } })); + vi.doMock('../pilotdeck-bridge.js', () => ({ + getPilotDeckGateway: vi.fn(async () => ({ + describeServer: vi.fn(async () => ({ capabilities: ['model_catalog_list'] })), modelCatalogList, + })), + })); + const { default: routes } = await import('./models.js'); + const app = express(); app.use('/api/models', routes); + const server = app.listen(0); + try { + const { port } = server.address(); + for (const suffix of ['', '&projectKey=/old-project']) { + const response = await nativeFetch(`http://127.0.0.1:${port}/api/models?includeAuto=true${suffix}`); + expect(response.status).toBe(200); + await response.json(); + } + expect(modelCatalogList.mock.calls.map(([input]) => input)).toEqual([ + { query: undefined, provider: undefined, includeAuto: true }, + { query: undefined, provider: undefined, includeAuto: true }, + ]); + } finally { await new Promise((resolve) => server.close(resolve)); } + }); + it('returns 422 for unsupported model parameters', async () => { const error = Object.assign(new Error('temperature is unsupported'), { code: 'UNSUPPORTED_MODEL_PARAMETER', diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx index 6bb2d9b35..925bac0e3 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.queue.test.tsx @@ -45,6 +45,7 @@ vi.mock('../chat/hooks/useChatProviderState', () => ({ modelSelection: { mode: 'auto' }, setModelSelection: vi.fn(async () => undefined), isModelCatalogLoading: false, + isModelSelectionReady: true, modelCatalogError: null, thinkingModelContext: null, permissionMode: 'default', diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.tsx index 2ed8171a6..99dcd777e 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.tsx @@ -140,6 +140,7 @@ function ChatInterfaceV2({ modelSelection, setModelSelection, isModelCatalogLoading, + isModelSelectionReady, modelCatalogError, thinkingModelContext, permissionMode, @@ -284,6 +285,7 @@ function ChatInterfaceV2({ openImagePicker, addAttachmentFiles, handleSubmit, + canSubmitWithoutModel, handleInputChange, insertAtCursor, handleKeyDown, @@ -302,6 +304,7 @@ function ChatInterfaceV2({ currentSessionId, model, modelSelection, + isModelSelectionReady, runMode, permissionMode: effectivePermissionMode, basePermissionMode: permissionMode, @@ -563,6 +566,7 @@ function ChatInterfaceV2({ throw new Error(t('edit.missingTarget', { defaultValue: 'The last message can no longer be edited.' })); } + if (!isModelSelectionReady || !modelSelection) throw new Error(modelCatalogError || "Model selection is still loading."); const attachments = Array.isArray(message.attachments) ? message.attachments : []; const references = attachments .map((attachment) => normalizeContentReference(attachment.contentReference ?? attachment)) @@ -610,6 +614,7 @@ function ChatInterfaceV2({ command, runId, userVisibleInput: editedText, + modelSelection: { ...modelSelection }, toolsSettings: getPilotDeckSettings(), runMode, permissionMode: effectivePermissionMode, @@ -630,6 +635,9 @@ function ChatInterfaceV2({ return result; }, [ currentSessionId, + isModelSelectionReady, + modelSelection, + modelCatalogError, effectivePermissionMode, model, permissionMode, @@ -772,6 +780,8 @@ function ChatInterfaceV2({ modelCatalog={modelCatalog} modelSelection={modelSelection} isModelCatalogLoading={isModelCatalogLoading} + isModelSelectionReady={isModelSelectionReady} + canSubmitWithoutModel={canSubmitWithoutModel} modelCatalogError={modelCatalogError} projectKey={selectedProject?.fullPath || selectedProject?.path || ''} onModelSelectionChange={(selection) => { diff --git a/ui/src/components/chat-v2/ComposerV2.tsx b/ui/src/components/chat-v2/ComposerV2.tsx index 8e8dd3795..b014ec814 100644 --- a/ui/src/components/chat-v2/ComposerV2.tsx +++ b/ui/src/components/chat-v2/ComposerV2.tsx @@ -157,6 +157,8 @@ export type ComposerV2Props = { modelCatalog: ChatModelCatalogItem[]; modelSelection: ChatModelSelection | null; isModelCatalogLoading?: boolean; + isModelSelectionReady?: boolean; + canSubmitWithoutModel?: boolean; modelCatalogError?: string | null; projectKey: string; onModelSelectionChange: (selection: ChatModelSelection) => void; @@ -510,6 +512,8 @@ export default function ComposerV2({ modelCatalog, modelSelection, isModelCatalogLoading = false, + isModelSelectionReady = true, + canSubmitWithoutModel = false, modelCatalogError, projectKey, onModelSelectionChange, @@ -646,7 +650,8 @@ export default function ComposerV2({ ); const hasUploadingImages = [...uploadingImages.values()].some((percent) => percent < 100); const attachmentLimitError = imageErrors.get(MAX_ATTACHMENTS_ERROR_KEY); - const disabled = !hasDraftContent || isSubmitPending || hasUploadingImages; + const modelBlocksSubmission = !isModelSelectionReady && !canSubmitWithoutModel; + const disabled = !hasDraftContent || isSubmitPending || hasUploadingImages || modelBlocksSubmission; const primaryAction = getComposerPrimaryAction({ isLoading, isInputQueuePaused, @@ -695,7 +700,7 @@ export default function ComposerV2({ modelSelection?.mode === "auto" ? (t("input.models.auto", { defaultValue: "Auto" }) as string) : selectedModel?.displayName || - selectedModel?.model || + selectedModel?.model || (modelSelection?.mode === "model" ? modelSelection.model : "") || (t("input.models.select", { defaultValue: "Select model", }) as string); @@ -756,6 +761,7 @@ export default function ComposerV2({ {!hasBlockingPermissionPanel ? (
{ + if (modelBlocksSubmission) { event.preventDefault(); return; } if (showWorkspacePicker && !workspaceSelectedProject) { event.preventDefault(); setWorkspaceMenuForceOpen(true); @@ -1532,14 +1538,13 @@ export default function ComposerV2({ />
+ {modelCatalogError ? ( +
{modelCatalogError}
+ ) : null} {isModelCatalogLoading ? (
- ) : modelCatalogError ? ( -
- {modelCatalogError} -
) : filteredModels.length === 0 ? (
{t("input.models.empty", { diff --git a/ui/src/components/chat-v2/MessageRowV2.tsx b/ui/src/components/chat-v2/MessageRowV2.tsx index 0422cd042..1e0e00f4e 100644 --- a/ui/src/components/chat-v2/MessageRowV2.tsx +++ b/ui/src/components/chat-v2/MessageRowV2.tsx @@ -597,6 +597,11 @@ function MessageRowV2({ data-testid="assistant-message-actions" className="pointer-events-none mt-1.5 flex h-6 items-center justify-start gap-1 opacity-0 transition-opacity duration-150 group-hover/assistant-msg:pointer-events-auto group-hover/assistant-msg:opacity-100 group-focus-within/assistant-msg:pointer-events-auto group-focus-within/assistant-msg:opacity-100" > + {message.model ? ( + + {message.model} + + ) : null} {assistantMessageTime ? (