feat(protocol): extract shared protocol package from agent-core (#612)

* feat(protocol): extract shared protocol package from agent-core

- add `@moonshot-ai/protocol` package with REST/WS schemas, envelopes, error codes, event types, and display schemas\n- migrate agent-core `events.ts` and `display/schemas.ts` to re-export from protocol
- add centralized `onUnexpectedError` handler for safe emitter listener callbacks
- reject forkSession when source session has an active running turn
- add protocol schema tests and unexpectedError handler tests
This commit is contained in:
Haozhe 2026-06-10 14:03:38 +08:00 committed by GitHub
parent 95a124804e
commit 4603d8ad6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 6934 additions and 496 deletions

View file

@ -0,0 +1,7 @@
---
"@moonshot-ai/protocol": minor
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Prevent forking sessions during active turns and consolidate wire protocol definitions into a shared internal package.

View file

@ -69,6 +69,7 @@
./packages/migration-legacy
./packages/node-sdk
./packages/oauth
./packages/protocol
./packages/telemetry
./apps/kimi-code
./apps/vis
@ -85,6 +86,7 @@
"@moonshot-ai/migration-legacy"
"@moonshot-ai/kimi-code-sdk"
"@moonshot-ai/kimi-code-oauth"
"@moonshot-ai/protocol"
"@moonshot-ai/kimi-telemetry"
"@moonshot-ai/kimi-code"
"@moonshot-ai/vis"
@ -140,7 +142,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-x5O/+bDRoEW3juoxe3XyvTJxHitQ0hZ2nVXBItkBA5E=";
hash = "sha256-XwkLwxWZtOaw1N1GKR9G3z0yhXO/lDB5+O+VKtgxKWo=";
};
nativeBuildInputs = [

View file

@ -58,6 +58,7 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"@moonshot-ai/kaos": "workspace:^",
"@moonshot-ai/kosong": "workspace:^",
"@moonshot-ai/protocol": "workspace:^",
"@mozilla/readability": "^0.6.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",

View file

@ -15,3 +15,10 @@ export {
toKimiErrorPayload,
type KimiErrorPayload,
} from './serialize';
export {
onUnexpectedError,
resetUnexpectedErrorHandler,
safelyCallListener,
setUnexpectedErrorHandler,
type UnexpectedErrorHandler,
} from './unexpectedError';

View file

@ -0,0 +1,73 @@
/**
* Centralised reporting for unexpected, non-actionable errors. The pattern: listener
* callbacks (registered via `Emitter.event(...)`) may throw; the Emitter
* routes those exceptions through `onUnexpectedError` rather than swallowing
* them silently or letting them bubble through `fire()`.
*
* **Startup-timing constraint (plan §4.5)**: the default handler intentionally
* does NOT resolve `ILogService` at module-load time. At import time the DI
* container is empty touching `ILogService` would NPE. Instead, the default
* stays as a plain `console.error` until the daemon's `startDaemon` later
* calls `setUnexpectedErrorHandler(...)` with a logger-bound version (once
* `ILogger` has been resolved from the accessor). Until that handoff,
* exceptions routed here surface on stderr visible but unstructured.
*/
export type UnexpectedErrorHandler = (err: unknown) => void;
/**
* Default handler. NOTE: do not touch `ILogService` here this module is
* imported eagerly and the DI container has no logger registered at module-
* load time. Falling back to `console.error` keeps startup safe.
*/
const defaultHandler: UnexpectedErrorHandler = (err) => {
// eslint-disable-next-line no-console
console.error('[unexpected]', err);
};
let currentHandler: UnexpectedErrorHandler = defaultHandler;
/**
* Install a new global handler. Replaces any previously-installed handler.
* `startDaemon` calls this once after the DI container is fully wired so
* later exceptions route through `ILogService` instead of stderr.
*/
export function setUnexpectedErrorHandler(handler: UnexpectedErrorHandler): void {
currentHandler = handler;
}
/**
* Reset the global handler to the module-default `console.error` handler.
* Primarily used by tests so a handler installed by one test does not leak
* into the next.
*/
export function resetUnexpectedErrorHandler(): void {
currentHandler = defaultHandler;
}
/**
* Report an unexpected error through the currently-installed handler. The
* handler itself MUST NOT throw; if it does, we fall back to `console.error`
* so a single broken handler does not silently lose the original error.
*/
export function onUnexpectedError(err: unknown): void {
try {
currentHandler(err);
} catch (handlerErr) {
// eslint-disable-next-line no-console
console.error('[unexpected] handler threw', handlerErr, 'while reporting', err);
}
}
/**
* Helper used by `Emitter.fire()` to safely invoke a single listener: any
* synchronous exception is routed through `onUnexpectedError` so siblings
* still run and listener failures don't propagate up the `fire()` call site.
*/
export function safelyCallListener(listener: () => void): void {
try {
listener();
} catch (err) {
onUnexpectedError(err);
}
}

View file

@ -379,6 +379,14 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
async forkSession(input: ForkSessionPayload): Promise<ResumeSessionResult> {
const source = await this.sessionStore.get(input.sessionId);
const active = this.sessions.get(source.id);
if (active?.hasActiveTurn === true) {
throw new KimiError(
ErrorCodes.SESSION_FORK_ACTIVE_TURN,
`Session "${source.id}" cannot be forked while a turn is running`,
{ details: { sessionId: source.id } },
);
}
if (active !== undefined) {
await active.flushMetadata();
}

View file

@ -1,333 +1,49 @@
import type { FinishReason, TokenUsage } from '@moonshot-ai/kosong';
export { MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE } from '@moonshot-ai/protocol';
import type { GoalChange, GoalSnapshot } from '../agent/goal';
import type { CronJobOrigin, PromptOrigin } from '../agent/context';
import type { KimiErrorPayload } from '../errors';
import type { PermissionMode } from '../agent/permission';
import type { SkillSource } from '../skill';
import type { BackgroundTaskInfo } from '../agent/background';
import type { ToolInputDisplay } from '../tools/display';
export type {
AgentEvent,
AgentStatusUpdatedEvent,
AssistantDeltaEvent,
BackgroundTaskStartedEvent,
BackgroundTaskTerminatedEvent,
CompactionBlockedEvent,
CompactionCancelledEvent,
CompactionCompletedEvent,
CompactionResult,
CompactionStartedEvent,
CronFiredEvent,
ErrorEvent,
Event,
GoalUpdatedEvent,
HookResultEvent,
McpOAuthAuthorizationUrlUpdateData,
McpServerStatusEvent,
McpServerStatusPayload,
SessionMetaUpdatedEvent,
SkillActivatedEvent,
SubagentCompletedEvent,
SubagentFailedEvent,
SubagentSpawnedEvent,
SubagentStartedEvent,
SubagentSuspendedEvent,
ThinkingDeltaEvent,
ToolCallDeltaEvent,
ToolCallStartedEvent,
ToolInputDisplay,
ToolListUpdatedEvent,
ToolListUpdatedReason,
ToolProgressEvent,
ToolResultEvent,
ToolUpdate,
TurnEndedEvent,
TurnEndReason,
TurnStartedEvent,
TurnStepCompletedEvent,
TurnStepInterruptedEvent,
TurnStepRetryingEvent,
TurnStepStartedEvent,
UsageStatus,
WarningEvent,
} from '@moonshot-ai/protocol';
export type { ToolInputDisplay } from '../tools/display';
export type { KimiErrorPayload } from '../errors';
export interface UsageStatus {
readonly byModel?: Record<string, TokenUsage> | undefined;
readonly currentTurn?: TokenUsage | undefined;
readonly total?: TokenUsage | undefined;
}
export interface ToolUpdate {
readonly kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom';
readonly text?: string | undefined;
readonly percent?: number | undefined;
readonly customKind?: string | undefined;
readonly customData?: unknown;
}
export const MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE = 'mcp.oauth.authorization_url';
export interface McpOAuthAuthorizationUrlUpdateData {
readonly serverName: string;
readonly authorizationUrl: string;
}
export interface CompactionResult {
readonly summary: string;
readonly compactedCount: number;
readonly tokensBefore: number;
readonly tokensAfter: number;
}
export type TurnEndReason = 'completed' | 'cancelled' | 'failed';
export interface AgentStatusUpdatedEvent {
readonly type: 'agent.status.updated';
readonly model?: string | undefined;
readonly contextTokens?: number | undefined;
readonly maxContextTokens?: number | undefined;
readonly contextUsage?: number | undefined;
readonly planMode?: boolean | undefined;
readonly swarmMode?: boolean | undefined;
readonly permission?: PermissionMode | undefined;
readonly usage?: UsageStatus | undefined;
}
export interface SessionMetaUpdatedEvent {
readonly type: 'session.meta.updated';
readonly title?: string | undefined;
readonly patch?: Record<string, unknown> | undefined;
}
export interface GoalUpdatedEvent {
readonly type: 'goal.updated';
/** Current goal snapshot, or `null` when no goal is set (cleared/cancelled). */
readonly snapshot: GoalSnapshot | null;
/**
* What changed, when the update is a lifecycle / verdict / terminal transition.
* Absent for snapshot-only refreshes (e.g. a turn increment). Drives transcript
* markers and the completion card.
*/
readonly change?: GoalChange;
}
export interface SkillActivatedEvent {
readonly type: 'skill.activated';
readonly activationId: string;
readonly skillName: string;
readonly skillArgs?: string | undefined;
readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill';
readonly skillPath?: string | undefined;
readonly skillSource?: SkillSource | undefined;
}
export interface ErrorEvent extends KimiErrorPayload {
readonly type: 'error';
}
export interface WarningEvent {
readonly type: 'warning';
readonly message: string;
readonly code?: string | undefined;
}
export interface TurnStartedEvent {
readonly type: 'turn.started';
readonly turnId: number;
readonly origin: PromptOrigin;
}
export interface TurnEndedEvent {
readonly type: 'turn.ended';
readonly turnId: number;
readonly reason: TurnEndReason;
readonly error?: KimiErrorPayload | undefined;
}
export interface TurnStepStartedEvent {
readonly type: 'turn.step.started';
readonly turnId: number;
readonly step: number;
readonly stepId?: string | undefined;
}
export interface TurnStepCompletedEvent {
readonly type: 'turn.step.completed';
readonly turnId: number;
readonly step: number;
readonly stepId?: string | undefined;
readonly usage?: TokenUsage | undefined;
readonly finishReason?: string | undefined;
readonly llmFirstTokenLatencyMs?: number | undefined;
readonly llmStreamDurationMs?: number | undefined;
readonly providerFinishReason?: FinishReason | undefined;
readonly rawFinishReason?: string | undefined;
}
export interface TurnStepRetryingEvent {
readonly type: 'turn.step.retrying';
readonly turnId: number;
readonly step: number;
readonly stepId?: string;
readonly failedAttempt: number;
readonly nextAttempt: number;
readonly maxAttempts: number;
readonly delayMs: number;
readonly errorName: string;
readonly errorMessage: string;
readonly statusCode?: number;
}
export interface TurnStepInterruptedEvent {
readonly type: 'turn.step.interrupted';
readonly turnId: number;
readonly step: number;
readonly stepId?: string | undefined;
readonly reason: string;
readonly message?: string | undefined;
}
export interface AssistantDeltaEvent {
readonly type: 'assistant.delta';
readonly turnId: number;
readonly delta: string;
}
export interface HookResultEvent {
readonly type: 'hook.result';
readonly turnId: number;
readonly hookEvent: string;
readonly content: string;
readonly blocked?: boolean | undefined;
}
export interface ThinkingDeltaEvent {
readonly type: 'thinking.delta';
readonly turnId: number;
readonly delta: string;
}
export interface ToolCallDeltaEvent {
readonly type: 'tool.call.delta';
readonly turnId: number;
readonly toolCallId: string;
readonly name?: string | undefined;
readonly argumentsPart?: string | undefined;
}
export interface ToolCallStartedEvent {
readonly type: 'tool.call.started';
readonly turnId: number;
readonly toolCallId: string;
readonly name: string;
readonly args: unknown;
readonly description?: string | undefined;
readonly display?: ToolInputDisplay | undefined;
}
export interface ToolProgressEvent {
readonly type: 'tool.progress';
readonly turnId: number;
readonly toolCallId: string;
readonly update: ToolUpdate;
}
export interface ToolResultEvent {
readonly type: 'tool.result';
readonly turnId: number;
readonly toolCallId: string;
readonly output: unknown;
readonly isError?: boolean | undefined;
readonly synthetic?: boolean | undefined;
}
export interface SubagentSpawnedEvent {
readonly type: 'subagent.spawned';
readonly subagentId: string;
readonly subagentName: string;
readonly parentToolCallId: string;
readonly parentToolCallUuid?: string | undefined;
readonly parentAgentId?: string | undefined;
readonly description?: string | undefined;
readonly swarmIndex?: number;
readonly runInBackground: boolean;
}
export interface SubagentStartedEvent {
readonly type: 'subagent.started';
readonly subagentId: string;
}
export interface SubagentSuspendedEvent {
readonly type: 'subagent.suspended';
readonly subagentId: string;
readonly reason: string;
}
export interface SubagentCompletedEvent {
readonly type: 'subagent.completed';
readonly subagentId: string;
readonly resultSummary: string;
readonly usage?: TokenUsage | undefined;
readonly contextTokens?: number | undefined;
}
export interface SubagentFailedEvent {
readonly type: 'subagent.failed';
readonly subagentId: string;
readonly error: string;
}
export interface CompactionStartedEvent {
readonly type: 'compaction.started';
readonly trigger: 'manual' | 'auto';
readonly instruction?: string | undefined;
}
export interface CompactionBlockedEvent {
readonly type: 'compaction.blocked';
readonly turnId?: number | undefined;
}
export interface CompactionCancelledEvent {
readonly type: 'compaction.cancelled';
}
export interface CompactionCompletedEvent {
readonly type: 'compaction.completed';
readonly result: CompactionResult;
}
export interface BackgroundTaskStartedEvent {
readonly type: 'background.task.started';
readonly info: BackgroundTaskInfo;
}
export interface BackgroundTaskTerminatedEvent {
readonly type: 'background.task.terminated';
readonly info: BackgroundTaskInfo;
}
export interface CronFiredEvent {
readonly type: 'cron.fired';
readonly origin: CronJobOrigin;
readonly prompt: string;
}
export type ToolListUpdatedReason = 'mcp.connected' | 'mcp.disconnected' | 'mcp.failed';
export interface ToolListUpdatedEvent {
readonly type: 'tool.list.updated';
readonly reason: ToolListUpdatedReason;
readonly serverName: string;
}
export interface McpServerStatusEvent {
readonly type: 'mcp.server.status';
readonly server: McpServerStatusPayload;
}
export interface McpServerStatusPayload {
readonly name: string;
readonly transport: 'stdio' | 'http';
readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth';
readonly toolCount: number;
readonly error?: string | undefined;
}
export type AgentEvent =
| ErrorEvent
| WarningEvent
| AgentStatusUpdatedEvent
| SessionMetaUpdatedEvent
| GoalUpdatedEvent
| SkillActivatedEvent
| TurnStartedEvent
| TurnEndedEvent
| TurnStepStartedEvent
| TurnStepCompletedEvent
| TurnStepRetryingEvent
| TurnStepInterruptedEvent
| AssistantDeltaEvent
| HookResultEvent
| ThinkingDeltaEvent
| ToolCallDeltaEvent
| ToolCallStartedEvent
| ToolProgressEvent
| ToolResultEvent
| ToolListUpdatedEvent
| McpServerStatusEvent
| SubagentSpawnedEvent
| SubagentStartedEvent
| SubagentSuspendedEvent
| SubagentCompletedEvent
| SubagentFailedEvent
| CompactionStartedEvent
| CompactionBlockedEvent
| CompactionCancelledEvent
| CompactionCompletedEvent
| BackgroundTaskStartedEvent
| BackgroundTaskTerminatedEvent
| CronFiredEvent;
export type Event = AgentEvent & { agentId: string; sessionId: string };

View file

@ -1,165 +1,8 @@
/**
* Zod schemas for the display unions.
*
* The wire-record layer validates incoming / outgoing
* `input_display` / `result_display` fields against these schemas.
*/
import { z } from 'zod';
export const ToolInputDisplaySchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('command'),
command: z.string(),
cwd: z.string().optional(),
description: z.string().optional(),
language: z.literal('bash').optional(),
}),
z.object({
kind: z.literal('file_io'),
operation: z.enum(['read', 'write', 'edit', 'glob', 'grep']),
path: z.string(),
detail: z.string().optional(),
// Optional preview payload for the approval panel. Write attaches the
// full content; Edit attaches the old/new hunk as before/after. UIs that
// want a diff or code preview read these instead of having to re-derive
// them from the raw tool args.
content: z.string().optional(),
before: z.string().optional(),
after: z.string().optional(),
}),
z.object({
kind: z.literal('diff'),
path: z.string(),
before: z.string(),
after: z.string(),
hunks: z.number().optional(),
}),
z.object({
kind: z.literal('search'),
query: z.string(),
scope: z.string().optional(),
}),
z.object({
kind: z.literal('url_fetch'),
url: z.string(),
method: z.string().optional(),
}),
z.object({
kind: z.literal('agent_call'),
agent_name: z.string(),
prompt: z.string(),
background: z.boolean().optional(),
}),
z.object({
kind: z.literal('skill_call'),
skill_name: z.string(),
args: z.string().optional(),
}),
z.object({
kind: z.literal('todo_list'),
items: z.array(z.object({ title: z.string(), status: z.string() })),
}),
z.object({
kind: z.literal('background_task'),
task_id: z.string(),
status: z.string(),
description: z.string(),
task_kind: z.string().optional(),
}),
z.object({
kind: z.literal('task_stop'),
task_id: z.string(),
task_description: z.string(),
}),
z.object({
kind: z.literal('plan_review'),
plan: z.string(),
path: z.string().optional(),
options: z
.array(
z.object({
label: z.string(),
description: z.string(),
}),
)
.readonly()
.optional(),
}),
z.object({
kind: z.literal('generic'),
summary: z.string(),
detail: z.unknown().optional(),
}),
]);
export const ToolResultDisplaySchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('command_output'),
exit_code: z.number(),
stdout: z.string().optional(),
stderr: z.string().optional(),
}),
z.object({
kind: z.literal('file_content'),
path: z.string(),
content: z.string(),
range: z.object({ start: z.number(), end: z.number() }).optional(),
truncated: z.boolean().optional(),
}),
z.object({
kind: z.literal('diff'),
path: z.string(),
before: z.string(),
after: z.string(),
hunks: z.number().optional(),
}),
z.object({
kind: z.literal('search_results'),
query: z.string(),
matches: z.array(z.object({ file: z.string(), line: z.number(), text: z.string() })),
}),
z.object({
kind: z.literal('url_content'),
url: z.string(),
status: z.number(),
preview: z.string().optional(),
content_type: z.string().optional(),
}),
z.object({
kind: z.literal('agent_summary'),
agent_name: z.string(),
result: z.string().optional(),
steps: z.number().optional(),
}),
z.object({
kind: z.literal('background_task'),
task_id: z.string(),
status: z.string(),
description: z.string(),
}),
z.object({
kind: z.literal('todo_list'),
items: z.array(z.object({ title: z.string(), status: z.string() })),
}),
z.object({ kind: z.literal('structured'), data: z.unknown() }),
z.object({
kind: z.literal('text'),
text: z.string(),
truncated: z.boolean().optional(),
}),
z.object({
kind: z.literal('error'),
message: z.string(),
code: z.string().optional(),
}),
z.object({
kind: z.literal('generic'),
summary: z.string(),
detail: z.unknown().optional(),
}),
]);
// Types inferred from schemas — single source of truth.
export type ToolInputDisplay = z.infer<typeof ToolInputDisplaySchema>;
export type ToolResultDisplay = z.infer<typeof ToolResultDisplaySchema>;
export {
ToolInputDisplaySchema,
ToolResultDisplaySchema,
} from '@moonshot-ai/protocol';
export type {
ToolInputDisplay,
ToolResultDisplay,
} from '@moonshot-ai/protocol';

View file

@ -0,0 +1,81 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
onUnexpectedError,
resetUnexpectedErrorHandler,
safelyCallListener,
setUnexpectedErrorHandler,
} from '#/errors/unexpectedError';
describe('onUnexpectedError + setUnexpectedErrorHandler', () => {
afterEach(() => {
resetUnexpectedErrorHandler();
});
it('default handler does not throw when passed a thrown error', () => {
// Default handler is console.error; replace with a sink so the test
// output stays quiet, but verify the call shape doesn't throw.
let captured: unknown;
setUnexpectedErrorHandler((err) => {
captured = err;
});
expect(() => onUnexpectedError(new Error('boom'))).not.toThrow();
expect((captured as Error).message).toBe('boom');
});
it('setUnexpectedErrorHandler replaces the previous handler', () => {
const aSeen: unknown[] = [];
const bSeen: unknown[] = [];
setUnexpectedErrorHandler((err) => aSeen.push(err));
setUnexpectedErrorHandler((err) => bSeen.push(err));
onUnexpectedError(new Error('after-replace'));
expect(aSeen).toHaveLength(0);
expect(bSeen).toHaveLength(1);
});
it('a throwing handler does NOT propagate; original error is still surfaced', () => {
setUnexpectedErrorHandler(() => {
throw new Error('handler-boom');
});
expect(() => onUnexpectedError(new Error('original'))).not.toThrow();
});
it('resetUnexpectedErrorHandler restores the module default', () => {
const seen: unknown[] = [];
setUnexpectedErrorHandler((err) => seen.push(err));
onUnexpectedError(new Error('with-custom'));
expect(seen).toHaveLength(1);
resetUnexpectedErrorHandler();
// After reset the custom handler should no longer see further errors.
// We re-install another to verify the custom path is empty.
seen.length = 0;
onUnexpectedError(new Error('after-reset'));
expect(seen).toHaveLength(0);
});
});
describe('safelyCallListener', () => {
afterEach(() => {
resetUnexpectedErrorHandler();
});
it('invokes the listener', () => {
let called = false;
safelyCallListener(() => {
called = true;
});
expect(called).toBe(true);
});
it('routes a thrown error to the installed handler', () => {
const captured: unknown[] = [];
setUnexpectedErrorHandler((err) => captured.push(err));
expect(() =>
safelyCallListener(() => {
throw new Error('listener-boom');
}),
).not.toThrow();
expect(captured).toHaveLength(1);
expect((captured[0] as Error).message).toBe('listener-boom');
});
});

View file

@ -119,6 +119,40 @@ describe('Session.cancel', () => {
});
});
describe('KimiHarness.forkSession', () => {
it('rejects while the source session has an active turn', async () => {
const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-fork-active-home-');
const workDir = await makeTempDir(tempDirs, 'kimi-sdk-fork-active-work-');
await writeFakeModelConfig(homeDir);
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
try {
const session = await harness.createSession({ id: 'ses_fork_active_turn', workDir });
const started = waitForSDKEvent(session, (event) => event.type === 'turn.started');
const ended = waitForSDKEvent(session, (event) => event.type === 'turn.ended');
await session.prompt('keep this turn active');
await started;
try {
await expect(
harness.forkSession({
id: session.id,
forkId: 'ses_fork_active_child',
}),
).rejects.toMatchObject({
name: 'KimiError',
code: 'session.fork_active_turn',
} satisfies Partial<KimiError>);
} finally {
await session.cancel().catch(() => undefined);
await ended.catch(() => undefined);
}
} finally {
await harness.close();
}
});
});
async function writeFakeModelConfig(homeDir: string): Promise<void> {
await writeFile(
join(homeDir, 'config.toml'),

View file

@ -0,0 +1,39 @@
{
"name": "@moonshot-ai/protocol",
"version": "0.1.0",
"private": true,
"description": "Shared REST + WS protocol schemas (envelope, error codes, pagination, ws-control) for the kimi-code daemon.",
"license": "MIT",
"author": "Moonshot AI",
"repository": {
"type": "git",
"url": "git+https://github.com/MoonshotAI/kimi-code.git",
"directory": "packages/protocol"
},
"bugs": {
"url": "https://github.com/MoonshotAI/kimi-code/issues"
},
"type": "module",
"imports": {
"#/*": [
"./src/*.ts",
"./src/*/index.ts"
]
},
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "tsdown",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"ulid": "^3.0.1",
"zod": "catalog:"
}
}

View file

@ -0,0 +1,176 @@
import { describe, it, expect } from 'vitest';
import {
approvalDecisionSchema,
approvalScopeSchema,
approvalRequestSchema,
approvalResponseSchema,
} from '../approval';
import {
approvalResolveRequestSchema,
approvalResolveResultSchema,
approvalAlreadyResolvedDataSchema,
listPendingApprovalsQuerySchema,
listPendingApprovalsResponseSchema,
} from '../rest/approval';
describe('approvalDecisionSchema (SCHEMAS §6.1)', () => {
it.each(['approved', 'rejected', 'cancelled'] as const)('accepts %s', (d) => {
expect(approvalDecisionSchema.parse(d)).toBe(d);
});
it('rejects unknown decision', () => {
expect(() => approvalDecisionSchema.parse('expired')).toThrow();
});
});
describe('approvalScopeSchema', () => {
it('accepts "session"', () => {
expect(approvalScopeSchema.parse('session')).toBe('session');
});
it('rejects unknown scope', () => {
expect(() => approvalScopeSchema.parse('workspace')).toThrow();
});
});
describe('approvalRequestSchema (SCHEMAS §6.1)', () => {
const base = {
approval_id: '01J0000000APPROVAL',
session_id: 'sess_x',
tool_call_id: 'tc_1',
tool_name: 'shell.run',
action: 'Run `rm -rf foo/`',
tool_input_display: { kind: 'command', command: 'rm -rf foo/', summary: 'rm' },
created_at: '2026-06-04T10:30:00Z',
expires_at: '2026-06-04T10:31:00Z',
};
it('accepts a full approval request', () => {
const parsed = approvalRequestSchema.parse(base);
expect(parsed.approval_id).toBe('01J0000000APPROVAL');
expect(parsed.tool_call_id).toBe('tc_1');
});
it('accepts arbitrary tool_input_display shapes (12-arm passthrough)', () => {
const exotic = { ...base, tool_input_display: { kind: 'future_unknown_kind', summary: 'hi' } };
const parsed = approvalRequestSchema.parse(exotic);
expect((parsed.tool_input_display as { kind: string }).kind).toBe('future_unknown_kind');
});
it('accepts optional turn_id', () => {
const parsed = approvalRequestSchema.parse({ ...base, turn_id: 42 });
expect(parsed.turn_id).toBe(42);
});
it('normalizes timestamps', () => {
const parsed = approvalRequestSchema.parse({
...base,
created_at: '2026-06-04T18:30:00+08:00',
});
expect(parsed.created_at).toBe('2026-06-04T10:30:00.000Z');
});
it('rejects missing approval_id', () => {
const { approval_id: _, ...rest } = base;
void _;
expect(() => approvalRequestSchema.parse(rest)).toThrow();
});
});
describe('approvalResponseSchema (SCHEMAS §6.1)', () => {
it('accepts a minimal approval', () => {
expect(approvalResponseSchema.parse({ decision: 'approved' })).toEqual({
decision: 'approved',
});
});
it('accepts full response with scope/feedback/selected_label', () => {
const parsed = approvalResponseSchema.parse({
decision: 'approved',
scope: 'session',
feedback: 'looks good',
selected_label: 'Run command',
});
expect(parsed.scope).toBe('session');
expect(parsed.feedback).toBe('looks good');
expect(parsed.selected_label).toBe('Run command');
});
it('rejects unknown decision value', () => {
expect(() => approvalResponseSchema.parse({ decision: 'maybe' })).toThrow();
});
});
describe('approvalResolveRequestSchema (REST §3.6)', () => {
it('aliases approvalResponseSchema', () => {
const value = approvalResolveRequestSchema.parse({ decision: 'rejected', feedback: 'no' });
expect(value.decision).toBe('rejected');
});
});
describe('approvalResolveResultSchema (REST §3.6)', () => {
it('requires resolved:true literal and ISO resolved_at', () => {
const parsed = approvalResolveResultSchema.parse({
resolved: true,
resolved_at: '2026-06-04T10:31:00Z',
});
expect(parsed.resolved).toBe(true);
expect(parsed.resolved_at).toBe('2026-06-04T10:31:00.000Z');
});
it('rejects resolved:false here (that path uses approvalAlreadyResolvedDataSchema)', () => {
expect(() =>
approvalResolveResultSchema.parse({ resolved: false, resolved_at: '2026-06-04T10:31:00Z' }),
).toThrow();
});
});
describe('approvalAlreadyResolvedDataSchema (REST §3.6 idempotent 40902)', () => {
it('accepts the idempotent shape', () => {
expect(approvalAlreadyResolvedDataSchema.parse({ resolved: false })).toEqual({
resolved: false,
});
});
it('rejects resolved:true here', () => {
expect(() => approvalAlreadyResolvedDataSchema.parse({ resolved: true })).toThrow();
});
});
describe('listPendingApprovalsResponseSchema (REST pending recovery)', () => {
const pendingApproval = {
approval_id: '01J0000000APPROVAL',
session_id: 'sess_x',
tool_call_id: 'tc_1',
tool_name: 'shell.run',
action: 'Run `ls`',
tool_input_display: { kind: 'command', command: 'ls', summary: 'ls' },
created_at: '2026-06-04T10:30:00Z',
expires_at: '2026-06-04T10:31:00Z',
};
it('accepts status=pending query', () => {
expect(listPendingApprovalsQuerySchema.parse({ status: 'pending' })).toEqual({
status: 'pending',
});
});
it('rejects unsupported status query', () => {
expect(() =>
listPendingApprovalsQuerySchema.parse({ status: 'resolved' }),
).toThrow();
});
it('returns approval request items', () => {
const parsed = listPendingApprovalsResponseSchema.parse({
items: [pendingApproval],
});
expect(parsed.items[0]?.approval_id).toBe('01J0000000APPROVAL');
expect(parsed.items[0]?.tool_input_display).toEqual({
kind: 'command',
command: 'ls',
summary: 'ls',
});
});
});

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { envelopeSchema, errEnvelope, okEnvelope, type Envelope } from '../envelope';
import { ErrorCode, ErrorCodeReason } from '../error-codes';
describe('envelope', () => {
it('okEnvelope round-trips through envelopeSchema', () => {
const built = okEnvelope({ ok: true }, 'req_test');
expect(built).toEqual({
code: 0,
msg: 'success',
data: { ok: true },
request_id: 'req_test',
});
const schema = envelopeSchema(z.object({ ok: z.boolean() }));
const parsed = schema.parse(built);
expect(parsed).toEqual(built);
});
it('errEnvelope round-trips with data: null', () => {
const built = errEnvelope(ErrorCode.SESSION_NOT_FOUND, 'session abc123 does not exist', 'req_x');
expect(built).toEqual({
code: 40401,
msg: 'session abc123 does not exist',
data: null,
request_id: 'req_x',
});
const parsed = envelopeSchema(z.any()).parse(built);
expect(parsed.data).toBeNull();
expect(parsed.code).toBe(40401);
});
it('envelopeSchema rejects non-integer code', () => {
const schema = envelopeSchema(z.unknown());
expect(schema.safeParse({ code: 1.5, msg: 'x', data: null, request_id: 'r' }).success).toBe(
false,
);
});
it('envelopeSchema rejects missing request_id', () => {
const schema = envelopeSchema(z.unknown());
expect(schema.safeParse({ code: 0, msg: 'success', data: null }).success).toBe(false);
});
it('wire shape matches the daemon helper byte-for-byte', () => {
const ours = okEnvelope({ id: 'sess_1' }, 'req_y');
const oursJson = JSON.stringify(ours);
expect(oursJson).toBe('{"code":0,"msg":"success","data":{"id":"sess_1"},"request_id":"req_y"}');
const errJson = JSON.stringify(errEnvelope(40001, 'validation failed', 'req_z'));
expect(errJson).toBe(
'{"code":40001,"msg":"validation failed","data":null,"request_id":"req_z"}',
);
});
});
describe('error-codes', () => {
it('canonical codes match REST.md §1.4', () => {
expect(ErrorCode.SUCCESS).toBe(0);
expect(ErrorCode.VALIDATION_FAILED).toBe(40001);
expect(ErrorCode.SESSION_NOT_FOUND).toBe(40401);
expect(ErrorCode.APPROVAL_EXPIRED).toBe(41001);
expect(ErrorCode.FS_WATCH_LIMIT_EXCEEDED).toBe(42902);
expect(ErrorCode.INTERNAL_ERROR).toBe(50001);
expect(ErrorCode.TOOL_EXECUTION_FAILED).toBe(60001);
});
it('ErrorCodeReason maps every numeric code to its domain.reason label', () => {
expect(ErrorCodeReason[ErrorCode.SESSION_NOT_FOUND]).toBe('session.not_found');
expect(ErrorCodeReason[ErrorCode.PROVIDER_NOT_FOUND]).toBe('provider.not_found');
expect(ErrorCodeReason[ErrorCode.MODEL_NOT_FOUND]).toBe('model.not_found');
expect(ErrorCodeReason[ErrorCode.VALIDATION_FAILED]).toBe('validation.failed');
expect(ErrorCodeReason[ErrorCode.FS_WATCH_LIMIT_EXCEEDED]).toBe('fs.watch_limit_exceeded');
});
it('reserved codes are not redefined (40101, 50002 absent)', () => {
const allValues = Object.values(ErrorCode);
expect(allValues).not.toContain(40101);
expect(allValues).not.toContain(40102);
expect(allValues).not.toContain(40103);
expect(allValues).not.toContain(42901);
expect(allValues).not.toContain(50002);
});
it('ErrorCode type narrows to the literal union', () => {
const code: ErrorCode = ErrorCode.SESSION_NOT_FOUND;
const env: Envelope<null> = errEnvelope(code, 'x', 'req_t');
expect(env.code).toBe(40401);
});
});

View file

@ -0,0 +1,52 @@
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it, expect } from 'vitest';
import type { Event } from '../events';
import type { ToolInputDisplay } from '../display';
type _AssertEventNonNever = Event extends never ? never : true;
const _assertEvent: _AssertEventNonNever = true;
type _AssertToolInputDisplayNonNever = ToolInputDisplay extends never ? never : true;
const _assertDisplay: _AssertToolInputDisplayNonNever = true;
const packageRoot = fileURLToPath(new URL('../..', import.meta.url));
const sdkPackageName = ['@moonshot-ai', 'kimi-code-sdk'].join('/');
function readPackageFiles(): string {
const files = ['package.json', ...sourceFiles(join(packageRoot, 'src'))];
return files
.map((file) => readFileSync(join(packageRoot, file), 'utf8'))
.join('\n');
}
function sourceFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
files.push(...sourceFiles(full));
} else if (entry.endsWith('.ts')) {
files.push(relative(packageRoot, full));
}
}
return files;
}
describe('events / display re-exports', () => {
it('does not depend on the node SDK package', () => {
expect(readPackageFiles()).not.toContain(sdkPackageName);
});
it('Event re-export is non-never (compile-time check passed)', () => {
expect(_assertEvent).toBe(true);
});
it('ToolInputDisplay re-export is non-never (12-arm union preserved)', () => {
expect(_assertDisplay).toBe(true);
});
});

View file

@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { fileMetaSchema, type FileMeta } from '../file';
describe('fileMetaSchema (W12.2 / Chain 15)', () => {
const base: FileMeta = {
id: '01JABCDEFGHJKMNPQRSTVWXYZ0',
name: 'screenshot.png',
media_type: 'image/png',
size: 4096,
created_at: '2026-06-04T10:00:00.000Z',
};
it('round-trips a minimal record without expires_at', () => {
expect(fileMetaSchema.parse(base)).toEqual(base);
expect(fileMetaSchema.parse(base).expires_at).toBeUndefined();
});
it('round-trips a record with expires_at', () => {
const withExpiry: FileMeta = {
...base,
expires_at: '2026-06-05T10:00:00.000Z',
};
const parsed = fileMetaSchema.parse(withExpiry);
expect(parsed.expires_at).toBe('2026-06-05T10:00:00.000Z');
});
it('accepts size=0 (empty file is legal)', () => {
expect(fileMetaSchema.parse({ ...base, size: 0 }).size).toBe(0);
});
it('rejects a negative size', () => {
expect(
fileMetaSchema.safeParse({ ...base, size: -1 }).success,
).toBe(false);
});
it('rejects empty id / name / media_type', () => {
expect(fileMetaSchema.safeParse({ ...base, id: '' }).success).toBe(false);
expect(fileMetaSchema.safeParse({ ...base, name: '' }).success).toBe(false);
expect(
fileMetaSchema.safeParse({ ...base, media_type: '' }).success,
).toBe(false);
});
it('rejects a non-ISO created_at', () => {
expect(
fileMetaSchema.safeParse({ ...base, created_at: 'not a date' }).success,
).toBe(false);
});
});

View file

@ -0,0 +1,332 @@
import { describe, expect, it } from 'vitest';
import {
fsChangeActionSchema,
fsChangeEntrySchema,
fsChangeEventSchema,
fsChangeKindSchema,
fsEntrySchema,
fsGitStatusEntrySchema,
fsGitStatusSchema,
fsGrepFileHitSchema,
fsGrepMatchSchema,
fsKindSchema,
fsSearchHitSchema,
type FsChangeEntry,
type FsChangeEvent,
type FsEntry,
type FsGitStatusEntry,
type FsGrepFileHit,
type FsGrepMatch,
type FsSearchHit,
} from '../fs';
describe('fsKindSchema', () => {
it.each(['file', 'directory', 'symlink'] as const)('accepts %s', (k) => {
expect(fsKindSchema.parse(k)).toBe(k);
});
it("rejects agent-core-ish 'dir' / 'other' literals", () => {
expect(fsKindSchema.safeParse('dir').success).toBe(false);
expect(fsKindSchema.safeParse('other').success).toBe(false);
});
});
describe('fsGitStatusSchema', () => {
it.each([
'clean',
'modified',
'added',
'deleted',
'renamed',
'untracked',
'ignored',
'conflicted',
] as const)('accepts %s', (s) => {
expect(fsGitStatusSchema.parse(s)).toBe(s);
});
it('rejects unknown git status', () => {
expect(fsGitStatusSchema.safeParse('staged').success).toBe(false);
});
});
describe('fsEntrySchema', () => {
const minimal: FsEntry = {
path: 'src/index.ts',
name: 'index.ts',
kind: 'file',
modified_at: '2026-06-04T10:00:00.000Z',
};
it('round-trips a minimal file entry (no optional fields)', () => {
expect(fsEntrySchema.parse(minimal)).toEqual(minimal);
});
it('round-trips a fully populated entry', () => {
const full: FsEntry = {
...minimal,
size: 1234,
etag: 'abcdef',
mime: 'text/typescript',
language_id: 'typescript',
is_binary: false,
git_status: 'modified',
};
expect(fsEntrySchema.parse(full)).toEqual(full);
});
it('round-trips a directory with child_count', () => {
const dir: FsEntry = {
path: 'src',
name: 'src',
kind: 'directory',
modified_at: '2026-06-04T10:00:00.000Z',
child_count: 42,
};
expect(fsEntrySchema.parse(dir).child_count).toBe(42);
});
it('round-trips a symlink with is_symlink_to', () => {
const sym: FsEntry = {
path: 'link',
name: 'link',
kind: 'symlink',
modified_at: '2026-06-04T10:00:00.000Z',
is_symlink_to: 'target',
};
expect(fsEntrySchema.parse(sym).is_symlink_to).toBe('target');
});
it('rejects negative size', () => {
expect(fsEntrySchema.safeParse({ ...minimal, size: -1 }).success).toBe(false);
});
it('rejects malformed modified_at (no timezone)', () => {
const bad = { ...minimal, modified_at: '2026-06-04T10:00:00' };
expect(fsEntrySchema.safeParse(bad).success).toBe(false);
});
it('rejects negative child_count', () => {
const bad: unknown = {
path: 'src',
name: 'src',
kind: 'directory',
modified_at: '2026-06-04T10:00:00.000Z',
child_count: -1,
};
expect(fsEntrySchema.safeParse(bad).success).toBe(false);
});
});
describe('fsSearchHitSchema (W11.1 / Chain 11)', () => {
const hit: FsSearchHit = {
path: 'src/components/Button.tsx',
name: 'Button.tsx',
kind: 'file',
score: 0.87,
match_positions: [16, 17, 18, 19],
};
it('round-trips a populated hit', () => {
expect(fsSearchHitSchema.parse(hit)).toEqual(hit);
});
it('rejects score outside 0..1', () => {
expect(fsSearchHitSchema.safeParse({ ...hit, score: 1.5 }).success).toBe(false);
expect(fsSearchHitSchema.safeParse({ ...hit, score: -0.1 }).success).toBe(false);
});
it('rejects negative match positions', () => {
expect(
fsSearchHitSchema.safeParse({ ...hit, match_positions: [-1] }).success,
).toBe(false);
});
it('accepts an empty match_positions list', () => {
expect(fsSearchHitSchema.parse({ ...hit, match_positions: [] }).match_positions).toEqual([]);
});
});
describe('fsGrepMatchSchema (W11.1 / Chain 11)', () => {
const match: FsGrepMatch = {
line: 42,
col: 7,
text: ' console.log(message);',
before: ['function greet() {', ' const message = "hello";'],
after: ['}', ''],
};
it('round-trips a populated match', () => {
expect(fsGrepMatchSchema.parse(match)).toEqual(match);
});
it('rejects zero line / col (1-based)', () => {
expect(fsGrepMatchSchema.safeParse({ ...match, line: 0 }).success).toBe(false);
expect(fsGrepMatchSchema.safeParse({ ...match, col: 0 }).success).toBe(false);
});
it('accepts empty before / after arrays', () => {
const m: FsGrepMatch = { ...match, before: [], after: [] };
expect(fsGrepMatchSchema.parse(m).before).toEqual([]);
});
});
describe('fsGrepFileHitSchema (W11.1 / Chain 11)', () => {
it('round-trips a file with one match', () => {
const fh: FsGrepFileHit = {
path: 'src/index.ts',
matches: [
{
line: 1,
col: 1,
text: 'export {}',
before: [],
after: [],
},
],
};
expect(fsGrepFileHitSchema.parse(fh)).toEqual(fh);
});
it('round-trips a file with multiple matches', () => {
const fh: FsGrepFileHit = {
path: 'README.md',
matches: [
{ line: 1, col: 1, text: '# Project', before: [], after: [] },
{ line: 5, col: 3, text: 'Project description', before: [], after: [] },
],
};
expect(fsGrepFileHitSchema.parse(fh).matches).toHaveLength(2);
});
});
describe('fsGitStatusEntrySchema (W11.2 / Chain 12)', () => {
const entry: FsGitStatusEntry = {
path: 'src/index.ts',
status: 'modified',
};
it('round-trips a minimal entry', () => {
expect(fsGitStatusEntrySchema.parse(entry)).toEqual(entry);
});
it('round-trips a renamed entry with rename_from', () => {
const ren: FsGitStatusEntry = {
path: 'src/new-name.ts',
status: 'renamed',
rename_from: 'src/old-name.ts',
};
expect(fsGitStatusEntrySchema.parse(ren).rename_from).toBe('src/old-name.ts');
});
it('rejects an unknown status', () => {
expect(
fsGitStatusEntrySchema.safeParse({ ...entry, status: 'staged' }).success,
).toBe(false);
});
});
describe('fsChangeKindSchema (W12 / Chain 14)', () => {
it.each(['file', 'directory', 'symlink'] as const)('accepts %s', (k) => {
expect(fsChangeKindSchema.parse(k)).toBe(k);
});
it('rejects unknown kinds (chokidar leakage like "addDir")', () => {
expect(fsChangeKindSchema.safeParse('addDir').success).toBe(false);
expect(fsChangeKindSchema.safeParse('dir').success).toBe(false);
});
});
describe('fsChangeActionSchema (W12 / Chain 14)', () => {
it.each(['created', 'modified', 'deleted'] as const)(
'accepts %s',
(a) => {
expect(fsChangeActionSchema.parse(a)).toBe(a);
},
);
it('rejects chokidar raw event names (must collapse before wire)', () => {
for (const raw of ['add', 'change', 'unlink', 'addDir', 'unlinkDir']) {
expect(fsChangeActionSchema.safeParse(raw).success).toBe(false);
}
});
});
describe('fsChangeEntrySchema (W12 / Chain 14)', () => {
it('round-trips a minimal created-file entry', () => {
const entry: FsChangeEntry = {
path: 'src/index.ts',
change: 'created',
kind: 'file',
};
expect(fsChangeEntrySchema.parse(entry)).toEqual(entry);
});
it('accepts size_delta + etag on a modified file', () => {
const entry: FsChangeEntry = {
path: 'src/foo.ts',
change: 'modified',
kind: 'file',
size_delta: 17,
etag: 'abc123',
};
const parsed = fsChangeEntrySchema.parse(entry);
expect(parsed.size_delta).toBe(17);
expect(parsed.etag).toBe('abc123');
});
it('accepts a negative size_delta (file shrank)', () => {
expect(
fsChangeEntrySchema.parse({
path: 'src/big.log',
change: 'modified',
kind: 'file',
size_delta: -1024,
}).size_delta,
).toBe(-1024);
});
it('round-trips a deleted-directory entry', () => {
const entry: FsChangeEntry = {
path: 'old/',
change: 'deleted',
kind: 'directory',
};
expect(fsChangeEntrySchema.parse(entry)).toEqual(entry);
});
});
describe('fsChangeEventSchema (W12 / Chain 14)', () => {
it('round-trips a non-truncated event with two changes', () => {
const ev: FsChangeEvent = {
changes: [
{ path: 'a.txt', change: 'created', kind: 'file' },
{ path: 'b.txt', change: 'modified', kind: 'file', size_delta: 5 },
],
coalesced_window_ms: 200,
};
const parsed = fsChangeEventSchema.parse(ev);
expect(parsed.changes.length).toBe(2);
expect(parsed.truncated).toBeUndefined();
});
it('round-trips a truncated burst notification', () => {
const ev: FsChangeEvent = {
changes: [],
coalesced_window_ms: 200,
truncated: true,
count: 1742,
};
const parsed = fsChangeEventSchema.parse(ev);
expect(parsed.truncated).toBe(true);
expect(parsed.count).toBe(1742);
expect(parsed.changes).toEqual([]);
});
it('rejects a missing coalesced_window_ms (always echoed)', () => {
expect(
fsChangeEventSchema.safeParse({ changes: [] }).success,
).toBe(false);
});
});

View file

@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest';
import {
fileContentSchema,
imageContentSchema,
messageContentSchema,
messageRoleSchema,
messageSchema,
textContentSchema,
thinkingContentSchema,
toolResultContentSchema,
toolUseContentSchema,
} from '../message';
describe('messageRoleSchema', () => {
it.each(['user', 'assistant', 'tool', 'system'] as const)('accepts %s', (role) => {
expect(messageRoleSchema.parse(role)).toBe(role);
});
it('rejects an unknown role', () => {
expect(messageRoleSchema.safeParse('cat').success).toBe(false);
});
});
describe('messageContentSchema variants', () => {
it('parses text content', () => {
const parsed = textContentSchema.parse({ type: 'text', text: 'hello' });
expect(parsed.text).toBe('hello');
});
it('parses tool_use content', () => {
const parsed = toolUseContentSchema.parse({
type: 'tool_use',
tool_call_id: 'call_1',
tool_name: 'Bash',
input: { command: 'ls' },
});
expect(parsed.tool_name).toBe('Bash');
});
it('parses tool_result content with is_error', () => {
const parsed = toolResultContentSchema.parse({
type: 'tool_result',
tool_call_id: 'call_1',
output: 'error',
is_error: true,
});
expect(parsed.is_error).toBe(true);
});
it('parses image url source', () => {
const parsed = imageContentSchema.parse({
type: 'image',
source: { kind: 'url', url: 'https://example.com/a.png' },
});
expect(parsed.source.kind).toBe('url');
});
it('parses image base64 source', () => {
const parsed = imageContentSchema.parse({
type: 'image',
source: { kind: 'base64', media_type: 'image/png', data: 'aGVsbG8=' },
});
expect(parsed.source.kind).toBe('base64');
});
it('parses file content', () => {
const parsed = fileContentSchema.parse({
type: 'file',
file_id: 'file_01',
name: 'doc.pdf',
media_type: 'application/pdf',
size: 12345,
});
expect(parsed.size).toBe(12345);
});
it('parses thinking content', () => {
const parsed = thinkingContentSchema.parse({
type: 'thinking',
thinking: 'pondering',
});
expect(parsed.thinking).toBe('pondering');
});
it('messageContentSchema discriminates by type', () => {
const parsed = messageContentSchema.parse({ type: 'text', text: 'hi' });
expect(parsed.type).toBe('text');
});
it('rejects unknown content type', () => {
expect(messageContentSchema.safeParse({ type: 'audio', text: '' }).success).toBe(false);
});
});
describe('messageSchema', () => {
const validMessage = {
id: 'msg_01HZZZZ',
session_id: 'sess_01',
role: 'assistant' as const,
content: [{ type: 'text' as const, text: 'hi' }],
created_at: '2026-06-04T10:30:00.000Z',
};
it('parses a minimal assistant message', () => {
const parsed = messageSchema.parse(validMessage);
expect(parsed.id).toBe('msg_01HZZZZ');
expect(parsed.role).toBe('assistant');
expect(parsed.content[0]?.type).toBe('text');
});
it('parses with optional fields', () => {
const parsed = messageSchema.parse({
...validMessage,
prompt_id: 'prompt_01',
parent_message_id: 'msg_parent',
metadata: { tag: 'demo' },
});
expect(parsed.prompt_id).toBe('prompt_01');
expect(parsed.parent_message_id).toBe('msg_parent');
expect(parsed.metadata).toEqual({ tag: 'demo' });
});
it('rejects empty id', () => {
expect(messageSchema.safeParse({ ...validMessage, id: '' }).success).toBe(false);
});
it('rejects bad ISO timestamp', () => {
expect(
messageSchema.safeParse({ ...validMessage, created_at: 'yesterday' }).success,
).toBe(false);
});
it('accepts tool message with tool_result content', () => {
const parsed = messageSchema.parse({
...validMessage,
role: 'tool',
content: [
{
type: 'tool_result',
tool_call_id: 'call_1',
output: 'done',
},
],
});
expect(parsed.role).toBe('tool');
});
});

View file

@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import {
getProviderResponseSchema,
listModelsResponseSchema,
listProvidersResponseSchema,
modelCatalogItemSchema,
providerCatalogItemSchema,
providerCatalogStatusSchema,
setDefaultModelResponseSchema,
type ModelCatalogItem,
type ProviderCatalogItem,
} from '../index';
describe('model catalog schemas', () => {
const model: ModelCatalogItem = {
provider: 'kimi',
model: 'k2',
display_name: 'Kimi K2',
max_context_size: 131072,
capabilities: ['thinking'],
};
const provider: ProviderCatalogItem = {
id: 'kimi',
type: 'kimi',
base_url: 'https://api.example.test/v1',
default_model: 'k2',
has_api_key: true,
status: 'connected',
models: ['k2'],
};
it('round-trips a model catalog item', () => {
expect(modelCatalogItemSchema.parse(model)).toEqual(model);
});
it('rejects invalid model context sizes', () => {
expect(
modelCatalogItemSchema.safeParse({ ...model, max_context_size: 0 }).success,
).toBe(false);
});
it.each(['connected', 'error', 'unconfigured'] as const)(
'accepts provider status %s',
(status) => {
expect(providerCatalogStatusSchema.parse(status)).toBe(status);
},
);
it('round-trips a provider catalog item', () => {
expect(providerCatalogItemSchema.parse(provider)).toEqual(provider);
expect(getProviderResponseSchema.parse(provider)).toEqual(provider);
});
it('round-trips list responses and set-default response', () => {
expect(listModelsResponseSchema.parse({ items: [model] })).toEqual({
items: [model],
});
expect(listProvidersResponseSchema.parse({ items: [provider] })).toEqual({
items: [provider],
});
expect(
setDefaultModelResponseSchema.parse({ default_model: 'k2', model }),
).toEqual({
default_model: 'k2',
model,
});
});
});

View file

@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { ErrorCode } from '../error-codes';
import { CursorQuery, cursorQuerySchema, pageResponseSchema } from '../pagination';
import { z } from 'zod';
describe('pagination — CursorQuery', () => {
it('alias and schema are the same object', () => {
expect(CursorQuery).toBe(cursorQuerySchema);
});
it('accepts empty query (first-page fetch)', () => {
expect(cursorQuerySchema.safeParse({}).success).toBe(true);
});
it('accepts before_id alone', () => {
const result = cursorQuerySchema.safeParse({ before_id: 'msg_01HX', page_size: 50 });
expect(result.success).toBe(true);
});
it('accepts after_id alone', () => {
const result = cursorQuerySchema.safeParse({ after_id: 'msg_01HX', page_size: 50 });
expect(result.success).toBe(true);
});
it('rejects before_id + after_id simultaneously with 40001-mapped issue', () => {
const result = cursorQuerySchema.safeParse({ before_id: 'x', after_id: 'y' });
expect(result.success).toBe(false);
if (!result.success) {
const mutexIssue = result.error.issues.find(
(issue) =>
issue.code === 'custom' &&
(issue.params as { code?: number } | undefined)?.code === ErrorCode.VALIDATION_FAILED,
);
expect(mutexIssue).toBeDefined();
expect(mutexIssue?.message).toMatch(/mutually exclusive/);
}
});
it('rejects page_size below 1', () => {
expect(cursorQuerySchema.safeParse({ page_size: 0 }).success).toBe(false);
});
it('rejects page_size above 100 (SCHEMAS.md §1.3 hard upper bound)', () => {
expect(cursorQuerySchema.safeParse({ page_size: 101 }).success).toBe(false);
expect(cursorQuerySchema.safeParse({ page_size: 300 }).success).toBe(false);
});
it('rejects non-integer page_size', () => {
expect(cursorQuerySchema.safeParse({ page_size: 50.5 }).success).toBe(false);
});
});
describe('pagination — pageResponseSchema', () => {
it('shapes `data` as { items, has_more } only — no next_cursor', () => {
const schema = pageResponseSchema(z.object({ id: z.string() }));
const value = schema.parse({
items: [{ id: 'a' }, { id: 'b' }],
has_more: true,
});
expect(value.items).toHaveLength(2);
expect(value.has_more).toBe(true);
expect((value as Record<string, unknown>)['next_cursor']).toBeUndefined();
});
it('rejects missing has_more', () => {
const schema = pageResponseSchema(z.unknown());
expect(schema.safeParse({ items: [] }).success).toBe(false);
});
it('rejects non-array items', () => {
const schema = pageResponseSchema(z.unknown());
expect(schema.safeParse({ items: 'oops', has_more: false }).success).toBe(false);
});
});

View file

@ -0,0 +1,287 @@
import { describe, it, expect } from 'vitest';
import {
questionAnswerMethodSchema,
questionAnswerSchema,
questionItemSchema,
questionOptionSchema,
questionRequestSchema,
questionResponseSchema,
} from '../question';
import {
questionResolveRequestSchema,
questionResolveResultSchema,
questionAlreadyResolvedDataSchema,
questionDismissResultSchema,
listPendingQuestionsQuerySchema,
listPendingQuestionsResponseSchema,
} from '../rest/question';
describe('questionOptionSchema (SCHEMAS §6.2)', () => {
it('accepts id+label', () => {
expect(questionOptionSchema.parse({ id: 'opt_1', label: 'Yes' })).toEqual({
id: 'opt_1',
label: 'Yes',
});
});
it('accepts optional description', () => {
const parsed = questionOptionSchema.parse({
id: 'opt_1',
label: 'Yes',
description: 'long form',
});
expect(parsed.description).toBe('long form');
});
it('rejects missing id', () => {
expect(() => questionOptionSchema.parse({ label: 'Yes' })).toThrow();
});
});
describe('questionItemSchema (SCHEMAS §6.2)', () => {
const baseItem = {
id: 'q_1',
question: 'Which?',
options: [
{ id: 'opt_1', label: 'Yes' },
{ id: 'opt_2', label: 'No' },
],
};
it('accepts a minimum 2-option single-select item', () => {
expect(questionItemSchema.parse(baseItem).id).toBe('q_1');
});
it('accepts a 4-option multi_select item with allow_other', () => {
const parsed = questionItemSchema.parse({
...baseItem,
options: [
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
{ id: 'c', label: 'C' },
{ id: 'd', label: 'D' },
],
multi_select: true,
allow_other: true,
other_label: 'Other',
});
expect(parsed.multi_select).toBe(true);
expect(parsed.allow_other).toBe(true);
expect(parsed.other_label).toBe('Other');
});
it('rejects fewer than 2 options', () => {
expect(() => questionItemSchema.parse({ ...baseItem, options: [baseItem.options[0]] })).toThrow();
});
it('rejects more than 4 options', () => {
const tooMany = Array.from({ length: 5 }, (_, i) => ({ id: `o${i}`, label: `L${i}` }));
expect(() => questionItemSchema.parse({ ...baseItem, options: tooMany })).toThrow();
});
});
describe('questionRequestSchema (SCHEMAS §6.2)', () => {
const baseReq = {
question_id: '01J_QUESTION',
session_id: 'sess_x',
questions: [
{
id: 'q_1',
question: 'Which?',
options: [
{ id: 'opt_1', label: 'A' },
{ id: 'opt_2', label: 'B' },
],
},
],
created_at: '2026-06-04T10:30:00Z',
expires_at: '2026-06-04T10:31:00Z',
};
it('accepts a 1-question request', () => {
expect(questionRequestSchema.parse(baseReq).question_id).toBe('01J_QUESTION');
});
it('rejects 0 questions', () => {
expect(() => questionRequestSchema.parse({ ...baseReq, questions: [] })).toThrow();
});
it('rejects more than 4 questions', () => {
const tooMany = Array.from({ length: 5 }, (_, i) => ({
id: `q${i}`,
question: `?${i}`,
options: [
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
],
}));
expect(() => questionRequestSchema.parse({ ...baseReq, questions: tooMany })).toThrow();
});
it('normalizes timestamps to UTC', () => {
const parsed = questionRequestSchema.parse({
...baseReq,
created_at: '2026-06-04T18:30:00+08:00',
});
expect(parsed.created_at).toBe('2026-06-04T10:30:00.000Z');
});
});
describe('questionAnswerSchema 5-kind discriminated union (SCHEMAS §6.2)', () => {
it.each([
['single', { kind: 'single', option_id: 'opt_1' }],
['multi', { kind: 'multi', option_ids: ['opt_1', 'opt_2'] }],
['other', { kind: 'other', text: 'free form' }],
[
'multi_with_other',
{ kind: 'multi_with_other', option_ids: ['opt_1'], other_text: 'tail' },
],
['skipped', { kind: 'skipped' }],
] as const)('accepts %s kind', (_, val) => {
expect(questionAnswerSchema.parse(val)).toEqual(val);
});
it('rejects single with empty option_id', () => {
expect(() => questionAnswerSchema.parse({ kind: 'single', option_id: '' })).toThrow();
});
it('rejects multi with empty option_ids array', () => {
expect(() => questionAnswerSchema.parse({ kind: 'multi', option_ids: [] })).toThrow();
});
it('rejects unknown kind', () => {
expect(() => questionAnswerSchema.parse({ kind: 'rangefinder', value: 42 })).toThrow();
});
});
describe('questionAnswerMethodSchema', () => {
it.each(['enter', 'space', 'number_key', 'click'] as const)('accepts %s', (m) => {
expect(questionAnswerMethodSchema.parse(m)).toBe(m);
});
it('rejects unknown method', () => {
expect(() => questionAnswerMethodSchema.parse('voice')).toThrow();
});
});
describe('questionResponseSchema (SCHEMAS §6.2)', () => {
it('accepts a single-answer response with all optional fields', () => {
const parsed = questionResponseSchema.parse({
answers: { q_1: { kind: 'single', option_id: 'opt_1' } },
method: 'click',
note: 'all done',
});
expect(parsed.answers['q_1']).toEqual({ kind: 'single', option_id: 'opt_1' });
expect(parsed.method).toBe('click');
expect(parsed.note).toBe('all done');
});
it('accepts a mixed-kind response (partial-answer pattern)', () => {
const parsed = questionResponseSchema.parse({
answers: {
q_1: { kind: 'single', option_id: 'opt_1' },
q_2: { kind: 'multi', option_ids: ['opt_1', 'opt_2'] },
q_3: { kind: 'other', text: 'free' },
q_4: { kind: 'skipped' },
},
});
expect(Object.keys(parsed.answers)).toHaveLength(4);
});
});
describe('questionResolveRequestSchema (REST §3.6)', () => {
it('aliases questionResponseSchema', () => {
const parsed = questionResolveRequestSchema.parse({
answers: { q_1: { kind: 'skipped' } },
});
expect(parsed.answers['q_1']).toEqual({ kind: 'skipped' });
});
});
describe('questionResolveResultSchema (REST §3.6)', () => {
it('requires resolved:true literal + ISO resolved_at', () => {
const parsed = questionResolveResultSchema.parse({
resolved: true,
resolved_at: '2026-06-04T10:31:00Z',
});
expect(parsed.resolved).toBe(true);
});
it('rejects resolved:false', () => {
expect(() =>
questionResolveResultSchema.parse({ resolved: false, resolved_at: '2026-06-04T10:31:00Z' }),
).toThrow();
});
});
describe('questionAlreadyResolvedDataSchema (REST §3.6 idempotent 40902)', () => {
it('accepts resolved:false', () => {
expect(questionAlreadyResolvedDataSchema.parse({ resolved: false })).toEqual({
resolved: false,
});
});
it('rejects resolved:true', () => {
expect(() => questionAlreadyResolvedDataSchema.parse({ resolved: true })).toThrow();
});
});
describe('questionDismissResultSchema (REST §3.6 dismiss with code 40909)', () => {
it('requires dismissed:true literal + ISO dismissed_at', () => {
const parsed = questionDismissResultSchema.parse({
dismissed: true,
dismissed_at: '2026-06-04T10:32:00Z',
});
expect(parsed.dismissed).toBe(true);
expect(parsed.dismissed_at).toBe('2026-06-04T10:32:00.000Z');
});
it('rejects dismissed:false', () => {
expect(() =>
questionDismissResultSchema.parse({
dismissed: false,
dismissed_at: '2026-06-04T10:32:00Z',
}),
).toThrow();
});
});
describe('listPendingQuestionsResponseSchema (REST pending recovery)', () => {
const pendingQuestion = {
question_id: '01J_QUESTION',
session_id: 'sess_x',
questions: [
{
id: 'q_1',
question: 'Which?',
options: [
{ id: 'opt_1', label: 'A' },
{ id: 'opt_2', label: 'B' },
],
},
],
created_at: '2026-06-04T10:30:00Z',
expires_at: '2026-06-04T10:31:00Z',
};
it('accepts status=pending query', () => {
expect(listPendingQuestionsQuerySchema.parse({ status: 'pending' })).toEqual({
status: 'pending',
});
});
it('rejects unsupported status query', () => {
expect(() =>
listPendingQuestionsQuerySchema.parse({ status: 'answered' }),
).toThrow();
});
it('returns question request items', () => {
const parsed = listPendingQuestionsResponseSchema.parse({
items: [pendingQuestion],
});
expect(parsed.items[0]?.question_id).toBe('01J_QUESTION');
expect(parsed.items[0]?.questions[0]?.options).toHaveLength(2);
});
});

View file

@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { ulid } from 'ulid';
import { isUlid, parseOrGenerateRequestId, ulidRegex } from '../request-id';
describe('request-id — ulidRegex', () => {
it('matches valid 26-char Crockford ULIDs', () => {
const id = ulid();
expect(ulidRegex.test(id)).toBe(true);
});
it('rejects malformed strings', () => {
expect(ulidRegex.test('not-a-ulid')).toBe(false);
expect(ulidRegex.test('')).toBe(false);
expect(ulidRegex.test('01HX')).toBe(false);
expect(ulidRegex.test('01ARZ3NDEKTSV4RRFFQ69G5FALI')).toBe(false);
});
});
describe('request-id — parseOrGenerateRequestId', () => {
it('mints a new ULID when input is undefined', () => {
const out = parseOrGenerateRequestId(undefined);
expect(ulidRegex.test(out)).toBe(true);
expect(isUlid(out)).toBe(true);
});
it('mints a new ULID when input is malformed (does not echo back)', () => {
const malformed = 'not-a-ulid';
const out = parseOrGenerateRequestId(malformed);
expect(out).not.toBe(malformed);
expect(ulidRegex.test(out)).toBe(true);
});
it('mints a new ULID when input is empty string', () => {
const out = parseOrGenerateRequestId('');
expect(out).not.toBe('');
expect(ulidRegex.test(out)).toBe(true);
});
it('returns a valid ULID input verbatim', () => {
const supplied = ulid();
const out = parseOrGenerateRequestId(supplied);
expect(out).toBe(supplied);
});
it('two undefined calls return different ULIDs', () => {
const a = parseOrGenerateRequestId(undefined);
const b = parseOrGenerateRequestId(undefined);
expect(a).not.toBe(b);
});
});
describe('request-id — isUlid', () => {
it('accepts a freshly generated ULID', () => {
expect(isUlid(ulid())).toBe(true);
});
it('rejects garbage', () => {
expect(isUlid('garbage')).toBe(false);
});
});

View file

@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import {
authSummarySchema,
managedProviderStatusSchema,
type AuthSummary,
} from '../rest/auth';
describe('authSummarySchema', () => {
const emptyState: AuthSummary = {
ready: false,
providers_count: 0,
default_model: null,
managed_provider: null,
};
const readyState: AuthSummary = {
ready: true,
providers_count: 1,
default_model: 'kimi-k2',
managed_provider: {
name: 'kimi-code-oauth',
status: 'authenticated',
},
};
it('round-trips an empty (unprovisioned) state', () => {
const parsed = authSummarySchema.parse(emptyState);
expect(parsed.ready).toBe(false);
expect(parsed.providers_count).toBe(0);
expect(parsed.default_model).toBeNull();
expect(parsed.managed_provider).toBeNull();
});
it('round-trips a ready state with managed provider', () => {
const parsed = authSummarySchema.parse(readyState);
expect(parsed.ready).toBe(true);
expect(parsed.providers_count).toBe(1);
expect(parsed.default_model).toBe('kimi-k2');
expect(parsed.managed_provider).toEqual({
name: 'kimi-code-oauth',
status: 'authenticated',
});
});
it.each(['authenticated', 'expired', 'revoked', 'unauthenticated'] as const)(
'accepts managed_provider.status = %s',
(status) => {
const parsed = managedProviderStatusSchema.parse(status);
expect(parsed).toBe(status);
},
);
it('rejects an unknown managed_provider.status', () => {
const bad = {
...readyState,
managed_provider: { name: 'kimi-code-oauth', status: 'pending' },
};
expect(authSummarySchema.safeParse(bad).success).toBe(false);
});
it('rejects missing ready', () => {
const { ready: _omit, ...rest } = emptyState;
expect(authSummarySchema.safeParse(rest).success).toBe(false);
});
it('rejects missing providers_count', () => {
const { providers_count: _omit, ...rest } = emptyState;
expect(authSummarySchema.safeParse(rest).success).toBe(false);
});
it('rejects missing default_model', () => {
const { default_model: _omit, ...rest } = emptyState;
expect(authSummarySchema.safeParse(rest).success).toBe(false);
});
it('rejects missing managed_provider', () => {
const { managed_provider: _omit, ...rest } = emptyState;
expect(authSummarySchema.safeParse(rest).success).toBe(false);
});
it('rejects negative providers_count', () => {
const bad = { ...emptyState, providers_count: -1 };
expect(authSummarySchema.safeParse(bad).success).toBe(false);
});
it('rejects empty managed_provider.name', () => {
const bad = {
...readyState,
managed_provider: { name: '', status: 'authenticated' as const },
};
expect(authSummarySchema.safeParse(bad).success).toBe(false);
});
});

View file

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import {
deleteFileParamSchema,
deleteFileResponseSchema,
getFileParamSchema,
uploadFileResponseSchema,
type DeleteFileResponse,
type UploadFileResponse,
} from '../rest/file';
describe('uploadFileResponseSchema (POST /api/v1/files)', () => {
it('round-trips a FileMeta payload', () => {
const payload: UploadFileResponse = {
id: '01JABCDEFGHJKMNPQRSTVWXYZ0',
name: 'note.txt',
media_type: 'text/plain',
size: 12,
created_at: '2026-06-04T10:00:00.000Z',
};
expect(uploadFileResponseSchema.parse(payload)).toEqual(payload);
});
});
describe('getFileParamSchema (GET /api/v1/files/{file_id})', () => {
it('accepts a non-empty file_id', () => {
expect(getFileParamSchema.parse({ file_id: 'f_abc' }).file_id).toBe('f_abc');
});
it('rejects an empty file_id', () => {
expect(getFileParamSchema.safeParse({ file_id: '' }).success).toBe(false);
});
});
describe('deleteFileParamSchema + deleteFileResponseSchema (DELETE /api/v1/files/{file_id})', () => {
it('accepts a non-empty file_id', () => {
expect(deleteFileParamSchema.parse({ file_id: 'f_abc' }).file_id).toBe(
'f_abc',
);
});
it('rejects an empty file_id', () => {
expect(deleteFileParamSchema.safeParse({ file_id: '' }).success).toBe(false);
});
it('response shape is exactly `{deleted: true}`', () => {
const ok: DeleteFileResponse = { deleted: true };
expect(deleteFileResponseSchema.parse(ok)).toEqual({ deleted: true });
});
it('rejects `{deleted: false}` (false-positive defence)', () => {
expect(
deleteFileResponseSchema.safeParse({ deleted: false }).success,
).toBe(false);
});
});

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import {
fsBrowseEntrySchema,
fsBrowseQuerySchema,
fsBrowseResponseSchema,
fsHomeResponseSchema,
} from '../rest/fsBrowse';
describe('fsBrowseQuerySchema', () => {
it('accepts an empty query (path defaults to $HOME at the daemon)', () => {
expect(fsBrowseQuerySchema.parse({})).toEqual({});
});
it('accepts a path string', () => {
expect(fsBrowseQuerySchema.parse({ path: '/Users/foo' })).toEqual({
path: '/Users/foo',
});
});
it('rejects empty string path', () => {
expect(fsBrowseQuerySchema.safeParse({ path: '' }).success).toBe(false);
});
});
describe('fsBrowseEntrySchema', () => {
it('round-trips a non-git dir entry without branch', () => {
const entry = {
name: 'src',
path: '/Users/foo/code/src',
is_dir: true as const,
is_git_repo: false,
};
expect(fsBrowseEntrySchema.parse(entry)).toEqual(entry);
});
it('round-trips a git dir entry with branch', () => {
const entry = {
name: 'kimi-code',
path: '/Users/foo/code/kimi-code',
is_dir: true as const,
is_git_repo: true,
branch: 'main',
};
expect(fsBrowseEntrySchema.parse(entry)).toEqual(entry);
});
it('rejects is_dir=false (only directories are surfaced)', () => {
const bad = {
name: 'README.md',
path: '/Users/foo/code/README.md',
is_dir: false,
is_git_repo: false,
};
expect(fsBrowseEntrySchema.safeParse(bad).success).toBe(false);
});
});
describe('fsBrowseResponseSchema', () => {
it('accepts a populated response with a non-null parent', () => {
const resp = {
path: '/Users/foo/code',
parent: '/Users/foo',
entries: [
{
name: 'kimi-code',
path: '/Users/foo/code/kimi-code',
is_dir: true as const,
is_git_repo: true,
branch: 'main',
},
],
};
expect(fsBrowseResponseSchema.parse(resp).entries.length).toBe(1);
});
it('accepts parent=null for filesystem roots', () => {
const resp = {
path: '/',
parent: null,
entries: [],
};
expect(fsBrowseResponseSchema.parse(resp).parent).toBeNull();
});
});
describe('fsHomeResponseSchema', () => {
it('round-trips an empty recent_roots list', () => {
expect(
fsHomeResponseSchema.parse({ home: '/Users/foo', recent_roots: [] }),
).toEqual({ home: '/Users/foo', recent_roots: [] });
});
it('round-trips a populated recent_roots list', () => {
const resp = {
home: '/Users/foo',
recent_roots: ['/Users/foo/code/kimi-code', '/Users/foo/code/other'],
};
expect(fsHomeResponseSchema.parse(resp).recent_roots.length).toBe(2);
});
});

View file

@ -0,0 +1,430 @@
import { describe, expect, it } from 'vitest';
import {
fsDownloadParamsSchema,
fsGitStatusRequestSchema,
fsGitStatusResponseSchema,
fsGrepRequestSchema,
fsGrepResponseSchema,
fsListManyRequestSchema,
fsListManyResponseSchema,
fsListRequestSchema,
fsListResponseSchema,
fsReadRequestSchema,
fsReadResponseSchema,
fsSearchRequestSchema,
fsSearchResponseSchema,
fsStatManyRequestSchema,
fsStatManyResponseSchema,
fsStatRequestSchema,
} from '../rest/fs';
describe('fsListRequestSchema', () => {
it('applies all defaults on empty body', () => {
const parsed = fsListRequestSchema.parse({});
expect(parsed).toEqual({
path: '.',
depth: 1,
limit: 200,
show_hidden: false,
follow_gitignore: true,
sort: 'type_first',
include_git_status: false,
});
});
it('round-trips a fully populated request', () => {
const body = {
path: 'src',
depth: 3,
limit: 500,
show_hidden: true,
follow_gitignore: false,
exclude_globs: ['**/node_modules/**'],
sort: 'mtime_desc' as const,
include_git_status: true,
};
expect(fsListRequestSchema.parse(body)).toEqual(body);
});
it('rejects depth > 10', () => {
expect(fsListRequestSchema.safeParse({ depth: 11 }).success).toBe(false);
});
it('rejects limit > 1000', () => {
expect(fsListRequestSchema.safeParse({ limit: 1001 }).success).toBe(false);
});
});
describe('fsListResponseSchema', () => {
it('round-trips an empty truncated:false response', () => {
expect(
fsListResponseSchema.parse({ items: [], truncated: false }),
).toEqual({ items: [], truncated: false });
});
it('round-trips a response with children_by_path', () => {
const r = {
items: [
{
path: 'src',
name: 'src',
kind: 'directory' as const,
modified_at: '2026-06-04T10:00:00.000Z',
child_count: 2,
},
],
children_by_path: {
src: [
{
path: 'src/index.ts',
name: 'index.ts',
kind: 'file' as const,
size: 100,
modified_at: '2026-06-04T10:00:00.000Z',
},
],
},
truncated: false,
};
expect(fsListResponseSchema.parse(r)).toEqual(r);
});
});
describe('fsReadRequestSchema', () => {
it('applies defaults', () => {
const parsed = fsReadRequestSchema.parse({ path: 'a.ts' });
expect(parsed).toEqual({
path: 'a.ts',
offset: 0,
length: 1_048_576,
encoding: 'auto',
});
});
it('rejects length > 10 MB', () => {
expect(
fsReadRequestSchema.safeParse({ path: 'a', length: 10_485_761 }).success,
).toBe(false);
});
it('rejects empty path', () => {
expect(fsReadRequestSchema.safeParse({ path: '' }).success).toBe(false);
});
it('rejects negative offset', () => {
expect(
fsReadRequestSchema.safeParse({ path: 'a', offset: -1 }).success,
).toBe(false);
});
});
describe('fsReadResponseSchema', () => {
it('round-trips a text response', () => {
const r = {
path: 'a.ts',
content: 'hello',
encoding: 'utf-8' as const,
size: 5,
truncated: false,
etag: 'deadbeef',
mime: 'text/typescript',
language_id: 'typescript',
line_count: 1,
is_binary: false,
};
expect(fsReadResponseSchema.parse(r)).toEqual(r);
});
it('round-trips a base64 response with line_count omitted', () => {
const r = {
path: 'a.png',
content: 'iVBORw0KGgo=',
encoding: 'base64' as const,
size: 9,
truncated: false,
etag: 'cafebabe',
mime: 'image/png',
is_binary: true,
};
expect(fsReadResponseSchema.parse(r)).toEqual(r);
});
});
describe('fsListManyRequestSchema', () => {
it('requires at least one path', () => {
expect(fsListManyRequestSchema.safeParse({ paths: [] }).success).toBe(false);
});
it('caps paths at 100', () => {
const paths = Array.from({ length: 101 }, (_, i) => `p${i}`);
expect(fsListManyRequestSchema.safeParse({ paths }).success).toBe(false);
});
it('applies depth/limit defaults', () => {
const parsed = fsListManyRequestSchema.parse({ paths: ['a', 'b'] });
expect(parsed.depth).toBe(1);
expect(parsed.limit).toBe(200);
});
});
describe('fsListManyResponseSchema', () => {
it('round-trips a per-path results map with partial_errors', () => {
const r = {
results: {
src: [
{
path: 'src/index.ts',
name: 'index.ts',
kind: 'file' as const,
size: 100,
modified_at: '2026-06-04T10:00:00.000Z',
},
],
},
partial_errors: {
'does/not/exist': { code: 40409, msg: 'fs.path_not_found' },
},
};
expect(fsListManyResponseSchema.parse(r)).toEqual(r);
});
});
describe('fsStatRequestSchema', () => {
it('requires non-empty path', () => {
expect(fsStatRequestSchema.safeParse({ path: '' }).success).toBe(false);
expect(fsStatRequestSchema.parse({ path: 'a' })).toEqual({ path: 'a' });
});
});
describe('fsStatManyRequestSchema', () => {
it('caps paths at 1000', () => {
const paths = Array.from({ length: 1001 }, (_, i) => `p${i}`);
expect(fsStatManyRequestSchema.safeParse({ paths }).success).toBe(false);
});
it('accepts exactly 1000 paths', () => {
const paths = Array.from({ length: 1000 }, (_, i) => `p${i}`);
expect(fsStatManyRequestSchema.safeParse({ paths }).success).toBe(true);
});
it('rejects empty paths array', () => {
expect(fsStatManyRequestSchema.safeParse({ paths: [] }).success).toBe(false);
});
});
describe('fsStatManyResponseSchema', () => {
it('accepts null per-path entries (miss marker)', () => {
const r = {
entries: {
present: {
path: 'present',
name: 'present',
kind: 'file' as const,
modified_at: '2026-06-04T10:00:00.000Z',
},
missing: null,
},
};
expect(fsStatManyResponseSchema.parse(r)).toEqual(r);
});
});
describe('fsSearchRequestSchema (W11.1)', () => {
it('applies all defaults on minimal body', () => {
const parsed = fsSearchRequestSchema.parse({ query: 'Button' });
expect(parsed).toEqual({
query: 'Button',
limit: 50,
follow_gitignore: true,
});
});
it('rejects empty query', () => {
expect(fsSearchRequestSchema.safeParse({ query: '' }).success).toBe(false);
});
it('caps limit at 200', () => {
expect(fsSearchRequestSchema.safeParse({ query: 'a', limit: 201 }).success).toBe(false);
expect(fsSearchRequestSchema.safeParse({ query: 'a', limit: 200 }).success).toBe(true);
});
it('round-trips include / exclude globs', () => {
const body = {
query: 'a',
limit: 100,
include_globs: ['**/*.ts'],
exclude_globs: ['**/node_modules/**'],
follow_gitignore: false,
};
expect(fsSearchRequestSchema.parse(body)).toEqual(body);
});
});
describe('fsSearchResponseSchema (W11.1)', () => {
it('round-trips an empty response', () => {
expect(fsSearchResponseSchema.parse({ items: [], truncated: false }))
.toEqual({ items: [], truncated: false });
});
it('round-trips a populated response', () => {
const r = {
items: [
{
path: 'src/Button.tsx',
name: 'Button.tsx',
kind: 'file' as const,
score: 0.9,
match_positions: [4, 5, 6, 7, 8, 9],
},
],
truncated: true,
};
expect(fsSearchResponseSchema.parse(r)).toEqual(r);
});
});
describe('fsGrepRequestSchema (W11.1)', () => {
it('applies all REST.md §3.9 defaults', () => {
const parsed = fsGrepRequestSchema.parse({ pattern: 'hello' });
expect(parsed).toEqual({
pattern: 'hello',
regex: false,
case_sensitive: true,
follow_gitignore: true,
max_files: 200,
max_matches_per_file: 50,
max_total_matches: 5000,
context_lines: 2,
});
});
it('rejects empty pattern', () => {
expect(fsGrepRequestSchema.safeParse({ pattern: '' }).success).toBe(false);
});
it('rejects context_lines > 10', () => {
expect(
fsGrepRequestSchema.safeParse({ pattern: 'a', context_lines: 11 }).success,
).toBe(false);
});
it('accepts a regex pattern', () => {
const parsed = fsGrepRequestSchema.parse({
pattern: 'foo|bar',
regex: true,
});
expect(parsed.regex).toBe(true);
});
});
describe('fsGrepResponseSchema (W11.1)', () => {
it('round-trips an empty response', () => {
expect(
fsGrepResponseSchema.parse({
files: [],
files_scanned: 0,
truncated: false,
elapsed_ms: 12,
}),
).toEqual({ files: [], files_scanned: 0, truncated: false, elapsed_ms: 12 });
});
it('round-trips a populated response', () => {
const r = {
files: [
{
path: 'src/index.ts',
matches: [
{
line: 1,
col: 1,
text: 'console.log("hello");',
before: [],
after: [],
},
],
},
],
files_scanned: 42,
truncated: false,
elapsed_ms: 87,
};
expect(fsGrepResponseSchema.parse(r)).toEqual(r);
});
});
describe('fsGitStatusRequestSchema (W11.2)', () => {
it('accepts an empty body', () => {
expect(fsGitStatusRequestSchema.parse({})).toEqual({});
});
it('accepts an explicit paths filter', () => {
const parsed = fsGitStatusRequestSchema.parse({ paths: ['a', 'b'] });
expect(parsed.paths).toEqual(['a', 'b']);
});
it('rejects empty path strings inside paths', () => {
expect(
fsGitStatusRequestSchema.safeParse({ paths: [''] }).success,
).toBe(false);
});
});
describe('fsGitStatusResponseSchema (W11.2)', () => {
it('round-trips a clean tree response', () => {
const r = {
branch: 'main',
ahead: 0,
behind: 0,
entries: {},
};
expect(fsGitStatusResponseSchema.parse(r)).toEqual(r);
});
it('round-trips a dirty tree response', () => {
const r = {
branch: 'feat/web',
ahead: 2,
behind: 1,
entries: {
'src/index.ts': 'modified' as const,
'src/new.ts': 'untracked' as const,
'src/old.ts': 'deleted' as const,
},
};
expect(fsGitStatusResponseSchema.parse(r)).toEqual(r);
});
it('accepts empty branch (detached HEAD)', () => {
expect(
fsGitStatusResponseSchema.parse({
branch: '',
ahead: 0,
behind: 0,
entries: {},
}).branch,
).toBe('');
});
});
describe('fsDownloadParamsSchema (W11.3)', () => {
it('parses a minimal path', () => {
expect(fsDownloadParamsSchema.parse({ path: 'a.txt' })).toEqual({
path: 'a.txt',
});
});
it('parses range + if-none-match headers', () => {
const p = {
path: 'big.bin',
range: 'bytes=0-65535',
if_none_match: 'cafebabe',
};
expect(fsDownloadParamsSchema.parse(p)).toEqual(p);
});
it('rejects empty path', () => {
expect(fsDownloadParamsSchema.safeParse({ path: '' }).success).toBe(false);
});
});

View file

@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
getMessageResponseSchema,
listMessagesQuerySchema,
listMessagesResponseSchema,
} from '../rest/message';
describe('listMessagesQuerySchema', () => {
it('accepts an empty query', () => {
expect(listMessagesQuerySchema.parse({})).toEqual({});
});
it('accepts before_id + page_size + role', () => {
const parsed = listMessagesQuerySchema.parse({
before_id: 'msg_abc',
page_size: 25,
role: 'assistant',
});
expect(parsed.before_id).toBe('msg_abc');
expect(parsed.page_size).toBe(25);
expect(parsed.role).toBe('assistant');
});
it('rejects before_id + after_id together', () => {
expect(
listMessagesQuerySchema.safeParse({ before_id: 'a', after_id: 'b' }).success,
).toBe(false);
});
it('rejects page_size > 100 (SCHEMAS §1.3 / REST §1.6)', () => {
expect(listMessagesQuerySchema.safeParse({ page_size: 101 }).success).toBe(false);
});
it('rejects unknown role values', () => {
expect(listMessagesQuerySchema.safeParse({ role: 'critter' }).success).toBe(false);
});
});
describe('listMessagesResponseSchema', () => {
it('parses an empty page', () => {
expect(listMessagesResponseSchema.parse({ items: [], has_more: false })).toEqual({
items: [],
has_more: false,
});
});
it('parses a page with one message', () => {
const parsed = listMessagesResponseSchema.parse({
items: [
{
id: 'msg_01',
session_id: 'sess_1',
role: 'user',
content: [{ type: 'text', text: 'hi' }],
created_at: '2026-06-04T10:30:00.000Z',
},
],
has_more: true,
});
expect(parsed.items).toHaveLength(1);
expect(parsed.has_more).toBe(true);
});
});
describe('getMessageResponseSchema', () => {
it('parses a Message with optional fields', () => {
const parsed = getMessageResponseSchema.parse({
id: 'msg_01',
session_id: 'sess_1',
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
created_at: '2026-06-04T10:30:00.000Z',
prompt_id: 'prompt_01',
});
expect(parsed.prompt_id).toBe('prompt_01');
});
});

View file

@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { metaResponseSchema, type MetaResponse } from '../rest/meta';
describe('metaResponseSchema', () => {
const sample = {
daemon_version: '0.1.0',
capabilities: {
websocket: true,
file_upload: true,
fs_query: true,
mcp: true,
background_tasks: true,
},
daemon_id: '01HXYZABCDEFGHJKMNPQRSTVWX',
started_at: '2026-06-04T10:30:00.000Z',
};
it('round-trips a well-formed payload', () => {
const parsed: MetaResponse = metaResponseSchema.parse(sample);
expect(parsed.daemon_version).toBe('0.1.0');
expect(parsed.capabilities.websocket).toBe(true);
expect(parsed.daemon_id).toBe('01HXYZABCDEFGHJKMNPQRSTVWX');
expect(parsed.started_at).toBe('2026-06-04T10:30:00.000Z');
});
it('normalizes started_at to UTC Z with millisecond precision', () => {
const offsetForm = {
...sample,
started_at: '2026-06-04T18:30:00+08:00',
};
const parsed = metaResponseSchema.parse(offsetForm);
expect(parsed.started_at).toBe('2026-06-04T10:30:00.000Z');
});
it('rejects missing daemon_version', () => {
const { daemon_version: _omit, ...rest } = sample;
expect(metaResponseSchema.safeParse(rest).success).toBe(false);
});
it('rejects missing capabilities', () => {
const { capabilities: _omit, ...rest } = sample;
expect(metaResponseSchema.safeParse(rest).success).toBe(false);
});
it('rejects missing daemon_id', () => {
const { daemon_id: _omit, ...rest } = sample;
expect(metaResponseSchema.safeParse(rest).success).toBe(false);
});
it('rejects missing started_at', () => {
const { started_at: _omit, ...rest } = sample;
expect(metaResponseSchema.safeParse(rest).success).toBe(false);
});
it('rejects a capability set with the wrong boolean literal', () => {
const bad = {
...sample,
capabilities: { ...sample.capabilities, websocket: false },
};
expect(metaResponseSchema.safeParse(bad).success).toBe(false);
});
it('rejects an empty daemon_version string', () => {
const bad = { ...sample, daemon_version: '' };
expect(metaResponseSchema.safeParse(bad).success).toBe(false);
});
it('rejects a malformed started_at (no timezone marker)', () => {
const bad = { ...sample, started_at: '2026-06-04T10:30:00' };
expect(metaResponseSchema.safeParse(bad).success).toBe(false);
});
});

View file

@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest';
import {
promptAbortResponseSchema,
promptListResponseSchema,
promptSubmissionSchema,
promptSubmitResultSchema,
promptSteerRequestSchema,
promptSteerResultSchema,
} from '../rest/prompt';
describe('promptSubmissionSchema', () => {
it('accepts a minimal text-only submission with no controls', () => {
const parsed = promptSubmissionSchema.parse({
content: [{ type: 'text', text: 'hi' }],
});
expect(parsed.content[0]?.type).toBe('text');
expect(parsed.model).toBeUndefined();
expect(parsed.thinking).toBeUndefined();
expect(parsed.permission_mode).toBeUndefined();
expect(parsed.plan_mode).toBeUndefined();
});
it('accepts metadata', () => {
const parsed = promptSubmissionSchema.parse({
content: [{ type: 'text', text: 'hi' }],
metadata: { source: 'cli' },
});
expect(parsed.metadata).toEqual({ source: 'cli' });
});
it('accepts image + text mixed content', () => {
const parsed = promptSubmissionSchema.parse({
content: [
{ type: 'text', text: 'see attached' },
{ type: 'image', source: { kind: 'url', url: 'https://a.png' } },
],
});
expect(parsed.content).toHaveLength(2);
});
it('accepts a partial per-turn override (model only)', () => {
const parsed = promptSubmissionSchema.parse({
content: [{ type: 'text', text: 'hi' }],
model: 'kimi-code/k2',
});
expect(parsed.model).toBe('kimi-code/k2');
expect(parsed.thinking).toBeUndefined();
});
it('accepts the full bundle of controls when supplied', () => {
const parsed = promptSubmissionSchema.parse({
content: [{ type: 'text', text: 'hi' }],
model: 'kimi-code/k2',
thinking: 'off',
permission_mode: 'manual',
plan_mode: false,
});
expect(parsed.model).toBe('kimi-code/k2');
expect(parsed.thinking).toBe('off');
expect(parsed.permission_mode).toBe('manual');
expect(parsed.plan_mode).toBe(false);
});
it('rejects empty content array', () => {
expect(
promptSubmissionSchema.safeParse({
content: [],
}).success,
).toBe(false);
});
it('rejects missing content', () => {
expect(promptSubmissionSchema.safeParse({} as unknown).success).toBe(false);
});
it('rejects unknown thinking level', () => {
expect(
promptSubmissionSchema.safeParse({
content: [{ type: 'text', text: 'hi' }],
thinking: 'mega' as unknown,
}).success,
).toBe(false);
});
it('rejects unknown permission_mode', () => {
expect(
promptSubmissionSchema.safeParse({
content: [{ type: 'text', text: 'hi' }],
permission_mode: 'unrestricted' as unknown,
}).success,
).toBe(false);
});
it('rejects empty model string', () => {
expect(
promptSubmissionSchema.safeParse({
content: [{ type: 'text', text: 'hi' }],
model: '',
}).success,
).toBe(false);
});
});
describe('promptSubmitResultSchema', () => {
it('parses a running prompt result shape', () => {
const parsed = promptSubmitResultSchema.parse({
prompt_id: 'prompt_01HZ',
user_message_id: 'msg_sess_01_000000',
status: 'running',
content: [{ type: 'text', text: 'hi' }],
created_at: '2026-06-09T00:00:00.000Z',
});
expect(parsed.prompt_id).toBe('prompt_01HZ');
expect(parsed.status).toBe('running');
});
it('rejects empty prompt_id', () => {
expect(
promptSubmitResultSchema.safeParse({ prompt_id: '', user_message_id: 'msg' })
.success,
).toBe(false);
});
});
describe('promptListResponseSchema', () => {
it('parses active and queued prompts', () => {
const parsed = promptListResponseSchema.parse({
active: {
prompt_id: 'prompt_active',
user_message_id: 'msg_active',
status: 'running',
content: [{ type: 'text', text: 'active' }],
created_at: '2026-06-09T00:00:00.000Z',
},
queued: [
{
prompt_id: 'prompt_queued',
user_message_id: 'msg_queued',
status: 'queued',
content: [{ type: 'text', text: 'queued' }],
created_at: '2026-06-09T00:00:01.000Z',
},
],
});
expect(parsed.active?.status).toBe('running');
expect(parsed.queued[0]?.status).toBe('queued');
});
});
describe('promptSteerRequestSchema', () => {
it('requires at least one prompt id', () => {
expect(promptSteerRequestSchema.parse({ prompt_ids: ['prompt_a'] }).prompt_ids)
.toEqual(['prompt_a']);
expect(promptSteerRequestSchema.safeParse({ prompt_ids: [] }).success).toBe(false);
});
});
describe('promptSteerResultSchema', () => {
it('parses steered prompt ids', () => {
const parsed = promptSteerResultSchema.parse({
steered: true,
prompt_ids: ['prompt_a', 'prompt_b'],
});
expect(parsed.steered).toBe(true);
expect(parsed.prompt_ids).toEqual(['prompt_a', 'prompt_b']);
});
});
describe('promptAbortResponseSchema', () => {
it('parses { aborted: true } success shape', () => {
const parsed = promptAbortResponseSchema.parse({ aborted: true, at_seq: 7 });
expect(parsed.aborted).toBe(true);
expect(parsed.at_seq).toBe(7);
});
it('parses { aborted: false } idempotent shape (used with envelope.code=40903)', () => {
const parsed = promptAbortResponseSchema.parse({ aborted: false });
expect(parsed.aborted).toBe(false);
});
});

View file

@ -0,0 +1,422 @@
import { describe, expect, it } from 'vitest';
import {
compactSessionRequestSchema,
compactSessionResponseSchema,
createSessionChildRequestSchema,
createSessionChildResponseSchema,
createSessionRequestSchema,
deleteSessionResponseSchema,
forkSessionRequestSchema,
forkSessionResponseSchema,
getSessionProfileResponseSchema,
listSessionChildrenResponseSchema,
listSessionsQuerySchema,
sessionStatusResponseSchema,
updateSessionProfileRequestSchema,
updateSessionRequestSchema,
undoSessionRequestSchema,
undoSessionResponseSchema,
} from '../rest/session';
describe('createSessionRequestSchema', () => {
it('accepts a minimal POST body with metadata.cwd', () => {
const parsed = createSessionRequestSchema.parse({ metadata: { cwd: '/tmp/foo' } });
expect(parsed.metadata?.cwd).toBe('/tmp/foo');
});
it('accepts a POST body with only workspace_id (route layer resolves cwd)', () => {
const parsed = createSessionRequestSchema.parse({
workspace_id: 'wd_kimi_0123456789ab',
});
expect(parsed.workspace_id).toBe('wd_kimi_0123456789ab');
expect(parsed.metadata).toBeUndefined();
});
it('rejects metadata without cwd', () => {
expect(
createSessionRequestSchema.safeParse({ metadata: {} } as unknown).success,
).toBe(false);
});
it('rejects extra unknown agent_config keys via partial schema (zod is permissive but the partial holds known keys)', () => {
const parsed = createSessionRequestSchema.parse({
metadata: { cwd: '/tmp/foo' },
agent_config: { model: 'm', unknown_key: 'x' } as unknown as { model: string },
});
expect(parsed.agent_config?.model).toBe('m');
expect((parsed.agent_config as Record<string, unknown>)['unknown_key']).toBeUndefined();
});
});
describe('listSessionsQuerySchema', () => {
it('accepts an empty query (defaults applied at handler layer)', () => {
expect(listSessionsQuerySchema.parse({})).toEqual({});
});
it('accepts before_id + page_size', () => {
const parsed = listSessionsQuerySchema.parse({ before_id: 'sess_abc', page_size: 20 });
expect(parsed.before_id).toBe('sess_abc');
expect(parsed.page_size).toBe(20);
});
it('rejects before_id + after_id together (REST §1.6 mutual exclusivity)', () => {
const result = listSessionsQuerySchema.safeParse({
before_id: 'a',
after_id: 'b',
});
expect(result.success).toBe(false);
});
it('rejects page_size > 100', () => {
expect(listSessionsQuerySchema.safeParse({ page_size: 101 }).success).toBe(false);
});
it('accepts a status filter', () => {
expect(listSessionsQuerySchema.parse({ status: 'idle' })).toEqual({ status: 'idle' });
});
it('rejects an unknown status value', () => {
expect(listSessionsQuerySchema.safeParse({ status: 'frozen' }).success).toBe(false);
});
});
describe('getSessionProfileResponseSchema', () => {
it('accepts a Session payload', () => {
const parsed = getSessionProfileResponseSchema.parse({
id: 'sess_abc',
workspace_id: 'wd_kimi_0123456789ab',
title: 'Profile',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
status: 'idle',
metadata: { cwd: '/tmp/foo' },
agent_config: { model: '' },
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
total_cost_usd: 0,
context_tokens: 0,
context_limit: 0,
turn_count: 0,
},
permission_rules: [],
message_count: 0,
last_seq: 0,
});
expect(parsed.id).toBe('sess_abc');
});
});
describe('updateSessionProfileRequestSchema', () => {
it('accepts a metadata patch (without cwd)', () => {
expect(
updateSessionProfileRequestSchema.parse({ metadata: { custom_field: 'x' } }),
).toEqual({ metadata: { custom_field: 'x' } });
});
it('accepts an empty POST body (no-op)', () => {
expect(updateSessionProfileRequestSchema.parse({})).toEqual({});
});
it('accepts agent_config.model', () => {
const parsed = updateSessionProfileRequestSchema.parse({
agent_config: { model: 'moonshot-v1-128k' },
});
expect(parsed.agent_config?.model).toBe('moonshot-v1-128k');
});
it('accepts agent_config runtime controls (thinking + permission_mode + plan_mode)', () => {
const parsed = updateSessionProfileRequestSchema.parse({
agent_config: {
thinking: 'medium',
permission_mode: 'auto',
plan_mode: false,
},
});
expect(parsed.agent_config).toEqual({
thinking: 'medium',
permission_mode: 'auto',
plan_mode: false,
});
});
});
describe('updateSessionRequestSchema (legacy alias)', () => {
it('round-trips through the same schema as updateSessionProfileRequestSchema', () => {
expect(updateSessionRequestSchema.parse({ metadata: { custom_field: 'x' } })).toEqual(
updateSessionProfileRequestSchema.parse({ metadata: { custom_field: 'x' } }),
);
});
});
describe('forkSessionRequestSchema', () => {
it('accepts an empty POST body', () => {
expect(forkSessionRequestSchema.parse({})).toEqual({});
});
it('accepts title and arbitrary metadata without requiring cwd', () => {
const parsed = forkSessionRequestSchema.parse({
title: 'Fork: source',
metadata: { origin: 'web', depth: 1 },
});
expect(parsed).toEqual({
title: 'Fork: source',
metadata: { origin: 'web', depth: 1 },
});
});
it('rejects non-object metadata', () => {
expect(forkSessionRequestSchema.safeParse({ metadata: 'x' }).success).toBe(false);
});
});
describe('forkSessionResponseSchema', () => {
it('accepts a Session payload', () => {
const parsed = forkSessionResponseSchema.parse({
id: 'sess_fork',
workspace_id: 'wd_kimi_0123456789ab',
title: 'Fork: source',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
status: 'idle',
metadata: { cwd: '/tmp/foo', origin: 'web' },
agent_config: { model: '' },
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
total_cost_usd: 0,
context_tokens: 0,
context_limit: 0,
turn_count: 0,
},
permission_rules: [],
message_count: 0,
last_seq: 0,
});
expect(parsed.id).toBe('sess_fork');
});
});
describe('createSessionChildRequestSchema', () => {
it('accepts title and arbitrary metadata without requiring cwd', () => {
const parsed = createSessionChildRequestSchema.parse({
title: 'Side question',
metadata: { origin: 'web', topic: 'btw' },
});
expect(parsed).toEqual({
title: 'Side question',
metadata: { origin: 'web', topic: 'btw' },
});
});
it('rejects non-object metadata', () => {
expect(createSessionChildRequestSchema.safeParse({ metadata: 'x' }).success).toBe(false);
});
});
describe('createSessionChildResponseSchema', () => {
it('accepts a Session payload', () => {
const parsed = createSessionChildResponseSchema.parse({
id: 'sess_child',
workspace_id: 'wd_kimi_0123456789ab',
title: 'Child: source',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
status: 'idle',
metadata: { cwd: '/tmp/foo', parent_session_id: 'sess_parent' },
agent_config: { model: '' },
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
total_cost_usd: 0,
context_tokens: 0,
context_limit: 0,
turn_count: 0,
},
permission_rules: [],
message_count: 0,
last_seq: 0,
});
expect(parsed.metadata['parent_session_id']).toBe('sess_parent');
});
});
describe('listSessionChildrenResponseSchema', () => {
it('accepts a paged list of child sessions', () => {
const parsed = listSessionChildrenResponseSchema.parse({
items: [
{
id: 'sess_child',
workspace_id: 'wd_kimi_0123456789ab',
title: 'Child: source',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
status: 'idle',
metadata: { cwd: '/tmp/foo', parent_session_id: 'sess_parent' },
agent_config: { model: '' },
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
total_cost_usd: 0,
context_tokens: 0,
context_limit: 0,
turn_count: 0,
},
permission_rules: [],
message_count: 0,
last_seq: 0,
},
],
has_more: false,
});
expect(parsed.items).toHaveLength(1);
});
});
describe('sessionStatusResponseSchema', () => {
it('accepts a full valid shape', () => {
const parsed = sessionStatusResponseSchema.parse({
model: 'moonshot-v1-128k',
thinking_level: 'on',
permission: 'ask',
plan_mode: true,
context_tokens: 1024,
max_context_tokens: 128000,
context_usage: 0.008,
});
expect(parsed.model).toBe('moonshot-v1-128k');
expect(parsed.plan_mode).toBe(true);
expect(parsed.context_usage).toBe(0.008);
});
it('accepts minimal shape without model', () => {
const parsed = sessionStatusResponseSchema.parse({
thinking_level: 'off',
permission: 'auto',
plan_mode: false,
context_tokens: 0,
max_context_tokens: 0,
context_usage: 0,
});
expect(parsed.model).toBeUndefined();
});
it('rejects negative context_tokens', () => {
expect(
sessionStatusResponseSchema.safeParse({
thinking_level: 'off',
permission: 'auto',
plan_mode: false,
context_tokens: -1,
max_context_tokens: 0,
context_usage: 0,
}).success,
).toBe(false);
});
it('rejects context_usage > 1', () => {
expect(
sessionStatusResponseSchema.safeParse({
thinking_level: 'off',
permission: 'auto',
plan_mode: false,
context_tokens: 10,
max_context_tokens: 5,
context_usage: 2,
}).success,
).toBe(false);
});
});
describe('compactSessionRequestSchema', () => {
it('accepts an empty body', () => {
expect(compactSessionRequestSchema.parse({})).toEqual({});
});
it('treats a missing body as empty', () => {
expect(compactSessionRequestSchema.parse(undefined)).toEqual({});
});
it('accepts an optional instruction string', () => {
expect(compactSessionRequestSchema.parse({ instruction: ' focus on decisions ' })).toEqual({
instruction: ' focus on decisions ',
});
});
it('rejects a non-string instruction', () => {
expect(compactSessionRequestSchema.safeParse({ instruction: 123 }).success).toBe(false);
});
});
describe('compactSessionResponseSchema', () => {
it('accepts the empty success payload', () => {
expect(compactSessionResponseSchema.parse({})).toEqual({});
});
});
describe('undoSessionRequestSchema', () => {
it('defaults a missing body to undoing one prompt', () => {
expect(undoSessionRequestSchema.parse(undefined)).toEqual({ count: 1 });
});
it('accepts a positive count and bounded page size', () => {
expect(undoSessionRequestSchema.parse({ count: 2, page_size: 25 })).toEqual({
count: 2,
page_size: 25,
});
});
it('rejects zero count and oversized page size', () => {
expect(undoSessionRequestSchema.safeParse({ count: 0 }).success).toBe(false);
expect(undoSessionRequestSchema.safeParse({ page_size: 101 }).success).toBe(false);
});
});
describe('undoSessionResponseSchema', () => {
it('accepts messages plus the refreshed session status', () => {
const parsed = undoSessionResponseSchema.parse({
messages: {
items: [
{
id: 'msg_sess_abc_000000',
session_id: 'sess_abc',
role: 'user',
content: [{ type: 'text', text: 'kept' }],
created_at: '2026-01-01T00:00:00.000Z',
},
],
has_more: false,
},
status: {
model: 'kimi-k2',
thinking_level: 'auto',
permission: 'manual',
plan_mode: false,
context_tokens: 10,
max_context_tokens: 100,
context_usage: 0.1,
},
});
expect(parsed.messages.items).toHaveLength(1);
expect(parsed.status.context_tokens).toBe(10);
});
});
describe('deleteSessionResponseSchema', () => {
it('accepts the canonical { deleted: true } shape', () => {
expect(deleteSessionResponseSchema.parse({ deleted: true })).toEqual({ deleted: true });
});
it('rejects { deleted: false }', () => {
expect(deleteSessionResponseSchema.safeParse({ deleted: false }).success).toBe(false);
});
});

View file

@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import {
cancelTaskResultSchema,
getTaskQuerySchema,
getTaskResponseSchema,
listTasksQuerySchema,
listTasksResponseSchema,
taskAlreadyFinishedDataSchema,
} from '../rest/task';
describe('listTasksQuerySchema', () => {
it('accepts empty query', () => {
expect(listTasksQuerySchema.parse({})).toEqual({});
});
it('accepts status filter', () => {
expect(listTasksQuerySchema.parse({ status: 'running' })).toEqual({
status: 'running',
});
});
it('rejects unknown status', () => {
expect(listTasksQuerySchema.safeParse({ status: 'pending' }).success).toBe(false);
});
});
describe('listTasksResponseSchema', () => {
it('round-trips empty items[]', () => {
expect(listTasksResponseSchema.parse({ items: [] })).toEqual({ items: [] });
});
});
describe('getTaskQuerySchema', () => {
it('accepts empty query', () => {
expect(getTaskQuerySchema.parse({})).toEqual({});
});
it('coerces with_output + output_bytes from strings (HTTP query)', () => {
const parsed = getTaskQuerySchema.parse({ with_output: 'true', output_bytes: '512' });
expect(parsed.with_output).toBe(true);
expect(parsed.output_bytes).toBe(512);
});
});
describe('getTaskResponseSchema', () => {
it('parses a minimal task shape', () => {
const t = {
id: 'task_01',
session_id: 'sess_01',
kind: 'subagent' as const,
description: 'spin up x',
status: 'running' as const,
created_at: '2026-06-04T10:00:00.000Z',
};
expect(getTaskResponseSchema.parse(t).kind).toBe('subagent');
});
});
describe('cancelTaskResultSchema', () => {
it('requires cancelled: true literal', () => {
expect(cancelTaskResultSchema.parse({ cancelled: true })).toEqual({ cancelled: true });
expect(cancelTaskResultSchema.safeParse({ cancelled: false }).success).toBe(false);
});
});
describe('taskAlreadyFinishedDataSchema (40904 envelope data)', () => {
it('requires cancelled: false literal', () => {
expect(taskAlreadyFinishedDataSchema.parse({ cancelled: false })).toEqual({
cancelled: false,
});
expect(taskAlreadyFinishedDataSchema.safeParse({ cancelled: true }).success).toBe(
false,
);
});
});

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import {
listMcpServersResponseSchema,
listToolsQuerySchema,
listToolsResponseSchema,
restartMcpServerResultSchema,
} from '../rest/tool';
describe('listToolsQuerySchema', () => {
it('accepts an empty query (global tool list)', () => {
expect(listToolsQuerySchema.parse({})).toEqual({});
});
it('accepts session_id (session-effective tool list)', () => {
expect(listToolsQuerySchema.parse({ session_id: 'sess_01' })).toEqual({
session_id: 'sess_01',
});
});
it('rejects an empty session_id string', () => {
expect(listToolsQuerySchema.safeParse({ session_id: '' }).success).toBe(false);
});
});
describe('listToolsResponseSchema', () => {
it('round-trips a list of tools', () => {
const payload = {
tools: [
{
name: 'Bash',
description: 'Execute shell',
input_schema: null,
source: 'builtin' as const,
},
],
};
expect(listToolsResponseSchema.parse(payload).tools).toHaveLength(1);
});
it('accepts an empty tools array', () => {
expect(listToolsResponseSchema.parse({ tools: [] })).toEqual({ tools: [] });
});
});
describe('listMcpServersResponseSchema', () => {
it('round-trips a list of servers', () => {
const payload = {
servers: [
{
id: 'lark',
name: 'lark',
transport: 'stdio' as const,
status: 'connected' as const,
tool_count: 3,
},
],
};
expect(listMcpServersResponseSchema.parse(payload).servers[0]!.id).toBe('lark');
});
});
describe('restartMcpServerResultSchema', () => {
it('requires restarting: true literal', () => {
expect(restartMcpServerResultSchema.parse({ restarting: true })).toEqual({
restarting: true,
});
expect(restartMcpServerResultSchema.safeParse({ restarting: false }).success).toBe(
false,
);
});
});

View file

@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest';
import {
createWorkspaceRequestSchema,
deleteWorkspaceResponseSchema,
listWorkspacesResponseSchema,
updateWorkspaceRequestSchema,
workspaceIdParamSchema,
} from '../rest/workspace';
import { workspaceIdSchema, workspaceSchema, type Workspace } from '../workspace';
const sampleWorkspace: Workspace = {
id: 'wd_kimi-code_0123456789ab',
root: '/Users/foo/code/kimi-code',
name: 'kimi-code',
is_git_repo: true,
branch: 'main',
created_at: '2026-06-08T09:00:00.000Z',
last_opened_at: '2026-06-08T09:30:00.000Z',
session_count: 3,
};
describe('workspaceIdSchema', () => {
it('accepts a wd_<slug>_<hash12> string', () => {
expect(workspaceIdSchema.parse('wd_kimi_0123456789ab')).toBe('wd_kimi_0123456789ab');
});
it('accepts dots, dashes, underscores in slug', () => {
expect(workspaceIdSchema.parse('wd_kimi-code.v2_0123456789ab')).toBe(
'wd_kimi-code.v2_0123456789ab',
);
});
it('rejects missing wd_ prefix', () => {
expect(workspaceIdSchema.safeParse('kimi_0123456789ab').success).toBe(false);
});
it('rejects non-hex tail', () => {
expect(workspaceIdSchema.safeParse('wd_kimi_xyzxyzxyzxyz').success).toBe(false);
});
it('rejects truncated hash', () => {
expect(workspaceIdSchema.safeParse('wd_kimi_0123456789').success).toBe(false);
});
});
describe('workspaceSchema', () => {
it('round-trips a fully populated Workspace', () => {
expect(workspaceSchema.parse(sampleWorkspace)).toEqual(sampleWorkspace);
});
it('accepts branch=null (detached HEAD / no git)', () => {
const detached = { ...sampleWorkspace, is_git_repo: true, branch: null };
expect(workspaceSchema.parse(detached).branch).toBeNull();
});
it('rejects name longer than 100 chars', () => {
const tooLong = { ...sampleWorkspace, name: 'x'.repeat(101) };
expect(workspaceSchema.safeParse(tooLong).success).toBe(false);
});
});
describe('createWorkspaceRequestSchema (POST /api/v1/workspaces)', () => {
it('accepts a root-only body', () => {
expect(createWorkspaceRequestSchema.parse({ root: '/Users/foo/code' })).toEqual({
root: '/Users/foo/code',
});
});
it('accepts root + name override', () => {
const parsed = createWorkspaceRequestSchema.parse({
root: '/Users/foo/code',
name: 'Frontend Project',
});
expect(parsed.name).toBe('Frontend Project');
});
it('rejects empty root', () => {
expect(createWorkspaceRequestSchema.safeParse({ root: '' }).success).toBe(false);
});
it('rejects missing root', () => {
expect(createWorkspaceRequestSchema.safeParse({} as unknown).success).toBe(false);
});
it('rejects empty name', () => {
expect(
createWorkspaceRequestSchema.safeParse({ root: '/Users/foo/code', name: '' }).success,
).toBe(false);
});
it('rejects name longer than 100 chars', () => {
expect(
createWorkspaceRequestSchema.safeParse({
root: '/Users/foo/code',
name: 'x'.repeat(101),
}).success,
).toBe(false);
});
});
describe('updateWorkspaceRequestSchema (PATCH /api/v1/workspaces/{id})', () => {
it('accepts a name patch', () => {
expect(updateWorkspaceRequestSchema.parse({ name: 'Renamed' })).toEqual({
name: 'Renamed',
});
});
it('rejects empty body (name is required for the patch)', () => {
expect(updateWorkspaceRequestSchema.safeParse({} as unknown).success).toBe(false);
});
});
describe('workspaceIdParamSchema', () => {
it('accepts a wd_-shaped workspace_id', () => {
expect(
workspaceIdParamSchema.parse({ workspace_id: 'wd_kimi_0123456789ab' }).workspace_id,
).toBe('wd_kimi_0123456789ab');
});
it('rejects a non-wd-shaped id', () => {
expect(
workspaceIdParamSchema.safeParse({ workspace_id: 'sess_abc' }).success,
).toBe(false);
});
});
describe('listWorkspacesResponseSchema', () => {
it('accepts an empty list', () => {
expect(listWorkspacesResponseSchema.parse({ items: [] })).toEqual({ items: [] });
});
it('accepts a non-empty list', () => {
expect(
listWorkspacesResponseSchema.parse({ items: [sampleWorkspace] }).items[0]?.id,
).toBe(sampleWorkspace.id);
});
});
describe('deleteWorkspaceResponseSchema', () => {
it('accepts {deleted:true}', () => {
expect(deleteWorkspaceResponseSchema.parse({ deleted: true })).toEqual({
deleted: true,
});
});
it('rejects {deleted:false}', () => {
expect(deleteWorkspaceResponseSchema.safeParse({ deleted: false }).success).toBe(false);
});
});

View file

@ -0,0 +1,218 @@
import { describe, expect, it } from 'vitest';
import {
emptySessionUsage,
permissionRuleSchema,
sessionCreateSchema,
sessionSchema,
sessionStatusSchema,
sessionUpdateSchema,
sessionUsageSchema,
type Session,
} from '../session';
describe('sessionStatusSchema', () => {
it.each(['idle', 'running', 'awaiting_approval', 'awaiting_question', 'aborted'] as const)(
'accepts %s',
(status) => {
expect(sessionStatusSchema.parse(status)).toBe(status);
},
);
it('rejects unknown status', () => {
expect(sessionStatusSchema.safeParse('chilling').success).toBe(false);
});
});
describe('sessionUsageSchema + emptySessionUsage', () => {
it('emptySessionUsage is parseable as zero usage', () => {
const parsed = sessionUsageSchema.parse(emptySessionUsage());
expect(parsed.input_tokens).toBe(0);
expect(parsed.context_limit).toBe(0);
expect(parsed.total_cost_usd).toBe(0);
});
it('rejects negative token counts', () => {
const bad = { ...emptySessionUsage(), input_tokens: -1 };
expect(sessionUsageSchema.safeParse(bad).success).toBe(false);
});
});
describe('permissionRuleSchema', () => {
const sample = {
id: 'rule_01',
tool_name: 'Bash',
matcher: { kind: 'always' as const },
decision: 'approved' as const,
created_at: '2026-06-04T10:30:00.000Z',
created_by: 'user' as const,
};
it('parses an always-approve rule', () => {
expect(permissionRuleSchema.parse(sample).tool_name).toBe('Bash');
});
it('rejects decision != approved (first-version invariant)', () => {
const bad = { ...sample, decision: 'rejected' };
expect(permissionRuleSchema.safeParse(bad).success).toBe(false);
});
});
describe('sessionSchema', () => {
const fullSession: Session = {
id: '01HXYZABCDEFGHJKMNPQRSTVWX',
workspace_id: 'wd_kimi_0123456789ab',
title: 'Test session',
created_at: '2026-06-04T10:30:00.000Z',
updated_at: '2026-06-04T10:35:00.000Z',
status: 'idle',
metadata: { cwd: '/tmp/test' },
agent_config: { model: 'moonshot-v1-128k' },
usage: emptySessionUsage(),
permission_rules: [],
message_count: 0,
last_seq: 0,
};
it('round-trips a full Session', () => {
expect(sessionSchema.parse(fullSession)).toEqual(fullSession);
});
it('accepts arbitrary metadata extensions via catchall', () => {
const withExtras = {
...fullSession,
metadata: { cwd: '/tmp/test', custom_flag: 'on', nested: { a: 1 } },
};
expect(sessionSchema.parse(withExtras).metadata['cwd']).toBe('/tmp/test');
});
it('rejects when metadata.cwd is missing', () => {
const bad = { ...fullSession, metadata: {} };
expect(sessionSchema.safeParse(bad).success).toBe(false);
});
it('rejects when workspace_id is missing', () => {
const { workspace_id: _drop, ...bad } = fullSession;
expect(sessionSchema.safeParse(bad).success).toBe(false);
});
it('rejects malformed workspace_id (not wd_ shape)', () => {
const bad = { ...fullSession, workspace_id: 'workspace_123' };
expect(sessionSchema.safeParse(bad).success).toBe(false);
});
it('rejects malformed created_at (no timezone)', () => {
const bad = { ...fullSession, created_at: '2026-06-04T10:30:00' };
expect(sessionSchema.safeParse(bad).success).toBe(false);
});
it('normalizes timestamp offsets to UTC Z', () => {
const offsetForm = { ...fullSession, created_at: '2026-06-04T18:30:00+08:00' };
const parsed = sessionSchema.parse(offsetForm);
expect(parsed.created_at).toBe('2026-06-04T10:30:00.000Z');
});
});
describe('sessionCreateSchema', () => {
it('parses a minimal create with metadata.cwd only', () => {
expect(
sessionCreateSchema.parse({
metadata: { cwd: '/tmp/test' },
}),
).toEqual({ metadata: { cwd: '/tmp/test' } });
});
it('parses a create with workspace_id only', () => {
expect(
sessionCreateSchema.parse({
workspace_id: 'wd_kimi_0123456789ab',
}),
).toEqual({ workspace_id: 'wd_kimi_0123456789ab' });
});
it('parses a create with BOTH workspace_id and metadata.cwd (route layer enforces agreement)', () => {
const parsed = sessionCreateSchema.parse({
workspace_id: 'wd_kimi_0123456789ab',
metadata: { cwd: '/tmp/test' },
});
expect(parsed.workspace_id).toBe('wd_kimi_0123456789ab');
expect(parsed.metadata?.cwd).toBe('/tmp/test');
});
it('parses a full create with title + agent_config', () => {
const parsed = sessionCreateSchema.parse({
title: 'My session',
metadata: { cwd: '/tmp/test' },
agent_config: { model: 'moonshot-v1-128k' },
});
expect(parsed.title).toBe('My session');
expect(parsed.agent_config?.model).toBe('moonshot-v1-128k');
});
it('accepts an entirely empty body (route layer rejects when neither workspace_id nor metadata.cwd is present)', () => {
expect(sessionCreateSchema.safeParse({}).success).toBe(true);
});
it('rejects malformed workspace_id', () => {
expect(
sessionCreateSchema.safeParse({ workspace_id: 'not-a-wd-key' }).success,
).toBe(false);
});
it('rejects metadata without cwd', () => {
expect(sessionCreateSchema.safeParse({ metadata: {} }).success).toBe(false);
});
});
describe('sessionUpdateSchema', () => {
it('parses a title-only update', () => {
expect(sessionUpdateSchema.parse({ title: 'Renamed' })).toEqual({ title: 'Renamed' });
});
it('parses a permission_rules full-replacement (including empty array = clear)', () => {
expect(sessionUpdateSchema.parse({ permission_rules: [] })).toEqual({
permission_rules: [],
});
});
it('parses a partial agent_config patch', () => {
expect(
sessionUpdateSchema.parse({ agent_config: { model: 'moonshot-v1-256k' } }),
).toEqual({ agent_config: { model: 'moonshot-v1-256k' } });
});
it('parses a runtime-controls patch (thinking + permission_mode + plan_mode)', () => {
const parsed = sessionUpdateSchema.parse({
agent_config: {
thinking: 'high',
permission_mode: 'yolo',
plan_mode: true,
},
});
expect(parsed.agent_config).toEqual({
thinking: 'high',
permission_mode: 'yolo',
plan_mode: true,
});
});
it('rejects an unknown thinking level in agent_config', () => {
expect(
sessionUpdateSchema.safeParse({
agent_config: { thinking: 'mega' as unknown },
}).success,
).toBe(false);
});
it('rejects an unknown permission_mode in agent_config', () => {
expect(
sessionUpdateSchema.safeParse({
agent_config: { permission_mode: 'unrestricted' as unknown },
}).success,
).toBe(false);
});
it('parses an empty update (no-op)', () => {
expect(sessionUpdateSchema.parse({})).toEqual({});
});
});

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import {
backgroundTaskKindSchema,
backgroundTaskSchema,
backgroundTaskStatusSchema,
type BackgroundTask,
} from '../task';
describe('backgroundTaskKindSchema', () => {
it.each(['subagent', 'bash', 'tool'] as const)('accepts %s', (k) => {
expect(backgroundTaskKindSchema.parse(k)).toBe(k);
});
it("rejects agent-core's 'process' / 'agent' / 'question' literals", () => {
expect(backgroundTaskKindSchema.safeParse('process').success).toBe(false);
expect(backgroundTaskKindSchema.safeParse('agent').success).toBe(false);
expect(backgroundTaskKindSchema.safeParse('question').success).toBe(false);
});
});
describe('backgroundTaskStatusSchema', () => {
it.each(['running', 'completed', 'failed', 'cancelled'] as const)(
'accepts %s',
(s) => {
expect(backgroundTaskStatusSchema.parse(s)).toBe(s);
},
);
it("rejects agent-core's 'timed_out' / 'killed' / 'lost' literals (adapter maps)", () => {
expect(backgroundTaskStatusSchema.safeParse('timed_out').success).toBe(false);
expect(backgroundTaskStatusSchema.safeParse('killed').success).toBe(false);
expect(backgroundTaskStatusSchema.safeParse('lost').success).toBe(false);
});
});
describe('backgroundTaskSchema', () => {
const full: BackgroundTask = {
id: 'task_01HXYZ',
session_id: 'sess_01HZZZ',
kind: 'bash',
description: 'pnpm install',
status: 'running',
created_at: '2026-06-04T10:00:00.000Z',
started_at: '2026-06-04T10:00:00.000Z',
};
it('round-trips a running task', () => {
expect(backgroundTaskSchema.parse(full)).toEqual(full);
});
it('round-trips a completed task with completed_at + output fields', () => {
const completed: BackgroundTask = {
...full,
status: 'completed',
completed_at: '2026-06-04T10:01:00.000Z',
output_preview: 'first line\nsecond line',
output_bytes: 4096,
};
expect(backgroundTaskSchema.parse(completed).output_bytes).toBe(4096);
});
it('rejects negative output_bytes', () => {
const bad = { ...full, output_bytes: -1 };
expect(backgroundTaskSchema.safeParse(bad).success).toBe(false);
});
it('rejects malformed created_at (no timezone)', () => {
const bad = { ...full, created_at: '2026-06-04T10:00:00' };
expect(backgroundTaskSchema.safeParse(bad).success).toBe(false);
});
});

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { IsoDateTime, isoDateTimeSchema, nowIsoDateTime } from '../time';
describe('time — IsoDateTime', () => {
it('alias and schema are the same object', () => {
expect(IsoDateTime).toBe(isoDateTimeSchema);
});
it('normalizes +08:00 offset to UTC `Z`', () => {
const parsed = isoDateTimeSchema.parse('2026-06-04T18:30:00+08:00');
expect(parsed.endsWith('Z')).toBe(true);
expect(parsed).toBe('2026-06-04T10:30:00.000Z');
});
it('normalizes -05:00 offset to UTC `Z`', () => {
const parsed = isoDateTimeSchema.parse('2026-06-04T05:30:00-05:00');
expect(parsed).toBe('2026-06-04T10:30:00.000Z');
});
it('canonicalizes already-UTC input with millisecond padding', () => {
expect(isoDateTimeSchema.parse('2026-06-04T10:30:00Z')).toBe('2026-06-04T10:30:00.000Z');
expect(isoDateTimeSchema.parse('2026-06-04T10:30:00.5Z')).toBe('2026-06-04T10:30:00.500Z');
expect(isoDateTimeSchema.parse('2026-06-04T10:30:00.123Z')).toBe('2026-06-04T10:30:00.123Z');
});
it('rejects no-offset input (offset is REQUIRED)', () => {
expect(isoDateTimeSchema.safeParse('2026-06-04T10:30:00').success).toBe(false);
});
it('rejects non-ISO strings', () => {
expect(isoDateTimeSchema.safeParse('not-a-date').success).toBe(false);
expect(isoDateTimeSchema.safeParse('2026/06/04 10:30:00').success).toBe(false);
expect(isoDateTimeSchema.safeParse('').success).toBe(false);
});
it('rejects unix epoch numbers (string form)', () => {
expect(isoDateTimeSchema.safeParse('1717497000').success).toBe(false);
});
it('rejects ISO-shaped but invalid dates', () => {
expect(isoDateTimeSchema.safeParse('2026-13-04T10:30:00Z').success).toBe(false);
});
it('nowIsoDateTime() produces a canonical-Z millisecond string', () => {
const stamp = nowIsoDateTime();
expect(stamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(isoDateTimeSchema.parse(stamp)).toBe(stamp);
});
});

View file

@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import {
mcpServerSchema,
mcpServerStatusSchema,
mcpServerTransportSchema,
toolDescriptorSchema,
toolSourceSchema,
type McpServer,
type ToolDescriptor,
} from '../tool';
describe('toolSourceSchema', () => {
it.each(['builtin', 'skill', 'mcp'] as const)('accepts %s', (s) => {
expect(toolSourceSchema.parse(s)).toBe(s);
});
it("rejects agent-core's raw 'user' literal (adapter must map first)", () => {
expect(toolSourceSchema.safeParse('user').success).toBe(false);
});
});
describe('toolDescriptorSchema', () => {
const sample: ToolDescriptor = {
name: 'Bash',
description: 'Execute a shell command',
input_schema: { type: 'object', properties: { command: { type: 'string' } } },
source: 'builtin',
};
it('round-trips a builtin tool', () => {
expect(toolDescriptorSchema.parse(sample)).toEqual(sample);
});
it('accepts an mcp tool with mcp_server_id', () => {
const tool: ToolDescriptor = {
...sample,
name: 'mcp:lark:search',
source: 'mcp',
mcp_server_id: 'lark',
};
expect(toolDescriptorSchema.parse(tool).mcp_server_id).toBe('lark');
});
it('allows input_schema = null (adapter emits null when surface absent)', () => {
const tool = { ...sample, input_schema: null };
expect(toolDescriptorSchema.parse(tool).input_schema).toBeNull();
});
it('rejects missing name', () => {
expect(toolDescriptorSchema.safeParse({ ...sample, name: '' }).success).toBe(false);
});
});
describe('mcpServerStatusSchema', () => {
it.each(['connected', 'connecting', 'disconnected', 'error'] as const)(
'accepts %s',
(s) => {
expect(mcpServerStatusSchema.parse(s)).toBe(s);
},
);
it("rejects agent-core's 'pending' literal (adapter maps to 'connecting')", () => {
expect(mcpServerStatusSchema.safeParse('pending').success).toBe(false);
});
});
describe('mcpServerTransportSchema', () => {
it.each(['stdio', 'http', 'sse'] as const)('accepts %s', (t) => {
expect(mcpServerTransportSchema.parse(t)).toBe(t);
});
});
describe('mcpServerSchema', () => {
const sample: McpServer = {
id: 'lark',
name: 'lark',
transport: 'stdio',
status: 'connected',
tool_count: 7,
};
it('round-trips a healthy MCP server', () => {
expect(mcpServerSchema.parse(sample)).toEqual(sample);
});
it('round-trips an errored server with last_error', () => {
const errored: McpServer = {
...sample,
status: 'error',
last_error: 'spawn failed: ENOENT',
};
expect(mcpServerSchema.parse(errored).last_error).toBe('spawn failed: ENOENT');
});
it('rejects negative tool_count', () => {
const bad = { ...sample, tool_count: -1 };
expect(mcpServerSchema.safeParse(bad).success).toBe(false);
});
});

View file

@ -0,0 +1,317 @@
import { describe, expect, it } from 'vitest';
import {
abortMessageSchema,
clientControlMessageSchema,
clientHelloMessageSchema,
pingMessageSchema,
pongMessageSchema,
resyncRequiredMessageSchema,
serverHelloMessageSchema,
serverSystemMessageSchema,
subscribeMessageSchema,
unsubscribeMessageSchema,
watchFsAddMessageSchema,
watchFsRemoveMessageSchema,
wsAckEnvelopeSchema,
wsControlEnvelopeSchema,
wsErrorMessageSchema,
wsEventEnvelopeSchema,
} from '../ws-control';
import { z } from 'zod';
const TS = '2026-06-04T10:30:00.000Z';
describe('ws-control — generic envelopes', () => {
it('wsEventEnvelopeSchema accepts a session event frame', () => {
const schema = wsEventEnvelopeSchema(z.object({ delta: z.string() }));
const parsed = schema.parse({
type: 'event.assistant.delta',
seq: 42,
session_id: 'sess_1',
timestamp: TS,
payload: { delta: 'hi' },
});
expect(parsed.seq).toBe(42);
});
it('wsControlEnvelopeSchema accepts an id-less message', () => {
const schema = wsControlEnvelopeSchema(z.object({}));
expect(schema.safeParse({ type: 'pong', payload: {} }).success).toBe(true);
});
it('wsAckEnvelopeSchema requires type=ack and an id', () => {
const schema = wsAckEnvelopeSchema(z.object({}));
expect(
schema.safeParse({ type: 'ack', id: 'c1', code: 0, msg: 'success', payload: {} }).success,
).toBe(true);
expect(
schema.safeParse({ type: 'not_ack', id: 'c1', code: 0, msg: 'success', payload: {} }).success,
).toBe(false);
expect(schema.safeParse({ type: 'ack', code: 0, msg: 'x', payload: {} }).success).toBe(false);
});
});
describe('ws-control — §3.1 server_hello', () => {
it('parses a canonical server_hello frame', () => {
const result = serverHelloMessageSchema.safeParse({
type: 'server_hello',
timestamp: TS,
payload: {
ws_connection_id: 'conn_local',
heartbeat_ms: 30000,
max_event_buffer_size: 1000,
capabilities: { event_batching: false, compression: false },
},
});
expect(result.success).toBe(true);
});
it('rejects a server_hello missing capabilities', () => {
const result = serverHelloMessageSchema.safeParse({
type: 'server_hello',
timestamp: TS,
payload: {
ws_connection_id: 'conn_local',
heartbeat_ms: 30000,
max_event_buffer_size: 1000,
},
});
expect(result.success).toBe(false);
});
});
describe('ws-control — §3.2 client_hello', () => {
it('parses a canonical client_hello', () => {
const result = clientHelloMessageSchema.safeParse({
type: 'client_hello',
id: 'c1',
payload: {
client_id: 'web_abc',
subscriptions: ['sess_1', 'sess_2'],
last_seq_by_session: { sess_1: 99 },
},
});
expect(result.success).toBe(true);
});
it('rejects a client_hello missing payload.client_id', () => {
const result = clientHelloMessageSchema.safeParse({
type: 'client_hello',
id: 'c1',
payload: { subscriptions: [] },
});
expect(result.success).toBe(false);
});
});
describe('ws-control — §3.3 subscribe / unsubscribe', () => {
it('subscribe accepts a watch_fs map', () => {
const result = subscribeMessageSchema.safeParse({
type: 'subscribe',
id: 'c2',
payload: {
session_ids: ['sess_1'],
watch_fs: {
sess_1: { paths: ['src'], recursive: true },
},
},
});
expect(result.success).toBe(true);
});
it('subscribe rejects missing session_ids', () => {
const result = subscribeMessageSchema.safeParse({
type: 'subscribe',
id: 'c2',
payload: {},
});
expect(result.success).toBe(false);
});
it('unsubscribe parses on session_ids', () => {
const ok = unsubscribeMessageSchema.safeParse({
type: 'unsubscribe',
id: 'c3',
payload: { session_ids: ['sess_1'] },
});
expect(ok.success).toBe(true);
});
it('unsubscribe rejects bad type literal', () => {
const bad = unsubscribeMessageSchema.safeParse({
type: 'unsub',
id: 'c3',
payload: { session_ids: [] },
});
expect(bad.success).toBe(false);
});
});
describe('ws-control — §3.3.1 watch_fs_add / watch_fs_remove', () => {
it('watch_fs_add accepts paths', () => {
const result = watchFsAddMessageSchema.safeParse({
type: 'watch_fs_add',
id: 'c4',
payload: {
session_id: 'sess_1',
paths: ['src/components'],
recursive: true,
},
});
expect(result.success).toBe(true);
});
it('watch_fs_add rejects missing session_id', () => {
const result = watchFsAddMessageSchema.safeParse({
type: 'watch_fs_add',
id: 'c4',
payload: { paths: [] },
});
expect(result.success).toBe(false);
});
it('watch_fs_remove requires session_id + paths', () => {
const ok = watchFsRemoveMessageSchema.safeParse({
type: 'watch_fs_remove',
id: 'c5',
payload: { session_id: 'sess_1', paths: ['src/components'] },
});
expect(ok.success).toBe(true);
const bad = watchFsRemoveMessageSchema.safeParse({
type: 'watch_fs_remove',
id: 'c5',
payload: { paths: ['src/components'] },
});
expect(bad.success).toBe(false);
});
});
describe('ws-control — §3.4 abort', () => {
it('parses a canonical abort frame', () => {
const result = abortMessageSchema.safeParse({
type: 'abort',
id: 'c6',
payload: { session_id: 'sess_1', prompt_id: 'prompt_1' },
});
expect(result.success).toBe(true);
});
it('rejects an abort missing prompt_id', () => {
const result = abortMessageSchema.safeParse({
type: 'abort',
id: 'c6',
payload: { session_id: 'sess_1' },
});
expect(result.success).toBe(false);
});
});
describe('ws-control — §3.5 ping / pong', () => {
it('ping (S→C) requires timestamp + nonce', () => {
const ok = pingMessageSchema.safeParse({
type: 'ping',
timestamp: TS,
payload: { nonce: 'n_1' },
});
expect(ok.success).toBe(true);
const bad = pingMessageSchema.safeParse({
type: 'ping',
payload: { nonce: 'n_1' },
});
expect(bad.success).toBe(false);
});
it('pong (C→S) requires nonce in payload', () => {
const ok = pongMessageSchema.safeParse({
type: 'pong',
payload: { nonce: 'n_1' },
});
expect(ok.success).toBe(true);
const bad = pongMessageSchema.safeParse({
type: 'pong',
payload: {},
});
expect(bad.success).toBe(false);
});
});
describe('ws-control — §3.6 resync_required', () => {
it('parses a canonical resync_required', () => {
const result = resyncRequiredMessageSchema.safeParse({
type: 'resync_required',
timestamp: TS,
payload: { session_id: 'sess_1', reason: 'buffer_overflow', current_seq: 1234 },
});
expect(result.success).toBe(true);
});
it('rejects an unknown reason', () => {
const result = resyncRequiredMessageSchema.safeParse({
type: 'resync_required',
timestamp: TS,
payload: { session_id: 'sess_1', reason: 'nope', current_seq: 0 },
});
expect(result.success).toBe(false);
});
});
describe('ws-control — §3.7 error', () => {
it('parses a canonical error frame', () => {
const result = wsErrorMessageSchema.safeParse({
type: 'error',
timestamp: TS,
payload: { code: 40001, msg: 'validation failed', fatal: false },
});
expect(result.success).toBe(true);
});
it('rejects an error missing fatal flag', () => {
const result = wsErrorMessageSchema.safeParse({
type: 'error',
timestamp: TS,
payload: { code: 40001, msg: 'x' },
});
expect(result.success).toBe(false);
});
});
describe('ws-control — discriminated unions', () => {
it('clientControlMessageSchema dispatches by type', () => {
const ok = clientControlMessageSchema.safeParse({
type: 'abort',
id: 'c7',
payload: { session_id: 'sess_1', prompt_id: 'prompt_1' },
});
expect(ok.success).toBe(true);
});
it('clientControlMessageSchema rejects an unknown control type', () => {
const result = clientControlMessageSchema.safeParse({
type: 'launch_missiles',
id: 'c8',
payload: {},
});
expect(result.success).toBe(false);
});
it('serverSystemMessageSchema accepts server_hello / ping / resync / error', () => {
expect(
serverSystemMessageSchema.safeParse({
type: 'ping',
timestamp: TS,
payload: { nonce: 'n_1' },
}).success,
).toBe(true);
expect(
serverSystemMessageSchema.safeParse({
type: 'error',
timestamp: TS,
payload: { code: 50001, msg: 'boom', fatal: true },
}).success,
).toBe(true);
});
});

View file

@ -0,0 +1,30 @@
import { z } from 'zod';
import { isoDateTimeSchema } from './time';
export const approvalDecisionSchema = z.enum(['approved', 'rejected', 'cancelled']);
export type ApprovalDecision = z.infer<typeof approvalDecisionSchema>;
export const approvalScopeSchema = z.enum(['session']);
export type ApprovalScope = z.infer<typeof approvalScopeSchema>;
export const approvalRequestSchema = z.object({
approval_id: z.string().min(1),
session_id: z.string().min(1),
turn_id: z.number().int().nonnegative().optional(),
tool_call_id: z.string().min(1),
tool_name: z.string().min(1),
action: z.string(),
tool_input_display: z.unknown(),
created_at: isoDateTimeSchema,
expires_at: isoDateTimeSchema,
});
export type ApprovalRequest = z.infer<typeof approvalRequestSchema>;
export const approvalResponseSchema = z.object({
decision: approvalDecisionSchema,
scope: approvalScopeSchema.optional(),
feedback: z.string().optional(),
selected_label: z.string().optional(),
});
export type ApprovalResponse = z.infer<typeof approvalResponseSchema>;

View file

@ -0,0 +1,153 @@
import { z } from 'zod';
export const ToolInputDisplaySchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('command'),
command: z.string(),
cwd: z.string().optional(),
description: z.string().optional(),
language: z.literal('bash').optional(),
}),
z.object({
kind: z.literal('file_io'),
operation: z.enum(['read', 'write', 'edit', 'glob', 'grep']),
path: z.string(),
detail: z.string().optional(),
content: z.string().optional(),
before: z.string().optional(),
after: z.string().optional(),
}),
z.object({
kind: z.literal('diff'),
path: z.string(),
before: z.string(),
after: z.string(),
hunks: z.number().optional(),
}),
z.object({
kind: z.literal('search'),
query: z.string(),
scope: z.string().optional(),
}),
z.object({
kind: z.literal('url_fetch'),
url: z.string(),
method: z.string().optional(),
}),
z.object({
kind: z.literal('agent_call'),
agent_name: z.string(),
prompt: z.string(),
background: z.boolean().optional(),
}),
z.object({
kind: z.literal('skill_call'),
skill_name: z.string(),
args: z.string().optional(),
}),
z.object({
kind: z.literal('todo_list'),
items: z.array(z.object({ title: z.string(), status: z.string() })),
}),
z.object({
kind: z.literal('background_task'),
task_id: z.string(),
status: z.string(),
description: z.string(),
task_kind: z.string().optional(),
}),
z.object({
kind: z.literal('task_stop'),
task_id: z.string(),
task_description: z.string(),
}),
z.object({
kind: z.literal('plan_review'),
plan: z.string(),
path: z.string().optional(),
options: z
.array(
z.object({
label: z.string(),
description: z.string(),
}),
)
.readonly()
.optional(),
}),
z.object({
kind: z.literal('generic'),
summary: z.string(),
detail: z.unknown().optional(),
}),
]);
export const ToolResultDisplaySchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('command_output'),
exit_code: z.number(),
stdout: z.string().optional(),
stderr: z.string().optional(),
}),
z.object({
kind: z.literal('file_content'),
path: z.string(),
content: z.string(),
range: z.object({ start: z.number(), end: z.number() }).optional(),
truncated: z.boolean().optional(),
}),
z.object({
kind: z.literal('diff'),
path: z.string(),
before: z.string(),
after: z.string(),
hunks: z.number().optional(),
}),
z.object({
kind: z.literal('search_results'),
query: z.string(),
matches: z.array(z.object({ file: z.string(), line: z.number(), text: z.string() })),
}),
z.object({
kind: z.literal('url_content'),
url: z.string(),
status: z.number(),
preview: z.string().optional(),
content_type: z.string().optional(),
}),
z.object({
kind: z.literal('agent_summary'),
agent_name: z.string(),
result: z.string().optional(),
steps: z.number().optional(),
}),
z.object({
kind: z.literal('background_task'),
task_id: z.string(),
status: z.string(),
description: z.string(),
}),
z.object({
kind: z.literal('todo_list'),
items: z.array(z.object({ title: z.string(), status: z.string() })),
}),
z.object({ kind: z.literal('structured'), data: z.unknown() }),
z.object({
kind: z.literal('text'),
text: z.string(),
truncated: z.boolean().optional(),
}),
z.object({
kind: z.literal('error'),
message: z.string(),
code: z.string().optional(),
}),
z.object({
kind: z.literal('generic'),
summary: z.string(),
detail: z.unknown().optional(),
}),
]);
export type ToolInputDisplay = z.infer<typeof ToolInputDisplaySchema>;
export type ToolResultDisplay = z.infer<typeof ToolResultDisplaySchema>;

View file

@ -0,0 +1,26 @@
import { z } from 'zod';
export const envelopeSchema = <T extends z.ZodTypeAny>(data: T) =>
z.object({
code: z.number().int(),
msg: z.string(),
data: data.nullable(),
request_id: z.string(),
details: z.unknown().optional(),
});
export interface Envelope<T> {
code: number;
msg: string;
data: T | null;
request_id: string;
details?: unknown;
}
export function okEnvelope<T>(data: T, requestId: string): Envelope<T> {
return { code: 0, msg: 'success', data, request_id: requestId };
}
export function errEnvelope(code: number, msg: string, requestId: string): Envelope<null> {
return { code, msg, data: null, request_id: requestId };
}

View file

@ -0,0 +1,180 @@
/**
* Integer namespaces:
* - 0 success
* - 4xxxx (HTTP-4xx analog)
* - 5xxxx daemon
* - 6xxxx
* - 7xxxx LLM provider (msg = original upstream text)
* - 8xxxx MCP server (msg = original upstream text)
* - 9xxxx
*/
export const ErrorCode = {
/** 成功 */
SUCCESS: 0,
/** Zod 校验失败,`details` 含字段路径列表 */
VALIDATION_FAILED: 40001,
/** JSON 解析失败、字段类型错 */
REQUEST_MALFORMED: 40002,
/** daemon 没有任何 provider 配置 */
AUTH_PROVISIONING_REQUIRED: 40110,
/** provider 存在但 token / api_key 缺失 */
AUTH_TOKEN_MISSING: 40111,
/** 刷新 token 收到 401用户撤销了授权 */
AUTH_TOKEN_UNAUTHORIZED: 40112,
/** 默认 / 请求的 model 解析不到 provider */
AUTH_MODEL_NOT_RESOLVED: 40113,
/** session_id 不存在 */
SESSION_NOT_FOUND: 40401,
/** prompt_id 不存在 */
PROMPT_NOT_FOUND: 40402,
/** message_id 不存在 */
MESSAGE_NOT_FOUND: 40403,
/** approval_id 不存在 */
APPROVAL_NOT_FOUND: 40404,
/** question_id 不存在 */
QUESTION_NOT_FOUND: 40405,
/** task_id 不存在 */
TASK_NOT_FOUND: 40406,
/** file_id 不存在 */
FILE_NOT_FOUND: 40407,
/** mcp_server_id 不存在 */
MCP_SERVER_NOT_FOUND: 40408,
/** fs path 不存在 */
FS_PATH_NOT_FOUND: 40409,
/** workspace_id 不存在 */
WORKSPACE_NOT_FOUND: 40410,
/** fs 路径存在但当前进程无权限读取 */
FS_PERMISSION_DENIED: 40411,
/** provider_id 不存在 */
PROVIDER_NOT_FOUND: 40412,
/** model_id 不存在 */
MODEL_NOT_FOUND: 40413,
/** session 有正在进行的 prompt拒绝新请求 */
SESSION_BUSY: 40901,
/** approval 已被其他 client 应答 */
APPROVAL_ALREADY_RESOLVED: 40902,
/** prompt 已结束abort 幂等返回 0 */
PROMPT_ALREADY_COMPLETED: 40903,
/** task 已完结,无法取消 */
TASK_ALREADY_FINISHED: 40904,
/** mcp restart 时若已在 connecting/connected */
MCP_ALREADY_CONNECTED: 40905,
/** fs.read 请求 file但 path 是目录 */
FS_IS_DIRECTORY: 40906,
/** fs.read 请求 utf-8但 path 是二进制client 改走 `:download` */
FS_IS_BINARY: 40907,
/** fs.git_status 但 session.cwd 不是 git repo */
FS_GIT_UNAVAILABLE: 40908,
/** 用户 ESC / 关闭面板放弃整组client 调 `:dismiss` */
QUESTION_DISMISSED: 40909,
/** 当前历史没有可 compact 的前缀 */
COMPACTION_UNABLE: 40910,
/** 当前历史没有足够的用户提示词可撤回 */
SESSION_UNDO_UNAVAILABLE: 40911,
/** approval 60s 超时 */
APPROVAL_EXPIRED: 41001,
/** question 60s 超时 */
QUESTION_EXPIRED: 41002,
/** 临时文件已过期 */
FILE_EXPIRED: 41003,
/** 上传超 50MB */
FILE_TOO_LARGE: 41301,
/** fs.read 超 10MB */
FS_TOO_LARGE: 41302,
/** fs.list / fs.search / fs.grep 命中超上限 */
FS_TOO_MANY_RESULTS: 41303,
/** path 越出 session cwd 边界 */
FS_PATH_ESCAPES_SESSION: 41304,
/** fs.grep 执行 >30s */
FS_GREP_TIMEOUT: 41305,
/** WS 单连接 watch_paths > 100 */
FS_WATCH_LIMIT_EXCEEDED: 42902,
/** 兜底 */
INTERNAL_ERROR: 50001,
/** 写入 session 持久化失败 */
PERSISTENCE_FAILURE: 50003,
/** tool 执行抛错 */
TOOL_EXECUTION_FAILED: 60001,
/** tool 在此 session 未启用 */
TOOL_NOT_AVAILABLE: 60002,
/** provider.* — provider 原 code 含义保留;`msg` 字段透传上游错误文本。 */
/** mcp.* — mcp server 原 code 含义保留;`msg` 字段透传上游错误文本。 */
} as const;
/**
* Reserved (intentionally unallocated; do NOT reuse for new variants):
* - 40101 auth.invalid_token (daemon's own token; future)
* - 40102 auth.missing_token (daemon's own token; future)
* - 40103 auth.forbidden_origin (daemon's own token; future)
* - 42901 rate.limited
* - 50002 protocol.version_mismatch
*/
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
export const ErrorCodeReason: Readonly<Record<ErrorCode, string>> = {
[ErrorCode.SUCCESS]: 'success',
[ErrorCode.VALIDATION_FAILED]: 'validation.failed',
[ErrorCode.REQUEST_MALFORMED]: 'request.malformed',
[ErrorCode.AUTH_PROVISIONING_REQUIRED]: 'auth.provisioning_required',
[ErrorCode.AUTH_TOKEN_MISSING]: 'auth.token_missing',
[ErrorCode.AUTH_TOKEN_UNAUTHORIZED]: 'auth.token_unauthorized',
[ErrorCode.AUTH_MODEL_NOT_RESOLVED]: 'auth.model_not_resolved',
[ErrorCode.SESSION_NOT_FOUND]: 'session.not_found',
[ErrorCode.PROMPT_NOT_FOUND]: 'prompt.not_found',
[ErrorCode.MESSAGE_NOT_FOUND]: 'message.not_found',
[ErrorCode.APPROVAL_NOT_FOUND]: 'approval.not_found',
[ErrorCode.QUESTION_NOT_FOUND]: 'question.not_found',
[ErrorCode.TASK_NOT_FOUND]: 'task.not_found',
[ErrorCode.FILE_NOT_FOUND]: 'file.not_found',
[ErrorCode.MCP_SERVER_NOT_FOUND]: 'mcp.server_not_found',
[ErrorCode.FS_PATH_NOT_FOUND]: 'fs.path_not_found',
[ErrorCode.WORKSPACE_NOT_FOUND]: 'workspace.not_found',
[ErrorCode.FS_PERMISSION_DENIED]: 'fs.permission_denied',
[ErrorCode.PROVIDER_NOT_FOUND]: 'provider.not_found',
[ErrorCode.MODEL_NOT_FOUND]: 'model.not_found',
[ErrorCode.SESSION_BUSY]: 'session.busy',
[ErrorCode.APPROVAL_ALREADY_RESOLVED]: 'approval.already_resolved',
[ErrorCode.PROMPT_ALREADY_COMPLETED]: 'prompt.already_completed',
[ErrorCode.TASK_ALREADY_FINISHED]: 'task.already_finished',
[ErrorCode.MCP_ALREADY_CONNECTED]: 'mcp.already_connected',
[ErrorCode.FS_IS_DIRECTORY]: 'fs.is_directory',
[ErrorCode.FS_IS_BINARY]: 'fs.is_binary',
[ErrorCode.FS_GIT_UNAVAILABLE]: 'fs.git_unavailable',
[ErrorCode.QUESTION_DISMISSED]: 'question.dismissed',
[ErrorCode.COMPACTION_UNABLE]: 'compaction.unable',
[ErrorCode.SESSION_UNDO_UNAVAILABLE]: 'session.undo_unavailable',
[ErrorCode.APPROVAL_EXPIRED]: 'approval.expired',
[ErrorCode.QUESTION_EXPIRED]: 'question.expired',
[ErrorCode.FILE_EXPIRED]: 'file.expired',
[ErrorCode.FILE_TOO_LARGE]: 'file.too_large',
[ErrorCode.FS_TOO_LARGE]: 'fs.too_large',
[ErrorCode.FS_TOO_MANY_RESULTS]: 'fs.too_many_results',
[ErrorCode.FS_PATH_ESCAPES_SESSION]: 'fs.path_escapes_session',
[ErrorCode.FS_GREP_TIMEOUT]: 'fs.grep_timeout',
[ErrorCode.FS_WATCH_LIMIT_EXCEEDED]: 'fs.watch_limit_exceeded',
[ErrorCode.INTERNAL_ERROR]: 'internal.error',
[ErrorCode.PERSISTENCE_FAILURE]: 'persistence.failure',
[ErrorCode.TOOL_EXECUTION_FAILED]: 'tool.execution_failed',
[ErrorCode.TOOL_NOT_AVAILABLE]: 'tool.not_available',
};

View file

@ -0,0 +1,572 @@
import type { ToolInputDisplay } from './display';
export interface TokenUsage {
readonly inputOther: number;
readonly output: number;
readonly inputCacheRead: number;
readonly inputCacheCreation: number;
}
export type FinishReason =
| 'completed'
| 'tool_calls'
| 'truncated'
| 'filtered'
| 'paused'
| 'other';
export interface UsageStatus {
readonly byModel?: Record<string, TokenUsage>;
readonly currentTurn?: TokenUsage;
readonly total?: TokenUsage;
}
export type PermissionMode = 'manual' | 'yolo' | 'auto';
export type SkillSource = 'project' | 'user' | 'extra' | 'builtin';
export interface UserPromptOrigin {
readonly kind: 'user';
}
export interface SkillActivationOrigin {
readonly kind: 'skill_activation';
readonly activationId: string;
readonly skillName: string;
readonly skillArgs?: string;
readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill';
readonly skillType?: string;
readonly skillPath?: string;
readonly skillSource?: SkillSource;
}
export interface InjectionOrigin {
readonly kind: 'injection';
readonly variant: string;
}
export interface CompactionSummaryOrigin {
readonly kind: 'compaction_summary';
}
export interface SystemTriggerOrigin {
readonly kind: 'system_trigger';
readonly name: string;
}
export type AgentCoreBackgroundTaskStatus =
| 'running'
| 'completed'
| 'failed'
| 'timed_out'
| 'killed'
| 'lost';
export interface BackgroundTaskOrigin {
readonly kind: 'background_task';
readonly taskId: string;
readonly status: AgentCoreBackgroundTaskStatus;
readonly notificationId: string;
}
export interface CronJobOrigin {
readonly kind: 'cron_job';
readonly jobId: string;
readonly cron: string;
readonly recurring: boolean;
readonly coalescedCount: number;
readonly stale: boolean;
}
export interface CronMissedOrigin {
readonly kind: 'cron_missed';
readonly count: number;
}
export interface HookResultOrigin {
readonly kind: 'hook_result';
readonly event: string;
readonly blocked?: boolean;
}
export interface RetryOrigin {
readonly kind: 'retry';
readonly trigger?: string;
}
export type PromptOrigin =
| UserPromptOrigin
| SkillActivationOrigin
| InjectionOrigin
| CompactionSummaryOrigin
| SystemTriggerOrigin
| BackgroundTaskOrigin
| CronJobOrigin
| CronMissedOrigin
| HookResultOrigin
| RetryOrigin;
export type GoalStatus = 'active' | 'paused' | 'blocked' | 'complete';
export type GoalActor = 'user' | 'model' | 'runtime' | 'system';
export interface GoalBudgetLimits {
readonly tokenBudget?: number;
readonly turnBudget?: number;
readonly wallClockBudgetMs?: number;
}
export interface GoalBudgetReport {
readonly tokenBudget: number | null;
readonly turnBudget: number | null;
readonly wallClockBudgetMs: number | null;
readonly remainingTokens: number | null;
readonly remainingTurns: number | null;
readonly remainingWallClockMs: number | null;
readonly tokenBudgetReached: boolean;
readonly turnBudgetReached: boolean;
readonly wallClockBudgetReached: boolean;
readonly overBudget: boolean;
}
export interface GoalSnapshot {
readonly goalId: string;
readonly objective: string;
readonly completionCriterion?: string;
readonly status: GoalStatus;
readonly turnsUsed: number;
readonly tokensUsed: number;
readonly wallClockMs: number;
readonly budget: GoalBudgetReport;
readonly terminalReason?: string;
}
export interface GoalToolResult {
readonly goal: GoalSnapshot | null;
}
export interface GoalChangeStats {
readonly turnsUsed: number;
readonly tokensUsed: number;
readonly wallClockMs: number;
}
export type GoalChangeKind = 'lifecycle' | 'completion';
export interface GoalChange {
readonly kind: GoalChangeKind;
readonly status?: GoalStatus;
readonly reason?: string;
readonly stats?: GoalChangeStats;
readonly actor?: GoalActor;
}
export type KimiErrorCode =
| 'config.invalid'
| 'session.not_found'
| 'session.already_exists'
| 'session.id_invalid'
| 'session.id_required'
| 'session.id_empty'
| 'session.title_empty'
| 'session.state_not_found'
| 'session.state_invalid'
| 'session.fork_active_turn'
| 'session.export_not_found'
| 'session.export_missing_version'
| 'session.closed'
| 'session.permission_mode_invalid'
| 'session.thinking_empty'
| 'session.model_empty'
| 'session.plan_mode_invalid'
| 'session.approval_handler_error'
| 'session.question_handler_error'
| 'session.init_failed'
| 'agent.not_found'
| 'turn.agent_busy'
| 'goal.already_exists'
| 'goal.not_found'
| 'goal.objective_empty'
| 'goal.objective_too_long'
| 'goal.status_invalid'
| 'goal.metadata_reserved'
| 'goal.not_resumable'
| 'model.not_configured'
| 'model.config_invalid'
| 'auth.login_required'
| 'context.overflow'
| 'loop.max_steps_exceeded'
| 'provider.api_error'
| 'provider.rate_limit'
| 'provider.auth_error'
| 'provider.connection_error'
| 'skill.not_found'
| 'skill.type_unsupported'
| 'skill.name_empty'
| 'records.write_failed'
| 'compaction.failed'
| 'compaction.unable'
| 'background.task_id_empty'
| 'mcp.server_not_found'
| 'mcp.server_disabled'
| 'mcp.startup_failed'
| 'mcp.tool_name_collision'
| 'plugin.not_found'
| 'plugin.load_failed'
| 'request.invalid'
| 'request.work_dir_required'
| 'request.prompt_input_empty'
| 'shell.git_bash_not_found'
| 'not_implemented'
| 'internal';
export interface KimiErrorPayload {
readonly code: KimiErrorCode;
readonly message: string;
readonly name?: string;
readonly details?: Record<string, unknown>;
readonly retryable: boolean;
}
export interface BackgroundTaskInfoBase {
readonly taskId: string;
readonly description: string;
readonly status: AgentCoreBackgroundTaskStatus;
readonly startedAt: number;
readonly endedAt: number | null;
readonly stopReason?: string;
readonly terminalNotificationSuppressed?: boolean;
readonly timeoutMs?: number;
}
export interface ProcessBackgroundTaskInfo extends BackgroundTaskInfoBase {
readonly kind: 'process';
readonly command: string;
readonly pid: number;
readonly exitCode: number | null;
}
export interface AgentBackgroundTaskInfo extends BackgroundTaskInfoBase {
readonly kind: 'agent';
readonly agentId?: string;
readonly subagentType?: string;
}
export interface QuestionBackgroundTaskInfo extends BackgroundTaskInfoBase {
readonly kind: 'question';
readonly questionCount: number;
readonly toolCallId?: string;
}
export type BackgroundTaskInfo =
| ProcessBackgroundTaskInfo
| AgentBackgroundTaskInfo
| QuestionBackgroundTaskInfo;
export interface CompactionResult {
readonly summary: string;
readonly compactedCount: number;
readonly tokensBefore: number;
readonly tokensAfter: number;
}
export interface ToolUpdate {
readonly kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom';
readonly text?: string;
readonly percent?: number;
readonly customKind?: string;
readonly customData?: unknown;
}
export const MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE = 'mcp.oauth.authorization_url';
export interface McpOAuthAuthorizationUrlUpdateData {
readonly serverName: string;
readonly authorizationUrl: string;
}
export type TurnEndReason = 'completed' | 'cancelled' | 'failed';
export interface AgentStatusUpdatedEvent {
readonly type: 'agent.status.updated';
readonly model?: string;
readonly contextTokens?: number;
readonly maxContextTokens?: number;
readonly contextUsage?: number;
readonly planMode?: boolean;
readonly swarmMode?: boolean;
readonly permission?: PermissionMode;
readonly usage?: UsageStatus;
}
export interface SessionMetaUpdatedEvent {
readonly type: 'session.meta.updated';
readonly title?: string;
readonly patch?: Record<string, unknown>;
}
export interface GoalUpdatedEvent {
readonly type: 'goal.updated';
readonly snapshot: GoalSnapshot | null;
readonly change?: GoalChange;
}
export interface SkillActivatedEvent {
readonly type: 'skill.activated';
readonly activationId: string;
readonly skillName: string;
readonly skillArgs?: string;
readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill';
readonly skillPath?: string;
readonly skillSource?: SkillSource;
}
export interface ErrorEvent extends KimiErrorPayload {
readonly type: 'error';
}
export interface WarningEvent {
readonly type: 'warning';
readonly message: string;
readonly code?: string;
}
export interface TurnStartedEvent {
readonly type: 'turn.started';
readonly turnId: number;
readonly origin: PromptOrigin;
}
export interface TurnEndedEvent {
readonly type: 'turn.ended';
readonly turnId: number;
readonly reason: TurnEndReason;
readonly error?: KimiErrorPayload;
}
export interface TurnStepStartedEvent {
readonly type: 'turn.step.started';
readonly turnId: number;
readonly step: number;
readonly stepId?: string;
}
export interface TurnStepCompletedEvent {
readonly type: 'turn.step.completed';
readonly turnId: number;
readonly step: number;
readonly stepId?: string;
readonly usage?: TokenUsage;
readonly finishReason?: string;
readonly llmFirstTokenLatencyMs?: number;
readonly llmStreamDurationMs?: number;
readonly providerFinishReason?: FinishReason;
readonly rawFinishReason?: string;
}
export interface TurnStepRetryingEvent {
readonly type: 'turn.step.retrying';
readonly turnId: number;
readonly step: number;
readonly stepId?: string;
readonly failedAttempt: number;
readonly nextAttempt: number;
readonly maxAttempts: number;
readonly delayMs: number;
readonly errorName: string;
readonly errorMessage: string;
readonly statusCode?: number;
}
export interface TurnStepInterruptedEvent {
readonly type: 'turn.step.interrupted';
readonly turnId: number;
readonly step: number;
readonly stepId?: string;
readonly reason: string;
readonly message?: string;
}
export interface AssistantDeltaEvent {
readonly type: 'assistant.delta';
readonly turnId: number;
readonly delta: string;
}
export interface HookResultEvent {
readonly type: 'hook.result';
readonly turnId: number;
readonly hookEvent: string;
readonly content: string;
readonly blocked?: boolean;
}
export interface ThinkingDeltaEvent {
readonly type: 'thinking.delta';
readonly turnId: number;
readonly delta: string;
}
export interface ToolCallDeltaEvent {
readonly type: 'tool.call.delta';
readonly turnId: number;
readonly toolCallId: string;
readonly name?: string;
readonly argumentsPart?: string;
}
export interface ToolCallStartedEvent {
readonly type: 'tool.call.started';
readonly turnId: number;
readonly toolCallId: string;
readonly name: string;
readonly args: unknown;
readonly description?: string;
readonly display?: ToolInputDisplay;
}
export interface ToolProgressEvent {
readonly type: 'tool.progress';
readonly turnId: number;
readonly toolCallId: string;
readonly update: ToolUpdate;
}
export interface ToolResultEvent {
readonly type: 'tool.result';
readonly turnId: number;
readonly toolCallId: string;
readonly output: unknown;
readonly isError?: boolean;
readonly synthetic?: boolean;
}
export interface SubagentSpawnedEvent {
readonly type: 'subagent.spawned';
readonly subagentId: string;
readonly subagentName: string;
readonly parentToolCallId: string;
readonly parentToolCallUuid?: string;
readonly parentAgentId?: string;
readonly description?: string;
readonly swarmIndex?: number;
readonly runInBackground: boolean;
}
export interface SubagentStartedEvent {
readonly type: 'subagent.started';
readonly subagentId: string;
}
export interface SubagentSuspendedEvent {
readonly type: 'subagent.suspended';
readonly subagentId: string;
readonly reason: string;
}
export interface SubagentCompletedEvent {
readonly type: 'subagent.completed';
readonly subagentId: string;
readonly resultSummary: string;
readonly usage?: TokenUsage;
readonly contextTokens?: number;
}
export interface SubagentFailedEvent {
readonly type: 'subagent.failed';
readonly subagentId: string;
readonly error: string;
}
export interface CompactionStartedEvent {
readonly type: 'compaction.started';
readonly trigger: 'manual' | 'auto';
readonly instruction?: string;
}
export interface CompactionBlockedEvent {
readonly type: 'compaction.blocked';
readonly turnId?: number;
}
export interface CompactionCancelledEvent {
readonly type: 'compaction.cancelled';
}
export interface CompactionCompletedEvent {
readonly type: 'compaction.completed';
readonly result: CompactionResult;
}
export interface BackgroundTaskStartedEvent {
readonly type: 'background.task.started';
readonly info: BackgroundTaskInfo;
}
export interface BackgroundTaskTerminatedEvent {
readonly type: 'background.task.terminated';
readonly info: BackgroundTaskInfo;
}
export interface CronFiredEvent {
readonly type: 'cron.fired';
readonly origin: CronJobOrigin;
readonly prompt: string;
}
export type ToolListUpdatedReason = 'mcp.connected' | 'mcp.disconnected' | 'mcp.failed';
export interface ToolListUpdatedEvent {
readonly type: 'tool.list.updated';
readonly reason: ToolListUpdatedReason;
readonly serverName: string;
}
export interface McpServerStatusEvent {
readonly type: 'mcp.server.status';
readonly server: McpServerStatusPayload;
}
export interface McpServerStatusPayload {
readonly name: string;
readonly transport: 'stdio' | 'http';
readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth';
readonly toolCount: number;
readonly error?: string;
}
export type AgentEvent =
| ErrorEvent
| WarningEvent
| AgentStatusUpdatedEvent
| SessionMetaUpdatedEvent
| GoalUpdatedEvent
| SkillActivatedEvent
| TurnStartedEvent
| TurnEndedEvent
| TurnStepStartedEvent
| TurnStepCompletedEvent
| TurnStepRetryingEvent
| TurnStepInterruptedEvent
| AssistantDeltaEvent
| HookResultEvent
| ThinkingDeltaEvent
| ToolCallDeltaEvent
| ToolCallStartedEvent
| ToolProgressEvent
| ToolResultEvent
| ToolListUpdatedEvent
| McpServerStatusEvent
| SubagentSpawnedEvent
| SubagentStartedEvent
| SubagentSuspendedEvent
| SubagentCompletedEvent
| SubagentFailedEvent
| CompactionStartedEvent
| CompactionBlockedEvent
| CompactionCancelledEvent
| CompactionCompletedEvent
| BackgroundTaskStartedEvent
| BackgroundTaskTerminatedEvent
| CronFiredEvent;
export type Event = AgentEvent & { agentId: string; sessionId: string };

View file

@ -0,0 +1,13 @@
import { z } from 'zod';
import { isoDateTimeSchema } from './time';
export const fileMetaSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
media_type: z.string().min(1),
size: z.number().int().nonnegative(),
created_at: isoDateTimeSchema,
expires_at: isoDateTimeSchema.optional(),
});
export type FileMeta = z.infer<typeof fileMetaSchema>;

View file

@ -0,0 +1,88 @@
import { z } from 'zod';
import { isoDateTimeSchema } from './time';
export const fsKindSchema = z.enum(['file', 'directory', 'symlink']);
export type FsKind = z.infer<typeof fsKindSchema>;
export const fsGitStatusSchema = z.enum([
'clean',
'modified',
'added',
'deleted',
'renamed',
'untracked',
'ignored',
'conflicted',
]);
export type FsGitStatus = z.infer<typeof fsGitStatusSchema>;
export const fsEntrySchema = z.object({
path: z.string(),
name: z.string(),
kind: fsKindSchema,
size: z.number().int().nonnegative().optional(),
modified_at: isoDateTimeSchema,
etag: z.string().optional(),
mime: z.string().optional(),
language_id: z.string().optional(),
is_binary: z.boolean().optional(),
is_symlink_to: z.string().optional(),
git_status: fsGitStatusSchema.optional(),
child_count: z.number().int().nonnegative().optional(),
});
export type FsEntry = z.infer<typeof fsEntrySchema>;
export const fsSearchHitSchema = z.object({
path: z.string(),
name: z.string(),
kind: fsKindSchema,
score: z.number().min(0).max(1),
match_positions: z.array(z.number().int().nonnegative()),
});
export type FsSearchHit = z.infer<typeof fsSearchHitSchema>;
export const fsGrepMatchSchema = z.object({
line: z.number().int().positive(),
col: z.number().int().positive(),
text: z.string(),
before: z.array(z.string()),
after: z.array(z.string()),
});
export type FsGrepMatch = z.infer<typeof fsGrepMatchSchema>;
export const fsGrepFileHitSchema = z.object({
path: z.string(),
matches: z.array(fsGrepMatchSchema),
});
export type FsGrepFileHit = z.infer<typeof fsGrepFileHitSchema>;
export const fsGitStatusEntrySchema = z.object({
path: z.string(),
status: fsGitStatusSchema,
rename_from: z.string().optional(),
});
export type FsGitStatusEntry = z.infer<typeof fsGitStatusEntrySchema>;
export const fsChangeKindSchema = z.enum(['file', 'directory', 'symlink']);
export type FsChangeKind = z.infer<typeof fsChangeKindSchema>;
export const fsChangeActionSchema = z.enum(['created', 'modified', 'deleted']);
export type FsChangeAction = z.infer<typeof fsChangeActionSchema>;
export const fsChangeEntrySchema = z.object({
path: z.string(),
change: fsChangeActionSchema,
kind: fsChangeKindSchema,
size_delta: z.number().int().optional(),
etag: z.string().optional(),
});
export type FsChangeEntry = z.infer<typeof fsChangeEntrySchema>;
export const fsChangeEventSchema = z.object({
changes: z.array(fsChangeEntrySchema),
coalesced_window_ms: z.number().int().positive(),
truncated: z.boolean().optional(),
count: z.number().int().nonnegative().optional(),
});
export type FsChangeEvent = z.infer<typeof fsChangeEventSchema>;

View file

@ -0,0 +1,35 @@
export * from './envelope';
export * from './error-codes';
export * from './pagination';
export * from './time';
export * from './request-id';
export * from './events';
export * from './display';
export * from './ws-control';
export * from './session';
export * from './workspace';
export * from './message';
export * from './approval';
export * from './question';
export * from './tool';
export * from './task';
export * from './fs';
export * from './file';
export * from './modelCatalog';
export * from './rest/meta';
export * from './rest/auth';
export * from './rest/oauth';
export * from './rest/session';
export * from './rest/workspace';
export * from './rest/fsBrowse';
export * from './rest/message';
export * from './rest/prompt';
export * from './rest/approval';
export * from './rest/question';
export * from './rest/tool';
export * from './rest/task';
export * from './rest/fs';
export * from './rest/file';
export * from './rest/modelCatalog';

View file

@ -0,0 +1,84 @@
import { z } from 'zod';
import { isoDateTimeSchema } from './time';
export const messageRoleSchema = z.enum(['user', 'assistant', 'tool', 'system']);
export type MessageRole = z.infer<typeof messageRoleSchema>;
export const textContentSchema = z.object({
type: z.literal('text'),
text: z.string(),
});
export type TextContent = z.infer<typeof textContentSchema>;
export const toolUseContentSchema = z.object({
type: z.literal('tool_use'),
tool_call_id: z.string().min(1),
tool_name: z.string().min(1),
input: z.unknown(),
});
export type ToolUseContent = z.infer<typeof toolUseContentSchema>;
export const toolResultContentSchema = z.object({
type: z.literal('tool_result'),
tool_call_id: z.string().min(1),
output: z.unknown(),
is_error: z.boolean().optional(),
});
export type ToolResultContent = z.infer<typeof toolResultContentSchema>;
export const imageSourceSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('url'), url: z.string().min(1) }),
z.object({
kind: z.literal('base64'),
media_type: z.string().min(1),
data: z.string().min(1),
}),
z.object({ kind: z.literal('file'), file_id: z.string().min(1) }),
]);
export type ImageSource = z.infer<typeof imageSourceSchema>;
export const imageContentSchema = z.object({
type: z.literal('image'),
source: imageSourceSchema,
});
export type ImageContent = z.infer<typeof imageContentSchema>;
export const fileContentSchema = z.object({
type: z.literal('file'),
file_id: z.string().min(1),
name: z.string(),
media_type: z.string().min(1),
size: z.number().int().nonnegative(),
});
export type FileContent = z.infer<typeof fileContentSchema>;
export const thinkingContentSchema = z.object({
type: z.literal('thinking'),
thinking: z.string(),
signature: z.string().optional(),
});
export type ThinkingContent = z.infer<typeof thinkingContentSchema>;
export const messageContentSchema = z.discriminatedUnion('type', [
textContentSchema,
toolUseContentSchema,
toolResultContentSchema,
imageContentSchema,
fileContentSchema,
thinkingContentSchema,
]);
export type MessageContent = z.infer<typeof messageContentSchema>;
export const messageSchema = z.object({
id: z.string().min(1),
session_id: z.string().min(1),
role: messageRoleSchema,
content: z.array(messageContentSchema),
created_at: isoDateTimeSchema,
prompt_id: z.string().min(1).optional(),
parent_message_id: z.string().min(1).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
export type Message = z.infer<typeof messageSchema>;

View file

@ -0,0 +1,28 @@
import { z } from 'zod';
export const modelCatalogItemSchema = z.object({
provider: z.string().min(1),
model: z.string().min(1),
display_name: z.string().min(1).optional(),
max_context_size: z.number().int().min(1),
capabilities: z.array(z.string()).optional(),
});
export type ModelCatalogItem = z.infer<typeof modelCatalogItemSchema>;
export const providerCatalogStatusSchema = z.enum([
'connected',
'error',
'unconfigured',
]);
export type ProviderCatalogStatus = z.infer<typeof providerCatalogStatusSchema>;
export const providerCatalogItemSchema = z.object({
id: z.string().min(1),
type: z.string().min(1),
base_url: z.string().min(1).optional(),
default_model: z.string().min(1).optional(),
has_api_key: z.boolean(),
status: providerCatalogStatusSchema,
models: z.array(z.string().min(1)).optional(),
});
export type ProviderCatalogItem = z.infer<typeof providerCatalogItemSchema>;

View file

@ -0,0 +1,35 @@
import { z } from 'zod';
import { ErrorCode } from './error-codes';
export const cursorQuerySchema = z
.object({
before_id: z.string().min(1).optional(),
after_id: z.string().min(1).optional(),
page_size: z.number().int().min(1).max(100).optional(),
})
.superRefine((value, ctx) => {
if (value.before_id !== undefined && value.after_id !== undefined) {
ctx.addIssue({
code: 'custom',
message: 'before_id and after_id are mutually exclusive',
path: ['before_id'],
params: { code: ErrorCode.VALIDATION_FAILED },
});
}
});
export type CursorQuery = z.infer<typeof cursorQuerySchema>;
export const CursorQuery = cursorQuerySchema;
export const pageResponseSchema = <T extends z.ZodTypeAny>(item: T) =>
z.object({
items: z.array(item),
has_more: z.boolean(),
});
export interface PageResponse<T> {
items: T[];
has_more: boolean;
}

View file

@ -0,0 +1,57 @@
import { z } from 'zod';
import { isoDateTimeSchema } from './time';
export const questionOptionSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
});
export type QuestionOption = z.infer<typeof questionOptionSchema>;
export const questionItemSchema = z.object({
id: z.string().min(1),
question: z.string().min(1),
header: z.string().optional(),
body: z.string().optional(),
options: z.array(questionOptionSchema).min(2).max(4),
multi_select: z.boolean().optional(),
allow_other: z.boolean().optional(),
other_label: z.string().optional(),
other_description: z.string().optional(),
});
export type QuestionItem = z.infer<typeof questionItemSchema>;
export const questionRequestSchema = z.object({
question_id: z.string().min(1),
session_id: z.string().min(1),
turn_id: z.number().int().nonnegative().optional(),
tool_call_id: z.string().min(1).optional(),
questions: z.array(questionItemSchema).min(1).max(4),
created_at: isoDateTimeSchema,
expires_at: isoDateTimeSchema,
});
export type QuestionRequest = z.infer<typeof questionRequestSchema>;
export const questionAnswerSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('single'), option_id: z.string().min(1) }),
z.object({ kind: z.literal('multi'), option_ids: z.array(z.string().min(1)).min(1) }),
z.object({ kind: z.literal('other'), text: z.string() }),
z.object({
kind: z.literal('multi_with_other'),
option_ids: z.array(z.string().min(1)),
other_text: z.string(),
}),
z.object({ kind: z.literal('skipped') }),
]);
export type QuestionAnswer = z.infer<typeof questionAnswerSchema>;
export const questionAnswerMethodSchema = z.enum(['enter', 'space', 'number_key', 'click']);
export type QuestionAnswerMethod = z.infer<typeof questionAnswerMethodSchema>;
export const questionResponseSchema = z.object({
answers: z.record(z.string().min(1), questionAnswerSchema),
method: questionAnswerMethodSchema.optional(),
note: z.string().optional(),
});
export type QuestionResponse = z.infer<typeof questionResponseSchema>;

View file

@ -0,0 +1,14 @@
import { isValid, ulid } from 'ulid';
export const ulidRegex = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
export function parseOrGenerateRequestId(headerValue: string | undefined): string {
if (typeof headerValue === 'string' && isValid(headerValue)) {
return headerValue;
}
return ulid();
}
export function isUlid(value: string): boolean {
return isValid(value);
}

View file

@ -0,0 +1,43 @@
/**
* GET /v1/sessions/{session_id}/approvals?status=pending
* Reply: { items: ApprovalRequest[] }
*
* POST /v1/sessions/{session_id}/approvals/{approval_id}
* Body: ApprovalResponse (decision + optional scope/feedback/selected_label)
* Reply: ApprovalResolveResult { resolved: true, resolved_at }
*
* **Error codes** (REST.md §3.6):
* - 40001 (validation.failed)
* - 40404 (approval.not_found)
* - 40902 (approval.already_resolved) custom envelope w/ data:{resolved:false}
* - 41001 (approval.expired)
*/
import { z } from 'zod';
import { approvalRequestSchema, approvalResponseSchema } from '../approval';
import { isoDateTimeSchema } from '../time';
export const listPendingApprovalsQuerySchema = z.object({
status: z.literal('pending'),
});
export type ListPendingApprovalsQuery = z.infer<typeof listPendingApprovalsQuerySchema>;
export const listPendingApprovalsResponseSchema = z.object({
items: z.array(approvalRequestSchema),
});
export type ListPendingApprovalsResponse = z.infer<typeof listPendingApprovalsResponseSchema>;
export const approvalResolveRequestSchema = approvalResponseSchema;
export type ApprovalResolveRequest = z.infer<typeof approvalResolveRequestSchema>;
export const approvalResolveResultSchema = z.object({
resolved: z.literal(true),
resolved_at: isoDateTimeSchema,
});
export type ApprovalResolveResult = z.infer<typeof approvalResolveResultSchema>;
export const approvalAlreadyResolvedDataSchema = z.object({
resolved: z.literal(false),
});
export type ApprovalAlreadyResolvedData = z.infer<typeof approvalAlreadyResolvedDataSchema>;

View file

@ -0,0 +1,32 @@
/**
* GET /v1/auth
* Reply: AuthSummary {
* ready,
* providers_count,
* default_model,
* managed_provider
* }
*/
import { z } from 'zod';
export const managedProviderStatusSchema = z.enum([
'authenticated',
'expired',
'revoked',
'unauthenticated',
]);
export type ManagedProviderStatus = z.infer<typeof managedProviderStatusSchema>;
export const managedProviderSummarySchema = z.object({
name: z.string().min(1),
status: managedProviderStatusSchema,
});
export type ManagedProviderSummary = z.infer<typeof managedProviderSummarySchema>;
export const authSummarySchema = z.object({
ready: z.boolean(),
providers_count: z.number().int().nonnegative(),
default_model: z.string().nullable(),
managed_provider: managedProviderSummarySchema.nullable(),
});
export type AuthSummary = z.infer<typeof authSummarySchema>;

View file

@ -0,0 +1,36 @@
/**
* POST /v1/files
* Request: multipart/form-data with `file` (binary), `name`
* (optional override), `expires_in_sec` (optional).
* Response data: `FileMeta` (full envelope).
* Errors: 41301 (>50MB).
*
* GET /v1/files/{file_id}
* Response: binary stream or envelope (40407 / 41003).
*
* DELETE /v1/files/{file_id}
* Response data: `{deleted: true}` (envelope-wrapped).
* Errors: 40407.
*/
import { z } from 'zod';
import { fileMetaSchema } from '../file';
export const uploadFileResponseSchema = fileMetaSchema;
export type UploadFileResponse = z.infer<typeof uploadFileResponseSchema>;
export const getFileParamSchema = z.object({
file_id: z.string().min(1),
});
export type GetFileParam = z.infer<typeof getFileParamSchema>;
export const deleteFileParamSchema = z.object({
file_id: z.string().min(1),
});
export type DeleteFileParam = z.infer<typeof deleteFileParamSchema>;
export const deleteFileResponseSchema = z.object({
deleted: z.literal(true),
});
export type DeleteFileResponse = z.infer<typeof deleteFileResponseSchema>;

View file

@ -0,0 +1,207 @@
/**
* POST /v1/sessions/{sid}/fs:list
* Body: FsListRequest
* Response data: FsListResponse { items, children_by_path?, truncated }
* Errors: 40401, 40409, 41304, 41303
*
* POST /v1/sessions/{sid}/fs:read
* Body: FsReadRequest
* Response data: FsReadResponse
* Errors: 40401, 40409, 40906, 40907, 41302, 41304
* POST /v1/sessions/{sid}/fs:list_many
* Body: FsListManyRequest (paths[] up to 100)
* Response data: FsListManyResponse { results, truncated_paths?,
* partial_errors? }
*
* POST /v1/sessions/{sid}/fs:stat
* Body: FsStatRequest
* Response data: FsEntry
* Errors: 40401, 40409, 41304
*
* POST /v1/sessions/{sid}/fs:stat_many
* Body: FsStatManyRequest (paths[] up to 1000)
* Response data: FsStatManyResponse { entries: { [path]: FsEntry | null } }
* Per-path failures land as `null` (REST.md §3.9 line 524); only
* path-safety (41304) fails the whole call.
* POST /v1/sessions/{sid}/fs:search
* Body: FsSearchRequest { query, limit?, include_globs?, exclude_globs?,
* follow_gitignore? }
* Response data: FsSearchResponse { items, truncated }
* Errors: 40401, 41303, 41304
*
* POST /v1/sessions/{sid}/fs:grep
* Body: FsGrepRequest
* Response data: FsGrepResponse { files, files_scanned, truncated,
* elapsed_ms }
* Errors: 40401, 41303, 41304, 41305 (>30s grep timeout)
* POST /v1/sessions/{sid}/fs:git_status
* Body: FsGitStatusRequest { paths? }
* Response data: FsGitStatusResponse { branch, ahead, behind, entries }
* Errors: 40401, 40908 (not a git repo), 41304
*
* GET /v1/sessions/{sid}/fs/{path}:download
* Response: binary stream or envelope (40401 / 40409 / 41304)
*/
import { z } from 'zod';
import {
fsEntrySchema,
fsGitStatusSchema,
fsGrepFileHitSchema,
fsSearchHitSchema,
} from '../fs';
export const fsListSortSchema = z.enum([
'type_first',
'name_asc',
'name_desc',
'mtime_desc',
'size_desc',
]);
export type FsListSort = z.infer<typeof fsListSortSchema>;
export const fsListRequestSchema = z.object({
path: z.string().default('.'),
depth: z.number().int().min(1).max(10).default(1),
limit: z.number().int().min(1).max(1000).default(200),
show_hidden: z.boolean().default(false),
follow_gitignore: z.boolean().default(true),
exclude_globs: z.array(z.string()).optional(),
sort: fsListSortSchema.default('type_first'),
include_git_status: z.boolean().default(false),
});
export type FsListRequest = z.infer<typeof fsListRequestSchema>;
export const fsListResponseSchema = z.object({
items: z.array(fsEntrySchema),
children_by_path: z.record(z.string(), z.array(fsEntrySchema)).optional(),
truncated: z.boolean(),
});
export type FsListResponse = z.infer<typeof fsListResponseSchema>;
export const fsReadEncodingRequestSchema = z.enum(['auto', 'utf-8', 'base64']);
export const fsReadEncodingResponseSchema = z.enum(['utf-8', 'base64']);
export type FsReadEncoding = z.infer<typeof fsReadEncodingResponseSchema>;
export const fsReadRequestSchema = z.object({
path: z.string().min(1),
offset: z.number().int().nonnegative().default(0),
length: z.number().int().min(1).max(10_485_760).default(1_048_576),
encoding: fsReadEncodingRequestSchema.default('auto'),
});
export type FsReadRequest = z.infer<typeof fsReadRequestSchema>;
export const fsReadResponseSchema = z.object({
path: z.string(),
content: z.string(),
encoding: fsReadEncodingResponseSchema,
size: z.number().int().nonnegative(),
truncated: z.boolean(),
etag: z.string(),
mime: z.string(),
language_id: z.string().optional(),
line_count: z.number().int().nonnegative().optional(),
is_binary: z.boolean(),
});
export type FsReadResponse = z.infer<typeof fsReadResponseSchema>;
export const fsListManyRequestSchema = z.object({
paths: z.array(z.string().min(1)).min(1).max(100),
depth: z.number().int().min(1).max(10).default(1),
limit: z.number().int().min(1).max(1000).default(200),
show_hidden: z.boolean().default(false),
follow_gitignore: z.boolean().default(true),
exclude_globs: z.array(z.string()).optional(),
sort: fsListSortSchema.default('type_first'),
include_git_status: z.boolean().default(false),
});
export type FsListManyRequest = z.infer<typeof fsListManyRequestSchema>;
export const fsListManyPartialErrorSchema = z.object({
code: z.number().int(),
msg: z.string(),
});
export type FsListManyPartialError = z.infer<typeof fsListManyPartialErrorSchema>;
export const fsListManyResponseSchema = z.object({
results: z.record(z.string(), z.array(fsEntrySchema)),
truncated_paths: z.array(z.string()).optional(),
partial_errors: z.record(z.string(), fsListManyPartialErrorSchema).optional(),
});
export type FsListManyResponse = z.infer<typeof fsListManyResponseSchema>;
export const fsStatRequestSchema = z.object({
path: z.string().min(1),
});
export type FsStatRequest = z.infer<typeof fsStatRequestSchema>;
export const fsStatResponseSchema = fsEntrySchema;
export type FsStatResponse = z.infer<typeof fsStatResponseSchema>;
export const fsStatManyRequestSchema = z.object({
paths: z.array(z.string().min(1)).min(1).max(1000),
});
export type FsStatManyRequest = z.infer<typeof fsStatManyRequestSchema>;
export const fsStatManyResponseSchema = z.object({
entries: z.record(z.string(), fsEntrySchema.nullable()),
});
export type FsStatManyResponse = z.infer<typeof fsStatManyResponseSchema>;
export const fsSearchRequestSchema = z.object({
query: z.string().min(1),
limit: z.number().int().min(1).max(200).default(50),
include_globs: z.array(z.string()).optional(),
exclude_globs: z.array(z.string()).optional(),
follow_gitignore: z.boolean().default(true),
});
export type FsSearchRequest = z.infer<typeof fsSearchRequestSchema>;
export const fsSearchResponseSchema = z.object({
items: z.array(fsSearchHitSchema),
truncated: z.boolean(),
});
export type FsSearchResponse = z.infer<typeof fsSearchResponseSchema>;
export const fsGrepRequestSchema = z.object({
pattern: z.string().min(1),
regex: z.boolean().default(false),
case_sensitive: z.boolean().default(true),
include_globs: z.array(z.string()).optional(),
exclude_globs: z.array(z.string()).optional(),
follow_gitignore: z.boolean().default(true),
max_files: z.number().int().min(1).max(10_000).default(200),
max_matches_per_file: z.number().int().min(1).max(10_000).default(50),
max_total_matches: z.number().int().min(1).max(100_000).default(5000),
context_lines: z.number().int().min(0).max(10).default(2),
});
export type FsGrepRequest = z.infer<typeof fsGrepRequestSchema>;
export const fsGrepResponseSchema = z.object({
files: z.array(fsGrepFileHitSchema),
files_scanned: z.number().int().nonnegative(),
truncated: z.boolean(),
elapsed_ms: z.number().int().nonnegative(),
});
export type FsGrepResponse = z.infer<typeof fsGrepResponseSchema>;
export const fsGitStatusRequestSchema = z.object({
paths: z.array(z.string().min(1)).optional(),
});
export type FsGitStatusRequest = z.infer<typeof fsGitStatusRequestSchema>;
export const fsGitStatusResponseSchema = z.object({
branch: z.string(),
ahead: z.number().int().nonnegative(),
behind: z.number().int().nonnegative(),
entries: z.record(z.string(), fsGitStatusSchema),
});
export type FsGitStatusResponse = z.infer<typeof fsGitStatusResponseSchema>;
export const fsDownloadParamsSchema = z.object({
path: z.string().min(1),
range: z.string().optional(),
if_none_match: z.string().optional(),
});
export type FsDownloadParams = z.infer<typeof fsDownloadParamsSchema>;

View file

@ -0,0 +1,41 @@
/**
* GET /v1/fs:browse?path=<abs-path>
* Reply: FsBrowseResponse
*
* GET /v1/fs:home
* Reply: FsHomeResponse
*
* Errors:
* - 40001 validation.failed (path is not absolute)
* - 40409 fs.path_not_found (ENOENT / ENOTDIR)
* - 40411 fs.permission_denied (EACCES)
*/
import { z } from 'zod';
export const fsBrowseQuerySchema = z.object({
path: z.string().min(1).optional(),
});
export type FsBrowseQuery = z.infer<typeof fsBrowseQuerySchema>;
export const fsBrowseEntrySchema = z.object({
name: z.string().min(1),
path: z.string().min(1),
is_dir: z.literal(true),
is_git_repo: z.boolean(),
branch: z.string().optional(),
});
export type FsBrowseEntry = z.infer<typeof fsBrowseEntrySchema>;
export const fsBrowseResponseSchema = z.object({
path: z.string().min(1),
parent: z.string().min(1).nullable(),
entries: z.array(fsBrowseEntrySchema),
});
export type FsBrowseResponse = z.infer<typeof fsBrowseResponseSchema>;
export const fsHomeResponseSchema = z.object({
home: z.string().min(1),
recent_roots: z.array(z.string().min(1)),
});
export type FsHomeResponse = z.infer<typeof fsHomeResponseSchema>;

View file

@ -0,0 +1,31 @@
/**
* GET /v1/sessions/{session_id}/messages
* Query: cursorQuery + role
* Default page_size=50; max 100 (per SCHEMAS §1.3).
* Response data: Page<Message>
*
* GET /v1/sessions/{session_id}/messages/{message_id}
* Response data: Message
* Errors: 40401 (session.not_found) + 40403 (message.not_found)
*/
import { z } from 'zod';
import { messageRoleSchema, messageSchema } from '../message';
import { cursorQuerySchema } from '../pagination';
export const listMessagesQuerySchema = cursorQuerySchema.and(
z.object({
role: messageRoleSchema.optional(),
}),
);
export type ListMessagesQuery = z.infer<typeof listMessagesQuerySchema>;
export const listMessagesResponseSchema = z.object({
items: z.array(messageSchema),
has_more: z.boolean(),
});
export type ListMessagesResponse = z.infer<typeof listMessagesResponseSchema>;
export const getMessageResponseSchema = messageSchema;
export type GetMessageResponse = z.infer<typeof getMessageResponseSchema>;

View file

@ -0,0 +1,31 @@
/**
* GET /v1/meta
* Reply: MetaResponse {
* daemon_version,
* capabilities,
* daemon_id,
* started_at
* }
*/
import { z } from 'zod';
import { isoDateTimeSchema } from '../time';
export const metaCapabilitiesSchema = z.object({
websocket: z.literal(true),
file_upload: z.literal(true),
fs_query: z.literal(true),
mcp: z.literal(true),
background_tasks: z.literal(true),
});
export type MetaCapabilities = z.infer<typeof metaCapabilitiesSchema>;
export const metaResponseSchema = z.object({
daemon_version: z.string().min(1),
capabilities: metaCapabilitiesSchema,
daemon_id: z.string().min(1),
started_at: isoDateTimeSchema,
});
export type MetaResponse = z.infer<typeof metaResponseSchema>;

View file

@ -0,0 +1,25 @@
import { z } from 'zod';
import {
modelCatalogItemSchema,
providerCatalogItemSchema,
} from '../modelCatalog';
export const listModelsResponseSchema = z.object({
items: z.array(modelCatalogItemSchema),
});
export type ListModelsResponse = z.infer<typeof listModelsResponseSchema>;
export const listProvidersResponseSchema = z.object({
items: z.array(providerCatalogItemSchema),
});
export type ListProvidersResponse = z.infer<typeof listProvidersResponseSchema>;
export const getProviderResponseSchema = providerCatalogItemSchema;
export type GetProviderResponse = z.infer<typeof getProviderResponseSchema>;
export const setDefaultModelResponseSchema = z.object({
default_model: z.string().min(1),
model: modelCatalogItemSchema,
});
export type SetDefaultModelResponse = z.infer<typeof setDefaultModelResponseSchema>;

View file

@ -0,0 +1,73 @@
/**
* POST /v1/oauth/login body: { provider? } data: OAuthFlowStart
* GET /v1/oauth/login query: { provider? } data: OAuthFlowStatus | null
* DELETE /v1/oauth/login query: { provider? } data: { cancelled, status }
* POST /v1/oauth/logout body: { provider? } data: { logged_out, provider }
*/
import { z } from 'zod';
import { isoDateTimeSchema } from '../time';
export const oauthFlowStatusEnum = z.enum([
'pending',
'authenticated',
'denied',
'expired',
'cancelled',
]);
export type OAuthFlowStatus = z.infer<typeof oauthFlowStatusEnum>;
export const oauthLoginStartRequestSchema = z.object({
provider: z.string().min(1).optional(),
});
export type OAuthLoginStartRequest = z.infer<typeof oauthLoginStartRequestSchema>;
export const oauthFlowStartSchema = z.object({
flow_id: z.string().min(1),
provider: z.string().min(1),
verification_uri: z.string().url(),
verification_uri_complete: z.string().url(),
user_code: z.string().min(1),
expires_in: z.number().int().positive(),
interval: z.number().int().positive(),
status: z.literal('pending'),
expires_at: isoDateTimeSchema,
});
export type OAuthFlowStart = z.infer<typeof oauthFlowStartSchema>;
export const oauthFlowSnapshotSchema = z.object({
flow_id: z.string().min(1),
provider: z.string().min(1),
status: oauthFlowStatusEnum,
verification_uri: z.string().url(),
verification_uri_complete: z.string().url(),
user_code: z.string().min(1),
expires_in: z.number().int().positive(),
expires_at: isoDateTimeSchema,
interval: z.number().int().positive(),
resolved_at: isoDateTimeSchema.optional(),
error_message: z.string().optional(),
});
export type OAuthFlowSnapshot = z.infer<typeof oauthFlowSnapshotSchema>;
export const oauthLoginQuerySchema = z.object({
provider: z.string().min(1).optional(),
});
export type OAuthLoginQuery = z.infer<typeof oauthLoginQuerySchema>;
export const oauthLoginCancelResponseSchema = z.object({
cancelled: z.boolean(),
status: oauthFlowStatusEnum,
});
export type OAuthLoginCancelResponse = z.infer<typeof oauthLoginCancelResponseSchema>;
export const oauthLogoutRequestSchema = z.object({
provider: z.string().min(1).optional(),
});
export type OAuthLogoutRequest = z.infer<typeof oauthLogoutRequestSchema>;
export const oauthLogoutResponseSchema = z.object({
logged_out: z.literal(true),
provider: z.string().min(1),
});
export type OAuthLogoutResponse = z.infer<typeof oauthLogoutResponseSchema>;

View file

@ -0,0 +1,119 @@
/**
* POST /v1/sessions/{sid}/prompts
* Body: PromptSubmission {
* content: MessageContent[],
* metadata?: ...,
* model?: string,
* thinking?: 'off'|'low'|'medium'|'high'|'xhigh'|'max',
* permission_mode?: 'manual'|'yolo'|'auto',
* plan_mode?: boolean,
* }
* Reply: PromptSubmitResult { prompt_id, user_message_id, status, content, created_at }
* status='running' when sent immediately, status='queued' when
* another prompt is already active.
*
* GET /v1/sessions/{sid}/prompts
* Reply: { active: PromptItem | null, queued: PromptItem[] }
*
* POST /v1/sessions/{sid}/prompts/{pid}:steer
* POST /v1/sessions/{sid}/prompts:steer
* Body: { prompt_ids: string[] } for the collection route
* Reply: { steered: true, prompt_ids: string[] }
*
* POST /v1/sessions/{sid}/prompts/{pid}:abort
* Body: empty
* Reply: { aborted: true, at_seq: number } (envelope code 0)
* { aborted: false, at_seq: number } (envelope code 40903, idempotent)
*/
import { z } from 'zod';
import { messageContentSchema } from '../message';
import { isoDateTimeSchema } from '../time';
export const promptThinkingSchema = z.enum([
'off',
'low',
'medium',
'high',
'xhigh',
'max',
]);
export type PromptThinking = z.infer<typeof promptThinkingSchema>;
export const promptPermissionModeSchema = z.enum(['manual', 'yolo', 'auto']);
export type PromptPermissionMode = z.infer<typeof promptPermissionModeSchema>;
export const promptSubmissionSchema = z.object({
content: z.array(messageContentSchema).min(1),
metadata: z.record(z.string(), z.unknown()).optional(),
model: z.string().min(1).optional(),
thinking: promptThinkingSchema.optional(),
permission_mode: promptPermissionModeSchema.optional(),
plan_mode: z.boolean().optional(),
});
export type PromptSubmission = z.infer<typeof promptSubmissionSchema>;
export const promptStatusSchema = z.enum(['running', 'queued']);
export type PromptStatus = z.infer<typeof promptStatusSchema>;
export const promptItemSchema = z.object({
prompt_id: z.string().min(1),
user_message_id: z.string().min(1),
status: promptStatusSchema,
content: z.array(messageContentSchema).min(1),
created_at: isoDateTimeSchema,
});
export type PromptItem = z.infer<typeof promptItemSchema>;
export const promptListResponseSchema = z.object({
active: promptItemSchema.nullable(),
queued: z.array(promptItemSchema),
});
export type PromptListResponse = z.infer<typeof promptListResponseSchema>;
export const promptSubmitResultSchema = promptItemSchema;
export type PromptSubmitResult = z.infer<typeof promptSubmitResultSchema>;
export const promptSteerRequestSchema = z.object({
prompt_ids: z.array(z.string().min(1)).min(1),
});
export type PromptSteerRequest = z.infer<typeof promptSteerRequestSchema>;
export const promptSteerResultSchema = z.object({
steered: z.literal(true),
prompt_ids: z.array(z.string().min(1)).min(1),
});
export type PromptSteerResult = z.infer<typeof promptSteerResultSchema>;
export const promptAbortResponseSchema = z.object({
aborted: z.boolean(),
at_seq: z.number().int().nonnegative().optional(),
});
export type PromptAbortResponse = z.infer<typeof promptAbortResponseSchema>;
export interface PromptCompletedEventPayload {
readonly type: 'prompt.completed';
readonly agentId: string;
readonly sessionId: string;
readonly promptId: string;
readonly finishedAt: string;
}
export interface PromptAbortedEventPayload {
readonly type: 'prompt.aborted';
readonly agentId: string;
readonly sessionId: string;
readonly promptId: string;
readonly abortedAt: string;
}
export interface PromptSteeredEventPayload {
readonly type: 'prompt.steered';
readonly agentId: string;
readonly sessionId: string;
readonly activePromptId: string;
readonly promptIds: readonly string[];
readonly content: PromptSubmission['content'];
readonly steeredAt: string;
}

