From f2e65a4552218e69cb0972b9dc1d8482a233d448 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 10 Jul 2026 14:20:13 +0800 Subject: [PATCH] fix: repair v2 session listing and resume history hydration - V2Host.listSessions now accepts the query payload (workDir scoped via encodeWorkDirKey, sessionId, childOf, limit, includeArchived) and maps agent-core-v2 SessionSummary to the wire contract: cwd -> workDir with workspace registry backfill and an '(unknown)' fallback, real sessionDir from IBootstrapService, metadata passthrough; blank workDir is rejected with request.work_dir_required per the v1 contract. - V2Host.resumeSession/forkSession return a ResumedSessionSummary: sessionMetadata from ISessionMetadata, agents.main assembled from IAgentRPCService getters (config/context/permission/plan/swarmMode/ usage/tools/tasks), projected replay records, and toolStore.todo mapped from the session-scope todo service. reloadSession is implemented as close+resume (was notImplemented). - agent-core-v2 gains projectReplayTimeline, which projects the op-native ReplayTimeline into v1-shaped AgentReplayRecord[] mirroring v1 ReplayBuilder restore-push semantics, and an eager IAgentReplayService that attaches ReplayTimelineModel before restore/replay so the derived model folds every op. - TUI hardening: homeAlias and sessionRowsForPicker tolerate missing work_dir so legacy sessions without cwd cannot crash the session picker render (uncaught TypeError in the render timer previously exited the process). Includes changesets for both fixes. --- .changeset/fix-resume-history-v2.md | 5 + .changeset/fix-sessions-picker-crash-v2.md | 5 + .../tui/components/dialogs/session-picker.ts | 4 + .../src/tui/utils/session-picker-rows.ts | 4 +- .../components/dialogs/session-picker.test.ts | 20 ++ .../tui/utils/session-picker-rows.test.ts | 10 + .../agent/replayBuilder/agentReplayService.ts | 62 +++++ .../agent/replayBuilder/replayProjection.ts | 240 ++++++++++++++++++ packages/agent-core-v2/src/index.ts | 2 + .../agentLifecycle/agentLifecycleService.ts | 7 + .../replayBuilder/replayProjection.test.ts | 114 +++++++++ packages/node-sdk/src/v2/host.ts | 170 ++++++++++++- .../session-plan-compact-usage-resume.test.ts | 38 +++ 13 files changed, 668 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-resume-history-v2.md create mode 100644 .changeset/fix-sessions-picker-crash-v2.md create mode 100644 packages/agent-core-v2/src/agent/replayBuilder/agentReplayService.ts create mode 100644 packages/agent-core-v2/src/agent/replayBuilder/replayProjection.ts create mode 100644 packages/agent-core-v2/test/replayBuilder/replayProjection.test.ts diff --git a/.changeset/fix-resume-history-v2.md b/.changeset/fix-resume-history-v2.md new file mode 100644 index 000000000..2c700af04 --- /dev/null +++ b/.changeset/fix-resume-history-v2.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix resumed sessions showing "Session history is unavailable" instead of the conversation transcript when running on the new agent engine. diff --git a/.changeset/fix-sessions-picker-crash-v2.md b/.changeset/fix-sessions-picker-crash-v2.md new file mode 100644 index 000000000..efac0360d --- /dev/null +++ b/.changeset/fix-sessions-picker-crash-v2.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the /sessions picker crashing the CLI with the new agent engine, scope the session list to the current working directory, and show sessions recorded before working-directory tracking with an `(unknown)` directory. diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index c8bd9017b..839a202e5 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -41,6 +41,10 @@ function formatRelativeTime(ts: number): string { } function homeAlias(path: string): string { + // Sessions written before cwd persistence (or served by a host that can't + // recover it) reach the picker with no work dir; render a placeholder + // instead of crashing the whole TUI for a single bad row. + if (typeof path !== 'string' || path.length === 0) return '(unknown)'; const home = process.env['HOME'] ?? ''; if (home && path.startsWith(home)) return '~' + path.slice(home.length); return path; diff --git a/apps/kimi-code/src/tui/utils/session-picker-rows.ts b/apps/kimi-code/src/tui/utils/session-picker-rows.ts index 55e063246..aab258fd5 100644 --- a/apps/kimi-code/src/tui/utils/session-picker-rows.ts +++ b/apps/kimi-code/src/tui/utils/session-picker-rows.ts @@ -13,7 +13,9 @@ export function sessionRowsForPicker( id: session.id, title: session.title ?? null, last_prompt: session.lastPrompt ?? null, - work_dir: session.workDir, + // Wire contract requires workDir, but older/broken backends may omit it — + // the picker must survive those rows (see homeAlias guard). + work_dir: session.workDir ?? '(unknown)', updated_at: session.updatedAt ?? session.createdAt ?? 0, metadata: session.metadata, })); diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index 3c885488b..d687aad98 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -101,6 +101,26 @@ describe('SessionPickerComponent', () => { expect(output).toContain('please redesign the picker UI'); }); + it('renders sessions written before cwd persistence with an (unknown) dir', () => { + const legacy = { + id: 'ses_nocwd', + title: 'legacy session', + work_dir: undefined as unknown as string, + updated_at: 1, + }; + const component = new SessionPickerComponent({ + sessions: [legacy], + loading: false, + currentSessionId: 'ses_other', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const output = renderPlain(component); + + expect(output).toContain('(unknown)'); + }); + it('omits the last-prompt row when last_prompt is missing', () => { const now = new Date('2026-05-11T12:00:00.000Z').getTime(); vi.spyOn(Date, 'now').mockReturnValue(now); diff --git a/apps/kimi-code/test/tui/utils/session-picker-rows.test.ts b/apps/kimi-code/test/tui/utils/session-picker-rows.test.ts index 30d56969c..43629f2d2 100644 --- a/apps/kimi-code/test/tui/utils/session-picker-rows.test.ts +++ b/apps/kimi-code/test/tui/utils/session-picker-rows.test.ts @@ -61,4 +61,14 @@ describe('sessionRowsForPicker', () => { expect(rows.map((row) => row.id)).toEqual(['ses_previous_empty']); }); + + it('fills a placeholder work_dir when the summary omits workDir', () => { + const withoutDir = structuredClone(summary({ id: 'ses_legacy' })); + // Old sessions written before cwd persistence skip the field entirely. + delete (withoutDir as { workDir?: string }).workDir; + + const rows = sessionRowsForPicker([withoutDir], 'ses_other', false); + + expect(rows[0]?.work_dir).toBe('(unknown)'); + }); }); diff --git a/packages/agent-core-v2/src/agent/replayBuilder/agentReplayService.ts b/packages/agent-core-v2/src/agent/replayBuilder/agentReplayService.ts new file mode 100644 index 000000000..a7f5531f1 --- /dev/null +++ b/packages/agent-core-v2/src/agent/replayBuilder/agentReplayService.ts @@ -0,0 +1,62 @@ +/** + * `replayBuilder` domain — `IAgentReplayService`, the agent-scope owner of + * the `ReplayTimelineModel` derived-model attachment. + * + * A derived model only folds Ops that pass through the wire *after* it is + * attached (`IWireService.attach` starts from `initial`), so the attach must + * land before session resume replays the persisted wire log — otherwise a + * resumed session's timeline stays empty and the TUI cannot rehydrate screen + * history. This service is force-instantiated from + * `IAgentLifecycleService.create` (with the other Eager agent services), + * which runs before `sessionLifecycleService` replays records on + * resume/fork, and before any live dispatch on fresh sessions. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentWireService } from '#/wire/tokens'; +import { type IWireService } from '#/wire/wireService'; + +import { ReplayTimelineModel, type ReplayTimeline } from './replayTimelineModel'; +import { projectReplayTimeline } from './replayProjection'; +import type { AgentReplayRecord } from './types'; + +export interface IAgentReplayService { + readonly _serviceBrand: undefined; + + /** The op-native folded timeline (raw form). */ + getReplayTimeline(): ReplayTimeline; + + /** v1-shaped replay DTO records for SDK/TUI resume hydration. */ + getReplayRecords(): readonly AgentReplayRecord[]; +} + +export const IAgentReplayService: ServiceIdentifier = + createDecorator('agentReplayService'); + +export class AgentReplayService extends Disposable implements IAgentReplayService { + declare readonly _serviceBrand: undefined; + + constructor(@IAgentWireService private readonly wire: IWireService) { + super(); + this._register(this.wire.attach(ReplayTimelineModel)); + } + + getReplayTimeline(): ReplayTimeline { + return this.wire.getModel(ReplayTimelineModel) as ReplayTimeline; + } + + getReplayRecords(): readonly AgentReplayRecord[] { + return projectReplayTimeline(this.getReplayTimeline()); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentReplayService, + AgentReplayService, + InstantiationType.Eager, + 'replayBuilder', +); diff --git a/packages/agent-core-v2/src/agent/replayBuilder/replayProjection.ts b/packages/agent-core-v2/src/agent/replayBuilder/replayProjection.ts new file mode 100644 index 000000000..af9387c19 --- /dev/null +++ b/packages/agent-core-v2/src/agent/replayBuilder/replayProjection.ts @@ -0,0 +1,240 @@ +/** + * `replayBuilder` domain — read-time projection from the op-native + * `ReplayTimeline` into the v1-shaped `AgentReplayRecord` DTO the SDK/TUI + * hydrates resumed sessions from (`ResumedAgentState.replay`). + * + * The timeline stores `{ type, payload }` entries reduced from wire Ops; this + * projection mirrors the push decisions v1's `ReplayBuilder` made at restore + * time (packages/agent-core `agent/replay/index.ts` and its domain push sites): + * + * - `context.append_message` → `message` + * - `context.apply_compaction` → `compaction` carrying the durable + * result (summary + token counts live on this op's payload in v2) + * - `full_compaction.begin` → `compaction` with instruction only + * (v1 pushes the same at restore; the TUI skips result-less records) + * - `full_compaction.cancel` → `compaction` with result 'cancelled' + * - `full_compaction.complete` → nothing (empty payload; the visible + * result already came from `context.apply_compaction`) + * - `goal.create` / `goal.update` → `goal_updated`; goal state is folded + * per record so each snapshot reflects that point in the timeline, and + * counter-only updates are skipped like v1 (`restoreUpdate` returns early + * when no status is present) + * - `goal.clear` → nothing (v1 pushes no record) + * - `plan_mode.enter` / cancel / exit → `plan_updated` + * - `config.update` → `config_updated` + * - `permission.set_mode` → `permission_updated` + * - `permission.record_approval_result`→ `approval_result` + * + * Timeline entries carry no record time (the derived model reduces op + * payloads, not persisted records), and no consumer reads + * `AgentReplayRecord.time`, so every record stamps `0`. + */ + +import type { ContextCompactionPayload } from '#/agent/contextMemory/contextOps'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import type { CompactionResult } from '#/agent/fullCompaction/types'; +import type { GoalState, GoalUpdatePayload } from '#/agent/goal/goalOps'; +import type { + GoalBudgetLimits, + GoalBudgetReport, + GoalChange, + GoalSnapshot, +} from '#/agent/goal/types'; + +import type { ReplayTimeline } from './replayTimelineModel'; +import type { AgentReplayRecord } from './types'; + +const NO_TIME = 0; + +export function projectReplayTimeline(timeline: ReplayTimeline): readonly AgentReplayRecord[] { + const records: AgentReplayRecord[] = []; + let goal: GoalState | null = null; + for (const entry of timeline) { + switch (entry.type) { + case 'context.append_message': + records.push({ time: NO_TIME, type: 'message', message: entry.payload.message }); + break; + case 'context.apply_compaction': { + const payload = entry.payload; + const result: CompactionResult = { + summary: compactionSummary(payload), + contextSummary: 'contextSummary' in payload ? payload.contextSummary : undefined, + compactedCount: payload.compactedCount ?? 0, + tokensBefore: payload.tokensBefore ?? 0, + tokensAfter: payload.tokensAfter ?? 0, + keptUserMessageCount: payload.keptUserMessageCount, + keptHeadUserMessageCount: payload.keptHeadUserMessageCount, + droppedCount: payload.droppedCount, + }; + records.push({ time: NO_TIME, type: 'compaction', result }); + break; + } + case 'full_compaction.begin': + records.push({ time: NO_TIME, type: 'compaction', instruction: entry.payload.instruction }); + break; + case 'full_compaction.cancel': + records.push({ time: NO_TIME, type: 'compaction', result: 'cancelled' }); + break; + case 'full_compaction.complete': + break; + case 'goal.create': + goal = { + goalId: entry.payload.goalId, + objective: entry.payload.objective, + completionCriterion: entry.payload.completionCriterion, + status: 'active', + turnsUsed: 0, + tokensUsed: 0, + wallClockMs: 0, + budgetLimits: {}, + }; + records.push({ + time: NO_TIME, + type: 'goal_updated', + snapshot: goalSnapshot(goal), + change: { kind: 'created' }, + }); + break; + case 'goal.update': { + if (goal === null) break; + const payload = entry.payload; + const statusChanged = payload.status !== undefined && payload.status !== goal.status; + goal = applyGoalUpdate(goal, payload); + // v1 parity: `restoreUpdate` only pushes a replay record when the + // update carries a status transition; pure counter bumps are not + // transcript events. + if (!statusChanged) break; + const change: GoalChange = + payload.status === 'complete' + ? { + kind: 'completion', + status: payload.status, + reason: payload.reason, + stats: { + turnsUsed: goal.turnsUsed, + tokensUsed: goal.tokensUsed, + wallClockMs: goal.wallClockMs, + }, + actor: payload.actor, + } + : { kind: 'lifecycle', status: payload.status, reason: payload.reason, actor: payload.actor }; + records.push({ + time: NO_TIME, + type: 'goal_updated', + snapshot: goalSnapshot(goal), + change, + }); + break; + } + case 'goal.clear': + goal = null; + break; + case 'plan_mode.enter': + records.push({ time: NO_TIME, type: 'plan_updated', enabled: true }); + break; + case 'plan_mode.cancel': + case 'plan_mode.exit': + records.push({ time: NO_TIME, type: 'plan_updated', enabled: false }); + break; + case 'config.update': { + const payload = entry.payload; + records.push({ + time: NO_TIME, + type: 'config_updated', + config: { + cwd: payload.cwd, + modelAlias: payload.modelAlias, + profileName: payload.profileName, + thinkingLevel: payload.thinkingLevel ?? payload.thinkingEffort, + systemPrompt: payload.systemPrompt, + }, + }); + break; + } + case 'permission.set_mode': + records.push({ time: NO_TIME, type: 'permission_updated', mode: entry.payload.mode }); + break; + case 'permission.record_approval_result': + records.push({ time: NO_TIME, type: 'approval_result', record: entry.payload }); + break; + } + } + return records; +} + +/** Mirror of `goalOps` `updateGoal.apply` so per-record snapshots track the timeline. */ +function applyGoalUpdate(state: GoalState, payload: GoalUpdatePayload): GoalState { + let next = state; + if (payload.status !== undefined && payload.status !== state.status) { + next = { + ...next, + status: payload.status, + terminalReason: payload.status === 'active' ? undefined : payload.reason, + }; + } + if (payload.turnsUsed !== undefined && payload.turnsUsed !== next.turnsUsed) { + next = { ...next, turnsUsed: payload.turnsUsed }; + } + if (payload.tokensUsed !== undefined && payload.tokensUsed !== next.tokensUsed) { + next = { ...next, tokensUsed: payload.tokensUsed }; + } + if (payload.wallClockMs !== undefined && payload.wallClockMs !== next.wallClockMs) { + next = { ...next, wallClockMs: payload.wallClockMs }; + } + if (payload.budgetLimits !== undefined && payload.budgetLimits !== next.budgetLimits) { + next = { ...next, budgetLimits: payload.budgetLimits }; + } + return next; +} + +function goalSnapshot(state: GoalState): GoalSnapshot { + return { + goalId: state.goalId, + objective: state.objective, + completionCriterion: state.completionCriterion, + status: state.status, + turnsUsed: state.turnsUsed, + tokensUsed: state.tokensUsed, + wallClockMs: state.wallClockMs, + budget: budgetReport(state.budgetLimits, state), + terminalReason: state.terminalReason, + }; +} + +function budgetReport( + limits: GoalBudgetLimits, + used: { readonly turnsUsed: number; readonly tokensUsed: number; readonly wallClockMs: number }, +): GoalBudgetReport { + const tokenBudget = limits.tokenBudget ?? null; + const turnBudget = limits.turnBudget ?? null; + const wallClockBudgetMs = limits.wallClockBudgetMs ?? null; + const tokenBudgetReached = tokenBudget !== null && used.tokensUsed >= tokenBudget; + const turnBudgetReached = turnBudget !== null && used.turnsUsed >= turnBudget; + const wallClockBudgetReached = wallClockBudgetMs !== null && used.wallClockMs >= wallClockBudgetMs; + return { + tokenBudget, + turnBudget, + wallClockBudgetMs, + remainingTokens: tokenBudget === null ? null : Math.max(0, tokenBudget - used.tokensUsed), + remainingTurns: turnBudget === null ? null : Math.max(0, turnBudget - used.turnsUsed), + remainingWallClockMs: + wallClockBudgetMs === null ? null : Math.max(0, wallClockBudgetMs - used.wallClockMs), + tokenBudgetReached, + turnBudgetReached, + wallClockBudgetReached, + overBudget: tokenBudgetReached || turnBudgetReached || wallClockBudgetReached, + }; +} + +function compactionSummary(payload: ContextCompactionPayload): string { + if (typeof payload.summary === 'string') return payload.summary; + if ('contextSummary' in payload && typeof payload.contextSummary === 'string') { + return payload.contextSummary; + } + // Legacy v1 records stored the literal summary ContextMessage the model saw. + const message = payload.summary as ContextMessage | undefined; + if (message !== undefined && Array.isArray(message.content)) { + return message.content.map((part) => (part?.type === 'text' ? part.text : '')).join(''); + } + return ''; +} diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index c6e9f9fad..4948689e2 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -391,6 +391,8 @@ export * from '#/agent/promptLegacy/promptLegacyService'; import '#/app/messageLegacy/errors'; export * from '#/app/messageLegacy/messageLegacy'; export * from '#/app/messageLegacy/messageLegacyService'; +export * from '#/agent/replayBuilder/agentReplayService'; +export * from '#/agent/replayBuilder/replayProjection'; export * from '#/agent/replayBuilder/replayTimelineModel'; export * from '#/agent/replayBuilder/types'; export * from '#/agent/shellCommand/shellCommand'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 28731e326..6e90e76dd 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -67,6 +67,7 @@ import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { AgentBlobServiceImpl } from '#/agent/blob/agentBlobServiceImpl'; import { IAgentExternalHooksService } from '#/agent/externalHooks/externalHooks'; import { IAgentInteractionTurnBridge } from '#/agent/interaction/interactionTurnBridge'; +import { IAgentReplayService } from '#/agent/replayBuilder/agentReplayService'; import { createHooks } from '#/hooks'; import { @@ -225,6 +226,12 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle // tools would never register until something explicitly requests the service. handle.accessor.get(IAgentMcpService); await mcpReady; + // Force-instantiate the replay service so it attaches the + // `ReplayTimelineModel` derived model before session resume replays this + // agent's wire log: derived models only fold ops that pass through after + // attach, so a late attach would leave resumed sessions with an empty + // replay timeline and the TUI could not rehydrate screen history. + handle.accessor.get(IAgentReplayService); await this.ensureWireMetadata(handle, agentScope); if (opts.binding !== undefined) { await handle.accessor.get(IAgentProfileService).bind(opts.binding); diff --git a/packages/agent-core-v2/test/replayBuilder/replayProjection.test.ts b/packages/agent-core-v2/test/replayBuilder/replayProjection.test.ts new file mode 100644 index 000000000..32a2870ec --- /dev/null +++ b/packages/agent-core-v2/test/replayBuilder/replayProjection.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import { projectReplayTimeline } from '#/agent/replayBuilder/replayProjection'; +import type { ReplayTimeline } from '#/agent/replayBuilder/replayTimelineModel'; + +/** Test entries are partial payloads cast into the op-native timeline shape. */ +function timeline(entries: readonly unknown[]): ReplayTimeline { + return entries as ReplayTimeline; +} + +describe('projectReplayTimeline', () => { + it('projects message and plan ops to v1-shaped replay records', () => { + const records = projectReplayTimeline( + timeline([ + { + type: 'context.append_message', + payload: { + message: { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + }, + }, + { type: 'plan_mode.enter', payload: { id: 'p1' } }, + { type: 'plan_mode.exit', payload: { id: 'p1' } }, + ]), + ); + expect(records).toEqual([ + expect.objectContaining({ type: 'message', message: expect.objectContaining({ role: 'user' }) }), + expect.objectContaining({ type: 'plan_updated', enabled: true }), + expect.objectContaining({ type: 'plan_updated', enabled: false }), + ]); + }); + + it('folds goal ops into per-record snapshots and change records', () => { + const records = projectReplayTimeline( + timeline([ + { type: 'goal.create', payload: { goalId: 'g1', objective: 'ship it' } }, + { type: 'goal.update', payload: { status: 'paused', reason: 'wait' } }, + { type: 'goal.update', payload: { turnsUsed: 3 } }, + { type: 'goal.update', payload: { status: 'complete', reason: 'done' } }, + ]), + ); + // The counter-only update (turnsUsed) is not a transcript event. + expect(records).toHaveLength(3); + expect(records[0]).toMatchObject({ + type: 'goal_updated', + change: { kind: 'created' }, + snapshot: { goalId: 'g1', objective: 'ship it', status: 'active' }, + }); + expect(records[1]).toMatchObject({ + type: 'goal_updated', + change: { kind: 'lifecycle', status: 'paused', reason: 'wait' }, + snapshot: { status: 'paused' }, + }); + expect(records[2]).toMatchObject({ + type: 'goal_updated', + change: { kind: 'completion', status: 'complete', stats: { turnsUsed: 3 } }, + snapshot: { status: 'complete', turnsUsed: 3 }, + }); + }); + + it('drops goal state on clear so later updates produce no records', () => { + const records = projectReplayTimeline( + timeline([ + { type: 'goal.create', payload: { goalId: 'g1', objective: 'x' } }, + { type: 'goal.clear', payload: {} }, + { type: 'goal.update', payload: { status: 'paused' } }, + ]), + ); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ change: { kind: 'created' } }); + }); + + it('projects compaction ops into begin/result/cancelled records', () => { + const records = projectReplayTimeline( + timeline([ + { type: 'full_compaction.begin', payload: { instruction: 'keep facts', source: 'manual' } }, + { + type: 'context.apply_compaction', + payload: { summary: 'condensed', compactedCount: 4, tokensBefore: 1000, tokensAfter: 200 }, + }, + { type: 'full_compaction.complete', payload: {} }, + { type: 'full_compaction.cancel', payload: {} }, + ]), + ); + expect(records).toEqual([ + expect.objectContaining({ type: 'compaction', instruction: 'keep facts' }), + expect.objectContaining({ + type: 'compaction', + result: expect.objectContaining({ + summary: 'condensed', + compactedCount: 4, + tokensBefore: 1000, + tokensAfter: 200, + }), + }), + expect.objectContaining({ type: 'compaction', result: 'cancelled' }), + ]); + }); + + it('projects permission, approval, and config ops', () => { + const approval = { toolCallId: 't1', toolName: 'Bash', action: 'run', result: { decision: 'approved' } }; + const records = projectReplayTimeline( + timeline([ + { type: 'permission.set_mode', payload: { mode: 'yolo' } }, + { type: 'permission.record_approval_result', payload: approval }, + { type: 'config.update', payload: { modelAlias: 'k2' } }, + ]), + ); + expect(records).toEqual([ + expect.objectContaining({ type: 'permission_updated', mode: 'yolo' }), + expect.objectContaining({ type: 'approval_result', record: approval }), + expect.objectContaining({ type: 'config_updated', config: { modelAlias: 'k2' } }), + ]); + }); +}); diff --git a/packages/node-sdk/src/v2/host.ts b/packages/node-sdk/src/v2/host.ts index 1820187fa..bedb89d48 100644 --- a/packages/node-sdk/src/v2/host.ts +++ b/packages/node-sdk/src/v2/host.ts @@ -14,15 +14,25 @@ * `plan/agent-core-v2-cli-adapter-p0.md`. */ -import type { CoreAPI, CoreRPCClient, RPCMethods, SDKAPI } from '@moonshot-ai/agent-core'; +import { + ErrorCodes, + KimiError, + type CoreAPI, + type CoreRPCClient, + type ResumeSessionResult, + type RPCMethods, + type SDKAPI, +} from '@moonshot-ai/agent-core'; import { bootstrap, ensureMainAgent, IAgentPermissionModeService, IAgentRPCService, + IBootstrapService, IEventBus, IEventService, IAgentLifecycleService, + IAgentReplayService, IAgentScopeContext, ICliSkillDirs, IConfigService, @@ -33,7 +43,9 @@ import { ISessionExportService, ISessionIndex, ISessionLifecycleService, + ISessionMetadata, ISessionSkillCatalog, + ISessionTodoService, ISessionWorkspaceCommandService, IWebSearchProviderService, IWorkspaceRegistry, @@ -49,8 +61,9 @@ import { type Scope, type ServiceIdentifier, } from '@moonshot-ai/agent-core-v2'; +import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; -import type { KimiHostIdentity } from '#/types'; +import type { JsonObject, KimiHostIdentity, SessionSummary as WireSessionSummary } from '#/types'; /** v1 AgentAPI method names (the `KimiCore` contract the SDK client calls). */ const AGENT_API_METHODS: readonly string[] = [ @@ -378,8 +391,100 @@ export class V2Host { // child agents, and forwards HITL exactly like a fresh one. const disposables = this.attachSessionBridges(handle, sessionId, main); this.sessions.set(sessionId, { handle, main, disposables }); - const now = Date.now(); - return { id: sessionId, workDir: '', sessionDir: '', createdAt: now, updatedAt: now }; + return this.buildResumeResult(handle, main); + } + + async reloadSession(payload: { sessionId: string }): Promise { + // v1 parity (`core-impl.ts` `reloadSession`): tear the live session down + // and resume it fresh so config/plugin changes take effect, returning the + // same full resume-state assembly as `resumeSession`. + const slot = this.sessions.get(payload.sessionId); + if (slot !== undefined) { + slot.disposables?.forEach((d) => { + try { + d.dispose(); + } catch { + // ignore + } + }); + this.sessions.delete(payload.sessionId); + await this.app.accessor.get(ISessionLifecycleService).close(payload.sessionId); + } + return this.resumeSession(payload); + } + + /** + * Assemble the v1 `ResumeSessionResult` the SDK/TUI hydrates a resumed + * session from (`Session.getResumeState()` → `hydrateFromReplay`): the + * summary plus `sessionMetadata` and a per-agent state record. Every getter + * on the v2 `IAgentRPCService` is already v1-shaped (see the file header), + * and the replay records come from the v2 `ReplayTimelineModel` projection, + * a JSON-compatible mirror of v1's DTO — hence the single boundary cast on + * the agent state and on the metadata (v2 `SessionMeta` uses epoch-ms + * timestamps and `cwd`, the v1 wire shape uses ISO strings and `workDir`). + */ + private async buildResumeResult( + handle: ISessionScopeHandle, + main: IAgentScopeHandle, + ): Promise { + // A successful resume has already resolved the session's cwd (`doResume` + // falls back summary.cwd → workspace registry and aborts when neither is + // recoverable), so the seeded context always carries the real values. + const ctx = handle.accessor.get(ISessionContext); + const meta = await handle.accessor.get(ISessionMetadata).read(); + const rpc = main.accessor.get(IAgentRPCService); + const replay = main.accessor.get(IAgentReplayService).getReplayRecords(); + // v1 parity: `toolStore.todo` carries the session todo list — v1 kept it + // on the agent tool store, v2 owns it at session scope. + const todos = handle.accessor.get(ISessionTodoService).getTodos(); + const [config, context, permission, plan, swarmMode, usage, tools, background] = + await Promise.all([ + rpc.getConfig({}), + rpc.getContext({}), + rpc.getPermission({}), + rpc.getPlan({}), + rpc.getSwarmMode({}), + rpc.getUsage({}), + rpc.getTools({}), + rpc.getTasks({ activeOnly: false }), + ]); + const sessionMetadata = { + createdAt: new Date(meta.createdAt).toISOString(), + updatedAt: new Date(meta.updatedAt).toISOString(), + title: meta.title ?? '', + isCustomTitle: meta.isCustomTitle ?? false, + lastPrompt: meta.lastPrompt, + forkedFrom: meta.forkedFrom, + workDir: meta.cwd ?? ctx.cwd, + agents: { ...meta.agents }, + custom: { ...meta.custom }, + } as unknown as ResumeSessionResult['sessionMetadata']; + const mainState = { + type: 'main', + config, + context, + replay, + permission, + plan, + swarmMode, + usage, + tools, + toolStore: { todo: [...todos] }, + background, + } as unknown as ResumeSessionResult['agents'][string]; + return { + id: ctx.sessionId, + title: meta.title, + lastPrompt: meta.lastPrompt, + workDir: ctx.cwd, + sessionDir: ctx.sessionDir, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + archived: meta.archived, + metadata: meta.custom as JsonObject | undefined, + sessionMetadata, + agents: { main: mainState }, + }; } async forkSession(payload: { @@ -401,16 +506,13 @@ export class V2Host { }); const ctx = handle.accessor.get(ISessionContext); const sessionId = ctx.sessionId; - const workDir = - (await this.app.accessor.get(IWorkspaceRegistry).get(ctx.workspaceId))?.root ?? ''; const main = await ensureMainAgent(handle); // Force-instantiate session-scope tool providers (cron registers its tools // into the main agent's registry on construction). handle.accessor.get(ISessionCronService); const disposables = this.attachSessionBridges(handle, sessionId, main); this.sessions.set(sessionId, { handle, main, disposables }); - const now = Date.now(); - return { id: sessionId, workDir, sessionDir: ctx.sessionDir, createdAt: now, updatedAt: now }; + return this.buildResumeResult(handle, main); } private attachSessionBridges( @@ -510,10 +612,54 @@ export class V2Host { return disposables; } - async listSessions(): Promise { - const page = await this.app.accessor.get(ISessionIndex).list({}); - const items = (page as { items?: readonly unknown[] } | undefined)?.items ?? page ?? []; - return items as readonly unknown[]; + async listSessions( + payload: { + workDir?: string; + sessionId?: string; + includeArchive?: boolean; + childOf?: string; + limit?: number; + } = {}, + ): Promise { + // v1 parity: `SessionStore.list` scopes the query by workDir bucket + // (`encodeWorkDirKey`) and rejects blank workDir outright. v1 returns a + // plain array; the v2 index answers a `Page` — the edge + // unwraps it. + if (payload.workDir !== undefined && payload.workDir.trim() === '') { + throw new KimiError(ErrorCodes.REQUEST_WORK_DIR_REQUIRED, 'listSessions requires workDir'); + } + const query = { + workspaceId: + payload.workDir === undefined ? undefined : encodeWorkDirKey(payload.workDir), + sessionId: payload.sessionId, + includeArchived: payload.includeArchive === true ? true : undefined, + childOf: payload.childOf, + limit: payload.limit, + }; + const page = await this.app.accessor.get(ISessionIndex).list(query); + const registry = this.app.accessor.get(IWorkspaceRegistry); + const bootstrapService = this.app.accessor.get(IBootstrapService); + const summaries: WireSessionSummary[] = []; + for (const item of page.items) { + // The wire contract has `workDir` as a required field, and sessions + // omit it only for documents predating cwd persistence; mirror the + // resume edge's fallback to the workspace registry, then to a fixed + // placeholder so old records still round-trip. + const workDir = + item.cwd ?? (await registry.get(item.workspaceId))?.root ?? '(unknown)'; + summaries.push({ + id: item.id, + title: item.title, + lastPrompt: item.lastPrompt, + workDir, + sessionDir: bootstrapService.sessionDir(item.workspaceId, item.id), + createdAt: item.createdAt, + updatedAt: item.updatedAt, + archived: item.archived, + metadata: item.custom as JsonObject | undefined, + }); + } + return summaries; } async closeSession(payload: { sessionId: string }): Promise { diff --git a/packages/node-sdk/test/session-plan-compact-usage-resume.test.ts b/packages/node-sdk/test/session-plan-compact-usage-resume.test.ts index dac29c2c1..e835ab9cd 100644 --- a/packages/node-sdk/test/session-plan-compact-usage-resume.test.ts +++ b/packages/node-sdk/test/session-plan-compact-usage-resume.test.ts @@ -157,6 +157,44 @@ describe('Session plan, compact, usage, and resume APIs', () => { } }); + it('resume exposes replay state for TUI history hydration', async () => { + const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-resume-state-home-'); + const workDir = await makeTempDir(tempDirs, 'kimi-sdk-resume-state-work-'); + await writeTestConfig(homeDir); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + try { + const created = await harness.createSession({ + id: 'ses_resume_state_runtime', + workDir, + model: 'test-model', + }); + await created.setPlanMode(true); + await created.setPlanMode(false); + await created.close(); + + const resumed = await harness.resumeSession({ id: created.id }); + const state = resumed.getResumeState(); + + // `hydrateFromReplay` in the TUI gates on `agents['main']`; assert the + // full shape it hydrates from so a bare-summary resume regression fails + // here instead of as "Session history is unavailable" on screen. + expect(state).toBeDefined(); + const main = state?.agents['main']; + expect(main).toBeDefined(); + expect(main?.type).toBe('main'); + expect(main?.replay).toContainEqual( + expect.objectContaining({ type: 'plan_updated', enabled: true }), + ); + expect(typeof main?.toolStore).toBe('object'); + expect(Array.isArray(main?.background)).toBe(true); + expect(state?.sessionMetadata.workDir).toBe(resumed.workDir); + expect(typeof state?.sessionMetadata.createdAt).toBe('string'); + } finally { + await harness.close(); + } + }); + it.todo('marks resumed plan mode active when the restored plan has no plan data', async () => { const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-resume-legacy-plan-home-'); const workDir = await makeTempDir(tempDirs, 'kimi-sdk-resume-legacy-plan-work-');