mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 22:26:23 +00:00
fix(server-v2): serve cold session messages from wire transcript
- add MessageLegacyService (agent-core-v2 L7 edge adapter) implementing the
v1 GET /sessions/{sid}/messages contract on top of the native services
- cold sessions: load + restore the main agent wire log and read the full
transcript from IReplayBuilderService; live sessions keep reading
IContextMemory
- rewrite the messages route as a thin adapter over the legacy service
- register the message.not_found (40403) error code in protocol and
agent-core-v2
This commit is contained in:
parent
37d3953368
commit
db3a7c4f43
10 changed files with 374 additions and 126 deletions
|
|
@ -135,6 +135,7 @@ const DOMAIN_LAYER = new Map([
|
|||
['promptLegacy', 7],
|
||||
['sessionLegacy', 7],
|
||||
['authLegacy', 7],
|
||||
['messageLegacy', 7],
|
||||
]);
|
||||
|
||||
const V1_PACKAGE = '@moonshot-ai/agent-core';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
9
packages/agent-core-v2/src/messageLegacy/errors.ts
Normal file
9
packages/agent-core-v2/src/messageLegacy/errors.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* `messageLegacy` domain error codes — v1-compatible message failures.
|
||||
*/
|
||||
|
||||
export const MessageLegacyErrors = {
|
||||
codes: {
|
||||
MESSAGE_NOT_FOUND: 'message.not_found',
|
||||
},
|
||||
} as const;
|
||||
9
packages/agent-core-v2/src/messageLegacy/index.ts
Normal file
9
packages/agent-core-v2/src/messageLegacy/index.ts
Normal file
|
|
@ -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';
|
||||
54
packages/agent-core-v2/src/messageLegacy/messageLegacy.ts
Normal file
54
packages/agent-core-v2/src/messageLegacy/messageLegacy.ts
Normal file
|
|
@ -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<PageResponse<Message>>;
|
||||
|
||||
/**
|
||||
* `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<Message>;
|
||||
}
|
||||
|
||||
export const IMessageLegacyService: ServiceIdentifier<IMessageLegacyService> =
|
||||
createDecorator<IMessageLegacyService>('messageLegacyService');
|
||||
176
packages/agent-core-v2/src/messageLegacy/messageLegacyService.ts
Normal file
176
packages/agent-core-v2/src/messageLegacy/messageLegacyService.ts
Normal file
|
|
@ -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<PageResponse<Message>> {
|
||||
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<Message> {
|
||||
// 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<Message[]> {
|
||||
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<IScopeHandle | undefined> {
|
||||
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',
|
||||
);
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<Message>
|
||||
* 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<Message[] | undefined> {
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<PageWire>(`/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<MessageWire>(`/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<null>(`/api/v1/sessions/${id}/messages/${messageId(id, 99)}`);
|
||||
expect(missing.body.code).toBe(40403);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue