fix(kimi): sync native local session titles

This commit is contained in:
weishu
2026-07-27 10:19:25 +08:00
parent 84323496c6
commit 042ffced8d
10 changed files with 440 additions and 24 deletions
+1
View File
@@ -19,6 +19,7 @@ describe('registerAcpSessionTitleSync', () => {
listener!({ sessionId: 'session-1', title: '' });
listener!({ sessionId: 'session-1', title: null });
listener!({ sessionId: 'session-1', title: 'Untitled' });
listener!({ sessionId: 'session-1', title: 'New Session' });
listener!({ sessionId: 'session-1', title: 'New session - 2026-07-12T15:30:03.251Z' });
expect(sendClaudeSessionMessage).toHaveBeenCalledTimes(1);
+18 -16
View File
@@ -1,28 +1,18 @@
import { randomUUID } from 'node:crypto';
import type { ApiSessionClient } from '@/api/apiSession';
import type { AcpSdkBackend } from '@/agent/backends/acp';
import { normalizeNativeSessionTitle } from '@/agent/nativeSessionTitle';
type AcpSessionTitleBackend = Pick<AcpSdkBackend, 'setSessionInfoUpdateListener'>;
type AcpSessionTitleClient = Pick<ApiSessionClient, 'sendClaudeSessionMessage'>;
function isPlaceholderTitle(title: string): boolean {
return title === 'Untitled'
|| /^(?:New|Child) session - \d{4}-\d{2}-\d{2}T/.test(title);
}
/** Syncs agent-generated ACP session titles into HAPI session metadata. */
export function registerAcpSessionTitleSync(
backend: AcpSessionTitleBackend,
client: AcpSessionTitleClient
): void {
/** Creates a normalized, deduplicated native-title sink for a HAPI session. */
function createSessionTitleSync(client: AcpSessionTitleClient): (title: unknown) => void {
let lastTitle: string | null = null;
backend.setSessionInfoUpdateListener(({ title }) => {
if (typeof title !== 'string') {
return;
}
const normalizedTitle = title.trim();
if (!normalizedTitle || isPlaceholderTitle(normalizedTitle) || normalizedTitle === lastTitle) {
return (title) => {
const normalizedTitle = normalizeNativeSessionTitle(title);
if (!normalizedTitle || normalizedTitle === lastTitle) {
return;
}
lastTitle = normalizedTitle;
@@ -31,5 +21,17 @@ export function registerAcpSessionTitleSync(
summary: normalizedTitle,
leafUuid: randomUUID()
});
};
}
/** Syncs agent-generated ACP session titles into HAPI session metadata. */
export function registerAcpSessionTitleSync(
backend: AcpSessionTitleBackend,
client: AcpSessionTitleClient
): void {
const syncTitle = createSessionTitleSync(client);
backend.setSessionInfoUpdateListener(({ title }) => {
syncTitle(title);
});
}
+52
View File
@@ -0,0 +1,52 @@
import type { ApiSessionClient } from '@/api/apiSession';
import type { Metadata } from '@/api/types';
type NativeSessionTitleMetadataClient = Pick<ApiSessionClient, 'getMetadata' | 'updateMetadata'>;
export function normalizeNativeSessionTitle(title: unknown): string | null {
if (typeof title !== 'string') {
return null;
}
const normalizedTitle = title.trim();
if (!normalizedTitle
|| /^(?:Untitled|New Session)$/i.test(normalizedTitle)
|| /^(?:New|Child) session - \d{4}-\d{2}-\d{2}T/i.test(normalizedTitle)) {
return null;
}
return normalizedTitle;
}
/** Syncs a native agent title into HAPI metadata without creating a chat row. */
export function createNativeSessionTitleMetadataSync(
client: NativeSessionTitleMetadataClient
): (title: unknown) => void {
let lastTitle: string | null = null;
return (title) => {
const normalizedTitle = normalizeNativeSessionTitle(title);
if (!normalizedTitle || normalizedTitle === lastTitle) {
return;
}
lastTitle = normalizedTitle;
if (metadataHasTitle(client.getMetadata(), normalizedTitle)) {
return;
}
client.updateMetadata((metadata) => {
if (metadataHasTitle(metadata, normalizedTitle)) {
return metadata;
}
return {
...metadata,
summary: {
text: normalizedTitle,
updatedAt: Date.now()
}
};
});
};
}
function metadataHasTitle(metadata: Readonly<Metadata> | null, title: string): boolean {
return metadata?.name?.trim() === title || metadata?.summary?.text.trim() === title;
}
+4
View File
@@ -423,6 +423,10 @@ export class ApiSessionClient extends EventEmitter {
return this.state
}
getMetadata(): Readonly<Metadata> | null {
return this.metadata
}
isPending(): boolean {
return this.state === 'pending' || this.state === 'materializing'
}
+150
View File
@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Metadata } from '@/api/types';
const harness = vi.hoisted(() => ({
locatorOptions: null as null | {
onLocated: (located: { sessionId: string; wirePath: string; statePath: string }) => void;
},
titleWatcherOptions: null as null | {
statePath: string;
onTitle: (title: string) => void;
},
titlesToEmit: [] as string[],
locatorCleanup: vi.fn(async () => {}),
scannerCleanup: vi.fn(async () => {}),
titleWatcherCleanup: vi.fn(async () => {})
}));
vi.mock('./utils/kimiWireLocator', () => ({
createKimiWireLocator: (options: typeof harness.locatorOptions) => {
harness.locatorOptions = options;
return {
ready: Promise.resolve(),
cleanup: harness.locatorCleanup
};
}
}));
vi.mock('./utils/kimiWireScanner', () => ({
createKimiWireScanner: async () => ({ cleanup: harness.scannerCleanup }),
convertKimiWireEvent: () => null
}));
vi.mock('./utils/kimiSessionTitleWatcher', () => ({
createKimiSessionTitleWatcher: async (options: NonNullable<typeof harness.titleWatcherOptions>) => {
harness.titleWatcherOptions = options;
for (const title of harness.titlesToEmit) {
options.onTitle(title);
}
return { cleanup: harness.titleWatcherCleanup };
}
}));
vi.mock('@/modules/common/launcher/BaseLocalLauncher', () => ({
BaseLocalLauncher: class {
async run(): Promise<'exit'> {
harness.locatorOptions?.onLocated({
sessionId: 'session_native',
wirePath: '/tmp/kimi/wire.jsonl',
statePath: '/tmp/kimi/state.json'
});
await Promise.resolve();
return 'exit';
}
}
}));
import { kimiLocalLauncher } from './kimiLocalLauncher';
describe('kimiLocalLauncher title sync', () => {
afterEach(() => {
harness.locatorOptions = null;
harness.titleWatcherOptions = null;
harness.titlesToEmit = [];
harness.locatorCleanup.mockClear();
harness.scannerCleanup.mockClear();
harness.titleWatcherCleanup.mockClear();
});
it('syncs the native state title and cleans up both session watchers', async () => {
const sentMessages: unknown[] = [];
const foundSessionIds: string[] = [];
let metadata: Metadata = { path: '/tmp/workspace', host: 'localhost' };
const updateMetadata = vi.fn((handler: (current: Metadata) => Metadata) => {
metadata = handler(metadata);
});
harness.titlesToEmit = ['New Session', ' Native Kimi Title ', 'Native Kimi Title'];
const session = {
path: '/tmp/workspace',
sessionId: null,
startedBy: 'terminal' as const,
startingMode: 'local' as const,
queue: {},
client: {
rpcHandlerManager: {},
getMetadata: () => metadata,
updateMetadata,
sendClaudeSessionMessage: (message: unknown) => sentMessages.push(message)
},
getPermissionMode: () => 'default',
onSessionFound: (sessionId: string) => foundSessionIds.push(sessionId),
sendUserMessage: () => {},
sendAgentMessage: () => {},
sendSessionEvent: () => {},
recordLocalLaunchFailure: () => {}
};
await expect(kimiLocalLauncher(session as never, {})).resolves.toBe('exit');
expect(foundSessionIds).toEqual(['session_native']);
expect(harness.titleWatcherOptions?.statePath).toBe('/tmp/kimi/state.json');
expect(sentMessages).toEqual([]);
expect(metadata).toEqual({
path: '/tmp/workspace',
host: 'localhost',
summary: {
text: 'Native Kimi Title',
updatedAt: expect.any(Number)
}
});
expect(updateMetadata).toHaveBeenCalledTimes(1);
expect(harness.locatorCleanup).toHaveBeenCalledTimes(1);
expect(harness.scannerCleanup).toHaveBeenCalledTimes(1);
expect(harness.titleWatcherCleanup).toHaveBeenCalledTimes(1);
});
it('does not update metadata when the resumed HAPI title already matches', async () => {
const metadata: Metadata = {
path: '/tmp/workspace',
host: 'localhost',
summary: { text: 'Native Kimi Title', updatedAt: 123 }
};
const updateMetadata = vi.fn();
const sendClaudeSessionMessage = vi.fn();
harness.titlesToEmit = ['Native Kimi Title'];
const session = {
path: '/tmp/workspace',
sessionId: 'session_native',
startedBy: 'terminal' as const,
startingMode: 'local' as const,
queue: {},
client: {
rpcHandlerManager: {},
getMetadata: () => metadata,
updateMetadata,
sendClaudeSessionMessage
},
getPermissionMode: () => 'default',
onSessionFound: () => {},
sendUserMessage: () => {},
sendAgentMessage: () => {},
sendSessionEvent: () => {},
recordLocalLaunchFailure: () => {}
};
await kimiLocalLauncher(session as never, {});
expect(updateMetadata).not.toHaveBeenCalled();
expect(sendClaudeSessionMessage).not.toHaveBeenCalled();
});
});
+39 -5
View File
@@ -3,8 +3,10 @@ import { kimiLocal } from './kimiLocal';
import { KimiSession } from './session';
import type { PermissionMode } from './types';
import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher';
import { createNativeSessionTitleMetadataSync } from '@/agent/nativeSessionTitle';
import { createKimiWireLocator, type KimiWireLocator } from './utils/kimiWireLocator';
import { convertKimiWireEvent, createKimiWireScanner, type KimiWireScanner } from './utils/kimiWireScanner';
import { createKimiSessionTitleWatcher, type KimiSessionTitleWatcher } from './utils/kimiSessionTitleWatcher';
function mapApprovalMode(mode: PermissionMode | undefined): { yolo: boolean; plan: boolean } {
if (!mode || mode === 'default' || mode === 'read-only') {
@@ -22,13 +24,15 @@ export async function kimiLocalLauncher(
model?: string;
}
): Promise<'switch' | 'exit'> {
// Local mode spawns the kimi TUI directly, so the only way to mirror the
// terminal conversation to hub/web is to watch the wire.jsonl journal the
// kimi-code process writes (same role as the codex transcript scanner).
// Local mode spawns the kimi TUI directly. Mirror conversation events from
// wire.jsonl and Kimi's authoritative native title from state.json.
const startupTimestampMs = Date.now();
let shuttingDown = false;
let scanner: KimiWireScanner | null = null;
let titleWatcher: KimiSessionTitleWatcher | null = null;
let pendingScannerSetup: Promise<void> | null = null;
let pendingTitleWatcherSetup: Promise<void> | null = null;
const syncTitle = createNativeSessionTitleMetadataSync(session.client);
const attachWireScanner = (wirePath: string): Promise<void> => {
const setup = (async () => {
@@ -65,19 +69,41 @@ export async function kimiLocalLauncher(
return pendingScannerSetup;
};
const attachTitleWatcher = (statePath: string): Promise<void> => {
const setup = (async () => {
const created = await createKimiSessionTitleWatcher({
statePath,
onTitle: syncTitle
});
if (shuttingDown) {
await created.cleanup();
return;
}
titleWatcher = created;
logger.debug(`[kimi-local]: Attached title watcher to ${statePath}`);
})();
pendingTitleWatcherSetup = setup.catch((error) => {
logger.warn(`[kimi-local]: Title watcher setup failed for ${statePath}`, error);
}).finally(() => {
pendingTitleWatcherSetup = null;
});
return pendingTitleWatcherSetup;
};
const locator: KimiWireLocator = createKimiWireLocator({
cwd: session.path,
startupTimestampMs,
resumeSessionId: session.sessionId,
onLocated: ({ sessionId, wirePath }) => {
onLocated: ({ sessionId, wirePath, statePath }) => {
if (shuttingDown) {
return;
}
session.onSessionFound(sessionId);
void attachWireScanner(wirePath);
void attachTitleWatcher(statePath);
},
onAmbiguous: (sessionIds) => {
logger.warn(`[kimi-local]: Multiple fresh kimi sessions found (${sessionIds.join(', ')}); transcript sync disabled for this launch`);
logger.warn(`[kimi-local]: Multiple fresh kimi sessions found (${sessionIds.join(', ')}); session sync disabled for this launch`);
}
});
@@ -119,10 +145,18 @@ export async function kimiLocalLauncher(
if (pendingScannerSetup) {
await pendingScannerSetup;
}
if (pendingTitleWatcherSetup) {
await pendingTitleWatcherSetup;
}
const activeScanner = scanner as KimiWireScanner | null;
if (activeScanner) {
await activeScanner.cleanup();
scanner = null;
}
const activeTitleWatcher = titleWatcher as KimiSessionTitleWatcher | null;
if (activeTitleWatcher) {
await activeTitleWatcher.cleanup();
titleWatcher = null;
}
}
}
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createKimiSessionTitleWatcher, type KimiSessionTitleWatcher } from './kimiSessionTitleWatcher';
describe('kimiSessionTitleWatcher', () => {
let testDir: string;
let statePath: string;
let watcher: KimiSessionTitleWatcher | null = null;
beforeEach(async () => {
testDir = join(tmpdir(), `kimi-title-${Date.now()}-${Math.random().toString(36).slice(2)}`);
statePath = join(testDir, 'state.json');
await mkdir(testDir, { recursive: true });
});
afterEach(async () => {
if (watcher) {
await watcher.cleanup();
watcher = null;
}
if (existsSync(testDir)) {
await rm(testDir, { recursive: true, force: true });
}
});
it('emits an existing resume title immediately and later title changes once', async () => {
await writeFile(statePath, JSON.stringify({ title: 'Existing title' }));
const titles: string[] = [];
watcher = await createKimiSessionTitleWatcher({
statePath,
intervalMs: 20,
onTitle: (title) => titles.push(title)
});
expect(titles).toEqual(['Existing title']);
await writeFile(statePath, '{');
await new Promise((resolve) => setTimeout(resolve, 60));
expect(titles).toEqual(['Existing title']);
await writeFile(statePath, JSON.stringify({ title: 'Renamed title' }));
await vi.waitFor(() => expect(titles).toEqual(['Existing title', 'Renamed title']));
await writeFile(statePath, JSON.stringify({ title: 'Renamed title', updatedAt: Date.now() }));
await new Promise((resolve) => setTimeout(resolve, 60));
expect(titles).toEqual(['Existing title', 'Renamed title']);
});
it('detects a title when a new session writes state later and stops after cleanup', async () => {
const titles: string[] = [];
watcher = await createKimiSessionTitleWatcher({
statePath,
intervalMs: 20,
onTitle: (title) => titles.push(title)
});
await writeFile(statePath, JSON.stringify({ title: 'New Session' }));
await vi.waitFor(() => expect(titles).toEqual(['New Session']));
await writeFile(statePath, JSON.stringify({ title: 'First prompt title' }));
await vi.waitFor(() => expect(titles).toEqual(['New Session', 'First prompt title']));
await watcher.cleanup();
watcher = null;
await writeFile(statePath, JSON.stringify({ title: 'After cleanup' }));
await new Promise((resolve) => setTimeout(resolve, 60));
expect(titles).toEqual(['New Session', 'First prompt title']);
});
});
@@ -0,0 +1,92 @@
import { readFile } from 'node:fs/promises';
const DEFAULT_TITLE_WATCH_INTERVAL_MS = 500;
export type KimiSessionTitleWatcher = {
cleanup: () => Promise<void>;
};
type KimiSessionTitleWatcherOptions = {
statePath: string;
onTitle: (title: string) => void;
intervalMs?: number;
};
/** Polls Kimi's authoritative state.json title, including atomic replacements. */
export async function createKimiSessionTitleWatcher(
options: KimiSessionTitleWatcherOptions
): Promise<KimiSessionTitleWatcher> {
const watcher = new KimiSessionTitleWatcherImpl(options);
await watcher.start();
return {
cleanup: async () => {
await watcher.cleanup();
}
};
}
class KimiSessionTitleWatcherImpl {
private readonly statePath: string;
private readonly onTitle: KimiSessionTitleWatcherOptions['onTitle'];
private readonly intervalMs: number;
private interval: ReturnType<typeof setInterval> | null = null;
private scanPromise: Promise<void> | null = null;
private lastTitle: string | null = null;
private stopped = false;
constructor(options: KimiSessionTitleWatcherOptions) {
this.statePath = options.statePath;
this.onTitle = options.onTitle;
this.intervalMs = options.intervalMs ?? DEFAULT_TITLE_WATCH_INTERVAL_MS;
}
async start(): Promise<void> {
await this.scan();
if (this.stopped) {
return;
}
this.interval = setInterval(() => void this.scan(), this.intervalMs);
this.interval.unref?.();
}
async cleanup(): Promise<void> {
this.stopped = true;
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
await this.scanPromise?.catch(() => {});
}
private async scan(): Promise<void> {
if (this.stopped || this.scanPromise) {
return this.scanPromise ?? Promise.resolve();
}
this.scanPromise = this.readTitle();
try {
await this.scanPromise;
} finally {
this.scanPromise = null;
}
}
private async readTitle(): Promise<void> {
try {
const raw = await readFile(this.statePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed) || typeof parsed.title !== 'string' || parsed.title === this.lastTitle) {
return;
}
this.lastTitle = parsed.title;
if (!this.stopped) {
this.onTitle(parsed.title);
}
} catch {
// state.json can be absent or temporarily invalid while Kimi writes it.
}
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
import {
createKimiWireLocator,
encodeKimiWorkDirKey,
getKimiStatePath,
getKimiWirePath,
type LocatedKimiWire
} from './kimiWireLocator';
@@ -74,6 +75,7 @@ describe('kimiWireLocator', () => {
expect(located).toHaveLength(1);
expect(located[0]?.sessionId).toBe('session_aaa-bbb');
expect(located[0]?.wirePath).toBe(getKimiWirePath(join(homeDir, 'sessions', encodeKimiWorkDirKey(workDir), 'session_aaa-bbb')));
expect(located[0]?.statePath).toBe(getKimiStatePath(join(homeDir, 'sessions', encodeKimiWorkDirKey(workDir), 'session_aaa-bbb')));
} finally {
await locator.cleanup();
}
+9 -3
View File
@@ -7,6 +7,7 @@ import { getKimiCodeHome } from './config';
export type LocatedKimiWire = {
sessionId: string;
wirePath: string;
statePath: string;
};
export type KimiWireLocator = {
@@ -63,10 +64,14 @@ export function getKimiWirePath(sessionDir: string): string {
return join(sessionDir, 'agents', 'main', 'wire.jsonl');
}
export function getKimiStatePath(sessionDir: string): string {
return join(sessionDir, 'state.json');
}
/**
* Polls the kimi-code session storage for the session the locally spawned
* `kimi` process just created in this working directory, and resolves with
* its wire transcript path. Mirrors the codex transcript locator: sessions
* its wire transcript and state paths. Mirrors the codex transcript locator: sessions
* created before hapi's launch are ignored; multiple fresh candidates are
* treated as ambiguous rather than attaching to the wrong session.
*/
@@ -202,6 +207,7 @@ class KimiWireLocatorImpl {
const sessionDir = join(this.workspaceDir, entry.name);
const wirePath = getKimiWirePath(sessionDir);
const statePath = getKimiStatePath(sessionDir);
const wireStats = await stat(wirePath).catch(() => null);
if (!wireStats || !wireStats.isFile()) {
continue;
@@ -223,14 +229,14 @@ class KimiWireLocatorImpl {
}
}
candidates.push({ sessionId: entry.name, wirePath });
candidates.push({ sessionId: entry.name, wirePath, statePath });
}
return candidates;
}
private async matchesWorkDir(sessionDir: string): Promise<boolean> {
try {
const raw = await readFile(join(sessionDir, 'state.json'), 'utf8');
const raw = await readFile(getKimiStatePath(sessionDir), 'utf8');
const parsed = JSON.parse(raw) as { workDir?: unknown };
if (typeof parsed.workDir !== 'string' || parsed.workDir.length === 0) {
return true;