mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 22:26:23 +00:00
* feat(agent-core): rework compaction to keep only user prompts and summary
* refactor(agent-core): rewrite compaction summary as first-person handoff
Rework the full-compaction summary to read as the agent's own continuing
notes instead of a third-party report:
- compaction-instruction.md: free-form first-person continuation that
preserves exact commands, paths and outcomes, states the precise next
action, and flags claimed-but-unverified work rather than trusting it.
- compaction-summary-prefix.md: skeptical "your own working notes"
framing; drop the collaborative third-party prefix.
- system.md: add compaction-awareness guidance so the model continues
naturally from a summary and re-checks any reported "done".
- Rename the compaction helpers module to handoff.ts.
Update tests and regenerate snapshots for the new prompt text, and fill
in contextSummary in the restored-compaction replay expectations.
* fix(agent-core): count image/audio/video parts in token estimation
estimateTokensForContentPart returned 0 for image_url/audio_url/video_url,
so auto-compaction triggers, the overflow-shrink budget, the kept-user
budget, and the reported context size all went blind to media — a
media-heavy session could overflow the model window while the estimate
reported a near-empty context. Media parts now carry a fixed estimate
(MEDIA_TOKEN_ESTIMATE), and the content-part switch is exhaustive so a new
ContentPart kind must declare its estimate rather than silently count as
zero.
* feat(agent-core): re-surface active background tasks after compaction
Folding the live context to [recent user prompts, summary] drops the
messages that started background tasks and their status updates, so the
model could forget a task is still running and spawn a duplicate.
injectAfterCompaction now appends a system-reminder listing active
background tasks (with guidance to use TaskOutput/TaskList/TaskStop
instead of re-spawning). It runs only post-compaction and carries an
injection origin, so the next compaction drops and rebuilds it rather
than stacking copies; the all-user-role post-compaction shape is
preserved (no tool-pairing reintroduced).
* test(agent-core): add compaction scenario guards and risk probes
Adds compaction-scenarios.test.ts driving the real Agent/ContextMemory/
FullCompaction machinery:
- A guard test locking in that repeated compaction folds the prior summary
into the new one instead of stacking two summaries.
- Seven `it.fails` probes that executably reproduce known, currently-accepted
edge-case defects so the suite stays green while documenting each one
precisely; any of them will flip red (forcing removal of `.fails`) the day
the behavior is fixed. They cover: assistant/tool appended during an
in-flight summarizer call being dropped; unbounded shrink on empty
summaries; the fixed 20k kept-user budget overflowing a small model window;
a tool result orphaned when compaction starts mid-exchange; legacy
compaction records dropping their verbatim tail on replay; micro-compaction
clearing recent tool results in an overflow-shrunk suffix; and media being
discarded when the oldest kept user message is truncated.
* fix(agent-core): repair tool_use/tool_result adjacency in projected context
A tool call and its result can end up non-adjacent in history — a
background-task notification or flushed steer lands between them, or an
interrupted/nested step delays the result — which strict providers reject
with HTTP 400. The projector now moves each tool_use's result up to
immediately follow it (projection-time only; the stored history is
untouched), and full compaction projects its summarizer input with a
synthetic result for any still-open call so the summary request stays
well-formed. Micro-compaction only surfaced this latent ordering by busting
the prompt cache, so it now defaults off.
Includes projector adjacency regression tests, a context-level integration
test, and a compaction synthesize-missing guard; the prior "keeps an
unresolved tool exchange out of the compaction prompt" test is updated to
the now-well-formed (synthetic-result) behavior.
* fix(agent-core): preserve the verbatim tail when restoring legacy compactions
A pre-rework `context.apply_compaction` record used
`[summary, ...history.slice(compactedCount)]` semantics and kept a verbatim
recent tail, but it has no `keptUserMessageCount`. The reworked applyCompaction
re-folded such records into the all-user shape, dropping the recent
assistant/tool tail — so resuming a session compacted by an older version
silently lost its most recent context.
On restore of such a record (gated on records.restoring, no keptUserMessageCount,
and compactedCount < history length) reproduce the old shape instead. The
forward/live path is unchanged; the projector's tool-adjacency repair keeps the
restored tail well-formed, and compaction only runs at clean step boundaries so
the tail has no open exchange. The legacy-tail probe now passes as a regression
guard via the real restore path.
* fix(agent-core): align legacy compaction foldedLength with live restore
The transcript reducer re-derived foldedLength for pre-rework
context.apply_compaction records (no keptUserMessageCount) using the new
kept-user+summary rule, but ContextMemory's restore now reproduces the legacy
[summary, ...history.slice(compactedCount)] shape for those records. The two
diverged for legacy sessions, so MessageService's foldedLength-vs-live-history
comparison could mis-handle GET /messages (miss or misorder recent output).
The reducer now mirrors the live legacy fold: when compactedCount is below the
pre-compaction length it computes 1 + (length - compactedCount); otherwise it
falls back to the kept-user derivation. The MessageService transcript test's
fixture is corrected to a new-format record, matching its all-user live mock.
* fix(kosong): merge a follow-up user turn into the preceding tool_results
The Anthropic message merge keyed on isToolResultOnly(last) ===
isToolResultOnly(converted), which left a tool_result-only user turn
followed by a plain-text user turn unmerged. After tool-exchange repair
this shape (assistant tool_use -> tool_result -> injected notification)
produces two adjacent user messages, which strict Anthropic-compatible
backends reject with HTTP 400.
Switch to the asymmetric predicate isToolResultOnly(last) ||
!isToolResultOnly(converted): a tool-result-only running message absorbs
whatever user turn follows (parallel tool_results or a trailing text),
yielding a valid [tool_result, ..., text] message; a plain-text running
message still only absorbs plain text. [tool_result, text] is valid for
both native Anthropic (which concatenates anyway) and strict backends.
* test(agent-core): pin micro-compaction flag in the shrunk-suffix probe
The 'does not clear recent tool results when projecting a shrunk suffix'
probe is an it.fails that only documents a real defect while
micro-compaction is active. It inherited the ambient
KIMI_CODE_EXPERIMENTAL master switch, so its pass/fail flipped with the
runner: green locally (master switch on) but a hard failure in CI, where
the flag defaults off and MicroCompaction.compact() is a no-op that
leaves the tool result intact.
Enable KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION explicitly for this probe
so it deterministically exercises the micro-compaction path regardless of
the environment.
* fix(agent-core): harden full compaction against in-flight races, unbounded shrink, and media loss
Three compaction-path fixes surfaced by review, each flipping its
documenting it.fails probe to a passing it:
- Append race (CMP-02): after the summarizer returns, the post-summary
history check only compared the compacted prefix. A live step appending
to the tail while a manual/SDK compaction was in flight slipped through —
an appended assistant/tool turn is neither summarized (the summary covers
only the snapshot) nor kept (the rebuild keeps user input), so it
vanished. Now cancel when the appended tail contains a non-user message;
an appended user message is still kept (rebuild picks it up), preserving
the existing 'keeps messages appended while compacting an unchanged
prefix' behavior.
- Unbounded empty/truncated shrink: an empty or truncated summary dropped
the oldest message and reset retryCount, so a model that kept returning
empty could issue ~one request per history entry. Bound the shrink
attempts by MAX_COMPACTION_RETRY_ATTEMPTS, mirroring the overflow-shrink
counter.
- Media dropped on truncation (CMP-07): truncating the oldest kept user
message replaced its whole content with one text block, discarding any
image/audio/video. Keep the non-text parts and spend the remaining budget
(maxTokens minus their cost) on truncated text.
* fix(vis): mirror legacy compaction tail in the model-mode projector
For a pre-rework context.apply_compaction record (no keptUserMessageCount),
agent-core's ContextMemory restore and the transcript reducer keep the old
[summary, ...history.slice(compactedCount)] tail — a verbatim recent tail
including assistant/tool. The vis model-mode projector always applied the
new kept-user selection, so opening an older compacted session in model
mode hid the assistant/tool tail the resumed agent still holds (and
surfaced a pre-compaction user message the agent dropped).
Branch on a missing keptUserMessageCount with compactedCount < history
length and reproduce the legacy shape, matching the agent-core restore.
* fix(agent-core): cancel compaction on any droppable user-role tail
The in-flight append guard cancelled only when the tail grew with a
non-user role. A user-role message that compaction would still drop — a
background-task notification, hook/cron reminder, or shell-command output —
slipped through: appended after the summary snapshot (so absent from the
summary) and dropped by the all-user rebuild (which keeps only real user
input), vanishing silently.
Key the guard on the same predicate applyCompaction uses (!isRealUserInput)
so it cancels whenever the appended tail holds anything compaction would
drop. A real user message is still kept, so a live user turn racing a
manual/SDK compaction continues to complete.
* fix(agent-core): exclude pre-clear prompts from legacy folded length
The transcript reducer's legacy fallback (records predating
keptUserMessageCount, compacted with no verbatim tail) re-derived the
kept-user count from the whole transcript, including messages before the
last context.clear. Live ContextMemory rebuilds _history from post-clear
messages only, so counting pre-clear prompts overstated foldedLength;
MessageService then saw context.history.length <= foldedLength and skipped
appending unflushed live tail messages, dropping recent output from the
messages endpoint for old sessions compacted after a clear.
Derive only from entries at or after clearFloor to match the live context.
* fix(agent-core): drop media when truncating the oldest kept prompt
Revert the media-preserving truncation: keeping non-text parts on the
truncated boundary message overshot the kept-user budget when the media
alone exceeded it, and reordered interleaved text/media parts. Both codex
(no media-aware truncation) and Claude Code (strips media at compaction)
decline to preserve media on a truncated message, since media cannot be
partially truncated and keeping it whole breaks the budget.
truncateUserMessage now keeps only the truncated text. Recent messages
that fit the budget are still kept verbatim with their media; only the
oldest, partially-overflowing boundary message loses its attachments.
* fix(agent-core): make manual compaction and turns mutually exclusive
A manual/SDK compaction could start while a turn was streaming, or a new
turn could launch while a compaction was in flight. Either way the turn
mutates the shared context (streaming content into an existing assistant
message, or appending new messages) during the summarizer await, and that
output is neither summarized nor preserved by the all-user rebuild —
silent loss that object-identity checks can't detect (the streamed message
is mutated in place).
Guard both directions so the agent does one of {turn, compaction} at a
time: begin() refuses a manual compaction while a turn is active, and
launch() refuses a new turn while a compaction is in progress. Auto
compaction is exempt — it runs from within the turn at a step boundary,
which blocks the turn for its duration.
* chore(changeset): consolidate compaction changesets into one
* chore(agent-core): drop external-product references from compaction comments
* test(agent-core): add Anthropic wire-compliance smoke tests for compaction
Drive real compaction output and the compaction summarizer projection
through the real Anthropic provider conversion and assert the wire request
is well-formed: strict user/assistant alternation and every tool_use
answered by an adjacent tool_result. Locks in the cross-layer guarantee
(projector merge + Anthropic consecutive-user merge + adjacency repair +
synthesizeMissing) that compacted sessions stay valid for strict
Anthropic-compatible backends.
* fix(agent-core): defer and replay inputs during manual compaction instead of rejecting
Manual/SDK compaction runs outside a turn, so the earlier guard rejected
prompts/steers that arrived while it held the context. That broke three
things: a REST/web prompt got stuck 'running' (no terminal turn event), a
background-task/cron steer was silently lost (null was read as 'buffered'
but nothing was), and a follow-up prompt could land in the window after
isCompacting cleared but before reminders were reinjected.
Reuse the existing defer-and-replay model instead of rejecting:
- steer() and launch() buffer into steerBuffer while a compaction is in
progress (returning null = buffered), mirroring how an active turn defers
input.
- FullCompaction.compactionWorker keeps isCompacting true through
refreshSystemPrompt + injectAfterCompaction (moving markCompleted and the
completed event after reinjection), then replays the buffer via
TurnFlow.onCompactionFinished — on success, on an A1 prefix/tail cancel,
and on failure/abort.
- onCompactionFinished flushes into an active turn if one exists, else
launches a fresh turn from the deferred input.
No PromptService change: a deferred prompt's eventual turn.started lets it
associate the pending prompt and clear it on turn.ended.
* fix(kosong): merge consecutive user turns for strict providers
Gemini/Vertex require strictly alternating user/model turns and reject
consecutive user turns with HTTP 400. They arise after compaction (kept
prompts + user-role summary + injected reminders) and when a turn is
steered in right after a tool result. Anthropic already merged them
inline; the Google converter did not, so post-compaction requests failed.
Extract the asymmetric merge into a shared mergeConsecutiveUserMessages
helper applied at each strict provider's conversion boundary: refactor
Anthropic to use it (behavior unchanged) and apply it at the Google
converter's exit. A conformance suite drives every strict provider with
the post-compaction shape and a steer-after-tool-result shape, asserting
no consecutive same-role turns reach the wire, so a new strict provider
cannot silently omit the merge.
The provider-agnostic projector stays structure-preserving: lenient
providers (OpenAI/Kimi) keep distinct turns for clearer message
boundaries; only strict providers normalize, where the requirement lives.
858 lines
29 KiB
TypeScript
858 lines
29 KiB
TypeScript
/**
|
|
* Sessions CRUD end-to-end tests (W6.2 / Chain 2 / P1.2).
|
|
*
|
|
* **Bootstrap strategy**: spawn the real server (port 0, tmp lock + bridge
|
|
* home) and exercise the 5 endpoints via `app.inject(...)`. KimiCore is fully
|
|
* constructed via the W3 bridge pattern; the HOME dir is a fresh tmpdir so
|
|
* no `~/.kimi` interference. This is non-hermetic in the sense that plugin
|
|
* discovery runs (the bridge's pluginsReady captures errors silently per
|
|
* `core-impl.ts:170-172`), but no network / external state is involved.
|
|
*
|
|
* Coverage matrix per REST.md §3.3:
|
|
* - POST /api/v1/sessions → envelope code 0 + Session payload
|
|
* - GET /api/v1/sessions → Page<Session> + has_more
|
|
* - GET /api/v1/sessions/{id} → Session (40401 on unknown id)
|
|
* - GET /api/v1/sessions/{id}/profile → Session (40401 on unknown id)
|
|
* - POST /api/v1/sessions/{id}/profile → Session (40401 on unknown id)
|
|
* - POST /api/v1/sessions/{id}:archive → { archived: true } (40401 on unknown)
|
|
*
|
|
* Plus the validation matrix:
|
|
* - POST with missing `metadata.cwd` → 40001 + `details` containing path.
|
|
* - GET list with `page_size=0` → 40001 (out of range).
|
|
* - GET list with both before_id+after_id → 40001 (mutual exclusivity).
|
|
*
|
|
* Plus the snake_case + ISO `Z` invariants on the response shape (the load-
|
|
* bearing piece of Chain 2).
|
|
*/
|
|
|
|
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { pino } from 'pino';
|
|
import { ErrorCode, sessionSchema, sessionStatusResponseSchema, undoSessionResponseSchema } from '@moonshot-ai/protocol';
|
|
import type { TelemetryClient, TelemetryProperties } from '@moonshot-ai/agent-core';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { WebSocket } from 'ws';
|
|
|
|
import { IRestGateway, startServer, type RunningServer } from '../src';
|
|
import { fixedTokenAuth } from './helpers/serverHarness';
|
|
|
|
let tmpDir: string;
|
|
let lockPath: string;
|
|
let bridgeHome: string;
|
|
let server: RunningServer | undefined;
|
|
|
|
interface TelemetryRecord {
|
|
readonly event: string;
|
|
readonly sessionId: string | null;
|
|
readonly properties?: TelemetryProperties;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-sessions-test-'));
|
|
lockPath = join(tmpDir, 'lock');
|
|
bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-sessions-home-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
await server?.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
server = undefined;
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
rmSync(bridgeHome, { recursive: true, force: true });
|
|
});
|
|
|
|
async function bootDaemon(options: { telemetry?: TelemetryClient } = {}): Promise<RunningServer> {
|
|
server = await startServer({
|
|
serviceOverrides: [fixedTokenAuth()],
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
lockPath,
|
|
logger: pino({ level: 'silent' }),
|
|
coreProcessOptions: { homeDir: bridgeHome, telemetry: options.telemetry },
|
|
});
|
|
return server;
|
|
}
|
|
|
|
function recordingTelemetry(records: TelemetryRecord[]): TelemetryClient {
|
|
return {
|
|
track: (event, properties) => {
|
|
records.push({ event, sessionId: null, properties });
|
|
},
|
|
withContext: (patch) => ({
|
|
track: (event, properties) => {
|
|
records.push({ event, sessionId: patch.sessionId ?? null, properties });
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
function appOf(r: RunningServer): {
|
|
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
|
} {
|
|
const app = r.services.invokeFunction((a) => {
|
|
const gw = a.get(IRestGateway);
|
|
return gw.app as unknown as {
|
|
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
|
|
};
|
|
});
|
|
// Auto-attach the fixed bearer token so the M5.1 auth hook passes. A
|
|
// caller-supplied `authorization` header wins, so explicit token tests keep
|
|
// working; every other header (Range, content-type, …) is preserved.
|
|
return {
|
|
inject(req: unknown) {
|
|
const q = req as { headers?: Record<string, string | string[] | undefined> };
|
|
return app.inject({
|
|
...q,
|
|
headers: { authorization: 'Bearer test-token', ...q.headers },
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function envelopeOf<T>(body: unknown): { code: number; msg: string; data: T | null; request_id: string; details?: unknown } {
|
|
return body as { code: number; msg: string; data: T | null; request_id: string; details?: unknown };
|
|
}
|
|
|
|
function wsDataToString(data: unknown): string {
|
|
if (typeof data === 'string') return data;
|
|
if (Buffer.isBuffer(data)) return data.toString('utf8');
|
|
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
|
|
return JSON.stringify(data);
|
|
}
|
|
|
|
async function openSessionListListener(r: RunningServer): Promise<{
|
|
ws: WebSocket;
|
|
received: Record<string, unknown>[];
|
|
}> {
|
|
const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws';
|
|
const received: Record<string, unknown>[] = [];
|
|
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
|
const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']);
|
|
sock.on('message', (data) => {
|
|
try {
|
|
received.push(JSON.parse(wsDataToString(data)) as Record<string, unknown>);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
});
|
|
sock.once('open', () => resolve(sock));
|
|
sock.once('error', reject);
|
|
});
|
|
await waitFor(received, (f) => f['type'] === 'server_hello');
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'client_hello',
|
|
id: 'h1',
|
|
payload: { client_id: 'session-list-test', subscriptions: [] },
|
|
}),
|
|
);
|
|
await waitFor(received, (f) => f['type'] === 'ack' && f['id'] === 'h1');
|
|
return { ws, received };
|
|
}
|
|
|
|
async function waitFor(
|
|
received: Record<string, unknown>[],
|
|
pred: (f: Record<string, unknown>) => boolean,
|
|
timeoutMs = 2000,
|
|
): Promise<Record<string, unknown>> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
const found = received.find(pred);
|
|
if (found !== undefined) return found;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
throw new Error(`timed out waiting for frame; got ${JSON.stringify(received)}`);
|
|
}
|
|
|
|
describe('POST /api/v1/sessions — create', () => {
|
|
it('returns a Session payload with snake_case + ISO Z timestamps', async () => {
|
|
const r = await bootDaemon();
|
|
const cwd = join(tmpDir, 'workspace-create');
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { metadata: { cwd }, title: 'created via test' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(0);
|
|
expect(env.msg).toBe('success');
|
|
expect(env.data).not.toBeNull();
|
|
const session = sessionSchema.parse(env.data);
|
|
expect(session.metadata.cwd).toBe(cwd);
|
|
expect(session.title).toBe('created via test');
|
|
expect(session.created_at.endsWith('Z')).toBe(true);
|
|
expect(session.updated_at.endsWith('Z')).toBe(true);
|
|
expect(session.id.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('broadcasts event.session.created to connected clients without a session subscription', async () => {
|
|
const r = await bootDaemon();
|
|
const { ws, received } = await openSessionListListener(r);
|
|
const cwd = join(tmpDir, 'workspace-create-broadcast');
|
|
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { metadata: { cwd }, title: 'created via ws test' },
|
|
});
|
|
const env = envelopeOf<{ id: string }>(res.json());
|
|
expect(env.code).toBe(0);
|
|
expect(env.data).not.toBeNull();
|
|
|
|
const frame = await waitFor(
|
|
received,
|
|
(f) => f['type'] === 'event.session.created',
|
|
);
|
|
expect(frame['session_id']).toBe(env.data!.id);
|
|
expect(frame['payload']).toMatchObject({
|
|
session: {
|
|
id: env.data!.id,
|
|
title: 'created via ws test',
|
|
metadata: { cwd },
|
|
},
|
|
});
|
|
|
|
ws.close();
|
|
});
|
|
|
|
it('reports web client headers in new-session telemetry', async () => {
|
|
const records: TelemetryRecord[] = [];
|
|
const r = await bootDaemon({ telemetry: recordingTelemetry(records) });
|
|
const cwd = join(tmpDir, 'workspace-client-telemetry');
|
|
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
headers: {
|
|
'x-kimi-client-id': 'web_test_client',
|
|
'x-kimi-client-name': 'kimi-code-web',
|
|
'x-kimi-client-version': '0.1.1',
|
|
'x-kimi-client-ui-mode': 'web',
|
|
},
|
|
payload: { metadata: { cwd }, title: 'client telemetry' },
|
|
});
|
|
const env = envelopeOf<{ id: string }>(res.json());
|
|
expect(env.code).toBe(0);
|
|
expect(env.data).not.toBeNull();
|
|
|
|
expect(records).toContainEqual({
|
|
event: 'session_started',
|
|
sessionId: env.data!.id,
|
|
properties: {
|
|
client_id: 'web_test_client',
|
|
client_name: 'kimi-code-web',
|
|
client_version: '0.1.1',
|
|
ui_mode: 'web',
|
|
resumed: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('rejects a body missing metadata.cwd with code 40001 + details', async () => {
|
|
const r = await bootDaemon();
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { title: 'no cwd' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(40001);
|
|
expect(env.data).toBeNull();
|
|
expect(Array.isArray(env.details)).toBe(true);
|
|
const details = env.details as Array<{ path: string; message: string }>;
|
|
expect(details.length).toBeGreaterThan(0);
|
|
// The path should reference the failed field (`metadata` or `metadata.cwd`).
|
|
expect(details[0]!.path).toMatch(/^metadata/);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/sessions — list', () => {
|
|
it('returns Page<Session> with has_more=false when fewer than page_size entries exist', async () => {
|
|
const r = await bootDaemon();
|
|
const cwd1 = join(tmpDir, 'workspace-list-1');
|
|
const cwd2 = join(tmpDir, 'workspace-list-2');
|
|
await appOf(r).inject({ method: 'POST', url: '/api/v1/sessions', payload: { metadata: { cwd: cwd1 } } });
|
|
await appOf(r).inject({ method: 'POST', url: '/api/v1/sessions', payload: { metadata: { cwd: cwd2 } } });
|
|
|
|
const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions' });
|
|
expect(res.statusCode).toBe(200);
|
|
const env = envelopeOf<{ items: unknown[]; has_more: boolean }>(res.json());
|
|
expect(env.code).toBe(0);
|
|
expect(env.data).not.toBeNull();
|
|
expect(env.data!.has_more).toBe(false);
|
|
expect(env.data!.items.length).toBeGreaterThanOrEqual(2);
|
|
// Each item should parse as Session.
|
|
for (const item of env.data!.items) {
|
|
sessionSchema.parse(item);
|
|
}
|
|
});
|
|
|
|
it('honors page_size and surfaces has_more', async () => {
|
|
const r = await bootDaemon();
|
|
await appOf(r).inject({ method: 'POST', url: '/api/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'ws-a') } } });
|
|
await appOf(r).inject({ method: 'POST', url: '/api/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'ws-b') } } });
|
|
await appOf(r).inject({ method: 'POST', url: '/api/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'ws-c') } } });
|
|
|
|
const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions?page_size=2' });
|
|
const env = envelopeOf<{ items: unknown[]; has_more: boolean }>(res.json());
|
|
expect(env.code).toBe(0);
|
|
expect(env.data!.items).toHaveLength(2);
|
|
expect(env.data!.has_more).toBe(true);
|
|
});
|
|
|
|
it('rejects page_size=0 (out of range)', async () => {
|
|
const r = await bootDaemon();
|
|
const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions?page_size=0' });
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(40001);
|
|
});
|
|
|
|
it('rejects before_id + after_id together', async () => {
|
|
const r = await bootDaemon();
|
|
const res = await appOf(r).inject({
|
|
method: 'GET',
|
|
url: '/api/v1/sessions?before_id=a&after_id=b',
|
|
});
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(40001);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/sessions/{session_id} — fetch single', () => {
|
|
it('returns the matching Session', async () => {
|
|
const r = await bootDaemon();
|
|
const cwd = join(tmpDir, 'workspace-get');
|
|
const createRes = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { metadata: { cwd } },
|
|
});
|
|
const created = envelopeOf<{ id: string }>(createRes.json()).data!;
|
|
|
|
const getRes = await appOf(r).inject({
|
|
method: 'GET',
|
|
url: `/api/v1/sessions/${created.id}`,
|
|
});
|
|
const env = envelopeOf<unknown>(getRes.json());
|
|
expect(env.code).toBe(0);
|
|
const session = sessionSchema.parse(env.data);
|
|
expect(session.id).toBe(created.id);
|
|
expect(session.metadata.cwd).toBe(cwd);
|
|
});
|
|
|
|
it('returns code 40401 for an unknown id', async () => {
|
|
const r = await bootDaemon();
|
|
const res = await appOf(r).inject({
|
|
method: 'GET',
|
|
url: '/api/v1/sessions/sess_does_not_exist',
|
|
});
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(40401);
|
|
expect(env.data).toBeNull();
|
|
expect(env.msg).toMatch(/does not exist/);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/sessions/{session_id}/profile — fetch profile', () => {
|
|
it('returns the matching Session profile', async () => {
|
|
const r = await bootDaemon();
|
|
const cwd = join(tmpDir, 'workspace-profile-get');
|
|
const createRes = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { metadata: { cwd } },
|
|
});
|
|
const created = envelopeOf<{ id: string }>(createRes.json()).data!;
|
|
|
|
const res = await appOf(r).inject({
|
|
method: 'GET',
|
|
url: `/api/v1/sessions/${created.id}/profile`,
|
|
});
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(0);
|
|
const session = sessionSchema.parse(env.data);
|
|
expect(session.id).toBe(created.id);
|
|
expect(session.metadata.cwd).toBe(cwd);
|
|
});
|
|
|
|
it('returns 40401 for unknown id', async () => {
|
|
const r = await bootDaemon();
|
|
const res = await appOf(r).inject({
|
|
method: 'GET',
|
|
url: '/api/v1/sessions/sess_missing/profile',
|
|
});
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(40401);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/sessions/{session_id}/status — fetch live status', () => {
|
|
it('returns the live status envelope for a fresh session', async () => {
|
|
const r = await bootDaemon();
|
|
const cwd = join(tmpDir, 'workspace-status-get');
|
|
const createRes = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { metadata: { cwd } },
|
|
});
|
|
const created = envelopeOf<{ id: string }>(createRes.json()).data!;
|
|
|
|
const res = await appOf(r).inject({
|
|
method: 'GET',
|
|
url: `/api/v1/sessions/${created.id}/status`,
|
|
});
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(0);
|
|
const status = sessionStatusResponseSchema.parse(env.data);
|
|
expect(status.status).toBe('idle');
|
|
});
|
|
|
|
it('returns 40401 for unknown id', async () => {
|
|
const r = await bootDaemon();
|
|
const res = await appOf(r).inject({
|
|
method: 'GET',
|
|
url: '/api/v1/sessions/sess_missing/status',
|
|
});
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(40401);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/sessions/{session_id}/profile — update profile', () => {
|
|
it('updates the title and returns the post-update Session', async () => {
|
|
const r = await bootDaemon();
|
|
const cwd = join(tmpDir, 'workspace-profile-update');
|
|
const created = envelopeOf<{ id: string }>(
|
|
(await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { metadata: { cwd } },
|
|
})).json(),
|
|
).data!;
|
|
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: `/api/v1/sessions/${created.id}/profile`,
|
|
payload: { title: 'Renamed' },
|
|
});
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(0);
|
|
const session = sessionSchema.parse(env.data);
|
|
expect(session.id).toBe(created.id);
|
|
// The Session shape is returned (title reflection may rely on
|
|
// metadata round-tripping; the contract is "200 + Session payload").
|
|
});
|
|
|
|
it('returns 40401 for unknown id', async () => {
|
|
const r = await bootDaemon();
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions/sess_missing/profile',
|
|
payload: { title: 'x' },
|
|
});
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(40401);
|
|
});
|
|
|
|
it('broadcasts session.meta.updated to clients not subscribed to the session on rename', async () => {
|
|
const r = await bootDaemon();
|
|
const { ws, received } = await openSessionListListener(r);
|
|
const cwd = join(tmpDir, 'workspace-profile-rename-broadcast');
|
|
const created = envelopeOf<{ id: string }>(
|
|
(
|
|
await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { metadata: { cwd } },
|
|
})
|
|
).json(),
|
|
).data!;
|
|
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: `/api/v1/sessions/${created.id}/profile`,
|
|
payload: { title: 'Renamed' },
|
|
});
|
|
expect(envelopeOf<unknown>(res.json()).code).toBe(0);
|
|
|
|
const frame = await waitFor(received, (f) => f['type'] === 'session.meta.updated');
|
|
expect(frame['session_id']).toBe(created.id);
|
|
expect(frame['payload']).toMatchObject({
|
|
title: 'Renamed',
|
|
patch: { title: 'Renamed', isCustomTitle: true },
|
|
});
|
|
|
|
ws.close();
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/sessions/{session_id}:fork — fork', () => {
|
|
it('forks the session, defaults the title from the source, and returns the fork', async () => {
|
|
const r = await bootDaemon();
|
|
const cwd = join(tmpDir, 'workspace-fork');
|
|
const source = envelopeOf<{ id: string }>(
|
|
(await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: {
|
|
title: 'Source session',
|
|
metadata: { cwd, source: true },
|
|
},
|
|
})).json(),
|
|
).data!;
|
|
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: `/api/v1/sessions/${source.id}:fork`,
|
|
payload: { metadata: { child: true } },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(0);
|
|
const fork = sessionSchema.parse(env.data);
|
|
expect(fork.id).not.toBe(source.id);
|
|
expect(fork.title).toBe('Fork: Source session');
|
|
expect(fork.metadata).toMatchObject({
|
|
cwd,
|
|
source: true,
|
|
child: true,
|
|
});
|
|
|
|
const forkGet = envelopeOf<unknown>(
|
|
(await appOf(r).inject({
|
|
method: 'GET',
|
|
url: `/api/v1/sessions/${fork.id}`,
|
|
})).json(),
|
|
);
|
|
expect(forkGet.code).toBe(0);
|
|
expect(sessionSchema.parse(forkGet.data).id).toBe(fork.id);
|
|
});
|
|
|
|
it('returns 40401 for an unknown source session', async () => {
|
|
const r = await bootDaemon();
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions/sess_missing:fork',
|
|
payload: {},
|
|
});
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(40401);
|
|
expect(env.data).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/sessions/{session_id}:compact — begin compaction', () => {
|
|
it('returns 40401 for unknown id', async () => {
|
|
const r = await bootDaemon();
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions/sess_missing:compact',
|
|
payload: {},
|
|
});
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(ErrorCode.SESSION_NOT_FOUND);
|
|
expect(env.data).toBeNull();
|
|
});
|
|
|
|
it('maps an empty-history compaction attempt to compaction.unable', async () => {
|
|
const r = await bootDaemon();
|
|
const created = envelopeOf<{ id: string }>(
|
|
(await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { metadata: { cwd: join(tmpDir, 'workspace-compact') } },
|
|
})).json(),
|
|
).data!;
|
|
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: `/api/v1/sessions/${created.id}:compact`,
|
|
payload: { instruction: ' focus on decisions ' },
|
|
});
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(ErrorCode.COMPACTION_UNABLE);
|
|
expect(env.data).toBeNull();
|
|
expect(env.msg).toMatch(/No messages to compact/);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/sessions/{session_id}:undo — undo history', () => {
|
|
it('returns 40401 for unknown id', async () => {
|
|
const r = await bootDaemon();
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions/sess_missing:undo',
|
|
payload: {},
|
|
});
|
|
const env = envelopeOf(res.json());
|
|
expect(env.code).toBe(ErrorCode.SESSION_NOT_FOUND);
|
|
expect(env.data).toBeNull();
|
|
});
|
|
|
|
it('rejects invalid counts before dispatching undo', async () => {
|
|
const r = await bootDaemon();
|
|
const created = envelopeOf<{ id: string }>(
|
|
(await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { metadata: { cwd: join(tmpDir, 'workspace-undo-invalid') } },
|
|
})).json(),
|
|
).data!;
|
|
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: `/api/v1/sessions/${created.id}:undo`,
|
|
payload: { count: 0 },
|
|
});
|
|
const env = envelopeOf(res.json());
|
|
expect(env.code).toBe(ErrorCode.VALIDATION_FAILED);
|
|
expect(env.data).toBeNull();
|
|
});
|
|
|
|
it('maps a fresh session undo attempt to session.undo_unavailable', async () => {
|
|
const r = await bootDaemon();
|
|
const created = envelopeOf<{ id: string }>(
|
|
(await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { metadata: { cwd: join(tmpDir, 'workspace-undo-empty') } },
|
|
})).json(),
|
|
).data!;
|
|
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: `/api/v1/sessions/${created.id}:undo`,
|
|
payload: {},
|
|
});
|
|
const env = envelopeOf(res.json());
|
|
expect(env.code).toBe(ErrorCode.SESSION_UNDO_UNAVAILABLE);
|
|
expect(env.data).toBeNull();
|
|
});
|
|
|
|
it('accepts the undo response schema', () => {
|
|
expect(
|
|
undoSessionResponseSchema.parse({
|
|
messages: { items: [], has_more: false },
|
|
status: {
|
|
status: 'idle',
|
|
thinking_level: 'auto',
|
|
permission: 'manual',
|
|
plan_mode: false,
|
|
swarm_mode: false,
|
|
context_tokens: 0,
|
|
max_context_tokens: 0,
|
|
context_usage: 0,
|
|
},
|
|
}),
|
|
).toMatchObject({ messages: { items: [] } });
|
|
});
|
|
});
|
|
|
|
describe('POST and GET /api/v1/sessions/{session_id}/children', () => {
|
|
it('creates a child session and lists it under the parent', async () => {
|
|
const r = await bootDaemon();
|
|
const cwd = join(tmpDir, 'workspace-children');
|
|
const parent = envelopeOf<{ id: string }>(
|
|
(await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: {
|
|
title: 'Parent session',
|
|
metadata: { cwd, source: true },
|
|
},
|
|
})).json(),
|
|
).data!;
|
|
|
|
const createChild = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: `/api/v1/sessions/${parent.id}/children`,
|
|
payload: {
|
|
metadata: {
|
|
parent_session_id: 'spoofed-parent',
|
|
child_session_kind: 'spoofed-kind',
|
|
topic: 'btw',
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(createChild.statusCode).toBe(200);
|
|
const createEnv = envelopeOf(createChild.json());
|
|
expect(createEnv.code).toBe(0);
|
|
const child = sessionSchema.parse(createEnv.data);
|
|
expect(child.id).not.toBe(parent.id);
|
|
expect(child.title).toBe('Child: Parent session');
|
|
expect(child.metadata).toMatchObject({
|
|
cwd,
|
|
source: true,
|
|
parent_session_id: parent.id,
|
|
child_session_kind: 'child',
|
|
topic: 'btw',
|
|
});
|
|
|
|
await appOf(r).inject({
|
|
method: 'POST',
|
|
url: `/api/v1/sessions/${parent.id}:fork`,
|
|
payload: { metadata: { ordinary_fork: true } },
|
|
});
|
|
|
|
const listChildren = await appOf(r).inject({
|
|
method: 'GET',
|
|
url: `/api/v1/sessions/${parent.id}/children`,
|
|
});
|
|
const listEnv = envelopeOf<{ items: unknown[]; has_more: boolean }>(listChildren.json());
|
|
expect(listEnv.code).toBe(0);
|
|
expect(listEnv.data?.has_more).toBe(false);
|
|
const children = listEnv.data!.items.map((item) => sessionSchema.parse(item));
|
|
expect(children.map((item) => item.id)).toEqual([child.id]);
|
|
});
|
|
|
|
it('returns 40401 for a missing parent session', async () => {
|
|
const r = await bootDaemon();
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions/sess_missing/children',
|
|
payload: {},
|
|
});
|
|
const env = envelopeOf(res.json());
|
|
expect(env.code).toBe(40401);
|
|
expect(env.data).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('POST /api/v1/sessions/{session_id}:archive — archive', () => {
|
|
it('returns { archived: true } envelope and hides the session from list', async () => {
|
|
const r = await bootDaemon();
|
|
const cwd = join(tmpDir, 'workspace-archive');
|
|
const created = envelopeOf<{ id: string }>(
|
|
(await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { metadata: { cwd } },
|
|
})).json(),
|
|
).data!;
|
|
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: `/api/v1/sessions/${created.id}:archive`,
|
|
payload: {},
|
|
});
|
|
const env = envelopeOf<{ archived: boolean }>(res.json());
|
|
expect(env.code).toBe(0);
|
|
expect(env.data).toEqual({ archived: true });
|
|
|
|
const listRes = await appOf(r).inject({
|
|
method: 'GET',
|
|
url: '/api/v1/sessions',
|
|
});
|
|
const listEnv = envelopeOf<{ items: Array<{ id: string }>; has_more: boolean }>(listRes.json());
|
|
expect(listEnv.code).toBe(0);
|
|
expect(listEnv.data!.items.find((s) => s.id === created.id)).toBeUndefined();
|
|
});
|
|
|
|
it('updates the workspace session_count to 0 when the last session is archived', async () => {
|
|
const r = await bootDaemon();
|
|
const cwd = join(tmpDir, 'workspace-archive-count');
|
|
mkdirSync(cwd, { recursive: true });
|
|
const ws = envelopeOf<{ id: string; session_count: number; root: string }>(
|
|
(await appOf(r).inject({ method: 'POST', url: '/api/v1/workspaces', payload: { root: cwd } })).json(),
|
|
).data!;
|
|
expect(ws.session_count).toBe(0);
|
|
|
|
const created = envelopeOf<{ id: string }>(
|
|
(await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { workspace_id: ws.id, metadata: { cwd: ws.root } },
|
|
})).json(),
|
|
).data!;
|
|
|
|
const listBefore = envelopeOf<{ items: Array<{ id: string; session_count: number }> }>(
|
|
(await appOf(r).inject({ method: 'GET', url: '/api/v1/workspaces' })).json(),
|
|
).data!;
|
|
const before = listBefore.items.find((w) => w.id === ws.id);
|
|
expect(before).toBeDefined();
|
|
expect(before!.session_count).toBe(1);
|
|
|
|
const archiveRes = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: `/api/v1/sessions/${created.id}:archive`,
|
|
payload: {},
|
|
});
|
|
expect(envelopeOf<{ archived: boolean }>(archiveRes.json()).data).toEqual({ archived: true });
|
|
|
|
const listAfter = envelopeOf<{ items: Array<{ id: string; session_count: number }> }>(
|
|
(await appOf(r).inject({ method: 'GET', url: '/api/v1/workspaces' })).json(),
|
|
).data!;
|
|
const after = listAfter.items.find((w) => w.id === ws.id);
|
|
expect(after).toBeDefined();
|
|
expect(after!.session_count).toBe(0);
|
|
});
|
|
|
|
it('includes archived sessions when include_archive=true and marks archived flag', async () => {
|
|
const r = await bootDaemon();
|
|
const cwd = join(tmpDir, 'workspace-archive-include');
|
|
const created = envelopeOf<{ id: string }>(
|
|
(await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions',
|
|
payload: { metadata: { cwd } },
|
|
})).json(),
|
|
).data!;
|
|
|
|
const archiveRes = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: `/api/v1/sessions/${created.id}:archive`,
|
|
payload: {},
|
|
});
|
|
expect(envelopeOf<{ archived: boolean }>(archiveRes.json()).data).toEqual({ archived: true });
|
|
|
|
const defaultList = await appOf(r).inject({
|
|
method: 'GET',
|
|
url: '/api/v1/sessions',
|
|
});
|
|
const defaultEnv = envelopeOf<{ items: Array<{ id: string; archived?: boolean }>; has_more: boolean }>(
|
|
defaultList.json(),
|
|
);
|
|
expect(defaultEnv.code).toBe(0);
|
|
expect(defaultEnv.data!.items.find((s) => s.id === created.id)).toBeUndefined();
|
|
|
|
const archivedList = await appOf(r).inject({
|
|
method: 'GET',
|
|
url: '/api/v1/sessions?include_archive=true',
|
|
});
|
|
const archivedEnv = envelopeOf<{ items: Array<{ id: string; archived?: boolean }>; has_more: boolean }>(
|
|
archivedList.json(),
|
|
);
|
|
expect(archivedEnv.code).toBe(0);
|
|
const listed = archivedEnv.data!.items.find((s) => s.id === created.id);
|
|
expect(listed).toBeDefined();
|
|
expect(listed!.archived).toBe(true);
|
|
|
|
const explicitList = await appOf(r).inject({
|
|
method: 'GET',
|
|
url: '/api/v1/sessions?include_archive=false',
|
|
});
|
|
const explicitEnv = envelopeOf<{ items: Array<{ id: string }>; has_more: boolean }>(explicitList.json());
|
|
expect(explicitEnv.code).toBe(0);
|
|
expect(explicitEnv.data!.items.find((s) => s.id === created.id)).toBeUndefined();
|
|
});
|
|
|
|
it('returns 40401 for unknown id', async () => {
|
|
const r = await bootDaemon();
|
|
const res = await appOf(r).inject({
|
|
method: 'POST',
|
|
url: '/api/v1/sessions/sess_missing:archive',
|
|
payload: {},
|
|
});
|
|
const env = envelopeOf<unknown>(res.json());
|
|
expect(env.code).toBe(40401);
|
|
});
|
|
});
|