mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
95a124804e
commit
4603d8ad6e
75 changed files with 6934 additions and 496 deletions
7
.changeset/protocol-and-fork-guard.md
Normal file
7
.changeset/protocol-and-fork-guard.md
Normal 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.
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -15,3 +15,10 @@ export {
|
|||
toKimiErrorPayload,
|
||||
type KimiErrorPayload,
|
||||
} from './serialize';
|
||||
export {
|
||||
onUnexpectedError,
|
||||
resetUnexpectedErrorHandler,
|
||||
safelyCallListener,
|
||||
setUnexpectedErrorHandler,
|
||||
type UnexpectedErrorHandler,
|
||||
} from './unexpectedError';
|
||||
|
|
|
|||
73
packages/agent-core/src/errors/unexpectedError.ts
Normal file
73
packages/agent-core/src/errors/unexpectedError.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
81
packages/agent-core/test/errors/unexpectedError.test.ts
Normal file
81
packages/agent-core/test/errors/unexpectedError.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
39
packages/protocol/package.json
Normal file
39
packages/protocol/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
176
packages/protocol/src/__tests__/approval.test.ts
Normal file
176
packages/protocol/src/__tests__/approval.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
95
packages/protocol/src/__tests__/envelope.test.ts
Normal file
95
packages/protocol/src/__tests__/envelope.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
52
packages/protocol/src/__tests__/events.test.ts
Normal file
52
packages/protocol/src/__tests__/events.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
packages/protocol/src/__tests__/file.test.ts
Normal file
51
packages/protocol/src/__tests__/file.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
332
packages/protocol/src/__tests__/fs.test.ts
Normal file
332
packages/protocol/src/__tests__/fs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
148
packages/protocol/src/__tests__/message.test.ts
Normal file
148
packages/protocol/src/__tests__/message.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
70
packages/protocol/src/__tests__/model-catalog.test.ts
Normal file
70
packages/protocol/src/__tests__/model-catalog.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
75
packages/protocol/src/__tests__/pagination.test.ts
Normal file
75
packages/protocol/src/__tests__/pagination.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
287
packages/protocol/src/__tests__/question.test.ts
Normal file
287
packages/protocol/src/__tests__/question.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
61
packages/protocol/src/__tests__/request-id.test.ts
Normal file
61
packages/protocol/src/__tests__/request-id.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
94
packages/protocol/src/__tests__/rest-auth.test.ts
Normal file
94
packages/protocol/src/__tests__/rest-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
56
packages/protocol/src/__tests__/rest-file.test.ts
Normal file
56
packages/protocol/src/__tests__/rest-file.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
101
packages/protocol/src/__tests__/rest-fs-browse.test.ts
Normal file
101
packages/protocol/src/__tests__/rest-fs-browse.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
430
packages/protocol/src/__tests__/rest-fs.test.ts
Normal file
430
packages/protocol/src/__tests__/rest-fs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
78
packages/protocol/src/__tests__/rest-message.test.ts
Normal file
78
packages/protocol/src/__tests__/rest-message.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
73
packages/protocol/src/__tests__/rest-meta.test.ts
Normal file
73
packages/protocol/src/__tests__/rest-meta.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
181
packages/protocol/src/__tests__/rest-prompt.test.ts
Normal file
181
packages/protocol/src/__tests__/rest-prompt.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
422
packages/protocol/src/__tests__/rest-session.test.ts
Normal file
422
packages/protocol/src/__tests__/rest-session.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
73
packages/protocol/src/__tests__/rest-task.test.ts
Normal file
73
packages/protocol/src/__tests__/rest-task.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
72
packages/protocol/src/__tests__/rest-tool.test.ts
Normal file
72
packages/protocol/src/__tests__/rest-tool.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
150
packages/protocol/src/__tests__/rest-workspace.test.ts
Normal file
150
packages/protocol/src/__tests__/rest-workspace.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
218
packages/protocol/src/__tests__/session.test.ts
Normal file
218
packages/protocol/src/__tests__/session.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
72
packages/protocol/src/__tests__/task.test.ts
Normal file
72
packages/protocol/src/__tests__/task.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
50
packages/protocol/src/__tests__/time.test.ts
Normal file
50
packages/protocol/src/__tests__/time.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
100
packages/protocol/src/__tests__/tool.test.ts
Normal file
100
packages/protocol/src/__tests__/tool.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
317
packages/protocol/src/__tests__/ws-control.test.ts
Normal file
317
packages/protocol/src/__tests__/ws-control.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
30
packages/protocol/src/approval.ts
Normal file
30
packages/protocol/src/approval.ts
Normal 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>;
|
||||
153
packages/protocol/src/display.ts
Normal file
153
packages/protocol/src/display.ts
Normal 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>;
|
||||
26
packages/protocol/src/envelope.ts
Normal file
26
packages/protocol/src/envelope.ts
Normal 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 };
|
||||
}
|
||||
180
packages/protocol/src/error-codes.ts
Normal file
180
packages/protocol/src/error-codes.ts
Normal 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',
|
||||
};
|
||||
572
packages/protocol/src/events.ts
Normal file
572
packages/protocol/src/events.ts
Normal 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 };
|
||||
13
packages/protocol/src/file.ts
Normal file
13
packages/protocol/src/file.ts
Normal 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>;
|
||||
88
packages/protocol/src/fs.ts
Normal file
88
packages/protocol/src/fs.ts
Normal 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>;
|
||||
35
packages/protocol/src/index.ts
Normal file
35
packages/protocol/src/index.ts
Normal 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';
|
||||
84
packages/protocol/src/message.ts
Normal file
84
packages/protocol/src/message.ts
Normal 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>;
|
||||
28
packages/protocol/src/modelCatalog.ts
Normal file
28
packages/protocol/src/modelCatalog.ts
Normal 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>;
|
||||
35
packages/protocol/src/pagination.ts
Normal file
35
packages/protocol/src/pagination.ts
Normal 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;
|
||||
}
|
||||
57
packages/protocol/src/question.ts
Normal file
57
packages/protocol/src/question.ts
Normal 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>;
|
||||
14
packages/protocol/src/request-id.ts
Normal file
14
packages/protocol/src/request-id.ts
Normal 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);
|
||||
}
|
||||
43
packages/protocol/src/rest/approval.ts
Normal file
43
packages/protocol/src/rest/approval.ts
Normal 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>;
|
||||
32
packages/protocol/src/rest/auth.ts
Normal file
32
packages/protocol/src/rest/auth.ts
Normal 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>;
|
||||
36
packages/protocol/src/rest/file.ts
Normal file
36
packages/protocol/src/rest/file.ts
Normal 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>;
|
||||
207
packages/protocol/src/rest/fs.ts
Normal file
207
packages/protocol/src/rest/fs.ts
Normal 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>;
|
||||
41
packages/protocol/src/rest/fsBrowse.ts
Normal file
41
packages/protocol/src/rest/fsBrowse.ts
Normal 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>;
|
||||
31
packages/protocol/src/rest/message.ts
Normal file
31
packages/protocol/src/rest/message.ts
Normal 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>;
|
||||
31
packages/protocol/src/rest/meta.ts
Normal file
31
packages/protocol/src/rest/meta.ts
Normal 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>;
|
||||
25
packages/protocol/src/rest/modelCatalog.ts
Normal file
25
packages/protocol/src/rest/modelCatalog.ts
Normal 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>;
|
||||
73
packages/protocol/src/rest/oauth.ts
Normal file
73
packages/protocol/src/rest/oauth.ts
Normal 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>;
|
||||
119
packages/protocol/src/rest/prompt.ts
Normal file
119
packages/protocol/src/rest/prompt.ts
Normal 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;
|
||||
}
|
||||
48
packages/protocol/src/rest/question.ts
Normal file
48
packages/protocol/src/rest/question.ts
Normal 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>;
|
||||
124
packages/protocol/src/rest/session.ts
Normal file
124
packages/protocol/src/rest/session.ts
Normal 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>;
|
||||
46
packages/protocol/src/rest/task.ts
Normal file
46
packages/protocol/src/rest/task.ts
Normal 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>;
|
||||
38
packages/protocol/src/rest/tool.ts
Normal file
38
packages/protocol/src/rest/tool.ts
Normal 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>;
|
||||
47
packages/protocol/src/rest/workspace.ts
Normal file
47
packages/protocol/src/rest/workspace.ts
Normal 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>;
|
||||
133
packages/protocol/src/session.ts
Normal file
133
packages/protocol/src/session.ts
Normal 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>;
|
||||
28
packages/protocol/src/task.ts
Normal file
28
packages/protocol/src/task.ts
Normal 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>;
|
||||
29
packages/protocol/src/time.ts
Normal file
29
packages/protocol/src/time.ts
Normal 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();
|
||||
}
|
||||
34
packages/protocol/src/tool.ts
Normal file
34
packages/protocol/src/tool.ts
Normal 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>;
|
||||
37
packages/protocol/src/workspace.ts
Normal file
37
packages/protocol/src/workspace.ts
Normal 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>;
|
||||
227
packages/protocol/src/ws-control.ts
Normal file
227
packages/protocol/src/ws-control.ts
Normal 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>;
|
||||
7
packages/protocol/tsconfig.json
Normal file
7
packages/protocol/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
13
packages/protocol/tsdown.config.ts
Normal file
13
packages/protocol/tsdown.config.ts
Normal 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: [],
|
||||
},
|
||||
});
|
||||
11
packages/protocol/vitest.config.ts
Normal file
11
packages/protocol/vitest.config.ts
Normal 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
18
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue