diff --git a/electron/main/engines/claude/index.ts b/electron/main/engines/claude/index.ts index f59708f1..d88b2b06 100644 --- a/electron/main/engines/claude/index.ts +++ b/electron/main/engines/claude/index.ts @@ -14,6 +14,7 @@ import { unstable_v2_resumeSession, listSessions as sdkListSessions, getSessionMessages as sdkGetSessionMessages, + renameSession as sdkRenameSession, query as sdkQuery, } from "@anthropic-ai/claude-agent-sdk"; import type { @@ -577,6 +578,35 @@ export class ClaudeCodeAdapter extends EngineAdapter { return this.v2Sessions.has(sessionId) || this.sessionDirectories.has(sessionId); } + /** + * Rename a Claude session via SDK. The codemux session ID is opaque to the + * SDK; the real on-disk session is keyed by ccSessionId (captured during + * the system init message). + */ + async renameSession( + sessionId: string, + title: string, + directory?: string, + engineMeta?: Record, + ): Promise { + const ccSessionId = + this.sessionCcIds.get(sessionId) ?? + (typeof engineMeta?.ccSessionId === "string" + ? (engineMeta.ccSessionId as string) + : undefined); + if (!ccSessionId) { + claudeLog.debug( + `[Claude][${sessionId}] renameSession skipped — no ccSessionId yet`, + ); + return; + } + try { + await sdkRenameSession(ccSessionId, title, directory ? { dir: directory } : undefined); + } catch (err) { + claudeLog.warn(`[Claude][${sessionId}] renameSession via SDK failed:`, err); + } + } + async getSession(sessionId: string): Promise { return null; } @@ -3413,8 +3443,6 @@ export class ClaudeCodeAdapter extends EngineAdapter { // Emit final message this.emit("message.updated", { sessionId: buffer.sessionId, message: finalMessage }); - // Title updates are handled by EngineManager's applyTitleFallback() - // Clean up this.messageBuffers.delete(sessionId); for (const [key, part] of this.toolCallParts) { diff --git a/electron/main/engines/codex/index.ts b/electron/main/engines/codex/index.ts index 759a85fb..12ea6745 100644 --- a/electron/main/engines/codex/index.ts +++ b/electron/main/engines/codex/index.ts @@ -462,6 +462,25 @@ export class CodexAdapter extends EngineAdapter { this.clearSessionState(sessionId); } + /** Push a renamed title to the Codex app-server via thread/name/set RPC. */ + async renameSession(sessionId: string, title: string): Promise { + const threadId = this.sessionToThread.get(sessionId); + if (!threadId || !this.client?.running) return; + try { + await this.client.request("thread/name/set", { + threadId, + threadName: title, + }); + const thread = this.threads.get(threadId); + if (thread) { + thread.title = title || undefined; + thread.updatedAt = Date.now(); + } + } catch (error) { + codexLog.warn(`Failed to set Codex thread name for ${threadId}:`, error); + } + } + async sendMessage( sessionId: string, content: MessagePromptContent[], diff --git a/electron/main/engines/copilot/index.ts b/electron/main/engines/copilot/index.ts index f961bd03..715636a7 100644 --- a/electron/main/engines/copilot/index.ts +++ b/electron/main/engines/copilot/index.ts @@ -8,6 +8,7 @@ import { tmpdir } from "os"; import { timeId } from "../../utils/id-gen"; import { CopilotClient, CopilotSession } from "@github/copilot-sdk"; +import { isPromptFallbackTitle } from "../../../../src/lib/session-utils"; import type { SessionEvent, SessionConfig, @@ -200,6 +201,7 @@ export class CopilotSdkAdapter extends EngineAdapter { private sessionModes = new Map(); private sessionReasoningEfforts = new Map(); private sessionDirectories = new Map(); + private sessionTitles?: Map; private sessionTodos = new Map>(); // Fallback for permission prompts that still expose an "Always Allow" option but @@ -457,6 +459,17 @@ export class CopilotSdkAdapter extends EngineAdapter { this.allowedAlwaysKinds.delete(sessionId); } + async renameSession(sessionId: string, title: string, directory?: string): Promise { + const trimmed = title.trim(); + if (!trimmed) return; + try { + const session = await this.ensureActiveSession(sessionId, directory); + await session.rpc.name.set({ name: trimmed.slice(0, 100) }); + } catch (err) { + copilotLog.warn(`[Copilot][${sessionId}] renameSession failed:`, err); + } + } + async sendMessage( sessionId: string, content: MessagePromptContent[], @@ -1428,10 +1441,37 @@ export class CopilotSdkAdapter extends EngineAdapter { this.pendingUserMessages.delete(sessionId); } } + + void this.refreshSessionTitle(sessionId); + } + + private getFirstUserPrompt(sessionId: string): string | undefined { + const firstUser = this.messageHistory.get(sessionId)?.find((message) => message.role === "user"); + const textPart = firstUser?.parts.find((part): part is TextPart => part.type === "text"); + return textPart?.text; + } + + private async refreshSessionTitle(sessionId: string): Promise { + if (!this.client) return; + try { + const meta = await this.client.getSessionMetadata(sessionId); + const title = meta?.summary?.trim(); + if (!title || isPromptFallbackTitle(title, this.getFirstUserPrompt(sessionId))) return; + const cached = this.sessionTitles?.get(sessionId); + if (cached === title) return; + if (!this.sessionTitles) this.sessionTitles = new Map(); + this.sessionTitles.set(sessionId, title); + this.emit("session.updated", { + session: { id: sessionId, engineType: this.engineType, title }, + }); + } catch (err) { + copilotLog.debug(`[Copilot][${sessionId}] refreshSessionTitle failed:`, err); + } } private handleTitleChanged(sessionId: string, data: { title?: string }): void { - if (data.title) this.emit("session.updated", { session: { id: sessionId, engineType: this.engineType, title: data.title } }); + const title = data.title?.trim(); + if (title) this.emit("session.updated", { session: { id: sessionId, engineType: this.engineType, title } }); } private handleSessionError(sessionId: string, data: { message?: string }): void { diff --git a/electron/main/engines/engine-adapter.ts b/electron/main/engines/engine-adapter.ts index 03d69fc1..b713c33b 100644 --- a/electron/main/engines/engine-adapter.ts +++ b/electron/main/engines/engine-adapter.ts @@ -204,6 +204,23 @@ export abstract class EngineAdapter extends EventEmitter { /** Delete a session */ abstract deleteSession(sessionId: string): Promise; + /** + * Rename a session on the engine side. Default: no-op. + * Engines that persist titles (Claude SDK, Codex thread/name/set, OpenCode + * session.update) override this so codemux's local rename stays in sync + * with the engine's own session list. + * + * @param title Empty string clears any engine-side custom title. + */ + async renameSession( + _sessionId: string, + _title: string, + _directory?: string, + _engineMeta?: Record, + ): Promise { + /* default: not supported */ + } + // --- Messages --- /** diff --git a/electron/main/engines/opencode/converters.ts b/electron/main/engines/opencode/converters.ts index 80d57c55..20c93b17 100644 --- a/electron/main/engines/opencode/converters.ts +++ b/electron/main/engines/opencode/converters.ts @@ -11,6 +11,7 @@ import type { ToolState as SdkToolState, ProviderListResponse, } from "@opencode-ai/sdk/v2"; +import { isDefaultTitle } from "../../../../src/lib/session-utils"; import { normalizeToolName, inferToolKind } from "../../../../src/types/tool-mapping"; import type { EngineType, @@ -23,11 +24,13 @@ import type { } from "../../../../src/types/unified"; export function convertSession(engineType: EngineType, sdk: SdkSession): UnifiedSession { + const title = sdk.title && !isDefaultTitle(sdk.title) ? sdk.title : undefined; + return { id: sdk.id, engineType, directory: sdk.directory.replaceAll("\\", "/"), - title: sdk.title, + title, parentId: sdk.parentID, projectId: sdk.projectID, time: { @@ -192,6 +195,28 @@ function convertToolState(sdkState: SdkToolState): ToolState { } } +type OpenCodeModelCompat = { + capabilities?: { + temperature?: boolean; + reasoning?: boolean; + attachment?: boolean; + toolcall?: boolean; + tool_call?: boolean; + }; + temperature?: boolean; + reasoning?: boolean; + attachment?: boolean; + toolcall?: boolean; + tool_call?: boolean; + cost?: { + input: number; + output: number; + cache?: { read?: number; write?: number }; + cache_read?: number; + cache_write?: number; + }; +}; + export function convertProviders(engineType: EngineType, response: ProviderListResponse): UnifiedModelInfo[] { const models: UnifiedModelInfo[] = []; for (const provider of response.all) { @@ -199,7 +224,9 @@ export function convertProviders(engineType: EngineType, response: ProviderListR if (!response.connected.includes(provider.id)) continue; for (const model of Object.values(provider.models)) { - const capabilities = model.capabilities; + const compat = model as typeof model & OpenCodeModelCompat; + const capabilities = compat.capabilities ?? compat; + const cost = compat.cost; models.push({ modelId: `${provider.id}/${model.id}`, name: model.name, @@ -207,19 +234,19 @@ export function convertProviders(engineType: EngineType, response: ProviderListR engineType, providerId: provider.id, providerName: provider.name, - cost: model.cost ? { - input: model.cost.input, - output: model.cost.output, + cost: cost ? { + input: cost.input, + output: cost.output, cache: { - read: model.cost.cache.read ?? 0, - write: model.cost.cache.write ?? 0, + read: cost.cache?.read ?? cost.cache_read ?? 0, + write: cost.cache?.write ?? cost.cache_write ?? 0, }, } : undefined, capabilities: { - temperature: capabilities?.temperature ?? false, - reasoning: capabilities?.reasoning ?? false, - attachment: capabilities?.attachment ?? false, - toolcall: capabilities?.toolcall ?? false, + temperature: capabilities.temperature ?? false, + reasoning: capabilities.reasoning ?? false, + attachment: capabilities.attachment ?? false, + toolcall: capabilities.toolcall ?? capabilities.tool_call ?? false, }, meta: { status: model.status, diff --git a/electron/main/engines/opencode/index.ts b/electron/main/engines/opencode/index.ts index 8e182da9..48fa9af7 100644 --- a/electron/main/engines/opencode/index.ts +++ b/electron/main/engines/opencode/index.ts @@ -549,6 +549,25 @@ export class OpenCodeAdapter extends EngineAdapter { for (const entry of entries) { entry.resolve(finalMessage); } + + void this.refreshSessionTitle(sessionID); + } + + private async refreshSessionTitle(sessionId: string): Promise { + try { + const client = this.clientForSession(sessionId); + const result = await client.session.get({ sessionID: sessionId }); + if (result.error || !result.data) return; + const sdkSession = result.data; + const cached = this.sessions.get(sessionId); + const session = convertSession(this.engineType, sdkSession); + if (session.title && session.title !== cached?.title) { + this.sessions.set(session.id, session); + this.emit("session.updated", { session }); + } + } catch (err) { + openCodeLog.debug(`[OpenCode] refreshSessionTitle failed for ${sessionId}:`, err); + } } private handleSessionUpdated(sdkSession: SdkSession): void { @@ -912,6 +931,23 @@ export class OpenCodeAdapter extends EngineAdapter { this.userMessageIds.delete(sessionId); } + /** Push a renamed title to OpenCode via session.update. */ + async renameSession(sessionId: string, title: string, directory?: string): Promise { + const session = this.sessions.get(sessionId); + const dir = directory ?? session?.directory; + const client = dir ? this.createClient(dir) : this.ensureClient(); + try { + await client.session.update({ + sessionID: sessionId, + ...(dir ? { directory: dir } : {}), + title, + }); + } catch (err) { + // Don't surface — local rename already succeeded + openCodeLog.warn(`session.update title failed for ${sessionId}:`, err); + } + } + // --- Messages --- async sendMessage( diff --git a/electron/main/gateway/engine-manager.ts b/electron/main/gateway/engine-manager.ts index d2c0e195..d8990404 100644 --- a/electron/main/gateway/engine-manager.ts +++ b/electron/main/gateway/engine-manager.ts @@ -8,6 +8,7 @@ import { conversationStore } from "../services/conversation-store"; import { getDefaultWorkspacePath } from "../services/default-workspace"; import { engineManagerLog, getDefaultEngineFromSettings } from "../services/logger"; import { timeId } from "../utils/id-gen"; +import { isDefaultTitle, isPromptFallbackTitle } from "../../../src/lib/session-utils"; import type { EngineType, EngineInfo, @@ -43,6 +44,28 @@ function normalizeDir(dir: string): string { return dir ? dir.replaceAll("\\", "/") : ""; } +/** Compute the display title from a ConversationMeta — render-time priority. */ +function getUsableEngineTitle(conv: ConversationMeta): string | undefined { + const title = conv.engineTitle?.trim(); + if (!title) return undefined; + if (isDefaultTitle(title)) return undefined; + if (isPromptFallbackTitle(title, conv.firstPrompt)) return undefined; + return title; +} + +function getUsableEngineTitleCandidate(conv: ConversationMeta, title: string): string | undefined { + const trimmed = title.trim(); + if (!trimmed) return undefined; + if (conv.customTitle?.trim() === trimmed) return undefined; + if (isDefaultTitle(trimmed)) return undefined; + if (isPromptFallbackTitle(trimmed, conv.firstPrompt)) return undefined; + return trimmed; +} + +function computeDisplayTitle(conv: ConversationMeta): string { + return conv.customTitle || getUsableEngineTitle(conv) || conv.firstPrompt || "New Chat"; +} + /** Convert ConversationMeta → UnifiedSession for wire compatibility */ function convToSession(conv: ConversationMeta): UnifiedSession { // For worktree sessions, resolve projectId from the parent repo directory @@ -54,7 +77,7 @@ function convToSession(conv: ConversationMeta): UnifiedSession { id: conv.id, engineType: conv.engineType, directory: normalizeDir(conv.directory), - title: conv.title, + title: computeDisplayTitle(conv), mode: conv.mode, modelId: conv.modelId, reasoningEffort: conv.reasoningEffort, @@ -327,12 +350,10 @@ export class EngineManager extends EventEmitter { const convId = engineSessionId ? this.resolveConversationId(engineSessionId) : null; if (convId) { - // Persist title changes - if (data.session.title) { - const conv = conversationStore.get(convId); - if (conv && this.isDefaultTitle(conv.title)) { - conversationStore.rename(convId, data.session.title); - } + const current = conversationStore.get(convId); + if (data.session.title && current) { + const title = getUsableEngineTitleCandidate(current, data.session.title); + if (title) conversationStore.setEngineTitle(convId, title); } // Persist engineMeta (e.g. ccSessionId for Claude Code session resumption) if (data.session.engineMeta) { @@ -359,7 +380,10 @@ export class EngineManager extends EventEmitter { : {}), }); } - this.emit("session.updated", this.rewriteSessionId(data as any, engineSessionId!, convId) as any); + const conv = conversationStore.get(convId); + if (conv) { + this.emit("session.updated", { session: convToSession(conv) }); + } } else { this.emit("session.updated", data); } @@ -637,8 +661,16 @@ export class EngineManager extends EventEmitter { parts, }; + const hadFirstPrompt = !!conversationStore.get(conversationId)?.firstPrompt; await conversationStore.appendMessage(conversationId, convMessage); + if (!hadFirstPrompt) { + const updated = conversationStore.get(conversationId); + if (updated?.firstPrompt) { + this.emit("session.updated", { session: convToSession(updated) }); + } + } + if (trackQueuedTiming) { // Queue this ID only for sends known to be queued. Foreground sends do // not later emit timing patches and must not shift the FIFO. @@ -891,7 +923,25 @@ export class EngineManager extends EventEmitter { } async renameSession(sessionId: string, title: string): Promise<{ success: boolean }> { - conversationStore.rename(sessionId, title); + const conv = conversationStore.get(sessionId); + if (!conv) return { success: false }; + + const trimmed = title.trim(); + // Empty string clears the customTitle (revert to engineTitle/firstPrompt fallback) + conversationStore.setCustomTitle(sessionId, trimmed || undefined); + + // Best-effort writeback to the engine so its own session list stays in sync. + // Adapters that don't support rename are no-ops by default. + if (conv.engineSessionId) { + try { + const adapter = this.adapters.get(conv.engineType); + await adapter?.renameSession(conv.engineSessionId, trimmed, conv.directory, conv.engineMeta); + } catch (err) { + engineManagerLog.warn(`Engine rename writeback failed for ${sessionId}:`, err); + } + } + + this.emit("session.updated", { session: convToSession(conversationStore.get(sessionId)!) }); return { success: true }; } @@ -985,14 +1035,6 @@ export class EngineManager extends EventEmitter { // Cache the engineSessionId → conversationId mapping this.engineToConvMap.set(engineSessionId, sessionId); - // Title fallback: derive title from first user message if still default. - // Run BEFORE persistUserMessage — appendMessage has its own auto-title - // logic that silently sets conv.title without emitting session.updated, - // which would cause applyTitleFallback to skip (title no longer default). - // Run BEFORE adapter.sendMessage so the sidebar updates immediately, - // not after the (potentially long-running) engine processing completes. - this.applyTitleFallback(sessionId, content); - // Persist user message before sending to engine // (Some adapters like OpenCode don't emit user message events) await this.persistUserMessage(sessionId, content, trackQueuedTiming); @@ -1052,38 +1094,6 @@ export class EngineManager extends EventEmitter { } } - /** - * If a conversation still has no meaningful title (empty, or matches default - * pattern), set it to the first user message text (truncated to 100 chars). - */ - private applyTitleFallback( - sessionId: string, - content: MessagePromptContent[], - ): void { - const conv = conversationStore.get(sessionId); - if (!conv) return; - - // Already has a real title — nothing to do - if (conv.title && !this.isDefaultTitle(conv.title)) return; - - // Extract first text from the user prompt - const firstText = content.find((c) => c.type === "text" && c.text)?.text; - if (!firstText) return; - - const maxLen = 100; - const title = - firstText.length > maxLen - ? firstText.slice(0, maxLen).trimEnd() + "…" - : firstText; - conversationStore.rename(sessionId, title); - this.emit("session.updated", { session: convToSession(conversationStore.get(sessionId)!) }); - } - - private isDefaultTitle(title: string): boolean { - // Match engine-generated default titles and ConversationStore's "Chat M-D HH:MM" format - return /^(New session|New Chat|Child session|Chat \d)/.test(title); - } - async listMessages(sessionId: string): Promise { const messages = await conversationStore.listMessages(sessionId); const stepsFile = await conversationStore.getAllSteps(sessionId); @@ -1157,7 +1167,6 @@ export class EngineManager extends EventEmitter { // Persist user command message const commandText = `/${commandName}${args ? ` ${args}` : ""}`; - this.applyTitleFallback(sessionId, [{ type: "text", text: commandText }]); await this.persistUserMessage(sessionId, [{ type: "text", text: commandText }], trackQueuedTiming); const resolvedOptions = this.resolveSessionOptions(conv, options); diff --git a/electron/main/services/conversation-store.ts b/electron/main/services/conversation-store.ts index cdcd3739..909e49a7 100644 --- a/electron/main/services/conversation-store.ts +++ b/electron/main/services/conversation-store.ts @@ -134,7 +134,6 @@ class ConversationStore { create(params: { engineType: EngineType; directory: string; - title?: string; worktreeId?: string; parentDirectory?: string; }): ConversationMeta { @@ -145,7 +144,6 @@ class ConversationStore { id: timeId("conv"), engineType: params.engineType, directory: params.directory, - title: params.title || this.generateTitle(), createdAt: now, updatedAt: now, messageCount: 0, @@ -200,8 +198,15 @@ class ConversationStore { conversationStoreLog.info(`Deleted conversation ${id}`); } - rename(id: string, title: string): void { - this.update(id, { title }); + setCustomTitle(id: string, title?: string): void { + this.update(id, { customTitle: title }); + } + + setEngineTitle(id: string, title?: string): void { + if (!title) return; + const conv = this.index.get(id); + if (!conv || conv.engineTitle === title) return; + this.update(id, { engineTitle: title }); } // ------------------------------------------------------------------------- @@ -258,15 +263,19 @@ class ConversationStore { } } - // Auto-title from first user message - if (messages.length === 1 && msg.role === "user") { + // Capture first user prompt for displayTitle fallback + if ( + messages.length === 1 && + msg.role === "user" && + !conv.firstPrompt + ) { const textPart = msg.parts.find( (p): p is TextPart => p.type === "text", ); if (textPart) { - conv.title = - textPart.text.slice(0, 50) + - (textPart.text.length > 50 ? "..." : ""); + conv.firstPrompt = + textPart.text.slice(0, 100) + + (textPart.text.length > 100 ? "…" : ""); } } @@ -534,7 +543,7 @@ class ConversationStore { id: convId, engineType: params.engineType, directory: params.directory, - title: params.title, + engineTitle: params.title, createdAt: params.createdAt, updatedAt: params.updatedAt, messageCount: params.messages.length, @@ -561,6 +570,17 @@ class ConversationStore { } } + // Capture first user prompt for displayTitle fallback + for (const m of rewrittenMessages) { + if (m.role !== "user") continue; + const tp = m.parts.find((p): p is TextPart => p.type === "text"); + if (tp) { + conv.firstPrompt = + tp.text.slice(0, 100) + (tp.text.length > 100 ? "…" : ""); + break; + } + } + // Write messages file if (rewrittenMessages.length > 0) { await this.atomicWrite(this.getMessageFilePath(convId), rewrittenMessages); @@ -816,15 +836,6 @@ class ConversationStore { } } } - - private generateTitle(): string { - const now = new Date(); - const month = now.getMonth() + 1; - const day = now.getDate(); - const hour = now.getHours(); - const minute = now.getMinutes(); - return `Chat ${month}-${day} ${hour}:${minute.toString().padStart(2, "0")}`; - } } export const conversationStore = new ConversationStore(); diff --git a/src/lib/session-utils.ts b/src/lib/session-utils.ts index 5071e634..8f52c56d 100644 --- a/src/lib/session-utils.ts +++ b/src/lib/session-utils.ts @@ -1,5 +1,28 @@ const DEFAULT_TITLE_PATTERN = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +const TRAILING_ELLIPSIS_PATTERN = /(?:\s*(?:…|\.\.\.))+$/; + +function stripTrailingEllipsis(value: string): string { + return value.trim().replace(TRAILING_ELLIPSIS_PATTERN, "").trim(); +} export function isDefaultTitle(title: string): boolean { return DEFAULT_TITLE_PATTERN.test(title); } + +export function isPromptFallbackTitle(title: string, firstPrompt?: string): boolean { + const normalizedTitle = title.trim(); + const normalizedPrompt = firstPrompt?.trim(); + if (!normalizedTitle || !normalizedPrompt) return false; + if (normalizedTitle === normalizedPrompt) return true; + + const titleBase = stripTrailingEllipsis(normalizedTitle); + const promptBase = stripTrailingEllipsis(normalizedPrompt); + if (!titleBase || !promptBase) return false; + + const titleHadEllipsis = titleBase !== normalizedTitle; + const promptHadEllipsis = promptBase !== normalizedPrompt; + if (!titleHadEllipsis && !promptHadEllipsis) return false; + if (titleBase === promptBase) return true; + if (titleHadEllipsis && promptBase.startsWith(titleBase)) return true; + return promptHadEllipsis && titleBase.startsWith(promptBase); +} diff --git a/src/types/unified.ts b/src/types/unified.ts index 4b84469a..897f4872 100644 --- a/src/types/unified.ts +++ b/src/types/unified.ts @@ -164,7 +164,12 @@ export interface ConversationMeta extends UnifiedSessionConfig { id: string; engineType: EngineType; directory: string; - title: string; + /** User-set title (via rename). Highest priority in displayTitle resolution. */ + customTitle?: string; + /** Engine-summarized title observed from adapter or engine updates. */ + engineTitle?: string; + /** Truncated first user prompt — used as last-resort fallback for displayTitle. */ + firstPrompt?: string; createdAt: number; updatedAt: number; messageCount: number; diff --git a/tests/unit/electron/engines/copilot/index.test.ts b/tests/unit/electron/engines/copilot/index.test.ts index e502ec2f..5e0c3daa 100644 --- a/tests/unit/electron/engines/copilot/index.test.ts +++ b/tests/unit/electron/engines/copilot/index.test.ts @@ -32,6 +32,7 @@ const { rpc: { model: { switchTo: vi.fn(async function() {}) }, mode: { set: vi.fn(async function() {}) }, + name: { set: vi.fn(async function() {}) }, skills: { list: vi.fn(async function() { return { skills: [] }; }) }, commands: { handlePendingCommand: vi.fn(async function() {}) }, }, @@ -54,6 +55,7 @@ const { }), deleteSession: vi.fn(async function() {}), listModels: vi.fn(async function() { return []; }), + getSessionMetadata: vi.fn(async function() { return undefined; }), getState: vi.fn(function() { return "connected"; }), }; @@ -160,6 +162,7 @@ function makeMockSession(sessionId = "s1") { rpc: { model: { switchTo: vi.fn(async () => {}) }, mode: { set: vi.fn(async () => {}) }, + name: { set: vi.fn(async () => {}) }, skills: { list: vi.fn(async () => ({ skills: [] })) }, commands: { handlePendingCommand: vi.fn(async () => {}) }, }, @@ -451,6 +454,30 @@ describe("CopilotSdkAdapter", () => { }); }); + describe("renameSession()", () => { + beforeEach(async () => { + await adapter.start(); + }); + + it("writes the trimmed title through Copilot's name RPC", async () => { + const sess = makeMockSession("s1"); + (adapter as any).activeSessions.set("s1", sess); + + await adapter.renameSession("s1", " Manual Title "); + + expect(sess.rpc.name.set).toHaveBeenCalledWith({ name: "Manual Title" }); + }); + + it("does not write empty titles", async () => { + const sess = makeMockSession("s1"); + (adapter as any).activeSessions.set("s1", sess); + + await adapter.renameSession("s1", " "); + + expect(sess.rpc.name.set).not.toHaveBeenCalled(); + }); + }); + describe("ensureActiveSession()", () => { beforeEach(async () => { await adapter.start(); @@ -1297,6 +1324,51 @@ describe("CopilotSdkAdapter", () => { }); }); + describe("refreshSessionTitle()", () => { + beforeEach(async () => { + await adapter.start(); + }); + + it("ignores metadata summaries that are just the first user prompt", async () => { + const updates: any[] = []; + adapter.on("session.updated", (e) => updates.push(e)); + (adapter as any).messageHistory.set("s1", [{ + id: "user-1", + sessionId: "s1", + role: "user", + time: { created: 1 }, + parts: [{ id: "part-1", messageId: "user-1", sessionId: "s1", type: "text", text: "Summarize mock project metadata: inspect the sample manifest and report the sample package name." }], + }]); + mockClientInstance.getSessionMetadata.mockResolvedValueOnce({ + summary: "Summarize mock project metadata: inspect the sample manifest...", + }); + + await (adapter as any).refreshSessionTitle("s1"); + + expect(updates).toHaveLength(0); + }); + + it("emits session.updated for meaningful metadata summaries", async () => { + const updates: any[] = []; + adapter.on("session.updated", (e) => updates.push(e)); + (adapter as any).messageHistory.set("s1", [{ + id: "user-1", + sessionId: "s1", + role: "user", + time: { created: 1 }, + parts: [{ id: "part-1", messageId: "user-1", sessionId: "s1", type: "text", text: "Please review the sample upload integration changes" }], + }]); + mockClientInstance.getSessionMetadata.mockResolvedValueOnce({ + summary: " Review Sample Upload Integration ", + }); + + await (adapter as any).refreshSessionTitle("s1"); + + expect(updates).toEqual([{ session: { id: "s1", engineType: "copilot", title: "Review Sample Upload Integration" } }]); + expect((adapter as any).sessionTitles.get("s1")).toBe("Review Sample Upload Integration"); + }); + }); + // ============================================================================ // E. Permission Handling // ============================================================================ diff --git a/tests/unit/electron/engines/opencode/converters.test.ts b/tests/unit/electron/engines/opencode/converters.test.ts index 47627843..745614aa 100644 --- a/tests/unit/electron/engines/opencode/converters.test.ts +++ b/tests/unit/electron/engines/opencode/converters.test.ts @@ -60,6 +60,17 @@ describe('OpenCode Converters', () => { const unifiedUnix = convertSession(ENGINE_TYPE, unixSession as any); expect(unifiedUnix.directory).toBe('/home/user/project'); }); + + it('does not expose default placeholder titles', () => { + const unified = convertSession(ENGINE_TYPE, { + id: 's1', + directory: '/project', + title: 'New session - 2026-04-27T12:38:30.603Z', + time: { created: 1, updated: 2 }, + } as any); + + expect(unified.title).toBeUndefined(); + }); }); describe('convertMessage', () => { @@ -251,16 +262,11 @@ describe('OpenCode Converters', () => { id: 'm1', name: 'Model 1', family: 'GPT', - cost: { input: 1, output: 2, cache: { read: 0.5, write: 0.8 } }, - capabilities: { - temperature: true, - reasoning: false, - attachment: true, - toolcall: true, - input: { text: true, audio: false, image: true, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, + cost: { input: 1, output: 2, cache_read: 0.5, cache_write: 0.8 }, + temperature: true, + reasoning: false, + attachment: true, + tool_call: true, status: 'online', release_date: '2023-01-01', limit: { tpd: 1000 } @@ -304,6 +310,28 @@ describe('OpenCode Converters', () => { } }); + const responseLegacySchema = { + all: [{ + id: 'p1', + name: 'P1', + models: { + m1: { + id: 'm1', + name: 'M1', + cost: { input: 3, output: 4, cache: { read: 1, write: 2 } }, + capabilities: { temperature: false, reasoning: true, attachment: false, toolcall: true }, + release_date: '2023-01-01', + limit: {}, + } + } + }], + connected: ['p1'] + }; + expect(convertProviders(ENGINE_TYPE, responseLegacySchema as any)[0]).toMatchObject({ + cost: { input: 3, output: 4, cache: { read: 1, write: 2 } }, + capabilities: { temperature: false, reasoning: true, attachment: false, toolcall: true }, + }); + const responseNoCost = { all: [{ id: 'p1', name: 'P1', models: { 'm1': { id: 'm1', name: 'M1' } } }], connected: ['p1'] diff --git a/tests/unit/electron/gateway/engine-manager.test.ts b/tests/unit/electron/gateway/engine-manager.test.ts index 19171195..ddbe4a72 100644 --- a/tests/unit/electron/gateway/engine-manager.test.ts +++ b/tests/unit/electron/gateway/engine-manager.test.ts @@ -13,7 +13,8 @@ vi.mock("../../../../electron/main/services/conversation-store", () => { list: vi.fn(() => []), create: vi.fn(), delete: vi.fn(), - rename: vi.fn(), + setCustomTitle: vi.fn(), + setEngineTitle: vi.fn(), update: vi.fn(), listMessages: vi.fn(() => Promise.resolve([])), appendMessage: vi.fn(), @@ -120,7 +121,6 @@ function makeMockConv(overrides: Record = {}) { id: "conv1", engineType: "opencode" as EngineType, directory: "/dir", - title: "New session", engineSessionId: null, createdAt: 1000, updatedAt: 2000, @@ -398,6 +398,131 @@ describe("EngineManager", () => { expect(emittedSessions[0].session.id).toBe("conv6"); }); + it("does not persist default engine placeholder titles", () => { + const conv = makeMockConv({ + id: "conv-title", + firstPrompt: "Inspect the mock workspace read-only…", + }); + (conversationStore.findByEngineSession as any).mockReturnValue(conv); + (conversationStore.get as any).mockReturnValue(conv); + + const emittedSessions: any[] = []; + engineManager.on("session.updated" as any, (data: any) => emittedSessions.push(data)); + adapterA.emit("session.updated", { + session: { + id: "engine-title", + engineType: adapterA.engineType, + title: "New session - 2026-04-27T12:38:30.603Z", + }, + }); + + expect(conversationStore.setEngineTitle).not.toHaveBeenCalled(); + expect(emittedSessions[0].session.title).toBe("Inspect the mock workspace read-only…"); + }); + + it("does not persist prompt-derived engine summaries", () => { + const conv = makeMockConv({ + id: "conv-title", + firstPrompt: "Summarize mock project metadata: inspect the sample manifest…", + }); + (conversationStore.findByEngineSession as any).mockReturnValue(conv); + (conversationStore.get as any).mockReturnValue(conv); + + adapterA.emit("session.updated", { + session: { + id: "engine-title", + engineType: adapterA.engineType, + title: "Summarize mock project metadata: inspect the sample manifest...", + }, + }); + + expect(conversationStore.setEngineTitle).not.toHaveBeenCalled(); + }); + + it("persists meaningful engine titles", () => { + const conv = makeMockConv({ + id: "conv-title", + firstPrompt: "Please review the sample upload integration changes…", + }); + (conversationStore.findByEngineSession as any).mockReturnValue(conv); + (conversationStore.get as any).mockReturnValue(conv); + + adapterA.emit("session.updated", { + session: { + id: "engine-title", + engineType: adapterA.engineType, + title: " Review Sample Upload Integration ", + }, + }); + + expect(conversationStore.setEngineTitle).toHaveBeenCalledWith( + "conv-title", + "Review Sample Upload Integration", + ); + }); + + it("displays engineTitle over firstPrompt", () => { + (conversationStore.list as any).mockReturnValue([ + makeMockConv({ + id: "conv-title", + firstPrompt: "Please review the sample upload integration changes…", + engineTitle: "Review Sample Upload Integration", + }), + ]); + + expect(engineManager.listAllSessions()[0].title).toBe("Review Sample Upload Integration"); + }); + + it("displays customTitle over engineTitle", () => { + (conversationStore.list as any).mockReturnValue([ + makeMockConv({ + id: "conv-title", + firstPrompt: "Please review the sample upload integration changes…", + engineTitle: "Review Sample Upload Integration", + customTitle: "My Manual Title", + }), + ]); + + expect(engineManager.listAllSessions()[0].title).toBe("My Manual Title"); + }); + + it("ignores stale stored title fields", () => { + (conversationStore.list as any).mockReturnValue([ + { + ...makeMockConv({ id: "conv-title" }), + title: "Old Chat", + }, + ]); + + expect(engineManager.listAllSessions()[0].title).toBe("New Chat"); + }); + + it("emits the resolved engineTitle after a meaningful engine update", () => { + const conv = makeMockConv({ + id: "conv-title", + firstPrompt: "Please review the sample upload integration changes…", + }); + (conversationStore.findByEngineSession as any).mockReturnValue(conv); + (conversationStore.get as any) + .mockReturnValueOnce(conv) + .mockReturnValueOnce({ + ...conv, + engineTitle: "Review Sample Upload Integration", + }); + const emittedSessions: any[] = []; + engineManager.on("session.updated" as any, (data: any) => emittedSessions.push(data)); + + adapterA.emit("session.updated", { + session: { + id: "engine-title", + engineType: adapterA.engineType, + title: "Review Sample Upload Integration", + }, + }); + + expect(emittedSessions[0].session.title).toBe("Review Sample Upload Integration"); + }); + it("retrieves and deletes sessions from store and engine", async () => { (conversationStore.get as any).mockReturnValue({ id: "conv1", engineType: adapterA.engineType }); const session = await engineManager.getSession("conv1"); @@ -489,8 +614,10 @@ describe("EngineManager", () => { expect(adapterA.deleteSession).toHaveBeenCalledWith("es1"); expect(conversationStore.delete).toHaveBeenCalledWith("c1"); + // renameSession requires the conv to exist; mock get() to return one + (conversationStore.get as any).mockReturnValue(makeMockConv({ id: "conv1" })); await engineManager.renameSession("conv1", "New Title"); - expect(conversationStore.rename).toHaveBeenCalledWith("conv1", "New Title"); + expect(conversationStore.setCustomTitle).toHaveBeenCalledWith("conv1", "New Title"); }); it("deleteProject skips engine cleanup when no engineSessionId", async () => { @@ -727,87 +854,6 @@ describe("EngineManager", () => { }); }); - // =========================================================================== - // applyTitleFallback and isDefaultTitle - // =========================================================================== - - describe("applyTitleFallback and isDefaultTitle", () => { - beforeEach(() => { - engineManager.registerAdapter(adapterA); - }); - - const titleCases = [ - ["New session", true], - ["New Chat", true], - ["Child session", true], - ["Chat 5", true], - ["My real title", false], - ["", false], // empty is falsy, isDefaultTitle checks the string - ] as const; - - it.each(titleCases)("isDefaultTitle('%s') should be %s", (title, expected) => { - // Test indirectly: titles that are "default" should get replaced by sendMessage - const conv = makeMockConv({ - title: title || undefined, - engineSessionId: "eng-s1", - }); - (conversationStore.get as any).mockReturnValue(conv); - adapterA.hasSession.mockReturnValue(true); - - const emittedUpdates: any[] = []; - engineManager.on("session.updated" as any, (data: any) => emittedUpdates.push(data)); - - engineManager["applyTitleFallback"]("conv1", [{ type: "text", text: "Hello world" }]); - - if (!title || expected) { - // default or empty title → should be replaced - expect(conversationStore.rename).toHaveBeenCalledWith("conv1", "Hello world"); - } else { - // real title → should NOT be replaced - expect(conversationStore.rename).not.toHaveBeenCalled(); - } - - vi.clearAllMocks(); - }); - - it("applyTitleFallback returns early when conv not found", () => { - (conversationStore.get as any).mockReturnValue(null); - engineManager["applyTitleFallback"]("missing-conv", [{ type: "text", text: "hi" }]); - expect(conversationStore.rename).not.toHaveBeenCalled(); - }); - - it("applyTitleFallback returns early when no text in content", () => { - (conversationStore.get as any).mockReturnValue(makeMockConv({ title: "New session" })); - engineManager["applyTitleFallback"]("conv1", [{ type: "image", data: "base64..." } as any]); - expect(conversationStore.rename).not.toHaveBeenCalled(); - }); - - it("applyTitleFallback truncates long text to 100 chars with ellipsis", () => { - const longText = "A".repeat(150); - (conversationStore.get as any).mockReturnValue(makeMockConv({ title: "New session" })); - (conversationStore.get as any).mockReturnValueOnce(makeMockConv({ title: "New session" })) - .mockReturnValueOnce(makeMockConv({ title: "A".repeat(100) + "…" })); - - engineManager["applyTitleFallback"]("conv1", [{ type: "text", text: longText }]); - - const callArg = (conversationStore.rename as any).mock.calls[0][1]; - expect(callArg).toHaveLength(101); // 100 chars + "…" - expect(callArg.endsWith("…")).toBe(true); - }); - - it("applyTitleFallback emits session.updated with truncated title", () => { - const longText = "B".repeat(200); - (conversationStore.get as any).mockReturnValue(makeMockConv({ title: "" })); - - const emitted: any[] = []; - engineManager.on("session.updated" as any, (d: any) => emitted.push(d)); - - engineManager["applyTitleFallback"]("conv1", [{ type: "text", text: longText }]); - - expect(emitted).toHaveLength(1); - }); - }); - // =========================================================================== // Models and Modes // =========================================================================== @@ -1214,7 +1260,7 @@ describe("EngineManager", () => { beforeEach(() => { engineManager.registerAdapter(adapterA); (conversationStore.findByEngineSession as any).mockReturnValue({ id: "conv1" }); - (conversationStore.get as any).mockReturnValue(makeMockConv({ title: "New session" })); + (conversationStore.get as any).mockReturnValue(makeMockConv()); }); it("forwards message part updates for text and reasoning parts", () => { @@ -1422,7 +1468,8 @@ describe("EngineManager", () => { adapterA.emit("session.updated", { session: { id: "engine-s1", title: "Real Title", engineType: adapterA.engineType } as any, }); - expect(conversationStore.rename).toHaveBeenCalledWith("conv1", "Real Title"); + // session.updated now writes engineTitle directly without interception + expect(conversationStore.setEngineTitle).toHaveBeenCalledWith("conv1", "Real Title"); adapterA.emit("permission.asked", { permission: { @@ -1436,19 +1483,27 @@ describe("EngineManager", () => { }); }); - it("session.updated does NOT rename when title is not default", () => { - (conversationStore.get as any).mockReturnValue(makeMockConv({ title: "My Custom Title" })); + it("session.updated writes engineTitle even when conv has a customTitle", () => { + // Render-time displayTitle resolution gives customTitle precedence over engineTitle, + // so the store layer no longer guards against overwriting "real" titles. + (conversationStore.get as any).mockReturnValue(makeMockConv({ customTitle: "My Custom Title" })); adapterA.emit("session.updated", { session: { id: "engine-s1", title: "Engine Title", engineType: adapterA.engineType } as any, }); - // conv.title is "My Custom Title" (not default), so rename should be called - // Wait - isDefaultTitle("My Custom Title") = false, so rename SHOULD NOT be called - expect(conversationStore.rename).not.toHaveBeenCalled(); + expect(conversationStore.setEngineTitle).toHaveBeenCalledWith("conv1", "Engine Title"); + }); + + it("session.updated does not treat a customTitle echo as engineTitle", () => { + (conversationStore.get as any).mockReturnValue(makeMockConv({ customTitle: "My Custom Title" })); + adapterA.emit("session.updated", { + session: { id: "engine-s1", title: "My Custom Title", engineType: adapterA.engineType } as any, + }); + expect(conversationStore.setEngineTitle).not.toHaveBeenCalled(); }); it("session.updated persists engineMeta when provided", () => { (conversationStore.get as any).mockReturnValue( - makeMockConv({ title: "New session", engineSessionId: "eng-s" }), + makeMockConv({ engineSessionId: "eng-s" }), ); adapterA.emit("session.updated", { session: { diff --git a/tests/unit/electron/services/conversation-store.test.ts b/tests/unit/electron/services/conversation-store.test.ts index 94dbbc3f..f2fbbba2 100644 --- a/tests/unit/electron/services/conversation-store.test.ts +++ b/tests/unit/electron/services/conversation-store.test.ts @@ -84,29 +84,27 @@ describe("ConversationStore", () => { describe("Conversation CRUD", () => { it("creates conversations with metadata and handles retrieval", () => { - // create() returns a ConversationMeta with correct fields + // create() returns a ConversationMeta with correct fields. + // Titles are derived at render time from customTitle/engineTitle/firstPrompt. const conv = conversationStore.create({ engineType: "claude", directory: "/projects/foo", - title: "My Project" }); expect(conv.id).toMatch(/^conv_/); expect(conv.engineType).toBe("claude"); expect(conv.directory).toBe("/projects/foo"); - expect(conv.title).toBe("My Project"); + expect(conv).not.toHaveProperty("title"); + expect(conv.customTitle).toBeUndefined(); + expect(conv.engineTitle).toBeUndefined(); expect(conv.createdAt).toBeLessThanOrEqual(Date.now()); expect(conv.updatedAt).toBe(conv.createdAt); expect(conv.messageCount).toBe(0); - // create() generates a default title if not provided - const conv2 = conversationStore.create({ - engineType: "opencode", - directory: "/test" - }); - expect(conv2.title).toMatch(/^Chat \d+-\d+ \d+:\d+/); + // setCustomTitle() overrides displayTitle on the wire layer + conversationStore.setCustomTitle(conv.id, "My Project"); + expect(conversationStore.get(conv.id)!.customTitle).toBe("My Project"); // get() returns the conversation or null - expect(conversationStore.get(conv.id)).toEqual(conv); expect(conversationStore.get("non-existent")).toBeNull(); }); @@ -175,18 +173,18 @@ describe("ConversationStore", () => { expect(fs.existsSync(msgPath)).toBe(false); expect(fs.existsSync(stepsPath)).toBe(false); - // rename() is a shorthand for update({ title }) + // setCustomTitle() persists user-set title const conv2 = conversationStore.create({ engineType: "opencode", directory: "/test2" }); - conversationStore.rename(conv2.id, "Renamed"); - expect(conversationStore.get(conv2.id)!.title).toBe("Renamed"); + conversationStore.setCustomTitle(conv2.id, "Renamed"); + expect(conversationStore.get(conv2.id)!.customTitle).toBe("Renamed"); }); it("updates persisted session config fields without touching unrelated metadata", () => { const conv = conversationStore.create({ engineType: "opencode", directory: "/test", - title: "Pinned title", }); + conversationStore.setCustomTitle(conv.id, "Pinned title"); const updated = conversationStore.updateSessionConfig(conv.id, { mode: "plan", @@ -197,7 +195,7 @@ describe("ConversationStore", () => { expect(updated).toMatchObject({ id: conv.id, - title: "Pinned title", + customTitle: "Pinned title", mode: "plan", modelId: "gpt-5.4", reasoningEffort: "high", @@ -238,13 +236,18 @@ describe("ConversationStore", () => { const updatedConv = conversationStore.get(conv.id)!; expect(updatedConv.messageCount).toBe(1); expect(updatedConv.preview).toBe("Hello"); - expect(updatedConv.title).toBe("Hello"); + // First user message captures firstPrompt fallback + expect(updatedConv.firstPrompt).toBe("Hello"); + // No title is auto-set anymore — displayTitle is derived at render time + expect(updatedConv.title).toBeUndefined(); + expect(updatedConv.customTitle).toBeUndefined(); + expect(updatedConv.engineTitle).toBeUndefined(); const messages = await conversationStore.listMessages(conv.id); expect(messages.length).toBe(1); expect(messages[0].id).toBe("msg_1"); - // First message with long text triggers auto-title truncation (50 chars + "...") + // First message with long text triggers firstPrompt truncation (100 chars + "…") const conv2 = conversationStore.create({ engineType: "opencode", directory: "/test2" }); const longFirstMsg: ConversationMessage = { ...mockMsg, @@ -253,10 +256,10 @@ describe("ConversationStore", () => { }; await conversationStore.appendMessage(conv2.id, longFirstMsg); const conv2Updated = conversationStore.get(conv2.id)!; - expect(conv2Updated.title.length).toBe(53); - expect(conv2Updated.title.endsWith("...")).toBe(true); + expect(conv2Updated.firstPrompt!.length).toBe(101); + expect(conv2Updated.firstPrompt!.endsWith("…")).toBe(true); - // appendMessage() handles long previews (title only auto-set on first message) + // appendMessage() handles long previews (firstPrompt only set on first message) const longText = "A".repeat(200); const longMsg: ConversationMessage = { ...mockMsg, @@ -267,8 +270,8 @@ describe("ConversationStore", () => { const updatedLong = conversationStore.get(conv.id)!; expect(updatedLong.preview?.length).toBe(103); expect(updatedLong.preview?.endsWith("...")).toBe(true); - // Title stays as "Hello" from first message — auto-title only applies on messages.length === 1 - expect(updatedLong.title).toBe("Hello"); + // firstPrompt stays as "Hello" from first message + expect(updatedLong.firstPrompt).toBe("Hello"); }); it("updates existing messages in history", async () => { @@ -357,15 +360,15 @@ describe("ConversationStore", () => { describe("Persistence & Recovery", () => { it("recovers state from disk and handles corruption or version mismatches", async () => { - // survives re-initialization (persistence check) - const conv = conversationStore.create({ engineType: "opencode", directory: "/persist", title: "Keep Me" }); + const conv = conversationStore.create({ engineType: "opencode", directory: "/persist" }); + conversationStore.update(conv.id, { customTitle: "Keep Me" }); await conversationStore.flushAll(); (conversationStore as any).initialized = false; (conversationStore as any).index = new Map(); conversationStore.init(); const recovered = conversationStore.get(conv.id); expect(recovered).not.toBeNull(); - expect(recovered?.title).toBe("Keep Me"); + expect(recovered?.customTitle).toBe("Keep Me"); // handles corrupt index file gracefully const indexPath = path.join(tmpDir, "conversations", "index.json"); diff --git a/tests/unit/src/lib/session-utils.test.ts b/tests/unit/src/lib/session-utils.test.ts index ed76384a..9f21fea4 100644 --- a/tests/unit/src/lib/session-utils.test.ts +++ b/tests/unit/src/lib/session-utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { isDefaultTitle } from '../../../../src/lib/session-utils'; +import { isDefaultTitle, isPromptFallbackTitle } from '../../../../src/lib/session-utils'; describe('isDefaultTitle', () => { it.each([ @@ -17,3 +17,19 @@ describe('isDefaultTitle', () => { expect(isDefaultTitle(input)).toBe(expected); }); }); + +describe('isPromptFallbackTitle', () => { + it.each([ + ['Explain Promise.all', 'Explain Promise.all', true], + ['Summarize mock project metadata...', 'Summarize mock project metadata: inspect the sample manifest…', true], + ['Summarize mock project metadata: inspect the sample manifest', 'Summarize mock project metadata: inspect the sample manifest…', true], + ['Summarize mock project metadata', 'Summarize mock project metadata: inspect the sample manifest…', false], + ['Fix sample parser', 'Fix sample parser and add tests for the regression…', false], + ['Review Sample Upload Integration', 'Please review the sample upload integration changes…', false], + ['Retrieve Mock Session Title', 'How can a mock engine expose a generated conversation title?', false], + ['', 'First prompt', false], + ['Title', undefined, false], + ])('isPromptFallbackTitle("%s", "%s") returns %s', (title, firstPrompt, expected) => { + expect(isPromptFallbackTitle(title, firstPrompt)).toBe(expected); + }); +});