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:
haozhe.yang 2026-06-30 21:02:23 +08:00
parent 37d3953368
commit db3a7c4f43
10 changed files with 374 additions and 126 deletions

View file

@ -135,6 +135,7 @@ const DOMAIN_LAYER = new Map([
['promptLegacy', 7],
['sessionLegacy', 7],
['authLegacy', 7],
['messageLegacy', 7],
]);
const V1_PACKAGE = '@moonshot-ai/agent-core';

View file

@ -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,

View file

@ -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';

View 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;

View 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';

View 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');

View 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',
);

View file

@ -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',

View file

@ -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),
);
}

View file

@ -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);
});
});