From 8437cecb6b02d26da6e5f66bd051fb770a37815b Mon Sep 17 00:00:00 2001 From: Duang Cheng Date: Wed, 22 Apr 2026 17:08:46 +0800 Subject: [PATCH 1/9] feat(conversation): split title into customTitle/engineTitle with render-time resolution Replace single-title write path with priority-based fields persisted on ConversationMeta: - customTitle: user-set via rename - engineTitle: pushed from engine session.updated / Claude aiTitle - firstPrompt: captured from first user message as last-resort fallback displayTitle is computed at render time as customTitle ?? engineTitle ?? title (legacy) ?? firstPrompt ?? "New Chat". Engine writeback on user rename: - Claude: renameSession via @anthropic-ai/claude-agent-sdk - Codex: thread/name/set JSON-RPC - OpenCode: client.session.update({ title }) - Copilot: local-only (public SDK lacks the API) Claude engine refreshes engineTitle by polling getSessionInfo() with a 1s debounce after each result event, surfacing the SDK's pre-computed summary (custom title -> aiTitle -> first prompt). Co-Authored-By: Claude Opus 4.6 --- electron/main/engines/claude/index.ts | 82 +++++++++++++- electron/main/engines/codex/index.ts | 19 ++++ electron/main/engines/engine-adapter.ts | 17 +++ electron/main/engines/opencode/index.ts | 18 ++++ electron/main/gateway/engine-manager.ts | 82 ++++++-------- electron/main/services/conversation-store.ts | 49 +++++---- src/types/unified.ts | 14 ++- .../electron/gateway/engine-manager.test.ts | 101 +++--------------- .../services/conversation-store.test.ts | 50 +++++---- 9 files changed, 252 insertions(+), 180 deletions(-) diff --git a/electron/main/engines/claude/index.ts b/electron/main/engines/claude/index.ts index 0fe9358d..5ba1441d 100644 --- a/electron/main/engines/claude/index.ts +++ b/electron/main/engines/claude/index.ts @@ -14,6 +14,8 @@ import { unstable_v2_resumeSession, listSessions as sdkListSessions, getSessionMessages as sdkGetSessionMessages, + getSessionInfo as sdkGetSessionInfo, + renameSession as sdkRenameSession, query as sdkQuery, } from "@anthropic-ai/claude-agent-sdk"; import type { @@ -215,6 +217,8 @@ export class ClaudeCodeAdapter extends EngineAdapter { private sessionDirectories = new Map(); /** Persisted ccSessionId per session, for SDK session resumption across restarts */ private sessionCcIds = new Map(); + /** Pending debounced timers for engine-title refresh (Claude SDK getSessionInfo). */ + private pendingTitleRefreshes = new Map>(); /** Sessions that were just resumed after a dead process — emit notice on next message */ private pendingResumeNotice = new Set(); @@ -574,6 +578,77 @@ 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); + } + } + + /** + * Fetch the SDK's pre-computed session summary (which already collapses + * customTitle / aiTitle / firstPrompt priority) and emit it as engineTitle. + * Called with debounce after each turn completes. + */ + private async refreshEngineTitle(sessionId: string): Promise { + const ccSessionId = this.sessionCcIds.get(sessionId); + if (!ccSessionId) return; + const directory = this.sessionDirectories.get(sessionId); + try { + const info = await sdkGetSessionInfo( + ccSessionId, + directory ? { dir: directory } : undefined, + ); + const title = info?.summary; + if (!title) return; + this.emit("session.updated", { + session: { + id: sessionId, + engineType: this.engineType, + title, + }, + }); + } catch (err) { + claudeLog.debug( + `[Claude][${sessionId}] getSessionInfo failed:`, + (err as Error)?.message, + ); + } + } + + /** Debounced wrapper around refreshEngineTitle (1s after last result). */ + private scheduleEngineTitleRefresh(sessionId: string): void { + const existing = this.pendingTitleRefreshes.get(sessionId); + if (existing) clearTimeout(existing); + const t = setTimeout(() => { + this.pendingTitleRefreshes.delete(sessionId); + void this.refreshEngineTitle(sessionId); + }, 1000); + this.pendingTitleRefreshes.set(sessionId, t); + } + async getSession(sessionId: string): Promise { return null; } @@ -2778,6 +2853,11 @@ export class ClaudeCodeAdapter extends EngineAdapter { `[Claude][${sessionId}] Result: cost=$${buffer.cost?.toFixed(4)}, ` + `tokens=${buffer.tokens?.input ?? 0}/${buffer.tokens?.output ?? 0}`, ); + + // Claude Code writes aiTitle/customTitle into the JSONL session file + // shortly after a turn completes. Debounce a getSessionInfo() call to + // pick up the latest title without polling or watching files. + this.scheduleEngineTitleRefresh(sessionId); } /** @@ -3251,7 +3331,7 @@ 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() + // Title updates: engineTitle is refreshed via getSessionInfo after each turn (see scheduleEngineTitleRefresh) // Clean up this.messageBuffers.delete(sessionId); diff --git a/electron/main/engines/codex/index.ts b/electron/main/engines/codex/index.ts index a0ec9f79..f858c245 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/engine-adapter.ts b/electron/main/engines/engine-adapter.ts index cd07a96f..77e57c84 100644 --- a/electron/main/engines/engine-adapter.ts +++ b/electron/main/engines/engine-adapter.ts @@ -197,6 +197,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/index.ts b/electron/main/engines/opencode/index.ts index 8bbc4c45..485cef03 100644 --- a/electron/main/engines/opencode/index.ts +++ b/electron/main/engines/opencode/index.ts @@ -877,6 +877,24 @@ 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 + // eslint-disable-next-line no-console + console.warn(`[opencode] 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 bfa81173..ecdf3442 100644 --- a/electron/main/gateway/engine-manager.ts +++ b/electron/main/gateway/engine-manager.ts @@ -39,6 +39,17 @@ function normalizeDir(dir: string): string { return dir ? dir.replaceAll("\\", "/") : ""; } +/** Compute the display title from a ConversationMeta — render-time priority. */ +function computeDisplayTitle(conv: ConversationMeta): string { + return ( + conv.customTitle || + conv.engineTitle || + conv.title || // legacy single-title field for pre-refactor data + 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 @@ -50,7 +61,7 @@ function convToSession(conv: ConversationMeta): UnifiedSession { id: conv.id, engineType: conv.engineType, directory: normalizeDir(conv.directory), - title: conv.title, + title: computeDisplayTitle(conv), worktreeId: conv.worktreeId, projectId: `dir-${projectDir}`, time: { @@ -309,12 +320,10 @@ export class EngineManager extends EventEmitter { const convId = engineSessionId ? this.resolveConversationId(engineSessionId) : null; if (convId) { - // Persist title changes + // Persist engine-pushed title (no interception — render-time priority + // ensures customTitle wins over engineTitle when both are present) if (data.session.title) { - const conv = conversationStore.get(convId); - if (conv && this.isDefaultTitle(conv.title)) { - conversationStore.rename(convId, data.session.title); - } + conversationStore.setEngineTitle(convId, data.session.title); } // Persist engineMeta (e.g. ccSessionId for Claude Code session resumption) if (data.session.engineMeta) { @@ -819,7 +828,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 }; } @@ -850,14 +877,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); @@ -916,38 +935,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); @@ -1017,7 +1004,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 }]); const result = await adapter.invokeCommand( diff --git a/electron/main/services/conversation-store.ts b/electron/main/services/conversation-store.ts index 3b118a0c..d5de1b4d 100644 --- a/electron/main/services/conversation-store.ts +++ b/electron/main/services/conversation-store.ts @@ -132,7 +132,6 @@ class ConversationStore { create(params: { engineType: EngineType; directory: string; - title?: string; worktreeId?: string; parentDirectory?: string; }): ConversationMeta { @@ -143,7 +142,6 @@ class ConversationStore { id: timeId("conv"), engineType: params.engineType, directory: params.directory, - title: params.title || this.generateTitle(), createdAt: now, updatedAt: now, messageCount: 0, @@ -198,8 +196,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 }); } // ------------------------------------------------------------------------- @@ -256,15 +261,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 ? "…" : ""); } } @@ -479,7 +488,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, @@ -506,6 +515,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); @@ -761,15 +781,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/types/unified.ts b/src/types/unified.ts index 6344c5f5..20fb9161 100644 --- a/src/types/unified.ts +++ b/src/types/unified.ts @@ -143,7 +143,19 @@ export interface ConversationMeta { id: string; engineType: EngineType; directory: string; - title: string; + /** + * Legacy single-title field. New code persists `customTitle`/`engineTitle` + * separately and derives a display title at render time. Kept here so old + * stored data still renders something instead of falling all the way back + * to the first-prompt label. + */ + title?: string; + /** User-set title (via rename). Highest priority in displayTitle resolution. */ + customTitle?: string; + /** Engine-summarized title pushed via session.updated / aiTitle. */ + 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/gateway/engine-manager.test.ts b/tests/unit/electron/gateway/engine-manager.test.ts index f7a12c1d..7040a6ef 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(), @@ -486,8 +487,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 () => { @@ -670,87 +673,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 // =========================================================================== @@ -1217,7 +1139,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: { @@ -1231,14 +1154,14 @@ 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 persists engineMeta when provided", () => { diff --git a/tests/unit/electron/services/conversation-store.test.ts b/tests/unit/electron/services/conversation-store.test.ts index 3d0c7df3..ab7b34dc 100644 --- a/tests/unit/electron/services/conversation-store.test.ts +++ b/tests/unit/electron/services/conversation-store.test.ts @@ -84,29 +84,28 @@ 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 now derived at render time from customTitle/engineTitle/firstPrompt; + // the store no longer initializes a title in create(). 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.title).toBeUndefined(); + 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,10 +174,10 @@ 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"); }); }); @@ -200,13 +199,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, @@ -215,10 +219,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, @@ -229,8 +233,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 () => { @@ -319,8 +323,10 @@ 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" }); + // survives re-initialization (persistence check) — uses legacy title field + // to verify pre-refactor data is preserved across reloads. + const conv = conversationStore.create({ engineType: "opencode", directory: "/persist" }); + conversationStore.update(conv.id, { title: "Keep Me" }); await conversationStore.flushAll(); (conversationStore as any).initialized = false; (conversationStore as any).index = new Map(); From abdcaa96d1159a8c186db72e23a5e0e629c74fd8 Mon Sep 17 00:00:00 2001 From: Duang Cheng Date: Wed, 22 Apr 2026 17:24:46 +0800 Subject: [PATCH 2/9] fix: address PR review feedback - opencode renameSession: use openCodeLog.warn instead of console.warn - claude: clear pendingTitleRefreshes timers in deleteSession/stop and unref the timer so it doesn't keep the event loop alive at shutdown Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- electron/main/engines/claude/index.ts | 13 +++++++++++++ electron/main/engines/opencode/index.ts | 3 +-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/electron/main/engines/claude/index.ts b/electron/main/engines/claude/index.ts index 5ba1441d..56333b54 100644 --- a/electron/main/engines/claude/index.ts +++ b/electron/main/engines/claude/index.ts @@ -462,6 +462,10 @@ export class ClaudeCodeAdapter extends EngineAdapter { this.rejectAllPendingPermissions("Adapter stopped"); this.rejectAllPendingQuestions("Adapter stopped"); + // Clear any pending engineTitle refresh timers + for (const t of this.pendingTitleRefreshes.values()) clearTimeout(t); + this.pendingTitleRefreshes.clear(); + // Stop cleanup interval this.stopSessionCleanup(); @@ -646,6 +650,8 @@ export class ClaudeCodeAdapter extends EngineAdapter { this.pendingTitleRefreshes.delete(sessionId); void this.refreshEngineTitle(sessionId); }, 1000); + // Don't keep the event loop alive during shutdown + t.unref?.(); this.pendingTitleRefreshes.set(sessionId, t); } @@ -654,6 +660,13 @@ export class ClaudeCodeAdapter extends EngineAdapter { } async deleteSession(sessionId: string): Promise { + // Cancel any pending engineTitle refresh for this session + const titleTimer = this.pendingTitleRefreshes.get(sessionId); + if (titleTimer) { + clearTimeout(titleTimer); + this.pendingTitleRefreshes.delete(sessionId); + } + // Abort any active request for this session const controller = this.activeAbortControllers.get(sessionId); if (controller) { diff --git a/electron/main/engines/opencode/index.ts b/electron/main/engines/opencode/index.ts index 485cef03..b4a5486c 100644 --- a/electron/main/engines/opencode/index.ts +++ b/electron/main/engines/opencode/index.ts @@ -890,8 +890,7 @@ export class OpenCodeAdapter extends EngineAdapter { }); } catch (err) { // Don't surface — local rename already succeeded - // eslint-disable-next-line no-console - console.warn(`[opencode] session.update title failed for ${sessionId}:`, err); + openCodeLog.warn(`session.update title failed for ${sessionId}:`, err); } } From 89cc67891c41ebab7bb77a3079d615ccbfaba140 Mon Sep 17 00:00:00 2001 From: Duang Cheng Date: Thu, 23 Apr 2026 17:07:46 +0800 Subject: [PATCH 3/9] fix(claude): reject XML-tag noise in CC SDK aiTitle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CC's getSessionInfo().summary can return garbage when the session contains tool/system-injected user messages (e.g. blocks from background agents — the SDK feeds them into the aiTitle generator and the resulting summary is the literal XML). Detect strings that start with an XML/HTML-style opening tag and fall back to firstPrompt for the engineTitle write-back. This also self-heals previously persisted junk titles on the next turn. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- electron/main/engines/claude/index.ts | 24 ++++++++++- node_modules | 1 + .../electron/engines/claude/index.test.ts | 40 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 120000 node_modules diff --git a/electron/main/engines/claude/index.ts b/electron/main/engines/claude/index.ts index 56333b54..e2e86c0f 100644 --- a/electron/main/engines/claude/index.ts +++ b/electron/main/engines/claude/index.ts @@ -184,6 +184,21 @@ const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // ClaudeCodeAdapter // ============================================================================ +/** + * Reject titles that look like system-injected noise (XML-style tags such as + * ``, ``, `` etc.) so the UI + * doesn't display them as the conversation title. + */ +export function isUsableTitle(title: string | undefined): title is string { + if (!title) return false; + const trimmed = title.trim(); + if (!trimmed) return false; + // Anything starting with an XML/HTML-like opening tag is noise from injected + // tool output / hook payloads, not a real summary. + if (/^<[a-zA-Z][\w-]*[\s>]/.test(trimmed)) return false; + return true; +} + export class ClaudeCodeAdapter extends EngineAdapter { readonly engineType: EngineType = "claude"; @@ -615,6 +630,10 @@ export class ClaudeCodeAdapter extends EngineAdapter { * Fetch the SDK's pre-computed session summary (which already collapses * customTitle / aiTitle / firstPrompt priority) and emit it as engineTitle. * Called with debounce after each turn completes. + * + * Note: the SDK's aiTitle generation can produce garbage when the session + * contains tool/system-injected user messages (e.g. `` + * blocks from background agents). Detect that and fall back to firstPrompt. */ private async refreshEngineTitle(sessionId: string): Promise { const ccSessionId = this.sessionCcIds.get(sessionId); @@ -625,7 +644,10 @@ export class ClaudeCodeAdapter extends EngineAdapter { ccSessionId, directory ? { dir: directory } : undefined, ); - const title = info?.summary; + if (!info) return; + const title = isUsableTitle(info.summary) + ? info.summary + : info.firstPrompt; if (!title) return; this.emit("session.updated", { session: { diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..4748789a --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Users/duang/workspace/codemux/node_modules \ No newline at end of file diff --git a/tests/unit/electron/engines/claude/index.test.ts b/tests/unit/electron/engines/claude/index.test.ts index 692ba820..580f67c5 100644 --- a/tests/unit/electron/engines/claude/index.test.ts +++ b/tests/unit/electron/engines/claude/index.test.ts @@ -3375,3 +3375,43 @@ describe("ClaudeCodeAdapter", () => { }); }); }); + +describe("isUsableTitle()", () => { + it("rejects empty / whitespace / undefined", async () => { + const { isUsableTitle } = await import( + "../../../../../electron/main/engines/claude/index" + ); + expect(isUsableTitle(undefined)).toBe(false); + expect(isUsableTitle("")).toBe(false); + expect(isUsableTitle(" ")).toBe(false); + }); + + it("rejects strings starting with XML/HTML-like tags", async () => { + const { isUsableTitle } = await import( + "../../../../../electron/main/engines/claude/index" + ); + expect(isUsableTitle("foo")).toBe( + false, + ); + expect(isUsableTitle("x")).toBe(false); + expect(isUsableTitle("commit")).toBe(false); + expect(isUsableTitle(" leading whitespace")).toBe(false); + }); + + it("accepts normal titles even if they contain angle brackets later", async () => { + const { isUsableTitle } = await import( + "../../../../../electron/main/engines/claude/index" + ); + expect(isUsableTitle("Refactor rendering")).toBe(true); + expect(isUsableTitle("查一下本机 ip")).toBe(true); + expect(isUsableTitle("Fix bug in parser")).toBe(true); + }); + + it("accepts a lone less-than that isn't an opening tag", async () => { + const { isUsableTitle } = await import( + "../../../../../electron/main/engines/claude/index" + ); + expect(isUsableTitle("<= 5 retries")).toBe(true); + expect(isUsableTitle("a < b comparison")).toBe(true); + }); +}); From be50e6c0205fdf588380e6bc465d9c6eeceb4dff Mon Sep 17 00:00:00 2001 From: Duang Cheng Date: Thu, 23 Apr 2026 17:08:00 +0800 Subject: [PATCH 4/9] chore: drop accidentally committed node_modules symlink --- node_modules | 1 - 1 file changed, 1 deletion(-) delete mode 120000 node_modules diff --git a/node_modules b/node_modules deleted file mode 120000 index 4748789a..00000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/duang/workspace/codemux/node_modules \ No newline at end of file From fabd43e1ee06ce41da0caf47923583cdb7733f96 Mon Sep 17 00:00:00 2001 From: Duang Cheng Date: Tue, 28 Apr 2026 15:45:28 +0800 Subject: [PATCH 5/9] fix(conversation): persist only real engine titles Remove the unsafe Claude summary polling path and keep title resolution limited to adapter-observed engine updates, user custom titles, and first-prompt fallback. Filter default/prompt-derived titles conservatively so real engine summaries can replace firstPrompt without being overwritten by placeholders or prompt echoes. Co-Authored-By: Claude Opus 4.7 --- electron/main/engines/claude/index.ts | 87 ----------- electron/main/engines/copilot/index.ts | 60 +++++++- electron/main/engines/opencode/converters.ts | 5 +- electron/main/engines/opencode/index.ts | 19 +++ electron/main/gateway/engine-manager.ts | 47 ++++-- src/lib/session-utils.ts | 23 +++ src/types/unified.ts | 9 +- .../electron/engines/copilot/index.test.ts | 90 ++++++++++-- .../engines/opencode/converters.test.ts | 11 ++ .../electron/gateway/engine-manager.test.ts | 138 +++++++++++++++++- .../services/conversation-store.test.ts | 11 +- tests/unit/src/lib/session-utils.test.ts | 18 ++- 12 files changed, 383 insertions(+), 135 deletions(-) diff --git a/electron/main/engines/claude/index.ts b/electron/main/engines/claude/index.ts index e2e86c0f..78885be5 100644 --- a/electron/main/engines/claude/index.ts +++ b/electron/main/engines/claude/index.ts @@ -14,7 +14,6 @@ import { unstable_v2_resumeSession, listSessions as sdkListSessions, getSessionMessages as sdkGetSessionMessages, - getSessionInfo as sdkGetSessionInfo, renameSession as sdkRenameSession, query as sdkQuery, } from "@anthropic-ai/claude-agent-sdk"; @@ -184,21 +183,6 @@ const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // ClaudeCodeAdapter // ============================================================================ -/** - * Reject titles that look like system-injected noise (XML-style tags such as - * ``, ``, `` etc.) so the UI - * doesn't display them as the conversation title. - */ -export function isUsableTitle(title: string | undefined): title is string { - if (!title) return false; - const trimmed = title.trim(); - if (!trimmed) return false; - // Anything starting with an XML/HTML-like opening tag is noise from injected - // tool output / hook payloads, not a real summary. - if (/^<[a-zA-Z][\w-]*[\s>]/.test(trimmed)) return false; - return true; -} - export class ClaudeCodeAdapter extends EngineAdapter { readonly engineType: EngineType = "claude"; @@ -232,8 +216,6 @@ export class ClaudeCodeAdapter extends EngineAdapter { private sessionDirectories = new Map(); /** Persisted ccSessionId per session, for SDK session resumption across restarts */ private sessionCcIds = new Map(); - /** Pending debounced timers for engine-title refresh (Claude SDK getSessionInfo). */ - private pendingTitleRefreshes = new Map>(); /** Sessions that were just resumed after a dead process — emit notice on next message */ private pendingResumeNotice = new Set(); @@ -477,10 +459,6 @@ export class ClaudeCodeAdapter extends EngineAdapter { this.rejectAllPendingPermissions("Adapter stopped"); this.rejectAllPendingQuestions("Adapter stopped"); - // Clear any pending engineTitle refresh timers - for (const t of this.pendingTitleRefreshes.values()) clearTimeout(t); - this.pendingTitleRefreshes.clear(); - // Stop cleanup interval this.stopSessionCleanup(); @@ -626,69 +604,11 @@ export class ClaudeCodeAdapter extends EngineAdapter { } } - /** - * Fetch the SDK's pre-computed session summary (which already collapses - * customTitle / aiTitle / firstPrompt priority) and emit it as engineTitle. - * Called with debounce after each turn completes. - * - * Note: the SDK's aiTitle generation can produce garbage when the session - * contains tool/system-injected user messages (e.g. `` - * blocks from background agents). Detect that and fall back to firstPrompt. - */ - private async refreshEngineTitle(sessionId: string): Promise { - const ccSessionId = this.sessionCcIds.get(sessionId); - if (!ccSessionId) return; - const directory = this.sessionDirectories.get(sessionId); - try { - const info = await sdkGetSessionInfo( - ccSessionId, - directory ? { dir: directory } : undefined, - ); - if (!info) return; - const title = isUsableTitle(info.summary) - ? info.summary - : info.firstPrompt; - if (!title) return; - this.emit("session.updated", { - session: { - id: sessionId, - engineType: this.engineType, - title, - }, - }); - } catch (err) { - claudeLog.debug( - `[Claude][${sessionId}] getSessionInfo failed:`, - (err as Error)?.message, - ); - } - } - - /** Debounced wrapper around refreshEngineTitle (1s after last result). */ - private scheduleEngineTitleRefresh(sessionId: string): void { - const existing = this.pendingTitleRefreshes.get(sessionId); - if (existing) clearTimeout(existing); - const t = setTimeout(() => { - this.pendingTitleRefreshes.delete(sessionId); - void this.refreshEngineTitle(sessionId); - }, 1000); - // Don't keep the event loop alive during shutdown - t.unref?.(); - this.pendingTitleRefreshes.set(sessionId, t); - } - async getSession(sessionId: string): Promise { return null; } async deleteSession(sessionId: string): Promise { - // Cancel any pending engineTitle refresh for this session - const titleTimer = this.pendingTitleRefreshes.get(sessionId); - if (titleTimer) { - clearTimeout(titleTimer); - this.pendingTitleRefreshes.delete(sessionId); - } - // Abort any active request for this session const controller = this.activeAbortControllers.get(sessionId); if (controller) { @@ -2888,11 +2808,6 @@ export class ClaudeCodeAdapter extends EngineAdapter { `[Claude][${sessionId}] Result: cost=$${buffer.cost?.toFixed(4)}, ` + `tokens=${buffer.tokens?.input ?? 0}/${buffer.tokens?.output ?? 0}`, ); - - // Claude Code writes aiTitle/customTitle into the JSONL session file - // shortly after a turn completes. Debounce a getSessionInfo() call to - // pick up the latest title without polling or watching files. - this.scheduleEngineTitleRefresh(sessionId); } /** @@ -3366,8 +3281,6 @@ export class ClaudeCodeAdapter extends EngineAdapter { // Emit final message this.emit("message.updated", { sessionId: buffer.sessionId, message: finalMessage }); - // Title updates: engineTitle is refreshed via getSessionInfo after each turn (see scheduleEngineTitleRefresh) - // Clean up this.messageBuffers.delete(sessionId); for (const [key, part] of this.toolCallParts) { diff --git a/electron/main/engines/copilot/index.ts b/electron/main/engines/copilot/index.ts index 01c842ca..ebfc046c 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, @@ -92,6 +93,12 @@ interface PendingQuestion { type CopilotReasoningEffort = NonNullable; +const approvePermissionOnce = (): PermissionRequestResult => ({ kind: "approve-once" }); +const rejectPermission = (feedback?: string): PermissionRequestResult => ( + feedback ? { kind: "reject", feedback } : { kind: "reject" } +); +const noPermissionResult = (): PermissionRequestResult => ({ kind: "no-result" }); + function buildCopilotSubprocessEnv(extraEnv?: Record): NodeJS.ProcessEnv { const env = { ...process.env, ...extraEnv }; @@ -124,6 +131,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>(); private allowedAlwaysKinds = new Set(); @@ -377,6 +385,17 @@ export class CopilotSdkAdapter extends EngineAdapter { this.sessionTodos.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[], @@ -589,7 +608,7 @@ export class CopilotSdkAdapter extends EngineAdapter { } for (const [id, pending] of this.pendingPermissions) { if (pending.permission.sessionId === sessionId) { - pending.resolve({ kind: "denied-interactively-by-user" }); + pending.resolve(rejectPermission()); this.pendingPermissions.delete(id); } } @@ -632,7 +651,7 @@ export class CopilotSdkAdapter extends EngineAdapter { const config: ResumeSessionConfig = { streaming: true, workingDirectory: directory, - onPermissionRequest: () => ({ kind: "denied-interactively-by-user" as const }), + onPermissionRequest: noPermissionResult, }; session = await this.client!.resumeSession(engineSessionId, config); const events = await session.getMessages(); @@ -740,7 +759,7 @@ export class CopilotSdkAdapter extends EngineAdapter { if (rawKind) this.allowedAlwaysKinds.add(rawKind); } - pending.resolve({ kind: isApproved ? "approved" : "denied-interactively-by-user" }); + pending.resolve(isApproved ? approvePermissionOnce() : rejectPermission()); this.pendingPermissions.delete(permissionId); this.emit("permission.replied", { permissionId, optionId }); } @@ -1241,10 +1260,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 { @@ -1315,8 +1361,8 @@ export class CopilotSdkAdapter extends EngineAdapter { private handlePermissionRequest(req: PermissionRequest, ctx: { sessionId: string }): Promise { const sessionId = ctx.sessionId; - if ((this.sessionModes.get(sessionId) || "autopilot") === "autopilot") return Promise.resolve({ kind: "approved" }); - if (this.allowedAlwaysKinds.has(req.kind)) return Promise.resolve({ kind: "approved" }); + if ((this.sessionModes.get(sessionId) || "autopilot") === "autopilot") return Promise.resolve(approvePermissionOnce()); + if (this.allowedAlwaysKinds.has(req.kind)) return Promise.resolve(approvePermissionOnce()); const permissionId = timeId("perm"); const kind: any = req.kind === "read" ? "read" : req.kind === "write" || req.kind === "shell" ? "edit" : "other"; @@ -1417,7 +1463,7 @@ export class CopilotSdkAdapter extends EngineAdapter { } private rejectAllPendingPermissions(_reason: string): void { - for (const [_id, pending] of this.pendingPermissions) pending.resolve({ kind: "denied-no-approval-rule-and-could-not-request-from-user" }); + for (const [_id, pending] of this.pendingPermissions) pending.resolve(rejectPermission()); this.pendingPermissions.clear(); } diff --git a/electron/main/engines/opencode/converters.ts b/electron/main/engines/opencode/converters.ts index cd4cea56..5b2c4ab8 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: { diff --git a/electron/main/engines/opencode/index.ts b/electron/main/engines/opencode/index.ts index b4a5486c..d6865042 100644 --- a/electron/main/engines/opencode/index.ts +++ b/electron/main/engines/opencode/index.ts @@ -550,6 +550,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 { diff --git a/electron/main/gateway/engine-manager.ts b/electron/main/gateway/engine-manager.ts index ecdf3442..d2704cc0 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, @@ -40,14 +41,25 @@ function normalizeDir(dir: string): string { } /** 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 || - conv.engineTitle || - conv.title || // legacy single-title field for pre-refactor data - conv.firstPrompt || - "New Chat" - ); + return conv.customTitle || getUsableEngineTitle(conv) || conv.firstPrompt || "New Chat"; } /** Convert ConversationMeta → UnifiedSession for wire compatibility */ @@ -320,10 +332,10 @@ export class EngineManager extends EventEmitter { const convId = engineSessionId ? this.resolveConversationId(engineSessionId) : null; if (convId) { - // Persist engine-pushed title (no interception — render-time priority - // ensures customTitle wins over engineTitle when both are present) - if (data.session.title) { - conversationStore.setEngineTitle(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) { @@ -333,7 +345,10 @@ export class EngineManager extends EventEmitter { data.session.engineMeta as Record, ); } - 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); } @@ -584,8 +599,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) }); + } + } + engineManagerLog.debug( `Persisted user message ${msgId} to conversation ${conversationId}: ${parts.length} parts`, ); 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 20fb9161..be6b1818 100644 --- a/src/types/unified.ts +++ b/src/types/unified.ts @@ -143,16 +143,9 @@ export interface ConversationMeta { id: string; engineType: EngineType; directory: string; - /** - * Legacy single-title field. New code persists `customTitle`/`engineTitle` - * separately and derives a display title at render time. Kept here so old - * stored data still renders something instead of falling all the way back - * to the first-prompt label. - */ - title?: string; /** User-set title (via rename). Highest priority in displayTitle resolution. */ customTitle?: string; - /** Engine-summarized title pushed via session.updated / aiTitle. */ + /** Engine-summarized title observed from adapter or engine updates. */ engineTitle?: string; /** Truncated first user prompt — used as last-resort fallback for displayTitle. */ firstPrompt?: string; diff --git a/tests/unit/electron/engines/copilot/index.test.ts b/tests/unit/electron/engines/copilot/index.test.ts index 410b3bae..4ed88729 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 () => {}) }, }, @@ -276,7 +279,7 @@ describe("CopilotSdkAdapter", () => { await adapter.stop(); expect(resolve).toHaveBeenCalledWith({ - kind: "denied-no-approval-rule-and-could-not-request-from-user", + kind: "reject", }); expect((adapter as any).pendingPermissions.size).toBe(0); }); @@ -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(); @@ -712,7 +739,7 @@ describe("CopilotSdkAdapter", () => { await adapter.cancelMessage("s1"); - expect(resolve).toHaveBeenCalledWith({ kind: "denied-interactively-by-user" }); + expect(resolve).toHaveBeenCalledWith({ kind: "reject" }); expect((adapter as any).pendingPermissions.has("p1")).toBe(false); }); @@ -1058,6 +1085,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: "Read this repository metadata only: inspect package.json and tell me the package name." }], + }]); + mockClientInstance.getSessionMetadata.mockResolvedValueOnce({ + summary: "Read this repository metadata only: inspect package.json...", + }); + + 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: "看看目前修改区,应该是加了 picgo 的支持" }], + }]); + mockClientInstance.getSessionMetadata.mockResolvedValueOnce({ + summary: " Review PicGo Integration ", + }); + + await (adapter as any).refreshSessionTitle("s1"); + + expect(updates).toEqual([{ session: { id: "s1", engineType: "copilot", title: "Review PicGo Integration" } }]); + expect((adapter as any).sessionTitles.get("s1")).toBe("Review PicGo Integration"); + }); + }); + // ============================================================================ // E. Permission Handling // ============================================================================ @@ -1073,7 +1145,7 @@ describe("CopilotSdkAdapter", () => { { sessionId: "s1" }, ); - expect(result).toEqual({ kind: "approved" }); + expect(result).toEqual({ kind: "approve-once" }); expect(permEvents).toHaveLength(0); }); @@ -1086,7 +1158,7 @@ describe("CopilotSdkAdapter", () => { { sessionId: "s1" }, ); - expect(result).toEqual({ kind: "approved" }); + expect(result).toEqual({ kind: "approve-once" }); }); it("emits permission.asked in non-autopilot mode and waits for reply", async () => { @@ -1106,9 +1178,9 @@ describe("CopilotSdkAdapter", () => { expect(perm.options.map((o: any) => o.id)).toEqual(["allow_once", "allow_always", "reject_once"]); const pending = (adapter as any).pendingPermissions.get(perm.id); - pending.resolve({ kind: "approved" }); + pending.resolve({ kind: "approve-once" }); - await expect(requestPromise).resolves.toEqual({ kind: "approved" }); + await expect(requestPromise).resolves.toEqual({ kind: "approve-once" }); }); it("maps 'read' kind to 'read', 'shell' to 'edit', unknown to 'other'", async () => { @@ -1136,7 +1208,7 @@ describe("CopilotSdkAdapter", () => { await adapter.replyPermission("p1", { optionId: "allow_once" }); - expect(resolve).toHaveBeenCalledWith({ kind: "approved" }); + expect(resolve).toHaveBeenCalledWith({ kind: "approve-once" }); expect((adapter as any).pendingPermissions.has("p1")).toBe(false); }); @@ -1149,7 +1221,7 @@ describe("CopilotSdkAdapter", () => { await adapter.replyPermission("p1", { optionId: "allow_always" }); - expect(resolve).toHaveBeenCalledWith({ kind: "approved" }); + expect(resolve).toHaveBeenCalledWith({ kind: "approve-once" }); expect((adapter as any).allowedAlwaysKinds.has("shell")).toBe(true); }); @@ -1162,7 +1234,7 @@ describe("CopilotSdkAdapter", () => { await adapter.replyPermission("p1", { optionId: "reject_once" }); - expect(resolve).toHaveBeenCalledWith({ kind: "denied-interactively-by-user" }); + expect(resolve).toHaveBeenCalledWith({ kind: "reject" }); }); it("emits permission.replied with the correct optionId", async () => { diff --git a/tests/unit/electron/engines/opencode/converters.test.ts b/tests/unit/electron/engines/opencode/converters.test.ts index 617844d1..00631967 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', () => { diff --git a/tests/unit/electron/gateway/engine-manager.test.ts b/tests/unit/electron/gateway/engine-manager.test.ts index 7040a6ef..ecfeba5f 100644 --- a/tests/unit/electron/gateway/engine-manager.test.ts +++ b/tests/unit/electron/gateway/engine-manager.test.ts @@ -118,7 +118,6 @@ function makeMockConv(overrides: Record = {}) { id: "conv1", engineType: "opencode" as EngineType, directory: "/dir", - title: "New session", engineSessionId: null, createdAt: 1000, updatedAt: 2000, @@ -396,6 +395,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 this repository 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 this repository read-only…"); + }); + + it("does not persist prompt-derived engine summaries", () => { + const conv = makeMockConv({ + id: "conv-title", + firstPrompt: "Read this repository metadata only: inspect package.json…", + }); + (conversationStore.findByEngineSession as any).mockReturnValue(conv); + (conversationStore.get as any).mockReturnValue(conv); + + adapterA.emit("session.updated", { + session: { + id: "engine-title", + engineType: adapterA.engineType, + title: "Read this repository metadata only: inspect package.json...", + }, + }); + + expect(conversationStore.setEngineTitle).not.toHaveBeenCalled(); + }); + + it("persists meaningful engine titles", () => { + const conv = makeMockConv({ + id: "conv-title", + firstPrompt: "看看目前修改区,应该是加了 picgo 的支持…", + }); + (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 PicGo Integration ", + }, + }); + + expect(conversationStore.setEngineTitle).toHaveBeenCalledWith( + "conv-title", + "Review PicGo Integration", + ); + }); + + it("displays engineTitle over firstPrompt", () => { + (conversationStore.list as any).mockReturnValue([ + makeMockConv({ + id: "conv-title", + firstPrompt: "看看目前修改区,应该是加了 picgo 的支持…", + engineTitle: "Review PicGo Integration", + }), + ]); + + expect(engineManager.listAllSessions()[0].title).toBe("Review PicGo Integration"); + }); + + it("displays customTitle over engineTitle", () => { + (conversationStore.list as any).mockReturnValue([ + makeMockConv({ + id: "conv-title", + firstPrompt: "看看目前修改区,应该是加了 picgo 的支持…", + engineTitle: "Review PicGo 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: "看看目前修改区,应该是加了 picgo 的支持…", + }); + (conversationStore.findByEngineSession as any).mockReturnValue(conv); + (conversationStore.get as any) + .mockReturnValueOnce(conv) + .mockReturnValueOnce({ + ...conv, + engineTitle: "Review PicGo 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 PicGo Integration", + }, + }); + + expect(emittedSessions[0].session.title).toBe("Review PicGo 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"); @@ -980,7 +1104,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", () => { @@ -1164,9 +1288,17 @@ describe("EngineManager", () => { 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 ab7b34dc..575afd92 100644 --- a/tests/unit/electron/services/conversation-store.test.ts +++ b/tests/unit/electron/services/conversation-store.test.ts @@ -85,8 +85,7 @@ describe("ConversationStore", () => { describe("Conversation CRUD", () => { it("creates conversations with metadata and handles retrieval", () => { // create() returns a ConversationMeta with correct fields. - // Titles are now derived at render time from customTitle/engineTitle/firstPrompt; - // the store no longer initializes a title in create(). + // Titles are derived at render time from customTitle/engineTitle/firstPrompt. const conv = conversationStore.create({ engineType: "claude", directory: "/projects/foo", @@ -94,7 +93,7 @@ describe("ConversationStore", () => { expect(conv.id).toMatch(/^conv_/); expect(conv.engineType).toBe("claude"); expect(conv.directory).toBe("/projects/foo"); - expect(conv.title).toBeUndefined(); + expect(conv).not.toHaveProperty("title"); expect(conv.customTitle).toBeUndefined(); expect(conv.engineTitle).toBeUndefined(); expect(conv.createdAt).toBeLessThanOrEqual(Date.now()); @@ -323,17 +322,15 @@ describe("ConversationStore", () => { describe("Persistence & Recovery", () => { it("recovers state from disk and handles corruption or version mismatches", async () => { - // survives re-initialization (persistence check) — uses legacy title field - // to verify pre-refactor data is preserved across reloads. const conv = conversationStore.create({ engineType: "opencode", directory: "/persist" }); - conversationStore.update(conv.id, { title: "Keep Me" }); + 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..7da370fb 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], + ['Read this repository metadata only...', 'Read this repository metadata only: inspect package.json…', true], + ['Read this repository metadata only: inspect package.json', 'Read this repository metadata only: inspect package.json…', true], + ['Read this repository metadata only', 'Read this repository metadata only: inspect package.json…', false], + ['Fix bug', 'Fix bug in parser and add tests for the regression…', false], + ['Review PicGo Integration', '看看目前修改区,应该是加了 picgo 的支持…', false], + ['Retrieve Copilot Session Title', '你知道你的 copilot sdk 里如何获取到一个 session 的由 copilot引擎 summary 出来的标题吗', false], + ['', 'First prompt', false], + ['Title', undefined, false], + ])('isPromptFallbackTitle("%s", "%s") returns %s', (title, firstPrompt, expected) => { + expect(isPromptFallbackTitle(title, firstPrompt)).toBe(expected); + }); +}); From 9b2b5648d4e5f8041fb3b63cb97506bfecbb3691 Mon Sep 17 00:00:00 2001 From: Duang Cheng Date: Tue, 28 Apr 2026 15:48:20 +0800 Subject: [PATCH 6/9] test(claude): remove stale title filter coverage The Claude title polling path was removed because SDK summaries are not a reliable engine title source. Drop the tests for the deleted XML-style title filter so the suite matches the supported title lifecycle. Co-Authored-By: Claude Opus 4.7 --- .../electron/engines/claude/index.test.ts | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/tests/unit/electron/engines/claude/index.test.ts b/tests/unit/electron/engines/claude/index.test.ts index 580f67c5..0bfbc11b 100644 --- a/tests/unit/electron/engines/claude/index.test.ts +++ b/tests/unit/electron/engines/claude/index.test.ts @@ -3376,42 +3376,3 @@ describe("ClaudeCodeAdapter", () => { }); }); -describe("isUsableTitle()", () => { - it("rejects empty / whitespace / undefined", async () => { - const { isUsableTitle } = await import( - "../../../../../electron/main/engines/claude/index" - ); - expect(isUsableTitle(undefined)).toBe(false); - expect(isUsableTitle("")).toBe(false); - expect(isUsableTitle(" ")).toBe(false); - }); - - it("rejects strings starting with XML/HTML-like tags", async () => { - const { isUsableTitle } = await import( - "../../../../../electron/main/engines/claude/index" - ); - expect(isUsableTitle("foo")).toBe( - false, - ); - expect(isUsableTitle("x")).toBe(false); - expect(isUsableTitle("commit")).toBe(false); - expect(isUsableTitle(" leading whitespace")).toBe(false); - }); - - it("accepts normal titles even if they contain angle brackets later", async () => { - const { isUsableTitle } = await import( - "../../../../../electron/main/engines/claude/index" - ); - expect(isUsableTitle("Refactor rendering")).toBe(true); - expect(isUsableTitle("查一下本机 ip")).toBe(true); - expect(isUsableTitle("Fix bug in parser")).toBe(true); - }); - - it("accepts a lone less-than that isn't an opening tag", async () => { - const { isUsableTitle } = await import( - "../../../../../electron/main/engines/claude/index" - ); - expect(isUsableTitle("<= 5 retries")).toBe(true); - expect(isUsableTitle("a < b comparison")).toBe(true); - }); -}); From 345875322092b29400830a8a636743c6ab6c9514 Mon Sep 17 00:00:00 2001 From: Duang Cheng Date: Tue, 28 Apr 2026 15:50:09 +0800 Subject: [PATCH 7/9] test(claude): drop empty title test diff Remove the leftover blank-line-only change from the deleted Claude title filter tests so the PR diff only includes relevant files. Co-Authored-By: Claude Opus 4.7 --- tests/unit/electron/engines/claude/index.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/electron/engines/claude/index.test.ts b/tests/unit/electron/engines/claude/index.test.ts index 0bfbc11b..692ba820 100644 --- a/tests/unit/electron/engines/claude/index.test.ts +++ b/tests/unit/electron/engines/claude/index.test.ts @@ -3375,4 +3375,3 @@ describe("ClaudeCodeAdapter", () => { }); }); }); - From 5671cde8d47dddee8a96c41b21a0a7f5a062e38a Mon Sep 17 00:00:00 2001 From: Duang Cheng Date: Wed, 29 Apr 2026 16:51:01 +0800 Subject: [PATCH 8/9] fix(opencode): support provider model schema variants Handle both OpenCode provider model shapes when converting costs and capabilities so typecheck passes with the SDK version installed in CI and local development. Co-Authored-By: Claude Opus 4.7 --- electron/main/engines/opencode/converters.ts | 43 +++++++++++++++---- .../engines/opencode/converters.test.ts | 22 ++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/electron/main/engines/opencode/converters.ts b/electron/main/engines/opencode/converters.ts index 5b2c4ab8..20c93b17 100644 --- a/electron/main/engines/opencode/converters.ts +++ b/electron/main/engines/opencode/converters.ts @@ -195,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) { @@ -202,6 +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 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, @@ -209,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: model.temperature, - reasoning: model.reasoning, - attachment: model.attachment, - toolcall: model.tool_call, + 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/tests/unit/electron/engines/opencode/converters.test.ts b/tests/unit/electron/engines/opencode/converters.test.ts index 00631967..745614aa 100644 --- a/tests/unit/electron/engines/opencode/converters.test.ts +++ b/tests/unit/electron/engines/opencode/converters.test.ts @@ -310,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'] From 4989d7e3540bd49070f0f0ab96668d77c2e7de41 Mon Sep 17 00:00:00 2001 From: Duang Cheng Date: Wed, 29 Apr 2026 17:16:16 +0800 Subject: [PATCH 9/9] test: remove real session content from title tests Replace real prompts and generated titles in title-related unit tests with neutral mock fixture text so test coverage does not expose user session content. Co-Authored-By: Claude Opus 4.7 --- .../electron/engines/copilot/index.test.ts | 12 +++---- .../electron/gateway/engine-manager.test.ts | 32 +++++++++---------- tests/unit/src/lib/session-utils.test.ts | 12 +++---- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/unit/electron/engines/copilot/index.test.ts b/tests/unit/electron/engines/copilot/index.test.ts index aa2d32fd..5e0c3daa 100644 --- a/tests/unit/electron/engines/copilot/index.test.ts +++ b/tests/unit/electron/engines/copilot/index.test.ts @@ -1337,10 +1337,10 @@ describe("CopilotSdkAdapter", () => { sessionId: "s1", role: "user", time: { created: 1 }, - parts: [{ id: "part-1", messageId: "user-1", sessionId: "s1", type: "text", text: "Read this repository metadata only: inspect package.json and tell me the package name." }], + 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: "Read this repository metadata only: inspect package.json...", + summary: "Summarize mock project metadata: inspect the sample manifest...", }); await (adapter as any).refreshSessionTitle("s1"); @@ -1356,16 +1356,16 @@ describe("CopilotSdkAdapter", () => { sessionId: "s1", role: "user", time: { created: 1 }, - parts: [{ id: "part-1", messageId: "user-1", sessionId: "s1", type: "text", text: "看看目前修改区,应该是加了 picgo 的支持" }], + parts: [{ id: "part-1", messageId: "user-1", sessionId: "s1", type: "text", text: "Please review the sample upload integration changes" }], }]); mockClientInstance.getSessionMetadata.mockResolvedValueOnce({ - summary: " Review PicGo Integration ", + summary: " Review Sample Upload Integration ", }); await (adapter as any).refreshSessionTitle("s1"); - expect(updates).toEqual([{ session: { id: "s1", engineType: "copilot", title: "Review PicGo Integration" } }]); - expect((adapter as any).sessionTitles.get("s1")).toBe("Review PicGo Integration"); + 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"); }); }); diff --git a/tests/unit/electron/gateway/engine-manager.test.ts b/tests/unit/electron/gateway/engine-manager.test.ts index fe7f0cd8..ddbe4a72 100644 --- a/tests/unit/electron/gateway/engine-manager.test.ts +++ b/tests/unit/electron/gateway/engine-manager.test.ts @@ -401,7 +401,7 @@ describe("EngineManager", () => { it("does not persist default engine placeholder titles", () => { const conv = makeMockConv({ id: "conv-title", - firstPrompt: "Inspect this repository read-only…", + firstPrompt: "Inspect the mock workspace read-only…", }); (conversationStore.findByEngineSession as any).mockReturnValue(conv); (conversationStore.get as any).mockReturnValue(conv); @@ -417,13 +417,13 @@ describe("EngineManager", () => { }); expect(conversationStore.setEngineTitle).not.toHaveBeenCalled(); - expect(emittedSessions[0].session.title).toBe("Inspect this repository read-only…"); + 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: "Read this repository metadata only: inspect package.json…", + firstPrompt: "Summarize mock project metadata: inspect the sample manifest…", }); (conversationStore.findByEngineSession as any).mockReturnValue(conv); (conversationStore.get as any).mockReturnValue(conv); @@ -432,7 +432,7 @@ describe("EngineManager", () => { session: { id: "engine-title", engineType: adapterA.engineType, - title: "Read this repository metadata only: inspect package.json...", + title: "Summarize mock project metadata: inspect the sample manifest...", }, }); @@ -442,7 +442,7 @@ describe("EngineManager", () => { it("persists meaningful engine titles", () => { const conv = makeMockConv({ id: "conv-title", - firstPrompt: "看看目前修改区,应该是加了 picgo 的支持…", + firstPrompt: "Please review the sample upload integration changes…", }); (conversationStore.findByEngineSession as any).mockReturnValue(conv); (conversationStore.get as any).mockReturnValue(conv); @@ -451,13 +451,13 @@ describe("EngineManager", () => { session: { id: "engine-title", engineType: adapterA.engineType, - title: " Review PicGo Integration ", + title: " Review Sample Upload Integration ", }, }); expect(conversationStore.setEngineTitle).toHaveBeenCalledWith( "conv-title", - "Review PicGo Integration", + "Review Sample Upload Integration", ); }); @@ -465,20 +465,20 @@ describe("EngineManager", () => { (conversationStore.list as any).mockReturnValue([ makeMockConv({ id: "conv-title", - firstPrompt: "看看目前修改区,应该是加了 picgo 的支持…", - engineTitle: "Review PicGo Integration", + firstPrompt: "Please review the sample upload integration changes…", + engineTitle: "Review Sample Upload Integration", }), ]); - expect(engineManager.listAllSessions()[0].title).toBe("Review PicGo 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: "看看目前修改区,应该是加了 picgo 的支持…", - engineTitle: "Review PicGo Integration", + firstPrompt: "Please review the sample upload integration changes…", + engineTitle: "Review Sample Upload Integration", customTitle: "My Manual Title", }), ]); @@ -500,14 +500,14 @@ describe("EngineManager", () => { it("emits the resolved engineTitle after a meaningful engine update", () => { const conv = makeMockConv({ id: "conv-title", - firstPrompt: "看看目前修改区,应该是加了 picgo 的支持…", + firstPrompt: "Please review the sample upload integration changes…", }); (conversationStore.findByEngineSession as any).mockReturnValue(conv); (conversationStore.get as any) .mockReturnValueOnce(conv) .mockReturnValueOnce({ ...conv, - engineTitle: "Review PicGo Integration", + engineTitle: "Review Sample Upload Integration", }); const emittedSessions: any[] = []; engineManager.on("session.updated" as any, (data: any) => emittedSessions.push(data)); @@ -516,11 +516,11 @@ describe("EngineManager", () => { session: { id: "engine-title", engineType: adapterA.engineType, - title: "Review PicGo Integration", + title: "Review Sample Upload Integration", }, }); - expect(emittedSessions[0].session.title).toBe("Review PicGo Integration"); + expect(emittedSessions[0].session.title).toBe("Review Sample Upload Integration"); }); it("retrieves and deletes sessions from store and engine", async () => { diff --git a/tests/unit/src/lib/session-utils.test.ts b/tests/unit/src/lib/session-utils.test.ts index 7da370fb..9f21fea4 100644 --- a/tests/unit/src/lib/session-utils.test.ts +++ b/tests/unit/src/lib/session-utils.test.ts @@ -21,12 +21,12 @@ describe('isDefaultTitle', () => { describe('isPromptFallbackTitle', () => { it.each([ ['Explain Promise.all', 'Explain Promise.all', true], - ['Read this repository metadata only...', 'Read this repository metadata only: inspect package.json…', true], - ['Read this repository metadata only: inspect package.json', 'Read this repository metadata only: inspect package.json…', true], - ['Read this repository metadata only', 'Read this repository metadata only: inspect package.json…', false], - ['Fix bug', 'Fix bug in parser and add tests for the regression…', false], - ['Review PicGo Integration', '看看目前修改区,应该是加了 picgo 的支持…', false], - ['Retrieve Copilot Session Title', '你知道你的 copilot sdk 里如何获取到一个 session 的由 copilot引擎 summary 出来的标题吗', false], + ['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) => {