View file

@ -0,0 +1,48 @@
/**
* GET /v1/sessions/{sid}/questions?status=pending
* Reply: { items: QuestionRequest[] }
*
* POST /v1/sessions/{sid}/questions/{qid} (resolve)
* Body: QuestionResponse (answers map + method? + note?)
* Reply: QuestionResolveResult { resolved: true, resolved_at }
* Errors: 40001 / 40404 / 40902 / 41002
*
* POST /v1/sessions/{sid}/questions/{qid}:dismiss (dismiss)
* Body: empty
* Reply: envelope code: 40909 + data { dismissed: true, dismissed_at }
*/
import { z } from 'zod';
import { questionRequestSchema, questionResponseSchema } from '../question';
import { isoDateTimeSchema } from '../time';
export const listPendingQuestionsQuerySchema = z.object({
status: z.literal('pending'),
});
export type ListPendingQuestionsQuery = z.infer<typeof listPendingQuestionsQuerySchema>;
export const listPendingQuestionsResponseSchema = z.object({
items: z.array(questionRequestSchema),
});
export type ListPendingQuestionsResponse = z.infer<typeof listPendingQuestionsResponseSchema>;
export const questionResolveRequestSchema = questionResponseSchema;
export type QuestionResolveRequest = z.infer<typeof questionResolveRequestSchema>;
export const questionResolveResultSchema = z.object({
resolved: z.literal(true),
resolved_at: isoDateTimeSchema,
});
export type QuestionResolveResult = z.infer<typeof questionResolveResultSchema>;
export const questionAlreadyResolvedDataSchema = z.object({
resolved: z.literal(false),
});
export type QuestionAlreadyResolvedData = z.infer<typeof questionAlreadyResolvedDataSchema>;
export const questionDismissResultSchema = z.object({
dismissed: z.literal(true),
dismissed_at: isoDateTimeSchema,
});
export type QuestionDismissResult = z.infer<typeof questionDismissResultSchema>;

View file

@ -0,0 +1,124 @@
/**
* POST /v1/sessions body: SessionCreate data: Session
* GET /v1/sessions query: ListSessions data: Page<Session>
* GET /v1/sessions/{id} - data: Session
* GET /v1/sessions/{id}/profile - data: Session
* POST /v1/sessions/{id}/profile body: SessionUpdate data: Session
* POST /v1/sessions/{id}:fork body: SessionFork data: Session
* GET /v1/sessions/{id}/children query: ListSessions data: Page<Session>
* POST /v1/sessions/{id}/children body: SessionChild data: Session
* GET /v1/sessions/{id}/status - data: SessionStatus
* POST /v1/sessions/{id}:compact body: CompactSession data: {}
* POST /v1/sessions/{id}:undo body: UndoSession data: UndoSession
* DELETE /v1/sessions/{id} - data: { deleted: true }
*/
import { z } from 'zod';
import { messageSchema } from '../message';
import { cursorQuerySchema, pageResponseSchema } from '../pagination';
import {
sessionChildCreateSchema,
sessionCreateSchema,
sessionForkSchema,
sessionSchema,
sessionStatusSchema,
sessionUpdateSchema,
} from '../session';
export const createSessionRequestSchema = sessionCreateSchema;
export type CreateSessionRequest = z.infer<typeof createSessionRequestSchema>;
export const createSessionResponseSchema = sessionSchema;
export type CreateSessionResponse = z.infer<typeof createSessionResponseSchema>;
export const listSessionsQuerySchema = cursorQuerySchema.and(
z.object({
status: sessionStatusSchema.optional(),
}),
);
export type ListSessionsQuery = z.infer<typeof listSessionsQuerySchema>;
export const getSessionResponseSchema = sessionSchema;
export type GetSessionResponse = z.infer<typeof getSessionResponseSchema>;
export const getSessionProfileResponseSchema = sessionSchema;
export type GetSessionProfileResponse = z.infer<typeof getSessionProfileResponseSchema>;
export const updateSessionProfileRequestSchema = sessionUpdateSchema;
export type UpdateSessionProfileRequest = z.infer<typeof updateSessionProfileRequestSchema>;
export const updateSessionProfileResponseSchema = sessionSchema;
export type UpdateSessionProfileResponse = z.infer<typeof updateSessionProfileResponseSchema>;
export const updateSessionMetaRequestSchema = updateSessionProfileRequestSchema;
export type UpdateSessionMetaRequest = UpdateSessionProfileRequest;
export const updateSessionMetaResponseSchema = updateSessionProfileResponseSchema;
export type UpdateSessionMetaResponse = UpdateSessionProfileResponse;
export const updateSessionRequestSchema = sessionUpdateSchema;
export type UpdateSessionRequest = z.infer<typeof updateSessionRequestSchema>;
export const updateSessionResponseSchema = sessionSchema;
export type UpdateSessionResponse = z.infer<typeof updateSessionResponseSchema>;
export const forkSessionRequestSchema = sessionForkSchema;
export type ForkSessionRequest = z.infer<typeof forkSessionRequestSchema>;
export const forkSessionResponseSchema = sessionSchema;
export type ForkSessionResponse = z.infer<typeof forkSessionResponseSchema>;
export const listSessionChildrenQuerySchema = listSessionsQuerySchema;
export type ListSessionChildrenQuery = z.infer<typeof listSessionChildrenQuerySchema>;
export const listSessionChildrenResponseSchema = pageResponseSchema(sessionSchema);
export type ListSessionChildrenResponse = z.infer<typeof listSessionChildrenResponseSchema>;
export const createSessionChildRequestSchema = sessionChildCreateSchema;
export type CreateSessionChildRequest = z.infer<typeof createSessionChildRequestSchema>;
export const createSessionChildResponseSchema = sessionSchema;
export type CreateSessionChildResponse = z.infer<typeof createSessionChildResponseSchema>;
export const sessionStatusResponseSchema = z.object({
model: z.string().optional(),
thinking_level: z.string(),
permission: z.string(),
plan_mode: z.boolean(),
context_tokens: z.number().int().nonnegative(),
max_context_tokens: z.number().int().nonnegative(),
context_usage: z.number().min(0).max(1),
});
export type SessionStatusResponse = z.infer<typeof sessionStatusResponseSchema>;
export const compactSessionRequestSchema = z.preprocess(
(value) => value === undefined ? {} : value,
z.object({
instruction: z.string().optional(),
}),
);
export type CompactSessionRequest = z.infer<typeof compactSessionRequestSchema>;
export const compactSessionResponseSchema = z.object({});
export type CompactSessionResponse = z.infer<typeof compactSessionResponseSchema>;
export const undoSessionRequestSchema = z.preprocess(
(value) => value === undefined ? {} : value,
z.object({
count: z.number().int().positive().default(1),
page_size: z.number().int().min(1).max(100).optional(),
}),
);
export type UndoSessionRequest = z.infer<typeof undoSessionRequestSchema>;
export const undoSessionResponseSchema = z.object({
messages: pageResponseSchema(messageSchema),
status: sessionStatusResponseSchema,
});
export type UndoSessionResponse = z.infer<typeof undoSessionResponseSchema>;
export const deleteSessionResponseSchema = z.object({
deleted: z.literal(true),
});
export type DeleteSessionResponse = z.infer<typeof deleteSessionResponseSchema>;

View file

@ -0,0 +1,46 @@
/**
* GET /v1/sessions/{session_id}/tasks query: {status?}
* Response data: `{ items: BackgroundTask[] }`
*
* GET /v1/sessions/{session_id}/tasks/{task_id} query: {with_output?, output_bytes?}
* Response data: `BackgroundTask`
* Errors: 40406 (task.not_found)
*
* POST /v1/sessions/{session_id}/tasks/{task_id}:cancel
* Body: empty
* Response data: `{ cancelled: true }`
* Errors: 40406 (task.not_found), 40904 (task.already_finished)
*/
import { z } from 'zod';
import { backgroundTaskSchema, backgroundTaskStatusSchema } from '../task';
export const listTasksQuerySchema = z.object({
status: backgroundTaskStatusSchema.optional(),
});
export type ListTasksQuery = z.infer<typeof listTasksQuerySchema>;
export const listTasksResponseSchema = z.object({
items: z.array(backgroundTaskSchema),
});
export type ListTasksResponse = z.infer<typeof listTasksResponseSchema>;
export const getTaskQuerySchema = z.object({
with_output: z.coerce.boolean().optional(),
output_bytes: z.coerce.number().int().nonnegative().optional(),
});
export type GetTaskQuery = z.infer<typeof getTaskQuerySchema>;
export const getTaskResponseSchema = backgroundTaskSchema;
export type GetTaskResponse = z.infer<typeof getTaskResponseSchema>;
export const cancelTaskResultSchema = z.object({
cancelled: z.literal(true),
});
export type CancelTaskResult = z.infer<typeof cancelTaskResultSchema>;
export const taskAlreadyFinishedDataSchema = z.object({
cancelled: z.literal(false),
});
export type TaskAlreadyFinishedData = z.infer<typeof taskAlreadyFinishedDataSchema>;

View file

@ -0,0 +1,38 @@
/**
* GET /v1/tools
* Query: `{ session_id?: string }` when omitted returns global tool list;
* when present returns the session-effective list (REST §3.8 line 430).
* Response data: `{ tools: ToolDescriptor[] }`
*
* GET /v1/mcp/servers
* Response data: `{ servers: McpServer[] }`
*
* POST /v1/mcp/servers/{mcp_server_id}:restart
* Body: empty
* Response data: `{ restarting: true }` (REST §3.8 line 442)
* Errors: 40408 mcp.server_not_found
*/
import { z } from 'zod';
import { mcpServerSchema, toolDescriptorSchema } from '../tool';
export const listToolsQuerySchema = z.object({
session_id: z.string().min(1).optional(),
});
export type ListToolsQuery = z.infer<typeof listToolsQuerySchema>;
export const listToolsResponseSchema = z.object({
tools: z.array(toolDescriptorSchema),
});
export type ListToolsResponse = z.infer<typeof listToolsResponseSchema>;
export const listMcpServersResponseSchema = z.object({
servers: z.array(mcpServerSchema),
});
export type ListMcpServersResponse = z.infer<typeof listMcpServersResponseSchema>;
export const restartMcpServerResultSchema = z.object({
restarting: z.literal(true),
});
export type RestartMcpServerResult = z.infer<typeof restartMcpServerResultSchema>;

View file

@ -0,0 +1,47 @@
/**
* GET /v1/workspaces ListWorkspacesResponse { items }
* POST /v1/workspaces body: WorkspaceCreate Workspace
* PATCH /v1/workspaces/{workspace_id} body: WorkspaceUpdate Workspace
* DELETE /v1/workspaces/{workspace_id} { deleted: true }
*
* Errors:
* - 40001 validation.failed (root not absolute, name too long, etc.)
* - 40409 fs.path_not_found (POST /workspaces { root } where root doesn't exist)
* - 40410 workspace.not_found (PATCH/DELETE unknown id)
*/
import { z } from 'zod';
import {
workspaceCreateSchema,
workspaceIdSchema,
workspaceSchema,
workspaceUpdateSchema,
} from '../workspace';
export const listWorkspacesResponseSchema = z.object({
items: z.array(workspaceSchema),
});
export type ListWorkspacesResponse = z.infer<typeof listWorkspacesResponseSchema>;
export const createWorkspaceRequestSchema = workspaceCreateSchema;
export type CreateWorkspaceRequest = z.infer<typeof createWorkspaceRequestSchema>;
export const createWorkspaceResponseSchema = workspaceSchema;
export type CreateWorkspaceResponse = z.infer<typeof createWorkspaceResponseSchema>;
export const workspaceIdParamSchema = z.object({
workspace_id: workspaceIdSchema,
});
export type WorkspaceIdParam = z.infer<typeof workspaceIdParamSchema>;
export const updateWorkspaceRequestSchema = workspaceUpdateSchema;
export type UpdateWorkspaceRequest = z.infer<typeof updateWorkspaceRequestSchema>;
export const updateWorkspaceResponseSchema = workspaceSchema;
export type UpdateWorkspaceResponse = z.infer<typeof updateWorkspaceResponseSchema>;
export const deleteWorkspaceResponseSchema = z.object({
deleted: z.literal(true),
});
export type DeleteWorkspaceResponse = z.infer<typeof deleteWorkspaceResponseSchema>;

View file

@ -0,0 +1,133 @@
import { z } from 'zod';
import {
promptPermissionModeSchema,
promptThinkingSchema,
} from './rest/prompt';
import { isoDateTimeSchema } from './time';
import { workspaceIdSchema } from './workspace';
export const sessionStatusSchema = z.enum([
'idle',
'running',
'awaiting_approval',
'awaiting_question',
'aborted',
]);
export type SessionStatus = z.infer<typeof sessionStatusSchema>;
export const sessionUsageSchema = z.object({
input_tokens: z.number().int().nonnegative(),
output_tokens: z.number().int().nonnegative(),
cache_read_tokens: z.number().int().nonnegative(),
cache_creation_tokens: z.number().int().nonnegative(),
total_cost_usd: z.number().nonnegative(),
context_tokens: z.number().int().nonnegative(),
context_limit: z.number().int().nonnegative(),
turn_count: z.number().int().nonnegative(),
});
export type SessionUsage = z.infer<typeof sessionUsageSchema>;
export function emptySessionUsage(): SessionUsage {
return {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
total_cost_usd: 0,
context_tokens: 0,
context_limit: 0,
turn_count: 0,
};
}
export const permissionRuleMatcherSchema = z.object({
kind: z.enum(['command_prefix', 'path_glob', 'exact_input', 'always']),
value: z.string().optional(),
});
export const permissionRuleSchema = z.object({
id: z.string().min(1),
tool_name: z.string().min(1),
matcher: permissionRuleMatcherSchema.optional(),
decision: z.literal('approved'),
created_at: isoDateTimeSchema,
created_by: z.enum(['user', 'agent']),
});
export type PermissionRule = z.infer<typeof permissionRuleSchema>;
export const sessionAgentConfigSchema = z.object({
model: z.string(),
system_prompt: z.string().optional(),
tools: z.array(z.string()).optional(),
mcp_servers: z.array(z.string()).optional(),
thinking: promptThinkingSchema.optional(),
permission_mode: promptPermissionModeSchema.optional(),
plan_mode: z.boolean().optional(),
});
export type SessionAgentConfig = z.infer<typeof sessionAgentConfigSchema>;
export const sessionAgentConfigPartialSchema = sessionAgentConfigSchema.partial();
export type SessionAgentConfigPartial = z.infer<typeof sessionAgentConfigPartialSchema>;
export const sessionMetadataSchema = z
.object({
cwd: z.string().min(1),
})
.catchall(z.unknown());
export type SessionMetadata = z.infer<typeof sessionMetadataSchema>;
export const sessionSchema = z.object({
id: z.string().min(1),
workspace_id: workspaceIdSchema,
title: z.string(),
created_at: isoDateTimeSchema,
updated_at: isoDateTimeSchema,
status: sessionStatusSchema,
current_prompt_id: z.string().min(1).optional(),
metadata: sessionMetadataSchema,
agent_config: sessionAgentConfigSchema,
usage: sessionUsageSchema,
permission_rules: z.array(permissionRuleSchema),
message_count: z.number().int().nonnegative(),
last_seq: z.number().int().nonnegative(),
});
export type Session = z.infer<typeof sessionSchema>;
export const sessionCreateSchema = z.object({
title: z.string().min(1).optional(),
metadata: sessionMetadataSchema.optional(),
agent_config: sessionAgentConfigPartialSchema.optional(),
workspace_id: workspaceIdSchema.optional(),
});
export type SessionCreate = z.infer<typeof sessionCreateSchema>;
export const sessionUpdateSchema = z.object({
title: z.string().min(1).optional(),
metadata: sessionMetadataSchema.partial().optional(),
agent_config: sessionAgentConfigPartialSchema.optional(),
permission_rules: z.array(permissionRuleSchema).optional(),
});
export type SessionUpdate = z.infer<typeof sessionUpdateSchema>;
export const sessionForkSchema = z.object({
title: z.string().min(1).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
export type SessionFork = z.infer<typeof sessionForkSchema>;
export const sessionChildCreateSchema = z.object({
title: z.string().min(1).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
export type SessionChildCreate = z.infer<typeof sessionChildCreateSchema>;

View file

@ -0,0 +1,28 @@
import { z } from 'zod';
import { isoDateTimeSchema } from './time';
export const backgroundTaskKindSchema = z.enum(['subagent', 'bash', 'tool']);
export type BackgroundTaskKind = z.infer<typeof backgroundTaskKindSchema>;
export const backgroundTaskStatusSchema = z.enum([
'running',
'completed',
'failed',
'cancelled',
]);
export type BackgroundTaskStatus = z.infer<typeof backgroundTaskStatusSchema>;
export const backgroundTaskSchema = z.object({
id: z.string().min(1),
session_id: z.string().min(1),
kind: backgroundTaskKindSchema,
description: z.string(),
status: backgroundTaskStatusSchema,
created_at: isoDateTimeSchema,
started_at: isoDateTimeSchema.optional(),
completed_at: isoDateTimeSchema.optional(),
output_preview: z.string().optional(),
output_bytes: z.number().int().nonnegative().optional(),
});
export type BackgroundTask = z.infer<typeof backgroundTaskSchema>;

View file

@ -0,0 +1,29 @@
import { z } from 'zod';
const ISO_8601_REGEX =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}(?::?\d{2})?)$/;
export const isoDateTimeSchema = z
.string()
.refine((value) => ISO_8601_REGEX.test(value), {
message: 'must be an ISO 8601 datetime string',
})
.transform((value, ctx) => {
const ms = Date.parse(value);
if (Number.isNaN(ms)) {
ctx.addIssue({
code: 'custom',
message: 'invalid ISO 8601 datetime',
});
return z.NEVER;
}
return new Date(ms).toISOString();
});
export const IsoDateTime = isoDateTimeSchema;
export type IsoDateTime = string;
export function nowIsoDateTime(): IsoDateTime {
return new Date().toISOString();
}

View file

@ -0,0 +1,34 @@
import { z } from 'zod';
export const toolSourceSchema = z.enum(['builtin', 'skill', 'mcp']);
export type ToolSource = z.infer<typeof toolSourceSchema>;
export const toolDescriptorSchema = z.object({
name: z.string().min(1),
description: z.string(),
input_schema: z.unknown(),
source: toolSourceSchema,
mcp_server_id: z.string().min(1).optional(),
});
export type ToolDescriptor = z.infer<typeof toolDescriptorSchema>;
export const mcpServerStatusSchema = z.enum([
'connected',
'connecting',
'disconnected',
'error',
]);
export type McpServerStatus = z.infer<typeof mcpServerStatusSchema>;
export const mcpServerTransportSchema = z.enum(['stdio', 'http', 'sse']);
export type McpServerTransport = z.infer<typeof mcpServerTransportSchema>;
export const mcpServerSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
transport: mcpServerTransportSchema,
status: mcpServerStatusSchema,
last_error: z.string().optional(),
tool_count: z.number().int().nonnegative(),
});
export type McpServer = z.infer<typeof mcpServerSchema>;

View file

@ -0,0 +1,37 @@
import { z } from 'zod';
import { isoDateTimeSchema } from './time';
export const workspaceIdSchema = z
.string()
.regex(/^wd_[a-z0-9._-]+_[0-9a-f]{12}$/, {
message: 'workspace_id must be a wd_<slug>_<hash12> string',
});
export type WorkspaceId = z.infer<typeof workspaceIdSchema>;
export const workspaceSchema = z.object({
id: workspaceIdSchema,
root: z.string().min(1),
name: z.string().min(1).max(100),
is_git_repo: z.boolean(),
branch: z.string().nullable(),
created_at: isoDateTimeSchema,
last_opened_at: isoDateTimeSchema,
session_count: z.number().int().nonnegative(),
});
export type Workspace = z.infer<typeof workspaceSchema>;
export const workspaceCreateSchema = z.object({
root: z.string().min(1),
name: z.string().min(1).max(100).optional(),
});
export type WorkspaceCreate = z.infer<typeof workspaceCreateSchema>;
export const workspaceUpdateSchema = z.object({
name: z.string().min(1).max(100),
});
export type WorkspaceUpdate = z.infer<typeof workspaceUpdateSchema>;

View file

@ -0,0 +1,227 @@
/**
* Event: { type, seq, session_id?, timestamp, payload }
* Control: { type, id?, payload }
* Ack: { type: 'ack', id, code, msg, payload }
*/
import { z } from 'zod';
import { isoDateTimeSchema } from './time';
export const wsEventEnvelopeSchema = <T extends z.ZodTypeAny>(payload: T) =>
z.object({
type: z.string(),
seq: z.number().int().nonnegative(),
session_id: z.string().optional(),
timestamp: isoDateTimeSchema,
payload,
});
export const wsControlEnvelopeSchema = <T extends z.ZodTypeAny>(payload: T) =>
z.object({
type: z.string(),
id: z.string().optional(),
payload,
});
export const wsAckEnvelopeSchema = <T extends z.ZodTypeAny>(payload: T) =>
z.object({
type: z.literal('ack'),
id: z.string(),
code: z.number().int(),
msg: z.string(),
payload,
});
export const serverHelloPayloadSchema = z.object({
ws_connection_id: z.string(),
heartbeat_ms: z.number().int().positive(),
max_event_buffer_size: z.number().int().positive(),
capabilities: z.object({
event_batching: z.boolean(),
compression: z.boolean(),
}),
});
export const serverHelloMessageSchema = z.object({
type: z.literal('server_hello'),
timestamp: isoDateTimeSchema,
payload: serverHelloPayloadSchema,
});
export type ServerHelloMessage = z.infer<typeof serverHelloMessageSchema>;
export const clientHelloPayloadSchema = z.object({
client_id: z.string(),
subscriptions: z.array(z.string()),
last_seq_by_session: z.record(z.string(), z.number().int().nonnegative()).optional(),
});
export const clientHelloMessageSchema = z.object({
type: z.literal('client_hello'),
id: z.string(),
payload: clientHelloPayloadSchema,
});
export type ClientHelloMessage = z.infer<typeof clientHelloMessageSchema>;
export const helloAckPayloadSchema = z.object({
accepted_subscriptions: z.array(z.string()).optional(),
accepted: z.array(z.string()).optional(),
not_found: z.array(z.string()).optional(),
resync_required: z.array(z.string()),
});
export const watchFsConfigSchema = z.object({
paths: z.array(z.string()),
recursive: z.boolean().optional(),
});
export const subscribePayloadSchema = z.object({
session_ids: z.array(z.string()),
last_seq_by_session: z.record(z.string(), z.number().int().nonnegative()).optional(),
watch_fs: z.record(z.string(), watchFsConfigSchema).optional(),
});
export const subscribeMessageSchema = z.object({
type: z.literal('subscribe'),
id: z.string(),
payload: subscribePayloadSchema,
});
export type SubscribeMessage = z.infer<typeof subscribeMessageSchema>;
export const unsubscribePayloadSchema = z.object({
session_ids: z.array(z.string()),
});
export const unsubscribeMessageSchema = z.object({
type: z.literal('unsubscribe'),
id: z.string(),
payload: unsubscribePayloadSchema,
});
export type UnsubscribeMessage = z.infer<typeof unsubscribeMessageSchema>;
export const watchFsAddPayloadSchema = z.object({
session_id: z.string(),
paths: z.array(z.string()),
recursive: z.boolean().optional(),
});
export const watchFsAddMessageSchema = z.object({
type: z.literal('watch_fs_add'),
id: z.string(),
payload: watchFsAddPayloadSchema,
});
export type WatchFsAddMessage = z.infer<typeof watchFsAddMessageSchema>;
export const watchFsRemovePayloadSchema = z.object({
session_id: z.string(),
paths: z.array(z.string()),
});
export const watchFsRemoveMessageSchema = z.object({
type: z.literal('watch_fs_remove'),
id: z.string(),
payload: watchFsRemovePayloadSchema,
});
export type WatchFsRemoveMessage = z.infer<typeof watchFsRemoveMessageSchema>;
export const watchFsAckPayloadSchema = z.object({
watched_paths: z.array(z.string()).optional(),
current_count: z.number().int().nonnegative().optional(),
});
export const abortPayloadSchema = z.object({
session_id: z.string(),
prompt_id: z.string(),
});
export const abortMessageSchema = z.object({
type: z.literal('abort'),
id: z.string(),
payload: abortPayloadSchema,
});
export type AbortMessage = z.infer<typeof abortMessageSchema>;
export const abortAckPayloadSchema = z.object({
aborted: z.boolean().optional(),
at_seq: z.number().int().nonnegative().optional(),
});
export const pingPayloadSchema = z.object({
nonce: z.string(),
});
export const pingMessageSchema = z.object({
type: z.literal('ping'),
timestamp: isoDateTimeSchema,
payload: pingPayloadSchema,
});
export type PingMessage = z.infer<typeof pingMessageSchema>;
export const pongPayloadSchema = z.object({
nonce: z.string(),
});
export const pongMessageSchema = z.object({
type: z.literal('pong'),
payload: pongPayloadSchema,
});
export type PongMessage = z.infer<typeof pongMessageSchema>;
export const resyncRequiredPayloadSchema = z.object({
session_id: z.string(),
reason: z.enum(['buffer_overflow', 'session_recreated']),
current_seq: z.number().int().nonnegative(),
});
export const resyncRequiredMessageSchema = z.object({
type: z.literal('resync_required'),
timestamp: isoDateTimeSchema,
payload: resyncRequiredPayloadSchema,
});
export type ResyncRequiredMessage = z.infer<typeof resyncRequiredMessageSchema>;
export const wsErrorPayloadSchema = z.object({
code: z.number().int(),
msg: z.string(),
fatal: z.boolean(),
request_id: z.string().optional(),
details: z.unknown().optional(),
});
export const wsErrorMessageSchema = z.object({
type: z.literal('error'),
timestamp: isoDateTimeSchema,
payload: wsErrorPayloadSchema,
});
export type WsErrorMessage = z.infer<typeof wsErrorMessageSchema>;
export const clientControlMessageSchema = z.discriminatedUnion('type', [
clientHelloMessageSchema,
subscribeMessageSchema,
unsubscribeMessageSchema,
watchFsAddMessageSchema,
watchFsRemoveMessageSchema,
abortMessageSchema,
pongMessageSchema,
]);
export type ClientControlMessage = z.infer<typeof clientControlMessageSchema>;
export const serverSystemMessageSchema = z.discriminatedUnion('type', [
serverHelloMessageSchema,
pingMessageSchema,
resyncRequiredMessageSchema,
wsErrorMessageSchema,
]);
export type ServerSystemMessage = z.infer<typeof serverSystemMessageSchema>;

View file

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["ESNext"]
},
"include": ["src"]
}

View file

@ -0,0 +1,13 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
entry: ['./src/index.ts'],
format: ['esm'],
dts: false,
outDir: 'dist',
clean: true,
deps: {
alwaysBundle: [/^@moonshot-ai\//],
neverBundle: [],
},
});

View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import { rawTextPlugin } from '../../build/raw-text-plugin.mjs';
export default defineConfig({
plugins: [rawTextPlugin()],
test: {
name: 'protocol',
include: ['src/__tests__/**/*.test.ts'],
},
});

18
pnpm-lock.yaml generated
View file

@ -241,6 +241,9 @@ importers:
'@moonshot-ai/kosong':
specifier: workspace:^
version: link:../kosong
'@moonshot-ai/protocol':
specifier: workspace:^
version: link:../protocol
'@mozilla/readability':
specifier: ^0.6.0
version: 0.6.0
@ -422,6 +425,15 @@ importers:
specifier: ^4.1.4
version: 4.1.4
packages/protocol:
dependencies:
ulid:
specifier: ^3.0.1
version: 3.0.2
zod:
specifier: 'catalog:'
version: 4.3.6
packages/telemetry: {}
packages:
@ -5155,6 +5167,10 @@ packages:
uhyphen@0.2.0:
resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==}
ulid@3.0.2:
resolution: {integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==}
hasBin: true
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@ -10364,6 +10380,8 @@ snapshots:
uhyphen@0.2.0: {}
ulid@3.0.2: {}
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4