diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index ffbc38ac9..fe624516a 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -135,6 +135,7 @@ const DOMAIN_LAYER = new Map([ ['promptLegacy', 7], ['sessionLegacy', 7], ['authLegacy', 7], + ['messageLegacy', 7], ]); const V1_PACKAGE = '@moonshot-ai/agent-core'; diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index d8f420ec9..eb4f32717 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -19,6 +19,7 @@ import { FullCompactionErrors } from '#/fullCompaction/errors'; import { GoalErrors } from '#/goal/errors'; import { LoopErrors } from '#/loop/errors'; import { McpErrors } from '#/mcp/errors'; +import { MessageLegacyErrors } from '#/messageLegacy/errors'; import { ModelCatalogErrors } from '#/modelCatalog/errors'; import { PluginErrors } from '#/plugin/errors'; import { ProfileErrors } from '#/profile/errors'; @@ -42,6 +43,7 @@ export { FullCompactionErrors } from '#/fullCompaction/errors'; export { GoalErrors } from '#/goal/errors'; export { LoopErrors } from '#/loop/errors'; export { McpErrors } from '#/mcp/errors'; +export { MessageLegacyErrors } from '#/messageLegacy/errors'; export { ModelCatalogErrors } from '#/modelCatalog/errors'; export { PluginErrors } from '#/plugin/errors'; export { ProfileErrors } from '#/profile/errors'; @@ -66,6 +68,7 @@ export const ErrorCodes = { ...GoalErrors.codes, ...LoopErrors.codes, ...McpErrors.codes, + ...MessageLegacyErrors.codes, ...ModelCatalogErrors.codes, ...PluginErrors.codes, ...ProfileErrors.codes, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index e1b2c5e7f..5b4899277 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -84,6 +84,7 @@ export * from './permissionRules/index'; export * from './profile/index'; export * from './prompt/index'; export * from './promptLegacy/index'; +export * from './messageLegacy/index'; export * from './replayBuilder/index'; export * from './rpc/index'; export * from './subagentHost/index'; diff --git a/packages/agent-core-v2/src/messageLegacy/errors.ts b/packages/agent-core-v2/src/messageLegacy/errors.ts new file mode 100644 index 000000000..6eeb1f5f5 --- /dev/null +++ b/packages/agent-core-v2/src/messageLegacy/errors.ts @@ -0,0 +1,9 @@ +/** + * `messageLegacy` domain error codes — v1-compatible message failures. + */ + +export const MessageLegacyErrors = { + codes: { + MESSAGE_NOT_FOUND: 'message.not_found', + }, +} as const; diff --git a/packages/agent-core-v2/src/messageLegacy/index.ts b/packages/agent-core-v2/src/messageLegacy/index.ts new file mode 100644 index 000000000..8edc1978a --- /dev/null +++ b/packages/agent-core-v2/src/messageLegacy/index.ts @@ -0,0 +1,9 @@ +/** + * `messageLegacy` domain barrel — re-exports the v1 message-history adapter + * contract and implementation. Importing this barrel registers the + * `message.not_found` error code and the `IMessageLegacyService` binding. + */ + +import './errors'; +export * from './messageLegacy'; +export * from './messageLegacyService'; diff --git a/packages/agent-core-v2/src/messageLegacy/messageLegacy.ts b/packages/agent-core-v2/src/messageLegacy/messageLegacy.ts new file mode 100644 index 000000000..6694800af --- /dev/null +++ b/packages/agent-core-v2/src/messageLegacy/messageLegacy.ts @@ -0,0 +1,54 @@ +/** + * `messageLegacy` domain (L7 edge adapter) — v1-compatible message history. + * + * Implements the legacy `GET /api/v1/sessions/{sid}/messages[/{mid}]` contract + * (`packages/server/src/routes/messages.ts`) on top of the native v2 services. + * + * The native `IContextMemory` (Agent scope, serving `/api/v2` `messages:*`) + * holds the model's CURRENT, folded context and is left untouched. For a live + * session this adapter reads that folded history (its transcript is in memory + * by definition); for a cold session it loads the session, restores the main + * agent's wire log, and reads the FULL transcript from `IReplayBuilderService` + * (pre-compaction messages preserved, matching v1's `wire.jsonl` rebuild). The + * `ContextMessage → Message` projection is shared with the `snapshot` and + * `:undo` edges via `contextMemory/messageProjection`. Bound at Core scope — a + * stateless dispatcher that resolves the target session/agent per call. + * + * Error contract (mapped at the route layer): + * - `session.not_found` → 40401 + * - `message.not_found` → 40403 + */ + +import type { + CursorQuery, + Message, + MessageRole, + PageResponse, +} from '@moonshot-ai/protocol'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +/** Listing query — v1 `cursorQuery` plus an optional role filter. */ +export interface MessageListQuery extends CursorQuery { + readonly role?: MessageRole; +} + +export interface IMessageLegacyService { + readonly _serviceBrand: undefined; + + /** + * `GET /sessions/{sid}/messages` — paginated, newest-first message history. + * Throws `session.not_found` when `sid` is unknown. + */ + list(sessionId: string, query: MessageListQuery): Promise>; + + /** + * `GET /sessions/{sid}/messages/{mid}` — single message by id. + * Throws `session.not_found` when `sid` is unknown, `message.not_found` when + * the session is known but `mid` is missing, mismatched, or out of range. + */ + get(sessionId: string, messageId: string): Promise; +} + +export const IMessageLegacyService: ServiceIdentifier = + createDecorator('messageLegacyService'); diff --git a/packages/agent-core-v2/src/messageLegacy/messageLegacyService.ts b/packages/agent-core-v2/src/messageLegacy/messageLegacyService.ts new file mode 100644 index 000000000..0db3d29f3 --- /dev/null +++ b/packages/agent-core-v2/src/messageLegacy/messageLegacyService.ts @@ -0,0 +1,176 @@ +/** + * `messageLegacy` domain — `IMessageLegacyService` implementation. + * + * Stateless Core-scope dispatcher: each call resolves the target session (and + * its main agent), sources the transcript, and projects it into the v1 wire + * shape. Live sessions are read from the main agent's `IContextMemory` (the + * folded history already in memory); cold sessions are loaded, their main agent + * is restored from the persisted wire log, and the FULL transcript is read from + * `IReplayBuilderService` — v2's own replay reducer, so no reduction logic is + * duplicated here. Pagination, id derivation, and the role filter mirror v1's + * `MessageService` (`packages/agent-core/src/services/message/messageService.ts`). + */ + +import type { Message, PageResponse } from '@moonshot-ai/protocol'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { type IScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentLifecycleService } from '#/agent-lifecycle'; +import { + IContextMemory, + parseMessageId, + toProtocolMessage, + type ContextMessage, +} from '#/contextMemory'; +import { ErrorCodes, KimiError } from '#/errors'; +import { IReplayBuilderService } from '#/replayBuilder'; +import { ISessionIndex, type SessionSummary } from '#/session-index'; +import { ISessionLifecycleService } from '#/session-lifecycle'; +import { IWireRecord } from '#/wireRecord'; +import { IWorkspaceRegistry } from '#/workspaceRegistry'; + +import { IMessageLegacyService, type MessageListQuery } from './messageLegacy'; + +const MAIN_AGENT_ID = 'main'; +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 100; + +export class MessageLegacyService implements IMessageLegacyService { + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService, + @ISessionIndex private readonly index: ISessionIndex, + @IWorkspaceRegistry private readonly workspaceRegistry: IWorkspaceRegistry, + ) {} + + async list(sessionId: string, query: MessageListQuery): Promise> { + const all = await this.loadMessages(sessionId); + // v1 / SCHEMAS §1.3: newest first (`created_at desc`). + const desc = [...all].reverse(); + + let pivotIndex = -1; + if (query.before_id !== undefined) { + pivotIndex = desc.findIndex((m) => m.id === query.before_id); + } else if (query.after_id !== undefined) { + pivotIndex = desc.findIndex((m) => m.id === query.after_id); + } + + let slice: Message[]; + if (query.before_id !== undefined && pivotIndex >= 0) { + // before_id = older entries → tail of the desc array, exclusive of pivot. + slice = desc.slice(pivotIndex + 1); + } else if (query.after_id !== undefined && pivotIndex >= 0) { + // after_id = newer entries → head of the desc array, exclusive of pivot. + slice = desc.slice(0, pivotIndex); + } else { + // Unknown cursor → fall through to the full list, matching v1. + slice = desc; + } + + const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE; + const pageSize = Math.min(Math.max(requestedSize, 1), MAX_PAGE_SIZE); + const page = slice.slice(0, pageSize); + const hasMore = slice.length > pageSize; + + // Role filter is applied AFTER pagination, matching v1. + const filtered = query.role !== undefined ? page.filter((m) => m.role === query.role) : page; + + return { items: filtered, has_more: hasMore }; + } + + async get(sessionId: string, messageId: string): Promise { + // Resolve the session first: an unknown sid maps to 40401 even when the + // message id is malformed or belongs to another session (40403). + const all = await this.loadMessages(sessionId); + const parsed = parseMessageId(messageId); + if (parsed === undefined || parsed.sessionId !== sessionId) { + throw new KimiError( + ErrorCodes.MESSAGE_NOT_FOUND, + `message ${messageId} does not exist in session ${sessionId}`, + ); + } + const entry = all[parsed.index]; + if (entry === undefined) { + throw new KimiError( + ErrorCodes.MESSAGE_NOT_FOUND, + `message ${messageId} does not exist in session ${sessionId}`, + ); + } + return entry; + } + + /** + * Full main-agent transcript projected into the v1 `Message` wire shape, + * oldest-first. Throws `session.not_found` (→ 40401) when the session is + * unknown. An unreachable cold session (workspace gone) yields an empty + * transcript rather than an error. + */ + private async loadMessages(sessionId: string): Promise { + const summary = await this.index.get(sessionId); + if (summary === undefined) { + throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + } + + const agent = await this.resolveMainAgent(sessionId, summary); + if (agent === undefined) return []; + + // Prefer the replay transcript: it is populated only by `wireRecord.restore` + // (live-appended records are not captured), so a non-empty replay means the + // agent was restored and the replay holds the full pre-compaction history. + // Otherwise the agent is fresh (never restored) and the folded context + // memory IS the transcript. + const replay = agent.accessor.get(IReplayBuilderService).buildResult(); + const restored: ContextMessage[] = []; + for (const record of replay) { + if (record.type === 'message') restored.push(record.message); + } + const source = restored.length > 0 ? restored : agent.accessor.get(IContextMemory).get(); + + return source.map((msg, index) => toProtocolMessage(sessionId, index, msg, summary.createdAt)); + } + + /** + * Resolve the session's main agent, loading + restoring it from the persisted + * wire log when the session is cold. Returns `undefined` only when a cold + * session's workspace is gone and the session directory cannot be + * reconstructed (mirrors the `fork` limitation). + */ + private async resolveMainAgent( + sessionId: string, + summary: SessionSummary, + ): Promise { + let session = this.lifecycle.get(sessionId); + let loaded = false; + if (session === undefined) { + const workspace = await this.workspaceRegistry.get(summary.workspaceId); + if (workspace === undefined) return undefined; + session = await this.lifecycle.create({ sessionId, workDir: workspace.root }); + loaded = true; + } + + const agents = session.accessor.get(IAgentLifecycleService); + let agent = agents.getHandle(MAIN_AGENT_ID); + if (agent === undefined) { + agent = await agents.createMain(); + // Restore only the agent we just materialized from a cold session; an + // already-live agent must not be re-restored (it would re-apply splices). + if (loaded) { + // `IContextMemory` registers the `context.splice` resumer and pulls in + // `IReplayBuilderService`; both are Delayed, so resolve them before + // replaying the wire or the restore would replay into a void. + agent.accessor.get(IContextMemory); + await agent.accessor.get(IWireRecord).restore(); + } + } + return agent; + } +} + +registerScopedService( + LifecycleScope.Core, + IMessageLegacyService, + MessageLegacyService, + InstantiationType.Delayed, + 'messageLegacy', +); diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 788e121d7..b299b635a 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -220,6 +220,7 @@ export type KimiErrorCode = | 'mcp.server_disabled' | 'mcp.startup_failed' | 'mcp.tool_name_collision' + | 'message.not_found' | 'plugin.not_found' | 'plugin.load_failed' | 'request.invalid' @@ -868,6 +869,7 @@ export const kimiErrorCodeSchema = z.enum([ 'mcp.server_disabled', 'mcp.startup_failed', 'mcp.tool_name_collision', + 'message.not_found', 'plugin.not_found', 'plugin.load_failed', 'request.invalid', diff --git a/packages/server-v2/src/routes/messages.ts b/packages/server-v2/src/routes/messages.ts index 56a7b766c..398057f22 100644 --- a/packages/server-v2/src/routes/messages.ts +++ b/packages/server-v2/src/routes/messages.ts @@ -2,58 +2,32 @@ * `/sessions/{session_id}/messages*` route handlers — server-v2 port. * * Implements the v1 `/api/v1/sessions/{sid}/messages` wire contract on top of - * `agent-core-v2`: + * `IMessageLegacyService` (`packages/agent-core-v2/src/messageLegacy`), which + * reads the persisted wire transcript for cold sessions and the live context + * for live ones. This route is a thin adapter: it resolves the Core-scoped + * legacy service, projects the result into the protocol envelope, and maps the + * domain error codes to the v1 wire codes. + * * GET /sessions/{session_id}/messages query: ListMessages data: Page * GET /sessions/{session_id}/messages/{message_id} - data: Message * - * **Thin wrapper over `IContextMemory`**: the main agent's `IContextMemory` is - * already exposed at Agent scope (`messages:list` in the RPC action map). These - * REST routes borrow it by interface and project its `ContextMessage[]` history - * into the protocol's `Message` shape, then apply the same id-derivation, - * pagination and role-filter semantics as v1 - * (`packages/agent-core/src/services/message/message.ts`). - * - * **History source**: the live in-memory history (`IContextMemory.get()`), not - * the persisted wire transcript. Unlike v1, this slice does not rebuild the - * pre-compaction transcript from `wire.jsonl` — a compacted agent reports the - * folded view. Rebuilding the full transcript is left as a gap until v2 exposes - * a wire-read facade. - * - * **Resolution**: `core` → `ISessionIndex` (existence + `createdAt` base) → - * `ISessionLifecycleService` (live session handle) → `IAgentLifecycleService` - * (the `main` agent) → `IContextMemory`. When the session is not live or has no - * main agent yet (server-v2 gap G10 — the main agent is not created on session - * creation), the history is empty: `list` returns an empty page and `get` - * answers `40403`. - * * **Error mapping**: * - unknown session → `40401` (session.not_found) - * - unknown message → `40403` (message.not_found) + * - unknown message → `40403` (message.not_found, get endpoint only) * - invalid query → `40001` (validation.failed, via defineRoute) */ -import { - IContextMemory, - ISessionIndex, - ISessionLifecycleService, - type Scope, -} from '@moonshot-ai/agent-core-v2'; +import { IMessageLegacyService, isKimiError, type Scope } from '@moonshot-ai/agent-core-v2'; import { ErrorCode, getMessageResponseSchema, listMessagesResponseSchema, messageRoleSchema, } from '@moonshot-ai/protocol'; -import type { Message, MessageContent, MessageRole, ToolUseContent } from '@moonshot-ai/protocol'; import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; import { defineRoute } from '../middleware/defineRoute'; -import { ensureMainAgent } from '../transport/mainAgent'; -import { parseMessageId, toProtocolMessage } from './_messageProjection'; - -const DEFAULT_PAGE_SIZE = 50; -const MAX_PAGE_SIZE = 100; interface MessageRouteHost { get( @@ -123,45 +97,13 @@ export function registerMessagesRoutes(app: MessageRouteHost, core: Scope): void tags: ['messages'], }, async (req, reply) => { - const { session_id } = req.params; - const loaded = await loadProtocolMessages(core, session_id); - if (loaded === undefined) { - reply.send(sessionNotFound(session_id, req.id)); - return; + try { + const { session_id } = req.params; + const page = await core.accessor.get(IMessageLegacyService).list(session_id, req.query); + reply.send(okEnvelope(page, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); } - - const query = req.query; - // SCHEMAS §1.3: newest first (`created_at desc`). - const desc = [...loaded].reverse(); - - let pivotIndex = -1; - if (query.before_id !== undefined) { - pivotIndex = desc.findIndex((m) => m.id === query.before_id); - } else if (query.after_id !== undefined) { - pivotIndex = desc.findIndex((m) => m.id === query.after_id); - } - - let slice: Message[]; - if (query.before_id !== undefined && pivotIndex >= 0) { - // before_id = older entries → tail of the desc array, exclusive of pivot. - slice = desc.slice(pivotIndex + 1); - } else if (query.after_id !== undefined && pivotIndex >= 0) { - // after_id = newer entries → head of the desc array, exclusive of pivot. - slice = desc.slice(0, pivotIndex); - } else { - slice = desc; - } - - const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE; - const pageSize = Math.min(Math.max(requestedSize, 1), MAX_PAGE_SIZE); - const page = slice.slice(0, pageSize); - const hasMore = slice.length > pageSize; - - // Role filter is applied AFTER pagination, matching v1. - const filtered = - query.role !== undefined ? page.filter((m) => m.role === query.role) : page; - - reply.send(okEnvelope({ items: filtered, has_more: hasMore }, req.id)); }, ); app.get( @@ -186,26 +128,13 @@ export function registerMessagesRoutes(app: MessageRouteHost, core: Scope): void tags: ['messages'], }, async (req, reply) => { - const { session_id, message_id } = req.params; - // Resolve the session first: an unknown sid maps to 40401 even when the - // message id is malformed or belongs to another session (40403). - const loaded = await loadProtocolMessages(core, session_id); - if (loaded === undefined) { - reply.send(sessionNotFound(session_id, req.id)); - return; + try { + const { session_id, message_id } = req.params; + const message = await core.accessor.get(IMessageLegacyService).get(session_id, message_id); + reply.send(okEnvelope(message, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); } - - const parsed = parseMessageId(message_id); - if (parsed === undefined || parsed.sessionId !== session_id) { - reply.send(messageNotFound(session_id, message_id, req.id)); - return; - } - const entry = loaded[parsed.index]; - if (entry === undefined) { - reply.send(messageNotFound(session_id, message_id, req.id)); - return; - } - reply.send(okEnvelope(entry, req.id)); }, ); app.get( @@ -215,37 +144,28 @@ export function registerMessagesRoutes(app: MessageRouteHost, core: Scope): void ); } -// --------------------------------------------------------------------------- -// Resolution — walk core → session → main agent → live history. Returns the -// full protocol-shaped transcript in ascending order, or `undefined` when the -// session does not exist (→ 40401). A missing live session / main agent yields -// an empty history (gap G10), not a 404. -// --------------------------------------------------------------------------- - -async function loadProtocolMessages(core: Scope, sid: string): Promise { - const summary = await core.accessor.get(ISessionIndex).get(sid); - if (summary === undefined) return undefined; - - const session = core.accessor.get(ISessionLifecycleService).get(sid); - if (session === undefined) return []; - const agent = await ensureMainAgent(session); - const history = agent.accessor.get(IContextMemory).get(); - - return history.map((msg, index) => toProtocolMessage(sid, index, msg, summary.createdAt)); -} - -// --------------------------------------------------------------------------- -// Error envelopes -// --------------------------------------------------------------------------- - -function sessionNotFound(sid: string, requestId: string): unknown { - return errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${sid} does not exist`, requestId); -} - -function messageNotFound(sid: string, mid: string, requestId: string): unknown { - return errEnvelope( - ErrorCode.MESSAGE_NOT_FOUND, - `message ${mid} does not exist in session ${sid}`, - requestId, +/** + * Map a thrown `KimiError` to the right envelope: + * - `session.not_found` → `code: 40401` + * - `message.not_found` → `code: 40403` + * - anything else → `code: 50001`. + */ +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (isKimiError(err)) { + switch (err.code) { + case 'session.not_found': + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId)); + return; + case 'message.not_found': + reply.send(errEnvelope(ErrorCode.MESSAGE_NOT_FOUND, err.message, requestId)); + return; + } + } + reply.send( + errEnvelope(ErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : String(err), requestId), ); } diff --git a/packages/server-v2/test/messages.test.ts b/packages/server-v2/test/messages.test.ts index 4d447b291..98f0e6fbe 100644 --- a/packages/server-v2/test/messages.test.ts +++ b/packages/server-v2/test/messages.test.ts @@ -6,8 +6,10 @@ import { IAgentLifecycleService, IContextMemory, ISessionLifecycleService, + IWireRecord, modelResolverSeed, SingleModelResolver, + type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -39,6 +41,7 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => { let server: RunningServer | undefined; let home: string | undefined; let base: string; + let seeds: ScopeSeed | undefined; beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-messages-')); @@ -49,15 +52,20 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => { model: 'stub', apiKey: 'stub', }); + seeds = modelResolverSeed(modelResolver); + await boot(); + }); + + async function boot(): Promise { server = await startServer({ host: '127.0.0.1', port: 0, - homeDir: home, + homeDir: home as string, logLevel: 'silent', - seeds: modelResolverSeed(modelResolver), + seeds, }); base = `http://127.0.0.1:${server.port}`; - }); + } afterEach(async () => { if (server !== undefined) { @@ -101,6 +109,9 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => { } if (messages.length > 0) { agent.accessor.get(IContextMemory).splice(0, 0, messages); + // Flush the wire log so the temp home is quiescent before afterEach rm's + // it (macOS can ENOTEMPTY an rmdir while an append is still in flight). + await agent.accessor.get(IWireRecord).flush(); } } @@ -257,4 +268,66 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => { expect(body.data.items.every((m) => m.role === 'user')).toBe(true); expect(body.data.items.map((m) => m.id)).toEqual([messageId(id, 2), messageId(id, 0)]); }); + + // Regression for the cold-session gap: a persisted (non-live) session must + // return its full wire transcript — including the pre-compaction prefix — + // instead of an empty page / 40403. We seed the wire log through the live + // agent (splice + a compaction fold + flush), then restart the whole server + // on the same home so the session is genuinely cold on the read path. + it('reads the persisted full transcript for a cold session', async () => { + const id = await createSession(); + const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + if (session === undefined) throw new Error(`session ${id} not found`); + const agent = await session.accessor.get(IAgentLifecycleService).createMain(); + const ctx = agent.accessor.get(IContextMemory); + // Three messages, then a compaction that folds the prefix into a summary. + ctx.splice(0, 0, [ + { role: 'user', content: [{ type: 'text', text: 'm0' }], toolCalls: [] }, + { role: 'assistant', content: [{ type: 'text', text: 'm1' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: 'm2' }], toolCalls: [] }, + ]); + ctx.splice(0, 3, [ + { + role: 'assistant', + content: [{ type: 'text', text: 'summary' }], + toolCalls: [], + origin: { kind: 'compaction_summary' }, + }, + ]); + await agent.accessor.get(IWireRecord).flush(); + + // Restart the server on the same homeDir → the session is cold for the next + // read (mirrors a session carried over from a prior process). + await server!.close(); + server = undefined; + await boot(); + + // Full transcript preserved (pre-compaction m0/m1/m2 + summary), newest first. + const { body } = await getJson(`/api/v1/sessions/${id}/messages?page_size=100`); + expect(body.code).toBe(0); + expect(body.data.items.map((m) => m.id)).toEqual([ + messageId(id, 3), + messageId(id, 2), + messageId(id, 1), + messageId(id, 0), + ]); + expect(body.data.items[0]).toMatchObject({ + id: messageId(id, 3), + role: 'assistant', + metadata: { origin: { kind: 'compaction_summary' } }, + }); + + // get returns a specific message for a cold session … + const got = await getJson(`/api/v1/sessions/${id}/messages/${messageId(id, 1)}`); + expect(got.body.code).toBe(0); + expect(got.body.data).toMatchObject({ + id: messageId(id, 1), + role: 'assistant', + content: [{ type: 'text', text: 'm1' }], + }); + + // … and 40403 for an unknown message id in the same cold session. + const missing = await getJson(`/api/v1/sessions/${id}/messages/${messageId(id, 99)}`); + expect(missing.body.code).toBe(40403); + }); });