From 0c3e1e4cfafa68768dc95f48748acc35cadb6cee Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 14 Jul 2026 17:17:18 +0800 Subject: [PATCH 01/10] fix(server): report CLI version as server_version - kap-server: accept opts.version in startServer, reported as server_version (/meta, OpenAPI, session exports, lock registry, default User-Agent); defaults to its own package version - kimi-code: pass the CLI product version when starting the server - add boot test covering /api/v1/meta, lock file, and User-Agent --- .changeset/server-version-from-cli.md | 5 ++++ apps/kimi-code/src/cli/sub/server/run.ts | 3 +++ packages/kap-server/src/start.ts | 11 +++++++-- packages/kap-server/test/boot.test.ts | 29 ++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .changeset/server-version-from-cli.md diff --git a/.changeset/server-version-from-cli.md b/.changeset/server-version-from-cli.md new file mode 100644 index 0000000000..17e7f6ff50 --- /dev/null +++ b/.changeset/server-version-from-cli.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix `kimi server` reporting the internal server package version instead of the CLI version in its metadata; the web UI settings now show the CLI version. diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts index 6a903123dd..b7a9ffb033 100644 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -401,6 +401,9 @@ async function runServerInProcess( const v2 = await startServer({ host: options.host, port: options.port, + // Report the CLI's product version as `server_version` (/meta, web UI) + // rather than kap-server's private package version. + version, logLevel: options.logLevel, logger, debugEndpoints: options.debugEndpoints, diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 1284d0fa20..1b46e060c8 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -115,6 +115,13 @@ export interface ServerStartOptions { * the API server without the web UI. */ readonly webAssetsDir?: string; + /** + * Host product version, reported as `server_version` (GET /api/v1/meta), in + * the OpenAPI document, session exports, the lock / instance registry, and + * the default User-Agent. Defaults to kap-server's own package version; + * embedding hosts (the CLI) should pass their own version. + */ + readonly version?: string; } export interface RunningServer { @@ -161,7 +168,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { const { default: swagger } = await import('@fastify/swagger'); diff --git a/packages/kap-server/test/boot.test.ts b/packages/kap-server/test/boot.test.ts index 3db723cab1..f2a65b4334 100644 --- a/packages/kap-server/test/boot.test.ts +++ b/packages/kap-server/test/boot.test.ts @@ -80,6 +80,35 @@ describe('server-v2 boot', () => { expect(oauthBody.data).toBeNull(); }); + it('reports opts.version as server_version instead of the package version', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-version-')); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + version: '9.9.9-host', + }); + + const base = `http://127.0.0.1:${server.port}`; + const meta = await authedFetch(server, base, '/api/v1/meta'); + const metaBody = await meta.json() as { + code: number; + data: { server_version: string }; + }; + expect(metaBody.data.server_version).toBe('9.9.9-host'); + + // The host version is also what the lock advertises to status/ps clients. + const stored = JSON.parse( + await readFile(join(home, 'server', 'lock'), 'utf8'), + ) as LockContents; + expect(stored.host_version).toBe('9.9.9-host'); + + // ... and it backs the default product User-Agent. + const defaults = server.core.accessor.get(IHostRequestHeaders); + expect(defaults.headers['User-Agent']).toBe('kimi-code-cli/9.9.9-host'); + }); + it('seeds a default product User-Agent that opts.seeds can override', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ua-')); server = await startServer({ From 1a9d8643b82f22b5085982c8ee19026d35e924a5 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 14 Jul 2026 17:19:41 +0800 Subject: [PATCH 02/10] fix(agent-core-v2): make new sessions resumable by older v1 builds - add wireRecord.seal() to write the metadata envelope at agent creation, so fresh logs satisfy v1 replay's first-record-must-be-metadata invariant - seal the wire log before any op dispatch in AgentLifecycleService.create; no-op when the log already has records (resume / forked copies) - seed empty agents/custom maps in session metadata so v1 Session.resume() can index agents['main'] on a v2-created state.json - add seal unit tests and lifecycle sealing tests --- .../fix-session-format-backward-compat.md | 5 ++ .../src/agent/wireRecord/wireRecord.ts | 10 +++ .../src/agent/wireRecord/wireRecordService.ts | 27 +++++++- .../agentLifecycle/agentLifecycleService.ts | 13 ++-- .../sessionMetadata/sessionMetadataService.ts | 5 ++ .../test/agent/contextMemory/stubs.ts | 1 + .../test/agent/wireRecord/persistence.test.ts | 63 +++++++++++++++++++ .../app/sessionExport/sessionExport.test.ts | 1 + .../agentLifecycle/agentLifecycle.test.ts | 53 +++++++++++++--- .../sessionMetadata/sessionMetadata.test.ts | 9 ++- 10 files changed, 172 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-session-format-backward-compat.md diff --git a/.changeset/fix-session-format-backward-compat.md b/.changeset/fix-session-format-backward-compat.md new file mode 100644 index 0000000000..dd048e5422 --- /dev/null +++ b/.changeset/fix-session-format-backward-compat.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sessions created by newer builds being listed but failing to open in older CLI builds on the same machine; new sessions are now written so both versions can resume them. diff --git a/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts b/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts index d10522584b..2075a54c18 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts @@ -24,6 +24,16 @@ export interface WireRecordRestoreResult { export interface IAgentWireRecordService { readonly _serviceBrand: undefined; + /** + * Ensure the on-disk wire log starts with the `metadata` envelope: append it + * when the log is still empty (fresh agent), no-op otherwise. Called at + * agent creation so the log always satisfies v1's replay invariant — v1's + * `AgentRecords.replay()` hard-rejects a non-empty log whose first record is + * not `metadata`, and sessions on a shared `KIMI_CODE_HOME` must stay + * readable by both engines. Legacy envelope-less logs (written before this + * existed) are healed by `restore()`, never here. + */ + seal(): Promise; getRecords(): readonly PersistedWireRecord[]; restore( records?: readonly PersistedWireRecord[], diff --git a/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts b/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts index 7eb92eb52d..12d8ba8377 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts @@ -3,9 +3,13 @@ * * Restores and retains the owning agent's wire journal, applies protocol * migrations, rejects non-empty unversioned logs, and awaits durable atomic - * rewrites before restore completes. Tracks live records through `wire`, uses - * `agent/scopeContext` for storage addressing, and persists through the - * `appendLog` access-pattern store. Bound at Agent scope. + * rewrites before restore completes. Seals fresh logs with the `metadata` + * envelope at creation (`seal`) so released v1 builds — whose replay + * hard-rejects envelope-less logs — can read sessions on a shared + * `KIMI_CODE_HOME`; legacy envelope-less logs are healed on `restore`. + * Tracks live records through `wire`, uses `agent/scopeContext` for storage + * addressing, and persists through the `appendLog` access-pattern store. + * Bound at Agent scope. */ import { relative } from 'pathe'; @@ -13,6 +17,7 @@ import { relative } from 'pathe'; import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAgentWireService } from '#/wire/tokens'; @@ -64,6 +69,14 @@ export class AgentWireRecordService extends Disposable implements IAgentWireReco return [...this.records]; } + async seal(): Promise { + if (this.log === undefined) return; + if (await hasAnyRecord(this.log, this.wireScope, WIRE_RECORD_FILENAME)) return; + this.log.append(this.wireScope, WIRE_RECORD_FILENAME, metadataRecord(), { + onError: onUnexpectedError, + }); + } + async restore( records?: readonly PersistedWireRecord[], options: WireRecordRestoreOptions = {}, @@ -161,6 +174,14 @@ function isWireRecordMetadata(record: PersistedWireRecord): record is WireRecord return record.type === 'metadata' && typeof record['protocol_version'] === 'string'; } +async function hasAnyRecord(log: IAppendLogStore, scope: string, key: string): Promise { + for await (const record of log.read(scope, key)) { + void record; + return true; + } + return false; +} + export const WIRE_RECORD_FILENAME = 'wire.jsonl'; export function missingWireMetadataError(): Error { diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 65485aa4c4..d93e556329 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -55,6 +55,7 @@ import { IImageConfigBridge } from '#/agent/media/imageConfigBridge'; import { IAgentMcpService } from '#/agent/mcp/mcp'; import { IAgentExternalHooksService } from '#/agent/externalHooks/externalHooks'; import { IAgentPluginService } from '#/agent/plugin/agentPlugin'; +import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; import { ISessionInteractionService } from '#/session/interaction/interaction'; import { type AgentListFilter, @@ -168,6 +169,11 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle ) as IAgentScopeHandle; this.handles.set(agentId, handle); try { + // Seal the wire log with the metadata envelope before any op can be + // dispatched: v1's replay hard-rejects envelope-less logs, and sessions + // on a shared KIMI_CODE_HOME must stay readable by released v1 builds. + // No-op when the log already has records (resume / forked copies). + await handle.accessor.get(IAgentWireRecordService).seal(); await this.sessionMetadata.registerAgent(agentId, { homedir: agentHomedir, type: agentId === 'main' ? 'main' : 'sub', @@ -182,10 +188,9 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle // Bootstrap (profile binding and the force-instantiated observer // services) is complete: drive the activity kernel `initializing → idle` // so the agent can admit turns. Until this point `begin` rejects with - // `activity.initializing`. The wire log's metadata envelope is NOT - // seeded here — `wireRecord.restore()` heals envelope-less logs on - // resume (prepend + rewrite), so creation stays free of log-format - // concerns. + // `activity.initializing`. The wire log was already sealed with its + // metadata envelope above; `wireRecord.restore()` heals any legacy + // envelope-less log on resume (prepend + rewrite). handle.accessor.get(IAgentActivityService).markReady(); return handle; } catch (error) { diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index e05901c177..da361ed0ee 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -141,6 +141,11 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { createdAt: now, updatedAt: now, archived: false, + // Seed the maps v1 reads unconditionally on resume (`agents['main']` in + // v1's Session.resume()); v1 also defaults both on fresh sessions, so a + // v2-created state.json stays openable by released v1 builds. + agents: {}, + custom: {}, }; await this.store.set(this.scope, META_KEY, this.data); this.log.debug('session metadata created', { sessionId: this.ctx.sessionId }); diff --git a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts index 178b2f24e5..fec907db1b 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts @@ -24,6 +24,7 @@ import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; export function stubWireRecord(): IAgentWireRecordService { return { _serviceBrand: undefined, + seal: () => Promise.resolve(), restore: () => Promise.resolve({}), flush: () => Promise.resolve(), close: () => Promise.resolve(), diff --git a/packages/agent-core-v2/test/agent/wireRecord/persistence.test.ts b/packages/agent-core-v2/test/agent/wireRecord/persistence.test.ts index c1bfd57570..13e56a3205 100644 --- a/packages/agent-core-v2/test/agent/wireRecord/persistence.test.ts +++ b/packages/agent-core-v2/test/agent/wireRecord/persistence.test.ts @@ -294,6 +294,69 @@ describe('wire record append-log persistence', () => { }); }); +describe('AgentWireRecordService seal', () => { + it('appends the metadata envelope to an empty log', async () => { + const { dir, log } = await createFileAppendLogHarness(); + const svc = createWireRecordHarness(log); + + await svc.seal(); + await log.close(); + + const lines = await readLines(join(dir, SCOPE, KEY)); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0]!)).toMatchObject({ + type: 'metadata', + protocol_version: AGENT_WIRE_PROTOCOL_VERSION, + }); + }); + + it('is a no-op on a non-empty log', async () => { + const { dir, log } = await createFileAppendLogHarness(); + log.append(SCOPE, KEY, { + type: 'turn.prompt', + input: [{ type: 'text', text: 'existing' }], + origin: { kind: 'user' }, + }); + await log.flush(); + const svc = createWireRecordHarness(log); + + await svc.seal(); + await log.close(); + + const lines = await readLines(join(dir, SCOPE, KEY)); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0]!)['type']).toBe('turn.prompt'); + }); + + it('seals only once across repeated calls', async () => { + const { dir, log } = await createFileAppendLogHarness(); + const svc = createWireRecordHarness(log); + + await svc.seal(); + await svc.seal(); + await log.close(); + + const lines = await readLines(join(dir, SCOPE, KEY)); + expect(lines).toHaveLength(1); + }); + + it('restore after seal keeps the sealed log untouched', async () => { + const { dir, log } = await createFileAppendLogHarness(); + const svc = createWireRecordHarness(log); + + await svc.seal(); + await svc.restore(); + await log.close(); + + const lines = await readLines(join(dir, SCOPE, KEY)); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0]!)).toMatchObject({ + type: 'metadata', + protocol_version: AGENT_WIRE_PROTOCOL_VERSION, + }); + }); +}); + describe('AgentWireRecordService migration rewrite', () => { async function seedLegacyLog(storage: InMemoryStorageService): Promise { const log = createAppendLogHarness(storage); diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 2fb0f27535..c87d4fe67e 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -916,6 +916,7 @@ function stubAgentLifecycle(agents: readonly IAgentScopeHandle[]): IAgentLifecyc function stubAgentWire(flush: () => Promise = async () => {}): IAgentWireRecordService { return { _serviceBrand: undefined, + seal: async () => {}, getRecords: () => [], restore: async () => ({}), flush, diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 48ff1fb30d..58232f5f4d 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -28,6 +28,7 @@ import { SessionSubagentService } from '#/session/subagent/subagentService'; import '#/activity/agentActivityService'; import '#/agent/mcp/mcpService'; import '#/agent/wireRecord/agentWireService'; +import '#/agent/wireRecord/wireRecordService'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionCronService } from '#/session/cron/sessionCronService'; import '#/agent/toolDedupe/toolDedupeService'; @@ -42,7 +43,10 @@ import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { type PersistedWireRecord } from '#/agent/wireRecord/wireRecord'; +import { + AGENT_WIRE_PROTOCOL_VERSION, + type PersistedWireRecord, +} from '#/agent/wireRecord/wireRecord'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentLoopService } from '#/agent/loop/loop'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -324,6 +328,34 @@ describe('AgentLifecycleService', () => { }); }); + it('seals a fresh wire log with the metadata envelope as the first record', async () => { + const log = recordingAppendLog(); + ix.stub(IAppendLogStore, log.store); + const svc = ix.get(IAgentLifecycleService); + + await svc.create({ agentId: 'main' }); + + expect(log.appended[0]).toMatchObject({ + type: 'metadata', + protocol_version: AGENT_WIRE_PROTOCOL_VERSION, + }); + }); + + it('does not re-seal a wire log that already has records', async () => { + const existing: PersistedWireRecord = { + type: 'turn.prompt', + input: [{ type: 'text', text: 'existing' }], + origin: { kind: 'user' }, + } as unknown as PersistedWireRecord; + const log = recordingAppendLog([existing]); + ix.stub(IAppendLogStore, log.store); + const svc = ix.get(IAgentLifecycleService); + + await svc.create({ agentId: 'main' }); + + expect(log.appended.some((record) => record.type === 'metadata')).toBe(false); + }); + it('leaves permission mode at the default when permissionMode is omitted', async () => { const svc = ix.get(IAgentLifecycleService); @@ -442,12 +474,16 @@ describe('AgentLifecycleService', () => { it('exposes the in-flight handle and yields it idle after bootstrap', async () => { let releaseRegister!: () => void; - registerAgent.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseRegister = resolve; - }), - ); + let registerStarted!: () => void; + const registerCalled = new Promise((resolve) => { + registerStarted = resolve; + }); + registerAgent.mockImplementationOnce(() => { + registerStarted(); + return new Promise((resolve) => { + releaseRegister = resolve; + }); + }); const svc = ix.get(IAgentLifecycleService); const create = svc.create({ agentId: 'main' }); @@ -456,6 +492,9 @@ describe('AgentLifecycleService', () => { expect(early!.accessor.get(IAgentActivityService).lane()).toBe('initializing'); const joined = svc.create({ agentId: 'main' }); + // doCreate awaits the wire-log seal before registerAgent, so the mock is + // invoked a few microtasks after create() — wait for the actual call. + await registerCalled; releaseRegister(); const handle = await joined; await create; diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index eb590436bc..c0c6c97b6a 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -51,7 +51,14 @@ describe('SessionMetadata', () => { it('creates an initial document on first read', async () => { const meta = ix.get(ISessionMetadata); - expect(await meta.read()).toMatchObject({ id: 's1', archived: false }); + expect(await meta.read()).toMatchObject({ + id: 's1', + archived: false, + // Seeded so released v1 builds can open a v2-created state.json + // (v1's Session.resume() indexes `agents` unconditionally). + agents: {}, + custom: {}, + }); expect((await meta.read()).createdAt).toBeGreaterThan(0); }); From b01618ef1485b2914ce999dacb1bda0a816580cc Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 14 Jul 2026 17:23:12 +0800 Subject: [PATCH 03/10] fix(kap-server): show real message send times in snapshot history - read per-record times stamped on wire.jsonl via reduceContextTranscript and cache them alongside the transcript messages - prefer each record's real dispatch time for created_at; records without a stamp fall back to session.createdAt + index, clamped so the page stays strictly increasing (mirrors MessageLegacyService.list) - add tests for fallback, clamping, and the page-offset index mapping --- .changeset/web-message-history-real-times.md | 5 ++ .../src/services/snapshot/snapshotReader.ts | 28 +++++++--- .../test/snapshotReader.unit.test.ts | 52 +++++++++++++++++++ 3 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 .changeset/web-message-history-real-times.md diff --git a/.changeset/web-message-history-real-times.md b/.changeset/web-message-history-real-times.md new file mode 100644 index 0000000000..5c7b78cb67 --- /dev/null +++ b/.changeset/web-message-history-real-times.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Show each message's actual send time in chat history after reloading a session, instead of the session creation time. diff --git a/packages/kap-server/src/services/snapshot/snapshotReader.ts b/packages/kap-server/src/services/snapshot/snapshotReader.ts index fc21d6bd61..ac94287687 100644 --- a/packages/kap-server/src/services/snapshot/snapshotReader.ts +++ b/packages/kap-server/src/services/snapshot/snapshotReader.ts @@ -76,6 +76,7 @@ interface TranscriptCacheEntry { readonly size: number; readonly mtimeMs: number; readonly messages: ContextMessage[]; + readonly times: readonly (number | undefined)[]; } interface LocatedSession { @@ -106,9 +107,18 @@ export class SnapshotReader implements ISnapshotReader { const offset = hasMore ? full.length - SNAPSHOT_MESSAGE_PAGE_SIZE : 0; const page = hasMore ? full.slice(offset) : full; await this.rehydrateBlobRefs(page, join(located.sessionDir, AGENTS_DIR, MAIN_AGENT_ID, BLOBS_DIR)); - const items = page.map((msg, i) => - toProtocolMessage(sid, offset + i, msg, located.meta.createdAt), - ); + // `created_at` prefers the real per-record time stamped onto wire.jsonl at + // dispatch; records predating the stamp (or the `metadata` envelope) fall + // back to the synthesized `session.createdAt + index`, clamped so the + // page stays strictly increasing (mirrors `MessageLegacyService.list`). + let previousMs = Number.NEGATIVE_INFINITY; + const items = page.map((msg, i) => { + const index = offset + i; + const baseMs = transcript.times[index] ?? located.meta.createdAt + index; + const createdAtMs = Math.max(previousMs + 1, baseMs); + previousMs = createdAtMs; + return toProtocolMessage(sid, index, msg, located.meta.createdAt, createdAtMs); + }); const live = core.accessor.get(ISessionLifecycleService).get(sid); const status = this.resolveStatus(live); @@ -180,6 +190,7 @@ export class SnapshotReader implements ISnapshotReader { sessionDir: string, ): Promise<{ messages: ContextMessage[]; + times: readonly (number | undefined)[]; tag: 'hit' | 'miss' | 'shrink_invalidate' | 'enoent'; wireBytes: number; }> { @@ -192,7 +203,7 @@ export class SnapshotReader implements ISnapshotReader { } if (info === undefined) { this.transcriptCache.delete(sid); - return { messages: [], tag: 'enoent', wireBytes: 0 }; + return { messages: [], times: [], tag: 'enoent', wireBytes: 0 }; } const cached = this.transcriptCache.get(sid); @@ -200,7 +211,7 @@ export class SnapshotReader implements ISnapshotReader { // LRU touch. this.transcriptCache.delete(sid); this.transcriptCache.set(sid, cached); - return { messages: cached.messages, tag: 'hit', wireBytes: info.size }; + return { messages: cached.messages, times: cached.times, tag: 'hit', wireBytes: info.size }; } const tag: 'miss' | 'shrink_invalidate' = @@ -208,14 +219,15 @@ export class SnapshotReader implements ISnapshotReader { if (cached !== undefined) this.transcriptCache.delete(sid); const records = await readWireRecords(wirePath); - const messages = [...reduceContextTranscript(records).entries]; - this.transcriptCache.set(sid, { size: info.size, mtimeMs: info.mtimeMs, messages }); + const { entries, times } = reduceContextTranscript(records); + const messages = [...entries]; + this.transcriptCache.set(sid, { size: info.size, mtimeMs: info.mtimeMs, messages, times }); while (this.transcriptCache.size > this.deps.config.cacheLimit) { const oldest = this.transcriptCache.keys().next().value; if (oldest === undefined) break; this.transcriptCache.delete(oldest); } - return { messages, tag, wireBytes: info.size }; + return { messages, times, tag, wireBytes: info.size }; } private resolveStatus( diff --git a/packages/kap-server/test/snapshotReader.unit.test.ts b/packages/kap-server/test/snapshotReader.unit.test.ts index c357bb3f7a..03cef26f7b 100644 --- a/packages/kap-server/test/snapshotReader.unit.test.ts +++ b/packages/kap-server/test/snapshotReader.unit.test.ts @@ -382,6 +382,58 @@ describe('SnapshotReader.read', () => { expect((snap.messages.items.at(-1)!.content[0] as { text: string }).text).toBe('m149'); }); + it('uses the wire record time as created_at, falling back and clamping to stay increasing', async () => { + const f = await makeFixtureAsync(); + const createdAt = 1700000000000; + const t0 = 1700001000000; + await seedSession(f, 'sess_times', { createdAt }); + await writeWire(f.sessionDir('sess_times'), [ + { type: 'context.append_message', message: userMessage('one'), time: t0 }, + // No time stamp → falls back to createdAt + index, which is earlier than + // the previous real time and gets clamped to previous + 1. + { type: 'context.append_message', message: userMessage('two') }, + // A time stamp EARLIER than the previous entry → clamped to previous + 1. + { type: 'context.append_message', message: userMessage('three'), time: t0 - 5000 }, + ]); + const snap = await f.reader.read('sess_times'); + expect(snap.messages.items.map((m) => Date.parse(m.created_at))).toEqual([t0, t0 + 1, t0 + 2]); + }); + + it('synthesizes created_at from session createdAt + index when no record carries a time', async () => { + const f = await makeFixtureAsync(); + const createdAt = 1700000000000; + await seedSession(f, 'sess_no_times', { createdAt }); + await writeWire(f.sessionDir('sess_no_times'), [ + { type: 'context.append_message', message: userMessage('a') }, + { type: 'context.append_message', message: userMessage('b') }, + ]); + const snap = await f.reader.read('sess_no_times'); + expect(snap.messages.items.map((m) => Date.parse(m.created_at))).toEqual([ + createdAt, + createdAt + 1, + ]); + }); + + it('maps record times by global index across the page offset', async () => { + const f = await makeFixtureAsync(); + const createdAt = 1700000000000; + const base = 1700002000000; + await seedSession(f, 'sess_times_paged', { createdAt }); + await writeWire( + f.sessionDir('sess_times_paged'), + Array.from({ length: 102 }, (_, i) => ({ + type: 'context.append_message' as const, + message: userMessage(`m${i}`), + time: base + i * 1000, + })), + ); + const snap = await f.reader.read('sess_times_paged'); + expect(snap.messages.items).toHaveLength(100); + // The page starts at global index 2, so the first item carries record[2]'s time. + expect(Date.parse(snap.messages.items[0]!.created_at)).toBe(base + 2000); + expect(Date.parse(snap.messages.items.at(-1)!.created_at)).toBe(base + 101 * 1000); + }); + it('normalizes a v1-layout state.json (ISO timestamps, no id)', async () => { const f = await makeFixtureAsync(); await seedSession(f, 'sess_v1', { From ded76570acd996a86b9eab1e5973a4570cf58fe5 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 14 Jul 2026 17:44:17 +0800 Subject: [PATCH 04/10] fix(agent-core-v2): backfill missing agents/custom maps in existing session metadata - load() now heals pre-fix v2 state.json documents that never gained the agents/custom maps, persisting the backfill so one open on a new build leaves the session resumable by released v1 builds (Session.resume() indexes agents['main'] unconditionally) - updatedAt is deliberately untouched so the format heal does not reorder session listings --- .../fix-session-format-backward-compat.md | 2 +- .../sessionMetadata/sessionMetadataService.ts | 14 ++++++ .../sessionMetadata/sessionMetadata.test.ts | 43 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-session-format-backward-compat.md b/.changeset/fix-session-format-backward-compat.md index dd048e5422..274ebd21f9 100644 --- a/.changeset/fix-session-format-backward-compat.md +++ b/.changeset/fix-session-format-backward-compat.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix sessions created by newer builds being listed but failing to open in older CLI builds on the same machine; new sessions are now written so both versions can resume them. +Fix sessions created by newer builds failing to open in older CLI builds on the same machine; new sessions are written in a compatible layout, and existing sessions are healed on first open. diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index da361ed0ee..f0c984e263 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -131,6 +131,20 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { const existing = await this.store.get(this.scope, META_KEY); if (existing !== undefined) { this.data = normalizeSessionMeta(existing, this.ctx.sessionId); + // Heal pre-fix v2 documents: sessions created before the create-path + // seeding never gained the `agents` / `custom` maps, and v1's + // Session.resume() indexes `agents` unconditionally — backfill and + // persist so opening the session once with this build leaves it + // resumable by released v1 builds. `updatedAt` is deliberately + // untouched: a format heal must not reorder session listings. + if (this.data.agents == null || this.data.custom == null) { + this.data = { + ...this.data, + agents: this.data.agents ?? {}, + custom: this.data.custom ?? {}, + }; + await this.store.set(this.scope, META_KEY, this.data); + } return; } const now = Date.now(); diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index c0c6c97b6a..cf22409158 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -88,6 +88,49 @@ describe('SessionMetadata', () => { expect(await fresh.read()).toMatchObject({ id: 's1', title: 'persisted' }); }); + it('backfills and persists missing agents/custom maps on a pre-fix document', async () => { + // Written by a v2 build predating the create-path map seeding: no + // agents / custom keys at all. + const store = ix.get(IAtomicDocumentStore); + await store.set(META_SCOPE, 'state.json', { + id: 's1', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + archived: false, + }); + + const meta = ix.get(ISessionMetadata); + expect(await meta.read()).toMatchObject({ agents: {}, custom: {} }); + + // The heal is persisted: a fresh instance reads the maps from disk, and + // updatedAt is untouched so session listings keep their order. + const fresh = ix.createInstance(SessionMetadata); + const healed = await fresh.read(); + expect(healed.agents).toEqual({}); + expect(healed.custom).toEqual({}); + expect(healed.updatedAt).toBe(1700000000000); + }); + + it('leaves existing agents/custom maps untouched', async () => { + const store = ix.get(IAtomicDocumentStore); + await store.set(META_SCOPE, 'state.json', { + id: 's1', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + archived: false, + agents: { main: { homedir: '/tmp/sessions/wd_test/s1/agents/main', type: 'main' } }, + custom: { cwd: '/tmp/work' }, + }); + + const meta = ix.get(ISessionMetadata); + expect(await meta.read()).toMatchObject({ + agents: { main: { homedir: '/tmp/sessions/wd_test/s1/agents/main', type: 'main' } }, + custom: { cwd: '/tmp/work' }, + }); + }); + it('fires onDidChangeMetadata with the changed keys after update', async () => { const meta = ix.get(ISessionMetadata); await meta.ready; From 6eea417d9784cec9d14ec72e5d4b0b277fa5caa3 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 14 Jul 2026 18:13:03 +0800 Subject: [PATCH 05/10] feat(agent-core-v2): make subagent timeout configurable, default 2h - add [subagent] config section with timeout_ms and KIMI_SUBAGENT_TIMEOUT_MS env override - resolve Agent and AgentSwarm per-run timeouts through resolveSubagentTimeoutMs - update tool descriptions and tests to reflect the 2-hour default --- .changeset/align-subagent-timeout.md | 5 ++ .../src/agent/swarm/tools/agent-swarm.ts | 7 +- .../src/session/subagent/configSection.ts | 79 +++++++++++++++++++ .../src/session/subagent/tools/agent.md | 2 +- .../src/session/subagent/tools/agent.ts | 16 ++-- .../test/agent/loop/loop.test.ts | 4 +- .../test/agent/swarm/swarm.test.ts | 51 +++++++++--- packages/agent-core-v2/test/tool/tool.test.ts | 62 +++++++++++---- 8 files changed, 192 insertions(+), 34 deletions(-) create mode 100644 .changeset/align-subagent-timeout.md create mode 100644 packages/agent-core-v2/src/session/subagent/configSection.ts diff --git a/.changeset/align-subagent-timeout.md b/.changeset/align-subagent-timeout.md new file mode 100644 index 0000000000..41920aa5a3 --- /dev/null +++ b/.changeset/align-subagent-timeout.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Align the subagent timeout across engines: a fixed 2-hour default, overridable with `[subagent] timeout_ms` in config.toml or the KIMI_SUBAGENT_TIMEOUT_MS environment variable. diff --git a/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts b/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts index c1475c06d5..09db93c303 100644 --- a/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts +++ b/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts @@ -19,13 +19,14 @@ import { } from '#/tool/toolContract'; import { registerTool } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; +import { IConfigService } from '#/app/config/config'; import { ISessionSwarmService, type SessionSwarmTask } from '#/session/swarm/sessionSwarm'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { resolveSubagentTimeoutMs } from '#/session/subagent/configSection'; import AGENT_SWARM_DESCRIPTION from './agent-swarm.md?raw'; const DEFAULT_SUBAGENT_TYPE = 'coder'; -const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000; const PROMPT_TEMPLATE_PLACEHOLDER = '{{item}}'; const MAX_AGENT_SWARM_SUBAGENTS = 128; @@ -107,6 +108,7 @@ export class AgentSwarmTool implements BuiltinTool { @ISessionSwarmService private readonly swarmService: ISessionSwarmService, @IAgentScopeContext scopeContext: IAgentScopeContext, @IAgentSwarmService private readonly swarmMode: IAgentSwarmService, + @IConfigService private readonly config: IConfigService, ) { this.callerAgentId = scopeContext.agentId; } @@ -150,6 +152,7 @@ export class AgentSwarmTool implements BuiltinTool { toolCallId: string, ): Promise { const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE; + const timeoutMs = resolveSubagentTimeoutMs(this.config); const specs = await createAgentSwarmSpecs(args, (agentId) => this.swarmService.getSwarmItem({ callerAgentId: this.callerAgentId, agentId }), ); @@ -165,7 +168,7 @@ export class AgentSwarmTool implements BuiltinTool { runInBackground: false, swarmItem: spec.item, signal, - timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, + timeout: timeoutMs, }; if (spec.kind === 'resume') { return { diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts new file mode 100644 index 0000000000..57aa44d287 --- /dev/null +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -0,0 +1,79 @@ +/** + * `subagent` domain (L6) — subagent config-section schema, env binding, and + * timeout resolution. + * + * Owns the `[subagent]` configuration section (`timeout_ms` on disk) together + * with the `KIMI_SUBAGENT_TIMEOUT_MS` env override, mirroring v1's + * `resolveSubagentTimeoutMs` precedence (env > config.toml > 2h default). Both + * collaboration tools — `Agent` in this domain and `AgentSwarm` in the `swarm` + * domain — resolve their per-run timeout through `resolveSubagentTimeoutMs`, + * and render the timeout message with `formatSubagentTimeoutDescription`. + * Self-registered at module load via `registerConfigSection`, so the `config` + * domain never imports this domain's types. + */ + +import { z } from 'zod'; + +import { type EnvBindings, envBindings, type IConfigService } from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const SUBAGENT_SECTION = 'subagent'; + +export const SubagentConfigSchema = z.object({ + /** Per-run subagent timeout in milliseconds; set a large value to effectively disable the cap. */ + timeoutMs: z.number().int().min(1).optional(), +}); + +export type SubagentConfig = z.infer; + +/** Default per-run subagent timeout: 2 hours, same as v1. */ +export const DEFAULT_SUBAGENT_TIMEOUT_MS = 2 * 60 * 60 * 1000; + +export const SUBAGENT_TIMEOUT_ENV = 'KIMI_SUBAGENT_TIMEOUT_MS'; + +/** Parse the env override; anything but a positive integer is ignored (v1 semantics). */ +function parseTimeoutMsEnv(raw: string): number | undefined { + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined; +} + +export const subagentEnvBindings: EnvBindings = envBindings( + SubagentConfigSchema, + { + timeoutMs: { env: SUBAGENT_TIMEOUT_ENV, parse: parseTimeoutMsEnv }, + }, +); + +registerConfigSection(SUBAGENT_SECTION, SubagentConfigSchema, { + defaultValue: { timeoutMs: DEFAULT_SUBAGENT_TIMEOUT_MS }, + env: subagentEnvBindings, +}); + +/** + * Resolve the effective per-run subagent timeout. Governs foreground and + * background subagents (and AgentSwarm) through the task manager's per-task + * timeout. + */ +export function resolveSubagentTimeoutMs(config: IConfigService): number { + return ( + config.get(SUBAGENT_SECTION)?.timeoutMs ?? + DEFAULT_SUBAGENT_TIMEOUT_MS + ); +} + +/** Human-readable duration for the subagent timeout message. */ +export function formatSubagentTimeoutDescription(ms: number): string { + if (ms % (60 * 60 * 1000) === 0) { + const h = ms / (60 * 60 * 1000); + return `${h} hour${h === 1 ? '' : 's'}`; + } + if (ms % (60 * 1000) === 0) { + const m = ms / (60 * 1000); + return `${m} minute${m === 1 ? '' : 's'}`; + } + if (ms % 1000 === 0) { + const s = ms / 1000; + return `${s} second${s === 1 ? '' : 's'}`; + } + return `${ms} ms`; +} diff --git a/packages/agent-core-v2/src/session/subagent/tools/agent.md b/packages/agent-core-v2/src/session/subagent/tools/agent.md index ec0533e7ef..d8b65d7c0d 100644 --- a/packages/agent-core-v2/src/session/subagent/tools/agent.md +++ b/packages/agent-core-v2/src/session/subagent/tools/agent.md @@ -9,7 +9,7 @@ Writing the prompt: Usage notes: - When the task continues earlier work a subagent already did, prefer resuming that agent (pass its `resume` id) over spawning a fresh instance — the resumed agent keeps its prior context. - A subagent's result is only visible to you, not to the user. When the user needs to see what a subagent produced, summarize the relevant parts yourself in your own reply. -- Subagents use a fixed 30-minute timeout. If one times out, resume the same agent instead of starting over. +- Subagents use a fixed 2-hour timeout. If one times out, resume the same agent instead of starting over. When NOT to use Agent: skip delegation for trivial work you can do directly — reading a file whose path you already know, searching a small known set of files, or any task that takes only a step or two. Delegation has a context-handoff cost; it pays off only when the task is substantial enough to outweigh it. diff --git a/packages/agent-core-v2/src/session/subagent/tools/agent.ts b/packages/agent-core-v2/src/session/subagent/tools/agent.ts index 6193b0a069..00d94b15a8 100644 --- a/packages/agent-core-v2/src/session/subagent/tools/agent.ts +++ b/packages/agent-core-v2/src/session/subagent/tools/agent.ts @@ -41,6 +41,7 @@ import { registerTool } from '#/agent/toolRegistry/toolContribution'; import { IAgentProfileCatalogService, type AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix'; import { ILogService } from '#/_base/log/log'; +import { IConfigService } from '#/app/config/config'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { isSubagentMeta, subagentLabels, subagentParentAgentId } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionProcessRunner } from '#/session/process/processRunner'; @@ -49,6 +50,10 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo import { emitAgentRunSpawned, mirrorAgentRun } from '../mirrorAgentRun'; import { ISessionSubagentService } from '../subagent'; +import { + formatSubagentTimeoutDescription, + resolveSubagentTimeoutMs, +} from '../configSection'; import { SubagentTask, type SubagentHandle } from './subagent-task'; import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.md?raw'; @@ -57,8 +62,6 @@ import AGENT_DESCRIPTION_BASE from './agent.md?raw'; const DEFAULT_PROFILE_NAME = 'coder'; const RESUMED_LABEL = 'subagent'; -export const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000; -export const DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION = '30 minutes'; export const AgentToolInputSchema = z.preprocess( (input) => { @@ -146,6 +149,7 @@ export class AgentTool implements BuiltinTool { @ISessionMetadata private readonly sessionMetadata: ISessionMetadata, @ILogService private readonly log: ILogService, @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, + @IConfigService private readonly config: IConfigService, ) { this.callerAgentId = scopeContext.agentId; this.canRunInBackground = () => @@ -334,6 +338,7 @@ export class AgentTool implements BuiltinTool { if (runInBackground && !allowBackground) { return { output: BACKGROUND_AGENT_UNAVAILABLE, isError: true }; } + const timeoutMs = resolveSubagentTimeoutMs(this.config); const controller = new AbortController(); const abortBeforeRegister = (): void => { @@ -363,7 +368,7 @@ export class AgentTool implements BuiltinTool { try { const registerOptions: RegisterAgentTaskOptions = { detached: runInBackground, - timeoutMs: DEFAULT_SUBAGENT_TIMEOUT_MS, + timeoutMs, signal: runInBackground ? undefined : signal, }; taskId = this.tasks.registerTask( @@ -403,7 +408,7 @@ export class AgentTool implements BuiltinTool { output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground), }; } - return await this.formatForegroundResult(taskId, handle); + return await this.formatForegroundResult(taskId, handle, timeoutMs); } catch (error) { return { output: `subagent error: ${launchErrorMessage(error, signal)}`, isError: true }; } @@ -412,6 +417,7 @@ export class AgentTool implements BuiltinTool { private async formatForegroundResult( taskId: string, handle: SubagentHandle, + timeoutMs: number, ): Promise { const info = this.tasks.getTask(taskId); if (info?.status === 'completed') { @@ -421,7 +427,7 @@ export class AgentTool implements BuiltinTool { } const timedOut = info?.status === 'timed_out'; const message = timedOut - ? `Agent timed out after ${DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION}.` + ? `Agent timed out after ${formatSubagentTimeoutDescription(timeoutMs)}.` : info?.stopReason === 'Interrupted by user' ? USER_INTERRUPTED_SUBAGENT_MESSAGE : info?.stopReason !== undefined diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 61a3b5ecb3..2f78b4708b 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -96,8 +96,8 @@ describe('Agent loop', () => { [emit] context.spliced { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "" } ] } [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "", "turnId": "0", "step": 1 }, "time": "