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
This commit is contained in:
Ananovo
2026-07-19 12:23:39 +08:00
committed by GitHub
parent 95eee432d4
commit f1b5ed5e5d
10 changed files with 200 additions and 5 deletions
+52 -4
View File
@@ -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<Record<string, unknown>>,
@@ -35,6 +36,7 @@ import { claudeLocalLauncher } from './claudeLocalLauncher'
function createSessionStub() {
const sentMessages: Array<Record<string, unknown>> = []
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<string, unknown>) => { 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 () => {
+10 -1
View File
@@ -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
+39
View File
@@ -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');
+5
View File
@@ -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
+4
View File
@@ -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 });
+12
View File
@@ -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
+6
View File
@@ -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.
+2
View File
@@ -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 {
@@ -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)
})
})
@@ -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<ApiSessionClient, 'updateMetadata'>,
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
}