From f1b5ed5e5d2207c3db13ace2193b5c4cbb128036 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Sun, 19 Jul 2026 12:23:39 +0800 Subject: [PATCH] fix(claude): preserve native titles and add a remote fallback (#1080) * fix(claude): preserve native titles and add remote fallback * fix(claude): write fallback titles as metadata only --- cli/src/claude/claudeLocalLauncher.test.ts | 56 +++++++++++++++++-- cli/src/claude/claudeLocalLauncher.ts | 11 +++- cli/src/claude/claudeRemote.test.ts | 39 +++++++++++++ cli/src/claude/claudeRemote.ts | 5 ++ cli/src/claude/claudeRemoteLauncher.ts | 4 ++ cli/src/claude/types.test.ts | 12 ++++ cli/src/claude/types.ts | 6 ++ cli/src/claude/utils/sessionScanner.ts | 2 + .../claude/utils/sessionTitleFallback.test.ts | 38 +++++++++++++ cli/src/claude/utils/sessionTitleFallback.ts | 32 +++++++++++ 10 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 cli/src/claude/utils/sessionTitleFallback.test.ts create mode 100644 cli/src/claude/utils/sessionTitleFallback.ts diff --git a/cli/src/claude/claudeLocalLauncher.test.ts b/cli/src/claude/claudeLocalLauncher.test.ts index a4e855aa..9267e29a 100644 --- a/cli/src/claude/claudeLocalLauncher.test.ts +++ b/cli/src/claude/claudeLocalLauncher.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Metadata } from '@/api/types' const harness = vi.hoisted(() => ({ launches: [] as Array>, @@ -35,6 +36,7 @@ import { claudeLocalLauncher } from './claudeLocalLauncher' function createSessionStub() { const sentMessages: Array> = [] + let metadata: Metadata = { path: '/tmp/test', host: 'localhost' } return { session: { sessionId: 'test-session', @@ -49,6 +51,9 @@ function createSessionStub() { queue: { size: () => 0, reset: () => {}, setOnMessage: () => {} }, client: { sendClaudeSessionMessage: (msg: Record) => { sentMessages.push(msg) }, + updateMetadata: (handler: (current: Metadata) => Metadata) => { + metadata = handler(metadata) + }, rpcHandlerManager: { registerHandler: () => {} } }, addSessionFoundCallback: () => {}, @@ -56,7 +61,9 @@ function createSessionStub() { consumeOneTimeFlags: () => {}, recordLocalLaunchFailure: () => {} }, - sentMessages + sentMessages, + getMetadata: () => metadata, + setMetadata: (value: Metadata) => { metadata = value } } } @@ -66,13 +73,54 @@ describe('claudeLocalLauncher message filtering', () => { harness.scannerOnMessage = null }) - it('filters out summary messages', async () => { - const { session, sentMessages } = createSessionStub() + it('uses Claude Code summary messages as a title fallback', async () => { + const { session, sentMessages, getMetadata } = createSessionStub() await claudeLocalLauncher(session as never) - harness.scannerOnMessage!({ type: 'summary', leafUuid: '1' }) + harness.scannerOnMessage!({ type: 'summary', summary: 'Native title', leafUuid: '1' }) expect(sentMessages).toHaveLength(0) + expect(getMetadata().summary?.text).toBe('Native title') + }) + + it('converts Claude Code ai-title metadata into a HAPI title', async () => { + const { session, sentMessages, getMetadata } = createSessionStub() + await claudeLocalLauncher(session as never) + + harness.scannerOnMessage!({ + type: 'ai-title', + aiTitle: '根据交接文档部署 HAPI 服务', + sessionId: 'test-session' + }) + + expect(sentMessages).toHaveLength(0) + expect(getMetadata().summary?.text).toBe('根据交接文档部署 HAPI 服务') + }) + + it('does not replace an existing HAPI title with ai-title metadata', async () => { + const { session, sentMessages, getMetadata, setMetadata } = createSessionStub() + setMetadata({ path: '/tmp/test', host: 'localhost', name: 'Manual title' }) + await claudeLocalLauncher(session as never) + + harness.scannerOnMessage!({ type: 'ai-title', aiTitle: 'Native title' }) + + expect(sentMessages).toHaveLength(0) + expect(getMetadata()).toEqual({ path: '/tmp/test', host: 'localhost', name: 'Manual title' }) + }) + + it('does not replace an existing HAPI title with a native summary', async () => { + const { session, sentMessages, getMetadata, setMetadata } = createSessionStub() + setMetadata({ + path: '/tmp/test', + host: 'localhost', + summary: { text: 'Existing title', updatedAt: 1 } + }) + await claudeLocalLauncher(session as never) + + harness.scannerOnMessage!({ type: 'summary', summary: 'Native title', leafUuid: '1' }) + + expect(sentMessages).toHaveLength(0) + expect(getMetadata().summary?.text).toBe('Existing title') }) it('filters out invisible system messages', async () => { diff --git a/cli/src/claude/claudeLocalLauncher.ts b/cli/src/claude/claudeLocalLauncher.ts index 291569cd..88732790 100644 --- a/cli/src/claude/claudeLocalLauncher.ts +++ b/cli/src/claude/claudeLocalLauncher.ts @@ -3,6 +3,7 @@ import { Session } from "./session"; import { createSessionScanner } from "./utils/sessionScanner"; import { isClaudeChatVisibleMessage } from "./utils/chatVisibility"; import { BaseLocalLauncher } from "@/modules/common/launcher/BaseLocalLauncher"; +import { applySessionTitleFallback } from './utils/sessionTitleFallback'; export async function claudeLocalLauncher(session: Session): Promise<'switch' | 'exit'> { @@ -11,8 +12,16 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | sessionId: session.sessionId, workingDirectory: session.path, onMessage: (message) => { - // Block SDK summary messages - we generate our own + // Preserve the AI-generated title emitted by Claude Code's native + // interactive CLI. It is metadata, not a visible chat message. + if (message.type === 'ai-title') { + applySessionTitleFallback(session.client, message.aiTitle) + return + } + // Claude Code writes its native session title as a summary. Use it as + // a fallback for older transcript formats. if (message.type === 'summary') { + applySessionTitleFallback(session.client, message.summary) return } // Filter out internal meta messages (e.g. skill injections) and diff --git a/cli/src/claude/claudeRemote.test.ts b/cli/src/claude/claudeRemote.test.ts index 04afb0c2..5aebb77a 100644 --- a/cli/src/claude/claudeRemote.test.ts +++ b/cli/src/claude/claudeRemote.test.ts @@ -69,6 +69,45 @@ async function waitFor(condition: () => boolean, timeoutMs = 300, intervalMs = 1 } describe('claudeRemote async message handling', () => { + it('reports the initial normal message once after the first result', async () => { + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + const { claudeRemote } = await import('./claudeRemote'); + const onFirstResult = vi.fn(); + + queryMock.mockReturnValueOnce(createAsyncStream([ + { type: 'result', subtype: 'success' } as unknown as SDKMessage, + { type: 'result', subtype: 'success' } as unknown as SDKMessage + ])); + + let nextCallCount = 0; + try { + await claudeRemote({ + sessionId: 'session-1', + path: process.cwd(), + mcpServers: {}, + claudeEnvVars: {}, + claudeArgs: [], + allowedTools: [], + hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => nextCallCount++ === 0 + ? { message: 'Review this project', mode: { permissionMode: 'default' } } + : null, + onReady: () => {}, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: () => {}, + onFirstResult + }); + + expect(onFirstResult).toHaveBeenCalledTimes(1); + expect(onFirstResult).toHaveBeenCalledWith('Review this project'); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + }); + it('continues consuming assistant messages even when next user message is pending', async () => { const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); const { claudeRemote } = await import('./claudeRemote'); diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 4b97294f..2e85ee15 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -34,6 +34,7 @@ export async function claudeRemote(opts: { onSessionFound: (id: string) => void, onThinkingChange?: (thinking: boolean) => void, onMessage: (message: SDKMessage) => void, + onFirstResult?: (initialMessage: string) => void, onCompletionEvent?: (message: string) => void, onSessionReset?: () => void }) { @@ -282,6 +283,10 @@ export async function claudeRemote(opts: { `(nextInFlight=${nextMessageFetchInFlight}, inputEnded=${inputEnded})` ); + if (resultSeq === 1 && specialCommand.type === null) { + opts.onFirstResult?.(initial.message); + } + // Send completion messages if (isCompactCommand) { const completion = compactFailure diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index 1df84bdf..b3f39ef1 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -12,6 +12,7 @@ import { PLAN_FAKE_REJECT } from "./sdk/prompts"; import { EnhancedMode } from "./loop"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; import type { ClaudePermissionMode } from "@hapi/protocol/types"; +import { applySessionTitleFallback } from './utils/sessionTitleFallback'; import { RemoteLauncherBase, type RemoteLauncherDisplayContext, @@ -362,6 +363,9 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { claudeEnvVars: session.claudeEnvVars, claudeArgs: session.claudeArgs, onMessage, + onFirstResult: (initialMessage) => { + applySessionTitleFallback(session.client, initialMessage); + }, onCompletionEvent: (message: string) => { logger.debug(`[remote]: Completion event: ${message}`); session.client.sendSessionEvent({ type: 'message', message }); diff --git a/cli/src/claude/types.test.ts b/cli/src/claude/types.test.ts index 97a18812..62e7490a 100644 --- a/cli/src/claude/types.test.ts +++ b/cli/src/claude/types.test.ts @@ -2,6 +2,18 @@ import { describe, it, expect } from "vitest"; import { RawJSONLinesSchema } from "./types"; describe("RawJSONLinesSchema", () => { + it("accepts Claude Code native ai-title events", () => { + expect(RawJSONLinesSchema.parse({ + type: "ai-title", + aiTitle: "根据交接文档部署 HAPI 服务", + sessionId: "session-1" + })).toEqual({ + type: "ai-title", + aiTitle: "根据交接文档部署 HAPI 服务", + sessionId: "session-1" + }); + }); + describe("system / turn_duration record", () => { it("preserves messageId so the web reducer can match the duration to the right block", () => { // Claude code emits turn_duration as a system record carrying the diff --git a/cli/src/claude/types.ts b/cli/src/claude/types.ts index 4fe88945..e0cb3669 100644 --- a/cli/src/claude/types.ts +++ b/cli/src/claude/types.ts @@ -67,6 +67,12 @@ export const RawJSONLinesSchema = z.discriminatedUnion("type", [ leafUuid: z.string(), }), + // Claude Code's native interactive CLI title event. + RawJSONLinesBaseSchema.extend({ + type: z.literal("ai-title"), + aiTitle: z.string(), + }), + // System message - validates uuid and subtype data used by the UI. // `passthrough` preserves fields like `messageId` on `turn_duration` and any // future system subtype data the hub forwards to the web reducer. diff --git a/cli/src/claude/utils/sessionScanner.ts b/cli/src/claude/utils/sessionScanner.ts index 7417cc6c..b85ec12a 100644 --- a/cli/src/claude/utils/sessionScanner.ts +++ b/cli/src/claude/utils/sessionScanner.ts @@ -161,6 +161,8 @@ function messageKey(message: RawJSONLines): string { return message.uuid; } else if (message.type === 'summary') { return 'summary: ' + message.leafUuid + ': ' + message.summary; + } else if (message.type === 'ai-title') { + return 'ai-title: ' + message.aiTitle; } else if (message.type === 'system') { return message.uuid; } else { diff --git a/cli/src/claude/utils/sessionTitleFallback.test.ts b/cli/src/claude/utils/sessionTitleFallback.test.ts new file mode 100644 index 00000000..e059170b --- /dev/null +++ b/cli/src/claude/utils/sessionTitleFallback.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { applySessionTitleFallback, createSessionTitleFallback } from './sessionTitleFallback' + +describe('Claude session title fallback', () => { + it('normalizes whitespace and truncates long initial messages', () => { + expect(createSessionTitleFallback(' Review\n\nthis project ')).toBe('Review this project') + + const title = createSessionTitleFallback('a'.repeat(100)) + expect(title).toBe('a'.repeat(79) + '…') + }) + + it('writes a summary when no title exists', () => { + const updateMetadata = vi.fn((handler) => handler({})) + + expect(applySessionTitleFallback({ + updateMetadata + }, 'Review this project')).toBe(true) + + expect(updateMetadata).toHaveReturnedWith(expect.objectContaining({ + summary: expect.objectContaining({ text: 'Review this project' }) + })) + }) + + it('does not replace an existing title or use an empty message', () => { + const manualMetadata = { name: 'Manual title' } + const updateMetadata = vi.fn((handler) => handler(manualMetadata)) + + expect(applySessionTitleFallback({ + updateMetadata + }, 'Review this project')).toBe(true) + expect(applySessionTitleFallback({ + updateMetadata + }, ' \n ')).toBe(false) + + expect(updateMetadata).toHaveBeenCalledTimes(1) + expect(updateMetadata).toHaveReturnedWith(manualMetadata) + }) +}) diff --git a/cli/src/claude/utils/sessionTitleFallback.ts b/cli/src/claude/utils/sessionTitleFallback.ts new file mode 100644 index 00000000..e5ef81f3 --- /dev/null +++ b/cli/src/claude/utils/sessionTitleFallback.ts @@ -0,0 +1,32 @@ +import type { ApiSessionClient } from '@/api/apiSession' + +const MAX_FALLBACK_TITLE_LENGTH = 80 + +export function createSessionTitleFallback(message: string): string | null { + const normalized = message.replace(/\s+/g, ' ').trim() + if (!normalized) return null + if (normalized.length <= MAX_FALLBACK_TITLE_LENGTH) return normalized + + return normalized.slice(0, MAX_FALLBACK_TITLE_LENGTH - 1).trimEnd() + '…' +} + +export function applySessionTitleFallback( + client: Pick, + message: string +): boolean { + const title = createSessionTitleFallback(message) + if (!title) return false + + client.updateMetadata((metadata) => { + if (metadata.name?.trim() || metadata.summary?.text.trim()) return metadata + + return { + ...metadata, + summary: { + text: title, + updatedAt: Date.now() + } + } + }) + return true +}