mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-10 17:29:18 +00:00
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.
This commit is contained in:
parent
cc3dd74d41
commit
f2e65a4552
13 changed files with 668 additions and 13 deletions
5
.changeset/fix-resume-history-v2.md
Normal file
5
.changeset/fix-resume-history-v2.md
Normal file
|
|
@ -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.
|
||||
5
.changeset/fix-sessions-picker-crash-v2.md
Normal file
5
.changeset/fix-sessions-picker-crash-v2.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<IAgentReplayService> =
|
||||
createDecorator<IAgentReplayService>('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',
|
||||
);
|
||||
|
|
@ -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 '';
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' } }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<unknown> {
|
||||
// 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<ResumeSessionResult> {
|
||||
// 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<readonly unknown[]> {
|
||||
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<readonly WireSessionSummary[]> {
|
||||
// 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<SessionSummary>` — 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<void> {
|
||||
|
|
|
|||
|
|
@ -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-');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue