mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(kimi): sync native local session titles
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user