mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 08:34:39 +00:00
* feat(kap-server): drop WS heartbeat; never terminate idle connections
- remove ping interval / pong timeout / socket.terminate() from both the
v1 (WsConnectionV1) and v2 (WsConnection) WebSocket connections
- v2 protocol: drop pong from the client message schema, remove PingMessage
and ReadyMessage.heartbeatMs; the ready frame is now bare { type: 'ready' }
- v1 protocol: remove buildPing/PingFrame and ServerHelloPayload.heartbeat_ms;
server_hello no longer advertises a heartbeat
- drop pingIntervalMs/pongTimeoutMs options from registerWs/registerWsV1
* feat(agent-core-v2): gate image formats and add media-stripped resend
- add image-format-policy as the single source of truth for the
provider-accepted image MIME set (PNG/JPEG/GIF/WebP), with MIME
normalization, data-URL parsing, byte sniffing, and refusal notices
- enforce the format gate at every ingestion point: ReadMediaFile refuses
unsupported formats with per-OS conversion guidance, MCP tool results and
prompt step requests drop them for a text notice, and kap-server prompt
routes gate inline, uploaded, and remote-URL images on the sniffed bytes
- resend once with every media part replaced by a text marker when the
provider rejects an image's format, and keep later steps of the same turn
on the media-stripped projection (v1 parity)
- commit the WebP decoder wasm as a base64 module for the bundled CLI, with
a regenerate script
* feat(agent-core-v2): add media-degraded 413 resend and WebP re-encoding
- resend once with the media-degraded projection after an HTTP 413
body-size rejection: all but the two most recent media parts are replaced
by text markers, and later steps of the turn stay degraded (stripped
still wins over degraded)
- generalize stripAllMediaParts into degradeOlderMediaParts; the
media-stripped resend is now the keepRecent=0 case
- re-encode non-animated WebP through the wasm decoder and the PNG/JPEG
ladder instead of passing oversized WebP through (animated WebP still
passes through whole)
- stop lossless PNG rescaling at a 1000px floor and switch to the JPEG
ladder below it, so small byte budgets stay readable
- add the @jsquash/webp dependency
* feat(agent-core-v2): add flag-gated fault injection for recovery testing
- add the faultInjection domain: a one-shot arm/take latch that raises a
deterministic provider failure (HTTP 413 body-size or image-format 400)
before the provider is contacted, so the media-degraded / media-stripped
recovery resends can be exercised end-to-end against a real provider
- gate arming behind the new fault-injection experimental flag
(KIMI_CODE_EXPERIMENTAL_FAULT_INJECTION), off by default
- expose IAgentPromptService and IFaultInjectionService over the kap-server
/api/v2 RPC channel
- add a klient example that drives the REST ingestion gate and both
recovery resends against a live server
514 lines
18 KiB
TypeScript
514 lines
18 KiB
TypeScript
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { deflateSync } from 'node:zlib';
|
|
|
|
import {
|
|
IAgentContextMemoryService,
|
|
IAgentLifecycleService,
|
|
ISessionLifecycleService,
|
|
} from '@moonshot-ai/agent-core-v2';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import { type RunningServer, startServer } from '../src/start';
|
|
import { authHeaders } from './helpers/auth';
|
|
|
|
interface Envelope<T> {
|
|
code: number;
|
|
msg: string;
|
|
data: T;
|
|
request_id: string;
|
|
details?: { path: string; message: string }[];
|
|
}
|
|
|
|
interface PromptItemWire {
|
|
prompt_id: string;
|
|
user_message_id: string;
|
|
status: 'running' | 'queued';
|
|
content: unknown;
|
|
created_at: string;
|
|
}
|
|
|
|
type PromptContentPart =
|
|
| { type: 'text'; text: string }
|
|
| {
|
|
type: 'image';
|
|
source: { kind: 'base64'; media_type: string; data: string };
|
|
};
|
|
|
|
const PROMPT_TOML = [
|
|
'default_model = "stub"',
|
|
'',
|
|
'[providers.stub]',
|
|
'type = "openai"',
|
|
'base_url = "http://127.0.0.1:9999"',
|
|
'api_key = "stub"',
|
|
'',
|
|
'[models.stub]',
|
|
'provider = "stub"',
|
|
'model = "stub"',
|
|
'max_context_size = 1000',
|
|
'',
|
|
].join('\n');
|
|
|
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
const CRC32_TABLE = makeCrc32Table();
|
|
|
|
function makeCrc32Table(): Uint32Array {
|
|
const table = new Uint32Array(256);
|
|
for (let i = 0; i < 256; i++) {
|
|
let c = i;
|
|
for (let k = 0; k < 8; k++) {
|
|
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
}
|
|
table[i] = c >>> 0;
|
|
}
|
|
return table;
|
|
}
|
|
|
|
function crc32(bytes: Buffer): number {
|
|
let crc = 0xffffffff;
|
|
for (const byte of bytes) {
|
|
crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function pngChunk(type: string, data: Buffer): Buffer {
|
|
const typeBytes = Buffer.from(type, 'ascii');
|
|
const length = Buffer.alloc(4);
|
|
length.writeUInt32BE(data.length, 0);
|
|
const crc = Buffer.alloc(4);
|
|
crc.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 0);
|
|
return Buffer.concat([length, typeBytes, data, crc]);
|
|
}
|
|
|
|
function solidPng(width: number, height: number): Buffer {
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(width, 0);
|
|
ihdr.writeUInt32BE(height, 4);
|
|
ihdr[8] = 8; // bit depth
|
|
ihdr[9] = 6; // RGBA
|
|
|
|
const row = Buffer.alloc(1 + width * 4);
|
|
for (let x = 0; x < width; x++) {
|
|
const offset = 1 + x * 4;
|
|
row[offset] = 0x33;
|
|
row[offset + 1] = 0x66;
|
|
row[offset + 2] = 0xcc;
|
|
row[offset + 3] = 0xff;
|
|
}
|
|
const raw = Buffer.alloc(row.length * height);
|
|
for (let y = 0; y < height; y++) {
|
|
row.copy(raw, y * row.length);
|
|
}
|
|
|
|
return Buffer.concat([
|
|
PNG_SIGNATURE,
|
|
pngChunk('IHDR', ihdr),
|
|
pngChunk('IDAT', deflateSync(raw)),
|
|
pngChunk('IEND', Buffer.alloc(0)),
|
|
]);
|
|
}
|
|
|
|
function pngDimensions(bytes: Buffer): { width: number; height: number } {
|
|
if (!bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
|
|
throw new Error('expected PNG data');
|
|
}
|
|
if (bytes.subarray(12, 16).toString('ascii') !== 'IHDR') {
|
|
throw new Error('expected IHDR as first PNG chunk');
|
|
}
|
|
return {
|
|
width: bytes.readUInt32BE(16),
|
|
height: bytes.readUInt32BE(20),
|
|
};
|
|
}
|
|
|
|
describe('server-v2 /api/v1 prompts', () => {
|
|
let server: RunningServer | undefined;
|
|
let home: string | undefined;
|
|
let base: string;
|
|
|
|
beforeEach(async () => {
|
|
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-prompts-'));
|
|
await writeFile(join(home, 'config.toml'), PROMPT_TOML, 'utf-8');
|
|
server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' });
|
|
base = `http://127.0.0.1:${server.port}`;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (server !== undefined) {
|
|
await server.close();
|
|
server = undefined;
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
if (home !== undefined) {
|
|
await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never);
|
|
home = undefined;
|
|
}
|
|
});
|
|
|
|
async function call<T>(
|
|
method: 'GET' | 'POST',
|
|
path: string,
|
|
arg?: unknown,
|
|
): Promise<{ status: number; body: Envelope<T> }> {
|
|
const headers = authHeaders(
|
|
server as RunningServer,
|
|
arg === undefined ? {} : { 'content-type': 'application/json' },
|
|
);
|
|
const init: { method: string; headers: Record<string, string>; body?: string } = {
|
|
method,
|
|
headers,
|
|
};
|
|
if (arg !== undefined) {
|
|
init.body = JSON.stringify(arg);
|
|
}
|
|
const res = await fetch(`${base}${path}`, init as never);
|
|
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
|
}
|
|
|
|
async function createSession(cwd: string): Promise<string> {
|
|
const res = await fetch(`${base}/api/v1/sessions`, {
|
|
method: 'POST',
|
|
headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }),
|
|
body: JSON.stringify({ metadata: { cwd } }),
|
|
} as never);
|
|
const body = (await res.json()) as Envelope<{ id: string }>;
|
|
expect(body.code).toBe(0);
|
|
return body.data.id;
|
|
}
|
|
|
|
// The main agent scope is not created automatically on session creation
|
|
// (server-v2 gap G10); create it here so the prompt route resolves.
|
|
async function createMainAgent(sessionId: string): Promise<void> {
|
|
const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId);
|
|
if (session === undefined) throw new Error(`session ${sessionId} not found`);
|
|
await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' });
|
|
}
|
|
|
|
it('submits a prompt and lists it as active', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const submitted = await call<PromptItemWire>('POST', `/api/v1/sessions/${id}/prompts`, {
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
});
|
|
expect(submitted.body.code).toBe(0);
|
|
expect(submitted.body.data.prompt_id).toMatch(/^msg_/);
|
|
expect(submitted.body.data.status).toBe('running');
|
|
// prompt_id IS the user_message_id now (one identity for prompt + message).
|
|
expect(submitted.body.data.user_message_id).toBe(submitted.body.data.prompt_id);
|
|
|
|
const list = await call<{ active: PromptItemWire | null; queued: PromptItemWire[] }>(
|
|
'GET',
|
|
`/api/v1/sessions/${id}/prompts`,
|
|
);
|
|
expect(list.body.code).toBe(0);
|
|
if (list.body.data.active !== null) {
|
|
expect(list.body.data.active.prompt_id).toBe(submitted.body.data.prompt_id);
|
|
}
|
|
expect(Array.isArray(list.body.data.queued)).toBe(true);
|
|
});
|
|
|
|
it('materializes uploaded video prompts into cache path tags', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
const videoBytes = Buffer.from('tiny fake mp4 bytes');
|
|
const form = new FormData();
|
|
form.set('file', new Blob([videoBytes], { type: 'video/mp4' }), 'clip.mp4');
|
|
const uploadRes = await fetch(`${base}/api/v1/files`, {
|
|
method: 'POST',
|
|
headers: authHeaders(server as RunningServer),
|
|
body: form,
|
|
} as never);
|
|
const uploaded = (await uploadRes.json()) as Envelope<{ id: string }>;
|
|
expect(uploaded.code).toBe(0);
|
|
|
|
const submitted = await call<PromptItemWire>('POST', `/api/v1/sessions/${id}/prompts`, {
|
|
content: [
|
|
{ type: 'text', text: 'what happens in this video?' },
|
|
{ type: 'video', source: { kind: 'file', file_id: uploaded.data.id } },
|
|
],
|
|
});
|
|
expect(submitted.body.code).toBe(0);
|
|
|
|
const content = submitted.body.data.content as Array<{ type: string; text?: string }>;
|
|
expect(content).toHaveLength(2);
|
|
expect(content[0]).toEqual({ type: 'text', text: 'what happens in this video?' });
|
|
expect(content[1]?.type).toBe('text');
|
|
const match = /<video path="([^"]+)"><\/video>/.exec(content[1]?.text ?? '');
|
|
expect(match).not.toBeNull();
|
|
const cachePath = match![1]!;
|
|
expect(cachePath.startsWith(join(home as string, 'cache'))).toBe(true);
|
|
expect(cachePath.endsWith('.mp4')).toBe(true);
|
|
expect(await readFile(cachePath)).toEqual(videoBytes);
|
|
});
|
|
|
|
it('compresses uploaded image prompts into base64 image parts with a readback caption', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
const bigPng = solidPng(3600, 1800);
|
|
const form = new FormData();
|
|
form.set('file', new Blob([bigPng], { type: 'image/png' }), 'big.png');
|
|
const uploadRes = await fetch(`${base}/api/v1/files`, {
|
|
method: 'POST',
|
|
headers: authHeaders(server as RunningServer),
|
|
body: form,
|
|
} as never);
|
|
const uploaded = (await uploadRes.json()) as Envelope<{ id: string; size: number }>;
|
|
expect(uploaded.code).toBe(0);
|
|
expect(uploaded.data.size).toBe(bigPng.length);
|
|
|
|
const submitted = await call<PromptItemWire>('POST', `/api/v1/sessions/${id}/prompts`, {
|
|
content: [{ type: 'image', source: { kind: 'file', file_id: uploaded.data.id } }],
|
|
});
|
|
expect(submitted.body.code).toBe(0);
|
|
|
|
const content = submitted.body.data.content as PromptContentPart[];
|
|
expect(content).toHaveLength(2);
|
|
const caption = content[0];
|
|
if (caption?.type !== 'text') throw new Error('expected compression caption');
|
|
expect(caption.text).toContain('Image compressed');
|
|
expect(caption.text).toContain('3600x1800');
|
|
const pathMatch = /saved at "([^"]+)"/.exec(caption.text);
|
|
expect(pathMatch).not.toBeNull();
|
|
expect(pathMatch![1]!).toContain('/media-originals/');
|
|
expect(await readFile(pathMatch![1]!)).toEqual(bigPng);
|
|
|
|
const image = content[1];
|
|
if (image?.type !== 'image' || image.source.kind !== 'base64') {
|
|
throw new Error('expected resolved base64 image');
|
|
}
|
|
expect(image.source.media_type).toBe('image/png');
|
|
expect(pngDimensions(Buffer.from(image.source.data, 'base64'))).toEqual({
|
|
width: 2000,
|
|
height: 1000,
|
|
});
|
|
});
|
|
|
|
it('compresses inline base64 image prompts into session media-originals', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
const bigPng = solidPng(3600, 1800);
|
|
|
|
const submitted = await call<PromptItemWire>('POST', `/api/v1/sessions/${id}/prompts`, {
|
|
content: [
|
|
{
|
|
type: 'image',
|
|
source: {
|
|
kind: 'base64',
|
|
media_type: 'image/png',
|
|
data: bigPng.toString('base64'),
|
|
},
|
|
},
|
|
],
|
|
});
|
|
expect(submitted.body.code).toBe(0);
|
|
|
|
const content = submitted.body.data.content as PromptContentPart[];
|
|
expect(content).toHaveLength(2);
|
|
const caption = content[0];
|
|
if (caption?.type !== 'text') throw new Error('expected compression caption');
|
|
const pathMatch = /saved at "([^"]+)"/.exec(caption.text);
|
|
expect(pathMatch).not.toBeNull();
|
|
expect(pathMatch![1]!).toContain('/media-originals/');
|
|
expect((await realpath(pathMatch![1]!)).startsWith(await realpath(home as string))).toBe(true);
|
|
expect(await readFile(pathMatch![1]!)).toEqual(bigPng);
|
|
|
|
const image = content[1];
|
|
if (image?.type !== 'image' || image.source.kind !== 'base64') {
|
|
throw new Error('expected resolved base64 image');
|
|
}
|
|
expect(pngDimensions(Buffer.from(image.source.data, 'base64'))).toEqual({
|
|
width: 2000,
|
|
height: 1000,
|
|
});
|
|
});
|
|
|
|
function avifBytes(): Buffer {
|
|
// Minimal ftyp box: size(4) + 'ftyp' + major_brand 'avif' + minor(4) + compat(8).
|
|
const buf = Buffer.alloc(24);
|
|
buf.writeUInt32BE(24, 0);
|
|
buf.write('ftyp', 4, 'latin1');
|
|
buf.write('avif', 8, 'latin1');
|
|
buf.write('avif', 16, 'latin1');
|
|
return buf;
|
|
}
|
|
|
|
it('replaces an inline base64 image in an unsupported format with a text notice', async () => {
|
|
// An AVIF payload (accepted by no provider) must never enter the session
|
|
// history as an image part — the bytes are authoritative, so even a
|
|
// mislabeled media_type is gated on the sniffed format.
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const submitted = await call<PromptItemWire>('POST', `/api/v1/sessions/${id}/prompts`, {
|
|
content: [
|
|
{
|
|
type: 'image',
|
|
source: {
|
|
kind: 'base64',
|
|
media_type: 'image/png',
|
|
data: avifBytes().toString('base64'),
|
|
},
|
|
},
|
|
],
|
|
});
|
|
expect(submitted.body.code).toBe(0);
|
|
|
|
const content = submitted.body.data.content as PromptContentPart[];
|
|
expect(content).toHaveLength(1);
|
|
const notice = content[0];
|
|
if (notice?.type !== 'text') throw new Error('expected a text notice');
|
|
expect(notice.text).toContain('image/avif');
|
|
});
|
|
|
|
it('replaces an uploaded image file in an unsupported format with a text notice', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
const form = new FormData();
|
|
form.set('file', new Blob([avifBytes()], { type: 'image/avif' }), 'photo.avif');
|
|
const uploadRes = await fetch(`${base}/api/v1/files`, {
|
|
method: 'POST',
|
|
headers: authHeaders(server as RunningServer),
|
|
body: form,
|
|
} as never);
|
|
const uploaded = (await uploadRes.json()) as Envelope<{ id: string }>;
|
|
expect(uploaded.code).toBe(0);
|
|
|
|
const submitted = await call<PromptItemWire>('POST', `/api/v1/sessions/${id}/prompts`, {
|
|
content: [{ type: 'image', source: { kind: 'file', file_id: uploaded.data.id } }],
|
|
});
|
|
expect(submitted.body.code).toBe(0);
|
|
|
|
const content = submitted.body.data.content as PromptContentPart[];
|
|
expect(content).toHaveLength(1);
|
|
const notice = content[0];
|
|
if (notice?.type !== 'text') throw new Error('expected a text notice');
|
|
expect(notice.text).toContain('image/avif');
|
|
expect(notice.text).toContain('photo.avif');
|
|
});
|
|
|
|
it('replaces a remote image URL with an unsupported extension with a text notice', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const submitted = await call<PromptItemWire>('POST', `/api/v1/sessions/${id}/prompts`, {
|
|
content: [{ type: 'image', source: { kind: 'url', url: 'https://example.com/pic.avif' } }],
|
|
});
|
|
expect(submitted.body.code).toBe(0);
|
|
|
|
const content = submitted.body.data.content as PromptContentPart[];
|
|
expect(content).toHaveLength(1);
|
|
const notice = content[0];
|
|
if (notice?.type !== 'text') throw new Error('expected a text notice');
|
|
expect(notice.text).toContain('image/avif');
|
|
// The notice keeps the URL so the model can fetch and convert the image.
|
|
expect(notice.text).toContain('https://example.com/pic.avif');
|
|
});
|
|
|
|
it('returns 40402 when aborting a prompt that already settled', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const submitted = await call<PromptItemWire>('POST', `/api/v1/sessions/${id}/prompts`, {
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
});
|
|
const promptId = submitted.body.data.prompt_id;
|
|
|
|
const aborted = await call<{ aborted: boolean }>(
|
|
'POST',
|
|
`/api/v1/sessions/${id}/prompts/${promptId}:abort`,
|
|
);
|
|
expect(aborted.body.code).toBe(40402);
|
|
});
|
|
|
|
it('returns 40402 when aborting an unknown prompt', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const { body } = await call<null>(
|
|
'POST',
|
|
`/api/v1/sessions/${id}/prompts/prompt_does_not_exist:abort`,
|
|
);
|
|
expect(body.code).toBe(40402);
|
|
});
|
|
|
|
it('returns 40401 for an unknown session', async () => {
|
|
const { body } = await call<null>('POST', '/api/v1/sessions/nope/prompts', {
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
});
|
|
expect(body.code).toBe(40401);
|
|
});
|
|
|
|
it('lists prompts for a persisted session with no live handle (cold resume)', async () => {
|
|
const id = await createSession(home as string);
|
|
// Drop the in-memory handle so the session only exists on disk / in the
|
|
// index — the state a session is in after a server restart. The route must
|
|
// cold-resume it rather than report 40401.
|
|
await server!.core.accessor.get(ISessionLifecycleService).close(id);
|
|
expect(server!.core.accessor.get(ISessionLifecycleService).get(id)).toBeUndefined();
|
|
|
|
const list = await call<{ active: PromptItemWire | null; queued: PromptItemWire[] }>(
|
|
'GET',
|
|
`/api/v1/sessions/${id}/prompts`,
|
|
);
|
|
expect(list.body.code).toBe(0);
|
|
expect(list.body.data.active).toBeNull();
|
|
expect(list.body.data.queued).toEqual([]);
|
|
});
|
|
|
|
it('routes a submitted prompt to the agent named by agent_id (BTW side channel)', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
// Fork the main agent into a side-channel child the way `/btw` does.
|
|
const session = server!.core.accessor.get(ISessionLifecycleService).get(id);
|
|
if (session === undefined) throw new Error(`session ${id} not found`);
|
|
const lifecycle = session.accessor.get(IAgentLifecycleService);
|
|
const child = await lifecycle.fork('main');
|
|
|
|
const submitted = await call<PromptItemWire>('POST', `/api/v1/sessions/${id}/prompts`, {
|
|
content: [{ type: 'text', text: 'side question' }],
|
|
agent_id: child.id,
|
|
});
|
|
expect(submitted.body.code).toBe(0);
|
|
|
|
// The user message is appended to the target agent's context before the turn
|
|
// runs, so it persists even after the (model-less) turn settles — a durable
|
|
// signal of which agent actually received the prompt.
|
|
const contextHasUserText = (
|
|
handle: { accessor: { get: typeof child.accessor.get } },
|
|
text: string,
|
|
): boolean =>
|
|
handle.accessor
|
|
.get(IAgentContextMemoryService)
|
|
.get()
|
|
.some(
|
|
(m) =>
|
|
m.role === 'user' &&
|
|
m.content.some((p) => p.type === 'text' && p.text === text),
|
|
);
|
|
|
|
// The side-channel child received the prompt.
|
|
expect(contextHasUserText(child, 'side question')).toBe(true);
|
|
|
|
// The main agent must NOT have received it — previously the route ignored
|
|
// agent_id and always targeted main, so the reply landed in the main view.
|
|
const main = lifecycle.getHandle('main');
|
|
expect(main).toBeDefined();
|
|
expect(contextHasUserText(main!, 'side question')).toBe(false);
|
|
});
|
|
|
|
it('returns 40401 when agent_id names an unknown agent', async () => {
|
|
const id = await createSession(home as string);
|
|
await createMainAgent(id);
|
|
|
|
const { body } = await call<null>('POST', `/api/v1/sessions/${id}/prompts`, {
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
agent_id: 'agent_does_not_exist',
|
|
});
|
|
expect(body.code).toBe(40401);
|
|
});
|
|
});
|