mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user