diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml new file mode 100644 index 0000000000000000000000000000000000000000..335ab4b9e09279cf9f4b2618ef4983a08545e3cc --- /dev/null +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -0,0 +1,490 @@ +# Config Section Manifest +# +# Generated by scripts/gen-config-manifest.mts — do not edit by hand. +# Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest +# +# One [table] per registered config section, in the on-disk config.toml shape +# (snake_case keys). Un-commented assignments are registered defaults; +# commented "# field: type" lines describe the remaining schema fields. +# Values resolve as: default -> config.toml -> env overlay -> memory. + +# Index (28 sections · 2 overlay(s)) +# background src/agent/task/configSection.ts +# builtinProductSkills src/features/skill/catalog/configSection.ts +# cron src/features/cron/configSection.ts +# database src/persistence/configSection.ts +# defaultPermissionMode src/agent/permissionMode/configSection.ts +# defaultPlanMode src/features/plan/configSection.ts +# experimental src/app/flag/flag.ts +# extraAgentDirs src/workspace/workspaceAgentProfileLoader/configSection.ts +# extraSkillDirs src/features/skill/catalog/configSection.ts +# hooks src/features/externalHooks/configSection.ts +# identity src/app/agentIdentity/configSection.ts +# image src/agent/media/configSection.ts +# loopControl src/agent/loop/configSection.ts +# mcp src/app/mcpConfig/configSection.ts +# mergeAllAvailableSkills src/features/skill/catalog/configSection.ts +# modelCatalog src/app/kosongConfig/configSection.ts +# models src/app/kosongConfig/configSection.ts +# permission src/agent/permissionRules/configSection.ts +# providers src/app/kosongConfig/configSection.ts +# read src/agent/tools/os/read/configSection.ts +# secondaryModel src/session/subagent/configSection.ts +# services src/app/auth/configSection.ts +# subagent src/session/subagent/configSection.ts +# swarm src/features/swarm/configSection.ts +# task src/agent/task/configSection.ts +# thinking src/app/kosongConfig/configSection.ts +# tokenCounting src/agent/tokenCounting/configSection.ts +# tools src/agent/toolPolicy/configSection.ts +# (overlay) servicesCredentialEnvOverlay src/app/auth/configSection.ts +# (overlay) kimiModelEnvOverlay src/app/kosongConfig/envOverlay.ts + +# ########################################################################## +# background +# owner: src/agent/task/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# keep_alive_on_exit <- KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT (custom parse) +# max_running_tasks <- KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS (custom parse) +# bash_task_timeout_s <- KIMI_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S (custom parse) +# print_wait_ceiling_s <- KIMI_CODE_BACKGROUND_PRINT_WAIT_CEILING_S (custom parse) +# print_background_mode <- KIMI_CODE_BACKGROUND_PRINT_BACKGROUND_MODE (custom parse) +# print_max_turns <- KIMI_CODE_BACKGROUND_PRINT_MAX_TURNS (custom parse) +# ########################################################################## + +[background] +# max_running_tasks: integer +# keep_alive_on_exit: boolean +# bash_auto_background_on_timeout: boolean +# bash_task_timeout_s: integer +# kill_grace_period_ms: integer +# print_wait_ceiling_s: integer +# print_background_mode: "exit" | "drain" | "steer" +# print_max_turns: integer + +# ########################################################################## +# builtinProductSkills (config.toml: builtin_product_skills) +# owner: src/features/skill/catalog/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# <- KIMI_CODE_BUILTIN_PRODUCT_SKILLS (custom parse) +# ########################################################################## + +builtin_product_skills = true + +# ########################################################################## +# cron +# owner: src/features/cron/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# debug <- KIMI_CRON_DEBUG (custom parse) +# no_jitter <- KIMI_CRON_NO_JITTER (custom parse) +# no_stale <- KIMI_CRON_NO_STALE (custom parse) +# disabled <- KIMI_DISABLE_CRON (custom parse) +# manual_tick <- KIMI_CRON_MANUAL_TICK (custom parse) +# clock <- KIMI_CRON_CLOCK +# poll_interval_ms <- KIMI_CRON_POLL_INTERVAL_MS (custom parse) +# ########################################################################## + +[cron] +# (schema uses transforms; fields below come from the registered default) +debug = false +no_jitter = false +no_stale = false +disabled = false +manual_tick = false + +# ########################################################################## +# database +# owner: src/persistence/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# base <- KIMI_CODE_PERSISTENCE_MINIDB_READMODEL (custom parse) +# search <- KIMI_CODE_SEARCH_WORKER (custom parse) +# ########################################################################## + +[database] +# base: boolean +# search: boolean + +# ########################################################################## +# defaultPermissionMode (config.toml: default_permission_mode) +# owner: src/agent/permissionMode/configSection.ts +# scope: core +# ########################################################################## + +# default_permission_mode: "manual" | "auto" | "yolo" + +# ########################################################################## +# defaultPlanMode (config.toml: default_plan_mode) +# owner: src/features/plan/configSection.ts +# scope: core +# ########################################################################## + +default_plan_mode = false + +# ########################################################################## +# experimental +# owner: src/app/flag/flag.ts +# scope: core +# hooks: custom fromToml · custom toToml +# ########################################################################## + +[experimental] +# : boolean + +# ########################################################################## +# extraAgentDirs (config.toml: extra_agent_dirs) +# owner: src/workspace/workspaceAgentProfileLoader/configSection.ts +# scope: core +# ########################################################################## + +extra_agent_dirs = [] + +# ########################################################################## +# extraSkillDirs (config.toml: extra_skill_dirs) +# owner: src/features/skill/catalog/configSection.ts +# scope: core +# ########################################################################## + +extra_skill_dirs = [] + +# ########################################################################## +# hooks +# owner: src/features/externalHooks/configSection.ts +# scope: core +# hooks: custom fromToml · custom toToml +# ########################################################################## + +# one [[hooks]] table per entry: +# [[hooks]] + # event: "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest" | "PermissionResult" | "UserPromptSubmit" | "UserPromptQueued" | "TurnStarted" | "Stop" | "StopFailure" | "Interrupt" | "SessionStart" | "SessionEnd" | "SessionHeartbeat" | "SubagentStart" | "SubagentStop" | "TaskStarted" | "PreCompact" | "PostCompact" | "Notification" + # matcher: string + # command: string + # timeout: integer + +# ########################################################################## +# identity +# owner: src/app/agentIdentity/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# name <- KIMI_CODE_IDENTITY_NAME (custom parse) +# slug <- KIMI_CODE_IDENTITY_SLUG (custom parse) +# ########################################################################## + +[identity] +# name: string +# slug: string + +# ########################################################################## +# image +# owner: src/agent/media/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# max_edge_px <- KIMI_IMAGE_MAX_EDGE_PX (custom parse) +# read_byte_budget <- KIMI_IMAGE_READ_BYTE_BUDGET (custom parse) +# ########################################################################## + +[image] +# max_edge_px: integer +# read_byte_budget: integer + +# ########################################################################## +# loopControl (config.toml: loop_control) +# owner: src/agent/loop/configSection.ts +# scope: core +# hooks: custom toToml · stripEnv +# deprecations (old key is ignored + warns; rename manually): +# max_retries_per_step -> max_attempts_per_step +# max_steps_per_run -> max_steps_per_turn +# env: +# max_steps_per_turn <- KIMI_LOOP_MAX_STEPS_PER_TURN (custom parse) +# max_attempts_per_step <- KIMI_LOOP_MAX_ATTEMPTS_PER_STEP (custom parse; deprecated fallback KIMI_LOOP_MAX_RETRIES_PER_STEP) +# ########################################################################## + +[loop_control] +# max_steps_per_turn: integer +# max_attempts_per_step: integer +# max_ralph_iterations: integer +# reserved_context_size: integer +# compaction_trigger_ratio: number +# compaction_max_attempts: integer + +# ########################################################################## +# mcp +# owner: src/app/mcpConfig/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# startup_timeout_ms <- KIMI_MCP_STARTUP_TIMEOUT_MS (custom parse) +# tool_timeout_ms <- KIMI_MCP_TOOL_TIMEOUT_MS (custom parse) +# ########################################################################## + +[mcp] +# startup_timeout_ms: integer +# tool_timeout_ms: integer + +# ########################################################################## +# mergeAllAvailableSkills (config.toml: merge_all_available_skills) +# owner: src/features/skill/catalog/configSection.ts +# scope: core +# ########################################################################## + +merge_all_available_skills = true + +# ########################################################################## +# modelCatalog (config.toml: model_catalog) +# owner: src/app/kosongConfig/configSection.ts +# scope: core +# ########################################################################## + +[model_catalog] +# refresh_interval_ms: integer +# refresh_on_start: boolean + +# ########################################################################## +# models +# owner: src/app/kosongConfig/configSection.ts +# scope: core +# hooks: custom fromToml · custom toToml +# ########################################################################## + +[models] + +# one [models.""] table per entry: +# [models.""] + # provider_id: string + # base_url: string + # api_key: string + # oauth: object + # storage: "file" | "keyring" + # key: string + # oauth_host: string + # protocol: "anthropic" | "openai" | "openai_responses" | "google-genai" + # name: string + # aliases: string[] + # provider: string + # model: string + # max_context_size: integer + # max_input_size: integer + # max_output_size: integer + # capabilities: string[] + # display_name: string + # reasoning_key: string + # adaptive_thinking: boolean + # beta_api: boolean + # support_efforts: string[] + # default_effort: string + # off_effort: string + # overrides: object + # max_context_size: integer + # max_input_size: integer + # max_output_size: integer + # capabilities: string[] + # display_name: string + # reasoning_key: string + # adaptive_thinking: boolean + # support_efforts: string[] + # default_effort: string + # off_effort: string + +# ########################################################################## +# permission +# owner: src/agent/permissionRules/configSection.ts +# scope: core +# hooks: custom fromToml · custom toToml · stripEnv +# env: +# dangerous_command_guard <- KIMI_CODE_DANGEROUS_COMMAND_GUARD (custom parse) +# ########################################################################## + +[permission] +# rules: object[] — one entry per item: + # decision: "allow" | "deny" | "ask" + # scope: "turn-override" | "session-runtime" | "project" | "user" (default: "user") + # pattern: string + # reason: string +# dangerous_command_guard: boolean + +# ########################################################################## +# providers +# owner: src/app/kosongConfig/configSection.ts +# scope: core +# hooks: custom fromToml · custom toToml · stripEnv +# env: +# __kimi_env__.api_key <- KIMI_MODEL_API_KEY +# __kimi_env__.type <- KIMI_MODEL_PROVIDER_TYPE +# __kimi_env__.base_url <- KIMI_MODEL_BASE_URL +# ########################################################################## + +[providers] + +# one [providers.""] table per entry: +# [providers.""] + # model_source: "static" | "discover" | "oauth-catalog" + # base_url: string + # custom_headers: record + # default_model: string + # type: string + # api_key: string + # oauth: object + # storage: "file" | "keyring" + # key: string + # oauth_host: string + # env: record + # source: record + +# ########################################################################## +# read +# owner: src/agent/tools/os/read/configSection.ts +# scope: core +# ########################################################################## + +[read] +# default_max_chars: integer +# max_chars: integer + +# ########################################################################## +# secondaryModel (config.toml: secondary_model) +# owner: src/session/subagent/configSection.ts +# scope: core +# ########################################################################## + +[secondary_model] +# default_model: string +# models: record +# force: boolean +# model: string +# max_context_size: integer +# max_input_size: integer +# max_output_size: integer +# capabilities: string[] +# display_name: string +# reasoning_key: string +# adaptive_thinking: boolean +# support_efforts: string[] +# default_effort: string +# off_effort: string + +# ########################################################################## +# services +# owner: src/app/auth/configSection.ts +# scope: core +# hooks: custom fromToml · custom toToml · stripEnv +# env: +# moonshot_search.base_url <- KIMI_WEB_SEARCH_BASE_URL (custom parse) +# moonshot_search.api_key <- KIMI_WEB_SEARCH_API_KEY (custom parse) +# moonshot_fetch.base_url <- KIMI_WEB_FETCH_BASE_URL (custom parse) +# moonshot_fetch.api_key <- KIMI_WEB_FETCH_API_KEY (custom parse) +# ########################################################################## + +[services] + +# [services.moonshot_search] + # base_url: string + # api_key: string + # oauth: object + # storage: "file" | "keyring" + # key: string + # oauth_host: string + # custom_headers: record + +# [services.moonshot_fetch] + # base_url: string + # api_key: string + # oauth: object + # storage: "file" | "keyring" + # key: string + # oauth_host: string + # custom_headers: record + +# ########################################################################## +# subagent +# owner: src/session/subagent/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# timeout_ms <- KIMI_SUBAGENT_TIMEOUT_MS (custom parse) +# ########################################################################## + +[subagent] +timeout_ms = 7200000 + +# ########################################################################## +# swarm +# owner: src/features/swarm/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# timeout_ms <- KIMI_CODE_SWARM_TIMEOUT_MS (custom parse) +# ########################################################################## + +[swarm] +timeout_ms = 7200000 + +# ########################################################################## +# task +# owner: src/agent/task/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# keep_alive_on_exit <- KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT (custom parse) +# max_running_tasks <- KIMI_CODE_BACKGROUND_MAX_RUNNING_TASKS (custom parse) +# bash_task_timeout_s <- KIMI_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S (custom parse) +# print_wait_ceiling_s <- KIMI_CODE_BACKGROUND_PRINT_WAIT_CEILING_S (custom parse) +# print_background_mode <- KIMI_CODE_BACKGROUND_PRINT_BACKGROUND_MODE (custom parse) +# print_max_turns <- KIMI_CODE_BACKGROUND_PRINT_MAX_TURNS (custom parse) +# ########################################################################## + +[task] +# max_running_tasks: integer +# keep_alive_on_exit: boolean +# bash_auto_background_on_timeout: boolean +# bash_task_timeout_s: integer +# kill_grace_period_ms: integer +# print_wait_ceiling_s: integer +# print_background_mode: "exit" | "drain" | "steer" +# print_max_turns: integer + +# ########################################################################## +# thinking +# owner: src/app/kosongConfig/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# forced_effort <- KIMI_MODEL_THINKING_EFFORT +# ########################################################################## + +[thinking] +# enabled: boolean +# effort: string +# forced_effort: string +# keep: string + +# ########################################################################## +# tokenCounting (config.toml: token_counting) +# owner: src/agent/tokenCounting/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# strategy <- KIMI_TOKEN_COUNTING_STRATEGY (custom parse) +# ########################################################################## + +[token_counting] +strategy = "measured+estimated" + +# ########################################################################## +# tools +# owner: src/agent/toolPolicy/configSection.ts +# scope: core +# ########################################################################## + +[tools] +# enabled: string[] +# disabled: string[] diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f13f064cf9bd566864bf50b66838bc57910abd7 --- /dev/null +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -0,0 +1,1270 @@ +// App, Workspace, Session & Agent State Manifest +// +// Generated by scripts/gen-state-manifest.mts — do not edit by hand. +// Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:state-manifest +// +// Every state key registered into the App-scope IAppStateService, the +// Workspace-scope IWorkspaceStateService, the Session-scope +// ISessionStateService, or the Agent-scope IAgentStateService (see +// src/_base/state/stateRegistry.ts), collected statically from the +// `states.contributeState(...)` call sites and the replayable key chains — a +// `defineState(...).replayable(...)` key is contributed into the Agent-scope +// service by its owner service at construction, and +// carries a `// replayable · durable|transient · undoable? — folds: ...` line. +// Replayable values are excluded from snapshot()/inspect(). A key defined via +// defineState but never registered nor replayable does not appear here. Each entry shows the +// compile-time StateKey value type fully expanded inline, so the manifest is +// self-contained (no imports, no helper declarations). A named type is marked +// at its expansion site with a `/* TypeName — source/file.ts */` comment; a +// `/* TypeName — recursive (...) */ unknown` marker stops a recursive expansion. +// Lib globals (Map/Set/Record/…) are referenced as-is. Generic instantiations +// expand structurally; classes render as their public instance shape. The +// defining source file heads each group. +// +// snapshot() returns JSON-safe deep copies of these values: Maps become plain +// objects (or [key, value] entry arrays when a key is not string/number), Sets +// become arrays, bigints become strings, functions are dropped, circular +// references become '(circular)', and class instances collapse to a '(ClassName)' +// marker — the wire shape of an entry is the JSON projection of the type here. +// +// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 73 keys) +// App +// Workspace +// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts +// workspaceDirs.fileDirs src/workspace/workspaceDirs/workspaceDirsService.ts +// workspaceInstructions.current src/workspace/workspaceInstructions/workspaceInstructionsService.ts +// workspaceSkillCatalog.contributions src/features/skill/workspace/workspaceSkillCatalogService.ts +// workspaceSkillCatalog.merged src/features/skill/workspace/workspaceSkillCatalogService.ts +// workspaceTrust.trusted src/workspace/workspaceTrust/workspaceTrustService.ts +// Session +// sessionActivity.current src/session/sessionActivity/sessionActivityService.ts +// sessionActivity.folds src/session/sessionActivity/sessionActivityService.ts +// sessionLog.rootLevel src/session/sessionLog/sessionLogService.ts +// sessionMetadata.data src/session/sessionMetadata/sessionMetadataService.ts +// sessionSkillCatalog.contributions src/features/skill/session/skillCatalogService.ts +// sessionSkillCatalog.merged src/features/skill/session/skillCatalogService.ts +// sessionToolPolicy.state src/session/sessionToolPolicy/sessionToolPolicyService.ts +// workspaceContext.additionalDirs src/session/workspaceContext/workspaceContextService.ts +// workspaceContext.workDir src/session/workspaceContext/workspaceContextService.ts +// Agent +// agentPlugin.sessionStartRefreshPending src/agent/plugin/agentPluginService.ts +// agentsMdReminder.cwd src/agent/agentsMdReminder/agentsMdReminderService.ts +// agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts +// agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts +// contextMemory src/agent/contextMemory/contextOps.ts +// contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts +// externalHooks.stopHookContinuationUsed src/features/externalHooks/agent/agentExternalHooksService.ts +// fileHistory src/features/fileHistory/fileHistoryOps.ts +// fullCompaction src/agent/fullCompaction/compactionOps.ts +// fullCompaction.activeTurnId src/agent/fullCompaction/fullCompactionService.ts +// fullCompaction.compactionCountInTurn src/agent/fullCompaction/fullCompactionService.ts +// fullCompaction.consecutiveOverflowCompactions src/agent/fullCompaction/fullCompactionService.ts +// fullCompaction.lastCompactedTokenCount src/agent/fullCompaction/fullCompactionService.ts +// fullCompaction.observedMaxContextTokensByModel src/agent/fullCompaction/fullCompactionService.ts +// fullCompaction.wireRanges src/agent/fullCompaction/compactionOps.ts +// interruptionReminder src/agent/interruptionReminder/interruptionReminderOps.ts +// llm.requestTrace src/agent/llmRequester/llmRequestOps.ts +// llmRequester.emittedThinkingEffortWarnings src/agent/llmRequester/llmRequesterService.ts +// llmRequester.lastConfigLogSignature src/agent/llmRequester/llmRequesterService.ts +// llmRequester.mediaDegradedTurns src/agent/llmRequester/llmRequesterService.ts +// llmRequester.mediaStrippedTurns src/agent/llmRequester/llmRequesterService.ts +// llmRequester.turnConfigs src/agent/llmRequester/llmRequesterService.ts +// loop.disposing src/agent/loop/loopService.ts +// loop.lastRequestTraceId src/agent/loop/loopService.ts +// mcp.discovery src/agent/mcp/mcpDiscoveryOps.ts +// mcp.discoveryWritesReady src/agent/mcp/mcpService.ts +// mcp.mcpToolsByServer src/agent/mcp/mcpService.ts +// media.budgetDropped src/agent/media/mediaResolverService.ts +// media.registeredKey src/agent/media/mediaToolsRegistrar.ts +// media.resolved src/agent/media/mediaResolverService.ts +// permissionMode src/agent/permissionMode/permissionModeOps.ts +// permissionMode.configured src/agent/permissionMode/permissionModeOps.ts +// permissionMode.lastMode src/agent/permissionMode/injection/permissionModeInjection.ts +// permissionRules src/agent/permissionRules/permissionRulesOps.ts +// plan src/features/plan/planOps.ts +// plan.wasActive src/features/plan/injection/planModeInjection.ts +// pluginSessionStartSnapshot src/agent/plugin/agentPluginOps.ts +// profile src/agent/profile/profileOps.ts +// profile.activeToolNamesOverlay src/agent/profile/profileService.ts +// profile.activeTools src/agent/profile/profileOps.ts +// profile.agentsMdWarning src/agent/profile/profileService.ts +// profile.emittedPluginBudgetWarnings src/agent/profile/profileService.ts +// profile.emittedThinkingEffortWarnings src/agent/profile/profileService.ts +// profile.emittedToolPatternWarnings src/agent/profile/profileService.ts +// runtime.binding src/agent/runtimeBinding/runtimeBindingService.ts +// runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts +// shellCommand.tasks src/agent/shellCommand/shellCommandService.ts +// swarm src/features/swarm/swarmOps.ts +// task src/agent/task/taskOps.ts +// task.activeTaskReminderPending src/agent/task/taskService.ts +// task.deliveredNotificationKeys src/agent/task/taskService.ts +// task.ghosts src/agent/task/taskService.ts +// task.notificationDelivery src/agent/task/taskService.ts +// task.scheduledNotificationKeys src/agent/task/taskService.ts +// toolDedupe.activeStep src/agent/toolDedupe/toolDedupeService.ts +// toolDedupe.activeTurnId src/agent/toolDedupe/toolDedupeService.ts +// toolDedupe.callKeyByCallId src/agent/toolDedupe/toolDedupeService.ts +// toolDedupe.consecutiveCount src/agent/toolDedupe/toolDedupeService.ts +// toolDedupe.consecutiveKey src/agent/toolDedupe/toolDedupeService.ts +// toolDedupe.handoffPhase src/agent/toolDedupe/toolDedupeService.ts +// toolDedupe.originalCallIndex src/agent/toolDedupe/toolDedupeService.ts +// toolDedupe.stepCalls src/agent/toolDedupe/toolDedupeService.ts +// toolDedupe.syntheticCallIds src/agent/toolDedupe/toolDedupeService.ts +// toolDedupe.turnCallRecords src/agent/toolDedupe/toolDedupeService.ts +// toolDedupe.turnRepeatCount src/agent/toolDedupe/toolDedupeService.ts +// toolExecutor.dupTypeTurnId src/agent/toolExecutor/toolExecutorService.ts +// toolExecutor.toolCallDupTypes src/agent/toolExecutor/toolExecutorService.ts +// toolSelect.pendingLoaded src/agent/toolSelect/toolSelectService.ts +// tower src/features/tower/towerOps.ts +// tower.base src/features/tower/towerOps.ts +// tower.owner src/features/tower/towerOps.ts +// turn src/agent/loop/turnOps.ts +// userTool src/agent/userTool/userToolOps.ts + +/** App-scope keys registered into IAppStateService. */ +export interface AppStateSnapshot { +} + +export type AppStateKey = keyof AppStateSnapshot; + +/** Workspace-scope keys registered into IWorkspaceStateService. */ +export interface WorkspaceStateSnapshot { + // src/features/skill/workspace/workspaceSkillCatalogService.ts + 'workspaceSkillCatalog.contributions': Map; + 'workspaceSkillCatalog.merged': /* InMemorySkillCatalog — packages/agent-core-v2/src/features/skill/catalog/registry.ts */ { + registerBuiltinSkill: (skill: /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + }) => void; + register: (skill: /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + }, options?: { + readonly replace?: boolean; + }) => void; + recordSkipped: (skills: readonly /* SkippedSkill — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly path: string; + readonly type: string; + readonly reason: string; + }[]) => void; + addRoots: (roots: readonly string[]) => void; + getSkill: (name: string) => /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + } | undefined; + getPluginSkill: (pluginId: string, name: string) => /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + } | undefined; + renderSkillPrompt: (skill: /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + }, rawArgs: string, context?: { + readonly sessionId?: string; + }) => string; + listSkills: () => readonly /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + }[]; + listInvocableSkills: () => readonly /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + }[]; + getSkillRoots: () => readonly string[]; + getSkippedByPolicy: () => readonly /* SkippedSkill — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly path: string; + readonly type: string; + readonly reason: string; + }[]; + getKimiSkillsDescription: () => string; + getModelSkillListing: () => string; + }; + // src/workspace/workspaceDirs/workspaceDirsService.ts + 'workspaceDirs.ephemeralDirs': readonly string[]; + 'workspaceDirs.fileDirs': readonly string[]; + // src/workspace/workspaceInstructions/workspaceInstructionsService.ts + 'workspaceInstructions.current': /* WorkspaceInstructionsSnapshot — packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts */ { + readonly agentsMd: string | undefined; + readonly agentsMdWarning: string | undefined; + readonly agentsMdPaths: readonly string[] | undefined; + }; + // src/workspace/workspaceTrust/workspaceTrustService.ts + 'workspaceTrust.trusted': boolean; +} + +export type WorkspaceStateKey = keyof WorkspaceStateSnapshot; + +/** Session-scope keys registered into ISessionStateService. */ +export interface SessionStateSnapshot { + // src/features/skill/session/skillCatalogService.ts + 'sessionSkillCatalog.contributions': Map; + 'sessionSkillCatalog.merged': /* InMemorySkillCatalog — packages/agent-core-v2/src/features/skill/catalog/registry.ts */ { + registerBuiltinSkill: (skill: /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + }) => void; + register: (skill: /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + }, options?: { + readonly replace?: boolean; + }) => void; + recordSkipped: (skills: readonly /* SkippedSkill — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly path: string; + readonly type: string; + readonly reason: string; + }[]) => void; + addRoots: (roots: readonly string[]) => void; + getSkill: (name: string) => /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + } | undefined; + getPluginSkill: (pluginId: string, name: string) => /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + } | undefined; + renderSkillPrompt: (skill: /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + }, rawArgs: string, context?: { + readonly sessionId?: string; + }) => string; + listSkills: () => readonly /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + }[]; + listInvocableSkills: () => readonly /* SkillDefinition — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/features/skill/catalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + readonly productSpecific?: boolean; + readonly experimentalFlag?: string; + }[]; + getSkillRoots: () => readonly string[]; + getSkippedByPolicy: () => readonly /* SkippedSkill — packages/agent-core-v2/src/features/skill/catalog/types.ts */ { + readonly path: string; + readonly type: string; + readonly reason: string; + }[]; + getKimiSkillsDescription: () => string; + getModelSkillListing: () => string; + }; + // src/session/sessionActivity/sessionActivityService.ts + 'sessionActivity.current': /* SessionActivityState — packages/agent-core-v2/src/session/sessionActivity/sessionActivity.ts */ { + readonly busy: boolean; + readonly mainTurnActive: boolean; + readonly pendingInteraction: /* SessionPendingInteraction — packages/agent-core-v2/src/session/sessionActivity/sessionActivity.ts */ 'none' | 'approval' | 'question'; + readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; + }; + 'sessionActivity.folds': Map; + compacting: boolean; + lastTurnReason?: 'completed' | 'cancelled' | 'failed'; + }>; + // src/session/sessionLog/sessionLogService.ts + 'sessionLog.rootLevel': /* LogLevelState — packages/agent-core-v2/src/_base/log/logService.ts */ { + level: /* LogLevel — packages/agent-core-v2/src/_base/log/log.ts */ 'info' | 'off' | 'error' | 'warn' | 'debug'; + }; + // src/session/sessionMetadata/sessionMetadataService.ts + 'sessionMetadata.data': /* SessionMeta — packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts */ { + readonly id: string; + readonly version?: number; + readonly title?: string; + readonly titleKind?: 'replaceable' | 'generated' | 'custom'; + readonly lastPrompt?: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly archived: boolean; + readonly archivedAt?: number; + readonly cwd?: string; + readonly forkedFrom?: string; + readonly agents?: Readonly>; + readonly swarmItem?: string; + }>>; + readonly custom?: Record; + readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; + } | undefined; + // src/session/sessionToolPolicy/sessionToolPolicyService.ts + 'sessionToolPolicy.state': /* SessionToolPolicyState — packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts */ { + readonly disabledTools: readonly string[]; + }; + // src/session/workspaceContext/workspaceContextService.ts + 'workspaceContext.additionalDirs': string[]; + 'workspaceContext.workDir': string; +} + +export type SessionStateKey = keyof SessionStateSnapshot; + +/** Agent-scope keys registered into IAgentStateService. */ +export interface AgentStateSnapshot { + // src/agent/agentsMdReminder/agentsMdReminderService.ts + 'agentsMdReminder.cwd': string | undefined; + 'agentsMdReminder.known': Set; + 'agentsMdReminder.seeded': boolean; + // src/agent/contextMemory/contextOps.ts + // replayable · durable · undoable — folds: ContextAppendMessage, ContextAppendLoopEvent, ContextClear, ContextApplyCompaction + 'contextMemory': (/* ContextMessage — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* Message — packages/agent-core-v2/src/llm-adapter/contract/message.ts */ { + readonly role: /* Role — packages/agent-core-v2/src/human/llm/message.ts */ 'user' | 'system' | 'assistant' | 'tool'; + readonly name?: string; + readonly content: (/* ContentPart — packages/agent-core-v2/src/human/llm/message.ts */ /* TextPart — packages/agent-core-v2/src/human/llm/message.ts */ { + type: 'text'; + text: string; + } | /* ThinkPart — packages/agent-core-v2/src/human/llm/message.ts */ { + type: 'think'; + think: string; + encrypted?: string; + detailsIndex?: number; + hidden?: boolean; + } | /* ImageURLPart — packages/agent-core-v2/src/human/llm/message.ts */ { + type: 'image_url'; + imageUrl: { + url: string; + id?: string; + name?: string; + }; + } | /* AudioURLPart — packages/agent-core-v2/src/human/llm/message.ts */ { + type: 'audio_url'; + audioUrl: { + url: string; + id?: string; + }; + } | /* VideoURLPart — packages/agent-core-v2/src/human/llm/message.ts */ { + type: 'video_url'; + videoUrl: { + url: string; + id?: string; + name?: string; + }; + })[]; + readonly toolCalls: /* ToolCall — packages/agent-core-v2/src/human/llm/message.ts */ { + type: 'function'; + id: string; + name: string; + arguments: string | null; + extras?: Record; + rawId?: string; + _streamIndex?: string | number; + }[]; + readonly toolCallId?: string; + readonly partial?: boolean; + readonly tools?: readonly /* ToolDescription — packages/agent-core-v2/src/human/llm/message.ts */ { + name: string; + description: string; + parameters: Record; + deferred?: true; + }[]; + } & { + readonly id?: string; + readonly providerMessageId?: string; + readonly origin?: /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'user'; + readonly clientMetadata?: readonly Readonly>[]; + readonly skillActivations?: readonly /* BundledSkillActivation — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + readonly skillType?: string; + readonly skillPath?: string; + readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin'; + }[]; + readonly attachments?: readonly /* PromptFileAttachment — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly name: string; + readonly mediaType: string; + readonly size: number; + readonly path: string; + }[]; + } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'skill_activation'; + readonly clientMetadata?: readonly Readonly>[]; + 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?: 'project' | 'user' | 'extra' | 'builtin'; + readonly attachments?: readonly /* PromptFileAttachment — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly name: string; + readonly mediaType: string; + readonly size: number; + readonly path: string; + }[]; + } | /* PluginCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'plugin_command'; + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly commandArgs?: string; + readonly trigger: 'user-slash'; + } | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'injection'; + readonly variant: string; + readonly ownerPromptId?: string; + readonly disclosure?: unknown; + } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'shell_command'; + readonly phase: 'input' | 'output'; + readonly isError?: boolean; + } | /* CompactionSummaryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'compaction_summary'; + } | /* SystemTriggerOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'system_trigger'; + readonly name: string; + } | /* TaskOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'task'; + readonly taskId: string; + readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; + readonly notificationId: string; + } | /* CronJobOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'cron_job'; + readonly jobId: string; + readonly cron: string; + readonly recurring: boolean; + readonly coalescedCount: number; + readonly stale: boolean; + } | /* CronMissedOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'cron_missed'; + readonly count: number; + } | /* HookResultOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'hook_result'; + readonly event: string; + readonly blocked?: boolean; + } | /* RetryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly kind: 'retry'; + readonly trigger?: string; + }; + readonly isError?: boolean; + toolCallDisplays?: Record; + readonly note?: string; + })[]; + // src/agent/contextProjector/contextProjectorService.ts + 'contextProjector.lastRepairSignature': string | null; + // src/agent/fullCompaction/compactionOps.ts + // replayable · durable — folds: FullCompactionBegin, FullCompactionCancel, FullCompactionComplete + 'fullCompaction': /* CompactionState — packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts */ { + readonly phase: /* CompactionPhase — packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts */ 'completed' | 'cancelled' | 'running' | 'idle'; + }; + // replayable · durable — folds: ContextApplyCompaction, ContextClear + 'fullCompaction.wireRanges': readonly /* WireLineRange — packages/agent-core-v2/src/wire/record.ts */ { + readonly start: number; + readonly end: number; + }[]; + // src/agent/fullCompaction/fullCompactionService.ts + 'fullCompaction.activeTurnId': number | undefined; + 'fullCompaction.compactionCountInTurn': number; + 'fullCompaction.consecutiveOverflowCompactions': number; + 'fullCompaction.lastCompactedTokenCount': number | null; + 'fullCompaction.observedMaxContextTokensByModel': Map; + // src/agent/interruptionReminder/interruptionReminderOps.ts + // replayable · durable — folds: InterruptionReminderRecorded + 'interruptionReminder': null; + // src/agent/llmRequester/llmRequestOps.ts + // replayable · durable — folds: LlmToolsSnapshot, LlmRequest + 'llm.requestTrace': /* LlmRequestTraceState — packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts */ { + readonly seenToolsHashes: readonly string[]; + }; + // src/agent/llmRequester/llmRequesterService.ts + 'llmRequester.emittedThinkingEffortWarnings': Set; + 'llmRequester.lastConfigLogSignature': string | undefined; + 'llmRequester.mediaDegradedTurns': Set; + 'llmRequester.mediaStrippedTurns': Map; + 'llmRequester.turnConfigs': Map void; + }; + readonly systemPrompt: string; + }>; + // src/agent/loop/loopService.ts + 'loop.disposing': boolean; + 'loop.lastRequestTraceId': string | undefined; + // src/agent/loop/turnOps.ts + // replayable · durable — folds: ContextAppendLoopEvent, TurnPrompt, TurnSteer, ContextUndo, ContextApplyCompaction, ContextClear, TurnCancel, TurnEnded + 'turn': /* TurnModelState — packages/agent-core-v2/src/agent/loop/turnOps.ts */ { + readonly nextTurnId: number; + readonly cancelledTurnIds: readonly number[]; + readonly anchorTurnIds: readonly number[]; + readonly lastEnded?: { + readonly turnId: number; + readonly reason: 'completed' | 'cancelled' | 'failed' | 'blocked'; + readonly durationMs?: number; + }; + }; + // src/agent/mcp/mcpDiscoveryOps.ts + // replayable · durable — folds: McpToolsDiscovered + 'mcp.discovery': /* McpDiscoveryState — packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts */ { + readonly seen: readonly string[]; + }; + // src/agent/mcp/mcpService.ts + 'mcp.discoveryWritesReady': boolean; + 'mcp.mcpToolsByServer': Map; + // src/agent/media/mediaResolverService.ts + 'media.budgetDropped': Set; + 'media.resolved': Map; + // src/agent/media/mediaToolsRegistrar.ts + 'media.registeredKey': string | undefined; + // src/agent/permissionMode/injection/permissionModeInjection.ts + 'permissionMode.lastMode': 'manual' | 'yolo' | 'auto' | undefined; + // src/agent/permissionMode/permissionModeOps.ts + // replayable · durable — folds: PermissionSetMode + 'permissionMode': /* PermissionMode — packages/agent-core-v2/src/agent/permissionPolicy/types.ts */ 'manual' | 'yolo' | 'auto'; + // replayable · durable — folds: PermissionSetMode + 'permissionMode.configured': boolean; + // src/agent/permissionRules/permissionRulesOps.ts + // replayable · durable — folds: PermissionRulesAdd, PermissionRecordApprovalResult + 'permissionRules': /* PermissionRulesModelState — packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts */ { + readonly rules: readonly /* PermissionRule — packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts */ { + readonly decision: /* PermissionRuleDecision — packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts */ 'allow' | 'deny' | 'ask'; + readonly scope: /* PermissionRuleScope — packages/agent-core-v2/src/agent/permissionRules/permissionRules.ts */ 'project' | 'user' | 'turn-override' | 'session-runtime'; + readonly pattern: string; + readonly reason?: string; + }[]; + readonly sessionApprovalRulePatterns: readonly string[]; + }; + // src/agent/plugin/agentPluginOps.ts + // replayable · durable — folds: PluginSessionStartEvent + 'pluginSessionStartSnapshot': /* PluginSessionStartSnapshotState — packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts */ { + readonly initialized: boolean; + readonly content?: string; + }; + // src/agent/plugin/agentPluginService.ts + 'agentPlugin.sessionStartRefreshPending': boolean; + // src/agent/profile/profileOps.ts + // replayable · durable — folds: ProfileBind, ConfigUpdate + 'profile': /* ProfileModelState — packages/agent-core-v2/src/agent/profile/profileOps.ts */ { + readonly modelAlias?: string; + readonly profileName?: string; + readonly thinkingLevel: string; + readonly systemPrompt: string; + readonly environmentDisclosure?: /* EnvironmentDisclosureSnapshot — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { + readonly cwd: string; + }; + readonly renderGeneration: number; + readonly agentsMdPaths?: readonly string[]; + readonly disallowedTools?: readonly string[]; + readonly subagents?: readonly string[]; + }; + // replayable · durable — folds: ToolsSetActiveTools, ToolsResetActiveTools, ProfileBind + 'profile.activeTools': /* ActiveToolsState — packages/agent-core-v2/src/agent/profile/profileOps.ts */ readonly string[] | undefined; + // src/agent/profile/profileService.ts + 'profile.activeToolNamesOverlay': readonly string[] | undefined; + 'profile.agentsMdWarning': string | undefined; + 'profile.emittedPluginBudgetWarnings': Set; + 'profile.emittedThinkingEffortWarnings': Set; + 'profile.emittedToolPatternWarnings': Set; + // src/agent/runtimeBinding/runtimeBindingOps.ts + // replayable · durable — folds: RuntimeSetBinding + 'runtimeBinding': /* RuntimeBinding — packages/agent-core-v2/src/runtime/runtime.ts */ { + readonly workspaceId: string; + readonly runtimeId: string; + } | undefined; + // src/agent/runtimeBinding/runtimeBindingService.ts + 'runtime.binding': /* RuntimeBinding — packages/agent-core-v2/src/runtime/runtime.ts */ { + readonly workspaceId: string; + readonly runtimeId: string; + }; + // src/agent/shellCommand/shellCommandService.ts + 'shellCommand.tasks': Map; + // src/agent/task/taskOps.ts + // replayable · durable — folds: TaskStarted, TaskTerminated + 'task': /* TaskModelState — packages/agent-core-v2/src/agent/task/taskOps.ts */ Map; + // src/agent/task/taskService.ts + 'task.activeTaskReminderPending': boolean; + 'task.deliveredNotificationKeys': Set; + 'task.ghosts': Map; + // replayable · durable · undoable — folds: ContextAppendMessage, TaskWaitDelivered + 'task.notificationDelivery': readonly string[]; + 'task.scheduledNotificationKeys': Set; + // src/agent/toolDedupe/toolDedupeService.ts + 'toolDedupe.activeStep': number; + 'toolDedupe.activeTurnId': number | undefined; + 'toolDedupe.callKeyByCallId': Map; + 'toolDedupe.consecutiveCount': number; + 'toolDedupe.consecutiveKey': string | null; + 'toolDedupe.handoffPhase': /* HandoffPhase — packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts */ 'idle' | 'active' | 'pending' | 'done'; + 'toolDedupe.originalCallIndex': Map; + 'toolDedupe.stepCalls': string[]; + 'toolDedupe.syntheticCallIds': Set; + 'toolDedupe.turnCallRecords': Map; + 'toolDedupe.turnRepeatCount': number; + // src/agent/toolExecutor/toolExecutorService.ts + 'toolExecutor.dupTypeTurnId': number | undefined; + 'toolExecutor.toolCallDupTypes': Map; + // src/agent/toolSelect/toolSelectService.ts + 'toolSelect.pendingLoaded': Set; + // src/agent/userTool/userToolOps.ts + // replayable · durable — folds: ToolsRegisterUserTool, ToolsUnregisterUserTool + 'userTool': /* UserToolModelState — packages/agent-core-v2/src/agent/userTool/userToolOps.ts */ Map; + readonly disclosure?: 'deferred' | 'inline'; + }>; + // src/features/externalHooks/agent/agentExternalHooksService.ts + 'externalHooks.stopHookContinuationUsed': boolean; + // src/features/fileHistory/fileHistoryOps.ts + // replayable · durable — folds: FileHistoryCheckpointed, FileHistoryTracked + 'fileHistory': /* FileHistoryState — packages/agent-core-v2/src/features/fileHistory/fileHistory.ts */ { + readonly checkpoints: readonly /* FileHistoryCheckpointRecord — packages/agent-core-v2/src/features/fileHistory/fileHistory.ts */ { + readonly turnId: number; + readonly phase?: 'start' | 'end'; + readonly entries: Readonly>; + }[]; + readonly tracked: readonly string[]; + }; + // src/features/plan/injection/planModeInjection.ts + 'plan.wasActive': boolean; + // src/features/plan/planOps.ts + // replayable · durable · undoable — folds: PlanModeEnter, PlanModeCancel, PlanModeExit, PlanRevision + 'plan': /* PlanState — packages/agent-core-v2/src/features/plan/planOps.ts */ { + readonly active: boolean; + readonly id?: string; + readonly revisionCount?: Readonly>; + }; + // src/features/swarm/swarmOps.ts + // replayable · durable — folds: SwarmModeEnter, SwarmModeExit + 'swarm': 'tool' | 'task' | 'manual' | null; + // src/features/tower/towerOps.ts + // replayable · durable — folds: TowerModeEnter, TowerModeExit + 'tower': boolean; + // replayable · durable — folds: TowerModeEnter, TowerModeExit + 'tower.base': string | null; + // replayable · durable — folds: TowerModeEnter, TowerModeExit + 'tower.owner': string | undefined; +} + +export type AgentStateKey = keyof AgentStateSnapshot; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce18d2422fbd4c1a11be882aaa998dbcdba9e302 --- /dev/null +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -0,0 +1,946 @@ +// Wire Protocol Manifest +// +// Generated by scripts/gen-wire-manifest.mts — do not edit by hand. +// Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest +// +// protocol_version: "1.5" (migrations: 1.0 -> 1.1 -> 1.2 -> 1.3 -> 1.4 -> 1.5) +// +// One declaration per durable record type — an Event2 subclass declaring +// `static type` + `static durable = true` + `static schema` — drained from the +// runtime EVENT2_REGISTRY ("import = register"). Every payload declaration +// carries its record type in a `_name` field. Payload sketches use TypeScript +// type syntax; when a named type is expanded inline, its name appears as a doc +// comment (`/** ContextMessage */`). Bare type names (ContentPart, +// ContextMessage, …) refer to the real types in src/ — they are intentionally +// not resolved here. `// …` marks a capped field list. On disk (wire.jsonl) +// the journal opens with a metadata line {"type": "metadata", +// "protocol_version", "created_at"}; each record is {"type", ...payload, +// "time"} — object payloads spread at the top level. +// +// Every listed type is durable by construction — transient Event2 classes +// never enter EVENT2_REGISTRY, so there is no persisted flag. Declaration +// header lines: states (every state folding this record type on dispatch and +// replay; any state beyond the first is what the retired format listed as +// cross-reducers), blobs (the folding states whose blob codec offloads inline +// media to blob storage), owner (the source file declaring the class). + +// Index (59 record types) +// config.update profile src/agent/profile/profileOps.ts +// context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts +// context.append_message contextMemory, plan, task.notificationDelivery src/agent/contextMemory/contextEvents.ts +// context.apply_compaction contextMemory, plan, task.notificationDelivery, turn src/agent/contextMemory/contextEvents.ts +// context.clear contextMemory, plan, task.notificationDelivery, turn src/agent/contextMemory/contextEvents.ts +// context.undo contextMemory, plan, task.notificationDelivery, turn src/agent/contextMemory/contextEvents.ts +// cron.add (none) src/features/cron/cronOps.ts +// cron.cursor (none) src/features/cron/cronOps.ts +// cron.delete (none) src/features/cron/cronOps.ts +// file_history.checkpoint fileHistory src/features/fileHistory/fileHistoryOps.ts +// file_history.tracked fileHistory src/features/fileHistory/fileHistoryOps.ts +// forked (none) src/features/goal/goalOps.ts +// full_compaction.begin fullCompaction src/agent/fullCompaction/compactionOps.ts +// full_compaction.cancel fullCompaction src/agent/fullCompaction/compactionOps.ts +// full_compaction.complete fullCompaction src/agent/fullCompaction/compactionOps.ts +// goal.clear (none) src/features/goal/goalOps.ts +// goal.create (none) src/features/goal/goalOps.ts +// goal.update (none) src/features/goal/goalOps.ts +// interaction.request (none) src/agent/interaction/interactionOps.ts +// interaction.resolved (none) src/agent/interaction/interactionOps.ts +// interruptionReminder.recorded interruptionReminder src/agent/interruptionReminder/interruptionReminderOps.ts +// llm.request llm.requestTrace src/agent/llmRequester/llmRequestOps.ts +// llm.tools_snapshot llm.requestTrace src/agent/llmRequester/llmRequestOps.ts +// mcp.tools_discovered mcp.discovery src/agent/mcp/mcpDiscoveryOps.ts +// permission.record_approval_result permissionRules src/agent/permissionRules/permissionRulesOps.ts +// permission.set_mode permissionMode, permissionMode.configured src/agent/permissionMode/permissionModeOps.ts +// plan_mode.cancel plan src/features/plan/planOps.ts +// plan_mode.enter plan src/features/plan/planOps.ts +// plan_mode.exit plan src/features/plan/planOps.ts +// plan.revision plan src/features/plan/planOps.ts +// plugin.session_start pluginSessionStartSnapshot src/agent/plugin/agentPluginOps.ts +// profile.bind profile, profile.activeTools src/agent/profile/profileOps.ts +// prompt.aborted (none) src/agent/prompt/promptEvents.ts +// prompt.completed (none) src/agent/prompt/promptEvents.ts +// prompt.steered (none) src/agent/prompt/promptEvents.ts +// runtime.set_binding runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts +// swarm_mode.enter swarm src/features/swarm/swarmOps.ts +// swarm_mode.exit contextMemory, swarm src/features/swarm/swarmOps.ts +// task.started task src/agent/task/taskOps.ts +// task.terminated task src/agent/task/taskOps.ts +// task.waitDelivered task.notificationDelivery src/agent/task/taskOps.ts +// token_counting.measured (none) src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.rebased (none) src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.truncated (none) src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.turn_recorded (none) src/agent/tokenCounting/tokenCountingOps.ts +// tools.register_user_tool userTool src/agent/userTool/userToolOps.ts +// tools.reset_active_tools profile.activeTools src/agent/profile/profileOps.ts +// tools.set_active_tools profile.activeTools src/agent/profile/profileOps.ts +// tools.unregister_user_tool userTool src/agent/userTool/userToolOps.ts +// tools.update_store (none) src/features/todo/todoOps.ts +// tower_mode.enter tower, tower.base, tower.owner src/features/tower/towerOps.ts +// tower_mode.exit tower, tower.base, tower.owner src/features/tower/towerOps.ts +// turn.cancel turn src/agent/loop/turnOps.ts +// turn.ended turn src/agent/loop/turnOps.ts +// turn.prompt turn src/agent/loop/turnOps.ts +// turn.steer turn src/agent/loop/turnOps.ts +// turn.step.interrupted (none) src/agent/loop/turnEvents.ts +// turn.step.retrying (none) src/agent/loop/turnEvents.ts +// usage.record (none) src/agent/usage/usageOps.ts + +/** + * states: profile + * owner: src/agent/profile/profileOps.ts + */ +interface ConfigUpdatePayload { + _name: 'config.update'; + agentId: string; + modelAlias?: string; + profileName?: string; + thinkingEffort?: ThinkingEffort; + thinkingLevel?: ThinkingEffort; + systemPrompt?: string; + /** EnvironmentDisclosureSnapshot */ + environmentDisclosure?: { + cwd: string; + }; + renderGeneration?: number; + agentsMdPaths?: string[]; + disallowedTools?: string[]; +} + +/** + * states: contextMemory, turn · blobs: contextMemory + * owner: src/agent/contextMemory/contextEvents.ts + */ +interface ContextAppendLoopEventPayload { + _name: 'context.append_loop_event'; + agentId: string; + /** LoopRecordedEvent */ + event: 'step.begin' | 'step.end' | 'content.part' | 'tool.call' | 'tool.result'; +} + +/** + * states: contextMemory, plan, task.notificationDelivery · blobs: contextMemory + * owner: src/agent/contextMemory/contextEvents.ts + */ +interface ContextAppendMessagePayload { + _name: 'context.append_message'; + agentId: string; + /** ContextMessage */ + message: { + role: Role; + name?: string; + content: ContentPart[]; + toolCalls: ToolCall[]; + toolCallId?: string; + partial?: boolean; + tools?: ToolDescription[]; + id?: string; + providerMessageId?: string; + origin?: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry' | undefined; + isError?: boolean; + toolCallDisplays?: Record; + note?: string; + }; +} + +/** + * states: contextMemory, plan, task.notificationDelivery, turn · blobs: contextMemory + * owner: src/agent/contextMemory/contextEvents.ts + * shared base: ...contextCompactionBaseShape + */ +type ContextApplyCompactionPayload = { _name: 'context.apply_compaction'; } & ({ summary: string, compactedCount: number, contextSummary?: string } | { contextSummary: string, compactedCount: number, summary?: string } | { summary: ContextMessage, count: number, compactedCount?: number }); + +/** + * states: contextMemory, plan, task.notificationDelivery, turn · blobs: contextMemory + * owner: src/agent/contextMemory/contextEvents.ts + */ +interface ContextClearPayload { + _name: 'context.clear'; + agentId: string; +} + +/** + * states: contextMemory, plan, task.notificationDelivery, turn · blobs: contextMemory + * owner: src/agent/contextMemory/contextEvents.ts + */ +interface ContextUndoPayload { + _name: 'context.undo'; + agentId: string; + count: number; +} + +/** + * states: (none) + * owner: src/features/cron/cronOps.ts + */ +interface CronAddPayload { + _name: 'cron.add'; + task: { + id: string; + cron: string; + prompt: string; + createdAt: number; + recurring?: boolean; + lastFiredAt?: number; + tags?: Record; + }; +} + +/** + * states: (none) + * owner: src/features/cron/cronOps.ts + */ +interface CronCursorPayload { + _name: 'cron.cursor'; + id: string; + lastFiredAt: number; +} + +/** + * states: (none) + * owner: src/features/cron/cronOps.ts + */ +interface CronDeletePayload { + _name: 'cron.delete'; + ids: string[]; +} + +/** + * states: fileHistory + * owner: src/features/fileHistory/fileHistoryOps.ts + */ +interface FileHistoryCheckpointPayload { + _name: 'file_history.checkpoint'; + agentId: string; + turnId: number; + phase?: 'start' | 'end'; + entries: Record; +} + +/** + * states: fileHistory + * owner: src/features/fileHistory/fileHistoryOps.ts + */ +interface FileHistoryTrackedPayload { + _name: 'file_history.tracked'; + agentId: string; + turnId: number; + path: string; + entry: { + key: string | null; + version: number; + contentHash?: string; + size?: number; + oversize?: boolean; + mtimeMs?: number; + }; +} + +/** + * states: (none) + * owner: src/features/goal/goalOps.ts + */ +interface ForkedPayload { + _name: 'forked'; + agentId: string; +} + +/** + * states: fullCompaction + * owner: src/agent/fullCompaction/compactionOps.ts + */ +interface FullCompactionBeginPayload { + _name: 'full_compaction.begin'; + agentId: string; + instruction?: string; + /** CompactionSource */ + source: 'manual' | 'auto'; +} + +/** + * states: fullCompaction + * owner: src/agent/fullCompaction/compactionOps.ts + */ +interface FullCompactionCancelPayload { + _name: 'full_compaction.cancel'; + agentId: string; +} + +/** + * states: fullCompaction + * owner: src/agent/fullCompaction/compactionOps.ts + */ +interface FullCompactionCompletePayload { + _name: 'full_compaction.complete'; + agentId: string; +} + +/** + * states: (none) + * owner: src/features/goal/goalOps.ts + */ +interface GoalClearPayload { + _name: 'goal.clear'; + agentId: string; +} + +/** + * states: (none) + * owner: src/features/goal/goalOps.ts + */ +interface GoalCreatePayload { + _name: 'goal.create'; + agentId: string; + goalId: string; + objective: string; + completionCriterion?: string; + wallClockResumedAt?: number; + status?: 'active' | 'paused' | 'blocked' | 'complete'; + actor?: 'user' | 'model' | 'runtime' | 'system'; + budgetLimits?: { + tokenBudget?: number; + turnBudget?: number; + wallClockBudgetMs?: number; + }; +} + +/** + * states: (none) + * owner: src/features/goal/goalOps.ts + */ +interface GoalUpdatePayload { + _name: 'goal.update'; + agentId: string; + goalId?: string; + status?: 'active' | 'paused' | 'blocked' | 'complete'; + reason?: string; + turnsUsed?: number; + tokensUsed?: number; + wallClockMs?: number; + wallClockResumedAt?: number; + budgetLimits?: { + tokenBudget?: number; + turnBudget?: number; + wallClockBudgetMs?: number; + }; + actor?: 'user' | 'model' | 'runtime' | 'system'; +} + +/** + * states: (none) + * owner: src/agent/interaction/interactionOps.ts + */ +interface InteractionRequestPayload { + _name: 'interaction.request'; + agentId: string; + id: string; + kind: 'approval' | 'question' | 'user_tool'; + toolCallId?: string; + request: any; +} + +/** + * states: (none) + * owner: src/agent/interaction/interactionOps.ts + */ +interface InteractionResolvedPayload { + _name: 'interaction.resolved'; + agentId: string; + id: string; + response: any; +} + +/** + * states: interruptionReminder + * owner: src/agent/interruptionReminder/interruptionReminderOps.ts + */ +interface InterruptionReminderRecordedPayload { + _name: 'interruptionReminder.recorded'; + agentId: string; + turnId: number; +} + +/** + * states: llm.requestTrace + * owner: src/agent/llmRequester/llmRequestOps.ts + */ +interface LlmRequestPayload { + _name: 'llm.request'; + agentId: string; + kind: 'loop' | 'compaction'; + provider: string; + model: string; + modelAlias?: string; + thinkingEffort?: ThinkingEffort; + thinkingKeep?: string; + temperature?: number; + topP?: number; + maxTokens?: number; + betaApi?: boolean; + toolSelect: boolean; + systemPromptHash: string; + systemPrompt?: string; + toolsHash: string; + messageCount: number; + turnStep?: string; + attempt?: string; + projection?: 'strict' | 'media-degraded' | 'media-stripped' | 'strict-media-degraded' | 'strict-media-stripped'; + droppedCount?: number; +} + +/** + * states: llm.requestTrace + * owner: src/agent/llmRequester/llmRequestOps.ts + */ +interface LlmToolsSnapshotPayload { + _name: 'llm.tools_snapshot'; + agentId: string; + hash: string; + tools: { + name: string; + description: string; + parameters: Record; + }[]; +} + +/** + * states: mcp.discovery + * owner: src/agent/mcp/mcpDiscoveryOps.ts + */ +interface McpToolsDiscoveredPayload { + _name: 'mcp.tools_discovered'; + agentId: string; + serverName: string; + hash: string; + tools: readonly MCPToolDefinition[]; + enabledNames: string[]; + collisions?: { + qualified: string; + toolName: string; + collidesWith: { kind: 'same_server', toolName: string } | { kind: 'other_server', serverName: string }; + }[]; +} + +/** + * states: permissionRules + * owner: src/agent/permissionRules/permissionRulesOps.ts + */ +interface PermissionRecordApprovalResultPayload { + _name: 'permission.record_approval_result'; + agentId: string; + turnId: number; + toolCallId: string; + toolName: string; + action: string; + sessionApprovalRule?: string; + result: PermissionApprovalResultRecord['result']; +} + +/** + * states: permissionMode, permissionMode.configured + * owner: src/agent/permissionMode/permissionModeOps.ts + */ +interface PermissionSetModePayload { + _name: 'permission.set_mode'; + agentId: string; + /** PermissionMode */ + mode: 'manual' | 'yolo' | 'auto'; +} + +/** + * states: plan + * owner: src/features/plan/planOps.ts + */ +interface PlanModeCancelPayload { + _name: 'plan_mode.cancel'; + agentId: string; + id?: string; +} + +/** + * states: plan + * owner: src/features/plan/planOps.ts + */ +interface PlanModeEnterPayload { + _name: 'plan_mode.enter'; + agentId: string; + id: string; +} + +/** + * states: plan + * owner: src/features/plan/planOps.ts + */ +interface PlanModeExitPayload { + _name: 'plan_mode.exit'; + agentId: string; + id?: string; +} + +/** + * states: plan + * owner: src/features/plan/planOps.ts + */ +interface PlanRevisionPayload { + _name: 'plan.revision'; + agentId: string; + id: string; + version: number; + key: string; + sha256: string; + bytes: number; +} + +/** + * states: pluginSessionStartSnapshot + * owner: src/agent/plugin/agentPluginOps.ts + */ +interface PluginSessionStartPayload { + _name: 'plugin.session_start'; + agentId: string; + content: string | null; +} + +/** + * states: profile, profile.activeTools + * owner: src/agent/profile/profileOps.ts + */ +interface ProfileBindPayload { + _name: 'profile.bind'; + agentId: string; + modelAlias?: string; + profileName?: string; + thinkingEffort: ThinkingEffort; + systemPrompt: string; + /** EnvironmentDisclosureSnapshot */ + environmentDisclosure?: { + cwd: string; + }; + renderGeneration?: number; + agentsMdPaths?: string[]; + activeToolNames?: string[]; + disallowedTools: string[]; + subagents?: string[]; +} + +/** + * states: (none) + * owner: src/agent/prompt/promptEvents.ts + */ +interface PromptAbortedPayload { + _name: 'prompt.aborted'; + agentId: string; + promptId: string; + abortedAt: string; +} + +/** + * states: (none) + * owner: src/agent/prompt/promptEvents.ts + */ +interface PromptCompletedPayload { + _name: 'prompt.completed'; + agentId: string; + promptId: string; + finishedAt: string; + reason: 'completed' | 'failed' | 'blocked'; +} + +/** + * states: (none) + * owner: src/agent/prompt/promptEvents.ts + */ +interface PromptSteeredPayload { + _name: 'prompt.steered'; + agentId: string; + activePromptId: string; + promptIds: string[]; + content: ContentPart[]; + steeredAt: string; +} + +/** + * states: runtimeBinding + * owner: src/agent/runtimeBinding/runtimeBindingOps.ts + */ +interface RuntimeSetBindingPayload { + _name: 'runtime.set_binding'; + agentId: string; + workspaceId: string; + runtimeId: string; +} + +/** + * states: swarm + * owner: src/features/swarm/swarmOps.ts + */ +interface SwarmModeEnterPayload { + _name: 'swarm_mode.enter'; + agentId: string; + /** SwarmModeTrigger */ + trigger: 'manual' | 'task' | 'tool'; +} + +/** + * states: contextMemory, swarm · blobs: contextMemory + * owner: src/features/swarm/swarmOps.ts + */ +interface SwarmModeExitPayload { + _name: 'swarm_mode.exit'; + agentId: string; +} + +/** + * states: task + * owner: src/agent/task/taskOps.ts + */ +interface TaskStartedPayload { + _name: 'task.started'; + agentId: string; + /** AgentTaskInfo */ + info: AgentTaskInfoByKind[AgentTaskKind]; +} + +/** + * states: task + * owner: src/agent/task/taskOps.ts + */ +interface TaskTerminatedPayload { + _name: 'task.terminated'; + agentId: string; + /** AgentTaskInfo */ + info: AgentTaskInfoByKind[AgentTaskKind]; + outputTail?: string; +} + +/** + * states: task.notificationDelivery + * owner: src/agent/task/taskOps.ts + */ +interface TaskWaitDeliveredPayload { + _name: 'task.waitDelivered'; + agentId: string; + keys: string[]; +} + +/** + * states: (none) + * owner: src/agent/tokenCounting/tokenCountingOps.ts + */ +interface TokenCountingMeasuredPayload { + _name: 'token_counting.measured'; + agentId: string; + length: number; + tokens: number; +} + +/** + * states: (none) + * owner: src/agent/tokenCounting/tokenCountingOps.ts + */ +interface TokenCountingRebasedPayload { + _name: 'token_counting.rebased'; + agentId: string; + length: number; + tokens: number; + measured: boolean; +} + +/** + * states: (none) + * owner: src/agent/tokenCounting/tokenCountingOps.ts + */ +interface TokenCountingTruncatedPayload { + _name: 'token_counting.truncated'; + agentId: string; + length: number; + tokens: number; +} + +/** + * states: (none) + * owner: src/agent/tokenCounting/tokenCountingOps.ts + */ +interface TokenCountingTurnRecordedPayload { + _name: 'token_counting.turn_recorded'; + agentId: string; + length: number; + tokens: number; + turnId: number; +} + +/** + * states: userTool + * owner: src/agent/userTool/userToolOps.ts + */ +interface ToolsRegisterUserToolPayload { + _name: 'tools.register_user_tool'; + agentId: string; + name: string; + description: string; + parameters: UserToolRegistration['parameters']; + disclosure?: UserToolRegistration['disclosure']; +} + +/** + * states: profile.activeTools + * owner: src/agent/profile/profileOps.ts + */ +interface ToolsResetActiveToolsPayload { + _name: 'tools.reset_active_tools'; + agentId: string; +} + +/** + * states: profile.activeTools + * owner: src/agent/profile/profileOps.ts + */ +interface ToolsSetActiveToolsPayload { + _name: 'tools.set_active_tools'; + agentId: string; + names: string[]; +} + +/** + * states: userTool + * owner: src/agent/userTool/userToolOps.ts + */ +interface ToolsUnregisterUserToolPayload { + _name: 'tools.unregister_user_tool'; + agentId: string; + name: string; +} + +/** + * states: (none) + * owner: src/features/todo/todoOps.ts + */ +interface ToolsUpdateStorePayload { + _name: 'tools.update_store'; + agentId: string; + key: string; + value: any; +} + +/** + * states: tower, tower.base, tower.owner + * owner: src/features/tower/towerOps.ts + */ +interface TowerModeEnterPayload { + _name: 'tower_mode.enter'; + agentId: string; + sessionId?: string; + base?: string; +} + +/** + * states: tower, tower.base, tower.owner + * owner: src/features/tower/towerOps.ts + */ +interface TowerModeExitPayload { + _name: 'tower_mode.exit'; + agentId: string; +} + +/** + * states: turn + * owner: src/agent/loop/turnOps.ts + */ +interface TurnCancelPayload { + _name: 'turn.cancel'; + agentId: string; + turnId?: number; + target?: 'active' | 'queued'; + reason?: 'user_cancelled' | 'aborted'; +} + +/** + * states: turn + * owner: src/agent/loop/turnOps.ts + */ +interface TurnEndedPayload { + _name: 'turn.ended'; + agentId: string; + turnId: number; + reason: 'completed' | 'cancelled' | 'failed' | 'blocked'; + /** KimiErrorPayload */ + error?: { + code: (typeof ErrorCodes)[keyof typeof ErrorCodes]; + message: string; + name?: string; + details?: Readonly>; + retryable: boolean; + cause?: { + code: (typeof ErrorCodes)[keyof typeof ErrorCodes]; + message: string; + name?: string; + details?: Readonly>; + retryable: boolean; + cause?: { + code: (typeof ErrorCodes)[keyof typeof ErrorCodes]; + message: string; + name?: string; + details?: Readonly>; + retryable: boolean; + cause?: { + code: (typeof ErrorCodes)[keyof typeof ErrorCodes]; + message: string; + name?: string; + details?: Readonly>; + retryable: boolean; + cause?: { + code: (typeof ErrorCodes)[keyof typeof ErrorCodes]; + message: string; + name?: string; + details?: Readonly>; + retryable: boolean; + cause?: { + code: ErrorCode; + message: string; + name?: string; + details?: Readonly>; + retryable: boolean; + cause?: ErrorPayload; + }; + }; + }; + }; + }; + }; + durationMs?: number; + stopReason?: string; +} + +/** + * states: turn + * owner: src/agent/loop/turnOps.ts + */ +interface TurnPromptPayload { + _name: 'turn.prompt'; + agentId: string; + input: readonly ContentPart[]; + /** PromptOrigin */ + origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry'; + promptId?: string; + turnId?: number; +} + +/** + * states: turn + * owner: src/agent/loop/turnOps.ts + */ +interface TurnSteerPayload { + _name: 'turn.steer'; + agentId: string; + input: readonly ContentPart[]; + /** PromptOrigin */ + origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry'; +} + +/** + * states: (none) + * owner: src/agent/loop/turnEvents.ts + */ +interface TurnStepInterruptedPayload { + _name: 'turn.step.interrupted'; + agentId: string; + turnId: number; + step: number; + stepId?: string; + reason: string; + message?: string; +} + +/** + * states: (none) + * owner: src/agent/loop/turnEvents.ts + */ +interface TurnStepRetryingPayload { + _name: 'turn.step.retrying'; + agentId: string; + turnId: number; + step: number; + stepId?: string; + failedAttempt: number; + nextAttempt: number; + maxAttempts: number; + delayMs: number; + errorName: string; + errorMessage: string; + statusCode?: number; +} + +/** + * states: (none) + * owner: src/agent/usage/usageOps.ts + */ +interface UsageRecordPayload { + _name: 'usage.record'; + agentId: string; + model: string; + usage: TokenUsage; + /** UsageRecordScope */ + usageScope?: 'session' | 'turn'; +} + +/** Record type → payload sketch. */ +interface WirePayloadMap { + "config.update": ConfigUpdatePayload; + "context.append_loop_event": ContextAppendLoopEventPayload; + "context.append_message": ContextAppendMessagePayload; + "context.apply_compaction": ContextApplyCompactionPayload; + "context.clear": ContextClearPayload; + "context.undo": ContextUndoPayload; + "cron.add": CronAddPayload; + "cron.cursor": CronCursorPayload; + "cron.delete": CronDeletePayload; + "file_history.checkpoint": FileHistoryCheckpointPayload; + "file_history.tracked": FileHistoryTrackedPayload; + "forked": ForkedPayload; + "full_compaction.begin": FullCompactionBeginPayload; + "full_compaction.cancel": FullCompactionCancelPayload; + "full_compaction.complete": FullCompactionCompletePayload; + "goal.clear": GoalClearPayload; + "goal.create": GoalCreatePayload; + "goal.update": GoalUpdatePayload; + "interaction.request": InteractionRequestPayload; + "interaction.resolved": InteractionResolvedPayload; + "interruptionReminder.recorded": InterruptionReminderRecordedPayload; + "llm.request": LlmRequestPayload; + "llm.tools_snapshot": LlmToolsSnapshotPayload; + "mcp.tools_discovered": McpToolsDiscoveredPayload; + "permission.record_approval_result": PermissionRecordApprovalResultPayload; + "permission.set_mode": PermissionSetModePayload; + "plan_mode.cancel": PlanModeCancelPayload; + "plan_mode.enter": PlanModeEnterPayload; + "plan_mode.exit": PlanModeExitPayload; + "plan.revision": PlanRevisionPayload; + "plugin.session_start": PluginSessionStartPayload; + "profile.bind": ProfileBindPayload; + "prompt.aborted": PromptAbortedPayload; + "prompt.completed": PromptCompletedPayload; + "prompt.steered": PromptSteeredPayload; + "runtime.set_binding": RuntimeSetBindingPayload; + "swarm_mode.enter": SwarmModeEnterPayload; + "swarm_mode.exit": SwarmModeExitPayload; + "task.started": TaskStartedPayload; + "task.terminated": TaskTerminatedPayload; + "task.waitDelivered": TaskWaitDeliveredPayload; + "token_counting.measured": TokenCountingMeasuredPayload; + "token_counting.rebased": TokenCountingRebasedPayload; + "token_counting.truncated": TokenCountingTruncatedPayload; + "token_counting.turn_recorded": TokenCountingTurnRecordedPayload; + "tools.register_user_tool": ToolsRegisterUserToolPayload; + "tools.reset_active_tools": ToolsResetActiveToolsPayload; + "tools.set_active_tools": ToolsSetActiveToolsPayload; + "tools.unregister_user_tool": ToolsUnregisterUserToolPayload; + "tools.update_store": ToolsUpdateStorePayload; + "tower_mode.enter": TowerModeEnterPayload; + "tower_mode.exit": TowerModeExitPayload; + "turn.cancel": TurnCancelPayload; + "turn.ended": TurnEndedPayload; + "turn.prompt": TurnPromptPayload; + "turn.steer": TurnSteerPayload; + "turn.step.interrupted": TurnStepInterruptedPayload; + "turn.step.retrying": TurnStepRetryingPayload; + "usage.record": UsageRecordPayload; +} diff --git a/packages/agent-core-v2/scripts/check-import-boundaries.d.mts b/packages/agent-core-v2/scripts/check-import-boundaries.d.mts new file mode 100644 index 0000000000000000000000000000000000000000..b637dccd71df7f6230f64ac6f6d01417db7f6332 --- /dev/null +++ b/packages/agent-core-v2/scripts/check-import-boundaries.d.mts @@ -0,0 +1,10 @@ +export interface Violation { + file: string; + line: number; + message: string; +} + +export const SRC_ROOT: string; + +export function checkSource(source: string, absFile: string): Violation[]; +export function checkFile(absFile: string): Violation[]; diff --git a/packages/agent-core-v2/scripts/check-import-boundaries.mjs b/packages/agent-core-v2/scripts/check-import-boundaries.mjs new file mode 100644 index 0000000000000000000000000000000000000000..001e6b9e2d30dc971cb8da0534d7d01a8747f135 --- /dev/null +++ b/packages/agent-core-v2/scripts/check-import-boundaries.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; + +const PKG_ROOT = resolve(import.meta.dirname, '..'); +export const SRC_ROOT = join(PKG_ROOT, 'src'); +const TEST_ROOT = join(PKG_ROOT, 'test'); +const HUMAN_ROOT = join(SRC_ROOT, 'human'); +const ADAPTER_ROOT = join(SRC_ROOT, 'llm-adapter'); +const LOOP_MACHINE_ADAPTER_ROOT = join(SRC_ROOT, 'agent/loop/machine'); +const SESSION_LIFECYCLE_ADAPTER_ROOT = join(SRC_ROOT, 'session/agentLifecycle'); + +const SELF_PACKAGE_PREFIX = '@moonshot-ai/agent-core-v2/'; +const KOSONG_PATH_RE = /(?:^|\/)kosong(?:\/|$)/; +const TRAIT_FILE_RE = /\/trait\.ts$/; +const FORMAT_LOWER_FILE_RE = /\/bases\/[^/]+\/(?:format|lower)\.ts$/; +const FORMAT_LOWER_MODULE_RE = /\/bases\/[^/]+\/(?:format|lower)$/; +const TRAIT_MODULE_RE = /\/trait$/; +const BASES_DIR_RE = /\/llm\/requester\/bases(?:\/|$)/; +const BASES_INTERNAL_MODULE_RE = + /\/llm\/requester\/bases\/[^/]+\/(?:format|lower|patterns|reasoning-key)$/; +const TEST_DIR_RE = /\/test(?:\/|$)/; + +function traitBoundaryViolation(absFile, targetAbs, specifier) { + const message = `format and trait never import each other ('${specifier}') — both sides speak only the neutral wire/chunk types in the protocol's contract.ts`; + if (TRAIT_FILE_RE.test(absFile) && FORMAT_LOWER_MODULE_RE.test(targetAbs)) { + return message; + } + if (FORMAT_LOWER_FILE_RE.test(absFile) && TRAIT_MODULE_RE.test(targetAbs)) { + return message; + } + return undefined; +} + +function basesInternalViolation(absFile, targetAbs, specifier) { + if (!BASES_INTERNAL_MODULE_RE.test(targetAbs)) return undefined; + if (TRAIT_FILE_RE.test(absFile) && FORMAT_LOWER_MODULE_RE.test(targetAbs)) return undefined; + if (BASES_DIR_RE.test(absFile) || TEST_DIR_RE.test(absFile)) return undefined; + return `protocol format modules are internal to the requester pipeline ('${specifier}') — only llm/requester/bases code and tests may import format/lower/patterns; everyone else speaks contract/trait/requester`; +} + +const HUMAN_VOCABULARY = new Set([ + 'agent/origin', + 'llm/message', + 'llm/usage', + 'llm/capability', + 'llm/thinking', + 'llm/finish-reason', + 'llm/response-format', + 'llm/media/upload', + 'llm/media/image-formats', + 'llm/requester/requester', + 'llm/toolCallIdNormalizer', + 'llm-kimi/trait', + 'interaction/interaction', + 'interaction/machine', + 'interaction/facade', + 'utils/watch', + 'xstate2', +]); + +const V2_ONLY_FIRST_SEGMENTS = new Set([ + 'llm-adapter', + 'app', + 'workspace', + 'features', + 'state', + 'wire', + 'persistence', + 'os', + 'mcpCore', + 'errors', + 'debug', + 'program', + 'runtime', + '_base', +]); + +function isInside(root, absPath) { + const rel = relative(root, absPath); + return rel !== '' && !rel.startsWith('..'); +} + +function humanSubpathOf(specifier) { + if (specifier.startsWith('#human/')) return specifier.slice('#human/'.length); + if (specifier.startsWith(`${SELF_PACKAGE_PREFIX}human/`)) { + return specifier.slice(`${SELF_PACKAGE_PREFIX}human/`.length); + } + return undefined; +} + +function stripTs(path) { + return path.endsWith('.ts') ? path.slice(0, -'.ts'.length) : path; +} + +function resolveIntraV2(specifier, fromFile) { + if (specifier.startsWith('#human/')) { + return join(HUMAN_ROOT, specifier.slice('#human/'.length)); + } + if (specifier.startsWith('#/')) { + if (isInside(HUMAN_ROOT, fromFile)) { + return join(HUMAN_ROOT, specifier.slice(2)); + } + return join(SRC_ROOT, specifier.slice(2)); + } + if (specifier.startsWith(SELF_PACKAGE_PREFIX)) { + return join(SRC_ROOT, specifier.slice(SELF_PACKAGE_PREFIX.length)); + } + if (specifier.startsWith('.')) { + return resolve(dirname(fromFile), specifier); + } + return undefined; +} + +const IMPORT_RE = + /(?:import|export)\s+(?:type\s+)?(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]|(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + +export function checkSource(source, absFile) { + const violations = []; + const inSrc = !relative(SRC_ROOT, absFile).startsWith('..'); + const inHuman = isInside(HUMAN_ROOT, absFile); + const inAdapter = + isInside(ADAPTER_ROOT, absFile) || + isInside(LOOP_MACHINE_ADAPTER_ROOT, absFile) || + isInside(SESSION_LIFECYCLE_ADAPTER_ROOT, absFile); + + let match; + IMPORT_RE.lastIndex = 0; + while ((match = IMPORT_RE.exec(source)) !== null) { + const specifier = match[1] ?? match[2]; + if (!specifier) continue; + const line = source.slice(0, match.index).split('\n').length; + + if (KOSONG_PATH_RE.test(specifier)) { + violations.push({ + file: absFile, + line, + message: `the kosong kernel is deleted (${specifier}) — request/provider code lives in #human/llm, the v2 compatibility boundary is #/llm-adapter`, + }); + continue; + } + + if (!inSrc) continue; + + const targetAbs = resolveIntraV2(specifier, absFile); + if (targetAbs !== undefined) { + const basesInternal = basesInternalViolation(absFile, stripTs(targetAbs), specifier); + if (basesInternal !== undefined) { + violations.push({ file: absFile, line, message: basesInternal }); + } + } + + if (inHuman) { + if (specifier.startsWith('#/')) { + const first = specifier.slice(2).split('/')[0]; + if (first !== undefined && V2_ONLY_FIRST_SEGMENTS.has(first)) { + violations.push({ + file: absFile, + line, + message: `human must not import outside its kernel ('${specifier}') — human is the pure LLM/agent kernel: it never imports llm-adapter or v2 domains`, + }); + continue; + } + } + if (targetAbs !== undefined && !isInside(HUMAN_ROOT, targetAbs)) { + violations.push({ + file: absFile, + line, + message: `human must not import outside its kernel ('${specifier}') — human is the pure LLM/agent kernel: it never imports llm-adapter or v2 domains`, + }); + } + if (targetAbs !== undefined) { + const traitBoundary = traitBoundaryViolation(absFile, stripTs(targetAbs), specifier); + if (traitBoundary !== undefined) { + violations.push({ file: absFile, line, message: traitBoundary }); + } + } + continue; + } + + const humanSub = humanSubpathOf(specifier); + if (humanSub !== undefined && !inAdapter && !HUMAN_VOCABULARY.has(stripTs(humanSub))) { + violations.push({ + file: absFile, + line, + message: `only llm-adapter, agent/loop/machine and session/agentLifecycle may import the human implementation ('${specifier}') — v2 code outside those adapter layers is limited to the vocabulary modules (${[...HUMAN_VOCABULARY].join(', ')})`, + }); + } + } + + return violations; +} + +export function checkFile(absFile) { + return checkSource(readFileSync(absFile, 'utf8'), absFile); +} + +function walk(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === 'dist') continue; + const abs = join(dir, entry); + const st = statSync(abs); + if (st.isDirectory()) out.push(...walk(abs)); + else if (abs.endsWith('.ts')) out.push(abs); + } + return out; +} + +function main() { + const files = [...walk(SRC_ROOT), ...walk(TEST_ROOT)]; + const violations = files.flatMap((f) => checkFile(f)); + if (violations.length === 0) { + console.log(`check-import-boundaries: OK (${files.length} files)`); + return 0; + } + for (const v of violations) { + console.error(`${relative(PKG_ROOT, v.file)}:${v.line}: ${v.message}`); + } + console.error(`\ncheck-import-boundaries: ${violations.length} violation(s)`); + return 1; +} + +const isMain = process.argv[1] && resolve(process.argv[1]) === import.meta.filename; +if (isMain) { + process.exit(main()); +} diff --git a/packages/agent-core-v2/scripts/debarrel.mjs b/packages/agent-core-v2/scripts/debarrel.mjs new file mode 100644 index 0000000000000000000000000000000000000000..84715e0fd8b29e21a90b463b0dab0d9d85fd4b3c --- /dev/null +++ b/packages/agent-core-v2/scripts/debarrel.mjs @@ -0,0 +1,474 @@ +#!/usr/bin/env node +import { Project } from 'ts-morph'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PKG = path.resolve(__dirname, '..'); +const SRC = path.join(PKG, 'src'); +const ENTRY = path.join(SRC, 'index.ts'); + +const args = process.argv.slice(2); +const DRY = args.includes('--dry-run'); +const ONLY = (args.find((a) => a.startsWith('--only=')) || '').slice('--only='.length) || null; +const ENTRY_ONLY = args.includes('--entry'); +const DELETE_BARRELS = args.includes('--delete-barrels'); +const LIST_REGS = args.includes('--list-registers'); +const VERIFY = args.includes('--verify-coverage'); + +const project = new Project({ tsConfigFilePath: path.join(PKG, 'tsconfig.json') }); + +const relSpec = (absFile) => + '#/' + path.relative(SRC, absFile).split(path.sep).join('/').replace(/\.ts$/, ''); + +const isUnderSrc = (abs) => abs === SRC || abs.startsWith(SRC + path.sep); +const isIndexBasename = (abs) => path.basename(abs) === 'index.ts'; +const isBarrelFile = (sf) => { + const f = sf.getFilePath(); + return isUnderSrc(f) && isIndexBasename(f) && f !== ENTRY; +}; +const resolvedFile = (decl) => decl.getModuleSpecifierSourceFile() || null; +const barrelOfDecl = (decl) => { + const sf = resolvedFile(decl); + return sf && isBarrelFile(sf) ? sf : null; +}; + +function resolveName(barrel, name) { + const decls = barrel.getExportedDeclarations().get(name); + if (!decls || decls.length === 0) return null; + const decl = decls[0]; + const leaf = decl.getSourceFile(); + const sym = decl.getSymbol(); + let leafName = null; + for (const [ln, lds] of leaf.getExportedDeclarations()) { + if (lds.some((d) => d.getSymbol() === sym)) { + leafName = ln; + break; + } + } + if (leafName === null) leafName = (sym && sym.getName()) || name; + return { leafFile: leaf.getFilePath(), leafName }; +} + +function expandBarrelClauses(barrel) { + const clauses = []; + for (const ed of barrel.getExportDeclarations()) { + const target = resolvedFile(ed); + if (!target) continue; + if (isBarrelFile(target)) { + clauses.push(...expandBarrelClauses(target)); + continue; + } + const file = target.getFilePath(); + const isStar = + ed.getNamedExports().length === 0 && !ed.getNamespaceExport(); + if (isStar) { + clauses.push({ kind: 'star', file }); + } else if (ed.getNamespaceExport()) { + clauses.push({ kind: 'namespace', file, name: ed.getNamespaceExport().getName() }); + } else { + const declType = ed.isTypeOnly(); + const specs = ed.getNamedExports().map((s) => ({ + name: s.getName(), + alias: s.getAliasNode()?.getText(), + isTypeOnly: declType || s.isTypeOnly(), + })); + clauses.push({ kind: 'named', file, isTypeOnly: declType, specs }); + } + } + return clauses; +} + +function allLeavesUnderDir(dirAbs) { + const out = []; + const walk = (d) => { + for (const ent of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, ent.name); + if (ent.isDirectory()) walk(p); + else if ( + ent.isFile() && + p.endsWith('.ts') && + !p.endsWith('.test.ts') && + !p.endsWith('.d.ts') && + path.basename(p) !== 'index.ts' + ) { + out.push(p); + } + } + }; + walk(dirAbs); + return out.sort((a, b) => a.localeCompare(b)); +} + +function rewriteConsumerFile(sf, onlyBarrelPath) { + const report = { imports: 0, exports: 0, manuals: [], sideEffects: 0 }; + + for (const decl of sf.getImportDeclarations()) { + const barrel = barrelOfDecl(decl); + if (!barrel) continue; + if (onlyBarrelPath && barrel.getFilePath() !== onlyBarrelPath) continue; + + if (decl.getNamespaceImport()) { + report.manuals.push({ sf: sf.getFilePath(), text: decl.getText(), why: 'namespace import' }); + continue; + } + const hasDefault = !!decl.getDefaultImport(); + const named = decl.getNamedImports(); + if (!hasDefault && named.length === 0) { + const leaves = [...new Set(expandBarrelClauses(barrel).map((c) => c.file))]; + const idx = sf.getImportDeclarations().indexOf(decl); + sf.insertImportDeclarations( + idx, + leaves.map((leaf) => ({ moduleSpecifier: relSpec(leaf) })), + ); + decl.remove(); + report.sideEffects += leaves.length; + report.imports++; + continue; + } + + const declType = decl.isTypeOnly(); + const groups = new Map(); + const add = (leaf, spec) => { + if (!groups.has(leaf)) groups.set(leaf, []); + groups.get(leaf).push(spec); + }; + + if (hasDefault) { + const r = resolveName(barrel, 'default'); + if (!r) report.manuals.push({ sf: sf.getFilePath(), text: decl.getText(), why: 'default import unresolved' }); + else add(r.leafFile, { default: decl.getDefaultImport().getText() }); + } + for (const s of named) { + const lookup = s.getName(); + const local = s.getAliasNode()?.getText() || s.getName(); + const r = resolveName(barrel, lookup); + if (!r) { + report.manuals.push({ sf: sf.getFilePath(), text: s.getText(), why: 'named import unresolved' }); + continue; + } + add(r.leafFile, { + name: r.leafName, + alias: local !== r.leafName ? local : undefined, + isTypeOnly: declType || s.isTypeOnly(), + }); + } + const structures = buildImportStructures(groups); + const idx = sf.getImportDeclarations().indexOf(decl); + if (structures.length) sf.insertImportDeclarations(idx, structures); + decl.remove(); + report.imports++; + } + + for (const decl of sf.getExportDeclarations()) { + const barrel = barrelOfDecl(decl); + if (!barrel) continue; + if (onlyBarrelPath && barrel.getFilePath() !== onlyBarrelPath) continue; + + const isStar = decl.getNamedExports().length === 0 && !decl.getNamespaceExport(); + if (isStar) { + const clauses = expandBarrelClauses(barrel); + decl.replaceWithText(clauses.map(exportClauseToText).join('\n')); + report.exports++; + continue; + } + if (decl.getNamespaceExport()) { + report.manuals.push({ sf: sf.getFilePath(), text: decl.getText(), why: 'namespace export' }); + continue; + } + const declType = decl.isTypeOnly(); + const groups = new Map(); + for (const s of decl.getNamedExports()) { + const lookup = s.getName(); + const exportedAs = s.getAliasNode()?.getText() || s.getName(); + const r = resolveName(barrel, lookup); + if (!r) { + report.manuals.push({ sf: sf.getFilePath(), text: s.getText(), why: 'named export unresolved' }); + continue; + } + if (!groups.has(r.leafFile)) groups.set(r.leafFile, { specs: [], allType: true }); + const g = groups.get(r.leafFile); + const t = declType || s.isTypeOnly(); + g.allType = g.allType && t; + g.specs.push({ + name: r.leafName, + alias: exportedAs !== r.leafName ? exportedAs : undefined, + isTypeOnly: t, + }); + } + const lines = []; + for (const [leaf, { specs, allType }] of groups) { + lines.push(renderNamedExport(relSpec(leaf), specs, allType)); + } + decl.replaceWithText(lines.join('\n')); + report.exports++; + } + return report; +} + +function buildImportStructures(groups) { + const structures = []; + for (const [leaf, specs] of groups) { + const spec = relSpec(leaf); + const defaults = specs.filter((s) => s.default); + const namedSpecs = specs.filter((s) => !s.default); + for (const d of defaults) { + structures.push({ moduleSpecifier: spec, defaultImport: d.default }); + } + const values = namedSpecs.filter((s) => !s.isTypeOnly); + const types = namedSpecs.filter((s) => s.isTypeOnly); + if (values.length) + structures.push({ + moduleSpecifier: spec, + namedImports: values.map((v) => ({ name: v.name, alias: v.alias })), + }); + if (types.length) + structures.push({ + moduleSpecifier: spec, + isTypeOnly: true, + namedImports: types.map((t) => ({ name: t.name, alias: t.alias })), + }); + } + return structures; +} + +function renderNamedExport(spec, specs, allType) { + const body = specs + .map((s) => `${allType ? '' : s.isTypeOnly ? 'type ' : ''}${s.name}${s.alias ? ' as ' + s.alias : ''}`) + .join(', '); + return `${allType ? 'export type' : 'export'} { ${body} } from '${spec}';`; +} + +function exportClauseToText(c) { + if (c.kind === 'star') return `export * from '${relSpec(c.file)}';`; + if (c.kind === 'namespace') return `export * as ${c.name} from '${relSpec(c.file)}';`; + return renderNamedExport(relSpec(c.file), c.specs, c.isTypeOnly); +} + +function regenerateEntry() { + const entrySf = project.getSourceFileOrThrow(ENTRY); + const original = entrySf.getFullText(); + const headerMatch = original.match(/^\s*\/\*\*[\s\S]*?\*\//); + const header = headerMatch ? headerMatch[0] : '/** agent-core-v2 public surface. */'; + + const refs = []; + for (const decl of [...entrySf.getExportDeclarations(), ...entrySf.getImportDeclarations()]) { + const barrel = barrelOfDecl(decl); + if (!barrel) continue; + let mode; + if (decl.getKindName() === 'ImportDeclaration') mode = 'side'; + else { + const isStar = decl.getNamedExports().length === 0 && !decl.getNamespaceExport(); + mode = isStar ? 'star' : 'named'; + } + refs.push({ decl, barrel, mode }); + } + + const publicLines = []; + const loadingLines = []; + const processed = new Set(); + + for (const { decl, barrel, mode } of refs) { + const bf = barrel.getFilePath(); + const dirAbs = path.dirname(bf); + const allLeaves = allLeavesUnderDir(dirAbs); + const clauses = expandBarrelClauses(barrel); + const starLeaves = new Set(clauses.filter((c) => c.kind === 'star').map((c) => c.file)); + + if (mode === 'star') { + for (const c of clauses) publicLines.push(exportClauseToText(c)); + } else if (mode === 'named') { + const declType = decl.isTypeOnly(); + const groups = new Map(); + for (const s of decl.getNamedExports()) { + const lookup = s.getName(); + const exportedAs = s.getAliasNode()?.getText() || s.getName(); + const r = resolveName(barrel, lookup); + if (!r) continue; + if (!groups.has(r.leafFile)) groups.set(r.leafFile, { specs: [], allType: true }); + const g = groups.get(r.leafFile); + const t = declType || s.isTypeOnly(); + g.allType = g.allType && t; + g.specs.push({ + name: r.leafName, + alias: exportedAs !== r.leafName ? exportedAs : undefined, + isTypeOnly: t, + }); + } + for (const [leaf, { specs, allType }] of groups) { + publicLines.push(renderNamedExport(relSpec(leaf), specs, allType)); + } + } + for (const leaf of allLeaves) { + const key = leaf; + if (starLeaves.has(leaf)) continue; + if (processed.has(key)) continue; + processed.add(key); + loadingLines.push(`import '${relSpec(leaf)}';`); + } + } + + const body = [ + header, + '', + '// Public surface — precise re-exports of each domain leaf (no barrels).', + ...publicLines, + '', + '// Side-effect loading — ensure every domain leaf (and its top-level', + '// `register*` calls) is evaluated when the package is imported.', + ...loadingLines, + '', + ].join('\n'); + + if (!DRY) fs.writeFileSync(ENTRY, body); + return { publicLines: publicLines.length, loadingLines: loadingLines.length }; +} + +const REGISTER_NAMES = new Set([ + 'registerScopedService', + 'registerAgentToolService', + 'registerErrorDomain', + 'registerConfigSection', + 'registerAgentProfile', + 'registerFlagDefinition', +]); + +function isModuleScoped(call) { + let n = call.getParent(); + while (n) { + const k = n.getKindName(); + if ( + k === 'FunctionDeclaration' || + k === 'FunctionExpression' || + k === 'ArrowFunction' || + k === 'MethodDeclaration' || + k === 'Constructor' || + k === 'ClassDeclaration' + ) { + return false; + } + n = n.getParent(); + } + return true; +} + +function findRegisterFiles() { + const files = []; + for (const sf of project.getSourceFiles()) { + const f = sf.getFilePath(); + if (!isUnderSrc(f) || f.endsWith('.test.ts')) continue; + let hit = false; + sf.forEachDescendant((node) => { + if (hit) return; + if (node.getKindName() !== 'CallExpression') return; + const expr = node.getExpression(); + if (expr.getKindName() !== 'Identifier') return; + if (!REGISTER_NAMES.has(expr.getText())) return; + if (isModuleScoped(node)) hit = true; + }); + if (hit) files.push(f); + } + return files.sort(); +} + +function reachedFromEntry() { + const reached = new Set(); + const visit = (sf) => { + const f = sf.getFilePath(); + if (reached.has(f)) return; + reached.add(f); + if (!isUnderSrc(f)) return; + const edges = [...sf.getImportDeclarations(), ...sf.getExportDeclarations()]; + for (const d of edges) { + if (d.isTypeOnly && d.isTypeOnly()) continue; + const t = resolvedFile(d); + if (t && isUnderSrc(t.getFilePath())) visit(t); + } + }; + visit(project.getSourceFileOrThrow(ENTRY)); + return reached; +} + +function verifyCoverage() { + const regs = findRegisterFiles(); + const reached = reachedFromEntry(); + const missing = regs.filter((f) => !reached.has(f)); + console.log(`register files: ${regs.length}; reachable from entry: ${reached.size}; missing: ${missing.length}`); + if (missing.length) { + console.log('MISSING (not reachable from src/index.ts):'); + for (const m of missing) console.log(' ' + path.relative(PKG, m)); + return false; + } + return true; +} + +function deleteBarrels() { + let n = 0; + for (const sf of project.getSourceFiles()) { + if (!isBarrelFile(sf)) continue; + const f = sf.getFilePath(); + if (!DRY) fs.unlinkSync(f); + n++; + } + return n; +} + +function main() { + if (LIST_REGS) { + for (const f of findRegisterFiles()) console.log(path.relative(PKG, f)); + return; + } + if (VERIFY) { + const ok = verifyCoverage(); + process.exit(ok ? 0 : 1); + } + if (ENTRY_ONLY) { + const r = regenerateEntry(); + console.log(`entry regenerated: ${r.publicLines} public lines, ${r.loadingLines} loading lines${DRY ? ' (dry-run)' : ''}`); + return; + } + + let onlyBarrelPath = null; + if (ONLY) { + onlyBarrelPath = path.join(SRC, ONLY, 'index.ts'); + if (!fs.existsSync(onlyBarrelPath)) { + console.error(`--only target not a barrel: ${path.relative(PKG, onlyBarrelPath)}`); + process.exit(2); + } + } + + const totals = { files: 0, imports: 0, exports: 0, sideEffects: 0, manuals: [] }; + for (const sf of project.getSourceFiles()) { + const f = sf.getFilePath(); + if (!isUnderSrc(f) && !f.startsWith(path.join(PKG, 'test') + path.sep)) continue; + if (f === ENTRY) continue; + const before = sf.getFullText(); + const r = rewriteConsumerFile(sf, onlyBarrelPath); + if (sf.getFullText() !== before) { + totals.files++; + totals.imports += r.imports; + totals.exports += r.exports; + totals.sideEffects += r.sideEffects; + } + totals.manuals.push(...r.manuals); + } + + if (!DRY) project.saveSync(); + + console.log( + `rewrote ${totals.files} files: ${totals.imports} barrel imports, ${totals.exports} barrel exports, ${totals.sideEffects} side-effect loads${DRY ? ' (dry-run)' : ''}`, + ); + if (totals.manuals.length) { + console.log(`MANUAL (${totals.manuals.length}) — could not auto-split:`); + for (const m of totals.manuals) + console.log(` ${path.relative(PKG, m.sf)} :: ${m.why} :: ${m.text.replace(/\s+/g, ' ').slice(0, 120)}`); + } + + if (DELETE_BARRELS) { + const n = deleteBarrels(); + console.log(`deleted ${n} domain barrels${DRY ? ' (dry-run)' : ''}`); + } +} + +main(); diff --git a/packages/agent-core-v2/scripts/gen-config-manifest.mts b/packages/agent-core-v2/scripts/gen-config-manifest.mts new file mode 100644 index 0000000000000000000000000000000000000000..411c5b5df07264e914e3c7925fa363f1afaed00a --- /dev/null +++ b/packages/agent-core-v2/scripts/gen-config-manifest.mts @@ -0,0 +1,314 @@ +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { getConfigOverlayContributions } from '#/app/config/configOverlayContributions'; +import type { ConfigSectionContribution } from '#/app/config/configSectionContributions'; +import { getConfigSectionContributions } from '#/app/config/configSectionContributions'; +import { camelToSnake } from '#/app/config/toml'; + +import { + asJsonSchema, + describeType, + isRecord, + resolveRef, + toJsonSchema, + truncate, + type JsonSchema, +} from './lib/jsonSchema.mts'; + +const PKG = join(import.meta.dirname, '..'); +const SRC = join(PKG, 'src'); +export const MANIFEST_PATH = join(PKG, 'docs', 'config-manifest.toml'); + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) walk(p, out); + else if (entry.endsWith('.ts')) out.push(p); + } + return out; +} + +function constStringValue(source: string, ident: string): string | undefined { + const re = new RegExp(`(?:export\\s+)?const\\s+${ident}\\s*(?::[^=;]+)?=\\s*'([^']+)'`); + return re.exec(source)?.[1]; +} + +function scanSectionOwners(): Map { + const owners = new Map(); + for (const file of walk(SRC)) { + const source = readFileSync(file, 'utf-8'); + if (!source.includes('registerConfigSection(')) continue; + for (const match of source.matchAll(/registerConfigSection\(\s*(?:'([^']+)'|([A-Za-z0-9_$]+))/g)) { + const ident = match[2]; + const domain = match[1] ?? (ident === undefined ? undefined : constStringValue(source, ident)); + if (domain !== undefined) owners.set(domain, relative(PKG, file)); + } + } + return owners; +} + +function scanOverlayOwners(): Map { + const owners = new Map(); + for (const file of walk(SRC)) { + if (file.endsWith('configOverlayContributions.ts')) continue; + const source = readFileSync(file, 'utf-8'); + if (!source.includes('registerConfigOverlay(')) continue; + for (const match of source.matchAll(/registerConfigOverlay\(\s*([A-Za-z0-9_$]+)/g)) { + const ident = match[1]; + if (ident !== undefined) owners.set(ident, relative(PKG, file)); + } + } + return owners; +} + +function toTomlValue(value: unknown): string { + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return `[${value.map(toTomlValue).join(', ')}]`; + if (isRecord(value)) { + const entries = Object.entries(value).map(([k, v]) => `${camelToSnake(k)} = ${toTomlValue(v)}`); + return `{ ${entries.join(', ')} }`; + } + return '""'; +} + +interface EnvRow { + readonly field: string; + readonly env: string; + readonly detail: string; +} + +interface EnvBindingFields { + readonly env?: unknown; + readonly deprecatedEnv?: unknown; + readonly parse?: unknown; + readonly default?: unknown; +} + +function flattenEnvBindings(bindings: unknown, path: string[] = []): EnvRow[] { + if (typeof bindings === 'string') { + return [{ field: path.join('.'), env: bindings, detail: '' }]; + } + if (!isRecord(bindings)) return []; + const binding = bindings as EnvBindingFields; + if (typeof binding.env === 'string') { + const detail: string[] = []; + if (binding.parse !== undefined) detail.push('custom parse'); + if (binding.default !== undefined) detail.push(`default ${JSON.stringify(binding.default)}`); + if (typeof binding.deprecatedEnv === 'string') { + detail.push(`deprecated fallback ${binding.deprecatedEnv}`); + } + return [{ field: path.join('.'), env: binding.env, detail: detail.join('; ') }]; + } + return Object.entries(bindings).flatMap(([key, value]) => flattenEnvBindings(value, [...path, key])); +} + +function snakePath(field: string): string { + return field.split('.').map(camelToSnake).join('.'); +} + +const RULE = `# ${'#'.repeat(74)}`; + +function renderFieldComments( + properties: Record, + root: JsonSchema, + indent: string, + depth = 0, +): string[] { + const lines: string[] = []; + for (const [name, prop] of Object.entries(properties)) { + const resolved = resolveRef(prop, root); + const propDefault = asJsonSchema(resolved)?.default; + const defNote = propDefault !== undefined ? ` (default: ${JSON.stringify(propDefault)})` : ''; + lines.push(`${indent}# ${camelToSnake(name)}: ${describeType(resolved)}${defNote}`); + const subProps = asJsonSchema(resolved)?.properties; + if (depth < 3 && isRecord(subProps) && Object.keys(subProps).length > 0) { + lines.push(...renderFieldComments(subProps, root, `${indent} `, depth + 1)); + } + } + return lines; +} + +function renderBody(section: ConfigSectionContribution): string[] { + const { domain, schema, options } = section; + const key = camelToSnake(domain); + const jsonSchema = schema === undefined ? undefined : toJsonSchema(schema); + + if (jsonSchema === undefined) { + if (isRecord(options.defaultValue)) { + return [ + `[${key}]`, + `# (${schema === undefined ? 'no schema — passthrough' : 'schema uses transforms'}; fields below come from the registered default)`, + ...Object.entries(options.defaultValue).map( + ([k, v]) => `${camelToSnake(k)} = ${truncate(toTomlValue(v))}`, + ), + ]; + } + if (options.defaultValue !== undefined) { + return [`${key} = ${truncate(toTomlValue(options.defaultValue))}`]; + } + return [`[${key}]`, `# (${schema === undefined ? 'no schema — passthrough' : 'schema uses transforms; see the owner file'})`]; + } + + if (isRecord(jsonSchema.properties) && Object.keys(jsonSchema.properties).length > 0) { + const defaults = isRecord(options.defaultValue) ? options.defaultValue : {}; + const lines = [`[${key}]`]; + for (const [name, prop] of Object.entries(jsonSchema.properties)) { + const fieldKey = camelToSnake(name); + if (defaults[name] !== undefined) { + lines.push(`${fieldKey} = ${truncate(toTomlValue(defaults[name]))}`); + continue; + } + const resolved = resolveRef(prop, jsonSchema); + const subProps = asJsonSchema(resolved)?.properties; + if (isRecord(subProps) && Object.keys(subProps).length > 0) { + lines.push(''); + lines.push(`# [${key}.${fieldKey}]`); + lines.push(...renderFieldComments(subProps, jsonSchema, ' ')); + continue; + } + const itemProps = asJsonSchema( + resolveRef(asJsonSchema(resolved)?.items, jsonSchema), + )?.properties; + if (isRecord(itemProps) && Object.keys(itemProps).length > 0) { + lines.push(`# ${fieldKey}: object[] — one entry per item:`); + lines.push(...renderFieldComments(itemProps, jsonSchema, ' ')); + continue; + } + lines.push(...renderFieldComments({ [name]: prop }, jsonSchema, '')); + } + return lines; + } + + if (jsonSchema.additionalProperties !== undefined) { + const valueSchema = resolveRef(jsonSchema.additionalProperties, jsonSchema); + const valueProps = asJsonSchema(valueSchema)?.properties; + const lines = [`[${key}]`]; + if (isRecord(valueProps) && Object.keys(valueProps).length > 0) { + lines.push(''); + lines.push(`# one [${key}.""] table per entry:`); + lines.push(`# [${key}.""]`); + lines.push(...renderFieldComments(valueProps, jsonSchema, ' ')); + } else { + lines.push(`# : ${describeType(valueSchema)}`); + } + return lines; + } + + if (jsonSchema.type === 'array') { + const itemProps = asJsonSchema(resolveRef(jsonSchema.items, jsonSchema))?.properties; + if (isRecord(itemProps) && Object.keys(itemProps).length > 0) { + return [ + `# one [[${key}]] table per entry:`, + `# [[${key}]]`, + ...renderFieldComments(itemProps, jsonSchema, ' '), + ]; + } + } + + if (options.defaultValue !== undefined) { + return [`${key} = ${truncate(toTomlValue(options.defaultValue))}`]; + } + return [`# ${key}: ${describeType(jsonSchema)}`]; +} + +function renderSection(section: ConfigSectionContribution, owner: string | undefined): string[] { + const { domain, options } = section; + const key = camelToSnake(domain); + const lines: string[] = [RULE]; + lines.push(`# ${domain}${key === domain ? '' : ` (config.toml: ${key})`}`); + lines.push(`# owner: ${owner ?? '(unresolved)'}`); + lines.push(`# scope: ${options.scope ?? 'core'}`); + const hooks: string[] = []; + if (options.merge !== undefined) hooks.push('custom merge'); + if (options.fromToml !== undefined) hooks.push('custom fromToml'); + if (options.toToml !== undefined) hooks.push('custom toToml'); + if (options.stripEnv !== undefined) hooks.push('stripEnv'); + if (hooks.length > 0) lines.push(`# hooks: ${hooks.join(' · ')}`); + const deprecations = options.deprecations ?? []; + if (deprecations.length > 0) { + lines.push('# deprecations (old key is ignored + warns; rename manually):'); + for (const deprecation of deprecations) { + lines.push(`# ${deprecation.key} -> ${deprecation.replacement}`); + } + } + const envRows = flattenEnvBindings(options.env); + if (envRows.length > 0) { + lines.push('# env:'); + for (const row of envRows) { + lines.push(`# ${snakePath(row.field)} <- ${row.env}${row.detail === '' ? '' : ` (${row.detail})`}`); + } + } + lines.push(RULE); + lines.push(''); + lines.push(...renderBody(section)); + return lines; +} + +export async function buildConfigManifest(): Promise { + await import('../src/index.ts'); + const sections = getConfigSectionContributions().toSorted((a, b) => + a.domain.localeCompare(b.domain), + ); + const overlays = getConfigOverlayContributions(); + const sectionOwners = scanSectionOwners(); + const overlayOwners = scanOverlayOwners(); + + const out: string[] = [ + '# Config Section Manifest', + '#', + '# Generated by scripts/gen-config-manifest.mts — do not edit by hand.', + '# Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest', + '#', + '# One [table] per registered config section, in the on-disk config.toml shape', + '# (snake_case keys). Un-commented assignments are registered defaults;', + '# commented "# field: type" lines describe the remaining schema fields.', + '# Values resolve as: default -> config.toml -> env overlay -> memory.', + '', + `# Index (${sections.length} sections · ${overlays.length} overlay(s))`, + ]; + const width = Math.max(...sections.map((s) => s.domain.length)); + for (const { domain } of sections) { + out.push(`# ${domain.padEnd(width)} ${sectionOwners.get(domain) ?? '(unresolved)'}`); + } + for (const [ident, file] of overlayOwners) { + out.push(`# ${'(overlay) ' + ident} ${file}`); + } + out.push(''); + + for (const section of sections) { + out.push(...renderSection(section, sectionOwners.get(section.domain))); + out.push(''); + } + return out.join('\n'); +} + +async function main(): Promise { + const check = process.argv.includes('--check'); + const manifest = await buildConfigManifest(); + if (check) { + let current: string | undefined; + try { + current = readFileSync(MANIFEST_PATH, 'utf-8'); + } catch { + current = undefined; + } + if (current !== manifest) { + console.error( + `[gen-config-manifest] ${relative(process.cwd(), MANIFEST_PATH)} is stale. ` + + 'Regenerate with `pnpm --filter @moonshot-ai/agent-core-v2 gen:config-manifest`.', + ); + process.exit(1); + } + console.log('[gen-config-manifest] up to date'); + return; + } + writeFileSync(MANIFEST_PATH, manifest); + console.log(`[gen-config-manifest] wrote ${relative(process.cwd(), MANIFEST_PATH)}`); +} + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/packages/agent-core-v2/scripts/gen-contract-types.mjs b/packages/agent-core-v2/scripts/gen-contract-types.mjs new file mode 100644 index 0000000000000000000000000000000000000000..c9a631ad98f183dbd9957d85150f23f76dc74da4 --- /dev/null +++ b/packages/agent-core-v2/scripts/gen-contract-types.mjs @@ -0,0 +1,171 @@ +import { execFileSync } from 'node:child_process'; +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, +} from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +import { Project, SyntaxKind } from 'ts-morph'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PKG = join(__dirname, '..'); +const SRC = join(PKG, 'src'); +const TMP = join(PKG, '.contract-types-tmp'); +const TSCONFIG = join(PKG, 'tsconfig.contract.json'); + +const repoRoot = join(PKG, '..', '..'); +const defaultOut = join(repoRoot, '..', 'kimi-code-mini-bench', 'types', 'agent-core-v2'); +const OUT = process.argv[2] ? join(process.cwd(), process.argv[2]) : defaultOut; + +const require = createRequire(import.meta.url); +const tscBin = require.resolve('typescript/bin/tsc'); + +function log(msg) { + console.log(`[gen-contract-types] ${msg}`); +} + +function walk(dir, out) { + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + const s = statSync(p); + if (s.isDirectory()) walk(p, out); + else out.push(p); + } +} + +rmSync(TMP, { recursive: true, force: true }); +mkdirSync(TMP, { recursive: true }); +log(`emitting declarations via tsc -> ${relative(PKG, TMP)}`); +try { + execFileSync(process.execPath, [tscBin, '-p', TSCONFIG, '--outDir', TMP], { + cwd: PKG, + stdio: 'pipe', + }); +} catch (err) { + const code = err && typeof err === 'object' && 'status' in err ? err.status : 'unknown'; + log(`tsc exited ${String(code)} (non-fatal; declarations are still emitted)`); +} + +log('scanning for registerScopedService(...) bindings'); +const project = new Project(); +project.addSourceFilesAtPaths(join(SRC, '**', '*.ts')); + +const dropByDts = new Map(); +const implFiles = []; + +for (const sf of project.getSourceFiles()) { + const calls = sf + .getDescendantsOfKind(SyntaxKind.CallExpression) + .filter((c) => c.getExpression().getText() === 'registerScopedService'); + if (calls.length === 0) continue; + + implFiles.push(sf.getFilePath()); + const names = new Set(); + for (const call of calls) { + const args = call.getArguments(); + if (args.length < 3) continue; + const text = args[2].getText().trim(); + names.add(/^[A-Za-z_$][\w$]*$/.test(text) ? text : '*'); + } + + const rel = relative(SRC, sf.getFilePath()).replace(/\.ts$/, '.d.ts'); + const dtsPath = join(TMP, rel); + const existing = dropByDts.get(dtsPath) ?? new Set(); + for (const n of names) existing.add(n); + dropByDts.set(dtsPath, existing); +} + +log(`found ${implFiles.length} impl files`); + +let scrubbedFiles = 0; +let scrubbedClasses = 0; +for (const [dtsPath, names] of dropByDts) { + if (!existsSync(dtsPath)) continue; + const dtsProject = new Project(); + const dts = dtsProject.addSourceFileAtPath(dtsPath); + const dropAll = names.has('*'); + let removed = 0; + for (const cls of dts.getClasses()) { + const clsName = cls.getName(); + if (dropAll || (clsName !== undefined && names.has(clsName))) { + cls.remove(); + removed++; + } + } + if (removed > 0) { + dts.saveSync(); + scrubbedFiles++; + scrubbedClasses += removed; + } +} +log(`scrubbed ${scrubbedClasses} impl class(es) across ${scrubbedFiles} file(s)`); + +function resolveReexportTarget(dtsPath, spec) { + const clean = spec.endsWith('.js') ? spec.slice(0, -'.js'.length) : spec; + if (clean.startsWith('.')) return join(dirname(dtsPath), `${clean}.d.ts`); + if (clean.startsWith('#/')) return join(TMP, `${clean.slice(2)}.d.ts`); + return undefined; +} + +let scrubbedReexports = 0; +const emittedDts = []; +walk(TMP, emittedDts); +const reexportProject = new Project(); +for (const dtsPath of emittedDts) { + if (!dtsPath.endsWith('.d.ts')) continue; + const dts = reexportProject.addSourceFileAtPath(dtsPath); + let changed = false; + for (const exp of dts.getExportDeclarations()) { + const spec = exp.getModuleSpecifierValue(); + if (spec === undefined) continue; + const target = resolveReexportTarget(dtsPath, spec); + const names = target === undefined ? undefined : dropByDts.get(target); + if (names === undefined) continue; + let removedHere = false; + for (const specifier of exp.getNamedExports()) { + const name = specifier.getNameNode().getText(); + if (names.has('*') || names.has(name)) { + specifier.remove(); + removedHere = true; + scrubbedReexports++; + } + } + if (removedHere && exp.getNamedExports().length === 0) exp.remove(); + changed = changed || removedHere; + } + if (changed) dts.saveSync(); +} +log(`scrubbed ${scrubbedReexports} re-export(s) of impl classes from alias modules`); + +rmSync(OUT, { recursive: true, force: true }); +mkdirSync(dirname(OUT), { recursive: true }); +cpSync(TMP, OUT, { recursive: true }); + +const emitted = []; +walk(OUT, emitted); +const dtsCount = emitted.filter((f) => f.endsWith('.d.ts')).length; +log(`wrote ${dtsCount} declaration file(s) -> ${OUT}`); + +const leaks = []; +for (const [dtsPath, names] of dropByDts) { + const outPath = join(OUT, relative(TMP, dtsPath)); + if (!existsSync(outPath) || names.has('*')) continue; + const text = readFileSync(outPath, 'utf8'); + for (const n of names) { + const re = new RegExp(`declare\\s+class\\s+${n}\\b`); + if (re.test(text)) leaks.push(`${relative(OUT, outPath)} still declares ${n}`); + } +} +if (leaks.length > 0) { + log(`WARNING: ${leaks.length} possible leak(s):`); + for (const l of leaks) log(` - ${l}`); +} else { + log('leak check passed: no registered impl class survives in its declaring file'); +} diff --git a/packages/agent-core-v2/scripts/gen-state-manifest.mts b/packages/agent-core-v2/scripts/gen-state-manifest.mts new file mode 100644 index 0000000000000000000000000000000000000000..1a0cc7be90fd36e56ca5776581c31257aa0b8e3b --- /dev/null +++ b/packages/agent-core-v2/scripts/gen-state-manifest.mts @@ -0,0 +1,882 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + type CallExpression, + Node, + Project, + SyntaxKind, + ts, + type Identifier, + type PropertyAccessExpression, + type Signature, + type SourceFile, + type Symbol as MorphSymbol, + type Type as MorphType, + type TypeChecker, + type VariableDeclaration, +} from 'ts-morph'; + +const PKG = join(import.meta.dirname, '..'); +const REPO_ROOT = join(PKG, '..', '..'); +const SRC = join(PKG, 'src'); +export const MANIFEST_PATH = join(PKG, 'docs', 'state-manifest.d.ts'); + +const SCOPES = [ + { + dir: 'app', + label: 'App', + interfaceName: 'AppStateSnapshot', + keyUnionName: 'AppStateKey', + }, + { + dir: 'workspace', + label: 'Workspace', + interfaceName: 'WorkspaceStateSnapshot', + keyUnionName: 'WorkspaceStateKey', + }, + { + dir: 'session', + label: 'Session', + interfaceName: 'SessionStateSnapshot', + keyUnionName: 'SessionStateKey', + }, + { + dir: 'agent', + label: 'Agent', + interfaceName: 'AgentStateSnapshot', + keyUnionName: 'AgentStateKey', + }, +] as const; + +type ScopeDir = (typeof SCOPES)[number]['dir']; + +interface KeyDef { + readonly constName: string; + readonly keyName: string; + readonly file: string; + readonly exported: boolean; + readonly declaration: VariableDeclaration; + readonly replayable?: { + readonly durable: boolean; + readonly undoable: boolean; + readonly folds: readonly string[]; + }; +} + +interface Registration { + readonly def: KeyDef; + readonly scope: ScopeDir; +} + +interface StateManifestModel { + readonly registrations: readonly Registration[]; + readonly unregistered: readonly KeyDef[]; +} + +function scopeDirOf(file: string): ScopeDir | undefined { + const first = relative(SRC, file).split(/[\\/]/)[0]; + return SCOPES.some((scope) => scope.dir === first) ? (first as ScopeDir) : undefined; +} + +function isFeaturesFile(file: string): boolean { + return relative(SRC, file).split(/[\\/]/)[0] === 'features'; +} + +const FEATURES_RECEIVER_SCOPE: Readonly> = { + IAppStateService: 'app', + IWorkspaceStateService: 'workspace', + ISessionStateService: 'session', + IAgentStateService: 'agent', +}; + +function receiverScope( + expression: PropertyAccessExpression, + checker: TypeChecker, +): ScopeDir | undefined { + const typeName = checker.getTypeAtLocation(expression.getExpression()).getSymbol()?.getName(); + return typeName === undefined ? undefined : FEATURES_RECEIVER_SCOPE[typeName]; +} + +function featuresRegisterScope( + expression: PropertyAccessExpression, + checker: TypeChecker, + sf: SourceFile, +): ScopeDir { + const scope = receiverScope(expression, checker); + if (scope === undefined) { + throw new Error( + `[gen-state-manifest] cannot resolve the state-service scope of '${expression.getText()}' ` + + `in ${srcRelative(sf.getFilePath())} — register through an ` + + 'I{App,Workspace,Session,Agent}StateService-typed member.', + ); + } + return scope; +} + +function srcRelative(file: string): string { + return relative(PKG, file).split('\\').join('/'); +} + +function repoRelative(file: string): string { + return relative(REPO_ROOT, file).split('\\').join('/'); +} + +function tsFieldKey(key: string): string { + return /^[$A-Z_a-z][$\w]*$/.test(key) ? key : JSON.stringify(key); +} + +function stableSymbolKey(key: string): string { + const match = /^__@(.+)@\d+$/.exec(key); + return match === null ? key : `__@${match[1]}`; +} + +function collectKeyDefs(project: Project): Map { + const defs = new Map(); + for (const sf of project.getSourceFiles()) { + const filePath = sf.getFilePath(); + if (scopeDirOf(filePath) === undefined && !isFeaturesFile(filePath)) continue; + for (const statement of sf.getVariableStatements()) { + for (const declaration of statement.getDeclarations()) { + const initializer = declaration.getInitializer(); + if (initializer === undefined || !Node.isCallExpression(initializer)) continue; + const parsed = parseDefineStateChain(initializer); + if (parsed === undefined) continue; + defs.set(declaration, { + constName: declaration.getName(), + keyName: parsed.keyName, + file: sf.getFilePath(), + exported: statement.isExported(), + declaration, + replayable: parsed.replayable, + }); + } + } + } + return defs; +} + +function parseDefineStateChain( + initializer: CallExpression, +): { keyName: string; replayable?: KeyDef['replayable'] } | undefined { + let durable = true; + let undoable = false; + let replayable = false; + const folds: string[] = []; + let current: CallExpression = initializer; + for (;;) { + const expression = current.getExpression(); + if (Node.isIdentifier(expression) && expression.getText() === 'defineState') { + const [nameArg] = current.getArguments(); + if (nameArg === undefined || !Node.isStringLiteral(nameArg)) return undefined; + return { + keyName: nameArg.getLiteralValue(), + replayable: replayable ? { durable, undoable, folds } : undefined, + }; + } + if (!Node.isPropertyAccessExpression(expression)) return undefined; + const method = expression.getName(); + if (method === 'replayable') { + replayable = true; + const [arg] = current.getArguments(); + if (arg !== undefined && Node.isObjectLiteralExpression(arg)) { + const durableProp = arg.getProperty('durable'); + if (durableProp !== undefined && Node.isPropertyAssignment(durableProp)) { + durable = durableProp.getInitializer()?.getText() !== 'false'; + } + } + } else if (method === 'undoable') { + undoable = true; + } else if (method === 'on') { + const [eventArg] = current.getArguments(); + if (eventArg !== undefined) folds.unshift(eventArg.getText()); + } else { + return undefined; + } + const inner = expression.getExpression(); + if (!Node.isCallExpression(inner)) return undefined; + current = inner; + } +} + +function resolveKeyDef( + identifier: Identifier, + defs: ReadonlyMap, +): KeyDef | undefined { + for (const info of identifier.getDefinitions()) { + const node = info.getDeclarationNode(); + if (node !== undefined && Node.isVariableDeclaration(node)) { + const def = defs.get(node); + if (def !== undefined) return def; + } + } + return undefined; +} + +function collectRegistrations( + project: Project, + defs: ReadonlyMap, +): Registration[] { + const checker = project.getTypeChecker(); + const registrations: Registration[] = []; + const seen = new Map(); + for (const sf of project.getSourceFiles()) { + const fileScope = scopeDirOf(sf.getFilePath()); + const featuresFile = isFeaturesFile(sf.getFilePath()); + if (fileScope === undefined && !featuresFile) continue; + for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const expression = call.getExpression(); + if ( + !Node.isPropertyAccessExpression(expression) || + expression.getName() !== 'contributeState' + ) { + continue; + } + const args = call.getArguments(); + const [arg] = args; + if (args.length !== 1 || arg === undefined || !Node.isIdentifier(arg)) continue; + const def = resolveKeyDef(arg, defs); + if (def === undefined) continue; + const scope = receiverScope(expression, checker) ?? fileScope ?? featuresRegisterScope(expression, checker, sf); + if (!def.exported) { + throw new Error( + `[gen-state-manifest] state key '${def.keyName}' (${srcRelative(def.file)}) is ` + + 'registered but its key constant is not exported — the manifest cannot reference it.', + ); + } + const dedupe = `${scope}:${def.keyName}`; + const seenFile = seen.get(dedupe); + if (seenFile !== undefined) { + if (seenFile === sf.getFilePath()) continue; + throw new Error( + `[gen-state-manifest] state key '${def.keyName}' is registered twice in ${scope} scope.`, + ); + } + seen.set(dedupe, sf.getFilePath()); + registrations.push({ def, scope }); + } + } + return registrations; +} + +function createProject(): Project { + const project = new Project({ + tsConfigFilePath: join(PKG, 'tsconfig.json'), + skipAddingFilesFromTsConfig: true, + }); + project.addSourceFilesAtPaths(join(SRC, '**', '*.ts')); + return project; +} + +const NO_TRUNCATION = ts.TypeFormatFlags.NoTruncation; + +class TypeRenderer { + private readonly checker: ts.TypeChecker; + private readonly expanding = new Set(); + private readonly expandingNamed: ts.Symbol[] = []; + readonly externals = new Set(); + readonly warnings = new Set(); + + constructor(private readonly project: Project) { + this.checker = project.getTypeChecker().compilerObject; + } + + renderKeyType(def: KeyDef): string { + const valueType = def.declaration.getType().getTypeArguments()[0]; + if (valueType === undefined) { + throw new Error( + `[gen-state-manifest] cannot resolve the value type of '${def.keyName}' (${srcRelative(def.file)}).`, + ); + } + return this.renderType(valueType, def.declaration, 0); + } + + private renderType( + type: MorphType, + location: Node, + depth: number, + skipSymbol?: ts.Symbol, + ): string { + if (depth > 40) return this.fallback(type, location, 'depth cap'); + + if ((type.getFlags() & ts.TypeFlags.EnumLiteral) !== 0) { + return this.renderEnumLiteral(type); + } + + if (type.isUnion() && (type.getFlags() & ts.TypeFlags.Boolean) !== 0) return 'boolean'; + + if (type.isUnion()) { + const alias = this.tryRenderAlias(type, location, depth, skipSymbol); + if (alias !== undefined) return alias; + const enumUnion = this.tryRenderEnumUnion(type); + if (enumUnion !== undefined) return enumUnion; + return this.renderUnionMembers(type.getUnionTypes(), location, depth); + } + + if (type.isIntersection()) { + const alias = this.tryRenderAlias(type, location, depth, skipSymbol); + if (alias !== undefined) return alias; + return type + .getIntersectionTypes() + .map((member) => this.renderType(member, location, depth + 1)) + .join(' & '); + } + + if (this.isLeaf(type)) return this.leafText(type); + + if (type.isTuple()) { + const elements = type + .getTupleElements() + .map((element) => this.renderType(element, location, depth + 1)); + return `[${elements.join(', ')}]`; + } + + if (type.isArray()) { + const element = type.getArrayElementType(); + if (element === undefined) return this.fallback(type, location, 'array without element'); + const rendered = this.renderType(element, location, depth + 1); + const text = + element.isUnion() || element.isIntersection() ? `(${rendered})[]` : `${rendered}[]`; + return type.getSymbol()?.getName() === 'ReadonlyArray' ? `readonly ${text}` : text; + } + + if (type.isObject()) { + return this.renderObjectType(type, location, depth, skipSymbol); + } + + return this.fallback(type, location, 'unhandled type kind'); + } + + private renderUnionMembers( + members: readonly MorphType[], + location: Node, + depth: number, + ): string { + const booleanLiterals = members.filter((m) => m.isBooleanLiteral()); + const collapseBoolean = + booleanLiterals.length === 2 && + new Set(booleanLiterals.map((m) => this.leafText(m))).size === 2; + const rest = collapseBoolean ? members.filter((m) => !m.isBooleanLiteral()) : members; + const rank = (type: MorphType): number => (type.isNull() ? 1 : type.isUndefined() ? 2 : 0); + const rendered = rest + .map((member) => ({ member, rank: rank(member) })) + .toSorted((a, b) => a.rank - b.rank) + .map(({ member }) => ({ + member, + text: this.renderType(member, location, depth + 1), + })); + const multi = rendered.length + (collapseBoolean ? 1 : 0) > 1; + const parts = rendered.map(({ member, text }) => + multi && this.needsParensInUnion(member) ? `(${text})` : text, + ); + if (collapseBoolean) parts.unshift('boolean'); + return [...new Set(parts)].join(' | '); + } + + private isLeaf(type: MorphType): boolean { + const flags = type.getFlags(); + return ( + type.isString() || + type.isNumber() || + type.isBoolean() || + type.isStringLiteral() || + type.isNumberLiteral() || + type.isBooleanLiteral() || + type.isNull() || + type.isUndefined() || + type.isUnknown() || + type.isAny() || + type.isNever() || + type.isTypeParameter() || + (flags & + (ts.TypeFlags.Void | + ts.TypeFlags.BigInt | + ts.TypeFlags.BigIntLiteral | + ts.TypeFlags.ESSymbol | + ts.TypeFlags.UniqueESSymbol | + ts.TypeFlags.TemplateLiteral)) !== + 0 + ); + } + + private leafText(type: MorphType): string { + const text = this.checker.typeToString(type.compilerType, undefined, NO_TRUNCATION); + if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) { + const value = JSON.parse(text) as string; + return value.includes("'") ? JSON.stringify(value) : `'${value}'`; + } + return text; + } + + private needsParensInUnion(type: MorphType): boolean { + return type.isIntersection() || (type.isObject() && type.getCallSignatures().length > 0); + } + + private fallback(type: MorphType, location: Node, reason: string): string { + const text = this.checker.typeToString(type.compilerType, location.compilerNode, NO_TRUNCATION); + this.warnings.add(`${reason}: fell back to '${text.slice(0, 80)}'`); + return text; + } + + private enumLiteralValue(type: MorphType): string { + const value = (type.compilerType as ts.LiteralType).value; + if (typeof value === 'string') { + return value.includes("'") ? JSON.stringify(value) : `'${value}'`; + } + if (typeof value === 'number') return String(value); + return this.leafText(type); + } + + private enumDeclOf(type: MorphType): Node | undefined { + const memberDecl = type.getSymbol()?.getDeclarations()[0]; + if (memberDecl === undefined || !Node.isEnumMember(memberDecl)) return undefined; + return memberDecl.getParent(); + } + + private renderEnumLiteral(type: MorphType): string { + const enumDecl = this.enumDeclOf(type); + const text = this.enumLiteralValue(type); + if (enumDecl !== undefined && Node.isEnumDeclaration(enumDecl)) { + const sym = enumDecl.getSymbol(); + if (sym !== undefined) { + return `/* ${sym.getName()} — ${repoRelative(enumDecl.getSourceFile().getFilePath())} */ ${text}`; + } + } + return text; + } + + private tryRenderEnumUnion(type: MorphType): string | undefined { + const members = type.getUnionTypes(); + if (members.length === 0) return undefined; + let enumDecl: Node | undefined; + for (const member of members) { + if ((member.getFlags() & ts.TypeFlags.EnumLiteral) === 0) return undefined; + const parent = this.enumDeclOf(member); + if (parent === undefined) return undefined; + if (enumDecl === undefined) enumDecl = parent; + else if (parent !== enumDecl) return undefined; + } + if (enumDecl === undefined || !Node.isEnumDeclaration(enumDecl)) return undefined; + if (enumDecl.getMembers().length !== members.length) return undefined; + const sym = enumDecl.getSymbol(); + if (sym === undefined) return undefined; + const values = members.map((member) => this.enumLiteralValue(member)); + return `/* ${sym.getName()} — ${repoRelative(enumDecl.getSourceFile().getFilePath())} */ ${values.join(' | ')}`; + } + + private classify(sym: MorphSymbol): 'named' | 'ambient' | 'inline' { + const decls = sym.getDeclarations(); + if (decls.length === 0) return 'inline'; + const inNodeModules = (file: string) => /[\\/]node_modules[\\/]/.test(file); + if (decls.every((d) => !inNodeModules(d.getSourceFile().getFilePath()))) { + const named = decls.some( + (d) => + Node.isInterfaceDeclaration(d) || + Node.isClassDeclaration(d) || + Node.isTypeAliasDeclaration(d) || + Node.isEnumDeclaration(d), + ); + const generic = decls.some( + (d) => + (Node.isInterfaceDeclaration(d) || + Node.isClassDeclaration(d) || + Node.isTypeAliasDeclaration(d)) && + d.getTypeParameters().length > 0, + ); + return named && !generic ? 'named' : 'inline'; + } + if (decls.every((d) => inNodeModules(d.getSourceFile().getFilePath()))) return 'ambient'; + return 'inline'; + } + + private noteExternal(sym: MorphSymbol): void { + const isTsLib = (file: string) => /[\\/]node_modules[\\/]typescript[\\/]lib[\\/]/.test(file); + if (sym.getDeclarations().some((d) => !isTsLib(d.getSourceFile().getFilePath()))) { + this.externals.add(sym.getName()); + } + } + + private renderNamed(sym: MorphSymbol, expand: () => string): string { + const decl = sym.getDeclarations()[0]; + const origin = + decl !== undefined + ? repoRelative(decl.getSourceFile().getFilePath()) + : '(unknown source)'; + const name = sym.getName(); + if (this.expandingNamed.includes(sym.compilerSymbol)) { + return `/* ${name} — recursive (${origin}) */ unknown`; + } + this.expandingNamed.push(sym.compilerSymbol); + try { + return `/* ${name} — ${origin} */ ${expand()}`; + } finally { + this.expandingNamed.pop(); + } + } + + private tryRenderAlias( + type: MorphType, + location: Node, + depth: number, + skipSymbol?: ts.Symbol, + ): string | undefined { + const alias = type.getAliasSymbol(); + if (alias === undefined || alias.compilerSymbol === skipSymbol) return undefined; + const kind = this.classify(alias); + if (kind === 'named') { + const decl = alias.getDeclarations().find((d) => Node.isTypeAliasDeclaration(d)); + if (decl === undefined || !Node.isTypeAliasDeclaration(decl)) return undefined; + return this.renderNamed(alias, () => + this.renderType(decl.getType(), decl, depth + 1, alias.compilerSymbol), + ); + } + if (kind === 'ambient') { + const args = type.getAliasTypeArguments(); + if (args.length === 0) return undefined; + this.noteExternal(alias); + const rendered = args.map((arg) => this.renderType(arg, location, depth + 1)); + return `${alias.getName()}<${rendered.join(', ')}>`; + } + return undefined; + } + + private renderObjectType( + type: MorphType, + location: Node, + depth: number, + skipSymbol?: ts.Symbol, + ): string { + const alias = this.tryRenderAlias(type, location, depth, skipSymbol); + if (alias !== undefined) return alias; + const sym = type.getSymbol(); + const typeArgs = type.getTypeArguments(); + const anonymous = sym === undefined || /^__(type|object)$/.test(sym.getName()); + if (!anonymous && sym.compilerSymbol !== skipSymbol) { + const kind = this.classify(sym); + if (kind === 'named' && typeArgs.length === 0) { + return this.renderNamed(sym, () => this.renderStructural(type, location, depth)); + } + if (kind === 'ambient') { + this.noteExternal(sym); + const name = sym.getName(); + if (typeArgs.length === 0) return name; + const args = typeArgs.map((arg) => this.renderType(arg, location, depth + 1)); + return `${name}<${args.join(', ')}>`; + } + } + return this.renderStructural(type, location, depth); + } + + private renderStructural(type: MorphType, location: Node, depth: number): string { + if (this.expanding.has(type.compilerType)) { + return this.fallback(type, location, 'cycle expanding'); + } + this.expanding.add(type.compilerType); + try { + const callSignatures = type.getCallSignatures(); + const props = type.getProperties().filter((prop) => this.isPublic(prop)); + const stringIndex = type.getStringIndexType(); + const numberIndex = type.getNumberIndexType(); + if ( + callSignatures.length > 0 && + props.length === 0 && + stringIndex === undefined && + numberIndex === undefined + ) { + if (callSignatures.length === 1 && callSignatures[0] !== undefined) { + return this.renderSignature(callSignatures[0], location, depth, 'arrow'); + } + return callSignatures + .map((sig) => `(${this.renderSignature(sig, location, depth, 'arrow')})`) + .join(' & '); + } + const body = this.renderObjectBody(type, location, depth, callSignatures, props); + if (body.length === 0) return '{}'; + return `{\n${body.join('\n')}\n}`; + } finally { + this.expanding.delete(type.compilerType); + } + } + + private renderObjectBody( + type: MorphType, + location: Node, + depth: number, + callSignatures?: readonly Signature[], + props?: readonly MorphSymbol[], + ): string[] { + const lines: string[] = []; + for (const sig of callSignatures ?? type.getCallSignatures()) { + lines.push(` ${this.renderSignature(sig, location, depth, 'call')};`); + } + for (const prop of props ?? type.getProperties().filter((p) => this.isPublic(p))) { + const decl = prop.getDeclarations()[0]; + const at = decl ?? location; + const propType = prop.getTypeAtLocation(at); + const optional = (prop.getFlags() & ts.SymbolFlags.Optional) !== 0; + const rendered = + optional && propType.isUnion() + ? this.renderUnionMembers( + propType.getUnionTypes().filter((member) => !member.isUndefined()), + at, + depth, + ) + : this.renderType(propType, at, depth + 1); + const readonly = + decl !== undefined && Node.isPropertySignature(decl) && decl.isReadonly() + ? 'readonly ' + : ''; + const propLines = rendered.split('\n'); + propLines[propLines.length - 1] += ';'; + lines.push( + ` ${readonly}${tsFieldKey(stableSymbolKey(prop.getName()))}${optional ? '?' : ''}: ${propLines[0]}`, + ...propLines.slice(1).map((line) => ` ${line}`), + ); + } + const stringIndex = type.getStringIndexType(); + if (stringIndex !== undefined) { + lines.push(` [key: string]: ${this.renderType(stringIndex, location, depth + 1)};`); + } + const numberIndex = type.getNumberIndexType(); + if (numberIndex !== undefined) { + lines.push(` [key: number]: ${this.renderType(numberIndex, location, depth + 1)};`); + } + return lines; + } + + private renderSignature( + sig: Signature, + location: Node, + depth: number, + style: 'arrow' | 'call', + ): string { + const params: string[] = []; + for (const param of sig.getParameters()) { + if (param.getName() === 'this') continue; + const decl = param.getDeclarations()[0]; + const at = decl ?? location; + const paramType = param.getTypeAtLocation(at); + let optional = (param.getFlags() & ts.SymbolFlags.Optional) !== 0; + let rest = false; + if (decl !== undefined && Node.isParameterDeclaration(decl)) { + optional = optional || decl.hasQuestionToken() || decl.getInitializer() !== undefined; + rest = decl.isRestParameter(); + } + const rendered = + optional && paramType.isUnion() + ? this.renderUnionMembers( + paramType.getUnionTypes().filter((member) => !member.isUndefined()), + at, + depth, + ) + : this.renderType(paramType, at, depth + 1); + params.push(`${rest ? '...' : ''}${param.getName()}${optional ? '?' : ''}: ${rendered}`); + } + const returnType = this.renderType(sig.getReturnType(), location, depth + 1); + return style === 'arrow' + ? `(${params.join(', ')}) => ${returnType}` + : `(${params.join(', ')}): ${returnType}`; + } + + private isPublic(prop: MorphSymbol): boolean { + if (prop.getName().startsWith('#')) return false; + return !prop.getDeclarations().some((decl) => { + const modifiers = ts.canHaveModifiers(decl.compilerNode) + ? ts.getModifiers(decl.compilerNode) + : undefined; + return ( + modifiers !== undefined && + modifiers.some( + (m) => + m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword, + ) + ); + }); + } +} + +function renderManifest( + model: StateManifestModel, + project: Project, +): { manifest: string; warnings: readonly string[] } { + const renderer = new TypeRenderer(project); + const byScope = new Map(); + for (const scope of SCOPES) byScope.set(scope.dir, []); + for (const registration of model.registrations) byScope.get(registration.scope)?.push(registration); + for (const [dir, regs] of byScope) { + byScope.set( + dir, + regs.toSorted((a, b) => a.def.keyName.localeCompare(b.def.keyName)), + ); + } + + const sections: string[] = []; + for (const scope of SCOPES) { + const regs = byScope.get(scope.dir) ?? []; + const lines = [ + `/** ${scope.label}-scope keys registered into I${scope.label}StateService. */`, + `export interface ${scope.interfaceName} {`, + ]; + const byFile = new Map(); + for (const r of regs) { + const list = byFile.get(r.def.file) ?? []; + list.push(r); + byFile.set(r.def.file, list); + } + for (const file of [...byFile.keys()].toSorted()) { + lines.push(` // ${srcRelative(file)}`); + for (const r of byFile.get(file) ?? []) { + if (r.def.replayable !== undefined) { + const meta = r.def.replayable; + const flags = [ + meta.durable ? 'durable' : 'transient', + ...(meta.undoable ? ['undoable'] : []), + ]; + lines.push( + ` // replayable · ${flags.join(' · ')} — folds: ${meta.folds.length > 0 ? meta.folds.join(', ') : '(protocol only)'}`, + ); + } + const rendered = renderer.renderKeyType(r.def).split('\n'); + rendered[rendered.length - 1] += ';'; + lines.push(` '${r.def.keyName}': ${rendered[0]}`, ...rendered.slice(1).map((l) => ` ${l}`)); + } + } + lines.push('}'); + lines.push(''); + lines.push(`export type ${scope.keyUnionName} = keyof ${scope.interfaceName};`); + sections.push(lines.join('\n')); + } + + const counts = SCOPES.map((s) => `${s.label}: ${byScope.get(s.dir)?.length ?? 0} keys`).join( + ' · ', + ); + const out: string[] = [ + '// App, Workspace, Session & Agent State Manifest', + '//', + '// Generated by scripts/gen-state-manifest.mts — do not edit by hand.', + '// Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:state-manifest', + '//', + '// Every state key registered into the App-scope IAppStateService, the', + '// Workspace-scope IWorkspaceStateService, the Session-scope', + '// ISessionStateService, or the Agent-scope IAgentStateService (see', + '// src/_base/state/stateRegistry.ts), collected statically from the', + '// `states.contributeState(...)` call sites and the replayable key chains — a', + '// `defineState(...).replayable(...)` key is contributed into the Agent-scope', + '// service by its owner service at construction, and', + '// carries a `// replayable · durable|transient · undoable? — folds: ...` line.', + '// Replayable values are excluded from snapshot()/inspect(). A key defined via', + '// defineState but never registered nor replayable does not appear here. Each entry shows the', + '// compile-time StateKey value type fully expanded inline, so the manifest is', + '// self-contained (no imports, no helper declarations). A named type is marked', + '// at its expansion site with a `/* TypeName — source/file.ts */` comment; a', + '// `/* TypeName — recursive (...) */ unknown` marker stops a recursive expansion.', + '// Lib globals (Map/Set/Record/…) are referenced as-is. Generic instantiations', + '// expand structurally; classes render as their public instance shape. The', + '// defining source file heads each group.', + ]; + if (renderer.externals.size > 0) { + out.push( + '//', + `// External ambient types referenced but not expanded (from node_modules): ${[...renderer.externals].toSorted().join(', ')}`, + ); + } + out.push( + '//', + '// snapshot() returns JSON-safe deep copies of these values: Maps become plain', + '// objects (or [key, value] entry arrays when a key is not string/number), Sets', + '// become arrays, bigints become strings, functions are dropped, circular', + "// references become '(circular)', and class instances collapse to a '(ClassName)'", + '// marker — the wire shape of an entry is the JSON projection of the type here.', + '//', + `// Index (${counts})`, + ); + for (const scope of SCOPES) { + const regs = byScope.get(scope.dir) ?? []; + const width = Math.max(0, ...regs.map((r) => r.def.keyName.length)); + out.push(`// ${scope.label}`); + for (const r of regs) { + out.push(`// ${r.def.keyName.padEnd(width)} ${srcRelative(r.def.file)}`); + } + } + out.push(''); + out.push(...sections.flatMap((section) => [section, ''])); + return { manifest: `${out.join('\n').trimEnd()}\n`, warnings: [...renderer.warnings] }; +} + +interface BuildResult { + readonly model: StateManifestModel; + readonly manifest: string; + readonly warnings: readonly string[]; +} + +function buildAll(): BuildResult { + const project = createProject(); + const defs = collectKeyDefs(project); + const registrations = collectRegistrations(project, defs); + const registered = new Set(registrations.map((r) => r.def)); + for (const def of defs.values()) { + if (def.replayable === undefined) continue; + if (!registered.has(def)) { + throw new Error( + `[gen-state-manifest] replayable state key '${def.keyName}' (${srcRelative(def.file)}) is ` + + 'never contributed — its owner service must contributeState it into the Agent-scope state service.', + ); + } + for (const registration of registrations) { + if (registration.def === def && registration.scope !== 'agent') { + throw new Error( + `[gen-state-manifest] replayable state key '${def.keyName}' (${srcRelative(def.file)}) is ` + + `contributed into the ${registration.scope} scope — replayable keys belong to the Agent scope.`, + ); + } + } + } + const unregistered = [...defs.values()].filter((def) => !registered.has(def)); + const model: StateManifestModel = { registrations, unregistered }; + const { manifest, warnings } = renderManifest(model, project); + return { model, manifest, warnings }; +} + +export function buildStateManifest(): string { + return buildAll().manifest; +} + +function main(): void { + const check = process.argv.includes('--check'); + const { model, manifest, warnings } = buildAll(); + for (const warning of warnings) { + console.warn(`[gen-state-manifest] warning: ${warning}`); + } + if (check) { + let current: string | undefined; + try { + current = readFileSync(MANIFEST_PATH, 'utf-8'); + } catch { + current = undefined; + } + if (current !== manifest) { + console.error( + `[gen-state-manifest] ${relative(process.cwd(), MANIFEST_PATH)} is stale. ` + + 'Regenerate with `pnpm --filter @moonshot-ai/agent-core-v2 gen:state-manifest`.', + ); + process.exit(1); + } + console.log('[gen-state-manifest] up to date'); + return; + } + writeFileSync(MANIFEST_PATH, manifest); + console.log(`[gen-state-manifest] wrote ${relative(process.cwd(), MANIFEST_PATH)}`); + if (model.unregistered.length > 0) { + console.log( + `[gen-state-manifest] note: ${model.unregistered.length} defineState key(s) never registered (excluded):`, + ); + for (const def of model.unregistered) { + console.log(` - ${def.keyName} (${srcRelative(def.file)})`); + } + } +} + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/packages/agent-core-v2/scripts/gen-wire-manifest.mts b/packages/agent-core-v2/scripts/gen-wire-manifest.mts new file mode 100644 index 0000000000000000000000000000000000000000..a51b0a7c11bebb1b359466fe502a93d23a7d3e81 --- /dev/null +++ b/packages/agent-core-v2/scripts/gen-wire-manifest.mts @@ -0,0 +1,1039 @@ +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { EVENT2_REGISTRY } from '#/app/event/event2'; + +import { + asJsonSchema, + describeType, + isRecord, + resolveRef, + toJsonSchema, + truncate, + type JsonSchema, +} from './lib/jsonSchema.mts'; + +const PKG = join(import.meta.dirname, '..'); +const SRC = join(PKG, 'src'); +export const MANIFEST_PATH = join(PKG, 'docs', 'wire-manifest.d.ts'); + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) walk(p, out); + else if (entry.endsWith('.ts')) out.push(p); + } + return out; +} + +const TYPE_DECL_RE = /static\s+override\s+readonly\s+type\s*=\s*'([^']+)'/g; +const DURABLE_DECL_RE = /static\s+override\s+readonly\s+durable\s*=\s*true/; +const CLASS_DECL_RE = /class\s+(\w+)\s+extends\s+(?:AgentEvent2|Event2)/g; + +function scanEventDeclarations(): { + owners: Map; + importFiles: string[]; + durableTypes: Set; + classTypes: Map; +} { + const owners = new Map(); + const importFiles: string[] = []; + const durableTypes = new Set(); + const classTypes = new Map(); + for (const file of walk(SRC)) { + const source = readFileSync(file, 'utf-8'); + const matches = [...source.matchAll(TYPE_DECL_RE)]; + const hasStates = source.includes('.replayable('); + if (matches.length > 0 || hasStates) importFiles.push(file); + for (const [i, match] of matches.entries()) { + const type = match[1]; + if (type === undefined) continue; + owners.set(type, relative(PKG, file)); + const windowEnd = i + 1 < matches.length ? matches[i + 1]!.index : source.length; + if (DURABLE_DECL_RE.test(source.slice(match.index, windowEnd))) durableTypes.add(type); + } + const classMatches = [...source.matchAll(CLASS_DECL_RE)]; + for (const [i, match] of classMatches.entries()) { + const className = match[1]; + if (className === undefined) continue; + const windowEnd = i + 1 < classMatches.length ? classMatches[i + 1]!.index : source.length; + const typeMatch = /static\s+override\s+readonly\s+type\s*=\s*'([^']+)'/.exec( + source.slice(match.index, windowEnd), + ); + if (typeMatch?.[1] !== undefined) classTypes.set(className, typeMatch[1]); + } + } + return { owners, importFiles, durableTypes, classTypes }; +} + +function scanMigrationChain(): string { + const dir = join(SRC, 'wire', 'migration'); + const pairs: { source: string; target: string }[] = []; + for (const entry of readdirSync(dir)) { + if (!/^v[\d.]+\.ts$/.test(entry)) continue; + const source = readFileSync(join(dir, entry), 'utf-8'); + const sourceVersion = /sourceVersion:\s*'([^']+)'/.exec(source)?.[1]; + const targetVersion = /targetVersion:\s*'([^']+)'/.exec(source)?.[1]; + if (sourceVersion !== undefined && targetVersion !== undefined) { + pairs.push({ source: sourceVersion, target: targetVersion }); + } + } + pairs.sort((a, b) => a.source.localeCompare(b.source, undefined, { numeric: true })); + const chain = pairs.flatMap((p, i) => (i === 0 ? [p.source, p.target] : [p.target])); + return chain.join(' -> '); +} + +interface ReplayableStateScan { + readonly keyName: string; + readonly constName: string; + readonly undoable: boolean; + readonly blobs: boolean; + readonly foldClasses: string[]; +} + +const ON_FOLD_RE = /\.on\(\s*([A-Za-z_$][\w$]*)/g; +const KEY_ON_RE = /\b([A-Za-z_$][\w$]*)\.on\(\s*([A-Za-z_$][\w$]*)/g; +const PROTOCOL_EVENT_RE = /(?:appendMessage|applyCompaction|clear|undo):\s*([A-Za-z_$][\w$]*)/g; + +function readCallArguments(text: string, parenIndex: number): string { + let depth = 0; + for (let i = parenIndex; i < text.length; i++) { + const ch = text[i]; + if (ch === "'" || ch === '"' || ch === '`') { + const quote = ch; + i += 1; + while (i < text.length && text[i] !== quote) { + if (text[i] === '\\') i += 1; + i += 1; + } + continue; + } + if (ch === '(') depth += 1; + else if (ch === ')') { + depth -= 1; + if (depth === 0) return text.slice(parenIndex + 1, i); + } + } + return text.slice(parenIndex + 1); +} + +function readChain(source: string, start: number): string { + let depth = 0; + const n = source.length; + for (let i = start; i < n; i++) { + const ch = source[i]; + if (ch === "'" || ch === '"' || ch === '`') { + const quote = ch; + i += 1; + while (i < n && source[i] !== quote) { + if (source[i] === '\\') i += 1; + i += 1; + } + continue; + } + if (ch === '{' || ch === '(' || ch === '[') depth += 1; + else if (ch === '}' || ch === ')' || ch === ']') depth = Math.max(0, depth - 1); + else if (ch === ';' && depth === 0) return source.slice(start, i); + } + return source.slice(start); +} + +function scanReplayableStates(): ReplayableStateScan[] { + const states: ReplayableStateScan[] = []; + const byConst = new Map(); + const constChainRe = + /(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*defineState\(\s*'([^']+)'/g; + for (const file of walk(SRC)) { + const source = readFileSync(file, 'utf-8'); + if (!source.includes('.replayable(') && !source.includes('.on(')) continue; + for (const match of source.matchAll(constChainRe)) { + const constName = match[1]; + const keyName = match[2]; + if (constName === undefined || keyName === undefined) continue; + const chain = readChain(source, source.indexOf('defineState', match.index)); + const replayableIndex = chain.indexOf('.replayable('); + if (replayableIndex === -1) continue; + const replayableArgs = readCallArguments(chain, replayableIndex + '.replayable'.length); + const scan: ReplayableStateScan = { + keyName, + constName, + undoable: chain.includes('.undoable('), + blobs: /\bblobs\s*:/.test(replayableArgs), + foldClasses: [...chain.matchAll(ON_FOLD_RE)].map((m) => m[1]!), + }; + states.push(scan); + byConst.set(constName, scan); + } + } + for (const file of walk(SRC)) { + const source = readFileSync(file, 'utf-8'); + if (!source.includes('.on(')) continue; + for (const match of source.matchAll(KEY_ON_RE)) { + const scan = byConst.get(match[1]!); + const cls = match[2]; + if (scan === undefined || cls === undefined) continue; + if (!scan.foldClasses.includes(cls)) scan.foldClasses.push(cls); + } + } + return states; +} + +function scanUndoableProtocolTypes(classTypes: ReadonlyMap): string[] { + for (const file of walk(SRC)) { + const source = readFileSync(file, 'utf-8'); + const index = source.indexOf('registerUndoableProtocol('); + if (index === -1) continue; + const window = readChain(source, index); + const types: string[] = []; + for (const match of window.matchAll(PROTOCOL_EVENT_RE)) { + const cls = match[1]!; + const type = classTypes.get(cls); + if (type === undefined) { + throw new Error( + `[gen-wire-manifest] undoable protocol event class '${cls}' has no resolved type`, + ); + } + types.push(type); + } + return types; + } + throw new Error('[gen-wire-manifest] registerUndoableProtocol call not found under src/'); +} + +type SketchDict = { [key: string]: Sketch }; +type Sketch = string | SketchDict | [Sketch]; + +const TYPE_KEY = '_type'; +const MORE_KEY = '…'; + +function stringifySketch(sketch: Sketch): string { + if (typeof sketch === 'string') return sketch; + if (Array.isArray(sketch)) { + const inner = stringifySketch(sketch[0]); + return inner.includes('|') ? `(${inner})[]` : `${inner}[]`; + } + return `{ ${Object.entries(sketch) + .map(([k, v]) => `${k}: ${stringifySketch(v)}`) + .join(', ')} }`; +} + +function sketchFromJsonSchema(schema: unknown, root: JsonSchema, depth: number): Sketch { + const resolved = resolveRef(schema, root); + const s = asJsonSchema(resolved); + if (s !== undefined && depth < 4) { + if (isRecord(s.properties) && Object.keys(s.properties).length > 0) { + const required = new Set(Array.isArray(s.required) ? s.required : []); + const dict: SketchDict = {}; + for (const [name, prop] of Object.entries(s.properties)) { + dict[required.has(name) ? name : `${name}?`] = sketchFromJsonSchema(prop, root, depth + 1); + } + return dict; + } + if (s.type === 'array' && s.items !== undefined) { + const inner = sketchFromJsonSchema(s.items, root, depth + 1); + if (typeof inner !== 'string') return [inner]; + } + } + return describeType(resolved, tsQuote); +} + +function buildPayloadSketch( + schema: unknown, + staticSketch?: string | Map, +): Sketch { + const jsonSchema = toJsonSchema(schema); + if (jsonSchema === undefined) { + if (typeof staticSketch === 'string') return staticSketch; + if (staticSketch !== undefined && staticSketch.size > 0) { + return Object.fromEntries(staticSketch); + } + return '(schema uses transforms; see the owner file)'; + } + if (isRecord(jsonSchema.properties) && Object.keys(jsonSchema.properties).length > 0) { + const required = new Set(Array.isArray(jsonSchema.required) ? jsonSchema.required : []); + const dict: SketchDict = {}; + for (const [name, prop] of Object.entries(jsonSchema.properties)) { + dict[required.has(name) ? name : `${name}?`] = sketchFromJsonSchema(prop, jsonSchema, 0); + } + return dict; + } + if ( + jsonSchema.type === 'object' && + (jsonSchema.additionalProperties === undefined || jsonSchema.additionalProperties === false) + ) { + return {}; + } + return describeType(jsonSchema, tsQuote); +} + +function pascalCase(name: string): string { + return name + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part) => (part[0] ?? '').toUpperCase() + part.slice(1)) + .join(''); +} + +function tsFieldKey(key: string): string { + return /^[$A-Z_a-z][$\w]*$/.test(key) ? key : JSON.stringify(key); +} + +function sketchStringToTs(text: string): { type: string; doc?: string } { + let t = text.trim(); + const docs: string[] = []; + const named = /^([A-Z][$\w]*) = ([\s\S]+)$/.exec(t); + if (named?.[1] !== undefined && named[2] !== undefined) { + docs.push(named[1]); + t = named[2].trim(); + } + const spread = /^((?:\.\.\.[$\w]+(?: \+ )?)+) & ([\s\S]+)$/.exec(t); + if (spread?.[1] !== undefined && spread[2] !== undefined) { + docs.push(`shared base: ${spread[1]}`); + t = spread[2].trim(); + } + t = t.replaceAll(/union on [$\w]+: /g, ''); + t = t.replaceAll(/\brecord 0 ? docs.join(' · ') : undefined }; +} + +function renderTsType(sketch: Sketch, indent: string): { doc?: string; lines: string[] } { + if (typeof sketch === 'string') { + const { type, doc } = sketchStringToTs(sketch); + return { doc, lines: [type] }; + } + if (Array.isArray(sketch)) { + const inner = renderTsType(sketch[0], indent); + const lines = [...inner.lines]; + lines[lines.length - 1] += '[]'; + return { doc: inner.doc, lines }; + } + const doc = typeof sketch[TYPE_KEY] === 'string' ? sketch[TYPE_KEY] : undefined; + const lines = ['{']; + emitTsDict(lines, sketch, indent + ' '); + lines.push(`${indent}}`); + return { doc, lines }; +} + +function emitTsDict(lines: string[], dict: SketchDict, indent: string): void { + for (const [key, sketch] of Object.entries(dict)) { + if (key === MORE_KEY) { + lines.push(`${indent}// …`); + continue; + } + if (key === TYPE_KEY) continue; + if (key.startsWith('...')) { + lines.push(`${indent}// spread: ${key}`); + continue; + } + const optional = key.endsWith('?'); + const fieldKey = tsFieldKey(optional ? key.slice(0, -1) : key); + const { doc, lines: typeLines } = renderTsType(sketch, indent); + if (doc !== undefined) lines.push(`${indent}/** ${doc} */`); + lines.push(`${indent}${fieldKey}${optional ? '?' : ''}: ${typeLines[0]}${typeLines.length === 1 ? ';' : ''}`); + if (typeLines.length > 1) { + lines.push(...typeLines.slice(1, -1)); + lines.push(`${typeLines[typeLines.length - 1]};`); + } + } +} + +function renderPayloadDecl( + entry: { type: string }, + owner: string | undefined, + states: string[], + flags: string[], + sketch: Sketch, +): string[] { + const name = `${pascalCase(entry.type)}Payload`; + const nameField = `_name: '${entry.type}';`; + const header = [ + '/**', + ` * states: ${states.length > 0 ? states.join(', ') : '(none)'}${flags.length > 0 ? ` · ${flags.join(' · ')}` : ''}`, + ` * owner: ${owner ?? '(unresolved)'}`, + ]; + if (typeof sketch === 'string') { + const { type, doc } = sketchStringToTs(sketch); + if (type.startsWith('(')) { + header.push(` * ${type.slice(1, -1)}`); + header.push(' */'); + return [...header, `interface ${name} {\n ${nameField}\n}`, '']; + } + if (doc !== undefined) header.push(` * ${doc}`); + header.push(' */'); + return [...header, `type ${name} = { ${nameField} } & (${type});`, '']; + } + if (Array.isArray(sketch)) { + const inner = renderTsType(sketch[0], ' '); + const lines = [...inner.lines]; + lines[lines.length - 1] += '[]'; + header.push(' */'); + if (lines.length === 1) { + return [...header, `type ${name} = { ${nameField} } & (${lines[0]});`, '']; + } + return [ + ...header, + `type ${name} = { ${nameField} } & (${lines[0]}`, + ...lines.slice(1, -1), + `${lines[lines.length - 1]});`, + '', + ]; + } + const payloadType = typeof sketch[TYPE_KEY] === 'string' ? sketch[TYPE_KEY] : undefined; + if (payloadType !== undefined) header.push(` * payload type: ${payloadType}`); + header.push(' */'); + const lines = [...header, `interface ${name} {`, ` ${nameField}`]; + emitTsDict(lines, sketch, ' '); + lines.push('}', ''); + return lines; +} + +function matchDelimiter(source: string, start: number, open: string, close: string): number { + let depth = 0; + for (let i = start; i < source.length; i++) { + const ch = source[i]; + if (ch === '/' && source[i + 1] === '/') { + i = source.indexOf('\n', i); + if (i === -1) return -1; + continue; + } + if (ch === '/' && source[i + 1] === '*') { + i = source.indexOf('*/', i); + if (i === -1) return -1; + i += 1; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + const quote = ch; + i += 1; + while (i < source.length && source[i] !== quote) { + if (source[i] === '\\') i += 1; + i += 1; + } + continue; + } + if (ch === open) depth += 1; + else if (ch === close) { + depth -= 1; + if (depth === 0) return i; + } + } + return -1; +} + +function splitTopLevel(body: string, separators: readonly string[] = [',']): string[] { + const parts: string[] = []; + let depth = 0; + let partStart = 0; + const n = body.length; + for (let i = 0; i < n; i++) { + const ch = body[i]; + if (ch === "'" || ch === '"' || ch === '`') { + const quote = ch; + i += 1; + while (i < n && body[i] !== quote) { + if (body[i] === '\\') i += 1; + i += 1; + } + continue; + } + if (ch === '{' || ch === '(' || ch === '[' || ch === '<') depth += 1; + else if (ch === '}' || ch === ')' || ch === ']' || ch === '>') depth = Math.max(0, depth - 1); + else if (ch !== undefined && depth === 0 && separators.includes(ch)) { + parts.push(body.slice(partStart, i).trim()); + partStart = i + 1; + } + } + parts.push(body.slice(partStart).trim()); + return parts.filter((p) => p !== ''); +} + +function splitObjectFields(body: string): Map { + const fields = new Map(); + for (const part of splitTopLevel(body)) { + const keyMatch = /^([$\w]+|'[^']+'|"[^"]+")\s*:/.exec(part); + if (keyMatch?.[1] !== undefined) { + const key = keyMatch[1].replace(/^['"]|['"]$/g, ''); + fields.set(key, part.slice(keyMatch[0].length).trim()); + } else if (part.startsWith('...')) { + fields.set(part, ''); + } + } + return fields; +} + +function objectBody(text: string, braceIndex: number): string | undefined { + const end = matchDelimiter(text, braceIndex, '{', '}'); + return end === -1 ? undefined : text.slice(braceIndex + 1, end); +} + +function readExpression(source: string, start: number): string { + let depth = 0; + const n = source.length; + for (let i = start; i < n; i++) { + const ch = source[i]; + if (ch === "'" || ch === '"' || ch === '`') { + const quote = ch; + i += 1; + while (i < n && source[i] !== quote) { + if (source[i] === '\\') i += 1; + i += 1; + } + continue; + } + if (ch === '{' || ch === '(' || ch === '[' || ch === '<') depth += 1; + else if (ch === '}' || ch === ')' || ch === ']' || ch === '>') depth = Math.max(0, depth - 1); + else if (ch === ';' && depth === 0) return source.slice(start, i); + } + return source.slice(start); +} + +function tsQuote(raw: string): string { + return raw.includes("'") ? JSON.stringify(raw) : `'${raw}'`; +} + +function escapeRegExp(raw: string): string { + return raw.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function resolveSchemaLiteral(expr: string, source: string, depth = 0): string | undefined { + if (depth > 2) return undefined; + const inline = /^z\.\w*[oO]bject\s*\(/.exec(expr); + if (inline !== null) { + const rest = expr.slice(inline[0].length).trimStart(); + if (rest.startsWith('{')) return objectBody(rest, 0); + const shapeName = /^([$\w]+)/.exec(rest)?.[1]; + if (shapeName !== undefined) { + const constRe = new RegExp(`const\\s+${shapeName}\\s*(?::[^=;]+)?=\\s*\\{`); + const m = constRe.exec(source); + if (m !== null) return objectBody(source, m.index + m[0].length - 1); + } + return undefined; + } + const ident = /^([$\w]+)$/.exec(expr.trim())?.[1]; + if (ident !== undefined) { + const constRe = new RegExp(`const\\s+${ident}\\s*(?::[^=;]+)?=\\s*`); + const m = constRe.exec(source); + if (m !== null) { + const rhs = readExpression(source, m.index + m[0].length).trim(); + return resolveSchemaLiteral(rhs, source, depth + 1); + } + } + return undefined; +} + +interface Budget { + remaining: number; +} + +function spend(budget: Budget): boolean { + if (budget.remaining <= 0) return false; + budget.remaining -= 1; + return true; +} + +const TS_BUDGET = (): Budget => ({ remaining: 24 }); + +const _fileCache = new Map(); + +function readCached(file: string): string { + let text = _fileCache.get(file); + if (text === undefined) { + text = readFileSync(file, 'utf-8'); + _fileCache.set(file, text); + } + return text; +} + +interface TsField { + readonly type: string; + readonly optional: boolean; +} + +function splitTsTypeFields(body: string): Map { + const fields = new Map(); + for (const part of splitTopLevel(body, [';', ','])) { + const m = /^(?:readonly\s+)?([$\w]+|'[^']+'|"[^"]+")\s*(\?)?\s*:\s*(.+)$/.exec(part); + if (m?.[1] !== undefined && m[3] !== undefined) { + fields.set(m[1].replace(/^['"]|['"]$/g, ''), { + type: m[3].trim(), + optional: m[2] !== undefined, + }); + } + } + return fields; +} + +const TS_PRIMITIVES = new Set(['string', 'number', 'boolean', 'unknown', 'any', 'null', 'undefined', 'void']); + +function renderTsFields( + fields: Map, + file: string, + budget: Budget, + charBudget: number, + depth: number, +): SketchDict { + const dict: SketchDict = {}; + let count = 0; + for (const [name, f] of fields) { + if (count >= 8) { + dict[MORE_KEY] = '…'; + break; + } + count += 1; + dict[`${name}${f.optional ? '?' : ''}`] = summarizeTsTypeExpr( + f.type, + file, + budget, + Math.max(120, Math.floor(charBudget / 2)), + depth + 1, + ); + } + return dict; +} + +function findTsTypeDef(name: string, file: string): string | undefined { + const source = readCached(file); + const typeRe = new RegExp(`(?:export\\s+)?type\\s+${name}(?:<[^>;=]*>)?\\s*=\\s*`); + const m = typeRe.exec(source); + if (m !== null) return readExpression(source, m.index + m[0].length).trim(); + const ifaceRe = new RegExp(`(?:export\\s+)?interface\\s+${name}(?:<[^>]*>)?(?:\\s+extends[^{]+)?\\s*\\{`); + const im = ifaceRe.exec(source); + if (im !== null) { + const body = objectBody(source, im.index + im[0].length - 1); + if (body !== undefined) return `{ ${body} }`; + } + return undefined; +} + +function findImportSource(file: string, name: string): string | undefined { + const source = readCached(file); + const re = /(?:import|export)\s+(?:type\s+)?\{([^}]+)\}\s*from\s*'([^']+)'/g; + for (const m of source.matchAll(re)) { + for (const part of m[1]!.split(',')) { + const named = /^(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?$/.exec(part.trim()); + if (named === null) continue; + if ((named[2] ?? named[1]) === name) return m[2]; + } + } + return undefined; +} + +function resolveModuleFile(fromFile: string, specifier: string): string | undefined { + let base: string; + if (specifier.startsWith('#/')) base = join(SRC, specifier.slice(2)); + else if (specifier.startsWith('.')) base = join(dirname(fromFile), specifier); + else return undefined; + for (const candidate of [`${base}.ts`, join(base, 'index.ts')]) { + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +function summarizeTsUnion( + members: string[], + file: string, + budget: Budget, + charBudget: number, + depth: number, +): string { + const resolved = members.map((m) => { + const t = m.trim(); + if (/^[$\w]+$/.test(t)) { + const def = findTsTypeDef(t, file); + if (def !== undefined) return def; + } + return t; + }); + const bodies = resolved.map((m) => (m.trim().startsWith('{') ? objectBody(m.trim(), 0) : undefined)); + if (bodies.length > 0 && bodies.every((b) => b !== undefined)) { + const fieldMaps = bodies.map((b) => splitTsTypeFields(b!)); + for (const [name, info] of fieldMaps[0]!) { + if ( + /^'[^']*'$/.test(info.type) && + fieldMaps.every((fm) => /^'[^']*'$/.test(fm.get(name)?.type ?? '')) + ) { + const values = fieldMaps.map((fm) => tsQuote(fm.get(name)!.type.slice(1, -1))); + return truncate(`union on ${name}: ${values.join(' | ')}`, charBudget); + } + } + return truncate( + fieldMaps + .map((fm) => stringifySketch(renderTsFields(fm, file, budget, charBudget, depth + 1))) + .join(' | '), + charBudget * 2, + ); + } + return truncate( + members + .map((m) => stringifySketch(summarizeTsTypeExpr(m, file, budget, charBudget, depth + 1))) + .join(' | '), + charBudget * 2, + ); +} + +function summarizeTsTypeExpr( + rhs: string, + file: string, + budget: Budget, + charBudget = 480, + depth = 0, +): Sketch { + let text = rhs.replaceAll(/\s+/g, ' ').trim(); + if (text.startsWith('readonly ')) text = text.slice('readonly '.length).trim(); + const literal = /^'([^']*)'$/.exec(text); + if (literal?.[1] !== undefined) return tsQuote(literal[1]); + if (TS_PRIMITIVES.has(text)) return text; + if (text.endsWith('[]')) { + const inner = summarizeTsTypeExpr(text.slice(0, -2), file, budget, charBudget, depth + 1); + if (typeof inner !== 'string') return [inner]; + return inner.includes('|') ? `(${inner})[]` : `${inner}[]`; + } + const members = splitTopLevel(text, ['|']); + if (members.length > 1) { + return spend(budget) + ? summarizeTsUnion(members, file, budget, charBudget, depth) + : truncate(text, 80); + } + const intersections = splitTopLevel(text, ['&']); + if (intersections.length > 1) { + if (!spend(budget)) return truncate(text, 80); + const sides = intersections.map((m) => summarizeTsTypeExpr(m, file, budget, charBudget, depth + 1)); + if (sides.every((side) => typeof side !== 'string' && !Array.isArray(side))) { + return Object.assign({}, ...sides) as SketchDict; + } + return truncate(sides.map(stringifySketch).join(' & '), charBudget * 2); + } + if (text.startsWith('{')) { + if (!spend(budget) || depth >= 4) return 'object'; + const body = objectBody(text, 0); + if (body !== undefined) { + return renderTsFields(splitTsTypeFields(body), file, budget, charBudget, depth); + } + } + if (/^[$\w]+$/.test(text)) { + const summary = summarizeTsType(text, file, budget); + if (summary !== undefined) return summary; + } + return truncate(text, 80); +} + +function summarizeTsType(name: string, fromFile: string, budget: Budget): Sketch | undefined { + if (!spend(budget)) return undefined; + const def = findTsTypeDef(name, fromFile); + if (def !== undefined) return summarizeTsTypeExpr(def, fromFile, budget); + const specifier = findImportSource(fromFile, name); + if (specifier !== undefined) { + const target = resolveModuleFile(fromFile, specifier); + if (target !== undefined) return summarizeTsType(name, target, budget); + } + for (const m of readCached(fromFile).matchAll(/export\s+\*\s+from\s*'([^']+)'/g)) { + const target = m[1] === undefined ? undefined : resolveModuleFile(fromFile, m[1]); + if (target === undefined) continue; + const summary = summarizeTsType(name, target, budget); + if (summary !== undefined) return summary; + } + return undefined; +} + +function friendlyZodExpr(expr: string, ownerFile: string, depth = 0): Sketch { + let text = expr.replaceAll(/\s+/g, ' ').trim(); + let stripped = true; + while (stripped) { + stripped = false; + for (const suffix of ['.optional()', '.nullable()', '.nullish()', '.readonly()']) { + if (text.endsWith(suffix)) { + text = text.slice(0, -suffix.length).trim(); + stripped = true; + } + } + } + const stringLiteral = /^'([^']*)'$/.exec(text); + if (stringLiteral?.[1] !== undefined) return tsQuote(stringLiteral[1]); + const custom = /^z\.custom<(.+)>\(\)$/.exec(text); + if (custom?.[1] !== undefined) { + const typeName = custom[1].trim(); + if (depth > 1) return typeName; + const summary = summarizeTsType(typeName, ownerFile, TS_BUDGET()); + if (summary === undefined) return typeName; + if (typeof summary !== 'string' && !Array.isArray(summary)) { + return { [TYPE_KEY]: typeName, ...summary }; + } + return truncate(`${typeName} = ${stringifySketch(summary)}`, 1024); + } + if (/^z\.string\(\)$/.test(text)) return 'string'; + if (/^z\.number\(\)$/.test(text)) return 'number'; + if (/^z\.boolean\(\)$/.test(text)) return 'boolean'; + if (/^z\.(?:int|integer)\(\)$/.test(text)) return 'integer'; + const array = /^z\.array\((.+)\)$/.exec(text); + if (array?.[1] !== undefined) { + const inner = friendlyZodExpr(array[1], ownerFile, depth + 1); + if (typeof inner !== 'string') return [inner]; + return `${inner}[]`; + } + const literal = /^z\.literal\((.+)\)$/.exec(text); + if (literal?.[1] !== undefined) return friendlyZodExpr(literal[1], ownerFile, depth + 1); + const union = /^z\.union\((.+)\)$/.exec(text); + if (union?.[1] !== undefined) return friendlyZodUnion(union[1], ownerFile, depth); + const enumMatch = /^z\.enum\((.+)\)$/.exec(text); + if (enumMatch?.[1] !== undefined) { + const body = enumMatch[1].trim().replace(/^\[/, '').replace(/\]$/, ''); + return splitTopLevel(body) + .map((member) => stringifySketch(friendlyZodExpr(member, ownerFile, depth + 1))) + .join(' | '); + } + const record = /^z\.record\((.+)\)$/.exec(text); + if (record?.[1] !== undefined) { + const parts = splitTopLevel(record[1]); + if (parts.length === 2) { + return `record`; + } + } + if (/^z\.\w*[oO]bject\(/.test(text)) { + if (depth >= 3) return 'object'; + const body = resolveSchemaLiteral(text, readCached(ownerFile)); + if (body === undefined) return 'object'; + const dict: SketchDict = {}; + for (const [key, fieldExpr] of splitObjectFields(body)) { + if (fieldExpr === '') { + dict[key] = '(spread)'; + continue; + } + const optional = /\.(?:optional|nullish)\(\)$/.test(fieldExpr); + dict[`${key}${optional ? '?' : ''}`] = friendlyZodExpr(fieldExpr, ownerFile, depth + 1); + } + return dict; + } + const ident = /^([$\w]+)$/.exec(text)?.[1]; + if (ident !== undefined && depth < 4) { + const source = readCached(ownerFile); + const constRe = new RegExp(`const\\s+${ident}\\s*(?::[^=;]+)?=\\s*`); + const m = constRe.exec(source); + if (m !== null) { + const rhs = readExpression(source, m.index + m[0].length).trim(); + return friendlyZodExpr(rhs, ownerFile, depth + 1); + } + if (depth <= 1) { + const summary = summarizeTsType(ident, ownerFile, TS_BUDGET()); + if (summary !== undefined) { + if (typeof summary !== 'string' && !Array.isArray(summary)) { + return { [TYPE_KEY]: ident, ...summary }; + } + return truncate(`${ident} = ${stringifySketch(summary)}`, 320); + } + } + } + return truncate(text, 80); +} + +function friendlyZodUnion(body: string, ownerFile: string, depth: number): string { + const members = splitTopLevel(body.trim().replace(/^\[/, '').replace(/\]$/, '')); + const source = readCached(ownerFile); + const bodies = members.map((m) => resolveSchemaLiteral(m, source)); + if (members.length > 0 && bodies.every((b) => b !== undefined)) { + const fieldMaps = bodies.map((b) => splitObjectFields(b)); + const spreadSets = fieldMaps.map((fm) => [...fm.keys()].filter((k) => fm.get(k) === '')); + const commonSpreads = (spreadSets[0] ?? []).filter((s) => + spreadSets.every((set) => set.includes(s)), + ); + const sketches = fieldMaps.map((fm) => { + const dict: SketchDict = {}; + for (const [key, expr] of fm) { + if (expr === '') continue; + const optional = /\.(?:optional|nullish)\(\)$/.test(expr); + dict[`${key}${optional ? '?' : ''}`] = friendlyZodExpr(expr, ownerFile, depth + 1); + } + return stringifySketch(dict); + }); + const prefix = commonSpreads.length > 0 ? `${commonSpreads.join(' + ')} & ` : ''; + return truncate(`${prefix}${sketches.join(' | ')}`, 320); + } + return truncate( + members.map((m) => stringifySketch(friendlyZodExpr(m, ownerFile, depth + 1))).join(' | '), + 320, + ); +} + +function sketchPayloadFromSource( + ownerFile: string, + type: string, +): string | Map | undefined { + const absFile = join(PKG, ownerFile); + const source = readCached(absFile); + const typeRe = new RegExp( + `static\\s+override\\s+readonly\\s+type\\s*=\\s*'${escapeRegExp(type)}'`, + ); + const typeMatch = typeRe.exec(source); + if (typeMatch === null) return undefined; + const rest = source.slice(typeMatch.index + typeMatch[0].length); + const nextType = /static\s+override\s+readonly\s+type\s*=/.exec(rest); + const classWindow = nextType === null ? rest : rest.slice(0, nextType.index); + const schemaMatch = /static\s+override\s+readonly\s+schema\s*=\s*/.exec(classWindow); + if (schemaMatch === null) return undefined; + const schemaExpr = readExpression( + classWindow, + schemaMatch.index + schemaMatch[0].length, + ).trim(); + if (schemaExpr === '') return undefined; + const literal = resolveSchemaLiteral(schemaExpr, source); + if (literal === undefined) { + const sketch = friendlyZodExpr(schemaExpr, absFile); + if (typeof sketch === 'string') return sketch; + if (!Array.isArray(sketch)) return new Map(Object.entries(sketch)); + return stringifySketch(sketch); + } + const sketch = new Map(); + for (const [key, expr] of splitObjectFields(literal)) { + if (expr === '') { + sketch.set(key, '(spread)'); + continue; + } + const optional = /\.(?:optional|nullish)\(\)$/.test(expr); + sketch.set(`${key}${optional ? '?' : ''}`, friendlyZodExpr(expr, absFile)); + } + return sketch; +} + +export async function buildWireManifest(): Promise { + const { owners, importFiles, durableTypes, classTypes } = scanEventDeclarations(); + await import('../src/index.ts'); + for (const file of importFiles) { + await import(relative(join(PKG, 'scripts'), file)); + } + const { WIRE_PROTOCOL_VERSION } = (await import('#/wire/migration/migration')) as { + WIRE_PROTOCOL_VERSION: string; + }; + + const entries = [...EVENT2_REGISTRY.values()].toSorted((a, b) => a.type.localeCompare(b.type)); + const migrationChain = scanMigrationChain(); + + const folding = new Map(); + const protocolTypes = scanUndoableProtocolTypes(classTypes); + for (const state of scanReplayableStates()) { + const eventTypes = new Set(); + for (const cls of state.foldClasses) { + const type = classTypes.get(cls); + if (type === undefined) { + throw new Error( + `[gen-wire-manifest] state '${state.keyName}' folds unresolved event class '${cls}'`, + ); + } + eventTypes.add(type); + } + if (state.undoable) { + for (const type of protocolTypes) eventTypes.add(type); + } + for (const type of eventTypes) { + let info = folding.get(type); + if (info === undefined) { + info = { states: [], blobs: [] }; + folding.set(type, info); + } + info.states.push(state.keyName); + if (state.blobs) info.blobs.push(state.keyName); + } + } + for (const info of folding.values()) { + info.states.sort(); + info.blobs.sort(); + } + + const unregistered = [...durableTypes].filter((type) => !EVENT2_REGISTRY.has(type)); + if (unregistered.length > 0) { + console.error( + `[gen-wire-manifest] declared durable but never registered (no fold, not in EVENT2_REGISTRY): ${unregistered.toSorted().join(', ')}`, + ); + } + + const out: string[] = [ + '// Wire Protocol Manifest', + '//', + '// Generated by scripts/gen-wire-manifest.mts — do not edit by hand.', + '// Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest', + '//', + `// protocol_version: "${WIRE_PROTOCOL_VERSION}" (migrations: ${migrationChain})`, + '//', + '// One declaration per durable record type — an Event2 subclass declaring', + '// `static type` + `static durable = true` + `static schema` — drained from the', + '// runtime EVENT2_REGISTRY ("import = register"). Every payload declaration', + '// carries its record type in a `_name` field. Payload sketches use TypeScript', + '// type syntax; when a named type is expanded inline, its name appears as a doc', + '// comment (`/** ContextMessage */`). Bare type names (ContentPart,', + '// ContextMessage, …) refer to the real types in src/ — they are intentionally', + '// not resolved here. `// …` marks a capped field list. On disk (wire.jsonl)', + '// the journal opens with a metadata line {"type": "metadata",', + '// "protocol_version", "created_at"}; each record is {"type", ...payload,', + '// "time"} — object payloads spread at the top level.', + '//', + '// Every listed type is durable by construction — transient Event2 classes', + '// never enter EVENT2_REGISTRY, so there is no persisted flag. Declaration', + '// header lines: states (every state folding this record type on dispatch and', + '// replay; any state beyond the first is what the retired format listed as', + '// cross-reducers), blobs (the folding states whose blob codec offloads inline', + '// media to blob storage), owner (the source file declaring the class).', + '', + `// Index (${entries.length} record types)`, + ]; + const width = Math.max(...entries.map((e) => e.type.length)); + const statesWidth = Math.max( + ...entries.map((e) => (folding.get(e.type)?.states.join(', ') ?? '(none)').length), + ); + for (const entry of entries) { + const states = folding.get(entry.type)?.states.join(', ') ?? '(none)'; + out.push( + `// ${entry.type.padEnd(width)} ${states.padEnd(statesWidth)} ${owners.get(entry.type) ?? '(unresolved)'}`, + ); + } + out.push(''); + const declNames: [string, string][] = []; + for (const entry of entries) { + const info = folding.get(entry.type); + const states = info?.states ?? []; + const flags: string[] = []; + if (info !== undefined && info.blobs.length > 0) flags.push(`blobs: ${info.blobs.join(', ')}`); + const owner = owners.get(entry.type); + const staticSketch = + owner === undefined ? undefined : sketchPayloadFromSource(owner, entry.type); + const sketch = buildPayloadSketch(entry.schema as unknown, staticSketch); + out.push(...renderPayloadDecl(entry, owner, states, flags, sketch)); + declNames.push([entry.type, `${pascalCase(entry.type)}Payload`]); + } + + out.push('/** Record type → payload sketch. */'); + out.push('interface WirePayloadMap {'); + for (const [type, declName] of declNames) { + out.push(` ${JSON.stringify(type)}: ${declName};`); + } + out.push('}'); + out.push(''); + return out.join('\n'); +} + +async function main(): Promise { + const check = process.argv.includes('--check'); + const manifest = await buildWireManifest(); + if (check) { + let current: string | undefined; + try { + current = readFileSync(MANIFEST_PATH, 'utf-8'); + } catch { + current = undefined; + } + if (current !== manifest) { + console.error( + `[gen-wire-manifest] ${relative(process.cwd(), MANIFEST_PATH)} is stale. ` + + 'Regenerate with `pnpm --filter @moonshot-ai/agent-core-v2 gen:wire-manifest`.', + ); + process.exit(1); + } + console.log('[gen-wire-manifest] up to date'); + return; + } + writeFileSync(MANIFEST_PATH, manifest); + console.log(`[gen-wire-manifest] wrote ${relative(process.cwd(), MANIFEST_PATH)}`); +} + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/packages/agent-core-v2/scripts/generate-webp-dec-wasm.mjs b/packages/agent-core-v2/scripts/generate-webp-dec-wasm.mjs new file mode 100644 index 0000000000000000000000000000000000000000..04ac72d65a5362ac13d50a32a8beeaeb9f9d2fdc --- /dev/null +++ b/packages/agent-core-v2/scripts/generate-webp-dec-wasm.mjs @@ -0,0 +1,24 @@ +import { createRequire } from 'node:module'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const packageRoot = resolve(import.meta.dirname, '..'); +const require = createRequire(resolve(packageRoot, 'package.json')); + +const wasmPath = require.resolve('@jsquash/webp/codec/dec/webp_dec.wasm'); +const version = require('@jsquash/webp/package.json').version; +const wasm = readFileSync(wasmPath); + +const target = resolve(packageRoot, 'src/agent/media/webp-dec-wasm.ts'); +writeFileSync( + target, + `// GENERATED FILE — do not edit by hand. +// WebP decoder wasm from @jsquash/webp@${version} (codec/dec/webp_dec.wasm), +// base64-encoded so the bundled CLI needs no on-disk wasm asset. +// Regenerate with: node scripts/generate-webp-dec-wasm.mjs + +export const WEBP_DECODER_WASM_BASE64 = + '${wasm.toString('base64')}'; +`, +); +console.log(`Wrote ${target} (${wasm.length} bytes of wasm)`); diff --git a/packages/agent-core-v2/src/_base/asyncEventQueue.ts b/packages/agent-core-v2/src/_base/asyncEventQueue.ts new file mode 100644 index 0000000000000000000000000000000000000000..e49240fd7202df80ca03ae1263c8149cc9d6b365 --- /dev/null +++ b/packages/agent-core-v2/src/_base/asyncEventQueue.ts @@ -0,0 +1,58 @@ +export class AsyncEventQueue implements AsyncIterable, AsyncIterator { + private readonly values: T[] = []; + private readonly waiters: Array<{ + resolve: (result: IteratorResult) => void; + reject: (reason?: unknown) => void; + }> = []; + private error: unknown; + private failed = false; + private ended = false; + + push(value: T): void { + if (this.failed || this.ended) return; + const waiter = this.waiters.shift(); + if (waiter !== undefined) { + waiter.resolve({ done: false, value }); + return; + } + this.values.push(value); + } + + end(): void { + if (this.failed || this.ended) return; + this.ended = true; + for (const waiter of this.waiters.splice(0)) { + waiter.resolve({ done: true, value: undefined }); + } + } + + fail(error: unknown): void { + if (this.failed || this.ended) return; + this.error = error; + this.failed = true; + if (this.values.length > 0) return; + for (const waiter of this.waiters.splice(0)) { + waiter.reject(error); + } + } + + next(): Promise> { + if (this.values.length > 0) { + const value = this.values.shift()!; + return Promise.resolve({ done: false, value }); + } + if (this.failed) { + return Promise.reject(this.error); + } + if (this.ended) { + return Promise.resolve({ done: true, value: undefined }); + } + return new Promise>((resolve, reject) => { + this.waiters.push({ resolve, reject }); + }); + } + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } +} diff --git a/packages/agent-core-v2/src/_base/event.ts b/packages/agent-core-v2/src/_base/event.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c53bb85aeeaa32eeaa177c9b53662486d29c856 --- /dev/null +++ b/packages/agent-core-v2/src/_base/event.ts @@ -0,0 +1,281 @@ +import { onUnexpectedError, safelyCallListener } from './errors/unexpectedError'; +import { + Disposable, + DisposableStore, + combinedDisposable, + type IDisposable, + type IDisposableDebugLabel, +} from './di/lifecycle'; +import { LinkedList } from './di/util/linkedList'; + +export interface Event { + ( + listener: (e: T) => unknown, + thisArg?: unknown, + disposables?: IDisposable[] | DisposableStore, + ): IDisposable; +} + +interface ListenerEntry { + listener: (e: T) => unknown; + thisArg: unknown; +} + +export class EventSubscription implements IDisposable, IDisposableDebugLabel { + readonly debugLabel: string | undefined; + private _removed = false; + + constructor( + debugName: string | undefined, + private readonly _remove: () => void, + ) { + this.debugLabel = debugName === undefined ? undefined : `on:${debugName}`; + } + + dispose(): void { + if (this._removed) return; + this._removed = true; + this._remove(); + } +} + +export class Emitter { + protected _listeners: Set> | undefined; + private _disposed = false; + private _event: Event | undefined; + + constructor(public readonly debugName?: string) {} + + get event(): Event { + this._event ??= (listener, thisArg, disposables) => { + if (this._disposed) { + return Disposable.None; + } + this._listeners ??= new Set(); + const entry: ListenerEntry = { listener, thisArg }; + this._listeners.add(entry); + + const subscription = new EventSubscription(this.debugName, () => { + if (this._disposed) { + return; + } + this._listeners?.delete(entry); + }); + + if (disposables !== undefined) { + if (disposables instanceof DisposableStore) { + disposables.add(subscription); + } else { + disposables.push(subscription); + } + } + return subscription; + }; + return this._event; + } + + get listenerCount(): number { + return this._listeners?.size ?? 0; + } + + fire(value: T): void { + if (this._disposed || this._listeners === undefined) { + return; + } + const snapshot = Array.from(this._listeners); + for (const entry of snapshot) { + safelyCallListener(() => { + entry.listener.call(entry.thisArg, value); + }); + } + } + + dispose(): void { + if (this._disposed) return; + this._disposed = true; + this._listeners?.clear(); + this._listeners = undefined; + } + + get isDisposed(): boolean { + return this._disposed; + } +} + +export interface IWaitUntil { + readonly signal: AbortSignal; + waitUntil(thenable: Promise): void; +} + +export type IWaitUntilData = Omit; + +export class AsyncEmitter extends Emitter { + private _asyncDeliveryQueue?: LinkedList<[(event: T) => void, IWaitUntilData]>; + + async fireAsyncConcurrent(data: IWaitUntilData, signal: AbortSignal): Promise { + if (this.isDisposed || this._listeners === undefined || signal.aborted) { + return; + } + const snapshot = Array.from(this._listeners); + await Promise.all( + snapshot.map((entry) => + this.deliverAsync( + (event) => { + entry.listener.call(entry.thisArg, event); + }, + data, + signal, + ), + ), + ); + } + + async fireAsync(data: IWaitUntilData, signal: AbortSignal): Promise { + if (this.isDisposed || this._listeners === undefined) { + return; + } + + this._asyncDeliveryQueue ??= new LinkedList(); + for (const entry of this._listeners) { + this._asyncDeliveryQueue.push([ + (event) => { + entry.listener.call(entry.thisArg, event); + }, + data, + ]); + } + + while (this._asyncDeliveryQueue.size > 0 && !signal.aborted) { + const [deliver, eventData] = this._asyncDeliveryQueue.shift()!; + await this.deliverAsync(deliver, eventData, signal); + } + } + + private async deliverAsync( + deliver: (event: T) => void, + data: IWaitUntilData, + signal: AbortSignal, + ): Promise { + const thenables: Promise[] = []; + const event = { + ...data, + signal, + waitUntil: (p: Promise): void => { + if (Object.isFrozen(thenables)) { + throw new Error('waitUntil can NOT be called asynchronously'); + } + thenables.push(p); + }, + } as T; + try { + deliver(event); + } catch (error) { + onUnexpectedError(error); + return; + } + void Object.freeze(thenables); + const settled = await Promise.allSettled(thenables); + for (const result of settled) { + if (result.status === 'rejected') { + onUnexpectedError(result.reason); + } + } + } +} + +export function handleVetos( + vetos: (boolean | Promise)[], + onError: (error: unknown) => void, +): Promise { + if (vetos.length === 0) { + return Promise.resolve(false); + } + + const promises: Promise[] = []; + let lazyValue = false; + + for (const valueOrPromise of vetos) { + if (valueOrPromise === true) { + return Promise.resolve(true); + } + if (typeof valueOrPromise === 'boolean') { + continue; + } + promises.push( + valueOrPromise.then( + (value) => { + if (value) { + lazyValue = true; + } + }, + (error) => { + onError(error); + lazyValue = true; + }, + ), + ); + } + + return Promise.allSettled(promises).then(() => lazyValue); +} + +export namespace Event { + export const None: Event = () => Disposable.None; + + export function once(event: Event): Event { + return (listener, thisArg, disposables) => { + let fired = false; + const subscription = event( + (e) => { + if (fired) return; + fired = true; + subscription.dispose(); + try { + listener.call(thisArg, e); + } catch (error) { + onUnexpectedError(error); + } + }, + undefined, + disposables, + ); + return subscription; + }; + } + + export function map(event: Event, map: (i: I) => O): Event { + return (listener, thisArg, disposables) => + event( + (i) => listener.call(thisArg, map(i)), + undefined, + disposables, + ); + } + + export function filter(event: Event, filter: (e: T) => boolean): Event { + return (listener, thisArg, disposables) => + event( + (e) => { + if (filter(e)) listener.call(thisArg, e); + }, + undefined, + disposables, + ); + } + + export function any(...events: Event[]): Event { + return (listener, thisArg, disposables) => { + const combined = combinedDisposable( + ...events.map((e) => e((value) => listener.call(thisArg, value))), + ); + if (disposables !== undefined) { + if (disposables instanceof DisposableStore) { + disposables.add(combined); + } else { + disposables.push(combined); + } + } + return combined; + }; + } +} diff --git a/packages/agent-core-v2/src/_base/version.ts b/packages/agent-core-v2/src/_base/version.ts new file mode 100644 index 0000000000000000000000000000000000000000..baff627592258d1d9e4520504a1e8aed98d91dfe --- /dev/null +++ b/packages/agent-core-v2/src/_base/version.ts @@ -0,0 +1,3 @@ +export function getCoreVersion(): string { + return '0.0.0'; +} diff --git a/packages/agent-core-v2/src/app/scopes.ts b/packages/agent-core-v2/src/app/scopes.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5d23c5343f935935ccc43788606bf0ba3a883cb --- /dev/null +++ b/packages/agent-core-v2/src/app/scopes.ts @@ -0,0 +1,15 @@ +import { setScopeTopology } from '#/_base/di/scope'; + +export enum LifecycleScope { + App = 'app', + Session = 'session', + Agent = 'agent', +} + +export const SCOPE_TOPOLOGY: readonly LifecycleScope[] = [ + LifecycleScope.App, + LifecycleScope.Session, + LifecycleScope.Agent, +]; + +setScopeTopology(SCOPE_TOPOLOGY); diff --git a/packages/agent-core-v2/src/debug/debugCascade.ts b/packages/agent-core-v2/src/debug/debugCascade.ts new file mode 100644 index 0000000000000000000000000000000000000000..be4eef72d70222960d79b9766fb3dc493894dbd1 --- /dev/null +++ b/packages/agent-core-v2/src/debug/debugCascade.ts @@ -0,0 +1,63 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import type { CascadeAction, UnitState } from '#/_base/di/cascadeEngine'; +import { createDecorator } from '#/_base/di/instantiation'; +import { Event2 } from '#/app/event/event2'; + +export interface DebugCascadeEntry { + readonly scopePath: string; + readonly seq: number; + readonly reason: string; + readonly changes: ReadonlyArray<{ token: string; action: CascadeAction }>; + readonly affected: readonly string[]; + readonly tornDown: readonly string[]; + readonly rebuilt: readonly string[]; + readonly failed: readonly string[]; + readonly abortWaited: boolean; + readonly abortTimedOut: boolean; + readonly durationMs: number; +} + +export interface DebugPendingUnit { + readonly token: string; + readonly missing: string[]; +} + +export interface DebugFailedUnit { + readonly token: string; + readonly error?: string; +} + +export interface DebugPendingGroup { + readonly scopePath: string; + readonly waiting: DebugPendingUnit[]; + readonly failed: DebugFailedUnit[]; +} + +export interface DiUnitChangedPayload { + readonly scope: string; + readonly token: string; + readonly state: UnitState; + readonly error?: string; +} + +export class DiUnitChanged extends Event2<{ readonly payload: DiUnitChangedPayload }> { + static override readonly type = 'event.di.unit_changed'; +} +export interface DiUnitChanged { + readonly payload: DiUnitChangedPayload; +} + +export const DI_UNIT_CHANGED_EVENT = DiUnitChanged.type; + +export interface IDebugCascadeService { + readonly _serviceBrand: undefined; + + history(): DebugCascadeEntry[]; + pending(): DebugPendingGroup[]; + unprovide(scopePath: string, token: string): Promise; + update(scopePath: string, token: string, config?: unknown): Promise; + dispose(scopePath: string, token: string): Promise; +} + +export const IDebugCascadeService = + createDecorator('debugCascadeService'); diff --git a/packages/agent-core-v2/src/debug/debugCascadeService.ts b/packages/agent-core-v2/src/debug/debugCascadeService.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad01fa20e3d2438299228a802212727cee71a28e --- /dev/null +++ b/packages/agent-core-v2/src/debug/debugCascadeService.ts @@ -0,0 +1,183 @@ +import type { CascadeEngine } from '#/_base/di/cascadeEngine'; +import { + IInstantiationService, + type ServiceIdentifier, +} from '#/_base/di/instantiation'; +import type { InstantiationService } from '#/_base/di/instantiationService'; +import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IEventService } from '#/app/event/event'; +import { LifecycleScope } from '#/app/scopes'; +import { Error2, ErrorCodes } from '#/errors'; + +import { + DiUnitChanged, + IDebugCascadeService, + type DebugCascadeEntry, + type DebugFailedUnit, + type DebugPendingGroup, + type DebugPendingUnit, + type DiUnitChangedPayload, +} from './debugCascade'; +import { + resolveScopeContainer, + scopePathOfEngine, + walkScopeContainers, +} from './scopeTree'; + +export class DebugCascadeService implements IDebugCascadeService { + declare readonly _serviceBrand: undefined; + + private readonly root: InstantiationService; + private readonly events: IEventService; + private readonly store = new DisposableStore(); + private readonly engineSubscriptions = new Map(); + private tornDown = false; + + constructor( + @IInstantiationService instantiation: IInstantiationService, + @IEventService events: IEventService, + ) { + this.root = instantiation as InstantiationService; + this.events = events; + const tree = this.root.cascadeTree; + for (const engine of tree.engines) { + this._watchEngine(engine); + } + this.store.add( + tree.onDidAddEngine((engine) => { + this._watchEngine(engine); + }), + ); + this.store.add( + tree.onDidRemoveEngine((engine) => { + this._unwatchEngine(engine); + }), + ); + } + + history(): DebugCascadeEntry[] { + const entries: DebugCascadeEntry[] = []; + for (const info of walkScopeContainers(this.root)) { + for (const entry of info.container.cascade.history()) { + entries.push({ scopePath: info.path, ...entry }); + } + } + return entries.toSorted( + (a, b) => a.seq - b.seq || a.scopePath.localeCompare(b.scopePath), + ); + } + + pending(): DebugPendingGroup[] { + const groups: DebugPendingGroup[] = []; + for (const info of walkScopeContainers(this.root)) { + const waiting: DebugPendingUnit[] = []; + for (const [token, missing] of info.container.cascade.pendingSnapshot()) { + waiting.push({ token, missing: [...missing] }); + } + const failed: DebugFailedUnit[] = info.container.cascade + .unitsSnapshot() + .filter((unit) => unit.state === 'Failed') + .map((unit) => ({ token: unit.token, error: unit.error })); + if (waiting.length > 0 || failed.length > 0) { + groups.push({ scopePath: info.path, waiting, failed }); + } + } + return groups; + } + + async unprovide(scopePath: string, token: string): Promise { + const { container, id } = this._resolve(scopePath, token); + container.unprovide(id); + await container.cascade.whenIdle(); + } + + async update(scopePath: string, token: string, config?: unknown): Promise { + const { container, id } = this._resolve(scopePath, token); + if (config === undefined) { + await container.cascade.update(id, `debug update ${token}`); + } else { + await container.fiberHost.updateToken(id, config, true); + } + } + + async dispose(scopePath: string, token: string): Promise; + dispose(): void; + async dispose(scopePath?: string, token?: string): Promise { + if (scopePath === undefined && token === undefined) { + if (!this.tornDown) { + this.tornDown = true; + this.store.dispose(); + for (const subscription of this.engineSubscriptions.values()) { + subscription.dispose(); + } + this.engineSubscriptions.clear(); + } + return; + } + if (scopePath === undefined || token === undefined) { + throw new Error2( + ErrorCodes.DEBUG_TOKEN_NOT_FOUND, + 'dispose requires both a scope path and a token', + ); + } + const { container, id } = this._resolve(scopePath, token); + await container.cascade.submit({ + action: 'unprovide', + token: id, + reason: `debug dispose ${token}`, + }); + } + + private _resolve( + scopePath: string, + token: string, + ): { container: InstantiationService; id: ServiceIdentifier } { + const container = resolveScopeContainer(this.root, scopePath); + if (container === undefined) { + throw new Error2( + ErrorCodes.DEBUG_SCOPE_NOT_FOUND, + `no DI container at scope path '${scopePath}'`, + ); + } + const id = container.findIdentifier(token); + if (id === undefined) { + throw new Error2( + ErrorCodes.DEBUG_TOKEN_NOT_FOUND, + `token '${token}' is not registered in container '${scopePath}'`, + ); + } + return { container, id }; + } + + private _watchEngine(engine: CascadeEngine): void { + if (this.engineSubscriptions.has(engine)) { + return; + } + this.engineSubscriptions.set( + engine, + engine.onDidChangeUnitState((change) => { + const payload: DiUnitChangedPayload = { + scope: scopePathOfEngine(this.root, engine) ?? '#unknown', + token: change.token, + state: change.state, + error: change.error, + }; + this.events.publish(new DiUnitChanged({ payload })); + }), + ); + } + + private _unwatchEngine(engine: CascadeEngine): void { + this.engineSubscriptions.get(engine)?.dispose(); + this.engineSubscriptions.delete(engine); + } +} + +registerScopedService( + LifecycleScope.App, + IDebugCascadeService, + DebugCascadeService, + ScopeActivation.OnScopeCreated, + 'debug', +); diff --git a/packages/agent-core-v2/src/debug/debugGraph.ts b/packages/agent-core-v2/src/debug/debugGraph.ts new file mode 100644 index 0000000000000000000000000000000000000000..02c98fe2b48e5f01f3427c6d501420f9f671f33e --- /dev/null +++ b/packages/agent-core-v2/src/debug/debugGraph.ts @@ -0,0 +1,30 @@ +import type { UnitState } from '#/_base/di/cascadeEngine'; +import type { DependencyEdgeKind } from '#/_base/di/dependencyGraph'; +import { createDecorator } from '#/_base/di/instantiation'; + +export interface DebugGraphNode { + readonly id: string; + readonly token: string; + readonly scopePath: string; + readonly uid?: number; + readonly state?: UnitState; +} + +export interface DebugGraphEdge { + readonly from: string; + readonly to: string; + readonly kind: DependencyEdgeKind; +} + +export interface DebugGraph { + readonly nodes: DebugGraphNode[]; + readonly edges: DebugGraphEdge[]; +} + +export interface IDebugGraphService { + readonly _serviceBrand: undefined; + + graph(): DebugGraph; +} + +export const IDebugGraphService = createDecorator('debugGraphService'); diff --git a/packages/agent-core-v2/src/debug/debugGraphService.ts b/packages/agent-core-v2/src/debug/debugGraphService.ts new file mode 100644 index 0000000000000000000000000000000000000000..1bd2e49d0b9cb189447aeaf5b8d3fc3c1c0cfeae --- /dev/null +++ b/packages/agent-core-v2/src/debug/debugGraphService.ts @@ -0,0 +1,76 @@ +import { IInstantiationService } from '#/_base/di/instantiation'; +import type { InstantiationService } from '#/_base/di/instantiationService'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { + IDebugGraphService, + type DebugGraph, + type DebugGraphEdge, + type DebugGraphNode, +} from './debugGraph'; +import { walkScopeContainers } from './scopeTree'; + +export class DebugGraphService implements IDebugGraphService { + declare readonly _serviceBrand: undefined; + + private readonly root: InstantiationService; + + constructor(@IInstantiationService instantiation: IInstantiationService) { + this.root = instantiation as InstantiationService; + } + + graph(): DebugGraph { + const infos = walkScopeContainers(this.root); + const pathByContainer = new Map( + infos.map((info) => [info.container, info.path]), + ); + const nodes = new Map(); + for (const info of infos) { + const states = new Map( + info.container.cascade.unitsSnapshot().map((unit) => [unit.token, unit]), + ); + for (const registration of info.container.servicesSnapshot()) { + const id = nodeId(info.path, registration.token); + nodes.set(id, { + id, + token: registration.token, + scopePath: info.path, + uid: registration.uid, + state: states.get(registration.token)?.state, + }); + } + } + const pathOf = (scope: object): string => + pathByContainer.get(scope as InstantiationService) ?? + `#${this.root.cascadeTree.seqOf(scope)}`; + const edges: DebugGraphEdge[] = []; + const endpointNode = (path: string, token: string): string => { + const id = nodeId(path, token); + if (!nodes.has(id)) { + nodes.set(id, { id, token, scopePath: path }); + } + return id; + }; + for (const edge of this.root.dependencyGraph.edges()) { + edges.push({ + from: endpointNode(pathOf(edge.consumer.scope), edge.consumer.token.toString()), + to: endpointNode(pathOf(edge.dependency.scope), edge.dependency.token.toString()), + kind: edge.kind, + }); + } + return { nodes: [...nodes.values()], edges }; + } +} + +function nodeId(scopePath: string, token: string): string { + return `${scopePath}::${token}`; +} + +registerScopedService( + LifecycleScope.App, + IDebugGraphService, + DebugGraphService, + ScopeActivation.OnDemand, + 'debug', +); diff --git a/packages/agent-core-v2/src/debug/debugLedger.ts b/packages/agent-core-v2/src/debug/debugLedger.ts new file mode 100644 index 0000000000000000000000000000000000000000..da61a7b85bd536a76210a43325159018c0dfce49 --- /dev/null +++ b/packages/agent-core-v2/src/debug/debugLedger.ts @@ -0,0 +1,29 @@ +import type { UnitState } from '#/_base/di/cascadeEngine'; +import { createDecorator } from '#/_base/di/instantiation'; +import type { LedgerEntryInfo } from '#/_base/lifecycle/ledger'; + +export interface DebugUnit { + readonly token: string; + readonly uid: number; + readonly state?: UnitState; + readonly error?: string; + readonly everActive?: boolean; + readonly inFlight?: boolean; +} + +export interface DebugLedgerNode { + readonly path: string; + readonly label: string; + readonly units: DebugUnit[]; + readonly ledger: LedgerEntryInfo[]; + readonly children: DebugLedgerNode[]; +} + +export interface IDebugLedgerService { + readonly _serviceBrand: undefined; + + tree(): DebugLedgerNode; +} + +export const IDebugLedgerService = + createDecorator('debugLedgerService'); diff --git a/packages/agent-core-v2/src/debug/debugLedgerService.ts b/packages/agent-core-v2/src/debug/debugLedgerService.ts new file mode 100644 index 0000000000000000000000000000000000000000..6499e14cd3a6ef63a7326dc98730117c596e1c21 --- /dev/null +++ b/packages/agent-core-v2/src/debug/debugLedgerService.ts @@ -0,0 +1,56 @@ +import { IInstantiationService } from '#/_base/di/instantiation'; +import type { InstantiationService } from '#/_base/di/instantiationService'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; + +import { IDebugLedgerService, type DebugLedgerNode, type DebugUnit } from './debugLedger'; +import { scopeContainerLabel } from './scopeTree'; + +export class DebugLedgerService implements IDebugLedgerService { + declare readonly _serviceBrand: undefined; + + private readonly root: InstantiationService; + + constructor(@IInstantiationService instantiation: IInstantiationService) { + this.root = instantiation as InstantiationService; + } + + tree(): DebugLedgerNode { + return this._node(this.root, scopeContainerLabel(this.root)); + } + + private _node(container: InstantiationService, path: string): DebugLedgerNode { + return { + path, + label: scopeContainerLabel(container), + units: joinUnits(container), + ledger: container.ledger.entries(), + children: container.children.map((child) => + this._node(child, `${path}/${scopeContainerLabel(child)}`), + ), + }; + } +} + +function joinUnits(container: InstantiationService): DebugUnit[] { + const states = new Map(container.cascade.unitsSnapshot().map((unit) => [unit.token, unit])); + return container.servicesSnapshot().map((registration) => { + const unit = states.get(registration.token); + return { + token: registration.token, + uid: registration.uid, + state: unit?.state, + error: unit?.error, + everActive: unit?.everActive, + inFlight: unit?.inFlight, + }; + }); +} + +registerScopedService( + LifecycleScope.App, + IDebugLedgerService, + DebugLedgerService, + ScopeActivation.OnDemand, + 'debug', +); diff --git a/packages/agent-core-v2/src/debug/errors.ts b/packages/agent-core-v2/src/debug/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7d832e039cacae548f2067472dacafd023a756d --- /dev/null +++ b/packages/agent-core-v2/src/debug/errors.ts @@ -0,0 +1,10 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const DebugErrors = { + codes: { + DEBUG_SCOPE_NOT_FOUND: 'debug.scope_not_found', + DEBUG_TOKEN_NOT_FOUND: 'debug.token_not_found', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(DebugErrors); diff --git a/packages/agent-core-v2/src/debug/index.ts b/packages/agent-core-v2/src/debug/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7809d003d1baf81498dae2b0eb149cc3131bccb --- /dev/null +++ b/packages/agent-core-v2/src/debug/index.ts @@ -0,0 +1,7 @@ +export * from './debugLedger'; +export * from './debugGraph'; +export * from './debugCascade'; +export * from './errors'; +export * from './debugLedgerService'; +export * from './debugGraphService'; +export * from './debugCascadeService'; diff --git a/packages/agent-core-v2/src/debug/scopeTree.ts b/packages/agent-core-v2/src/debug/scopeTree.ts new file mode 100644 index 0000000000000000000000000000000000000000..a25a6af395cd22ef3bb0cc71f7c217e31c4086e4 --- /dev/null +++ b/packages/agent-core-v2/src/debug/scopeTree.ts @@ -0,0 +1,38 @@ +import type { CascadeEngine } from '#/_base/di/cascadeEngine'; +import type { InstantiationService } from '#/_base/di/instantiationService'; + +export interface ScopeContainerInfo { + readonly container: InstantiationService; + readonly path: string; + readonly label: string; +} + +export function scopeContainerLabel(container: InstantiationService): string { + return container.debugLabel ?? `#${container.cascadeTree.seqOf(container)}`; +} + +export function walkScopeContainers(root: InstantiationService): ScopeContainerInfo[] { + const out: ScopeContainerInfo[] = []; + const visit = (container: InstantiationService, path: string): void => { + out.push({ container, path, label: scopeContainerLabel(container) }); + for (const child of container.children) { + visit(child, `${path}/${scopeContainerLabel(child)}`); + } + }; + visit(root, scopeContainerLabel(root)); + return out; +} + +export function resolveScopeContainer( + root: InstantiationService, + path: string, +): InstantiationService | undefined { + return walkScopeContainers(root).find((info) => info.path === path)?.container; +} + +export function scopePathOfEngine( + root: InstantiationService, + engine: CascadeEngine, +): string | undefined { + return walkScopeContainers(root).find((info) => info.container.cascade === engine)?.path; +} diff --git a/packages/agent-core-v2/src/env.d.ts b/packages/agent-core-v2/src/env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..88d404d0aeceefe0515e69718066bd899426d263 --- /dev/null +++ b/packages/agent-core-v2/src/env.d.ts @@ -0,0 +1,4 @@ +declare module '*?raw' { + const content: string; + export default content; +} diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c3aba07ab84e0bc7f6c28256942e1d2720bf681 --- /dev/null +++ b/packages/agent-core-v2/src/errors.ts @@ -0,0 +1,257 @@ +import { CoreErrors } from '#/_base/errors/codes'; +import type { KimiErrorPayload } from '#/_base/errors/serialize'; +import { AgentLifecycleErrors } from '#/session/agentLifecycle/errors'; +import { AuthErrors } from '#/app/auth/errors'; +import { TaskErrors } from '#/agent/task/errors'; +import { ProtocolErrors } from '#/llm-adapter/protocol/errors'; +import { ConfigErrors } from '#/app/config/errors'; +import { CapabilityErrors } from '#/app/capability/errors'; +import { CronErrors } from '#/features/cron/errors'; +import { DebugErrors } from '#/debug/errors'; +import { EventErrors } from '#/app/event/errors'; +import { FileErrors } from '#/app/file/fileService'; +import { FsErrors } from '#/workspace/workspaceFs/internal/errors'; +import { FullCompactionErrors } from '#/agent/fullCompaction/errors'; +import { GoalErrors } from '#/features/goal/errors'; +import { LoopErrors } from '#/agent/loop/errors'; +import { McpErrors } from '#/mcpCore/errors'; +import { ModelCatalogErrors } from '#/llm-adapter/model/errors'; +import { OsFsErrors } from '#/os/interface/hostFsErrors'; +import { OsProcessErrors } from '#/os/interface/hostProcess'; +import { PluginErrors } from '#/app/plugin/errors'; +import { ProfileErrors } from '#/agent/profile/errors'; +import { PromptErrors } from '#/agent/prompt/errors'; +import { ModelsDevImportErrors } from '#/app/kosongConfig/errors'; +import { SessionExportErrors } from '#/app/sessionExport/errors'; +import { SessionErrors } from '#/session/errors'; +import { SkillErrors } from '#/features/skill/catalog/errors'; +import { StateErrors } from '#/state/errors'; +import { StorageErrors } from '#/persistence/interface/storage'; +import { TerminalErrors } from '#/os/interface/terminalErrors'; +import { UsageErrors } from '#/agent/usage/errors'; +import { WebErrors } from '#/app/web/errors'; +import { WireErrors } from '#/wire/errors'; +import { WorkspaceErrors } from '#/app/workspace/errors'; + +export * from '#/_base/errors/codes'; +export * from '#/_base/errors/errorMessage'; +export * from '#/_base/errors/errors'; +export * from '#/_base/errors/serialize'; +export * from '#/_base/errors/unexpectedError'; +export { AgentLifecycleErrors } from '#/session/agentLifecycle/errors'; +export { AuthErrors } from '#/app/auth/errors'; +export { TaskErrors } from '#/agent/task/errors'; +export { ProtocolErrors } from '#/llm-adapter/protocol/errors'; +export { ConfigErrors } from '#/app/config/errors'; +export { CapabilityErrors } from '#/app/capability/errors'; +export { CronErrors } from '#/features/cron/errors'; +export { DebugErrors } from '#/debug/errors'; +export { FileErrors } from '#/app/file/fileService'; +export { FsErrors } from '#/workspace/workspaceFs/internal/errors'; +export { FullCompactionErrors } from '#/agent/fullCompaction/errors'; +export { GoalErrors } from '#/features/goal/errors'; +export { LoopErrors } from '#/agent/loop/errors'; +export { McpErrors } from '#/mcpCore/errors'; +export { ModelCatalogErrors } from '#/llm-adapter/model/errors'; +export { OsFsErrors } from '#/os/interface/hostFsErrors'; +export { OsProcessErrors } from '#/os/interface/hostProcess'; +export { PluginErrors } from '#/app/plugin/errors'; +export { ProfileErrors } from '#/agent/profile/errors'; +export { PromptErrors } from '#/agent/prompt/errors'; +export { ModelsDevImportErrors } from '#/app/kosongConfig/errors'; +export { SessionExportErrors } from '#/app/sessionExport/errors'; +export { SessionErrors } from '#/session/errors'; +export { SkillErrors } from '#/features/skill/catalog/errors'; +export { StorageErrors } from '#/persistence/interface/storage'; +export { TerminalErrors } from '#/os/interface/terminalErrors'; +export { UsageErrors } from '#/agent/usage/errors'; +export { WebErrors } from '#/app/web/errors'; +export { WireErrors } from '#/wire/errors'; +export { WorkspaceErrors } from '#/app/workspace/errors'; +export { EventErrors } from '#/app/event/errors'; +export { StateErrors } from '#/state/errors'; + +export const ErrorCodes = { + ...CoreErrors.codes, + ...AgentLifecycleErrors.codes, + ...AuthErrors.codes, + ...TaskErrors.codes, + ...ProtocolErrors.codes, + ...ConfigErrors.codes, + ...CapabilityErrors.codes, + ...CronErrors.codes, + ...DebugErrors.codes, + ...FileErrors.codes, + ...FsErrors.codes, + ...FullCompactionErrors.codes, + ...GoalErrors.codes, + ...LoopErrors.codes, + ...McpErrors.codes, + ...ModelCatalogErrors.codes, + ...OsFsErrors.codes, + ...OsProcessErrors.codes, + ...PluginErrors.codes, + ...ProfileErrors.codes, + ...PromptErrors.codes, + ...ModelsDevImportErrors.codes, + ...SessionExportErrors.codes, + ...SessionErrors.codes, + ...SkillErrors.codes, + ...StorageErrors.codes, + ...TerminalErrors.codes, + ...UsageErrors.codes, + ...WebErrors.codes, + ...WireErrors.codes, + ...WorkspaceErrors.codes, + ...EventErrors.codes, + ...StateErrors.codes, +} as const; + +export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; + +export type KimiErrorCode = + | 'config.invalid' + | 'config.persist_blocked' + | '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.undo_unavailable' + | 'session.export_not_found' + | 'session.export_missing_version' + | 'session.export_output_conflict' + | 'session.export_too_large' + | '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' + | 'agent.already_exists' + | 'agent.already_running' + | 'agent.not_a_subagent' + | 'agent.not_owned' + | 'agent.type_not_allowed' + | 'agent.max_tokens_exceeded' + | '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' + | 'goal.unsupported_agent' + | 'model.not_configured' + | 'model.config_invalid' + | 'profile.thinking_alias_conflict' + | 'profile.unknown' + | 'profile.already_bound' + | 'profile.not_bound' + | 'model.not_found' + | 'auth.login_required' + | 'auth.provisioning_required' + | 'auth.token_missing' + | 'auth.token_unauthorized' + | 'auth.model_not_resolved' + | 'context.overflow' + | 'loop.max_steps_exceeded' + | 'provider.api_error' + | 'provider.filtered' + | 'provider.rate_limit' + | 'provider.auth_error' + | 'provider.connection_error' + | 'provider.overloaded' + | 'provider.not_found' + | 'skill.not_found' + | 'skill.type_unsupported' + | 'skill.name_empty' + | 'skill.parse_failed' + | 'skill.nested_too_deep' + | 'records.write_failed' + | 'compaction.failed' + | 'compaction.unable' + | 'task.task_id_empty' + | 'task.limit_exceeded' + | 'usage.turn_id_conflict' + | 'mcp.server_not_found' + | 'mcp.server_disabled' + | 'mcp.startup_failed' + | 'mcp.tool_name_collision' + | 'mcp.oauth_failed' + | 'message.not_found' + | 'plugin.not_found' + | 'plugin.load_failed' + | 'request.invalid' + | 'request.work_dir_required' + | 'request.prompt_input_empty' + | 'prompt.id_conflict' + | 'prompt.not_found' + | 'session.busy' + | 'shell.git_bash_not_found' + | 'workspace.not_found' + | 'terminal.not_found' + | 'file.not_found' + | 'file.too_large' + | 'fs.path_not_found' + | 'fs.permission_denied' + | 'fs.path_escapes' + | 'fs.is_directory' + | 'fs.is_binary' + | 'fs.too_large' + | 'fs.already_exists' + | 'fs.too_many_results' + | 'fs.grep_timeout' + | 'fs.git_unavailable' + | 'os.fs.not_found' + | 'os.fs.is_directory' + | 'os.fs.not_directory' + | 'os.fs.already_exists' + | 'os.fs.permission_denied' + | 'os.fs.not_empty' + | 'os.fs.unavailable' + | 'os.fs.unknown' + | 'os.process.spawn_failed' + | 'os.process.kill_failed' + | 'storage.not_found' + | 'storage.decode_failed' + | 'storage.corrupted' + | 'storage.io_failed' + | 'storage.locked' + | 'storage.permission_denied' + | 'storage.disk_full' + | 'wire.duplicate_op' + | 'wire.cycle' + | 'wire.unknown_record' + | 'wire.migration_missing' + | 'cron.expression_invalid' + | 'web.invalid_url' + | 'web.private_address' + | 'web.fetch_failed' + | 'validation.failed' + | 'not_implemented' + | 'internal'; + +export interface ErrorEvent { + readonly type: 'error'; + readonly code: KimiErrorCode; + readonly message: string; + readonly name?: string; + readonly details?: Record; + readonly retryable: boolean; + readonly cause?: KimiErrorPayload; +} + +export interface WarningEvent { + readonly type: 'warning'; + readonly message: string; + readonly code?: string; +} diff --git a/packages/agent-core-v2/src/events.ts b/packages/agent-core-v2/src/events.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bbce792b09e367488046158f553f8bf93db9a4c --- /dev/null +++ b/packages/agent-core-v2/src/events.ts @@ -0,0 +1,87 @@ +import type { CompactionBlockedEvent, CompactionCancelledEvent, CompactionCompletedEvent, CompactionStartedEvent } from '#/agent/fullCompaction/compactionOps'; +import type { TurnStartedEvent, TurnStepCompletedEvent, TurnStepInterruptedEvent, TurnStepRetryingEvent, TurnStepStartedEvent, AssistantDeltaEvent, ThinkingDeltaEvent } from '#/agent/loop/turnEvents'; +import type { TurnEndedEvent } from '#/agent/loop/turnOps'; +import type { PluginCommandActivatedEvent } from '#/agent/pluginCommand/pluginCommand'; +import type { PromptAbortedEvent, PromptCompletedEvent, PromptSteeredEvent, PromptSubmittedEvent } from '#/agent/prompt/promptEvents'; +import type { BackgroundTaskStartedEvent, BackgroundTaskTerminatedEvent, TaskStartedEvent, TaskTerminatedEvent } from '#/agent/task/types'; +import type { McpServerStatusEvent, ShellCompletedEvent, ShellOutputEvent, ShellStartedEvent, ToolCallDeltaEvent, ToolCallStartedEvent, ToolListUpdatedEvent, ToolProgressEvent } from '#/agent/toolExecutor/toolExecutorEvents'; +import type { ToolResultEventPayload } from '#/agent/toolExecutor/toolExecutorEvents'; +import type { AgentStatusUpdatedEvent } from '#/agent/usage/usageEvents'; +import type { CapabilityChangedEvent } from '#/app/capability/capabilityEvents'; +import type { ConfigChangedEvent, ConfigWarningEvent } from '#/app/config/configEvents'; +import type { ModelCatalogChangedEvent } from '#/app/kosongConfig/discovery'; +import type { PluginChangedEvent } from '#/app/plugin/pluginEvents'; +import type { SessionCreatedEvent, SessionStatusChangedEvent, SessionWorkChangedEvent } from '#/app/sessionLegacy/sessionProtocol'; +import type { WorkspaceCreatedEvent, WorkspaceDeletedEvent, WorkspaceUpdatedEvent } from '#/app/workspace/workspaceProtocol'; +import type { CronFiredEvent } from '#/features/cron/cronOps'; +import type { HookResultEvent } from '#/features/externalHooks/agent/agentExternalHooksService'; +import type { GoalUpdatedEvent } from '#/features/goal/goalOps'; +import type { SkillActivatedEvent } from '#/features/skill/skillOps'; +import type { SubagentSuspendedEvent } from '#/features/swarm/session/sessionSwarmService'; +import type { SessionMetaUpdatedEvent } from '#/session/sessionMetadata/sessionMetaEvents'; +import type { SubagentCancelledEvent, SubagentCompletedEvent, SubagentFailedEvent, SubagentSpawnedEvent, SubagentStartedEvent } from '#/session/subagent/mirrorAgentRun'; + +import type { ErrorEvent, WarningEvent } from './errors'; + +export interface ToolResultEvent extends Omit { + readonly type: 'tool.result'; +} + +export type AgentEvent = + | ErrorEvent + | WarningEvent + | AgentStatusUpdatedEvent + | SessionMetaUpdatedEvent + | SessionCreatedEvent + | WorkspaceCreatedEvent + | WorkspaceUpdatedEvent + | WorkspaceDeletedEvent + | SessionWorkChangedEvent + | SessionStatusChangedEvent + | ConfigChangedEvent + | ConfigWarningEvent + | ModelCatalogChangedEvent + | PluginChangedEvent + | CapabilityChangedEvent + | GoalUpdatedEvent + | SkillActivatedEvent + | PluginCommandActivatedEvent + | TurnStartedEvent + | TurnEndedEvent + | TurnStepStartedEvent + | TurnStepCompletedEvent + | TurnStepRetryingEvent + | TurnStepInterruptedEvent + | AssistantDeltaEvent + | HookResultEvent + | ThinkingDeltaEvent + | ToolCallDeltaEvent + | ToolCallStartedEvent + | ToolProgressEvent + | ShellOutputEvent + | ShellStartedEvent + | ShellCompletedEvent + | ToolResultEvent + | ToolListUpdatedEvent + | McpServerStatusEvent + | SubagentSpawnedEvent + | SubagentStartedEvent + | SubagentSuspendedEvent + | SubagentCompletedEvent + | SubagentFailedEvent + | SubagentCancelledEvent + | CompactionStartedEvent + | CompactionBlockedEvent + | CompactionCancelledEvent + | CompactionCompletedEvent + | TaskStartedEvent + | TaskTerminatedEvent + | BackgroundTaskStartedEvent + | BackgroundTaskTerminatedEvent + | CronFiredEvent + | PromptSubmittedEvent + | PromptCompletedEvent + | PromptAbortedEvent + | PromptSteeredEvent; + +export type Event = AgentEvent & { agentId: string; sessionId: string }; diff --git a/packages/agent-core-v2/src/features/feature.ts b/packages/agent-core-v2/src/features/feature.ts new file mode 100644 index 0000000000000000000000000000000000000000..c914646bddbe4482656c8b1fb42dcc798487ffc1 --- /dev/null +++ b/packages/agent-core-v2/src/features/feature.ts @@ -0,0 +1,117 @@ +import { type CollectionToken } from '#/_base/di/collection'; +import { + ScopeUnits, + type Fiber, + type FiberHandle, + type FiberProvideOptions, + type ServiceClassRecipe, +} from '#/_base/di/fiber'; +import { ScopeActivation, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { toDisposable } from '#/_base/di/lifecycle'; +import { Service } from '#/_base/di/service'; +import { + AgentProfileContribution, + AGENT_PROFILE_SOURCE_PRIORITY, +} from '#/app/agentProfileCatalog/agentProfileContribution'; +import { FeatureServiceContribution } from '#/app/feature/featureServiceContribution'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import type { ConfigSchema, RegisterSectionOptions } from '#/app/config/config'; +import { ConfigSectionContribution } from '#/app/config/configSectionContributions'; +import { LifecycleScope } from '#/app/scopes'; +import { + CommandContribution, + type CommandContribution as CommandContributionPayload, +} from '#/agent/command/commandContribution'; +import { + AgentToolContribution, + type AgentToolContributionOptions, + type AgentToolCtor, + type AnyAgentTool, +} from '#/agent/toolRegistry/toolContribution'; +import type { + AgentModel, + AgentModelDefinition, + SessionModelDefinition, +} from '#/state/agentModel'; +import { AgentModelContribution, SessionModelContribution } from '#/state/agentModel'; + +export abstract class Feature extends Service { + contribute(token: CollectionToken, value: T): FiberHandle { + return this.provide(token, value); + } + + contributeSessionModel(definition: SessionModelDefinition): FiberHandle { + return this.provide(SessionModelContribution, definition as SessionModelDefinition); + } + + contributeAgentModel>( + definition: AgentModelDefinition, + ): FiberHandle { + return this.provide(AgentModelContribution, definition as AgentModelDefinition); + } + + contributeConfig( + domain: string, + schema: ConfigSchema, + options: RegisterSectionOptions = {}, + ): FiberHandle { + return this.provide(ConfigSectionContribution, { + domain, + schema: schema as ConfigSchema, + options: options as RegisterSectionOptions, + }); + } + + contributeService( + scope: LifecycleScope, + id: ServiceIdentifier, + ctor: ServiceClassRecipe, + opts?: FiberProvideOptions, + ): FiberHandle { + this.provide(FeatureServiceContribution, { scope, id }); + return this.provide(ScopeUnits(scope), { + name: `${this.name}:${String(id)}`, + apply(fiber: Fiber): void { + fiber.provide(id, ctor, opts); + }, + }); + } + + contributeAgentService( + id: ServiceIdentifier, + ctor: ServiceClassRecipe, + opts?: FiberProvideOptions, + ): FiberHandle { + return this.contributeService(LifecycleScope.Agent, id, ctor, opts); + } + + contributeTool( + id: ServiceIdentifier, + ctor: AgentToolCtor, + options: AgentToolContributionOptions, + ): void { + this.contributeService(LifecycleScope.Agent, id, ctor, { + activation: ScopeActivation.OnDemand, + }); + this.provide(AgentToolContribution, { id, ctor, options }); + } + + contributeCommand(contribution: CommandContributionPayload): FiberHandle { + return this.provide(CommandContribution, contribution); + } + + contributeProfiles( + profiles: readonly AgentProfile[], + opts?: { readonly sourceId?: string; readonly priority?: number }, + ): FiberHandle { + return this.provide(AgentProfileContribution, { + sourceId: opts?.sourceId ?? `feature:${this.name}`, + priority: opts?.priority ?? AGENT_PROFILE_SOURCE_PRIORITY.builtin, + contribution: { profiles }, + }); + } + + onDispose(fn: () => void): void { + this._register(toDisposable(fn)); + } +} diff --git a/packages/agent-core-v2/src/features/featureAssembly.ts b/packages/agent-core-v2/src/features/featureAssembly.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8e2c9786e4dd54dc4aab4531285c3495705db55 --- /dev/null +++ b/packages/agent-core-v2/src/features/featureAssembly.ts @@ -0,0 +1,8 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IFeatureAssemblyService { + readonly _serviceBrand: undefined; +} + +export const IFeatureAssemblyService: ServiceIdentifier = + createDecorator('featureAssemblyService'); diff --git a/packages/agent-core-v2/src/features/featureAssemblyService.ts b/packages/agent-core-v2/src/features/featureAssemblyService.ts new file mode 100644 index 0000000000000000000000000000000000000000..376064e6c144df38ab8a544a2c90857641991c9e --- /dev/null +++ b/packages/agent-core-v2/src/features/featureAssemblyService.ts @@ -0,0 +1,26 @@ +import { IFeatureManager } from '#/app/feature/featureManager'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Service } from '#/_base/di/service'; + +import { IFeatureAssemblyService } from './featureAssembly'; +import { getFeatureRecipes } from './featureRegistry'; + +export class FeatureAssemblyService extends Service implements IFeatureAssemblyService { + declare readonly _serviceBrand: undefined; + + constructor(@IFeatureManager featureManager: IFeatureManager) { + super(); + for (const recipe of getFeatureRecipes()) { + featureManager.provideUnit(recipe); + } + } +} + +registerScopedService( + LifecycleScope.App, + IFeatureAssemblyService, + FeatureAssemblyService, + ScopeActivation.OnScopeCreated, + 'features', +); diff --git a/packages/agent-core-v2/src/features/featureRegistry.ts b/packages/agent-core-v2/src/features/featureRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..96e715837558557c325e154e55bc059811a8adc7 --- /dev/null +++ b/packages/agent-core-v2/src/features/featureRegistry.ts @@ -0,0 +1,15 @@ +import type { ServiceClassRecipe } from '#/_base/di/fiber'; + +const _featureRecipes: ServiceClassRecipe[] = []; + +export function registerFeature(recipe: ServiceClassRecipe): void { + _featureRecipes.push(recipe); +} + +export function getFeatureRecipes(): readonly ServiceClassRecipe[] { + return _featureRecipes; +} + +export function _clearFeatureRecipesForTests(): void { + _featureRecipes.length = 0; +} diff --git a/packages/agent-core-v2/src/hooks.ts b/packages/agent-core-v2/src/hooks.ts new file mode 100644 index 0000000000000000000000000000000000000000..77e1202aa024077b2b67353a11bb9206b9db8d07 --- /dev/null +++ b/packages/agent-core-v2/src/hooks.ts @@ -0,0 +1,112 @@ +import { toDisposable, type IDisposable } from "#/_base/di/lifecycle"; +import { BugIndicatingError } from "#/errors"; + +export type Hooks> = { + readonly [K in keyof TEvents]: HookSlot; +}; + +export interface HookSlot { + register( + id: string, + handler: HookHandler, + options?: HookRegisterOptions, + ): IDisposable; + + delete(id: string): boolean; + + run(context: TContext, terminal?: (context: TContext) => Promise): Promise; +} + +export type HookHandler = ( + context: TContext, + next: (context?: TContext) => Promise, +) => void | Promise; + +export interface HookRegisterOptions { + before?: string; + after?: string; +} + +interface HookEntry { + readonly id: string; + readonly handler: HookHandler; +} + +export class OrderedHookSlot implements HookSlot { + private entries: HookEntry[] = []; + + register( + id: string, + handler: HookHandler, + options: HookRegisterOptions = {}, + ): IDisposable { + if (options.before !== undefined && options.after !== undefined) { + throw new BugIndicatingError('Hook registration cannot specify both before and after'); + } + + this.delete(id); + const entry = { id, handler }; + const target = options.before ?? options.after; + if (target === undefined) { + this.entries.push(entry); + return this.toEntryDisposable(entry); + } + + const targetIndex = this.entries.findIndex((item) => item.id === target); + if (targetIndex < 0) { + throw new BugIndicatingError(`Hook target "${target}" is not registered`); + } + + const insertAt = options.before !== undefined ? targetIndex : targetIndex + 1; + this.entries.splice(insertAt, 0, entry); + return this.toEntryDisposable(entry); + } + + delete(id: string): boolean { + const index = this.entries.findIndex((entry) => entry.id === id); + if (index < 0) return false; + this.entries.splice(index, 1); + return true; + } + + asDisposable(id: string): IDisposable { + return toDisposable(() => { + this.delete(id); + }); + } + + private toEntryDisposable(entry: HookEntry): IDisposable { + return toDisposable(() => { + const index = this.entries.indexOf(entry); + if (index < 0) return; + this.entries.splice(index, 1); + }); + } + + async run( + context: TContext, + terminal: (context: TContext) => Promise = async () => {}, + ): Promise { + const entries = [...this.entries]; + const dispatch = (index: number, ctx: TContext): ((override?: TContext) => Promise) => { + return async (override?: TContext): Promise => { + const current = override ?? ctx; + const entry = entries[index]; + if (entry === undefined) { + await terminal(current); + return; + } + await entry.handler(current, dispatch(index + 1, current)); + }; + }; + await dispatch(0, context)(); + } +} + +export function createHooks, TKeys extends keyof TEvents>( + keys: readonly TKeys[], +): Hooks { + return Object.fromEntries( + keys.map((key) => [key, new OrderedHookSlot()]), + ) as unknown as Hooks; +} diff --git a/packages/agent-core-v2/src/human/index.ts b/packages/agent-core-v2/src/human/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..303d46f6a89369f9446973e5f911fc98b07cc97b --- /dev/null +++ b/packages/agent-core-v2/src/human/index.ts @@ -0,0 +1,97 @@ +export * from './llm/message'; +export * from './llm/model'; +export * from './llm/capability'; +export * from './models-dev/models-dev'; +export * from './llm/errors'; +export * from './llm/syntax-errors'; +export * from './llm/thinking'; +export * from './llm/response-format'; +export * from './llm/finish-reason'; +export * from './llm/usage'; +export * from './plugin'; +export * from './llm/protocol/format'; +export * from './llm/protocol/base'; +export * from './llm/protocol/connection'; +export * from './llm/protocol/thinking'; +export * from './llm/protocol/rewrite'; +export * from './llm/protocol/patterns'; +export * from './llm/media'; +export * from './llm/requester/requester'; +export * from './llm/empty-response'; +export * from './llm/requester/actor'; +export * from './llm/requester/recovery'; +export * from './llm/requester/retry'; +export * from './llm/requester/bases/openai/contract'; +export * from './llm/requester/bases/openai/capability'; +export * from './llm/requester/bases/openai/trait'; +export * from './llm/requester/bases/openai/extra-params'; +export * from './llm/requester/bases/openai/requester'; +export * from './llm/requester/bases/openai-responses/contract'; +export * from './llm/requester/bases/openai-responses/capability'; +export * from './llm/requester/bases/openai-responses/trait'; +export * from './llm/requester/bases/openai-responses/extra-params'; +export * from './llm/requester/bases/openai-responses/requester'; +export * from './llm/requester/bases/google-genai/contract'; +export * from './llm/requester/bases/google-genai/capability'; +export * from './llm/requester/bases/google-genai/trait'; +export * from './llm/requester/bases/google-genai/extra-params'; +export * from './llm/requester/bases/google-genai/requester'; +export * from './llm/requester/bases/anthropic/contract'; +export * from './llm/requester/bases/anthropic/capability'; +export * from './llm/requester/bases/anthropic/trait'; +export * from './llm/requester/bases/anthropic/extra-params'; +export * from './llm/requester/bases/anthropic/profile'; +export * from './llm/requester/bases/anthropic/requester'; +export * from './llm/requester/bases/tool-call-id'; +export * from './llm/requester/bases/tool-result-text'; +export * from './llm/provider/definition'; +export * from './llm/provider-catalog'; +export * from './llm-kimi/provider'; +export * from './llm-kimi/errors'; +export * from './llm-kimi/files'; +export * from './llm-kimi/media'; +export * from './llm-kimi/schema'; +export * from './llm-kimi/trait'; +export * from './llm/provider/providers/standard'; +export * from './credentials'; +export * from './tool/executor'; +export * from './tool/machine'; +export * from './tool/wait-for'; +export * from './tool/tool'; +export * from './media/tool'; +export * from './agent/errors'; +export * from './agent/machine'; +export * from './agent/wait-for'; +export * from './agent/turn'; +export * from './agent/context-usage'; +export * from './agent/events'; +export * from './agent/slices'; +export * from './agent/historySchema'; +export * from './persist/open'; +export * from './eventStore/index'; +export * from './persist/v2/migrate'; +export * from './session/machine'; +export * from './session/events'; +export * from './session/slices'; +export * from './session/stores'; +export * from './compaction/controller'; +export * from './compaction/errors'; +export * from './compaction/shape'; +export * from './compaction/summarize'; +export * from './usage/usage'; +export * from './usage/machine'; +export * from './usage/plugin'; +export * from './timing/plugin'; +export * from './interaction/interaction'; +export * from './interaction/machine'; +export * from './interaction/facade'; +export * from './kimi/trace'; +export * from './todo/todoItem'; +export * from './todo/slice'; +export * from './todo/tool'; +export * from './todo/plugin'; +export * from './tool-select/state'; +export * from './tool-select/tool'; +export * from './tool-select/plugin'; +export * from './tool-select/resolver'; +export * from './store'; diff --git a/packages/agent-core-v2/src/human/package.json b/packages/agent-core-v2/src/human/package.json new file mode 100644 index 0000000000000000000000000000000000000000..d8fdda99a5f0f99670e419922844eb59d434c06c --- /dev/null +++ b/packages/agent-core-v2/src/human/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "imports": { + "#/*": "./*.ts" + } +} diff --git a/packages/agent-core-v2/src/human/plugin.ts b/packages/agent-core-v2/src/human/plugin.ts new file mode 100644 index 0000000000000000000000000000000000000000..0574aa9ded548db6950165bbf3437b26d868de11 --- /dev/null +++ b/packages/agent-core-v2/src/human/plugin.ts @@ -0,0 +1,54 @@ +import type { SystemMessage, UserMessage } from '#/llm/message'; +import type { AgentEmitted } from '#/agent/machine'; +import { createSystemEntry, createUserEntry, type SystemEntry, type UserEntry } from '#/agent/turn'; +import type { ToolDefinition } from '#/tool/tool'; + +export interface AgentPluginTarget { + kind: 'agent'; + on(type: AgentEmitted['type'], handler: (event: AgentEmitted) => void): unknown; + notify(message: UserMessage): void; + remind(key: string, message: UserMessage | SystemMessage): void; +} + +export type PluginTarget = AgentPluginTarget; + +export interface Plugin { + readonly name: string; + tools?(): readonly ToolDefinition[]; + connect?(target: PluginTarget): void; +} + +export function collectPluginTools(plugins: readonly Plugin[]): readonly ToolDefinition[] { + return plugins.flatMap((plugin) => plugin.tools?.() ?? []); +} + +export interface AgentPluginSource { + on(type: AgentEmitted['type'], handler: (event: AgentEmitted) => void): unknown; + send( + event: + | { type: 'input.notify'; entry: UserEntry } + | { type: 'input.remind'; key: string; entry: SystemEntry | UserEntry }, + ): void; +} + +export function connectPlugins(actor: AgentPluginSource, plugins: readonly Plugin[]): void { + const target: AgentPluginTarget = { + kind: 'agent', + on: (type, handler) => { + actor.on(type, handler); + }, + notify: (message) => { + actor.send({ type: 'input.notify', entry: { message } }); + }, + remind: (key, message) => { + actor.send({ + type: 'input.remind', + key, + entry: message.role === 'system' ? createSystemEntry(message) : createUserEntry(message), + }); + }, + }; + for (const plugin of plugins) { + plugin.connect?.(target); + } +} diff --git a/packages/agent-core-v2/src/human/xstate2.ts b/packages/agent-core-v2/src/human/xstate2.ts new file mode 100644 index 0000000000000000000000000000000000000000..68c781b952ff4d08eaf89c3b25d4433122bf8f89 --- /dev/null +++ b/packages/agent-core-v2/src/human/xstate2.ts @@ -0,0 +1,49 @@ +import { createActor as createXStateActor } from 'xstate'; +import type { Actor, ActorOptions, AnyActorLogic } from 'xstate'; + +import { isAbortError } from '#/llm/errors'; +import { xstateInspectionCollector } from '#/xstateInspection'; + +export * from 'xstate'; + +export type RootActorErrorReporter = (err: unknown) => void; + +let reportRootActorError: RootActorErrorReporter = () => {}; + +export function setRootActorErrorReporter(reporter: RootActorErrorReporter): void { + reportRootActorError = reporter; +} + +function createActorWithInspect( + logic: TLogic, + options?: ActorOptions, +): Actor { + const inspect = options?.inspect; + const actor = createXStateActor(logic, { + ...options, + inspect: (event) => { + xstateInspectionCollector.publish(event); + if (typeof inspect === 'function') { + inspect(event); + } else { + inspect?.next?.(event); + } + }, + }); + swallowRootAbortError(actor); + return actor; +} + +function swallowRootAbortError(actor: Actor): void { + const internal = actor as unknown as { _reportError(err: unknown): void }; + const reportError = internal._reportError.bind(actor); + internal._reportError = (err: unknown) => { + if (isAbortError(err)) { + reportRootActorError(err); + return; + } + reportError(err); + }; +} + +export const createActor = createActorWithInspect as typeof createXStateActor; diff --git a/packages/agent-core-v2/src/human/xstateInspection.ts b/packages/agent-core-v2/src/human/xstateInspection.ts new file mode 100644 index 0000000000000000000000000000000000000000..dc6c79ef9322c189ec6c1d1cde8b5ff06f24ddcd --- /dev/null +++ b/packages/agent-core-v2/src/human/xstateInspection.ts @@ -0,0 +1,72 @@ +import type { InspectionEvent } from 'xstate'; + +export type XstateInspectionEventType = InspectionEvent['type']; + +export interface XstateInspectionEnvelope { + readonly type: XstateInspectionEventType; + readonly timestamp: number; + readonly actorSessionId: string; + readonly actorId?: string; + readonly logicId?: string; + readonly eventType?: string; + readonly stateValue?: unknown; + readonly unhandled?: boolean; +} + +export type XstateInspectionListener = (envelope: XstateInspectionEnvelope) => void; + +export interface XstateInspectionCollector { + subscribe(listener: XstateInspectionListener): () => void; + publish(event: InspectionEvent): void; +} + +function scalar(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function toEnvelope(event: InspectionEvent, now: () => number): XstateInspectionEnvelope { + const actorRef = event.actorRef as { id?: unknown; logic?: unknown }; + const logic = actorRef.logic as { id?: unknown } | undefined; + const snapshot = 'snapshot' in event ? (event.snapshot as { value?: unknown }) : undefined; + const unhandled = + event.type === '@xstate.microstep' && + event._transitions.length === 0 && + !event.event.type.startsWith('xstate.'); + return { + type: event.type, + timestamp: now(), + actorSessionId: event.actorRef.sessionId, + actorId: scalar(actorRef.id), + logicId: scalar(logic?.id), + eventType: + 'event' in event + ? event.event.type + : event.type === '@xstate.action' + ? event.action.type + : undefined, + stateValue: snapshot?.value, + unhandled: unhandled || undefined, + }; +} + +export function createXstateInspectionCollector(input?: { + now?: () => number; +}): XstateInspectionCollector { + const now = input?.now ?? Date.now; + const listeners = new Set(); + return { + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + publish(event) { + if (listeners.size === 0) return; + const envelope = toEnvelope(event, now); + for (const listener of listeners) listener(envelope); + }, + }; +} + +export const xstateInspectionCollector = createXstateInspectionCollector(); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d95a4a5889fcb7efd86f7fad43464eb22d7de16e --- /dev/null +++ b/packages/agent-core-v2/src/index.ts @@ -0,0 +1,758 @@ +export * from '#/_base/di/descriptors'; +export * from '#/_base/di/errors'; +export * from '#/_base/di/graph'; +export * from '#/_base/di/instantiation'; +export * from '#/_base/di/instantiationService'; +export * from '#/_base/di/lifecycle'; +export * from '#/_base/di/scope'; +export * from './app/scopes'; +export * from '#/_base/di/serviceCollection'; +export * from '#/_base/di/cascadeEngine'; +export * from '#/_base/di/dependencyGraph'; +export * from '#/_base/lifecycle/ledger'; +export { + collection, + isCollectionToken, + type CollectionChange, + type CollectionRecord, + type CollectionToken, + type CollectionView, +} from '#/_base/di/collection'; +export { + FiberProtocolError, + FiberState, + ScopeUnits, + ServiceRecipeError, + setFiberEventResolver, + type ConfigSchema, + type Fiber, + type FiberHandle, + type FiberProvideOptions, + type RecipeStatics, + type ServiceRecipe, +} from '#/_base/di/fiber'; +export { Service } from '#/_base/di/service'; +export * from './errors'; +export * from './events'; +export * from '#/runtime/runtime'; +export * from '#/runtime/runtimeRegistry'; +export * from '#/runtime/runtimeWorkspaceView'; +export * from '#/runtime/runtimeProvider'; +export * from '#/runtime/runtimeUnitHost'; +export * from '#/runtime/localRuntime'; +export * from '#/runtime/standaloneRuntime'; +export * from '#/program/program'; +export * from '#/workspace/workspaceInstance/workspaceInstance'; +export * from '#/workspace/workspaceInstance/workspaceInstanceManager'; +export * from '#/workspace/workspaceInstance/workspaceInstanceManagerService'; +export * from '#/agent/runtimeBinding/runtimeBinding'; +export * from '#/agent/runtimeBinding/runtimeBindingService'; +export * from '#/agent/runtimeBinding/agentRuntime'; +export * from '#/app/sessionManager/sessionManager'; +export * from '#/app/sessionManager/sessionManagerService'; + +export * from '#/_base/log/log'; +export * from '#/_base/log/logConfig'; +export * from '#/_base/log/formatter'; +export * from '#/_base/log/fileLog'; +export * from '#/_base/log/logService'; +export * from '#/wire/wire'; +export * from '#/wire/wireService'; +export * from '#/wire/journal'; +export * from '#/wire/tree/index'; +export * from '#/wire/record'; +export * from '#/wire/migration/migration'; +export * from '#/session/sessionLog/sessionLogService'; +export * from '#/app/telemetry/telemetry'; +export * from '#/app/telemetry/context'; +export * from '#/app/telemetry/events'; +export * from '#/app/telemetry/telemetryService'; +export * from '#/app/telemetry/consoleAppender'; +export * from '#/app/telemetry/cloudAppender'; +export * from '#/app/bootstrap/bootstrap'; +export * from '#/app/bootstrap/bootstrapService'; +export * from '#/os/interface/hostClock'; +export * from '#/os/interface/hostEnvironment'; +export * from '#/os/interface/hostFileSystem'; +export * from '#/os/interface/hostProcess'; +export * from '#/os/interface/terminal'; +export * from '#/os/interface/terminalErrors'; +export * from '#/os/backends/node-local/hostClockService'; +export * from '#/os/backends/node-local/hostEnvironmentService'; +export * from '#/os/backends/node-local/hostFsService'; +export * from '#/os/backends/node-local/hostProcessService'; +export * from '#/os/backends/node-local/hostTerminalService'; +export * from '#/agent/tools/os/bash/bash'; +import '#/agent/tools/os/bash/bashTool'; +export * from '#/agent/tools/os/glob/glob'; +import '#/agent/tools/os/glob/globTool'; +export * from '#/agent/tools/os/grep/grep'; +import '#/agent/tools/os/grep/grepTool'; +export * from '#/agent/tools/os/read/read'; +import '#/agent/tools/os/read/readTool'; +export * from '#/agent/tools/os/write/write'; +import '#/agent/tools/os/write/writeTool'; +export * from '#/os/interface/terminal'; +export * from '#/os/interface/terminalErrors'; +export * from '#/os/backends/node-local/hostTerminalService'; +export * from '#/session/terminal/terminalService'; +export * from '#/app/task/task'; +import '#/app/task/taskService'; +export { TaskService } from '#/app/task/taskService'; +import '#/app/event/eventBusService'; +import '#/app/event/eventService'; +import '#/app/event/fiberEventResolver'; +export { IEventBus } from '#/app/event/eventBus'; +export { IEventService } from '#/app/event/event'; +export * from '#/app/event/errors'; +export * from '#/app/event/event2'; +export * from '#/state/errors'; +export * from '#/state/state'; +export * from '#/state/stateContribution'; +export * from '#/state/agentModel'; +export * from '#/state/eventDispatcher'; +import '#/state/eventDispatcherService'; +export * from '#/_base/state/stateRegistry'; +export * from '#/_base/contribution/registry'; +export * from '#/app/state/appState'; +import '#/app/state/appStateService'; +export * from '#/workspace/state/workspaceState'; +import '#/workspace/state/workspaceStateService'; +export * from '#/session/state/sessionState'; +import '#/session/state/sessionStateService'; +export * from '#/agent/state/agentState'; +import '#/agent/state/agentStateService'; +export * from '#/llm-adapter/contract/capability'; +export * from '#/llm-adapter/contract/errors'; +export { + createAssistantMessage, + createToolMessage, + createUserMessage, + isToolDeclarationOnlyMessage, + mergeInPlace, + type Message, +} from '#/llm-adapter/contract/message'; +export { + extractText, + getTextContent, + isContentPart, + isToolCall, + isToolCallPart, + type AudioURLPart, + type ContentPart, + type ImageURLPart, + type Role, + type StreamedMessagePart, + type TextPart, + type ThinkPart, + type ToolCall, + type ToolCallPart, + type VideoURLPart, +} from '#human/llm/message'; +export type { ToolDescription as Tool } from '#human/llm/message'; +export { addUsage, emptyUsage, grandTotal, inputTotal, type TokenUsage } from '#human/llm/usage'; +export type { FinishReason } from '#human/llm/finish-reason'; +export type { + JsonObjectResponseFormat, + JsonSchemaObject, + JsonSchemaResponseFormat, + ResponseFormat, +} from '#human/llm/response-format'; +export type { ThinkingEffort, ThinkingRequestOptions } from '#human/llm/thinking'; +export type { VideoUploadInput } from '#human/llm/media/upload'; +export type { ToolCallIdPolicy } from '#human/llm/requester/requester'; +export type { SamplingOptions } from '#/llm-adapter/model/model-requester'; +export * from '#/llm-adapter/contract/request-trace'; +export type { KimiThinkingConfig } from '#human/llm-kimi/trait'; + +export * from '#/app/sessionIndex/sessionIndex'; +export * from '#/app/sessionIndex/sessionIndexService'; +export * from '#/app/sessionIndex/sessionIndexMirrorService'; +export * from '#/session/sessionMetadata/sessionMetadata'; +export * from '#/session/sessionMetadata/sessionMetadataService'; +export * from '#/session/sessionMetadata/promptMetadata'; +export * from '#/session/sessionActivity/sessionActivity'; +export * from '#/session/sessionActivity/sessionActivityService'; +export * from '#/session/sessionActivity/sessionOutcomeMirror'; +export * from '#/session/sessionActivity/sessionOutcomeMirrorService'; +export * from '#/session/sessionTitle/agentTitlePromptSource'; +import '#/session/sessionTitle/agentTitlePromptSourceService'; +export * from '#/session/sessionTitle/sessionTitle'; +export * from '#/session/sessionTitle/sessionTitleService'; +export * from '#/session/sessionToolPolicy/sessionToolPolicy'; +export * from '#/session/sessionToolPolicy/sessionToolPolicyService'; +export * from '#/app/config/config'; +export * from '#/app/config/configEvents'; +export type { ConfigChangedEvent } from '#/app/config/configEvents'; +export * from '#/app/config/configService'; +export * from '#/app/config/configSectionContributions'; +import '#/app/kosongConfig/configSection'; +export * from '#/llm-adapter/provider/provider'; +export * from '#/llm-adapter/provider/provider-service'; +export * from '#/llm-adapter/provider/provider-definition'; +export * from '#/llm-adapter/protocol/protocolAdapterRegistry'; +import '#/features/skill/catalog/configSection'; +import '#/app/agentIdentity/configSection'; +export * from '#/app/agentIdentity/configSection'; +export * from '#/app/agentIdentity/agentIdentity'; +export * from '#/app/agentIdentity/agentIdentityService'; +import '#/llm-adapter/protocol/errors'; +export * from '#/llm-adapter/protocol/errors'; +export * from '#/llm-adapter/protocol/protocol'; +export * from '#/llm-adapter/protocol/protocol-base'; +import '#/app/kosongConfig/envOverlay'; +export * from '#/llm-adapter/model/completion-budget'; +export * from '#/llm-adapter/model/host-request-headers'; +export * from '#/llm-adapter/model/model'; +export * from '#/llm-adapter/model/model.types'; +export * from '#/llm-adapter/model/model-service'; +export * from '#/llm-adapter/model/thinking'; +export * from '#/llm-adapter/model/catalog'; +export * from '#/llm-adapter/model/catalog-service'; +export * from '#/llm-adapter/model/model-requester'; +import '#/llm-adapter/model/errors'; +export { + MODEL_CATALOG_SECTION, + ModelCatalogConfigSchema, + type ModelCatalogConfig, +} from '#/app/kosongConfig/configSection'; +export * from '#/app/kosongConfig/kosongConfig'; +export * from '#/app/kosongConfig/kosongConfigService'; +export * from '#/llm-adapter/model/model-oauth'; +export * from '#/app/kosongConfig/oauthTokenAdapter'; +export * from '#/app/kosongConfig/hostRequestHeadersAdapter'; +export * from '#/app/kosongConfig/discovery'; +export * from '#/app/kosongConfig/discoveryService'; +export * from '#/app/kosongConfig/errors'; +export * from '#/app/kosongConfig/modelsDevImport'; +export * from '#/app/kosongConfig/modelsDevImportService'; +export * from '#/app/kosongConfig/modelsDevUpstream'; +export * from '#/app/kosongConfig/modelsDev'; +export * from '#/app/agentProfileCatalog/agentProfileCatalog'; +export * from '#/app/agentProfileCatalog/agentProfileContribution'; +export * from '#/app/agentProfileCatalog/agentProfileRegistry'; +export * from '#/app/agentProfileCatalog/agentProfileRegistryService'; +export * from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; +export * from '#/app/agentProfileCatalog/builtinAgentProfileLoaderService'; +export * from '#/app/agentProfileCatalog/profile-shared'; +export * from '#/app/agentProfileCatalog/promptPrefix'; +export { + registerAgentProfile, + getAgentProfileContributions, + _clearAgentProfileContributionsForTests, +} from '#/app/agentProfileCatalog/contribution'; +export * from '#/workspace/workspaceAgentProfileLoader/configSection'; +export { parseAgentFileText } from '#/workspace/workspaceAgentProfileLoader/internal/agentFile'; +export { resolveAgentPath } from '#/workspace/workspaceAgentProfileLoader/internal/paths'; +export * from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService'; +export * from '#/app/plugin/types'; +export * from '#/app/plugin/commands'; +export * from '#/app/plugin/manifest'; +export * from '#/app/plugin/store'; +export * from '#/app/plugin/source'; +export * from '#/app/plugin/github-resolver'; +export * from '#/app/plugin/archive'; +export * from '#/app/plugin/manager'; +export * from '#/app/plugin/marketplace'; +export * from '#/app/plugin/plugin'; +export * from '#/app/plugin/pluginEvents'; +export * from '#/app/plugin/pluginService'; +export * from '#/app/capability/capability'; +export * from '#/app/capability/capabilityEvents'; +export * from '#/app/capability/capabilityService'; +export * from '#/app/capability/errors'; +export * from '#/app/capability/types'; +export * from '#/app/feature/featureManager'; +export * from '#/app/feature/featureServiceContribution'; +import '#/app/feature/featureManagerService'; +export * from '#/features/feature'; +export * from '#/features/featureAssembly'; +export * from '#/features/featureRegistry'; +import '#/features/featureAssemblyService'; +export * from '#/agent/command/agentCommand'; +export * from '#/agent/command/commandContribution'; +import '#/agent/command/agentCommandService'; +export * from '#/debug/index'; +export * from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService'; + +export type { SkillSource } from '#/features/skill/catalog/types'; +export * from '#/features/skill/tools/skill'; +export * from '#/features/skill/skill'; +export * from '#/features/skill/skillService'; +import '#/features/skill/skillFeature'; +export * from '#/features/skill/catalog/types'; +export * from '#/features/skill/catalog/configSection'; +export * from '#/features/skill/catalog/parser'; +export * from '#/features/skill/catalog/registry'; +export * from '#/features/skill/catalog/errors'; +export * from '#/features/skill/catalog/skillDiscovery'; +export * from '#/features/skill/catalog/inMemorySkillDiscovery'; +export * from '#/features/skill/catalog/skillSource'; +export * from '#/features/skill/catalog/skillRoots'; +export * from '#/features/skill/catalog/builtin/builtin'; +export * from '#/features/skill/catalog/builtinSkillSource'; +export * from '#/features/skill/catalog/userFileSkillSource'; +export * from '#/features/skill/session/skillCatalog'; +export * from '#/features/skill/session/skillCatalogData'; +export * from '#/features/skill/session/skillCatalogService'; +export * from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; +export * from '#/session/sessionAgentProfileCatalog/agentProfileCatalogSeed'; +export * from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService'; +export * from '#/session/sessionInstructions/instructionsProvider'; +export * from '#/session/workspaceInfo/workspaceInfo'; +export * from '#/workspace/workspaceDirs/workspaceDirs'; +export * from '#/workspace/workspaceDirs/workspaceDirsService'; +export * from '#/features/skill/workspace/workspaceSkillCatalog'; +export * from '#/features/skill/workspace/workspaceSkillCatalogService'; +export * from '#/features/skill/workspace/extraFileSkillSource'; +export * from '#/features/skill/workspace/explicitFileSkillSource'; +export * from '#/features/skill/workspace/rootFileSkillSource'; +export * from '#/features/skill/workspace/pluginSkillSource'; +export * from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService'; +export * from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService'; +export * from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +export * from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService'; +export * from '#/workspace/workspaceInstructions/workspaceInstructions'; +export * from '#/workspace/workspaceInstructions/workspaceInstructionsService'; +export * from '#/agent/permissionGate/permissionGate'; +export * from '#/agent/permissionGate/permissionGateService'; +export * from '#/agent/toolApproval/toolApproval'; +export * from '#/agent/toolApproval/toolApprovalService'; +import '#/app/flag/flag'; +import '#/app/flag/flagRegistry'; +import '#/app/flag/flagRegistryService'; +import '#/app/flag/flagService'; +export * from '#/app/flag/flagRegistry'; +export * from '#/app/flag/flagRegistryService'; +export * from '#/app/flag/flag'; +export * from '#/app/flag/flagService'; + +export * from '#/agent/modeMutex/modeMutex'; +import '#/agent/modeMutex/modeMutexService'; +export * from '#/features/btw/btw'; +export * from '#/features/btw/btwService'; +import '#/features/btw/btwFeature'; +import '#/features/plan/profile/plan'; +export * from '#/features/plan/tools/enter-plan-mode/enter-plan-mode'; +import '#/features/plan/tools/enter-plan-mode/enterPlanModeTool'; +export * from '#/features/plan/tools/exit-plan-mode/exit-plan-mode'; +import '#/features/plan/tools/exit-plan-mode/exitPlanModeTool'; +export * from '#/features/plan/configSection'; +export * from '#/features/plan/plan'; +export * from '#/features/plan/planOps'; +export * from '#/features/plan/planService'; +import '#/features/dateChange/dateChangeFeature'; +import '#/features/plan/planFeature'; +export * from '#/features/fileHistory/fileHistory'; +export * from '#/features/fileHistory/fileHistoryOps'; +export * from '#/features/fileHistory/fileHistoryService'; +import '#/features/fileHistory/fileHistoryFeature'; +export * from '#/features/externalHooks/configSection'; +export * from '#/features/externalHooks/app/externalHooksRunner'; +export * from '#/features/externalHooks/app/externalHooksRunnerService'; +export * from '#/features/externalHooks/session/sessionExternalHooks'; +export * from '#/features/externalHooks/session/sessionExternalHooksService'; +export * from '#/features/externalHooks/agent/agentExternalHooks'; +export * from '#/features/externalHooks/agent/agentExternalHooksService'; +import '#/features/externalHooks/externalHooksFeature'; +export * from '#/features/debugEvents/debugEvents'; +export * from '#/features/debugEvents/debugEventsService'; +import '#/features/debugEvents/debugEventsFeature'; +export * from '#/features/swarm/configSection'; +export * from '#/features/swarm/agent/swarm'; +export * from '#/features/swarm/agent/swarmService'; +export * from '#/features/swarm/session/sessionSwarm'; +export * from '#/features/swarm/session/sessionSwarmService'; +export * from '#/features/swarm/tools/agent-swarm/agent-swarm'; +import '#/features/swarm/tools/agent-swarm/agentSwarmTool'; +import '#/features/swarm/swarmFeature'; +export * from '#/features/goal/tools/create-goal/create-goal'; +import '#/features/goal/tools/create-goal/createGoalTool'; +export * from '#/features/goal/tools/get-goal/get-goal'; +import '#/features/goal/tools/get-goal/getGoalTool'; +export * from '#/features/goal/tools/set-goal-budget/set-goal-budget'; +import '#/features/goal/tools/set-goal-budget/setGoalBudgetTool'; +export * from '#/features/goal/tools/update-goal/update-goal'; +import '#/features/goal/tools/update-goal/updateGoalTool'; +export * from '#/features/goal/goalDeadlineScheduler'; +export * from '#/features/goal/goal'; +export * from '#/features/goal/goalService'; +export * from '#/features/goal/goalOps'; +export * from '#/features/goal/types'; +import '#/features/goal/goalFeature'; +export * from '#/features/tower/flag'; +export * from '#/features/tower/tower'; +export * from '#/features/tower/towerFeature'; +export * from '#/features/tower/towerService'; +export * from '#/features/tower/towerRateLimit'; +export * from '#/features/tower/towerRateLimitService'; +export * from '#/features/tower/tools/init/init'; +export * from '#/features/tower/tools/plan/plan'; +export * from '#/features/tower/tools/spawn/spawn'; +export * from '#/features/tower/tools/merge/merge'; +export * from '#/features/tower/tools/teardown/teardown'; +export * from '#/features/tower/tools/send/send'; +export * from '#/features/tower/tools/inbox/inbox'; +export * from '#/features/tower/tools/finding/finding'; +export * from '#/features/tower/tools/review/review'; +export * from '#/features/tower/tools/mission/mission'; +export * from '#/features/tower/tools/status/status'; +import '#/features/tower/flag'; +import '#/features/tower/towerFeature'; +export * from '#/agent/usage/usage'; +export * from '#/agent/usage/cacheProbe'; +export * from '#/agent/usage/cacheProbeService'; +export * from '#/session/usage/sessionUsage'; +export * from '#/session/usage/usageAgentModel'; +export * from '#/session/usage/sessionUsageService'; +import '#/features/usage/usageFeature'; +export * from '#/agent/toolDedupe/toolDedupe'; +export * from '#/agent/toolDedupe/toolDedupeService'; +export * from '#/agent/agentsMdReminder/agentsMdReminder'; +export * from '#/agent/agentsMdReminder/agentsMdReminderService'; +import '#/agent/toolSelect/flag'; +export * from '#/agent/tools/select-tools/select-tools'; +import '#/agent/tools/select-tools/selectToolsTool'; +export * from '#/agent/toolSelect/dynamicTools'; +export * from '#/agent/toolSelect/toolSelect'; +export * from '#/agent/toolSelect/toolSelectService'; +export * from '#/agent/toolSelect/toolSelectAnnouncements'; +export * from '#/agent/toolSelect/toolSelectAnnouncementsService'; +export * from '#/agent/toolSelect/toolSelectSchemas'; +export * from '#/agent/toolSelect/toolSelectSchemasService'; +import '#/agent/toolPolicy/configSection'; +export * from '#/agent/toolPolicy/configSection'; +export * from '#/agent/toolPolicy/evaluate'; +export * from '#/agent/toolPolicy/toolPolicy'; +export * from '#/agent/toolPolicy/toolPolicyService'; + +import '#/agent/task/configSection'; +export { + resolveAgentTaskConfig, + resolvePrintBackgroundMode, + type AgentTaskConfig, + type PrintBackgroundMode, +} from '#/agent/task/configSection'; +export * from '#/agent/task/printDefaults'; +export * from '#/agent/tools/task/task-list/task-list'; +import '#/agent/tools/task/task-list/taskListTool'; +export * from '#/agent/tools/task/task-output/task-output'; +import '#/agent/tools/task/task-output/taskOutputTool'; +export * from '#/agent/tools/task/task-stop/task-stop'; +import '#/agent/tools/task/task-stop/taskStopTool'; +export * from '#/agent/tools/task/task-wait/task-wait'; +import '#/agent/tools/task/task-wait/taskWaitTool'; +export * from '#/agent/task/task'; +export * from '#/agent/task/taskOps'; +export * from '#/agent/task/taskService'; +import '#/features/cron/configSection'; +export * from '#/features/cron/cronTask'; +export * from '#/features/cron/configSection'; +export * from '#/features/cron/cronService'; +export * from '#/features/cron/cronOps'; +export type { CronFiredEvent } from '#/features/cron/cronOps'; +import '#/features/cron/cronFeature'; +export * from '#/features/cron/tools/cron-create/cron-create'; +export * from '#/features/cron/tools/cron-list/cron-list'; +export * from '#/features/cron/tools/cron-delete/cron-delete'; + +import '#/session/agentLifecycle/profile/profiles'; +export * from '#/session/agentLifecycle/agentLifecycle'; +export * from '#/session/agentLifecycle/agentLifecycleService'; +export * from '#/session/agentLifecycle/mainAgent'; +export * from '#/session/mcp/sessionMcpHandle'; +import '#/app/mcpConfig/configSection'; +export { + MCP_SECTION, + McpSectionSchema, + type McpSection, +} from '#/app/mcpConfig/configSection'; +export * from '#/app/mcpConfig/oauthStore'; +export { IMcpConfigStore } from '#/app/mcpConfig/configStore'; +import '#/app/mcpConfig/configStore'; +export { IMcpOAuthService } from '#/app/mcpConfig/oauthService'; +import '#/app/mcpConfig/oauthService'; +export * from '#/app/mcpRegistry/mcpRegistry'; +import '#/app/mcpRegistry/mcpRegistryService'; +export * from '#/app/mcpManagement/mcpManagement'; +import '#/app/mcpManagement/mcpManagementService'; +export * from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; +export * from '#/workspace/workspaceMcpConfig/workspaceMcpConfigService'; +export * from '#/workspace/workspaceMcp/workspaceMcp'; +export * from '#/workspace/workspaceMcp/workspaceMcpService'; +export * from '#/session/subagent/subagent'; +export * from '#/session/subagent/subagentService'; +export * from '#/session/subagent/spawn'; +import '#/session/subagent/flag'; +export * from '#/session/subagent/subagentModelsValidation'; +import '#/session/subagent/subagentModelsValidationService'; +export * from '#/agent/tools/agent/subagent-task'; +export { AGENT_RUN_PROMPT_ORIGIN } from '#/session/subagent/runAgentTurn'; +export * from '#/session/subagent/mirrorAgentRun'; +export * from '#/session/subagent/subagentScopeCache'; +import '#/session/subagent/subagentScopeCacheService'; +import '#/session/subagent/configSection'; +export * from '#/agent/tools/agent/agent'; +import '#/agent/tools/agent/agentTool'; +export * from '#/app/sessionManager/sessionLookup'; +export * from '#/workspace/workspaceContext/workspaceContext'; +export * from '#/workspace/sessionLifecycle/sessionLifecycle'; +export * from '#/workspace/sessionLifecycle/sessionLifecycleEvents'; +export * from '#/workspace/sessionLifecycle/sessionLifecycleService'; +export * from '#/workspace/sessionLifecycle/coldSessionArchive'; +export * from '#/workspace/sessionLifecycle/internal/addressing'; +import '#/app/sessionExport/errors'; +export * from '#/app/sessionExport/sessionExport'; +export * from '#/app/sessionExport/sessionExportService'; +export * from '#/app/sessionExport/manifest'; +export * from '#/app/sessionExport/wire-scan'; +export * from '#/app/sessionExport/zip'; +export * from '#/app/sessionLegacy/sessionLegacy'; +export * from '#/app/sessionLegacy/sessionLegacyService'; +export * from '#/human/interaction/interaction'; +export * from '#/human/interaction/facade'; +export * from '#/agent/interaction/interactionOps'; +export * from '#/session/sessionContext/sessionContext'; + +export * from '#/agent/interaction/question'; +export { + type ApprovalDecision, + type ApprovalRequest as SessionApprovalRequest, + type ApprovalResponse as SessionApprovalResponse, +} from '#/agent/interaction/approval'; +export * from '#/agent/tools/ask-user-question/ask-user-question'; +import '#/agent/tools/ask-user-question/askUserQuestionTool'; +export * from '#/app/gateway/gateway'; +export * from '#/app/gateway/gatewayService'; + +export * from '#/session/workspaceContext/workspaceContext'; +export * from '#/session/workspaceContext/workspaceContextService'; +export * from '#/app/projectLocalConfig/projectLocalConfig'; +export * from '#/app/workspace/workspace'; +export * from '#/app/workspace/workspaceService'; +export * from '#/app/workspace/workspaceAlias'; +export * from '#/app/workspace/workspaceEvents'; +export * from '#/app/workspace/workspacePersistence'; +export * from '#/app/workspace/fileWorkspacePersistence'; +export * from '#/app/workspaceAliases/workspaceAliases'; +import '#/app/workspaceAliases/workspaceAliasesService'; +export * from '#/app/workspaceSessions/workspaceSessions'; +import '#/app/workspaceSessions/workspaceSessionsService'; +import '#/app/git/gitService'; +export * from '#/app/bashParser/bashParser'; +import '#/app/bashParser/bashParserService'; +export * from '#/workspace/workspaceFs/internal/errors'; +export * from '#/workspace/workspaceFs/fs'; +export * from '#/workspace/workspaceFs/fsService'; +export * from '#/session/agentLifecycle/profile/gitContext'; +export * from '#/workspace/workspaceFs/internal/rgLocator'; +export * from '#/workspace/workspaceFs/internal/runRg'; +export * from '#/workspace/workspaceGit/workspaceGit'; +export * from '#/workspace/workspaceGit/workspaceGitService'; +export * from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; +export * from '#/session/sessionToolPolicyGate/sessionToolPolicyGateService'; +export * from '#/workspace/workspaceTrust/workspaceTrust'; +export * from '#/workspace/workspaceTrust/workspaceTrustService'; +export * from '#/app/hostFolderBrowser/hostFolderBrowser'; +export * from '#/app/hostFolderBrowser/hostFolderBrowserService'; +export * from '#/persistence/interface/storage'; +export * from '#/persistence/interface/appendLogStore'; +export * from '#/persistence/interface/atomicDocumentStore'; +export * from '#/persistence/interface/queryStore'; +export * from '#/persistence/interface/blobStore'; +export * from '#/persistence/backends/node-fs/fileStorageService'; +export * from '#/persistence/backends/node-fs/appendLogStore'; +export * from '#/persistence/backends/node-fs/atomicDocumentStore'; +export * from '#/persistence/backends/node-fs/blobStoreService'; +export * from '#/persistence/backends/node-fs/projectLocalConfigService'; +export * from '#/persistence/configSection'; +import '#/persistence/configSection'; +export * from '#/persistence/backends/minidb/miniDbQueryStore'; +export * from '#/persistence/backends/memory/inMemoryStorageService'; +export * from '#/agent/tools/web-search/web-search'; +import '#/agent/tools/web-search/webSearchTool'; +export * from '#/app/auth/auth'; +export * from '#/app/auth/authService'; +export * from '#/app/auth/configSection'; +export * from '#/app/auth/webSearch/webSearch'; +export * from '#/app/auth/webSearch/webSearchService'; +export * from '#/app/auth/webSearch/providers/moonshot-web-search'; +export * from '#/app/authLegacy/authLegacy'; +export * from '#/app/authLegacy/authLegacyService'; +export * from '#/app/file/fileService'; +export * from '#/app/file/fileServiceImpl'; +export { + buildImageCompressionCaption, + compressBase64ForModel, + compressImageForModel, + gateImageFormatParts, + IMAGE_BYTE_BUDGET, + MAX_IMAGE_DECODE_BYTES, + MAX_IMAGE_EDGE_PX, + READ_IMAGE_BYTE_BUDGET, + resolveMaxImageEdgePx, + resolveReadImageByteBudget, +} from '#/agent/media/image-compress'; +export { providerImagePolicy, type ProviderImagePolicy } from '#human/llm/media/image-formats'; +export { + buildImageConversionGuidance, + buildUnsupportedImageNotice, + decodeBase64Prefix, + isModelAcceptedImageMime, + normalizeImageMime, + parseImageDataUrl, + resolveEffectiveImageMime, + unsupportedImageMimeFromUrl, +} from '#/agent/media/image-format-policy'; +export { + persistOriginalImage, + sessionMediaOriginalsDir, +} from '#/agent/media/image-originals'; +export * from '#/app/edit/fileEdit'; +export * from '#/app/edit/fileEditService'; +export * from '#/app/edit/editService'; +export * from '#/app/edit/textModel'; +export * from '#/agent/tools/edit/edit'; +import '#/agent/tools/edit/editTool'; +export * from '#/agent/tools/fetch-url/fetch-url'; +import '#/agent/tools/fetch-url/fetchUrlTool'; +export * from '#/app/web/web'; +export * from '#/app/web/webService'; +export * from '#/app/web/providers/local-fetch-url'; +export * from '#/app/web/providers/moonshot-fetch-url'; + +export * from '#/agent/blob/agentBlobService'; +export * from '#/agent/blob/agentBlobServiceImpl'; +export * from '#/agent/contextMemory/contextMemory'; +export * from '#/agent/contextMemory/contextMemoryService'; +export * from '#/agent/contextMemory/contextOps'; +export * from '#/agent/contextMemory/compactionHandoff'; +export * from '#/agent/contextMemory/conversationUndoParticipants'; +export * from '#/agent/contextMemory/conversationTime'; +export * from '#/agent/contextMemory/loopEventFold'; +export * from '#/agent/contextMemory/messageId'; +export * from '#/agent/contextMemory/contextTranscript'; +export * from '#/agent/contextMemory/types'; +export * from '#/features/reminder/reminderService'; +export * from '#/features/reminder/systemReminder'; +export * from '#/features/reminder/types'; +import '#/features/reminder/reminderFeature'; +export * from '#/features/dateChange/dateChange'; +export * from '#/features/dateChange/dateChangeService'; +export * from '#/agent/contextProjector/contextProjector'; +export * from '#/agent/contextProjector/contextProjectorService'; +export * from '#/agent/contextProjector/mediaProjection'; +export * from '#/agent/tokenCounting/tokenCounting'; +export * from '#/agent/tokenCounting/tokenCountingOps'; +export * from '#/session/tokenCounting/sessionTokenCounting'; +export * from '#/session/tokenCounting/tokenCountingAgentModel'; +export * from '#/session/tokenCounting/sessionTokenCountingService'; +import '#/features/tokenCounting/tokenCountingFeature'; +export * from '#/agent/plugin/agentPlugin'; +export * from '#/agent/plugin/agentPluginOps'; +export * from '#/agent/plugin/agentPluginService'; +export * from '#/agent/fullCompaction/strategy'; +export * from '#/agent/fullCompaction/fullCompaction'; +export * from '#/agent/fullCompaction/fullCompactionService'; +export * from '#/agent/fullCompaction/compactionOps'; +export * from '#/agent/fullCompaction/types'; +export * from '#/agent/fullCompaction/contextRecovery'; +export * from '#/agent/fullCompaction/compactionInstruction'; +export * from '#/agent/llmRequester/llmRequester'; +export * from '#/agent/llmRequester/llmRequesterService'; +export * from '#/agent/llmRequester/llmRequestOps'; +export * from '#/_base/utils/promise'; +export * from '#/_base/utils/retry'; +export * from '#/_base/utils/timer'; +import '#/agent/loop/configSection'; +export * from '#/agent/loop/loop'; +export * from '#/agent/loop/loopService'; +export * from '#/agent/loop/promptChannel'; +export * from '#/agent/interruptionReminder/interruptionReminder'; +export * from '#/agent/interruptionReminder/interruptionReminderService'; +export * from '#/agent/interruptionReminder/interruptionReminderOps'; +export * from '#/agent/mcp/mcp'; +export * from '#/agent/mcp/mcpService'; +export * from '#/agent/mcp/mcpDiscoveryOps'; +export * from '#/mcpCore/config-schema'; +export * from '#/agent/media/mediaTools'; +export * from '#/agent/media/mediaToolsRegistrar'; +export * from '#/agent/media/registerMediaTools'; +export { + buildDaemonFileUrl, + buildMediaPathTag, + daemonFileRefFromPart, + mediaExtensionForMime, + matchSingleMediaPathTag, + parseDaemonFileUrl, +} from '#/agent/media/mediaRef'; +export type { DaemonFileRef, MediaKind } from '#/agent/media/mediaRef'; +export * from '#/agent/media/sessionMediaStore'; +import '#/agent/media/sessionMediaStoreService'; +export * from '#/agent/media/kimiFileUrl'; +export * from '#/agent/media/videoUpload'; +export * from '#/agent/media/mediaResolver'; +export * from '#/agent/media/mediaResolverService'; +import '#/agent/media/configSection'; +export * from '#/agent/media/imageConfigBridge'; +import '#/agent/permissionMode/configSection'; +export * from '#/agent/permissionMode/permissionMode'; +export * from '#/agent/permissionMode/permissionModeService'; +export * from '#/agent/permissionPolicy/permissionPolicy'; +export * from '#/agent/permissionPolicy/permissionPolicyService'; +export * from '#/agent/permissionPolicy/types'; +import '#/agent/permissionRules/configSection'; +export * from '#/agent/permissionRules/permissionRules'; +export * from '#/agent/permissionRules/matchesRule'; +export * from '#/agent/permissionRules/permissionRulesService'; +export * from '#/agent/pluginCommand/pluginCommand'; +export * from '#/agent/pluginCommand/pluginCommandService'; +export * from '#/agent/profile/profile'; +export * from '#/agent/profile/profileService'; +export * from '#/agent/profile/context'; +export * from '#/agent/prompt/promptEvents'; +export * from '#/agent/prompt/promptMetadataText'; +export * from '#/agent/replayBuilder/types'; +export * from '#/agent/replayBuilder/fold'; +export { type SessionSummary } from '#/app/sessionIndex/sessionIndex'; +export * from '#/agent/undo/undo'; +export * from '#/agent/undo/undoService'; +export * from '#/agent/shellCommand/shellCommand'; +export * from '#/agent/shellCommand/shellCommandService'; +export * from '#/agent/agentContext/agentContext'; +export * from '#/agent/agentContext/agentSpace'; +export * from '#/agent/scopeContext/scopeContext'; +export * from '#/features/sessionInit/sessionInit'; +export * from '#/features/sessionInit/sessionInitService'; +export * from '#/features/sessionInit/profile/init'; +import '#/features/sessionInit/sessionInitFeature'; +export * from '#/features/todo/todoItem'; +export * from '#/features/todo/todoListReminder'; +export * from '#/features/todo/todoService'; +export * from '#/features/todo/tools/todo-list/todo-list'; +import '#/features/todo/todoFeature'; +export * from '#/features/notify/flag'; +export * from '#/features/notify/notifyUserAvailability'; +export * from '#/features/notify/tools/notify-user/notify-user'; +import '#/features/notify/notifyFeature'; +export * from '#/tool/toolContract'; +export * from '#/agent/toolExecutor/toolHooks'; +export * from '#/agent/toolExecutor/toolExecutor'; +export * from '#/agent/toolExecutor/toolExecutorService'; +export * from '#/agent/toolResultTruncation/toolResultTruncation'; +import '#/agent/toolResultTruncation/toolResultTruncationService'; +import '#/agent/toolActivation/toolActivationService'; +import '#/agent/toolRegistry/toolContribution'; +import '#/agent/toolRegistry/toolRegistry'; +import '#/agent/toolRegistry/toolRegistryService'; +export { IAgentToolActivationService } from '#/agent/toolActivation/toolActivation'; +export { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +export { registerAgentToolService, AgentToolContribution } from '#/agent/toolRegistry/toolContribution'; +export type { AgentToolContributionOptions } from '#/agent/toolRegistry/toolContribution'; +export * from '#/agent/userTool/userTool'; +export * from '#/agent/userTool/userToolOps'; +export * from '#/agent/userTool/userToolService'; diff --git a/packages/agent-core-v2/src/llm-adapter/record-diff.ts b/packages/agent-core-v2/src/llm-adapter/record-diff.ts new file mode 100644 index 0000000000000000000000000000000000000000..60c6976013dcc8f7ace2f00f9a07795a39b895eb --- /dev/null +++ b/packages/agent-core-v2/src/llm-adapter/record-diff.ts @@ -0,0 +1,49 @@ +export interface RecordDiff { + readonly added: readonly string[]; + readonly removed: readonly string[]; + readonly changed: readonly string[]; +} + +export function isEmptyDiff(diff: RecordDiff): boolean { + return diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0; +} + +export function diffRecords( + previous: Readonly> | undefined, + current: Readonly> | undefined, +): RecordDiff { + const prev = previous ?? {}; + const curr = current ?? {}; + const added: string[] = []; + const removed: string[] = []; + const changed: string[] = []; + for (const key of Object.keys(curr)) { + if (!(key in prev)) { + added.push(key); + } else if (!deepEqual(prev[key], curr[key])) { + changed.push(key); + } + } + for (const key of Object.keys(prev)) { + if (!(key in curr)) { + removed.push(key); + } + } + return { added, removed, changed }; +} + +export function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false; + if (!deepEqual((a as Record)[key], (b as Record)[key])) { + return false; + } + } + return true; +} diff --git a/packages/agent-core-v2/src/mcpCore/client-http.ts b/packages/agent-core-v2/src/mcpCore/client-http.ts new file mode 100644 index 0000000000000000000000000000000000000000..cba0f916220198ede86f388334d75b51088b4608 --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/client-http.ts @@ -0,0 +1,169 @@ +import { ErrorCodes, Error2 } from '#/errors'; +import type { McpServerHttpConfig } from './config-schema'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +import { + buildRequestOptions, + KIMI_MCP_CLIENT_NAME, + KIMI_MCP_CLIENT_VERSION, + MCP_LIVENESS_PROBE_TIMEOUT_MS, + toMcpToolDefinition, + toMcpToolResult, + type UnexpectedCloseListener, + type UnexpectedCloseReason, +} from './client-shared'; +import { buildMcpRemoteHeaders } from './client-remote'; +import { createMcpOAuthFetch } from './oauth/provider'; +import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types'; + +export interface HttpMcpClientOptions { + readonly clientName?: string; + readonly clientVersion?: string; + readonly startupTimeoutMs?: number; + readonly toolCallTimeoutMs?: number; + readonly envLookup?: (name: string) => string | undefined; + readonly fetch?: typeof fetch; + readonly oauthProvider?: OAuthClientProvider; +} + +export class HttpMcpClient implements MCPClient { + private readonly client: Client; + private readonly transport: StreamableHTTPClientTransport; + private readonly startupTimeoutMs?: number; + private readonly toolCallTimeoutMs?: number; + private started = false; + private closed = false; + private ready = false; + private hooksInstalled = false; + private unexpectedCloseListener: UnexpectedCloseListener | undefined; + private lastTransportError: Error | undefined; + private pendingUnexpectedClose: UnexpectedCloseReason | undefined; + private unexpectedCloseFired = false; + + constructor(config: McpServerHttpConfig, options: HttpMcpClientOptions = {}) { + const envLookup = options.envLookup ?? ((name) => process.env[name]); + const headers = buildMcpHttpHeaders(config, envLookup); + + this.transport = new StreamableHTTPClientTransport(new URL(config.url), { + requestInit: headers !== undefined ? { headers } : undefined, + fetch: createMcpOAuthFetch(options.oauthProvider, options.fetch), + authProvider: options.oauthProvider, + }); + this.client = new Client({ + name: options.clientName ?? KIMI_MCP_CLIENT_NAME, + version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION, + }); + this.startupTimeoutMs = options.startupTimeoutMs; + this.toolCallTimeoutMs = options.toolCallTimeoutMs; + } + + async connect(): Promise { + if (this.closed) { + throw new Error2(ErrorCodes.MCP_STARTUP_FAILED, 'MCP HTTP client is closed'); + } + if (this.started) return; + this.started = true; + this.installTransportHooks(); + try { + await this.client.connect( + this.transport, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); + } catch (error) { + await this.closeStartedClient(); + throw error; + } + if (this.closed) { + await this.closeStartedClient(); + throw new Error2(ErrorCodes.MCP_STARTUP_FAILED, 'MCP HTTP client was closed during startup'); + } + this.ready = true; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + await this.closeStartedClient(); + } + + onUnexpectedClose(listener: UnexpectedCloseListener): void { + this.unexpectedCloseListener = listener; + const pending = this.pendingUnexpectedClose; + if (pending !== undefined) { + this.pendingUnexpectedClose = undefined; + listener(pending); + } + } + + async listTools(): Promise { + const result = await this.client.listTools( + undefined, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); + return result.tools.map(toMcpToolDefinition); + } + + async callTool( + name: string, + args: Record, + signal?: AbortSignal, + ): Promise { + const requestOptions = buildRequestOptions(this.toolCallTimeoutMs, signal); + const result = await this.client.callTool({ name, arguments: args }, undefined, requestOptions); + return toMcpToolResult(result); + } + + async ping(signal?: AbortSignal): Promise { + await this.client.ping(buildRequestOptions(MCP_LIVENESS_PROBE_TIMEOUT_MS, signal)); + } + + private async closeStartedClient(): Promise { + if (!this.started) return; + this.started = false; + await this.client.close(); + } + + private installTransportHooks(): void { + if (this.hooksInstalled) return; + this.hooksInstalled = true; + this.client.onclose = () => { + if (this.closed) return; + if (!this.ready) return; + this.fireUnexpectedClose({ error: this.lastTransportError }); + }; + this.client.onerror = (error) => { + this.lastTransportError = error; + if (this.closed) return; + if (!this.ready) return; + if (isTerminalTransportError(error)) { + this.fireUnexpectedClose({ error }); + } + }; + } + + private fireUnexpectedClose(reason: UnexpectedCloseReason): void { + if (this.unexpectedCloseFired) return; + this.unexpectedCloseFired = true; + const listener = this.unexpectedCloseListener; + if (listener !== undefined) { + listener(reason); + } else { + this.pendingUnexpectedClose = reason; + } + } +} + +export function isTerminalTransportError(error: Error): boolean { + if (error.name === 'UnauthorizedError') return true; + if (/Maximum reconnection attempts/i.test(error.message)) return true; + return false; +} + +export function buildMcpHttpHeaders( + config: McpServerHttpConfig, + envLookup: (name: string) => string | undefined, +): Record | undefined { + return buildMcpRemoteHeaders(config, envLookup); +} diff --git a/packages/agent-core-v2/src/mcpCore/client-remote.ts b/packages/agent-core-v2/src/mcpCore/client-remote.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ca292abd6517f0f2915c79346e367a808616da8 --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/client-remote.ts @@ -0,0 +1,29 @@ +import type { McpRemoteServerConfig, McpServerConfig } from './config-schema'; +import { ErrorCodes, Error2 } from '#/errors'; + +export function buildMcpRemoteHeaders( + config: McpRemoteServerConfig, + envLookup: (name: string) => string | undefined, +): Record | undefined { + const headers: Record = { ...config.headers }; + if (config.bearerTokenEnvVar !== undefined) { + const token = envLookup(config.bearerTokenEnvVar); + if (token === undefined || token.length === 0) { + throw new Error2( + ErrorCodes.CONFIG_INVALID, + `MCP ${config.transport.toUpperCase()} bearer token env var "${config.bearerTokenEnvVar}" is not set or is empty`, + ); + } + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'authorization') { + delete headers[key]; + } + } + headers['Authorization'] = `Bearer ${token}`; + } + return Object.keys(headers).length > 0 ? headers : undefined; +} + +export function isRemoteMcpConfig(config: McpServerConfig): config is McpRemoteServerConfig { + return config.transport === 'http' || config.transport === 'sse'; +} diff --git a/packages/agent-core-v2/src/mcpCore/client-shared.ts b/packages/agent-core-v2/src/mcpCore/client-shared.ts new file mode 100644 index 0000000000000000000000000000000000000000..55937fa8df544cab3a2b41f9bea71c8bcac49630 --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/client-shared.ts @@ -0,0 +1,109 @@ +import { getCoreVersion } from '#/_base/version'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; + +import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types'; + +export const KIMI_MCP_CLIENT_NAME = 'kimi-code'; +export const KIMI_MCP_CLIENT_VERSION = getCoreVersion(); + +export interface UnexpectedCloseReason { + readonly error?: Error; + readonly stderr?: string; +} + +export type UnexpectedCloseListener = (reason: UnexpectedCloseReason) => void; + +export function isMcpConnectionClosedError(error: unknown): boolean { + return ( + error instanceof Error && + (error as Error & { readonly code?: unknown }).code === ErrorCode.ConnectionClosed + ); +} + +export function isMcpTransportFailure(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (isMcpConnectionClosedError(error)) return true; + return !(error instanceof McpError); +} + +export const MCP_LIVENESS_PROBE_TIMEOUT_MS = 5_000; + +export function isMcpMalformedResultError(error: unknown): boolean { + return error instanceof Error && error.name === 'ZodError'; +} + +export async function probeMcpLiveness(client: MCPClient, signal: AbortSignal): Promise { + try { + await client.ping(signal); + return true; + } catch (error) { + if (isMcpConnectionClosedError(error)) return false; + if (isMcpMalformedResultError(error)) return true; + if (error instanceof McpError) { + return (error as Error & { readonly code?: unknown }).code !== ErrorCode.RequestTimeout; + } + return false; + } +} + +export interface McpRequestOptions { + readonly timeout?: number; + readonly signal?: AbortSignal; +} + +export function buildRequestOptions( + timeoutMs: number | undefined, + signal: AbortSignal | undefined, +): McpRequestOptions | undefined { + if (timeoutMs === undefined && signal === undefined) return undefined; + return { timeout: timeoutMs, signal }; +} + +interface SdkListedTool { + readonly name: string; + readonly description?: string; + readonly inputSchema: Record; +} + +export function toMcpToolDefinition(tool: SdkListedTool): MCPToolDefinition { + return { + name: tool.name, + description: tool.description ?? '', + inputSchema: tool.inputSchema, + }; +} + +export function toMcpToolResult(result: unknown): MCPToolResult { + if (typeof result === 'object' && result !== null && 'content' in result) { + const typed = result as { + content: unknown; + isError?: unknown; + structuredContent?: unknown; + _meta?: unknown; + }; + if (Array.isArray(typed.content)) { + return { + content: typed.content as MCPToolResult['content'], + isError: typed.isError === true, + structuredContent: typed.structuredContent, + _meta: + typeof typed._meta === 'object' && typed._meta !== null + ? (typed._meta as Record) + : undefined, + }; + } + } + if (typeof result === 'object' && result !== null && 'toolResult' in result) { + const legacy = (result as { toolResult: unknown }).toolResult; + return { + content: [ + { + type: 'text', + text: typeof legacy === 'string' ? legacy : JSON.stringify(legacy), + }, + ], + isError: false, + }; + } + return { content: [], isError: false }; +} diff --git a/packages/agent-core-v2/src/mcpCore/client-sse.ts b/packages/agent-core-v2/src/mcpCore/client-sse.ts new file mode 100644 index 0000000000000000000000000000000000000000..d490a09bf7a381d764b4bcdcc00f29eb715f6c7a --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/client-sse.ts @@ -0,0 +1,161 @@ +import { ErrorCodes, Error2 } from '#/errors'; +import type { McpServerSseConfig } from './config-schema'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import { SSEClientTransport, SseError } from '@modelcontextprotocol/sdk/client/sse.js'; + +import { + buildRequestOptions, + KIMI_MCP_CLIENT_NAME, + KIMI_MCP_CLIENT_VERSION, + MCP_LIVENESS_PROBE_TIMEOUT_MS, + toMcpToolDefinition, + toMcpToolResult, + type UnexpectedCloseListener, + type UnexpectedCloseReason, +} from './client-shared'; +import { buildMcpRemoteHeaders } from './client-remote'; +import { createMcpOAuthFetch } from './oauth/provider'; +import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types'; + +export interface SseMcpClientOptions { + readonly clientName?: string; + readonly clientVersion?: string; + readonly startupTimeoutMs?: number; + readonly toolCallTimeoutMs?: number; + readonly envLookup?: (name: string) => string | undefined; + readonly fetch?: typeof fetch; + readonly oauthProvider?: OAuthClientProvider; +} + +export class SseMcpClient implements MCPClient { + private readonly client: Client; + private readonly transport: SSEClientTransport; + private readonly startupTimeoutMs?: number; + private readonly toolCallTimeoutMs?: number; + private started = false; + private closed = false; + private ready = false; + private hooksInstalled = false; + private unexpectedCloseListener: UnexpectedCloseListener | undefined; + private lastTransportError: Error | undefined; + private pendingUnexpectedClose: UnexpectedCloseReason | undefined; + private unexpectedCloseFired = false; + + constructor(config: McpServerSseConfig, options: SseMcpClientOptions = {}) { + const envLookup = options.envLookup ?? ((name) => process.env[name]); + const headers = buildMcpRemoteHeaders(config, envLookup); + + this.transport = new SSEClientTransport(new URL(config.url), { + requestInit: headers !== undefined ? { headers } : undefined, + fetch: createMcpOAuthFetch(options.oauthProvider, options.fetch), + authProvider: options.oauthProvider, + }); + this.client = new Client({ + name: options.clientName ?? KIMI_MCP_CLIENT_NAME, + version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION, + }); + this.startupTimeoutMs = options.startupTimeoutMs; + this.toolCallTimeoutMs = options.toolCallTimeoutMs; + } + + async connect(): Promise { + if (this.closed) { + throw new Error2(ErrorCodes.MCP_STARTUP_FAILED, 'MCP SSE client is closed'); + } + if (this.started) return; + this.started = true; + this.installTransportHooks(); + try { + await this.client.connect( + this.transport, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); + } catch (error) { + await this.closeStartedClient(); + throw error; + } + if (this.closed) { + await this.closeStartedClient(); + throw new Error2(ErrorCodes.MCP_STARTUP_FAILED, 'MCP SSE client was closed during startup'); + } + this.ready = true; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + await this.closeStartedClient(); + } + + onUnexpectedClose(listener: UnexpectedCloseListener): void { + this.unexpectedCloseListener = listener; + const pending = this.pendingUnexpectedClose; + if (pending !== undefined) { + this.pendingUnexpectedClose = undefined; + listener(pending); + } + } + + async listTools(): Promise { + const result = await this.client.listTools( + undefined, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); + return result.tools.map(toMcpToolDefinition); + } + + async callTool( + name: string, + args: Record, + signal?: AbortSignal, + ): Promise { + const requestOptions = buildRequestOptions(this.toolCallTimeoutMs, signal); + const result = await this.client.callTool({ name, arguments: args }, undefined, requestOptions); + return toMcpToolResult(result); + } + + async ping(signal?: AbortSignal): Promise { + await this.client.ping(buildRequestOptions(MCP_LIVENESS_PROBE_TIMEOUT_MS, signal)); + } + + private async closeStartedClient(): Promise { + if (!this.started) return; + this.started = false; + await this.client.close(); + } + + private installTransportHooks(): void { + if (this.hooksInstalled) return; + this.hooksInstalled = true; + this.client.onclose = () => { + if (this.closed) return; + if (!this.ready) return; + this.fireUnexpectedClose({ error: this.lastTransportError }); + }; + this.client.onerror = (error) => { + this.lastTransportError = error; + if (this.closed) return; + if (!this.ready) return; + if (isTerminalSseTransportError(error)) { + this.fireUnexpectedClose({ error }); + } + }; + } + + private fireUnexpectedClose(reason: UnexpectedCloseReason): void { + if (this.unexpectedCloseFired) return; + this.unexpectedCloseFired = true; + const listener = this.unexpectedCloseListener; + if (listener !== undefined) { + listener(reason); + } else { + this.pendingUnexpectedClose = reason; + } + } +} + +export function isTerminalSseTransportError(error: Error): boolean { + if (error.name === 'UnauthorizedError') return true; + return error instanceof SseError && error.code !== undefined; +} diff --git a/packages/agent-core-v2/src/mcpCore/client-stdio.ts b/packages/agent-core-v2/src/mcpCore/client-stdio.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a45e1ece46fedc2fd935a5e695fa11e433b32f3 --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/client-stdio.ts @@ -0,0 +1,304 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; + +import { ErrorCodes, Error2 } from '#/errors'; +import type { IHostProcess } from '#/os/interface/hostProcess'; +import type { IRuntimeResolver } from '#/workspace/workspaceInstance/workspaceInstanceManager'; +import { proxyEnvForChild, reconcileChildNoProxy } from '#/_base/utils/proxy'; + +import { + buildRequestOptions, + KIMI_MCP_CLIENT_NAME, + KIMI_MCP_CLIENT_VERSION, + MCP_LIVENESS_PROBE_TIMEOUT_MS, + toMcpToolDefinition, + toMcpToolResult, + type UnexpectedCloseListener, + type UnexpectedCloseReason, +} from './client-shared'; +import type { McpServerStdioConfig } from './config-schema'; +import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types'; + +export interface StdioMcpClientOptions { + readonly clientName?: string; + readonly clientVersion?: string; + readonly startupTimeoutMs?: number; + readonly toolCallTimeoutMs?: number; + readonly defaultCwd?: string; + readonly runtimeResolver: IRuntimeResolver; + readonly workspaceId: string; + readonly runtimeId: string; +} + +const STDERR_BUFFER_CAPACITY = 4 * 1024; + +export class StdioMcpClient implements MCPClient { + private readonly client: Client; + private readonly transport: RuntimeStdioTransport; + private readonly startupTimeoutMs?: number; + private readonly toolCallTimeoutMs?: number; + private readonly stderrBuffer = new BoundedTail(STDERR_BUFFER_CAPACITY); + private started = false; + private closed = false; + private ready = false; + private hooksInstalled = false; + private unexpectedCloseListener: UnexpectedCloseListener | undefined; + private lastTransportError: Error | undefined; + private pendingUnexpectedClose: UnexpectedCloseReason | undefined; + + static readonly stderrBufferCapacity = STDERR_BUFFER_CAPACITY; + + constructor(config: McpServerStdioConfig, options: StdioMcpClientOptions) { + if (config.executor !== undefined && config.executor !== 'local') { + throw new Error2(ErrorCodes.NOT_IMPLEMENTED, `MCP stdio executor '${config.executor}' is not yet implemented`); + } + this.transport = new RuntimeStdioTransport(config, options, this.stderrBuffer); + this.client = new Client({ + name: options.clientName ?? KIMI_MCP_CLIENT_NAME, + version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION, + }); + this.startupTimeoutMs = options.startupTimeoutMs; + this.toolCallTimeoutMs = options.toolCallTimeoutMs; + } + + async connect(): Promise { + if (this.closed) { + throw new Error2(ErrorCodes.MCP_STARTUP_FAILED, 'MCP stdio client is closed'); + } + if (this.started) return; + this.started = true; + this.installTransportHooks(); + try { + await this.client.connect( + this.transport, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); + } catch (error) { + await this.closeStartedClient(); + throw error; + } + if (this.closed) { + await this.closeStartedClient(); + throw new Error2(ErrorCodes.MCP_STARTUP_FAILED, 'MCP stdio client was closed during startup'); + } + this.ready = true; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + await this.closeStartedClient(); + } + + onUnexpectedClose(listener: UnexpectedCloseListener): void { + this.unexpectedCloseListener = listener; + const pending = this.pendingUnexpectedClose; + if (pending !== undefined) { + this.pendingUnexpectedClose = undefined; + listener(pending); + } + } + + stderrSnapshot(): string { + return this.stderrBuffer.snapshot(); + } + + async listTools(): Promise { + const result = await this.client.listTools( + undefined, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); + return result.tools.map(toMcpToolDefinition); + } + + async callTool( + name: string, + args: Record, + signal?: AbortSignal, + ): Promise { + const requestOptions = buildRequestOptions(this.toolCallTimeoutMs, signal); + const result = await this.client.callTool({ name, arguments: args }, undefined, requestOptions); + return toMcpToolResult(result); + } + + async ping(signal?: AbortSignal): Promise { + await this.client.ping(buildRequestOptions(MCP_LIVENESS_PROBE_TIMEOUT_MS, signal)); + } + + private async closeStartedClient(): Promise { + if (!this.started) return; + this.started = false; + await this.client.close(); + } + + private installTransportHooks(): void { + if (this.hooksInstalled) return; + this.hooksInstalled = true; + this.client.onclose = () => { + if (this.closed) return; + if (!this.ready) return; + const stderr = this.stderrBuffer.snapshot(); + const reason: UnexpectedCloseReason = { + error: this.lastTransportError, + stderr: stderr.length > 0 ? stderr : undefined, + }; + const listener = this.unexpectedCloseListener; + if (listener !== undefined) { + listener(reason); + } else { + this.pendingUnexpectedClose = reason; + } + }; + this.client.onerror = (error) => { + this.lastTransportError = error; + }; + } +} + +class RuntimeStdioTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: T) => void; + private readonly readBuffer = new ReadBuffer(); + private process: IHostProcess | undefined; + private lease: ReturnType | undefined; + private started = false; + private closed = false; + + constructor( + private readonly config: McpServerStdioConfig, + private readonly options: StdioMcpClientOptions, + private readonly stderr: BoundedTail, + ) {} + + async start(): Promise { + if (this.started) throw new Error('Runtime stdio transport is already started'); + if (this.closed) throw new Error('Runtime stdio transport is closed'); + this.started = true; + const lease = this.options.runtimeResolver.acquire( + { workspaceId: this.options.workspaceId, runtimeId: this.options.runtimeId }, + ['process'], + ); + this.lease = lease; + try { + const base = lease.runtime.path.resolve(this.options.defaultCwd ?? lease.runtime.environment.homeDir); + const cwd = this.config.cwd === undefined ? base : lease.runtime.path.resolve(base, this.config.cwd); + const process = lease.track(await lease.runtime.process!.spawn( + this.config.command, + this.config.args, + { cwd, env: mergeStdioEnv(this.config.env) }, + )); + this.process = process; + lease.track(this); + process.stdin.on('error', (error: Error) => this.onerror?.(error)); + process.stdout.on('data', (chunk: Buffer | string) => this.onData(chunk)); + process.stdout.on('end', () => this.finish()); + process.stdout.on('error', (error: Error) => this.onerror?.(error)); + process.stderr.on('data', (chunk: Buffer | string) => { + this.stderr.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8')); + }); + process.stderr.on('error', (error: Error) => this.onerror?.(error)); + void process.wait().then( + () => this.finish(), + (error: unknown) => { + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + this.finish(); + }, + ); + } catch (error) { + this.lease = undefined; + lease.dispose(); + throw error; + } + } + + async send(message: JSONRPCMessage): Promise { + const process = this.process; + if (process === undefined || this.closed) throw new Error('Runtime stdio transport is not running'); + const data = serializeMessage(message); + await new Promise((resolve, reject) => { + process.stdin.write(data, (error) => { + if (error !== null && error !== undefined) reject(error); + else resolve(); + }); + }); + } + + dispose(): Promise { + return this.close(); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + const process = this.process; + this.process = undefined; + if (process !== undefined) { + try { + await process.kill(); + } catch {} + void process.dispose(); + } + this.readBuffer.clear(); + const lease = this.lease; + this.lease = undefined; + lease?.dispose(); + this.onclose?.(); + } + + private onData(chunk: Buffer | string): void { + this.readBuffer.append(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + while (true) { + try { + const message = this.readBuffer.readMessage(); + if (message === null) return; + this.onmessage?.(message); + } catch (error) { + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + } + } + + private finish(): void { + if (this.closed) return; + this.closed = true; + this.process = undefined; + this.readBuffer.clear(); + const lease = this.lease; + this.lease = undefined; + lease?.dispose(); + this.onclose?.(); + } +} + +class BoundedTail { + private buffer = ''; + constructor(private readonly capacity: number) {} + + push(chunk: string): void { + this.buffer += chunk; + if (this.buffer.length > this.capacity) { + this.buffer = this.buffer.slice(this.buffer.length - this.capacity); + } + } + + snapshot(): string { + return this.buffer; + } +} + +export function mergeStdioEnv( + configEnv?: Record, + parentEnv: Readonly> = process.env, +): Record { + const merged: Record = {}; + for (const [key, value] of Object.entries(parentEnv)) { + if (value !== undefined) merged[key] = value; + } + if (configEnv !== undefined) Object.assign(merged, configEnv); + Object.assign(merged, proxyEnvForChild(merged)); + reconcileChildNoProxy(merged, configEnv); + return merged; +} diff --git a/packages/agent-core-v2/src/mcpCore/config-schema.ts b/packages/agent-core-v2/src/mcpCore/config-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..e88a81dc20d818f8abe58cda157d44faf4927ac4 --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/config-schema.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; + +const StringRecordSchema = z.record(z.string(), z.string()); + +export const MAX_MCP_TIMEOUT_MS = 2_147_483_647; +export const McpTimeoutMsSchema = z.number().int().min(1).max(MAX_MCP_TIMEOUT_MS); + +const McpServerCommonFields = { + enabled: z.boolean().optional(), + deferred: z.boolean().optional(), + startupTimeoutMs: McpTimeoutMsSchema.optional(), + toolTimeoutMs: McpTimeoutMsSchema.optional(), + enabledTools: z.array(z.string()).optional(), + disabledTools: z.array(z.string()).optional(), +} as const; + +export const McpServerStdioConfigSchema = z.object({ + transport: z.literal('stdio'), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: StringRecordSchema.optional(), + cwd: z.string().optional(), + executor: z.enum(['local', 'kaos']).optional(), + runtime_id: z.string().min(1).optional(), + ...McpServerCommonFields, +}); + +export type McpServerStdioConfig = z.infer; + +export const McpServerHttpConfigSchema = z.object({ + transport: z.literal('http'), + url: z.string().url(), + headers: StringRecordSchema.optional(), + auth: z.literal('oauth').optional(), + bearerTokenEnvVar: z.string().min(1).optional(), + ...McpServerCommonFields, +}); + +export type McpServerHttpConfig = z.infer; + +export const McpServerSseConfigSchema = z.object({ + transport: z.literal('sse'), + url: z.string().url(), + headers: StringRecordSchema.optional(), + auth: z.literal('oauth').optional(), + bearerTokenEnvVar: z.string().min(1).optional(), + ...McpServerCommonFields, +}); + +export type McpServerSseConfig = z.infer; +export type McpRemoteServerConfig = McpServerHttpConfig | McpServerSseConfig; + +const McpServerConfigDiscriminatedSchema = z.discriminatedUnion('transport', [ + McpServerStdioConfigSchema, + McpServerHttpConfigSchema, + McpServerSseConfigSchema, +]); + +export const McpServerConfigSchema = z.preprocess((raw) => { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return raw; + const obj = raw as Record; + if ('transport' in obj) return obj; + if (typeof obj['command'] === 'string') return { ...obj, transport: 'stdio' }; + if (typeof obj['url'] === 'string') return { ...obj, transport: 'http' }; + return obj; +}, McpServerConfigDiscriminatedSchema); + +export type McpServerConfig = z.infer; diff --git a/packages/agent-core-v2/src/mcpCore/configView.ts b/packages/agent-core-v2/src/mcpCore/configView.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9dcd95ef38beb6a7e62c69b41bc5da8c4a1886f --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/configView.ts @@ -0,0 +1,18 @@ +import type { McpServerConfig } from './config-schema'; + +export type McpServerConfigView = + | (Omit, 'env'> & { + readonly envKeys?: readonly string[]; + }) + | (Omit, 'headers'> & { + readonly headerKeys?: readonly string[]; + }); + +export function toMcpServerConfigView(config: McpServerConfig): McpServerConfigView { + if (config.transport === 'stdio') { + const { env, ...safe } = config; + return env === undefined ? safe : { ...safe, envKeys: Object.keys(env).toSorted() }; + } + const { headers, ...safe } = config; + return headers === undefined ? safe : { ...safe, headerKeys: Object.keys(headers).toSorted() }; +} diff --git a/packages/agent-core-v2/src/mcpCore/connection-manager.ts b/packages/agent-core-v2/src/mcpCore/connection-manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3b8a09c87bf1b218d3a12dffe5dac706ece846f --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/connection-manager.ts @@ -0,0 +1,603 @@ +import { ErrorCodes, Error2 } from '#/errors'; +import type { McpServerConfig } from './config-schema'; +import type { ILogger as Logger } from '#/_base/log/log'; +import type { ToolDescription as Tool } from '#human/llm/message'; +import { HostProcessError, HostProcessErrorCode } from '#/os/interface/hostProcess'; + +import { abortable } from '#/_base/utils/abort'; +import { HttpMcpClient } from './client-http'; +import { isRemoteMcpConfig } from './client-remote'; +import { SseMcpClient } from './client-sse'; +import type { UnexpectedCloseReason } from './client-shared'; +import { StdioMcpClient } from './client-stdio'; +import type { McpOAuthService } from '#/mcpCore/oauth/service'; +import { assertMcpInputSchema, type MCPClient, type MCPToolDefinition } from './types'; + +export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth' | 'removed'; + +export interface McpServerEntry { + readonly name: string; + readonly transport: McpServerConfig['transport']; + readonly status: McpServerStatus; + readonly toolCount: number; + readonly error?: string; +} + +interface InternalEntry { + readonly name: string; + readonly config: McpServerConfig; + attemptId: number; + status: McpServerStatus; + tools?: readonly Tool[]; + rawTools?: readonly MCPToolDefinition[]; + enabledNames?: ReadonlySet; + error?: string; + client?: RuntimeMcpClient; +} + +export type McpStatusListener = (entry: McpServerEntry) => void; + +export interface McpConnectionView { + readonly oauthService: McpOAuthService | undefined; + list(): readonly McpServerEntry[]; + get(name: string): McpServerEntry | undefined; + configOf(name: string): McpServerConfig | undefined; + resolved( + name: string, + ): + | { + client: MCPClient; + tools: readonly Tool[]; + rawTools: readonly MCPToolDefinition[]; + enabledNames: ReadonlySet; + deferred: boolean; + } + | undefined; + getRemoteServerUrl(name: string): string | undefined; + reconnect(name: string): Promise; + reconnectAndJoin(name: string): Promise; + waitForInitialLoad(signal?: AbortSignal): Promise; + initialLoadDurationMs(): number; + onStatusChange(listener: McpStatusListener): () => void; +} + +const DEFAULT_STARTUP_TIMEOUT_MS = 30_000; + +type RuntimeMcpClient = StdioMcpClient | HttpMcpClient | SseMcpClient; +const defaultLog: Logger = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + child: () => defaultLog, +}; + +export interface McpDefaultTimeouts { + readonly startupTimeoutMs?: number; + readonly toolTimeoutMs?: number; +} + +export interface McpConnectionManagerOptions { + readonly envLookup?: (name: string) => string | undefined; + readonly stdioCwd?: string; + readonly runtimeResolver?: import('#/workspace/workspaceInstance/workspaceInstanceManager').IRuntimeResolver; + readonly workspaceId?: string; + readonly runtimeId?: string; + readonly requireStdioRuntimeId?: boolean; + readonly oauthService?: McpOAuthService; + readonly log?: Logger; + readonly resolveDefaultTimeouts?: () => McpDefaultTimeouts; + readonly resolveClientName?: () => string | undefined; +} + +export class McpConnectionManager implements McpConnectionView { + private readonly entries = new Map(); + private readonly listeners = new Set(); + private readonly inFlightReconnects = new Map>(); + private initialLoad: Promise = Promise.resolve(); + private initialLoadAttemptId = 0; + private initialLoadStartedAt: number | undefined; + private initialLoadFinishedAt: number | undefined; + + readonly oauthService: McpOAuthService | undefined; + private readonly log: Logger; + + constructor(private readonly options: McpConnectionManagerOptions = {}) { + this.oauthService = options.oauthService; + this.log = options.log ?? defaultLog; + } + + getRemoteServerUrl(name: string): string | undefined { + const entry = this.entries.get(name); + if (entry === undefined) return undefined; + if (!isRemoteMcpConfig(entry.config)) return undefined; + return entry.config.url; + } + + getHttpServerUrl(name: string): string | undefined { + return this.getRemoteServerUrl(name); + } + + onStatusChange(listener: McpStatusListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + list(): readonly McpServerEntry[] { + return Array.from(this.entries.values(), toPublicEntry); + } + + get(name: string): McpServerEntry | undefined { + const entry = this.entries.get(name); + return entry !== undefined ? toPublicEntry(entry) : undefined; + } + + configOf(name: string): McpServerConfig | undefined { + return this.entries.get(name)?.config; + } + + resolved( + name: string, + ): + | { + client: MCPClient; + tools: readonly Tool[]; + rawTools: readonly MCPToolDefinition[]; + enabledNames: ReadonlySet; + deferred: boolean; + } + | undefined { + const entry = this.entries.get(name); + if ( + entry?.status !== 'connected' || + entry.tools === undefined || + entry.rawTools === undefined || + entry.client === undefined + ) { + return undefined; + } + return { + client: entry.client, + tools: entry.tools, + rawTools: entry.rawTools, + enabledNames: entry.enabledNames ?? new Set(entry.tools.map((t) => t.name)), + deferred: entry.config.deferred === true, + }; + } + + connectAll(configs: Record): Promise { + const attemptId = ++this.initialLoadAttemptId; + this.initialLoadStartedAt = Date.now(); + this.initialLoadFinishedAt = undefined; + const initialLoad = this.connectAllNow(configs).finally(() => { + if (this.initialLoadAttemptId === attemptId) { + this.initialLoadFinishedAt = Date.now(); + } + }); + this.initialLoad = initialLoad; + return initialLoad; + } + + async connect(name: string, config: McpServerConfig): Promise { + const previous = this.entries.get(name); + if (previous !== undefined) { + if ( + (previous.status === 'pending' || previous.status === 'connected') && + mcpServerConfigsEqual(previous.config, config) + ) { + return; + } + await this.closeClient(previous); + } + const disabled = config.enabled === false; + const entry: InternalEntry = { + name, + config, + attemptId: 0, + status: disabled ? 'disabled' : 'pending', + }; + this.entries.set(name, entry); + this.emit(entry); + if (!disabled) { + await this.connectOne(entry, this.beginConnectAttempt(entry)); + } + } + + async remove(name: string): Promise { + const entry = this.entries.get(name); + if (entry === undefined) return false; + await this.closeClient(entry); + entry.status = 'disabled'; + entry.tools = undefined; + entry.enabledNames = undefined; + entry.rawTools = undefined; + entry.error = undefined; + this.emit(entry); + this.entries.delete(name); + return true; + } + + async markRemoved(name: string): Promise { + const entry = this.entries.get(name); + if (entry === undefined) return false; + await this.closeClient(entry); + entry.status = 'removed'; + entry.tools = undefined; + entry.enabledNames = undefined; + entry.rawTools = undefined; + entry.error = undefined; + this.emit(entry); + return true; + } + + waitForInitialLoad(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + if (signal === undefined) return this.initialLoad; + return abortable(this.initialLoad, signal); + } + + initialLoadDurationMs(): number { + if (this.initialLoadStartedAt === undefined) return 0; + const endedAt = this.initialLoadFinishedAt ?? Date.now(); + return Math.max(0, endedAt - this.initialLoadStartedAt); + } + + private async connectAllNow(configs: Record): Promise { + const tasks: Promise[] = []; + for (const [name, config] of Object.entries(configs)) { + const disabled = config.enabled === false; + const entry: InternalEntry = { + name, + config, + attemptId: 0, + status: disabled ? 'disabled' : 'pending', + }; + this.entries.set(name, entry); + this.emit(entry); + if (!disabled) { + tasks.push(this.connectOne(entry, this.beginConnectAttempt(entry))); + } + } + await Promise.allSettled(tasks); + } + + async reconnect(name: string): Promise { + const entry = this.entries.get(name); + if (entry === undefined || entry.status === 'removed') { + throw new Error2(ErrorCodes.MCP_SERVER_NOT_FOUND, `Unknown MCP server: ${name}`); + } + if (entry.config.enabled === false) { + throw new Error2(ErrorCodes.MCP_SERVER_DISABLED, `MCP server is disabled: ${name}`); + } + const attemptId = this.beginConnectAttempt(entry); + await this.closeClient(entry); + if (!this.isCurrent(entry, attemptId)) return; + entry.status = 'pending'; + entry.tools = undefined; + entry.enabledNames = undefined; + entry.rawTools = undefined; + entry.error = undefined; + this.emit(entry); + await this.connectOne(entry, attemptId); + } + + reconnectAndJoin(name: string): Promise { + const existing = this.inFlightReconnects.get(name); + if (existing !== undefined) return existing; + const work = this.reconnect(name).finally(() => { + if (this.inFlightReconnects.get(name) === work) { + this.inFlightReconnects.delete(name); + } + }); + this.inFlightReconnects.set(name, work); + return work; + } + + async reconnectAfterCurrent(name: string): Promise { + const existing = this.inFlightReconnects.get(name); + if (existing !== undefined) await existing.catch(() => undefined); + await this.reconnectAndJoin(name); + } + + async shutdown(): Promise { + const entries = Array.from(this.entries.values()); + this.entries.clear(); + const tasks = entries.map((entry) => this.closeClient(entry)); + await Promise.allSettled(tasks); + } + + private async connectOne(entry: InternalEntry, attemptId: number): Promise { + const timeoutMs = + entry.config.startupTimeoutMs ?? + this.options.resolveDefaultTimeouts?.().startupTimeoutMs ?? + DEFAULT_STARTUP_TIMEOUT_MS; + + let client: RuntimeMcpClient | undefined; + try { + const startupClient = await this.createClient(entry.config, entry.name, timeoutMs); + client = startupClient; + entry.client = startupClient; + const discovered = await withTimeout( + this.connectAndDiscoverTools(startupClient), + timeoutMs, + () => { + void this.closeRuntimeClient(startupClient); + }, + ); + if (!this.isCurrent(entry, attemptId)) { + await this.closeRuntimeClient(startupClient); + return; + } + entry.tools = discovered.tools; + entry.rawTools = discovered.rawTools; + entry.enabledNames = computeEnabledNames(entry.config, discovered.tools); + entry.status = 'connected'; + this.watchForUnexpectedClose(entry, startupClient, attemptId); + } catch (error) { + if (!this.isCurrent(entry, attemptId)) { + if (client !== undefined) { + await this.closeRuntimeClient(client); + } + return; + } + if (this.shouldMarkNeedsAuth(entry, error)) { + entry.status = 'needs-auth'; + entry.error = `${entry.name} requires OAuth — run /mcp-config login ${entry.name}`; + } else { + entry.status = 'failed'; + entry.error = formatStartupError(error, client); + } + entry.tools = undefined; + entry.enabledNames = undefined; + entry.rawTools = undefined; + await this.closeClient(entry); + } + if (!this.isCurrent(entry, attemptId)) return; + this.emit(entry); + } + + private watchForUnexpectedClose( + entry: InternalEntry, + client: RuntimeMcpClient, + attemptId: number, + ): void { + client.onUnexpectedClose((reason) => { + if (!this.isCurrent(entry, attemptId)) return; + if (entry.client !== client) return; + entry.status = 'failed'; + entry.error = formatUnexpectedCloseError(entry.name, reason); + entry.tools = undefined; + entry.enabledNames = undefined; + entry.rawTools = undefined; + entry.client = undefined; + void this.closeRuntimeClient(client); + this.emit(entry); + }); + } + + private beginConnectAttempt(entry: InternalEntry): number { + entry.attemptId += 1; + return entry.attemptId; + } + + private async createClient( + config: McpServerConfig, + name: string, + startupTimeoutMs: number, + ): Promise { + const toolCallTimeoutMs = + config.toolTimeoutMs ?? this.options.resolveDefaultTimeouts?.().toolTimeoutMs; + const clientName = this.options.resolveClientName?.(); + if (config.transport === 'stdio') { + const runtimeResolver = this.options.runtimeResolver; + const workspaceId = this.options.workspaceId; + const runtimeId = config.runtime_id ?? this.options.runtimeId; + if (runtimeResolver === undefined || workspaceId === undefined || runtimeId === undefined || (this.options.requireStdioRuntimeId === true && config.runtime_id === undefined)) { + throw new Error('MCP stdio requires runtime_id and runtime binding'); + } + return new StdioMcpClient(config, { + startupTimeoutMs, + toolCallTimeoutMs, + defaultCwd: this.options.stdioCwd, + clientName, + runtimeResolver, + workspaceId, + runtimeId, + }); + } + if (config.transport === 'sse') { + return new SseMcpClient(config, { + startupTimeoutMs, + toolCallTimeoutMs, + envLookup: this.options.envLookup, + oauthProvider: await this.resolveOAuthProvider(config, name), + clientName, + }); + } + return new HttpMcpClient(config, { + startupTimeoutMs, + toolCallTimeoutMs, + envLookup: this.options.envLookup, + oauthProvider: await this.resolveOAuthProvider(config, name), + clientName, + }); + } + + private async resolveOAuthProvider( + config: McpServerConfig, + name: string, + ): Promise | undefined> { + const oauthService = this.oauthService; + if (oauthService === undefined) return undefined; + if (!isRemoteMcpConfig(config)) return undefined; + if (config.bearerTokenEnvVar !== undefined) return undefined; + if (!(await oauthService.hasTokens(name, config.url))) return undefined; + return oauthService.getProvider(name, config.url); + } + + private shouldMarkNeedsAuth(entry: InternalEntry, error: unknown): boolean { + if (this.oauthService === undefined) return false; + if (!isRemoteMcpConfig(entry.config)) return false; + if (entry.config.bearerTokenEnvVar !== undefined) return false; + if (entry.config.headers !== undefined && entry.config.auth !== 'oauth') return false; + return isUnauthorizedLikeError(error); + } + + private async connectAndDiscoverTools( + client: RuntimeMcpClient, + ): Promise<{ tools: Tool[]; rawTools: MCPToolDefinition[] }> { + await client.connect(); + const mcpTools = await client.listTools(); + return { + rawTools: mcpTools, + tools: mcpTools.map((mcpTool) => ({ + name: mcpTool.name, + description: mcpTool.description, + parameters: assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema), + })), + }; + } + + private async closeClient(entry: InternalEntry): Promise { + if (entry.client === undefined) return; + const client = entry.client; + entry.client = undefined; + await this.closeRuntimeClient(client); + } + + private async closeRuntimeClient(client: RuntimeMcpClient): Promise { + try { + await client.close(); + } catch { + } + } + + private isCurrent(entry: InternalEntry, attemptId: number): boolean { + return this.entries.get(entry.name) === entry && entry.attemptId === attemptId; + } + + private emit(entry: InternalEntry): void { + const view = toPublicEntry(entry); + if (view.status === 'failed' || view.status === 'needs-auth') { + this.log.error('mcp server unavailable', { + server: view.name, + transport: view.transport, + status: view.status, + reason: view.error, + }); + } + for (const listener of this.listeners) { + try { + listener(view); + } catch { + } + } + } +} + +function toPublicEntry(entry: InternalEntry): McpServerEntry { + return { + name: entry.name, + transport: entry.config.transport, + status: entry.status, + toolCount: + entry.status === 'connected' && entry.enabledNames !== undefined + ? entry.enabledNames.size + : 0, + error: entry.error, + }; +} + +function computeEnabledNames(config: McpServerConfig, tools: readonly Tool[]): Set { + const all = tools.map((t) => t.name); + const enabledFilter = + config.enabledTools !== undefined ? new Set(config.enabledTools) : undefined; + const disabledFilter = + config.disabledTools !== undefined ? new Set(config.disabledTools) : undefined; + const allowed = new Set(); + for (const name of all) { + if (enabledFilter !== undefined && !enabledFilter.has(name)) continue; + if (disabledFilter !== undefined && disabledFilter.has(name)) continue; + allowed.add(name); + } + return allowed; +} + +function isUnauthorizedLikeError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (error.name === 'UnauthorizedError') return true; + const code = (error as { code?: unknown }).code; + if (typeof code === 'number' && code === 401) return true; + if (typeof code === 'string' && code === '401') return true; + return /\b401\b/.test(error.message) || /unauthorized/i.test(error.message); +} + +function formatStartupError(error: unknown, client: RuntimeMcpClient | undefined): string { + const source = error instanceof HostProcessError && + error.code === HostProcessErrorCode.SpawnFailed && + error.cause instanceof Error + ? error.cause + : error; + const base = source instanceof Error ? source.message : String(source); + const tail = stderrTail(client); + if (tail === undefined) return base; + return `${base}\nstderr: ${tail}`; +} + +function formatUnexpectedCloseError(name: string, reason: UnexpectedCloseReason): string { + const parts = [`MCP server "${name}" closed unexpectedly`]; + if (reason.error !== undefined) { + parts.push(reason.error.message); + } + if (reason.stderr !== undefined && reason.stderr.length > 0) { + parts.push(`stderr: ${reason.stderr.trimEnd()}`); + } + return parts.join('\n'); +} + +function stderrTail(client: RuntimeMcpClient | undefined): string | undefined { + if (client === undefined) return undefined; + if (!(client instanceof StdioMcpClient)) return undefined; + const snapshot = client.stderrSnapshot(); + if (snapshot.length === 0) return undefined; + return snapshot.trimEnd(); +} + +export function mcpServerConfigsEqual(a: McpServerConfig, b: McpServerConfig): boolean { + return stableConfigJson(a) === stableConfigJson(b); +} + +function stableConfigJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableConfigJson).join(',')}]`; + } + if (typeof value === 'object' && value !== null) { + const entries = Object.entries(value) + .filter(([, entryValue]) => entryValue !== undefined) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableConfigJson(entryValue)}`) + .toSorted(); + return `{${entries.join(',')}}`; + } + return JSON.stringify(value) ?? 'undefined'; +} + +async function withTimeout( + promise: Promise, + timeoutMs: number, + onTimeout?: () => void, +): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await new Promise((resolve, reject) => { + timer = setTimeout(() => { + onTimeout?.(); + reject(new Error2(ErrorCodes.MCP_STARTUP_FAILED, `Timed out after ${timeoutMs}ms`)); + }, timeoutMs); + promise.then(resolve, reject); + }); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} diff --git a/packages/agent-core-v2/src/mcpCore/errors.ts b/packages/agent-core-v2/src/mcpCore/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d987e9beba8924194e75348e344e8a5fead3675 --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/errors.ts @@ -0,0 +1,13 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const McpErrors = { + codes: { + MCP_SERVER_NOT_FOUND: 'mcp.server_not_found', + MCP_SERVER_DISABLED: 'mcp.server_disabled', + MCP_STARTUP_FAILED: 'mcp.startup_failed', + MCP_TOOL_NAME_COLLISION: 'mcp.tool_name_collision', + MCP_OAUTH_FAILED: 'mcp.oauth_failed', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(McpErrors); diff --git a/packages/agent-core-v2/src/mcpCore/tool-naming.ts b/packages/agent-core-v2/src/mcpCore/tool-naming.ts new file mode 100644 index 0000000000000000000000000000000000000000..47f66cf354e9b9dad80e3639cf0dfc486b49e7ca --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/tool-naming.ts @@ -0,0 +1,26 @@ +const MCP_NAME_PREFIX = 'mcp__'; +const MCP_NAME_SEPARATOR = '__'; + +const MAX_QUALIFIED_LENGTH = 64; + +export function sanitizeMcpNamePart(part: string): string { + return part.replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_'); +} + +export function qualifyMcpToolName(serverName: string, toolName: string): string { + const full = `${MCP_NAME_PREFIX}${sanitizeMcpNamePart(serverName)}${MCP_NAME_SEPARATOR}${sanitizeMcpNamePart(toolName)}`; + if (full.length <= MAX_QUALIFIED_LENGTH) return full; + + const hash = stableHash8(full); + const head = full.slice(0, MAX_QUALIFIED_LENGTH - hash.length - 1); + return `${head}_${hash}`; +} + +function stableHash8(input: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.codePointAt(i)!; + hash = Math.trunc(Math.imul(hash, 0x01000193)); + } + return hash.toString(16).padStart(8, '0'); +} diff --git a/packages/agent-core-v2/src/mcpCore/types.ts b/packages/agent-core-v2/src/mcpCore/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..e51b509f61d75aeaa90c402c2a536d15785fa833 --- /dev/null +++ b/packages/agent-core-v2/src/mcpCore/types.ts @@ -0,0 +1,55 @@ +import { ErrorCodes, Error2 } from '#/errors'; + +export interface MCPEmbeddedResourceContents { + uri: string; + mimeType?: string; + text?: string; + blob?: string; + [key: string]: unknown; +} + +export interface MCPContentBlock { + type: string; + text?: string; + data?: string; + mimeType?: string; + uri?: string; + resource?: MCPEmbeddedResourceContents; + [key: string]: unknown; +} + +export interface MCPToolResult { + content: MCPContentBlock[]; + isError: boolean; + structuredContent?: unknown; + _meta?: Record; +} + +export interface MCPToolDefinition { + name: string; + description: string; + inputSchema: unknown; +} + +export interface MCPClient { + listTools(): Promise; + callTool( + name: string, + args: Record, + signal?: AbortSignal, + ): Promise; + ping(signal?: AbortSignal): Promise; +} + +export function assertMcpInputSchema( + toolName: string, + inputSchema: unknown, +): Record { + if (typeof inputSchema === 'object' && inputSchema !== null && !Array.isArray(inputSchema)) { + return inputSchema as Record; + } + throw new Error2( + ErrorCodes.MCP_STARTUP_FAILED, + `Invalid inputSchema for MCP tool "${toolName}": schema must be a JSON object`, + ); +} diff --git a/packages/agent-core-v2/src/persistence/configSection.ts b/packages/agent-core-v2/src/persistence/configSection.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba328a541fba892acf1f8f665440791837c552b4 --- /dev/null +++ b/packages/agent-core-v2/src/persistence/configSection.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +import { parseBooleanEnv } from '#/_base/utils/env'; +import { + type EnvBindings, + envBindings, + type IConfigService, + stripEnvBoundFields, +} from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const DATABASE_SECTION = 'database'; + +export const PERSISTENCE_MINIDB_READMODEL_ENV = 'KIMI_CODE_PERSISTENCE_MINIDB_READMODEL'; +export const SEARCH_WORKER_ENV = 'KIMI_CODE_SEARCH_WORKER'; + +export const DatabaseConfigSchema = z.object({ + base: z.boolean().optional(), + search: z.boolean().optional(), +}); + +export type DatabaseConfig = z.infer; + +export const databaseEnvBindings: EnvBindings = envBindings( + DatabaseConfigSchema, + { + base: { env: PERSISTENCE_MINIDB_READMODEL_ENV, parse: parseBooleanEnv }, + search: { env: SEARCH_WORKER_ENV, parse: parseBooleanEnv }, + }, +); + +export const stripDatabaseEnv = stripEnvBoundFields(databaseEnvBindings); + +registerConfigSection(DATABASE_SECTION, DatabaseConfigSchema, { + env: databaseEnvBindings, + stripEnv: stripDatabaseEnv, +}); + +export function databaseBaseEnabled(config: IConfigService): boolean { + return config.get(DATABASE_SECTION)?.base ?? true; +} + +export function databaseSearchEnabled(config: IConfigService): boolean { + return config.get(DATABASE_SECTION)?.search ?? true; +} diff --git a/packages/agent-core-v2/src/program/program.ts b/packages/agent-core-v2/src/program/program.ts new file mode 100644 index 0000000000000000000000000000000000000000..3486775aa12a4fc35dc5484127217cf8cd842605 --- /dev/null +++ b/packages/agent-core-v2/src/program/program.ts @@ -0,0 +1,389 @@ +import { Emitter, type Event } from '#/_base/event'; +import { UserFileSkillSource } from '#/features/skill/catalog/userFileSkillSource'; +import { FileProjectLocalConfigService } from '#/persistence/backends/node-fs/projectLocalConfigService'; +import type { RuntimeBinding, RuntimeLease } from '#/runtime/runtime'; +import { RuntimeError, type RuntimeGenerationSnapshot, type RuntimeRegistry, type RuntimeRegistryChange } from '#/runtime/runtimeRegistry'; +import type { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; +import type { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import type { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import type { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; +import type { IWorkspaceFsService } from '#/workspace/workspaceFs/fs'; +import { WorkspaceFsService } from '#/workspace/workspaceFs/fsService'; +import type { IWorkspaceGitService } from '#/workspace/workspaceGit/workspaceGit'; +import { WorkspaceGitService } from '#/workspace/workspaceGit/workspaceGitService'; +import type { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import { WorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructionsService'; +import type { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import { WorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcpService'; +import type { IWorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfig'; +import { WorkspaceMcpConfigService } from '#/workspace/workspaceMcpConfig/workspaceMcpConfigService'; +import type { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; +import { WorkspaceTrustService } from '#/workspace/workspaceTrust/workspaceTrustService'; +import type { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +import { ExtraAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoaderService'; +import type { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +import { ExplicitAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoaderService'; +import type { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { PluginAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoaderService'; +import type { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import { UserAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoaderService'; +import type { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +import { WorkspaceAgentProfileLoaderService } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoaderService'; +import { ExplicitFileSkillSource } from '#/features/skill/workspace/explicitFileSkillSource'; +import { ExtraFileSkillSource } from '#/features/skill/workspace/extraFileSkillSource'; +import { PluginSkillSource } from '#/features/skill/workspace/pluginSkillSource'; +import { WorkspaceRootSkillSource } from '#/features/skill/workspace/rootFileSkillSource'; +import { RuntimeSkillDiscovery } from '#/features/skill/workspace/runtimeSkillDiscovery'; +import type { IWorkspaceSkillCatalog } from '#/features/skill/workspace/workspaceSkillCatalog'; +import { WorkspaceSkillCatalogService } from '#/features/skill/workspace/workspaceSkillCatalogService'; +import type { IRuntimeResolver } from '#/workspace/workspaceInstance/workspaceInstanceManager'; + +import type { ProgramDependencies } from './programDependencies'; + +export type ProgramStatus = 'preparing' | 'ready' | 'degraded'; + +export interface ProgramCatalogSnapshot { + readonly skills: { + readonly total: number; + readonly invocable: number; + readonly skipped: number; + }; + readonly agentProfiles: number; + readonly mcpServers: number; +} + +export interface ProgramSourceProvenanceSnapshot { + readonly skills: readonly { + readonly source: string; + readonly count: number; + }[]; + readonly skillRoots: readonly string[]; + readonly agentProfiles: readonly { + readonly sourceId: string; + readonly priority: number; + readonly profiles: readonly string[]; + }[]; + readonly instructionPaths: readonly string[]; + readonly mcpServers: readonly string[]; +} + +export interface ProgramSnapshot { + readonly workspaceId: string; + readonly binding: RuntimeBinding; + readonly status: ProgramStatus; + readonly ready: boolean; + readonly generation?: string; + readonly trusted?: boolean; + readonly catalog: ProgramCatalogSnapshot; + readonly sources: ProgramSourceProvenanceSnapshot; + readonly runtimes: readonly RuntimeGenerationSnapshot[]; +} + +interface ProgramGeneration { + readonly id: string; + readonly lease: RuntimeLease; + readonly state: IWorkspaceStateService; + readonly dirs: IWorkspaceDirs; + readonly fs: IWorkspaceFsService; + readonly git: IWorkspaceGitService; + readonly instructions: IWorkspaceInstructionsService; + readonly mcpConfig: IWorkspaceMcpConfigService; + readonly mcp: IWorkspaceMcpService; + readonly trust: IWorkspaceTrust; + readonly skills: IWorkspaceSkillCatalog; + readonly agentProfiles: IWorkspaceAgentProfileLoader; + readonly userAgentProfiles: IUserAgentProfileLoader; + readonly pluginAgentProfiles: IPluginAgentProfileLoader; + readonly explicitAgentProfiles: IExplicitAgentProfileLoader; + readonly extraAgentProfiles: IExtraAgentProfileLoader; + readonly disposables: readonly { dispose(): void | Promise }[]; + ready: boolean; + failed: boolean; + references: number; + retired: boolean; +} + +const PROGRAM_CAPABILITIES = ['fs', 'process'] as const; + +export class Program { + readonly binding: RuntimeBinding; + private currentStatus: ProgramStatus = 'preparing'; + private readonly changeEmitter = new Emitter(); + readonly onDidChange: Event = this.changeEmitter.event; + private readonly registrySubscription; + private readonly resolver: IRuntimeResolver; + private generation?: ProgramGeneration; + private generationFailed = false; + private disposed = false; + private resolveReady?: () => void; + readonly ready = new Promise((resolve) => { this.resolveReady = resolve; }); + + constructor( + readonly workspaceId: string, + private readonly runtimes: RuntimeRegistry, + private readonly context: IWorkspaceContext, + private readonly dependencies: ProgramDependencies, + ) { + this.binding = Object.freeze({ workspaceId, runtimeId: 'local' }); + this.resolver = { + _serviceBrand: undefined, + inspect: (binding) => this.runtimes.inspect(binding), + acquire: (binding, required) => this.runtimes.acquire(binding, required), + }; + this.registrySubscription = runtimes.onDidChange((change) => this.onRuntimeChange(change)); + this.reconcileGeneration(); + } + + get status(): ProgramStatus { return this.currentStatus; } + get state(): IWorkspaceStateService { return this.requireGeneration().state; } + get dirs(): IWorkspaceDirs { return this.requireGeneration().dirs; } + get fs(): IWorkspaceFsService { return this.requireGeneration().fs; } + get git(): IWorkspaceGitService { return this.requireGeneration().git; } + get instructions(): IWorkspaceInstructionsService { return this.requireGeneration().instructions; } + get mcpConfig(): IWorkspaceMcpConfigService { return this.requireGeneration().mcpConfig; } + get mcp(): IWorkspaceMcpService { return this.requireGeneration().mcp; } + get trust(): IWorkspaceTrust { return this.requireGeneration().trust; } + get skills(): IWorkspaceSkillCatalog { return this.requireGeneration().skills; } + get agentProfiles(): IWorkspaceAgentProfileLoader { return this.requireGeneration().agentProfiles; } + get sessionControllerGeneration(): string { return this.requireGeneration().id; } + + createSessionController(): SessionLifecycleService { + const generation = this.requireGeneration(); + generation.references += 1; + let released = false; + const release = (): void => { + if (released) return; + released = true; + this.releaseGeneration(generation); + }; + try { + const runtime = generation.lease.runtime; + return this.dependencies.createSessionController({ + context: this.context, + fs: runtime.fs!, + workspaceAgentProfiles: generation.agentProfiles, + extraAgentProfiles: generation.extraAgentProfiles, + explicitAgentProfiles: generation.explicitAgentProfiles, + userAgentProfiles: generation.userAgentProfiles, + pluginAgentProfiles: generation.pluginAgentProfiles, + dirs: generation.dirs, + skills: generation.skills, + instructions: generation.instructions, + mcp: generation.mcp, + onDispose: release, + }); + } catch (error) { + release(); + throw error; + } + } + + snapshot(): ProgramSnapshot { + const generation = this.generation; + const skills = generation?.skills.catalog.listSkills() ?? []; + const skillsBySource = new Map(); + for (const skill of skills) { + skillsBySource.set(skill.source, (skillsBySource.get(skill.source) ?? 0) + 1); + } + const agentProfiles = this.dependencies.agentProfiles.entries() + .filter((entry) => entry.workspaceKey === undefined || entry.workspaceKey === this.workspaceId) + .map((entry) => ({ + sourceId: entry.sourceId, + priority: entry.priority, + profiles: entry.contribution.profiles.map((profile) => profile.name), + })); + const mcpServers = Object.keys(generation?.mcpConfig.servers() ?? {}); + return { + workspaceId: this.workspaceId, + binding: this.binding, + status: this.currentStatus, + ready: generation?.ready === true, + generation: generation?.id, + trusted: generation?.trust.isTrusted(), + catalog: { + skills: { + total: skills.length, + invocable: generation?.skills.catalog.listInvocableSkills().length ?? 0, + skipped: generation?.skills.catalog.getSkippedByPolicy().length ?? 0, + }, + agentProfiles: agentProfiles.reduce((total, source) => total + source.profiles.length, 0), + mcpServers: mcpServers.length, + }, + sources: { + skills: [...skillsBySource].map(([source, count]) => ({ source, count })), + skillRoots: generation?.skills.catalog.getSkillRoots() ?? [], + agentProfiles, + instructionPaths: generation?.instructions.snapshot.agentsMdPaths ?? [], + mcpServers, + }, + runtimes: this.runtimes.snapshot().runtimes, + }; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.registrySubscription.dispose(); + const generation = this.generation; + this.generation = undefined; + if (generation !== undefined) this.retireGeneration(generation); + this.changeEmitter.dispose(); + } + + private requireGeneration(): ProgramGeneration { + if (this.generation === undefined) throw new Error(`program ${this.workspaceId} has no available local runtime generation`); + return this.generation; + } + + private onRuntimeChange(change: RuntimeRegistryChange): void { + if (change.runtimeId !== 'local' || this.disposed) return; + this.reconcileGeneration(); + } + + private reconcileGeneration(): void { + const local = this.runtimes.current('local'); + if (local === undefined) { + const previous = this.generation; + this.generation = undefined; + if (previous !== undefined) this.retireGeneration(previous); + this.refresh(); + return; + } + if (this.generation?.id !== local.identity.generation) { + const previous = this.generation; + this.generationFailed = false; + try { + const next = this.createGeneration(); + this.generation = next; + if (previous !== undefined) this.retireGeneration(previous); + this.observeReadiness(next); + } catch (error) { + if (!(error instanceof RuntimeError && error.code === 'runtime.unavailable')) { + this.generationFailed = true; + this.resolveProgramReady(); + } + } + } + this.refresh(); + } + + private createGeneration(): ProgramGeneration { + const lease = this.resolver.acquire(this.binding, PROGRAM_CAPABILITIES); + const runtime = lease.runtime; + const disposables: { dispose(): void | Promise }[] = []; + const own = }>(value: T): T => { + disposables.push(value); + return value; + }; + try { + const state = own(new WorkspaceStateService(this.dependencies.appState)); + const localConfig = new FileProjectLocalConfigService(this.dependencies.bootstrap, runtime.fs!); + const dirs = own(new WorkspaceDirsService(this.context, localConfig, this.dependencies.log, state)); + const git = new WorkspaceGitService(this.context, this.dependencies.git); + const fs = new WorkspaceFsService(this.context, dirs, runtime.fs!, this.resolver, this.dependencies.telemetry, git); + const instructions = own(new WorkspaceInstructionsService(this.context, runtime.fs!, runtime.environment, this.dependencies.bootstrap, this.dependencies.log, state)); + const trust = own(new WorkspaceTrustService(this.context, this.dependencies.docs, state, this.dependencies.telemetry)); + const mcpConfig = own(new WorkspaceMcpConfigService(this.context, this.dependencies.bootstrap, this.dependencies.plugins, this.dependencies.log, this.dependencies.config, runtime.fs!, trust, this.dependencies.configStore)); + const mcp = own(new WorkspaceMcpService(this.context, this.resolver, mcpConfig, this.dependencies.oauth, this.dependencies.log, this.dependencies.telemetry, this.dependencies.identity, this.dependencies.sessionManager)); + const userAgentProfiles = own(new UserAgentProfileLoaderService(this.dependencies.bootstrap, runtime.fs!, this.dependencies.log, this.dependencies.builtinAgentProfiles, this.context, this.dependencies.agentProfiles)); + const pluginAgentProfiles = own(new PluginAgentProfileLoaderService(this.dependencies.plugins, runtime.fs!, this.dependencies.log, userAgentProfiles, this.context, this.dependencies.agentProfiles)); + const explicitAgentProfiles = own(new ExplicitAgentProfileLoaderService(this.context, this.dependencies.bootstrap, runtime.fs!, this.dependencies.log, userAgentProfiles, this.dependencies.agentProfiles)); + const extraAgentProfiles = own(new ExtraAgentProfileLoaderService(this.dependencies.config, this.context, this.dependencies.bootstrap, runtime.fs!, this.dependencies.log, userAgentProfiles, this.dependencies.agentProfiles)); + const agentProfiles = own(new WorkspaceAgentProfileLoaderService(this.context, runtime.fs!, this.dependencies.log, userAgentProfiles, this.dependencies.agentProfiles)); + const skillDiscovery = new RuntimeSkillDiscovery(this.dependencies.log, runtime.fs!); + const userSkills = own(new UserFileSkillSource(skillDiscovery, this.dependencies.bootstrap, this.dependencies.config)); + const explicitSkills = new ExplicitFileSkillSource(skillDiscovery, this.context, this.dependencies.bootstrap); + const extraSkills = own(new ExtraFileSkillSource(skillDiscovery, this.dependencies.config, this.context, this.dependencies.bootstrap)); + const workspaceSkills = own(new WorkspaceRootSkillSource(skillDiscovery, this.context, this.dependencies.config, this.dependencies.bootstrap)); + const pluginSkills = new PluginSkillSource(skillDiscovery, this.dependencies.plugins); + const skills = own(new WorkspaceSkillCatalogService(this.dependencies.builtinSkills, userSkills, explicitSkills, extraSkills, workspaceSkills, pluginSkills, state)); + return { + id: runtime.identity.generation, + lease, + state, + dirs, + fs, + git, + instructions, + mcpConfig, + mcp, + trust, + skills, + agentProfiles, + userAgentProfiles, + pluginAgentProfiles, + explicitAgentProfiles, + extraAgentProfiles, + disposables, + ready: false, + failed: false, + references: 1, + retired: false, + }; + } catch (error) { + for (const disposable of disposables.reverse()) void disposable.dispose(); + lease.dispose(); + throw error; + } + } + + private observeReadiness(generation: ProgramGeneration): void { + void Promise.all([ + readiness(generation.dirs), + readiness(generation.instructions), + readiness(generation.mcpConfig), + readiness(generation.mcp), + readiness(generation.skills), + readiness(generation.agentProfiles), + ]).then( + () => { + if (this.generation !== generation) return; + generation.ready = true; + this.resolveProgramReady(); + this.refresh(); + }, + () => { + if (this.generation !== generation) return; + generation.failed = true; + this.resolveProgramReady(); + this.refresh(); + }, + ); + } + + private retireGeneration(generation: ProgramGeneration): void { + if (generation.retired) return; + generation.retired = true; + this.releaseGeneration(generation); + } + + private releaseGeneration(generation: ProgramGeneration): void { + generation.references -= 1; + if (generation.references !== 0 || !generation.retired) return; + for (const disposable of [...generation.disposables].reverse()) void disposable.dispose(); + generation.lease.dispose(); + } + + private resolveProgramReady(): void { + this.resolveReady?.(); + this.resolveReady = undefined; + } + + private refresh(): void { + const local = this.runtimes.current('local'); + if (local === undefined || local.status === 'connecting') this.currentStatus = 'preparing'; + else if (this.generationFailed || this.generation?.failed === true) this.currentStatus = 'degraded'; + else if (this.generation?.ready !== true) this.currentStatus = this.generation === undefined && local.status !== 'ready' ? 'degraded' : 'preparing'; + else this.currentStatus = local.status === 'ready' ? 'ready' : 'degraded'; + this.changeEmitter.fire(this.snapshot()); + } +} + +function readiness(value: unknown): Promise { + const ready = (value as { readonly ready?: unknown }).ready; + return ready !== null && typeof ready === 'object' && 'then' in ready + ? Promise.resolve(ready as PromiseLike).then(() => {}) + : Promise.resolve(); +} diff --git a/packages/agent-core-v2/src/program/programDependencies.ts b/packages/agent-core-v2/src/program/programDependencies.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ae41144d8d0dd0a3df729470b98b9acb0932851 --- /dev/null +++ b/packages/agent-core-v2/src/program/programDependencies.ts @@ -0,0 +1,62 @@ +import type { LiveRef } from '#/_base/di/instantiation'; +import type { ILogService } from '#/_base/log/log'; +import type { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; +import type { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; +import type { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; +import type { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import type { IConfigService } from '#/app/config/config'; +import type { IGitService } from '#/app/git/git'; +import type { McpOAuthService } from '#/mcpCore/oauth/service'; +import type { IMcpConfigStore } from '#/app/mcpConfig/configStore'; +import type { IPluginService } from '#/app/plugin/plugin'; +import type { ISessionManager } from '#/app/sessionManager/sessionManager'; +import type { IBuiltinSkillSource } from '#/features/skill/catalog/builtinSkillSource'; +import type { IAppStateService } from '#/app/state/appState'; +import type { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import type { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService'; +import type { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; +import type { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/extraAgentProfileLoader'; +import type { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; +import type { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import type { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; +import type { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; +import type { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; +import type { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; +import type { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; +import type { IWorkspaceSkillCatalog } from '#/features/skill/workspace/workspaceSkillCatalog'; + +export interface ProgramSessionControllerInput { + readonly context: IWorkspaceContext; + readonly fs: IHostFileSystem; + readonly workspaceAgentProfiles: IWorkspaceAgentProfileLoader; + readonly extraAgentProfiles: IExtraAgentProfileLoader; + readonly explicitAgentProfiles: IExplicitAgentProfileLoader; + readonly userAgentProfiles: IUserAgentProfileLoader; + readonly pluginAgentProfiles: IPluginAgentProfileLoader; + readonly dirs: IWorkspaceDirs; + readonly skills: IWorkspaceSkillCatalog; + readonly instructions: IWorkspaceInstructionsService; + readonly mcp: IWorkspaceMcpService; + readonly onDispose: () => void; +} + +export interface ProgramDependencies { + readonly appState: IAppStateService; + readonly bootstrap: IBootstrapService; + readonly config: IConfigService; + readonly git: LiveRef; + readonly identity: IAgentIdentity; + readonly log: ILogService; + readonly oauth: McpOAuthService; + readonly configStore: IMcpConfigStore; + readonly plugins: IPluginService; + readonly sessionManager: LiveRef; + readonly agentProfiles: IAgentProfileRegistry; + readonly builtinAgentProfiles: IBuiltinAgentProfileLoader; + readonly builtinSkills: IBuiltinSkillSource; + readonly telemetry: ITelemetryService; + readonly docs: IAtomicDocumentStore; + createSessionController(input: ProgramSessionControllerInput): SessionLifecycleService; +} diff --git a/packages/agent-core-v2/src/runtime/fakeRuntime.ts b/packages/agent-core-v2/src/runtime/fakeRuntime.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b1c8925301c98ab96a3ddb331314d174522eb05 --- /dev/null +++ b/packages/agent-core-v2/src/runtime/fakeRuntime.ts @@ -0,0 +1,79 @@ +import * as posixPath from 'node:path/posix'; +import * as win32Path from 'node:path/win32'; + +import { Emitter } from '#/_base/event'; + +import type { Runtime, RuntimeCapability, RuntimePath, RuntimeStatus } from './runtime'; + +export class FakeRuntime implements Runtime { + readonly capabilities: ReadonlySet; + readonly environment; + readonly path: RuntimePath; + readonly workspace; + readonly fs = undefined; + readonly process = undefined; + readonly watch = undefined; + readonly terminal = undefined; + private currentStatus: RuntimeStatus; + private readonly statusEmitter = new Emitter(); + readonly onDidChangeStatus = this.statusEmitter.event; + disposed = false; + + constructor( + readonly identity: Runtime['identity'], + options: { + readonly status?: RuntimeStatus; + readonly capabilities?: readonly RuntimeCapability[]; + readonly pathClass?: 'posix' | 'win32'; + readonly environment?: Partial; + readonly mapWorkspaceRoots?: Runtime['workspace']['mapRoots']; + } = {}, + ) { + this.currentStatus = options.status ?? 'ready'; + this.capabilities = new Set(options.capabilities ?? []); + const path = options.pathClass === 'win32' ? win32Path : posixPath; + this.environment = { + osKind: 'fake', + osArch: 'fake', + osVersion: 'fake', + shellName: 'sh' as const, + shellPath: '/bin/sh', + pathClass: options.pathClass ?? 'posix', + homeDir: options.pathClass === 'win32' ? 'C:\\Users\\fake' : '/home/fake', + ...options.environment, + }; + this.path = { + separator: path.sep as '/' | '\\', + delimiter: path.delimiter as ':' | ';', + isAbsolute: (p) => path.isAbsolute(p), + join: (...paths) => path.join(...paths), + relative: (from, to) => path.relative(from, to), + resolve: (...paths) => path.resolve(...paths), + basename: (p) => path.basename(p), + dirname: (p) => path.dirname(p), + }; + this.workspace = { + mapRoots: options.mapWorkspaceRoots ?? ((roots) => ({ + workDir: path.resolve(roots.workDir), + additionalDirs: roots.additionalDirs?.map((root) => path.resolve(root)), + })), + }; + } + + get status(): RuntimeStatus { + return this.currentStatus; + } + + setStatus(status: RuntimeStatus): void { + if (this.currentStatus === status) return; + this.currentStatus = status; + this.statusEmitter.fire(status); + } + + dispose(): void { + this.disposed = true; + this.currentStatus = 'disposed'; + this.statusEmitter.fire('disposed'); + this.statusEmitter.dispose(); + } +} diff --git a/packages/agent-core-v2/src/runtime/localRuntime.ts b/packages/agent-core-v2/src/runtime/localRuntime.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c9a3889dd6e8a9622e506235e07893a7bd7d33a --- /dev/null +++ b/packages/agent-core-v2/src/runtime/localRuntime.ts @@ -0,0 +1,107 @@ +import * as posixPath from 'node:path/posix'; +import * as win32Path from 'node:path/win32'; + +import { Emitter } from '#/_base/event'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IHostProcessService } from '#/os/interface/hostProcess'; +import { IHostTerminalService } from '#/os/interface/terminal'; + +import type { Runtime, RuntimeCapability, RuntimePath, RuntimeStatus } from './runtime'; +import type { RuntimeProviderAttachment, RuntimeProviderContext, RuntimeProviderFactory } from './runtimeProvider'; +import type { RuntimeProviderHost } from './runtimeUnitHost'; + +let nextGeneration = 1; + +export class LocalRuntime implements Runtime { + readonly identity; + readonly capabilities: ReadonlySet; + readonly environment; + readonly path: RuntimePath; + readonly workspace: Runtime['workspace']; + readonly fs; + readonly process; + readonly terminal; + private currentStatus: RuntimeStatus = 'ready'; + private readonly statusEmitter = new Emitter(); + readonly onDidChangeStatus = this.statusEmitter.event; + + constructor( + workspaceId: string, + environment: IHostEnvironment, + fs: IHostFileSystem | undefined, + process: IHostProcessService | undefined, + terminal: IHostTerminalService | undefined, + ) { + this.identity = { workspaceId, runtimeId: 'local', generation: `local-${nextGeneration++}` }; + const capabilities = new Set(); + if (fs !== undefined) capabilities.add('fs'); + if (process !== undefined) capabilities.add('process'); + if (terminal !== undefined) capabilities.add('terminal'); + this.capabilities = capabilities; + this.environment = { + osKind: environment.osKind, + osArch: environment.osArch, + osVersion: environment.osVersion, + shellName: environment.shellName, + shellPath: environment.shellPath, + pathClass: environment.pathClass, + homeDir: environment.homeDir, + }; + const path = environment.pathClass === 'win32' ? win32Path : posixPath; + this.path = { + separator: path.sep as '/' | '\\', + delimiter: path.delimiter as ':' | ';', + isAbsolute: (p) => path.isAbsolute(p), + join: (...paths) => path.join(...paths), + relative: (from, to) => path.relative(from, to), + resolve: (...paths) => path.resolve(...paths), + basename: (p) => path.basename(p), + dirname: (p) => path.dirname(p), + }; + this.workspace = { + mapRoots: (roots) => ({ + workDir: path.resolve(roots.workDir), + additionalDirs: roots.additionalDirs?.map((root) => path.resolve(root)), + }), + }; + this.fs = fs; + this.process = process; + this.terminal = terminal; + } + + get status(): RuntimeStatus { + return this.currentStatus; + } + + dispose(): void { + this.currentStatus = 'disposed'; + this.statusEmitter.fire('disposed'); + this.statusEmitter.dispose(); + } +} + +export class LocalRuntimeProviderFactory implements RuntimeProviderFactory { + readonly id = 'local'; + readonly imports = { + root: [ + IHostEnvironment, + IHostFileSystem, + IHostProcessService, + IHostTerminalService, + ], + imports: [], + local: [], + }; + + async attach(context: RuntimeProviderContext, host: RuntimeProviderHost): Promise { + const handle = host.registerRuntime(new LocalRuntime( + context.id, + host.get(IHostEnvironment), + host.get(IHostFileSystem), + host.get(IHostProcessService), + host.get(IHostTerminalService), + )); + return { dispose: () => handle.remove() }; + } +} diff --git a/packages/agent-core-v2/src/runtime/runtime.ts b/packages/agent-core-v2/src/runtime/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..c28f0655820b9c03e4678af53861eaf002622c4f --- /dev/null +++ b/packages/agent-core-v2/src/runtime/runtime.ts @@ -0,0 +1,57 @@ +import type { Event } from '#/_base/event'; +import type { HostEnvironmentInfo } from '#/os/interface/hostEnvironment'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import type { IHostProcessService } from '#/os/interface/hostProcess'; +import type { IHostTerminalService } from '#/os/interface/terminal'; + +export type RuntimeStatus = 'connecting' | 'ready' | 'degraded' | 'disconnected' | 'draining' | 'disposed'; +export type RuntimeCapability = 'fs' | 'process' | 'terminal'; + +export interface RuntimeBinding { + readonly workspaceId: string; + readonly runtimeId: string; +} + +export interface RuntimeIdentity extends RuntimeBinding { + readonly generation: string; +} + +export interface RuntimePath { + readonly separator: '/' | '\\'; + readonly delimiter: ':' | ';'; + isAbsolute(path: string): boolean; + join(...paths: readonly string[]): string; + relative(from: string, to: string): string; + resolve(...paths: readonly string[]): string; + basename(path: string): string; + dirname(path: string): string; +} + +export interface RuntimeWorkspaceRoots { + readonly workDir: string; + readonly additionalDirs?: readonly string[]; +} + +export interface RuntimeWorkspaceMapper { + mapRoots(roots: RuntimeWorkspaceRoots): RuntimeWorkspaceRoots; +} + +export interface Runtime { + readonly identity: RuntimeIdentity; + readonly capabilities: ReadonlySet; + readonly environment: HostEnvironmentInfo; + readonly path: RuntimePath; + readonly workspace: RuntimeWorkspaceMapper; + readonly fs?: IHostFileSystem; + readonly process?: IHostProcessService; + readonly terminal?: IHostTerminalService; + readonly status: RuntimeStatus; + readonly onDidChangeStatus: Event; + dispose(): void | Promise; +} + +export interface RuntimeLease { + readonly runtime: Runtime; + track }>(resource: T): T; + dispose(): void; +} diff --git a/packages/agent-core-v2/src/runtime/runtimeProvider.ts b/packages/agent-core-v2/src/runtime/runtimeProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..20b7ab0b98903d11beac24ff88c4f98f932954ea --- /dev/null +++ b/packages/agent-core-v2/src/runtime/runtimeProvider.ts @@ -0,0 +1,19 @@ +import type { Workspace } from '#/app/workspace/workspace'; + +import type { RuntimeProviderHost, RuntimeUnitImports } from './runtimeUnitHost'; + +export interface RuntimeProviderAttachment { + dispose(): void | Promise; +} + +export interface RuntimeProviderContext { + readonly id: string; + readonly root: string; + readonly metadata: Workspace; +} + +export interface RuntimeProviderFactory { + readonly id: string; + readonly imports: RuntimeUnitImports; + attach(context: RuntimeProviderContext, host: RuntimeProviderHost): Promise; +} diff --git a/packages/agent-core-v2/src/runtime/runtimeRegistry.ts b/packages/agent-core-v2/src/runtime/runtimeRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..659acc5beb2e8114f8e0221aabe82c76775efd4c --- /dev/null +++ b/packages/agent-core-v2/src/runtime/runtimeRegistry.ts @@ -0,0 +1,334 @@ +import { Emitter, type Event } from '#/_base/event'; + +import type { Runtime, RuntimeBinding, RuntimeCapability, RuntimeLease } from './runtime'; + +export const RUNTIME_DRAIN_TIMEOUT_MS = 5_000; + +export type RuntimeErrorCode = 'runtime.not_found' | 'runtime.unavailable' | 'runtime.capability_unavailable' | 'runtime.conflict'; + +export class RuntimeError extends Error { + constructor(readonly code: RuntimeErrorCode, message: string) { + super(message); + this.name = 'RuntimeError'; + } +} + +export interface RuntimeResource { + dispose(): void | Promise; +} + +interface Generation { + readonly runtime: Runtime; + readonly resources: Set; + readonly statusSubscription: { dispose(): void }; + leases: number; + draining: boolean; + disposed: boolean; + drainPromise?: Promise; + releaseDrain?: () => void; +} + +export interface RuntimeRegistryChange { + readonly runtimeId: string; + readonly current?: Runtime; + readonly status?: Runtime['status'] | 'draining'; +} + +export interface RuntimeGenerationSnapshot { + readonly runtimeId: string; + readonly generation: string; + readonly status: Runtime['status']; + readonly capabilities: readonly RuntimeCapability[]; +} + +export interface RuntimeRegistrySnapshot { + readonly workspaceId: string; + readonly runtimes: readonly RuntimeGenerationSnapshot[]; +} + +export interface RuntimeRegistrationHandle { + readonly runtimeId: string; + replace(runtime: Runtime): Promise; + remove(): Promise; +} + +export interface RuntimeRegistryBatchEntry { + readonly runtime: Runtime; + readonly current?: Runtime; + readonly registration?: RuntimeRegistrationHandle; +} + +export interface RuntimeRegistryBatchResult { + readonly registrations: readonly RuntimeRegistrationHandle[]; + readonly cleanup: Promise; +} + +export class RuntimeRegistry { + private readonly currentGenerations = new Map(); + private readonly changeEmitter = new Emitter(); + readonly onDidChange: Event = this.changeEmitter.event; + private disposing = false; + + constructor( + readonly workspaceId: string, + private readonly drainTimeoutMs = RUNTIME_DRAIN_TIMEOUT_MS, + ) {} + + list(): readonly Runtime[] { + return [...this.currentGenerations.values()].map((value) => value.runtime); + } + + snapshot(): RuntimeRegistrySnapshot { + return { + workspaceId: this.workspaceId, + runtimes: this.list().map((runtime) => ({ + runtimeId: runtime.identity.runtimeId, + generation: runtime.identity.generation, + status: runtime.status, + capabilities: [...runtime.capabilities], + })), + }; + } + + current(runtimeId: string): Runtime | undefined { + return this.currentGenerations.get(runtimeId)?.runtime; + } + + inspect(binding: RuntimeBinding): Runtime { + if (binding.workspaceId !== this.workspaceId) { + throw new RuntimeError('runtime.not_found', `workspace ${binding.workspaceId} is not ${this.workspaceId}`); + } + const runtime = this.currentGenerations.get(binding.runtimeId)?.runtime; + if (runtime === undefined) { + throw new RuntimeError('runtime.not_found', `runtime ${binding.runtimeId} does not exist in workspace ${this.workspaceId}`); + } + return runtime; + } + + prepare(runtime: Runtime, expectedRuntimeId?: string): void { + if (this.disposing) throw new RuntimeError('runtime.unavailable', `runtime registry ${this.workspaceId} is disposing`); + this.assertPrepared(runtime, expectedRuntimeId); + } + + register(runtime: Runtime): RuntimeRegistrationHandle { + return this.publishBatch([{ runtime }]).registrations[0]!; + } + + publishBatch(entries: readonly RuntimeRegistryBatchEntry[]): RuntimeRegistryBatchResult { + if (this.disposing) throw new RuntimeError('runtime.unavailable', `runtime registry ${this.workspaceId} is disposing`); + const runtimeIds = new Set(); + const prepared = entries.map((entry) => { + const runtimeId = entry.runtime.identity.runtimeId; + if (runtimeIds.has(runtimeId)) { + throw new RuntimeError('runtime.conflict', `runtime ${runtimeId} appears twice in one registry batch`); + } + runtimeIds.add(runtimeId); + const replacement = entry.current !== undefined || entry.registration !== undefined; + if (replacement && (entry.current === undefined || entry.registration === undefined)) { + throw new Error(`runtime ${runtimeId} replacement requires its current runtime and registration`); + } + this.assertPrepared(entry.runtime, replacement ? runtimeId : undefined); + const previous = this.currentGenerations.get(runtimeId); + if (!replacement) { + if (previous !== undefined) { + throw new RuntimeError('runtime.conflict', `runtime ${runtimeId} already exists in workspace ${this.workspaceId}`); + } + } else { + if (entry.registration!.runtimeId !== runtimeId) { + throw new Error(`runtime registration ${entry.registration!.runtimeId} cannot replace ${runtimeId}`); + } + if (previous?.runtime !== entry.current) { + throw new RuntimeError('runtime.conflict', `runtime ${runtimeId} changed before registry batch publication`); + } + } + return { entry, previous }; + }); + const generations: Generation[] = []; + try { + for (const item of prepared) generations.push(this.createGeneration(item.entry.runtime)); + } catch (error) { + for (const generation of generations) generation.statusSubscription.dispose(); + throw error; + } + const registrations = prepared.map((item) => + item.entry.registration ?? this.createRegistration(item.entry.runtime.identity.runtimeId), + ); + for (let index = 0; index < prepared.length; index += 1) { + const runtimeId = prepared[index]!.entry.runtime.identity.runtimeId; + this.currentGenerations.set(runtimeId, generations[index]!); + } + for (const generation of generations) this.publish(generation); + const cleanup = Promise.all( + prepared.flatMap((item) => item.previous === undefined ? [] : [this.drain(item.previous)]), + ).then(() => {}); + return { registrations, cleanup }; + } + + acquire(binding: RuntimeBinding, required: readonly RuntimeCapability[] = []): RuntimeLease { + if (binding.workspaceId !== this.workspaceId) { + throw new RuntimeError('runtime.not_found', `workspace ${binding.workspaceId} is not ${this.workspaceId}`); + } + const generation = this.currentGenerations.get(binding.runtimeId); + if (generation === undefined) { + throw new RuntimeError('runtime.not_found', `runtime ${binding.runtimeId} does not exist in workspace ${this.workspaceId}`); + } + if (generation.draining || !runtimeStatusAllows(generation.runtime, required)) { + throw new RuntimeError('runtime.unavailable', `runtime ${binding.runtimeId} is ${generation.draining ? 'draining' : generation.runtime.status}`); + } + for (const capability of required) { + if (!generation.runtime.capabilities.has(capability)) { + throw new RuntimeError('runtime.capability_unavailable', `runtime ${binding.runtimeId} does not provide ${capability}`); + } + } + generation.leases += 1; + let active = true; + const release = (): void => { + if (!active) return; + active = false; + generation.leases -= 1; + if (generation.leases === 0) generation.releaseDrain?.(); + }; + return { + runtime: generation.runtime, + track: (resource: T): T => { + if (!active || generation.draining) throw new RuntimeError('runtime.unavailable', `runtime ${binding.runtimeId} is draining`); + const originalDispose = resource.dispose.bind(resource); + let disposed = false; + resource.dispose = function () { + if (disposed) return; + disposed = true; + generation.resources.delete(resource); + return originalDispose(); + } as T['dispose']; + generation.resources.add(resource); + return resource; + }, + dispose: release, + }; + } + + async dispose(): Promise { + if (this.disposing) return; + this.disposing = true; + const generations = [...this.currentGenerations.values()]; + this.currentGenerations.clear(); + for (const generation of generations.reverse()) await this.drain(generation); + this.changeEmitter.dispose(); + } + + private createRegistration(runtimeId: string): RuntimeRegistrationHandle { + let active = true; + let operation = Promise.resolve(); + const enqueue = (work: () => Promise): Promise => { + const next = operation.then(work, work); + operation = next.catch(() => {}); + return next; + }; + let handle: RuntimeRegistrationHandle; + handle = { + runtimeId, + replace: (replacement) => enqueue(async () => { + if (!active || this.disposing) { + await replacement.dispose(); + throw new Error(`runtime registration ${runtimeId} is disposed`); + } + const previous = this.currentGenerations.get(runtimeId); + if (previous === undefined) { + await replacement.dispose(); + throw new Error(`runtime ${runtimeId} is not registered`); + } + let publication: RuntimeRegistryBatchResult; + try { + publication = this.publishBatch([{ + runtime: replacement, + current: previous.runtime, + registration: handle, + }]); + } catch (error) { + await replacement.dispose(); + throw error; + } + await publication.cleanup; + }), + remove: () => enqueue(async () => { + if (!active) return; + active = false; + const previous = this.currentGenerations.get(runtimeId); + if (previous === undefined) return; + this.currentGenerations.delete(runtimeId); + this.changeEmitter.fire({ runtimeId }); + await this.drain(previous); + }), + }; + return handle; + } + + private createGeneration(runtime: Runtime): Generation { + const generation = { + runtime, + resources: new Set(), + leases: 0, + draining: false, + disposed: false, + statusSubscription: undefined as unknown as { dispose(): void }, + }; + generation.statusSubscription = runtime.onDidChangeStatus((status) => { + if (!generation.draining && !generation.disposed && this.currentGenerations.get(runtime.identity.runtimeId) === generation) { + this.changeEmitter.fire({ runtimeId: runtime.identity.runtimeId, current: runtime, status }); + } + }); + return generation; + } + + private publish(generation: Generation): void { + this.changeEmitter.fire({ + runtimeId: generation.runtime.identity.runtimeId, + current: generation.runtime, + status: generation.runtime.status, + }); + } + + private assertPrepared(runtime: Runtime, expectedRuntimeId?: string): void { + if (runtime.identity.workspaceId !== this.workspaceId) throw new Error(`runtime belongs to workspace ${runtime.identity.workspaceId}`); + if (expectedRuntimeId !== undefined && runtime.identity.runtimeId !== expectedRuntimeId) throw new Error(`replacement runtime id must remain ${expectedRuntimeId}`); + if (runtime.status === 'draining' || runtime.status === 'disposed') throw new RuntimeError('runtime.unavailable', `runtime ${runtime.identity.runtimeId} is ${runtime.status}`); + for (const capability of runtime.capabilities) { + if (runtime[capability] === undefined) throw new RuntimeError('runtime.capability_unavailable', `runtime ${runtime.identity.runtimeId} declares ${capability} without an implementation`); + } + } + + private drain(generation: Generation): Promise { + generation.drainPromise ??= (async () => { + generation.draining = true; + generation.statusSubscription.dispose(); + this.changeEmitter.fire({ + runtimeId: generation.runtime.identity.runtimeId, + current: generation.runtime, + status: 'draining', + }); + const resources = [...generation.resources].reverse(); + generation.resources.clear(); + for (const resource of resources) { + try { + await resource.dispose(); + } catch {} + } + if (generation.leases > 0) { + await Promise.race([ + new Promise((resolve) => { generation.releaseDrain = resolve; }), + new Promise((resolve) => setTimeout(resolve, this.drainTimeoutMs)), + ]); + } + if (!generation.disposed) { + generation.disposed = true; + await generation.runtime.dispose(); + } + })(); + return generation.drainPromise; + } +} + +export function runtimeStatusAllows(runtime: Runtime, required: readonly RuntimeCapability[]): boolean { + if (runtime.status === 'ready') return true; + return runtime.status === 'degraded' && required.every((capability) => runtime.capabilities.has(capability)); +} diff --git a/packages/agent-core-v2/src/runtime/runtimeUnitHost.ts b/packages/agent-core-v2/src/runtime/runtimeUnitHost.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d750f658c93917ee870e41dda8de772398c32f5 --- /dev/null +++ b/packages/agent-core-v2/src/runtime/runtimeUnitHost.ts @@ -0,0 +1,432 @@ +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { _util, type IInstantiationService, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { ServiceCollection } from '#/_base/di/serviceCollection'; +import type { Runtime } from './runtime'; +import type { RuntimeRegistrationHandle, RuntimeRegistry } from './runtimeRegistry'; + +type RuntimeUnitConstructor = new (...args: never[]) => T; + +export interface RuntimeUnitImports { + readonly root: readonly ServiceIdentifier[]; + readonly imports: readonly ServiceIdentifier[]; + readonly local: readonly ServiceIdentifier[]; +} + +export interface RuntimeProviderRuntimeHandle { + readonly runtimeId: string; + update(prepare: () => Runtime | Promise): Promise; + remove(): Promise; +} + +export interface RuntimeProviderHost { + get(id: ServiceIdentifier): T; + provide(id: ServiceIdentifier, ctor: RuntimeUnitConstructor, ...staticArguments: unknown[]): T; + registerRuntime(runtime: Runtime): RuntimeProviderRuntimeHandle; +} + +export interface RuntimeUnitHandle { + update }>( + imports: RuntimeUnitImports, + prepare: (host: RuntimeProviderHost) => Promise, + ): Promise; + remove(): Promise; + dispose(): Promise; +} + +export interface RuntimeUnitHost { + provide }>( + imports: RuntimeUnitImports, + prepare: (host: RuntimeProviderHost) => Promise, + ): Promise; + update }>( + handle: RuntimeUnitHandle, + imports: RuntimeUnitImports, + prepare: (host: RuntimeProviderHost) => Promise, + ): Promise; + remove(handle: RuntimeUnitHandle): Promise; + dispose(): Promise; +} + +export interface RuntimeUnitHostFactory { + create(root: IInstantiationService, registry: RuntimeRegistry): RuntimeUnitHost; +} + +export class SharedRuntimeUnitHostFactory implements RuntimeUnitHostFactory { + create(root: IInstantiationService, registry: RuntimeRegistry): RuntimeUnitHost { + return new SharedRuntimeUnitHost(root, registry); + } +} + +interface LocalRegistration { + readonly id: ServiceIdentifier; + readonly value: unknown; +} + +interface RuntimeUnitTransaction { + readonly host: RuntimeProviderHost; + readonly units: Array<{ dispose(): void | Promise }>; + readonly local: LocalRegistration[]; + readonly runtimes: StagedRuntime[]; + dispose(): Promise; + commit(): { readonly cleanup: Promise }; +} + +interface StagedRuntime { + runtime: Runtime; + registration?: RuntimeRegistrationHandle; + active: boolean; +} + +interface RuntimeUnitRecord { + attachment: { dispose(): void | Promise }; + transaction: RuntimeUnitTransaction; + active: boolean; + handle?: RuntimeUnitHandle; +} + +class SharedRuntimeUnitHost implements RuntimeUnitHost { + private readonly records: RuntimeUnitRecord[] = []; + private readonly recordByHandle = new Map(); + private readonly locals = new Map, LocalRegistration>(); + private tail = Promise.resolve(); + private closing = false; + + constructor(private readonly root: IInstantiationService, private readonly registry: RuntimeRegistry) {} + + provide }>( + imports: RuntimeUnitImports, + prepare: (host: RuntimeProviderHost) => Promise, + ): Promise { + if (this.closing) return Promise.reject(new Error('runtime unit host is disposed')); + return this.enqueue(async () => { + this.assertOpen(); + const transaction = this.createTransaction(imports); + let attachment: T; + let cleanup: Promise; + try { + attachment = await prepare(transaction.host); + cleanup = transaction.commit().cleanup; + } catch (error) { + await transaction.dispose(); + throw error; + } + const record: RuntimeUnitRecord = { attachment, transaction, active: true }; + const handle = this.handle(record); + record.handle = handle; + this.records.push(record); + this.recordByHandle.set(handle, record); + await cleanup; + return handle; + }); + } + + update }>( + handle: RuntimeUnitHandle, + imports: RuntimeUnitImports, + prepare: (host: RuntimeProviderHost) => Promise, + ): Promise { + if (this.closing) return Promise.reject(new Error('runtime unit host is disposed')); + return this.enqueue(async () => { + this.assertOpen(); + const record = this.find(handle); + if (!record.active) throw new Error('runtime unit handle is disposed'); + const transaction = this.createTransaction(imports, record.transaction); + let attachment: T; + let cleanup: Promise; + try { + attachment = await prepare(transaction.host); + cleanup = transaction.commit().cleanup; + } catch (error) { + await transaction.dispose(); + throw error; + } + const previousAttachment = record.attachment; + const previousTransaction = record.transaction; + record.attachment = attachment; + record.transaction = transaction; + let failure: unknown; + let failed = false; + try { + await cleanup; + } catch (error) { + failure = error; + failed = true; + } + try { + await previousAttachment.dispose(); + } catch (error) { + if (!failed) failure = error; + failed = true; + } + try { + await previousTransaction.dispose(); + } catch (error) { + if (!failed) failure = error; + failed = true; + } + if (failed) throw failure; + }); + } + + remove(handle: RuntimeUnitHandle): Promise { + return this.enqueue(async () => { + const record = this.find(handle); + if (!record.active) return; + record.active = false; + let failure: unknown; + let failed = false; + try { + await record.attachment.dispose(); + } catch (error) { + failure = error; + failed = true; + } + try { + await record.transaction.dispose(); + } catch (error) { + if (!failed) failure = error; + failed = true; + } + const index = this.records.indexOf(record); + if (index >= 0) this.records.splice(index, 1); + this.recordByHandle.delete(handle); + if (failed) throw failure; + }); + } + + async dispose(): Promise { + if (this.closing) return this.tail; + this.closing = true; + await this.tail; + await this.enqueue(async () => { + let failure: unknown; + let failed = false; + for (const record of [...this.records].reverse()) { + if (!record.active) continue; + record.active = false; + try { + await record.attachment.dispose(); + } catch (error) { + if (!failed) failure = error; + failed = true; + } + try { + await record.transaction.dispose(); + } catch (error) { + if (!failed) failure = error; + failed = true; + } + if (record.handle !== undefined) this.recordByHandle.delete(record.handle); + } + this.records.length = 0; + if (failed) throw failure; + }); + await this.tail; + } + + private handle(_record: RuntimeUnitRecord): RuntimeUnitHandle { + const handle: RuntimeUnitHandle = { + update: (imports, prepare) => this.update(handle, imports, prepare), + remove: () => this.remove(handle), + dispose: () => this.remove(handle), + }; + return handle; + } + + private find(handle: RuntimeUnitHandle): RuntimeUnitRecord { + const record = this.recordByHandle.get(handle); + if (record === undefined) throw new Error('runtime unit handle is not owned by this host'); + return record; + } + + private enqueue(work: () => Promise): Promise { + const next = this.tail.then(work, work); + this.tail = next.then(() => {}, () => {}); + return next; + } + + private assertOpen(): void { + if (this.closing) throw new Error('runtime unit host is disposed'); + } + + private createTransaction(imports: RuntimeUnitImports, previous?: RuntimeUnitTransaction): RuntimeUnitTransaction { + const declared = new Set([...imports.root, ...imports.imports, ...imports.local]); + if (declared.size !== imports.root.length + imports.imports.length + imports.local.length) { + throw new Error('runtime unit dependency manifest contains duplicate declarations'); + } + const services = new ServiceCollection(); + const units: Array<{ dispose(): void | Promise }> = []; + const local: LocalRegistration[] = []; + const runtimes: StagedRuntime[] = []; + let active = true; + let committed = false; + for (const id of imports.root) { + services.set(id, this.root.invokeFunction((accessor) => accessor.get(id))); + } + for (const id of imports.imports) { + const registration = this.locals.get(id); + if (registration === undefined) throw new Error(`runtime unit import is not available ${id.toString()}`); + services.set(id, registration.value); + } + const child = this.root.createChild(services); + const host: RuntimeProviderHost = { + get: (id: ServiceIdentifier): T => { + if (!active || !declared.has(id)) throw new Error(`runtime unit dependency is not declared ${id.toString()}`); + if (imports.local.includes(id) && !local.some((registration) => registration.id === id)) { + throw new Error(`runtime unit local dependency is not available ${id.toString()}`); + } + return child.invokeFunction((accessor) => accessor.get(id)); + }, + provide: (id: ServiceIdentifier, ctor: RuntimeUnitConstructor, ...staticArguments: unknown[]): T => { + if (!active || !imports.local.includes(id)) throw new Error(`runtime unit local registration is not declared ${id.toString()}`); + if (local.some((registration) => registration.id === id)) throw new Error(`runtime unit local registration already exists ${id.toString()}`); + for (const dependency of _util.getInstanceDependencies(ctor as unknown as _util.DI_TARGET_OBJ)) { + if (!declared.has(dependency.id)) throw new Error(`runtime unit dependency is not declared ${dependency.id.toString()}`); + if (imports.local.includes(dependency.id) && !local.some((registration) => registration.id === dependency.id)) { + throw new Error(`runtime unit local dependency is not available ${dependency.id.toString()}`); + } + } + const unit = child.createInstance(new SyncDescriptor(ctor as never, staticArguments)) as T; + services.set(id, unit); + local.push({ id, value: unit }); + const disposable = unit as { dispose?: () => void | Promise }; + if (typeof disposable.dispose === 'function') units.push(disposable as { dispose(): void | Promise }); + return unit; + }, + registerRuntime: (runtime) => { + if (!active) throw new Error('runtime unit transaction is disposed'); + if (runtimes.some((entry) => entry.runtime.identity.runtimeId === runtime.identity.runtimeId)) { + throw new Error(`runtime ${runtime.identity.runtimeId} is registered twice in one transaction`); + } + const staged: StagedRuntime = { runtime, active: true }; + if (committed) staged.registration = this.registry.register(runtime); + runtimes.push(staged); + const handle: RuntimeProviderRuntimeHandle = { + runtimeId: runtime.identity.runtimeId, + update: (replacement) => this.updateRuntime(staged, replacement), + remove: async () => { + try { + await this.removeRuntime(staged); + } finally { + const index = runtimes.indexOf(staged); + if (index >= 0) runtimes.splice(index, 1); + } + }, + }; + return handle; + }, + }; + const transaction: RuntimeUnitTransaction = { + host, + units, + local, + runtimes, + commit: () => { + if (!active) throw new Error('runtime unit transaction is disposed'); + const previousRuntimes = new Map( + previous?.runtimes.map((staged) => [staged.runtime.identity.runtimeId, staged]) ?? [], + ); + const previousLocals = new Set(previous?.local.map((registration) => registration.id) ?? []); + for (const staged of runtimes) { + const current = this.registry.current(staged.runtime.identity.runtimeId); + const previousRuntime = previousRuntimes.get(staged.runtime.identity.runtimeId); + if (current !== undefined && previousRuntime === undefined) { + throw new Error(`runtime ${staged.runtime.identity.runtimeId} already exists`); + } + this.registry.prepare( + staged.runtime, + previousRuntime === undefined ? undefined : staged.runtime.identity.runtimeId, + ); + } + for (const registration of local) { + if (this.locals.has(registration.id) && !previousLocals.has(registration.id)) { + throw new Error(`runtime unit local registration already exists ${registration.id.toString()}`); + } + } + const publication = this.registry.publishBatch(runtimes.map((staged) => { + const previousRuntime = previousRuntimes.get(staged.runtime.identity.runtimeId); + if (previousRuntime?.registration === undefined) return { runtime: staged.runtime }; + return { + runtime: staged.runtime, + current: previousRuntime.runtime, + registration: previousRuntime.registration, + }; + })); + for (let index = 0; index < runtimes.length; index += 1) { + const staged = runtimes[index]!; + const previousRuntime = previousRuntimes.get(staged.runtime.identity.runtimeId); + if (previousRuntime !== undefined) previousRuntime.active = false; + staged.registration = publication.registrations[index]; + } + for (const registration of local) this.locals.set(registration.id, registration); + committed = true; + return { cleanup: publication.cleanup }; + }, + dispose: async () => { + if (!active) return; + active = false; + let failure: unknown; + let failed = false; + for (const staged of runtimes.reverse()) { + if (!staged.active) continue; + staged.active = false; + try { + if (staged.registration === undefined) await staged.runtime.dispose(); + else await staged.registration.remove(); + } catch (error) { + if (!failed) failure = error; + failed = true; + } + } + for (const registration of local.reverse()) { + if (this.locals.get(registration.id) === registration) this.locals.delete(registration.id); + } + for (const unit of units.reverse()) { + try { + await unit.dispose(); + } catch (error) { + if (!failed) failure = error; + failed = true; + } + } + try { + child.dispose(); + } catch (error) { + if (!failed) failure = error; + failed = true; + } + if (failed) throw failure; + }, + }; + return transaction; + } + + private updateRuntime(staged: StagedRuntime, prepare: () => Runtime | Promise): Promise { + if (this.closing) return Promise.reject(new Error('runtime unit host is disposed')); + return this.enqueue(async () => { + if (!staged.active || staged.registration === undefined) throw new Error('runtime registration is not active'); + const replacement = await prepare(); + let cleanup: Promise; + try { + this.registry.prepare(replacement, staged.runtime.identity.runtimeId); + cleanup = this.registry.publishBatch([{ + runtime: replacement, + current: staged.runtime, + registration: staged.registration, + }]).cleanup; + } catch (error) { + await replacement.dispose(); + throw error; + } + staged.runtime = replacement; + await cleanup; + }); + } + + private async removeRuntime(staged: StagedRuntime): Promise { + if (!staged.active) return; + staged.active = false; + if (staged.registration === undefined) await staged.runtime.dispose(); + else await staged.registration.remove(); + } +} diff --git a/packages/agent-core-v2/src/runtime/runtimeWorkspaceView.ts b/packages/agent-core-v2/src/runtime/runtimeWorkspaceView.ts new file mode 100644 index 0000000000000000000000000000000000000000..01db1bbf8f51cc1b16178d3d4e07817d4b63d372 --- /dev/null +++ b/packages/agent-core-v2/src/runtime/runtimeWorkspaceView.ts @@ -0,0 +1,53 @@ +import { ErrorCodes, Error2 } from '#/errors'; +import { getShellPathBridge } from '#/_base/execEnv/shellPathBridge'; + +import type { Runtime, RuntimeBinding, RuntimeWorkspaceRoots } from './runtime'; + +export type { RuntimeWorkspaceRoots } from './runtime'; + +export class RuntimeWorkspaceView { + readonly binding: RuntimeBinding; + readonly generation: string; + readonly workDir: string; + readonly additionalDirs: readonly string[]; + readonly roots: readonly string[]; + + constructor( + readonly runtime: Runtime, + roots: RuntimeWorkspaceRoots, + ) { + this.binding = { + workspaceId: runtime.identity.workspaceId, + runtimeId: runtime.identity.runtimeId, + }; + this.generation = runtime.identity.generation; + const mapped = runtime.workspace.mapRoots(roots); + this.workDir = runtime.path.resolve(mapped.workDir); + this.additionalDirs = [...new Set((mapped.additionalDirs ?? []).map((root) => runtime.path.resolve(root)))]; + this.roots = [this.workDir, ...this.additionalDirs]; + } + + resolve(path: string, cwd = this.workDir): string { + const env = this.runtime.environment; + const bridged = env.pathClass === 'win32' ? getShellPathBridge(env).fromShellPath(path) : path; + return this.runtime.path.isAbsolute(bridged) + ? this.runtime.path.resolve(bridged) + : this.runtime.path.resolve(cwd, bridged); + } + + assertAllowed(path: string): string { + const resolved = this.runtime.path.resolve(path); + if (this.roots.some((root) => contains(this.runtime, root, resolved))) return resolved; + throw new Error2( + ErrorCodes.FS_PATH_ESCAPES, + `path ${path} is outside runtime workspace ${this.binding.runtimeId}`, + { details: { path: resolved } }, + ); + } +} + +function contains(runtime: Runtime, root: string, candidate: string): boolean { + const relative = runtime.path.relative(root, candidate); + if (relative === '') return true; + return relative !== '..' && !relative.startsWith(`..${runtime.path.separator}`) && !runtime.path.isAbsolute(relative); +} diff --git a/packages/agent-core-v2/src/runtime/standaloneRuntime.ts b/packages/agent-core-v2/src/runtime/standaloneRuntime.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0fe41ff656a4bd920802b4181a01c6a4de2ac0a --- /dev/null +++ b/packages/agent-core-v2/src/runtime/standaloneRuntime.ts @@ -0,0 +1,41 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IHostProcessService } from '#/os/interface/hostProcess'; +import { IHostTerminalService } from '#/os/interface/terminal'; + +import { LocalRuntime } from './localRuntime'; +import type { Runtime } from './runtime'; + +export interface IStandaloneRuntimeFactory { + readonly _serviceBrand: undefined; + createLocalRuntime(workspaceId: string): Runtime; +} + +export const IStandaloneRuntimeFactory: ServiceIdentifier = + createDecorator('standaloneRuntimeFactory'); + +export class StandaloneRuntimeFactory implements IStandaloneRuntimeFactory { + declare readonly _serviceBrand: undefined; + + constructor( + @IHostEnvironment private readonly environment: IHostEnvironment, + @IHostFileSystem private readonly fs: IHostFileSystem, + @IHostProcessService private readonly process: IHostProcessService, + @IHostTerminalService private readonly terminal: IHostTerminalService, + ) {} + + createLocalRuntime(workspaceId: string): Runtime { + return new LocalRuntime(workspaceId, this.environment, this.fs, this.process, this.terminal); + } +} + +registerScopedService( + LifecycleScope.App, + IStandaloneRuntimeFactory, + StandaloneRuntimeFactory, + ScopeActivation.OnDemand, + 'standaloneRuntimeFactory', +); diff --git a/packages/agent-core-v2/src/session/errors.ts b/packages/agent-core-v2/src/session/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..2efaa4037a6e51bc12052bfc8a697b12ec0e5eb1 --- /dev/null +++ b/packages/agent-core-v2/src/session/errors.ts @@ -0,0 +1,18 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const SessionErrors = { + codes: { + SESSION_NOT_FOUND: 'session.not_found', + SESSION_ALREADY_EXISTS: 'session.already_exists', + SESSION_ID_INVALID: 'session.id_invalid', + SESSION_CLOSED: 'session.closed', + SESSION_FORK_ACTIVE_TURN: 'session.fork_active_turn', + SESSION_UNDO_UNAVAILABLE: 'session.undo_unavailable', + SESSION_INIT_FAILED: 'session.init_failed', + SESSION_PLAN_MODE_INVALID: 'session.plan_mode_invalid', + SESSION_TOWER_MODE_INVALID: 'session.tower_mode_invalid', + }, + retryable: ['session.fork_active_turn'], +} as const satisfies ErrorDomain; + +registerErrorDomain(SessionErrors); diff --git a/packages/agent-core-v2/src/session/mcp/ephemeralMcpServers.ts b/packages/agent-core-v2/src/session/mcp/ephemeralMcpServers.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee18e5fb63db1c5ad45be7b3aaa400f6e7e1749c --- /dev/null +++ b/packages/agent-core-v2/src/session/mcp/ephemeralMcpServers.ts @@ -0,0 +1,13 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; + +export const ISessionEphemeralMcpServers: ServiceIdentifier< + Readonly> +> = createDecorator>>('sessionEphemeralMcpServers'); + +export function sessionEphemeralMcpServersSeed( + servers: Readonly>, +): ScopeSeed { + return [[ISessionEphemeralMcpServers as ServiceIdentifier, servers]]; +} diff --git a/packages/agent-core-v2/src/session/mcp/mergedConnectionView.ts b/packages/agent-core-v2/src/session/mcp/mergedConnectionView.ts new file mode 100644 index 0000000000000000000000000000000000000000..d20300979560b90256e3127b850916fed0009d0f --- /dev/null +++ b/packages/agent-core-v2/src/session/mcp/mergedConnectionView.ts @@ -0,0 +1,78 @@ +import type { + McpConnectionManager, + McpConnectionView, + McpServerEntry, + McpStatusListener, +} from '#/mcpCore/connection-manager'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; +import type { McpOAuthService } from '#/mcpCore/oauth/service'; +import { abortable } from '#/_base/utils/abort'; + +export class MergedMcpConnectionView implements McpConnectionView { + constructor( + private readonly base: McpConnectionManager, + private readonly overlay: McpConnectionManager, + private readonly overlayNames: ReadonlySet, + ) {} + + get oauthService(): McpOAuthService | undefined { + return this.overlay.oauthService ?? this.base.oauthService; + } + + list(): readonly McpServerEntry[] { + const baseEntries = this.base.list().filter((entry) => !this.overlayNames.has(entry.name)); + return [...baseEntries, ...this.overlay.list()]; + } + + get(name: string): McpServerEntry | undefined { + return this.owner(name).get(name); + } + + configOf(name: string): McpServerConfig | undefined { + return this.owner(name).configOf(name); + } + + resolved(name: string): ReturnType { + return this.owner(name).resolved(name); + } + + getRemoteServerUrl(name: string): string | undefined { + return this.owner(name).getRemoteServerUrl(name); + } + + reconnect(name: string): Promise { + return this.owner(name).reconnect(name); + } + + reconnectAndJoin(name: string): Promise { + return this.owner(name).reconnectAndJoin(name); + } + + waitForInitialLoad(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const both = Promise.all([ + this.base.waitForInitialLoad(), + this.overlay.waitForInitialLoad(), + ]).then(() => undefined); + return signal === undefined ? both : abortable(both, signal); + } + + initialLoadDurationMs(): number { + return Math.max(this.base.initialLoadDurationMs(), this.overlay.initialLoadDurationMs()); + } + + onStatusChange(listener: McpStatusListener): () => void { + const unsubscribeBase = this.base.onStatusChange((entry) => { + if (!this.overlayNames.has(entry.name)) listener(entry); + }); + const unsubscribeOverlay = this.overlay.onStatusChange(listener); + return () => { + unsubscribeBase(); + unsubscribeOverlay(); + }; + } + + private owner(name: string): McpConnectionManager { + return this.overlayNames.has(name) ? this.overlay : this.base; + } +} diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionActivity.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionActivity.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b64a4e72e3e673fb5e6ea248a1d5ea053304e31 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionActivity.ts @@ -0,0 +1,36 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; + +export type SessionPendingInteraction = 'none' | 'approval' | 'question'; + +export type SessionTurnOutcome = 'completed' | 'cancelled' | 'failed'; + +export interface SessionActivityState { + readonly busy: boolean; + readonly mainTurnActive: boolean; + readonly pendingInteraction: SessionPendingInteraction; + readonly lastTurnReason?: SessionTurnOutcome; +} + +export type SessionActivityCause = + | 'turn_started' + | 'turn_ended' + | 'background' + | 'interaction' + | 'agent_lifecycle'; + +export interface SessionActivityChangedEvent { + readonly state: SessionActivityState; + readonly cause: SessionActivityCause; +} + +export interface ISessionActivityView { + readonly _serviceBrand: undefined; + + state(): SessionActivityState; + + readonly onDidChange: Event; +} + +export const ISessionActivityView: ServiceIdentifier = + createDecorator('sessionActivityView'); diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts new file mode 100644 index 0000000000000000000000000000000000000000..a574f3551f96f87f5f4d8ce0ab0152ff93df41d8 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts @@ -0,0 +1,285 @@ +import { Disposable, DisposableStore, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { + ScopeActivation, + registerScopedService, + type IAgentScopeHandle, +} from '#/_base/di/scope'; +import { Emitter, type Event } from '#/_base/event'; +import { defineState } from '#/state/state'; +import { IEventBus } from '#/app/event/eventBus'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { TurnStarted, type TurnEndReason } from '#/agent/loop/turnEvents'; +import { TurnEnded, turnKey } from '#/agent/loop/turnOps'; +import { IAgentTaskService } from '#/agent/task/task'; +import { TaskStarted, TaskTerminatedNotice } from '#/agent/task/taskOps'; +import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; +import { + CompactionCancelled, + CompactionCompleted, + CompactionStarted, +} from '#/agent/fullCompaction/compactionOps'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { + INTERACTION_TAG_SESSION_ID, + type Interaction, +} from '#/human/interaction/interaction'; +import { interactions } from '#/human/interaction/facade'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionStateService } from '#/session/state/sessionState'; + +import { + ISessionActivityView, + type SessionActivityCause, + type SessionActivityChangedEvent, + type SessionActivityState, + type SessionPendingInteraction, + type SessionTurnOutcome, +} from './sessionActivity'; + +interface AgentWorkFold { + turnActive: boolean; + background: ReadonlySet; + compacting: boolean; + lastTurnReason?: SessionTurnOutcome; +} + +export const sessionActivityFoldsKey = defineState>( + 'sessionActivity.folds', + () => new Map(), +); +export const sessionActivityCurrentKey = defineState('sessionActivity.current', () => ({ + busy: false, + mainTurnActive: false, + pendingInteraction: 'none', + lastTurnReason: undefined, +})); + +export class SessionActivityView extends Disposable implements ISessionActivityView { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private readonly agentSubscriptions = new Map(); + + constructor( + @ISessionStateService private readonly states: ISessionStateService, + @IAgentLifecycleService private readonly agents: IAgentLifecycleService, + @ISessionContext private readonly ctx: ISessionContext, + ) { + super(); + this.states.contributeState(sessionActivityFoldsKey); + this.states.contributeState(sessionActivityCurrentKey); + for (const agent of this.agents.list()) { + const handle = this.agents.handleOf(agent.agentId); + if (handle !== undefined) this.attachAgent(handle); + } + this.current = this.aggregate(); + this._register( + this.agents.onDidCreateScope(({ handle }) => { + this.attachAgent(handle); + this.recompute('agent_lifecycle'); + }), + ); + this._register( + this.agents.onDidClose((agent) => { + this.agentSubscriptions.get(agent.agentId)?.dispose(); + this.agentSubscriptions.delete(agent.agentId); + if (this.folds.delete(agent.agentId)) this.recompute('agent_lifecycle'); + }), + ); + this._register( + toDisposable( + interactions.onDidChangePending(() => this.recompute('interaction')), + ), + ); + this._register( + toDisposable(() => { + for (const subscription of this.agentSubscriptions.values()) subscription.dispose(); + this.agentSubscriptions.clear(); + }), + ); + } + + private get folds(): Map { + return this.states.get(sessionActivityFoldsKey); + } + + private get current(): SessionActivityState { + return this.states.get(sessionActivityCurrentKey); + } + + private set current(value: SessionActivityState) { + this.states.set(sessionActivityCurrentKey, value); + } + + state(): SessionActivityState { + return this.current; + } + + private attachAgent(handle: IAgentScopeHandle): void { + if (this.folds.has(handle.id)) return; + this.folds.set(handle.id, seedFold(handle)); + const bus = handle.accessor.get(IEventBus) as IEventBus | undefined; + if (bus === undefined) return; + const subscriptions = new DisposableStore(); + subscriptions.add( + bus.subscribe(TurnStarted, () => + this.patchFold(handle.id, (fold) => ({ + ...fold, + turnActive: true, + lastTurnReason: handle.id === MAIN_AGENT_ID ? undefined : fold.lastTurnReason, + })), + ), + ); + subscriptions.add( + bus.subscribe(TurnEnded, (event) => + this.patchFold(handle.id, (fold) => ({ + ...fold, + turnActive: false, + lastTurnReason: handle.id === MAIN_AGENT_ID ? mapTurnReason(event.reason) : fold.lastTurnReason, + })), + ), + ); + subscriptions.add( + bus.subscribe(TaskStarted, (event) => + this.patchFold(handle.id, (fold) => ({ + ...fold, + background: new Set(fold.background).add(event.info.taskId), + })), + ), + ); + subscriptions.add( + bus.subscribe(TaskTerminatedNotice, (event) => + this.patchFold(handle.id, (fold) => { + if (!fold.background.has(event.info.taskId)) return fold; + const background = new Set(fold.background); + background.delete(event.info.taskId); + return { ...fold, background }; + }), + ), + ); + subscriptions.add( + bus.subscribe(CompactionStarted, () => + this.patchFold(handle.id, (fold) => ({ ...fold, compacting: true })), + ), + ); + subscriptions.add( + bus.subscribe(CompactionCompleted, () => + this.patchFold(handle.id, (fold) => ({ ...fold, compacting: false })), + ), + ); + subscriptions.add( + bus.subscribe(CompactionCancelled, () => + this.patchFold(handle.id, (fold) => ({ ...fold, compacting: false })), + ), + ); + const dispatcher = handle.accessor.get(IEventDispatcher) as IEventDispatcher | undefined; + if (dispatcher !== undefined) { + subscriptions.add( + dispatcher.hooks.onDidRestore.register('sessionActivity', async (_ctx, next) => { + this.folds.set(handle.id, seedFold(handle)); + this.recompute('agent_lifecycle'); + await next(); + }), + ); + } + this.agentSubscriptions.set(handle.id, subscriptions); + } + + private patchFold(agentId: string, patch: (fold: AgentWorkFold) => AgentWorkFold): void { + const previous = this.folds.get(agentId); + if (previous === undefined) return; + const next = patch(previous); + this.folds.set(agentId, next); + let cause: SessionActivityCause | undefined; + if (!previous.turnActive && next.turnActive) cause = 'turn_started'; + else if (previous.turnActive && !next.turnActive) cause = 'turn_ended'; + else if (previous.background.size !== next.background.size || previous.compacting !== next.compacting) { + cause = 'background'; + } + else if (agentId === MAIN_AGENT_ID && previous.lastTurnReason !== next.lastTurnReason) { + cause = 'turn_ended'; + } + if (cause !== undefined) this.recompute(cause); + } + + private recompute(cause: SessionActivityCause): void { + const next = this.aggregate(); + if (activityEquals(this.current, next)) return; + this.current = next; + this._onDidChange.fire({ state: next, cause }); + } + + private aggregate(): SessionActivityState { + let busy = false; + for (const fold of this.folds.values()) { + if (fold.turnActive || fold.background.size > 0 || fold.compacting) { + busy = true; + break; + } + } + return { + busy, + mainTurnActive: this.folds.get(MAIN_AGENT_ID)?.turnActive ?? false, + pendingInteraction: resolvePendingInteraction( + interactions.findAll({ + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: this.ctx.sessionId }, + }), + ), + lastTurnReason: this.folds.get(MAIN_AGENT_ID)?.lastTurnReason, + }; + } +} + +function seedFold(handle: IAgentScopeHandle): AgentWorkFold { + const loop = handle.accessor.get(IAgentLoopService) as IAgentLoopService | undefined; + const tasks = handle.accessor.get(IAgentTaskService) as IAgentTaskService | undefined; + const compaction = handle.accessor.get(IAgentFullCompactionService) as + | IAgentFullCompactionService + | undefined; + const states = handle.accessor.get(IAgentStateService) as IAgentStateService | undefined; + const lastEnded = + handle.id === MAIN_AGENT_ID && states?.has(turnKey) === true + ? states.get(turnKey).lastEnded + : undefined; + return { + turnActive: loop?.snapshot().state === 'running', + background: new Set(tasks?.list(true).map((task) => task.taskId) ?? []), + compacting: (compaction?.compacting ?? null) !== null, + lastTurnReason: + loop?.snapshot().state === 'running' ? undefined : mapTurnReason(lastEnded?.reason), + }; +} + +function mapTurnReason(reason: TurnEndReason | undefined): SessionTurnOutcome | undefined { + if (reason === undefined) return undefined; + return reason === 'completed' ? 'completed' : reason === 'cancelled' ? 'cancelled' : 'failed'; +} + +function resolvePendingInteraction(pending: readonly Interaction[]): SessionPendingInteraction { + if (pending.some((interaction) => interaction.kind === 'approval')) return 'approval'; + if (pending.some((interaction) => interaction.kind === 'question')) return 'question'; + return 'none'; +} + +function activityEquals(a: SessionActivityState, b: SessionActivityState): boolean { + return ( + a.busy === b.busy && + a.mainTurnActive === b.mainTurnActive && + a.pendingInteraction === b.pendingInteraction && + a.lastTurnReason === b.lastTurnReason + ); +} + +registerScopedService( + LifecycleScope.Session, + ISessionActivityView, + SessionActivityView, + ScopeActivation.OnScopeCreated, + 'sessionActivity', +); diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirror.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirror.ts new file mode 100644 index 0000000000000000000000000000000000000000..31fa6cae106316952adbabe81f617f9aaa22d752 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirror.ts @@ -0,0 +1,8 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ISessionOutcomeMirror { + readonly _serviceBrand: undefined; +} + +export const ISessionOutcomeMirror: ServiceIdentifier = + createDecorator('sessionOutcomeMirror'); diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b3a0fb811615532a026a89c421729dd40e93178 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts @@ -0,0 +1,175 @@ +import { Disposable, DisposableStore } from '#/_base/di/lifecycle'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { IEventBus } from '#/app/event/eventBus'; +import { TurnStarted } from '#/agent/loop/turnEvents'; +import { TurnEnded, turnKey } from '#/agent/loop/turnOps'; +import { ContextUndone } from '#/agent/undo/undoService'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import { + IAgentLifecycleService, + MAIN_AGENT_ID, +} from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; + +import type { SessionTurnOutcome } from './sessionActivity'; +import { ISessionOutcomeMirror } from './sessionOutcomeMirror'; + +export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeMirror { + declare readonly _serviceBrand: undefined; + + private lastPersisted: SessionTurnOutcome | undefined; + private lastPersistedTurnId: number | undefined; + private adopted = false; + private turnStartedHere = false; + private mainSubscription: DisposableStore | undefined; + private readonly metadataReady: Promise; + + constructor( + @IAgentLifecycleService private readonly agents: IAgentLifecycleService, + @ISessionMetadata private readonly metadata: ISessionMetadata, + ) { + super(); + this.metadataReady = this.metadata + .read() + .then((meta) => { + if (!this.adopted) this.lastPersisted = meta.lastTurnReason; + }) + .catch(() => {}); + this.attachMain(); + this._register(this.agents.onDidCreate((agent) => { + if (agent.agentId === MAIN_AGENT_ID) this.attachMain(); + })); + this._register(this.agents.onDidClose((agent) => { + if (agent.agentId !== MAIN_AGENT_ID) return; + this.mainSubscription?.dispose(); + this.mainSubscription = undefined; + })); + this._register({ + dispose: () => { + this.mainSubscription?.dispose(); + this.mainSubscription = undefined; + }, + }); + } + + private attachMain(): void { + if (this.mainSubscription !== undefined) return; + const handle = this.agents.handleOf(MAIN_AGENT_ID); + const bus = handle?.accessor.get(IEventBus) as IEventBus | undefined; + if (bus === undefined) return; + const subscription = new DisposableStore(); + this.mainSubscription = subscription; + const dispatcher = handle?.accessor.get(IEventDispatcher) as IEventDispatcher | undefined; + const agentStates = handle?.accessor.get(IAgentStateService) as IAgentStateService | undefined; + if (dispatcher !== undefined && agentStates !== undefined) { + subscription.add( + dispatcher.hooks.onDidRestore.register('session-outcome-mirror', async (_ctx, next) => { + await next(); + await this.reconcileAfterRestore(agentStates); + }), + ); + } + subscription.add( + bus.subscribe(TurnEnded, (event) => { + if (event.reason === 'completed') { + this.write('completed', { turnId: event.turnId }); + return; + } + if (event.reason === 'failed' || event.reason === 'blocked') { + this.write('failed', { turnId: event.turnId }); + return; + } + if (event.reason === 'cancelled' && event.interruptReason === 'user_cancelled') { + this.write('cancelled', { turnId: event.turnId }); + } + }), + ); + subscription.add( + bus.subscribe(TurnStarted, () => { + this.turnStartedHere = true; + this.write(undefined); + }), + ); + subscription.add( + bus.subscribe(ContextUndone, (event) => { + if ( + event.fromTurnId !== undefined && + this.lastPersistedTurnId !== undefined && + this.lastPersistedTurnId < event.fromTurnId + ) { + return; + } + this.write(undefined); + }), + ); + this.seedFromWire(agentStates); + } + + private seedFromWire(agentStates: IAgentStateService | undefined): void { + if (agentStates === undefined || !agentStates.has(turnKey)) return; + const lastEnded = agentStates.get(turnKey).lastEnded; + if (lastEnded === undefined) return; + void this.metadataReady.then(() => { + if (this.turnStartedHere || this.lastPersisted !== undefined) return; + this.adoptLastEnded(lastEnded); + }); + } + + private adoptLastEnded(lastEnded: { turnId: number; reason: string }): void { + if (lastEnded.reason === 'completed' || lastEnded.reason === 'cancelled') { + this.write(lastEnded.reason, { touchUpdatedAt: false, turnId: lastEnded.turnId }); + return; + } + this.write('failed', { touchUpdatedAt: false, turnId: lastEnded.turnId }); + } + + private async reconcileAfterRestore(agentStates: IAgentStateService): Promise { + await this.metadataReady; + if (this.turnStartedHere) return; + if (!agentStates.has(turnKey)) return; + const lastEnded = agentStates.get(turnKey).lastEnded; + if (this.lastPersisted === undefined) { + if (lastEnded !== undefined) this.adoptLastEnded(lastEnded); + return; + } + if (lastEnded === undefined) { + this.write(undefined, { touchUpdatedAt: false }); + return; + } + if (this.lastPersistedTurnId === undefined) this.lastPersistedTurnId = lastEnded.turnId; + } + + private write( + outcome: SessionTurnOutcome | undefined, + opts?: { readonly touchUpdatedAt?: boolean; readonly turnId?: number }, + ): void { + if (outcome === this.lastPersisted) { + if (opts?.turnId !== undefined) this.lastPersistedTurnId = opts.turnId; + return; + } + this.adopted = true; + const previous = this.lastPersisted; + const previousTurnId = this.lastPersistedTurnId; + this.lastPersisted = outcome; + this.lastPersistedTurnId = + outcome === undefined ? undefined : (opts?.turnId ?? this.lastPersistedTurnId); + void this.metadata + .update({ lastTurnReason: outcome }, { touchUpdatedAt: opts?.touchUpdatedAt }) + .catch(() => { + if (this.lastPersisted === outcome) { + this.lastPersisted = previous; + this.lastPersistedTurnId = previousTurnId; + } + }); + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionOutcomeMirror, + SessionOutcomeMirror, + ScopeActivation.OnScopeCreated, + 'sessionActivity', +); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f0e8e6b67e786c3e4560276a3e1b0ecd154c801 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts @@ -0,0 +1,17 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; + +export interface ISessionAgentProfileCatalogSeed { + readonly _serviceBrand: undefined; + + readonly workspaceKey: string; +} + +export const ISessionAgentProfileCatalogSeed: ServiceIdentifier = + createDecorator('sessionAgentProfileCatalogSeed'); + +export function sessionAgentProfileCatalogSeed( + seed: ISessionAgentProfileCatalogSeed, +): ScopeSeed { + return [[ISessionAgentProfileCatalogSeed as ServiceIdentifier, seed]]; +} diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8e4d8be153ce4419b8d44db989e5a2cf03843d0 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog.ts @@ -0,0 +1,33 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; + +export interface AgentProfileSuppressedCandidate { + readonly sourceId: string; + readonly priority: number; + readonly reason: 'priority' | 'builtin-override-required'; +} + +export interface AgentProfileInspection { + readonly name: string; + readonly profile: AgentProfile; + readonly sourceId: string; + readonly priority: number; + readonly suppressed: readonly AgentProfileSuppressedCandidate[]; +} + +export interface ISessionAgentProfileCatalog { + readonly _serviceBrand: undefined; + + readonly ready: Promise; + readonly onDidChange: Event; + get(name: string): AgentProfile | undefined; + getDefault(): AgentProfile; + list(): readonly AgentProfile[]; + inspect(name: string): AgentProfileInspection | undefined; + load(): Promise; + reload(): Promise; +} + +export const ISessionAgentProfileCatalog = + createDecorator('sessionAgentProfileCatalog'); diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e5a53a99cec6505ef56c4c2d966f17eeb04b64d --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts @@ -0,0 +1,197 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { BugIndicatingError } from '#/errors'; +import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { + IAgentProfileRegistry, + type AgentProfileRegistration, +} from '#/app/agentProfileCatalog/agentProfileRegistry'; +import { BUILTIN_AGENT_PROFILE_SOURCE_ID } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; + +import { ISessionAgentProfileCatalogSeed } from './agentProfileCatalogSeed'; +import { + ISessionAgentProfileCatalog, + type AgentProfileInspection, + type AgentProfileSuppressedCandidate, +} from './sessionAgentProfileCatalog'; + +interface ProfileCandidate { + readonly profile: AgentProfile; + readonly sourceId: string; + readonly priority: number; +} + +export class SessionAgentProfileCatalogService + extends Disposable + implements ISessionAgentProfileCatalog +{ + declare readonly _serviceBrand: undefined; + + private merged = new Map(); + private inspections = new Map(); + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + + constructor( + @IAgentProfileRegistry private readonly registry: IAgentProfileRegistry, + @ISessionAgentProfileCatalogSeed private readonly seed: ISessionAgentProfileCatalogSeed, + @ILogService private readonly log: ILogService, + ) { + super(); + this.reproject(); + this._register( + this.registry.onDidChange((change) => { + if (change.workspaceKey !== undefined && change.workspaceKey !== this.seed.workspaceKey) { + return; + } + this.reproject(); + this.onDidChangeEmitter.fire(change.sourceId); + }), + ); + } + + get ready(): Promise { + return Promise.resolve(); + } + + get(name: string): AgentProfile | undefined { + return this.merged.get(name); + } + + getDefault(): AgentProfile { + const profile = this.get(DEFAULT_AGENT_PROFILE_NAME); + if (profile === undefined) { + throw new BugIndicatingError( + `Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`, + ); + } + return profile; + } + + list(): readonly AgentProfile[] { + return [...this.merged.values()]; + } + + inspect(name: string): AgentProfileInspection | undefined { + return this.inspections.get(name); + } + + async load(): Promise { + await this.ready; + } + + async reload(): Promise { + await this.ready; + this.reproject(); + this.onDidChangeEmitter.fire('catalog'); + } + + private relevantEntries(): AgentProfileRegistration[] { + const key = this.seed.workspaceKey; + return this.registry + .entries() + .filter((e) => e.workspaceKey === undefined || e.workspaceKey === key); + } + + private reproject(): void { + const merged = new Map(); + const inspections = new Map(); + const entries = this.relevantEntries(); + + const builtinEntry = entries.find((e) => e.sourceId === BUILTIN_AGENT_PROFILE_SOURCE_ID); + if (builtinEntry !== undefined) { + for (const profile of builtinEntry.contribution.profiles) { + merged.set(profile.name, profile); + inspections.set(profile.name, { + name: profile.name, + profile, + sourceId: builtinEntry.sourceId, + priority: builtinEntry.priority, + suppressed: [], + }); + } + } + + const fileCandidates = new Map(); + const ordered = entries + .filter((e) => e.sourceId !== BUILTIN_AGENT_PROFILE_SOURCE_ID) + .toSorted((a, b) => b.priority - a.priority); + for (const entry of ordered) { + const entryProfiles = new Map(); + for (const profile of entry.contribution.profiles) { + entryProfiles.set(profile.name, profile); + } + for (const profile of entryProfiles.values()) { + const candidates = fileCandidates.get(profile.name) ?? []; + candidates.push({ + profile, + sourceId: entry.sourceId, + priority: entry.priority, + }); + fileCandidates.set(profile.name, candidates); + } + } + + for (const candidates of fileCandidates.values()) { + const suppressed: AgentProfileSuppressedCandidate[] = []; + let winner = false; + for (const candidate of candidates) { + if (merged.has(candidate.profile.name) && candidate.profile.override !== true) { + this.log.warn( + `agent file profile "${candidate.profile.name}" ignored: a same-name builtin profile exists; set "override: true" in the frontmatter to replace it`, + ); + suppressed.push({ + sourceId: candidate.sourceId, + priority: candidate.priority, + reason: 'builtin-override-required', + }); + continue; + } + const replaced = merged.get(candidate.profile.name); + const effective = + candidate.profile.subagents === undefined && replaced?.subagents !== undefined + ? { ...candidate.profile, subagents: replaced.subagents } + : candidate.profile; + merged.set(candidate.profile.name, effective); + inspections.set(candidate.profile.name, { + name: candidate.profile.name, + profile: effective, + sourceId: candidate.sourceId, + priority: candidate.priority, + suppressed: [ + ...suppressed, + ...candidates.slice(candidates.indexOf(candidate) + 1).map((rest) => ({ + sourceId: rest.sourceId, + priority: rest.priority, + reason: 'priority' as const, + })), + ], + }); + winner = true; + break; + } + if (!winner && suppressed.length > 0) { + const name = candidates[0]?.profile.name; + const existing = name === undefined ? undefined : inspections.get(name); + if (existing !== undefined) { + inspections.set(existing.name, { ...existing, suppressed }); + } + } + } + + this.merged = merged; + this.inspections = inspections; + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionAgentProfileCatalog, + SessionAgentProfileCatalogService, + ScopeActivation.OnScopeCreated, + 'sessionAgentProfileCatalog', +); diff --git a/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts b/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts new file mode 100644 index 0000000000000000000000000000000000000000..c79407d999ca3354522a2d1a2b5df3639da47edc --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts @@ -0,0 +1,57 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; + +export interface SessionWorkspaceAssociationSnapshot { + readonly sessionId: string; + readonly workspaceId: string; + readonly cwd: string; +} + +export interface ISessionContext { + readonly _serviceBrand: undefined; + + readonly sessionId: string; + readonly workspaceId: string; + readonly sessionDir: string; + readonly metaScope: string; + readonly cwd: string; + scope(subKey?: string): string; +} + +export const ISessionContext: ServiceIdentifier = + createDecorator('sessionContext'); + +export function snapshotSessionWorkspaceAssociation( + context: ISessionContext, +): SessionWorkspaceAssociationSnapshot { + return { + sessionId: context.sessionId, + workspaceId: context.workspaceId, + cwd: context.cwd, + }; +} + +export function sessionContextSeed(ctx: ISessionContext): ScopeSeed { + return [[ISessionContext as ServiceIdentifier, ctx]]; +} + +export function makeSessionContext(input: { + readonly sessionId: string; + readonly workspaceId: string; + readonly sessionDir: string; + readonly sessionScope: string; + readonly cwd: string; + readonly metaScope?: string; +}): ISessionContext { + const { sessionScope } = input; + return { + _serviceBrand: undefined, + sessionId: input.sessionId, + workspaceId: input.workspaceId, + sessionDir: input.sessionDir, + metaScope: input.metaScope ?? sessionScope, + cwd: input.cwd, + scope: (subKey?: string): string => + subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`, + }; +} diff --git a/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicy.ts b/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicy.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cc649d77ba53dda6f2e97ecc4f66b0fdd90eab0 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicy.ts @@ -0,0 +1,16 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event, IWaitUntil } from '#/_base/event'; + +export type SessionToolPolicyChangedEvent = IWaitUntil; + +export interface ISessionToolPolicy { + readonly _serviceBrand: undefined; + readonly ready: Promise; + readonly onDidChange: Event; + + disabledTools(): readonly string[]; + setDisabledTools(names: readonly string[]): Promise; +} + +export const ISessionToolPolicy: ServiceIdentifier = + createDecorator('sessionToolPolicy'); diff --git a/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts b/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts new file mode 100644 index 0000000000000000000000000000000000000000..12a1fbcfcba69b5f7a313917d49a31becdbc8ab5 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts @@ -0,0 +1,95 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { AsyncEmitter, type Event } from '#/_base/event'; +import { defineState } from '#/state/state'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionStateService } from '#/session/state/sessionState'; + +import { + ISessionToolPolicy, + type SessionToolPolicyChangedEvent, +} from './sessionToolPolicy'; + +interface SessionToolPolicyState { + readonly disabledTools: readonly string[]; +} + +export const sessionToolPolicyStateKey = defineState('sessionToolPolicy.state', () => ({ + disabledTools: [], +})); + +const STATE_KEY = 'state.json'; + +export class SessionToolPolicyService extends Disposable implements ISessionToolPolicy { + declare readonly _serviceBrand: undefined; + readonly ready: Promise; + readonly onDidChange: Event; + + private readonly changeEmitter = this._register( + new AsyncEmitter(), + ); + private readonly scope: string; + private updateQueue: Promise = Promise.resolve(); + + constructor( + @ISessionStateService private readonly states: ISessionStateService, + @ISessionContext sessionContext: ISessionContext, + @IAtomicDocumentStore private readonly store: IAtomicDocumentStore, + ) { + super(); + this.states.contributeState(sessionToolPolicyStateKey); + this.scope = sessionContext.scope('tool-policy'); + this.onDidChange = this.changeEmitter.event; + this.ready = this.load(); + } + + private get state(): SessionToolPolicyState { + return this.states.get(sessionToolPolicyStateKey); + } + + private set state(value: SessionToolPolicyState) { + this.states.set(sessionToolPolicyStateKey, value); + } + + disabledTools(): readonly string[] { + return this.state.disabledTools; + } + + setDisabledTools(names: readonly string[]): Promise { + const run = this.updateQueue.then(() => this.replace(names)); + this.updateQueue = run.catch(() => {}); + return run; + } + + private async load(): Promise { + const stored = await this.store.get(this.scope, STATE_KEY); + if (stored !== undefined) { + this.state = { disabledTools: [...new Set(stored.disabledTools)] }; + } + } + + private async replace(names: readonly string[]): Promise { + await this.ready; + const disabledTools = [...new Set(names)]; + if ( + disabledTools.length === this.state.disabledTools.length && + disabledTools.every((name, index) => name === this.state.disabledTools[index]) + ) { + return; + } + const nextState = { disabledTools }; + await this.store.set(this.scope, STATE_KEY, nextState); + this.state = nextState; + await this.changeEmitter.fireAsync({}, new AbortController().signal); + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionToolPolicy, + SessionToolPolicyService, + ScopeActivation.OnScopeCreated, + 'sessionToolPolicy', +); diff --git a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a776945d08f56339cf6a25c6f69dd8353d39b85 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts @@ -0,0 +1,17 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import type { Event } from '#/_base/event'; + +export interface ISessionToolPolicyGate { + readonly _serviceBrand: undefined; + + readonly disabledTools: readonly string[]; + readonly onDidChange: Event; +} + +export const ISessionToolPolicyGate: ServiceIdentifier = + createDecorator('sessionToolPolicyGate'); + +export function sessionToolPolicyGateSeed(gate: ISessionToolPolicyGate): ScopeSeed { + return [[ISessionToolPolicyGate as ServiceIdentifier, gate]]; +} diff --git a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts new file mode 100644 index 0000000000000000000000000000000000000000..06d1a9e2ddb39c36fc45f2729172b498f6e16a62 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts @@ -0,0 +1,20 @@ +import { Event } from '#/_base/event'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +import { ISessionToolPolicyGate } from './sessionToolPolicyGate'; + +export class NoopSessionToolPolicyGate implements ISessionToolPolicyGate { + declare readonly _serviceBrand: undefined; + + readonly disabledTools: readonly string[] = []; + readonly onDidChange = Event.None as Event; +} + +registerScopedService( + LifecycleScope.Session, + ISessionToolPolicyGate, + NoopSessionToolPolicyGate, + ScopeActivation.OnScopeCreated, + 'sessionToolPolicyGate', +); diff --git a/packages/agent-core-v2/src/session/terminal/terminalService.ts b/packages/agent-core-v2/src/session/terminal/terminalService.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d778229ffa63bce440aa8a2f97e79d203279e60 --- /dev/null +++ b/packages/agent-core-v2/src/session/terminal/terminalService.ts @@ -0,0 +1,265 @@ +import { randomUUID } from 'node:crypto'; + +import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import type { + CreateTerminalRequest, + Terminal, + TerminalAttachOptions, + TerminalAttachSink, + TerminalExitMessage, + TerminalFrame, + TerminalOutputMessage, + TerminalProcess, +} from '#/os/interface/terminal'; +import { ErrorCodes, Error2 } from '#/errors'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IRuntimeResolver } from '#/workspace/workspaceInstance/workspaceInstanceManager'; + +import type { RuntimeLease } from '#/runtime/runtime'; +import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; + +const DEFAULT_COLS = 80; +const DEFAULT_ROWS = 24; +const DEFAULT_MAX_BUFFERED_FRAMES = 2000; + +interface TerminalRecord { + terminal: Terminal; + process: TerminalProcess; + lease: RuntimeLease; + sinks: Map; + buffer: TerminalFrame[]; + nextSeq: number; + disposables: IDisposable[]; + closed: boolean; +} + +export interface ISessionTerminalService { + readonly _serviceBrand: undefined; + + create(input: CreateTerminalRequest): Promise; + list(): Promise; + get(terminalId: string): Promise; + attach( + terminalId: string, + sink: TerminalAttachSink, + options?: TerminalAttachOptions, + ): Promise<{ replayed: number }>; + detach(terminalId: string, sinkId: string): void; + detachAllForSink(sinkId: string): void; + write(terminalId: string, data: string): Promise; + resize(terminalId: string, cols: number, rows: number): Promise; + close(terminalId: string): Promise<{ closed: true }>; +} + +export const ISessionTerminalService: ServiceIdentifier = + createDecorator('sessionTerminalService'); + +export class SessionTerminalService extends Disposable implements ISessionTerminalService { + declare readonly _serviceBrand: undefined; + + private readonly records = new Map(); + + constructor( + @IRuntimeResolver private readonly runtimeResolver: IRuntimeResolver, + @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @ISessionContext private readonly sessionContext: ISessionContext, + ) { + super(); + } + + async create(input: CreateTerminalRequest): Promise { + const cols = input.cols ?? DEFAULT_COLS; + const rows = input.rows ?? DEFAULT_ROWS; + const lease = this.runtimeResolver.acquire( + { workspaceId: this.sessionContext.workspaceId, runtimeId: input.runtime_id }, + ['terminal'], + ); + const view = new RuntimeWorkspaceView(lease.runtime, this.workspace); + const cwd = input.cwd === undefined ? view.workDir : view.assertAllowed(view.resolve(input.cwd)); + const shell = input.shell ?? lease.runtime.environment.shellPath; + let process: TerminalProcess; + try { + process = await lease.runtime.terminal!.spawn({ cwd, shell, cols, rows }); + lease.track({ dispose: () => process.kill() }); + } catch (error) { + lease.dispose(); + throw error; + } + const terminal: Terminal = { + id: `term_${randomUUID()}`, + session_id: this.sessionContext.sessionId, + cwd, + shell, + cols, + rows, + status: 'running', + created_at: new Date().toISOString(), + }; + const record: TerminalRecord = { + terminal, + process, + lease, + sinks: new Map(), + buffer: [], + nextSeq: 0, + disposables: [], + closed: false, + }; + record.disposables.push( + process.onProcessData((data) => this.onData(record, data)), + process.onProcessExit((event) => this.onExit(record, event.exitCode)), + ); + this.records.set(terminal.id, record); + return { ...terminal }; + } + + list(): Promise { + return Promise.resolve( + [...this.records.values()].map((record) => ({ ...record.terminal })), + ); + } + + async get(terminalId: string): Promise { + return { ...this.requireRecord(terminalId).terminal }; + } + + async attach( + terminalId: string, + sink: TerminalAttachSink, + options: TerminalAttachOptions = {}, + ): Promise<{ replayed: number }> { + const record = this.requireRecord(terminalId); + record.sinks.set(sink.id, sink); + const sinceSeq = options.sinceSeq ?? 0; + const replay = record.buffer.filter((frame) => frameSeq(frame) > sinceSeq); + for (const frame of replay) { + sink.send(frame); + } + return { replayed: replay.length }; + } + + detach(terminalId: string, sinkId: string): void { + this.records.get(terminalId)?.sinks.delete(sinkId); + } + + detachAllForSink(sinkId: string): void { + for (const record of this.records.values()) { + record.sinks.delete(sinkId); + } + } + + async write(terminalId: string, data: string): Promise { + const record = this.requireRecord(terminalId); + record.process.write(data); + } + + async resize(terminalId: string, cols: number, rows: number): Promise { + const record = this.requireRecord(terminalId); + record.terminal = { ...record.terminal, cols, rows }; + record.process.resize(cols, rows); + } + + async close(terminalId: string): Promise<{ closed: true }> { + const record = this.requireRecord(terminalId); + if (!record.closed) { + record.closed = true; + record.process.kill(); + this.markExited(record, null); + } + return { closed: true }; + } + + override dispose(): void { + for (const record of this.records.values()) { + disposeAll(record.disposables); + record.lease.dispose(); + try { + record.process.kill(); + } catch { + } + } + this.records.clear(); + super.dispose(); + } + + private requireRecord(terminalId: string): TerminalRecord { + const record = this.records.get(terminalId); + if (record === undefined) { + throw new Error2( + ErrorCodes.TERMINAL_NOT_FOUND, + `terminal ${terminalId} does not exist in session ${this.sessionContext.sessionId}`, + ); + } + return record; + } + + private onData(record: TerminalRecord, data: string): void { + const frame: TerminalOutputMessage = { + type: 'terminal_output', + seq: ++record.nextSeq, + session_id: record.terminal.session_id, + terminal_id: record.terminal.id, + timestamp: new Date().toISOString(), + payload: { data }, + }; + this.pushFrame(record, frame); + } + + private onExit(record: TerminalRecord, exitCode: number | null): void { + this.markExited(record, exitCode); + } + + private markExited(record: TerminalRecord, exitCode: number | null): void { + if (record.terminal.status === 'exited') return; + record.closed = true; + record.terminal = { + ...record.terminal, + status: 'exited', + exited_at: new Date().toISOString(), + exit_code: exitCode, + }; + const frame: TerminalExitMessage = { + type: 'terminal_exit', + session_id: record.terminal.session_id, + terminal_id: record.terminal.id, + timestamp: new Date().toISOString(), + payload: { exit_code: exitCode }, + }; + this.pushFrame(record, frame); + disposeAll(record.disposables); + record.disposables = []; + record.lease.dispose(); + } + + private pushFrame(record: TerminalRecord, frame: TerminalFrame): void { + record.buffer.push(frame); + if (record.buffer.length > DEFAULT_MAX_BUFFERED_FRAMES) { + record.buffer.splice(0, record.buffer.length - DEFAULT_MAX_BUFFERED_FRAMES); + } + for (const sink of record.sinks.values()) { + sink.send(frame); + } + } +} + +function disposeAll(items: Iterable): void { + for (const item of items) { + item.dispose(); + } +} + +function frameSeq(frame: TerminalFrame): number { + return frame.type === 'terminal_output' ? frame.seq : Number.MAX_SAFE_INTEGER; +} + +registerScopedService( + LifecycleScope.Session, + ISessionTerminalService, + SessionTerminalService, + ScopeActivation.OnScopeCreated, + 'terminal', +); diff --git a/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2b9f7497cfdb7662d037fb75d9039c2a2e138f8 --- /dev/null +++ b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts @@ -0,0 +1,43 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import type { + ContextSize, + TokenCountingRequest, + TokenCountingStrategy, +} from '#/agent/tokenCounting/tokenCounting'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ToolDescription as Tool } from '#human/llm/message'; +import type { TokenUsage } from '#human/llm/usage'; + +export interface TokenCountingRebaseInput { + readonly length: number; + readonly tokens: number; + readonly measured: boolean; +} + +export interface ISessionTokenCountingService { + readonly _serviceBrand: undefined; + + readonly strategy: TokenCountingStrategy; + + get(agent: AgentContext, start?: number, end?: number): ContextSize; + measured( + agent: AgentContext, + input: readonly Message[], + output: readonly Message[], + usage: TokenUsage, + ): void; + latestMeasured(agent: AgentContext): number; + statusSize(agent: AgentContext): number; + recordTruncation(agent: AgentContext, cutIndex: number): void; + rebase(agent: AgentContext, input: TokenCountingRebaseInput): void; + requestSize(request: TokenCountingRequest): number; + + estimateText(text: string): number; + estimateMessage(message: Message): number; + estimateMessages(messages: readonly Message[]): number; + estimateTools(tools: readonly Tool[]): number; +} + +export const ISessionTokenCountingService = + createDecorator('sessionTokenCountingService'); diff --git a/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCountingService.ts b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCountingService.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e6f8bd2af29398f809417549d7fc444383da43e --- /dev/null +++ b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCountingService.ts @@ -0,0 +1,125 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import { agentSpaceOf } from '#/agent/agentContext/agentSpace'; +import { TurnEnded } from '#/agent/loop/turnOps'; +import { IConfigService } from '#/app/config/config'; +import { ISessionEventBus } from '#/app/event/eventBus'; +import { + TOKEN_COUNTING_SECTION, + type TokenCountingConfig, +} from '#/agent/tokenCounting/configSection'; +import type { + ContextSize, + TokenCountingRequest, + TokenCountingStrategy, +} from '#/agent/tokenCounting/tokenCounting'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ToolDescription as Tool } from '#human/llm/message'; +import { + estimateTokens, + estimateTokensForMessage, + estimateTokensForMessages, + estimateTokensForTools, +} from '#/llm-adapter/contract/tokens'; +import type { TokenUsage } from '#human/llm/usage'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; + +import { + ISessionTokenCountingService, + type TokenCountingRebaseInput, +} from './sessionTokenCounting'; +import { TokenCountingAgentModelDefinition } from './tokenCountingAgentModel'; + +export class SessionTokenCountingService extends Disposable implements ISessionTokenCountingService { + declare readonly _serviceBrand: undefined; + + constructor( + @IConfigService private readonly config: IConfigService, + @ISessionEventBus eventBus: ISessionEventBus, + @IAgentLifecycleService agentLifecycle: IAgentLifecycleService, + ) { + super(); + this._register( + eventBus.subscribe(TurnEnded, (event) => { + const agent = agentLifecycle.get(event.agentId); + if (agent === undefined) return; + void agentSpaceOf(agent).use( + TokenCountingAgentModelDefinition, + (model) => model.recordTurn(event.turnId, this.strategy), + ); + }), + ); + } + + get strategy(): TokenCountingStrategy { + return ( + this.config.get(TOKEN_COUNTING_SECTION)?.strategy ?? + 'measured+estimated' + ); + } + + get(agent: AgentContext, start?: number, end?: number): ContextSize { + return agentSpaceOf(agent).use(TokenCountingAgentModelDefinition, (model) => + model.get(start, end), + ); + } + + measured( + agent: AgentContext, + input: readonly Message[], + output: readonly Message[], + usage: TokenUsage, + ): void { + void agentSpaceOf(agent).use(TokenCountingAgentModelDefinition, (model) => + model.measured(input, output, usage), + ); + } + + latestMeasured(agent: AgentContext): number { + return agentSpaceOf(agent).use(TokenCountingAgentModelDefinition, (model) => + model.latestMeasured(), + ); + } + + statusSize(agent: AgentContext): number { + return agentSpaceOf(agent).use(TokenCountingAgentModelDefinition, (model) => + model.statusSize(this.strategy), + ); + } + + recordTruncation(agent: AgentContext, cutIndex: number): void { + void agentSpaceOf(agent).use(TokenCountingAgentModelDefinition, (model) => + model.recordTruncation(cutIndex), + ); + } + + rebase(agent: AgentContext, input: TokenCountingRebaseInput): void { + void agentSpaceOf(agent).use(TokenCountingAgentModelDefinition, (model) => + model.rebase(input), + ); + } + + requestSize(request: TokenCountingRequest): number { + return ( + this.estimateText(request.systemPrompt) + + this.estimateTools(request.tools) + + this.estimateMessages(request.messages) + ); + } + + estimateText(text: string): number { + return estimateTokens(text); + } + + estimateMessage(message: Message): number { + return estimateTokensForMessage(message); + } + + estimateMessages(messages: readonly Message[]): number { + return estimateTokensForMessages(messages); + } + + estimateTools(tools: readonly Tool[]): number { + return estimateTokensForTools(tools); + } +} diff --git a/packages/agent-core-v2/src/session/usage/sessionUsageService.ts b/packages/agent-core-v2/src/session/usage/sessionUsageService.ts new file mode 100644 index 0000000000000000000000000000000000000000..426abf29cbdb0cf506b2a3895f920e2794501499 --- /dev/null +++ b/packages/agent-core-v2/src/session/usage/sessionUsageService.ts @@ -0,0 +1,34 @@ +import { Service } from '#/_base/di/service'; +import { Emitter, type Event } from '#/_base/event'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import { agentSpaceOf } from '#/agent/agentContext/agentSpace'; +import type { AgentLLMRequestSource } from '#/agent/llmRequester/llmRequester'; +import type { UsageRecordedContext, UsageStatus } from '#/agent/usage/usage'; +import { copyUsage } from '#/agent/usage/usageOps'; +import type { TokenUsage } from '#human/llm/usage'; + +import { ISessionUsageService } from './sessionUsage'; +import { UsageAgentModelDefinition } from './usageAgentModel'; + +export class SessionUsageService extends Service implements ISessionUsageService { + declare readonly _serviceBrand: undefined; + + private readonly onDidRecordEmitter = this._register(new Emitter()); + readonly onDidRecord: Event = this.onDidRecordEmitter.event; + + async record( + agent: AgentContext, + model: string, + usage: TokenUsage, + source?: AgentLLMRequestSource, + ): Promise { + const firstRecord = await agentSpaceOf(agent).use(UsageAgentModelDefinition, (m) => + m.record({ model, usage, source }), + ); + this.onDidRecordEmitter.fire({ agent, model, usage: copyUsage(usage), source, firstRecord }); + } + + status(agent: AgentContext): UsageStatus { + return agentSpaceOf(agent).use(UsageAgentModelDefinition, (m) => m.status()); + } +} diff --git a/packages/agent-core-v2/src/session/usage/usageAgentModel.ts b/packages/agent-core-v2/src/session/usage/usageAgentModel.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd7dbdffb48776c487fd37d83f874458818abe2d --- /dev/null +++ b/packages/agent-core-v2/src/session/usage/usageAgentModel.ts @@ -0,0 +1,90 @@ +import { z } from 'zod'; + +import type { AgentLLMRequestSource } from '#/agent/llmRequester/llmRequester'; +import type { UsageStatus } from '#/agent/usage/usage'; +import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; +import { + copyUsage, + UsageRecord, + type UsageModelState, + type UsageRecordScope, +} from '#/agent/usage/usageOps'; +import { addUsage, type TokenUsage } from '#human/llm/usage'; +import { AgentModel, defineAgentModel, type AgentModelContext } from '#/state/agentModel'; + +export interface UsageRecordInput { + readonly model: string; + readonly usage: TokenUsage; + readonly source?: AgentLLMRequestSource; +} + +export class UsageAgentModel extends AgentModel { + private currentTurnId: number | undefined; + private currentTurn: TokenUsage | undefined; + + constructor(context: AgentModelContext) { + super(context); + this.on(UsageRecord, (event) => { + const current = this.state.byModel[event.model]; + this.state.byModel[event.model] = + current === undefined ? copyUsage(event.usage) : addUsage(current, event.usage); + }); + } + + record(input: UsageRecordInput): Promise { + const firstRecord = Object.keys(this.state.byModel).length === 0; + const usageScope: UsageRecordScope = input.source?.type === 'turn' ? 'turn' : 'session'; + const recorded = this.emit( + new UsageRecord({ + agentId: this.agent.agentId, + model: input.model, + usage: input.usage, + usageScope, + }), + ); + const turnId = input.source?.type === 'turn' ? input.source.turnId : undefined; + if (turnId !== undefined) { + if (this.currentTurnId !== turnId) { + this.currentTurnId = turnId; + this.currentTurn = copyUsage(input.usage); + } else { + this.currentTurn = + this.currentTurn === undefined + ? copyUsage(input.usage) + : addUsage(this.currentTurn, input.usage); + } + } + const notified = this.emit( + new AgentStatusUpdated({ agentId: this.agent.agentId, usage: this.status() }), + ); + return recorded.then(() => notified).then(() => firstRecord); + } + + status(): UsageStatus { + const byModel = Object.fromEntries( + Object.entries(this.state.byModel).map(([model, usage]) => [model, copyUsage(usage)]), + ); + const hasByModel = Object.keys(byModel).length > 0; + let total: TokenUsage | undefined; + if (hasByModel) { + for (const usage of Object.values(byModel)) { + total = total === undefined ? copyUsage(usage) : addUsage(total, usage); + } + } + return { + byModel: hasByModel ? byModel : undefined, + total, + currentTurn: this.currentTurn === undefined ? undefined : copyUsage(this.currentTurn), + }; + } +} + +export const UsageAgentModelDefinition = defineAgentModel({ + id: 'usage', + model: UsageAgentModel, + state: { + initial: (): UsageModelState => ({ byModel: {} }), + schema: z.custom(), + }, + events: [UsageRecord], +}); diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa330b49139e906585ad9f755341a7732bb0189c --- /dev/null +++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContext.ts @@ -0,0 +1,16 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export type PathAccessOperation = 'read' | 'write' | 'execute'; + +export interface ISessionWorkspaceContext { + readonly _serviceBrand: undefined; + + readonly workDir: string; + readonly additionalDirs: readonly string[]; + resolve(rel: string): string; + isWithin(absPath: string): boolean; + assertAllowed(absPath: string, op: PathAccessOperation): string; +} + +export const ISessionWorkspaceContext: ServiceIdentifier = + createDecorator('sessionWorkspaceContext'); diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts new file mode 100644 index 0000000000000000000000000000000000000000..0af6483124bace9746f416ef35650696472d498e --- /dev/null +++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts @@ -0,0 +1,92 @@ +import { isAbsolute, relative, resolve } from 'node:path'; + +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/state/state'; +import { ErrorCodes, Error2 } from '#/errors'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; + +import { ISessionWorkspaceContext, type PathAccessOperation } from './workspaceContext'; + +export const workspaceContextWorkDirKey = defineState('workspaceContext.workDir', () => ''); +export const workspaceContextAdditionalDirsKey = defineState( + 'workspaceContext.additionalDirs', + () => [], +); + +export class SessionWorkspaceContextService extends Service implements ISessionWorkspaceContext { + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionStateService private readonly states: ISessionStateService, + @ISessionContext ctx: ISessionContext, + @ISessionWorkspaceInfo workspaceInfo: ISessionWorkspaceInfo, + ) { + super(); + this.states.contributeState(workspaceContextWorkDirKey); + this.states.contributeState(workspaceContextAdditionalDirsKey); + this.states.set(workspaceContextWorkDirKey, resolve(ctx.cwd)); + this.states.set(workspaceContextAdditionalDirsKey, [ + ...new Set(workspaceInfo.additionalDirs.map((d) => resolve(d))), + ]); + this._register( + workspaceInfo.onDidChange(() => { + this.states.set(workspaceContextAdditionalDirsKey, [ + ...new Set(workspaceInfo.additionalDirs.map((d) => resolve(d))), + ]); + }), + ); + } + + private get _workDir(): string { + return this.states.get(workspaceContextWorkDirKey); + } + + private get _additionalDirs(): string[] { + return this.states.get(workspaceContextAdditionalDirsKey); + } + + get workDir(): string { + return this._workDir; + } + + get additionalDirs(): readonly string[] { + return this._additionalDirs; + } + + resolve(rel: string): string { + return isAbsolute(rel) ? resolve(rel) : resolve(this._workDir, rel); + } + + isWithin(absPath: string): boolean { + const target = resolve(absPath); + if (target === this._workDir) return true; + const rel = relative(this._workDir, target); + if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) return true; + return this._additionalDirs.some((dir) => { + const r = relative(dir, target); + return r === '' || (!r.startsWith('..') && !isAbsolute(r)); + }); + } + + assertAllowed(absPath: string, op: PathAccessOperation): string { + const target = this.resolve(absPath); + if (!this.isWithin(target)) { + throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `Path outside workspace (${op}): ${target}`, { + details: { op, path: target }, + }); + } + return target; + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionWorkspaceContext, + SessionWorkspaceContextService, + ScopeActivation.OnScopeCreated, + 'workspaceContext', +); diff --git a/packages/agent-core-v2/src/state/agentModel.ts b/packages/agent-core-v2/src/state/agentModel.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb51c3d87d8db9c30e1cdf2bd00f4a246fbd75f1 --- /dev/null +++ b/packages/agent-core-v2/src/state/agentModel.ts @@ -0,0 +1,196 @@ +import type { z } from 'zod'; +import type { Draft } from 'immer'; + +import { collection } from '#/_base/di/collection'; +import { BugIndicatingError } from '#/_base/errors/errors'; +import type { StateKey } from '#/_base/state/stateRegistry'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import { registerEvent2Class, type Event2, type Event2Class } from '#/app/event/event2'; + +import type { FoldContext } from './state'; + +export interface DomainResourceRuntime { + dispose(): void | Promise; + abort?(reason?: unknown): void; +} + +export interface AgentModelBridge { + dispatch(event: Event2): Promise; + readLegacy(key: StateKey): unknown; + initialState(): unknown; +} + +export interface AgentModelContext { + readonly agent: AgentContext; + readonly bridge: AgentModelBridge; +} + +interface ModelWindow { + readonly draft: unknown; + readonly ctx: FoldContext; + replaced: boolean; + replacement: unknown; +} + +export abstract class AgentModel implements DomainResourceRuntime { + private committedState: S; + private window: ModelWindow | undefined; + private readonly appliers = new Map, (event: any) => void>(); + private sealed = false; + + readonly agent: AgentContext; + private readonly bridge: AgentModelBridge; + + constructor(context: AgentModelContext) { + this.agent = context.agent; + this.bridge = context.bridge; + this.committedState = context.bridge.initialState() as S; + } + + protected get state(): Draft { + const window = this.window; + return (window !== undefined ? window.draft : this.committedState) as Draft; + } + + protected set state(value: S) { + if (this.window === undefined) { + throw new BugIndicatingError( + `Model '${this.constructor.name}' can only replace state inside an applier`, + ); + } + this.window.replaced = true; + this.window.replacement = value; + } + + protected on>(cls: Event2Class, applier: (event: E) => void): void { + if (this.sealed) { + throw new BugIndicatingError( + `Model '${this.constructor.name}' cannot register appliers after construction`, + ); + } + if (this.appliers.has(cls)) { + throw new BugIndicatingError( + `Model '${this.constructor.name}' already applies event '${cls.type}'`, + ); + } + this.appliers.set(cls, applier as (event: any) => void); + } + + protected emit(event: Event2): Promise { + const window = this.window; + if (window !== undefined) { + window.ctx.emit(event); + return Promise.resolve(); + } + return this.bridge.dispatch(event); + } + + protected readLegacy(key: StateKey): T { + return this.bridge.readLegacy(key) as T; + } + + onUndo?(count: number): void; + + dispose(): void | Promise {} + + _seal(): void { + this.sealed = true; + } + + _appliersTable(): ReadonlyMap, (event: any) => void> { + return this.appliers; + } + + _state(): S { + return this.committedState; + } + + _commitState(next: S): void { + this.committedState = next; + } + + _enterWindow(draft: S, ctx: FoldContext): void { + this.window = { draft, ctx, replaced: false, replacement: undefined }; + } + + _exitWindow(): { readonly replaced: boolean; readonly replacement: unknown } { + const window = this.window; + this.window = undefined; + return { replaced: window?.replaced ?? false, replacement: window?.replacement }; + } +} + +export interface AgentModelStateSpec { + readonly initial: () => S; + readonly schema: z.ZodType; +} + +export interface AgentModelDefinition = AgentModel> { + readonly id: string; + readonly model: new (context: AgentModelContext) => M; + readonly state: AgentModelStateSpec; + readonly events: readonly Event2Class[]; + readonly undoable: boolean; +} + +export interface AgentModelDefinitionInput> { + readonly id: string; + readonly model: new (context: AgentModelContext) => M; + readonly state: AgentModelStateSpec; + readonly events: readonly Event2Class[]; + readonly undoable?: boolean; +} + +const AGENT_MODEL_DEFINITIONS = new Map>(); + +export function defineAgentModel>( + input: AgentModelDefinitionInput, +): AgentModelDefinition { + if (AGENT_MODEL_DEFINITIONS.has(input.id)) { + throw new BugIndicatingError(`Agent model '${input.id}' is already defined`); + } + for (const cls of input.events) { + if (!cls.durable) { + throw new BugIndicatingError( + `Agent model '${input.id}' cannot apply non-durable event '${cls.type}'`, + ); + } + registerEvent2Class(cls); + } + const definition: AgentModelDefinition = Object.freeze({ + id: input.id, + model: input.model, + state: input.state, + events: Object.freeze([...input.events]), + undoable: input.undoable ?? false, + }); + AGENT_MODEL_DEFINITIONS.set(definition.id, definition); + return definition; +} + +export function agentModelDefinitions(): readonly AgentModelDefinition[] { + return [...AGENT_MODEL_DEFINITIONS.values()]; +} + +export const AgentModelContribution = collection>('agent-model', { + validate: (value, existing) => { + if (existing.some((definition) => definition.id === value.id)) { + throw new Error(`Agent model '${value.id}' already has an active provider`); + } + }, +}); + +export interface SessionModelDefinition { + readonly id: string; + readonly state: AgentModelStateSpec; + readonly events: readonly Event2Class[]; + readonly undoable: boolean; +} + +export const SessionModelContribution = collection('session-model', { + validate: (value, existing) => { + if (existing.some((definition) => definition.id === value.id)) { + throw new Error(`Session model '${value.id}' already has an active provider`); + } + }, +}); diff --git a/packages/agent-core-v2/src/state/errors.ts b/packages/agent-core-v2/src/state/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb2384c54769ecb3227e848fbf4d8b74a1e5df6a --- /dev/null +++ b/packages/agent-core-v2/src/state/errors.ts @@ -0,0 +1,41 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; +import { Error2, type Error2Options } from '#/_base/errors/errors'; + +export const StateErrors = { + codes: { + STATE_DUPLICATE_FOLD: 'state.duplicate_fold', + STATE_DURABILITY_MISMATCH: 'state.durability_mismatch', + STATE_CYCLE: 'state.cycle', + }, + info: { + 'state.duplicate_fold': { + title: 'Duplicate state fold', + retryable: false, + public: true, + action: 'A state registered two folds for the same event; merge them.', + }, + 'state.durability_mismatch': { + title: 'Transient state folds durable event', + retryable: false, + public: true, + action: 'A non-durable state cannot fold a durable event; mark the state durable.', + }, + 'state.cycle': { + title: 'Event dispatch cycle', + retryable: false, + public: true, + action: 'A subscriber re-dispatches endlessly; break the event cycle.', + }, + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(StateErrors); + +export type StateErrorCode = (typeof StateErrors.codes)[keyof typeof StateErrors.codes]; + +export class StateError extends Error2 { + constructor(code: StateErrorCode, message: string, options?: Error2Options) { + super(code, message, options); + this.name = 'StateError'; + } +} diff --git a/packages/agent-core-v2/src/state/eventDispatcher.ts b/packages/agent-core-v2/src/state/eventDispatcher.ts new file mode 100644 index 0000000000000000000000000000000000000000..41002e09e3303da7228ddaac3a169a1c226f7347 --- /dev/null +++ b/packages/agent-core-v2/src/state/eventDispatcher.ts @@ -0,0 +1,41 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IDisposable } from '#/_base/di/lifecycle'; +import type { Event2, Event2Class } from '#/app/event/event2'; +import type { Hooks } from '#/hooks'; + +import type { StateFold } from './state'; + +export type EventDispatcherHooks = { + readonly onDidRestore: Record; +}; + +export type RestorePhase = 'new' | 'restoring' | 'ready' | 'failed'; + +export interface DurableAgentRuntimeParticipant { + readonly id: string; + readonly events: readonly Event2Class[]; + readonly undoable: boolean; + readonly transition: StateFold; + getState(): State; + commit(state: State): void; +} + +export interface DurableRuntimeParticipantHost { + attach(participant: DurableAgentRuntimeParticipant): IDisposable; +} + +export interface IEventDispatcher extends DurableRuntimeParticipantHost { + readonly _serviceBrand: undefined; + + readonly hooks: Hooks; + + readonly restorePhase: RestorePhase; + + dispatch(event: Event2): Promise; + attachLate(participant: DurableAgentRuntimeParticipant): Promise; + restore(): Promise; + flush(): Promise; +} + +export const IEventDispatcher: ServiceIdentifier = + createDecorator('eventDispatcher'); diff --git a/packages/agent-core-v2/src/state/eventDispatcherService.ts b/packages/agent-core-v2/src/state/eventDispatcherService.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a3d32fb42c710a0bf89f8dca3cfca7dba340bad --- /dev/null +++ b/packages/agent-core-v2/src/state/eventDispatcherService.ts @@ -0,0 +1,832 @@ +import { produce } from 'immer'; + +import { BugIndicatingError } from '#/_base/errors/errors'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import { ILogService } from '#/_base/log/log'; +import { Service } from '#/_base/di/service'; +import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { type CollectionView } from '#/_base/di/collection'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { AgentSpaceImpl, type AgentSpaceHost } from '#/agent/agentContext/agentSpace'; +import { IAgentBlobService } from '#/agent/blob/agentBlobService'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { + event2FromRecord, + type AgentDomainTrait, + type Event2, + type Event2Class, +} from '#/app/event/event2'; +import { IEventBus } from '#/app/event/eventBus'; +import type { ContentPart } from '#human/llm/message'; +import { OrderedHookSlot } from '#/hooks'; +import { IWireService } from '#/wire/wire'; +import { WireError, WireErrors } from '#/wire/errors'; +import { isHumanRecordType } from '#/wire/human'; +import { AGENT_SWITCHED_TYPE } from '#/wire/tree/index'; +import type { PartsTransformer } from '#/wire/record'; + +import { + AgentModelContribution, + agentModelDefinitions, + type AgentModel, + type AgentModelDefinition, +} from './agentModel'; +import { IEventDispatcher, type DurableAgentRuntimeParticipant, type RestorePhase } from './eventDispatcher'; +import { StateError, StateErrors } from './errors'; +import { + expandedModelAppliers, + expandedRuntimeFolds, + type EventApplier, + type StateFold, + type FoldContext, + type ReplayableStateKey, +} from './state'; +import { + EventStateContribution, + foldEventStateContributions, + type EventStateContributionRecord, + type FoldedEventStateRegistry, +} from './stateContribution'; + +const MAX_DRAIN = 100; + +const UNREPORTED_WIRE_RECORD_TYPES: ReadonlySet = new Set([ + 'staleGuard.recorded', + 'staleGuard.cleared', + AGENT_SWITCHED_TYPE, + 'context.undone', +]); + +export class CycleError extends StateError { + constructor(readonly depth: number, readonly eventTypes: readonly string[]) { + super( + StateErrors.codes.STATE_CYCLE, + `Event dispatch cascade exceeded MAX_DRAIN (${depth}); possible event cycle`, + { details: { depth, eventTypes: eventTypes.slice(0, 20) } }, + ); + this.name = 'CycleError'; + } +} + +interface StateMeta { + checkpoints: unknown[]; +} + +interface QueuedEvent { + readonly event: Event2; + readonly resolve: () => void; + readonly reject: (error: unknown) => void; +} + +interface PreparedFold { + readonly key: ReplayableStateKey; + readonly meta: StateMeta; + readonly ctx: FoldContextImpl; + readonly next: any; +} + +type ParticipantApplier = ( + state: any, + event: Event2, + ctx: FoldContextImpl, +) => unknown; + +interface ParticipantAttachment { + readonly id: string; + readonly appliers: ReadonlyMap, ParticipantApplier>; + readonly meta: StateMeta; + readonly undoable: boolean; + readonly initial: unknown; + readonly getState: () => any; + readonly commit: (state: any) => void; +} + +interface PreparedParticipant { + readonly attachment: ParticipantAttachment; + readonly ctx: FoldContextImpl; + readonly next: any; +} + +class FoldContextImpl implements FoldContext { + pendingCheckpoint = false; + pendingClear = false; + pendingUndo: number | undefined; + + constructor( + private readonly owner: EventDispatcherService, + readonly silent: boolean, + ) {} + + checkpoint(): void { + if (!this.silent) return; + this.pendingCheckpoint = true; + } + + clearCheckpoints(): void { + if (!this.silent) return; + this.pendingClear = true; + } + + undoToCheckpoint(count: number): void { + if (!this.silent) return; + this.pendingUndo = count; + } + + emit(event: Event2): void { + if (this.silent) return; + this.owner.enqueue(event); + } +} + +function sanitizePendingUndo(ctx: FoldContextImpl, meta: StateMeta): void { + if ( + ctx.pendingUndo !== undefined && + (!Number.isSafeInteger(ctx.pendingUndo) || + ctx.pendingUndo <= 0 || + meta.checkpoints.length < ctx.pendingUndo) + ) { + ctx.pendingUndo = undefined; + } +} + +export class EventDispatcherService extends Service implements IEventDispatcher { + declare readonly _serviceBrand: undefined; + + readonly hooks: IEventDispatcher['hooks'] = { + onDidRestore: new OrderedHookSlot(), + }; + + private readonly metas = new Map, StateMeta>(); + private folded: FoldedEventStateRegistry; + + private activeModelDefs = new Map>(); + private readonly withdrawnModelIds = new Set(); + private modelTargets = new Map[]>(); + private readonly modelAttachments = new Map< + AgentModelDefinition, + ParticipantAttachment + >(); + private readonly participantTargets = new Map(); + private readonly participantAttachments = new Map(); + + private readonly spaceHost: AgentSpaceHost = { + isActiveModelDefinition: (definition) => + this.activeModelDefs.get(definition.id) === definition, + registerModel: (definition, model) => this.registerModel(definition, model), + dispatchModelEvent: (event) => this.dispatch(event), + readLegacyState: (key) => this.agentState.get(key), + }; + + restorePhase: RestorePhase = 'new'; + private dispatching = false; + private disposed = false; + private queue: QueuedEvent[] = []; + private drainDepth = 0; + private didRunRestoreHooks = false; + private lateAttachments: Array<{ + readonly participant: DurableAgentRuntimeParticipant; + readonly resolve: (disposable: IDisposable) => void; + readonly reject: (error: unknown) => void; + }> = []; + + constructor( + @IWireService private readonly wire: IWireService, + @IEventBus private readonly eventBus: IEventBus, + @IAgentScopeContext private readonly agentScope: IAgentScopeContext | undefined, + @IAgentBlobService private readonly blobService: IAgentBlobService, + @IAgentStateService private readonly agentState: IAgentStateService, + @ILogService private readonly logger: ILogService, + @EventStateContribution view: CollectionView, + @AgentModelContribution modelView: CollectionView>, + ) { + super(); + this.folded = this.foldContributions(view); + this._register( + view.onDidChange(() => { + this.folded = this.foldContributions(view); + }), + ); + this._register( + this.agentState.onDidContributeReplayable((key) => { + if (this.restorePhase !== 'new') { + throw new BugIndicatingError( + `Replayable state '${key.name}' contributed while the event dispatcher is in phase '${this.restorePhase}'; replayable state owners must contribute before restore`, + ); + } + this.folded = this.foldContributions(view); + }), + ); + this._register( + this.agentState.onDidWithdrawReplayable((key) => { + this.metas.delete(key); + this.folded = this.foldContributions(view); + }), + ); + this.refoldModels(modelView.items); + this._register( + modelView.onDidChange(({ added, removed }) => { + for (const definition of removed) { + this.withdrawnModelIds.add(definition.id); + const attachment = this.modelAttachments.get(definition); + if (attachment !== undefined) { + this.modelAttachments.delete(definition); + this.detachParticipant(attachment); + this.space()?.retireModel(definition); + } + } + for (const definition of added) { + this.withdrawnModelIds.delete(definition.id); + } + this.refoldModels(modelView.items); + this.materializeUndoableModels(); + }), + ); + this.space()?._attachHost(this.spaceHost); + this.materializeUndoableModels(); + } + + private space(): AgentSpaceImpl | undefined { + const space = this.agentScope?.agentContext.space; + return space instanceof AgentSpaceImpl ? space : undefined; + } + + private foldContributions( + view: CollectionView, + ): FoldedEventStateRegistry { + return foldEventStateContributions(view.items, this.agentState.replayableKeys()); + } + + attach(participant: DurableAgentRuntimeParticipant): IDisposable { + if (this.restorePhase !== 'new') { + throw new BugIndicatingError( + `Agent runtime participant '${participant.id}' attached while the event dispatcher is in phase '${this.restorePhase}'; durable runtime owners must attach before restore`, + ); + } + const attachment = this.buildParticipantAttachment(participant); + this.attachParticipant(attachment); + return toDisposable(() => { this.detachParticipant(attachment); }); + } + + async attachLate(participant: DurableAgentRuntimeParticipant): Promise { + if (this.restorePhase === 'restoring') { + return new Promise((resolve, reject) => { + this.lateAttachments.push({ participant, resolve, reject }); + }); + } + if (this.restorePhase !== 'ready') { + throw new BugIndicatingError( + `Agent runtime participant '${participant.id}' late-attached while the event dispatcher is in phase '${this.restorePhase}'; late attach requires a restored dispatcher`, + ); + } + return this.attachLateNow(participant); + } + + private async attachLateNow(participant: DurableAgentRuntimeParticipant): Promise { + if (this.disposed) { + throw new Error(`Agent runtime participant '${participant.id}' late-attached to a disposed event dispatcher`); + } + const attachment = this.buildParticipantAttachment(participant); + this.dispatching = true; + try { + await this.wire.flush(); + const stream = participant.undoable + ? this.wire.readRestorable() + : this.wire.readJournal(); + for await (const record of stream) { + if (record.type === 'metadata') continue; + const cls = this.folded.events.get(record.type); + if (cls === undefined) continue; + let eventRecord = record; + if (cls.agentDomain) { + if (this.agentScope === undefined) continue; + const recordAgentId = record['agentId']; + if (recordAgentId === undefined) eventRecord = { ...record, agentId: this.agentScope.agentId }; + else if (recordAgentId !== this.agentScope.agentId) continue; + } + const event = event2FromRecord(cls, eventRecord); + if (event === undefined) continue; + const applier = attachment.appliers.get(event.constructor as Event2Class); + if (applier === undefined) continue; + const ctx = new FoldContextImpl(this, true); + const next = produce( + attachment.getState(), + (draft: any) => applier(draft, event, ctx), + ); + if (ctx.pendingUndo !== undefined && next !== attachment.getState()) { + throw new BugIndicatingError( + `Fold of event '${event.type}' on durable participant '${attachment.id}' both mutates and undoes to a checkpoint`, + ); + } + sanitizePendingUndo(ctx, attachment.meta); + this.commitParticipant(attachment, ctx, next); + } + this.attachParticipant(attachment); + this.drainQueue(); + } catch (error) { + for (const entry of this.queue.splice(0)) entry.reject(error); + throw error; + } finally { + this.queue.length = 0; + this.dispatching = false; + this.drainDepth = 0; + } + return toDisposable(() => { this.detachParticipant(attachment); }); + } + + private buildParticipantAttachment( + participant: DurableAgentRuntimeParticipant, + ): ParticipantAttachment { + const base = new Map, StateFold>(); + for (const cls of participant.events) base.set(cls, participant.transition); + const folds = expandedRuntimeFolds(participant.id, participant.undoable, base); + const appliers = new Map, ParticipantApplier>(); + for (const [cls, fold] of folds) { + appliers.set(cls, (state, event, ctx) => fold(state, event, ctx)); + } + return { + id: participant.id, + appliers, + meta: { checkpoints: [] }, + undoable: participant.undoable, + initial: participant.getState(), + getState: () => participant.getState(), + commit: (state) => { participant.commit(state); }, + }; + } + + private attachParticipant(attachment: ParticipantAttachment): void { + if (this.participantAttachments.has(attachment.id)) { + throw new BugIndicatingError(`Durable participant '${attachment.id}' is already attached`); + } + this.participantAttachments.set(attachment.id, attachment); + for (const cls of attachment.appliers.keys()) { + const list = this.participantTargets.get(cls.type) ?? []; + list.push(attachment); + this.participantTargets.set(cls.type, list); + } + } + + private detachParticipant(attachment: ParticipantAttachment): void { + if (this.participantAttachments.get(attachment.id) !== attachment) return; + this.participantAttachments.delete(attachment.id); + for (const cls of attachment.appliers.keys()) { + const list = this.participantTargets.get(cls.type); + if (list === undefined) continue; + const next = list.filter((candidate) => candidate !== attachment); + if (next.length === 0) this.participantTargets.delete(cls.type); + else this.participantTargets.set(cls.type, next); + } + } + + private refoldModels(records: readonly AgentModelDefinition[]): void { + const defs = new Map>(); + for (const definition of agentModelDefinitions()) { + if (!this.withdrawnModelIds.has(definition.id)) defs.set(definition.id, definition); + } + for (const definition of records) defs.set(definition.id, definition); + this.activeModelDefs = defs; + this.rebuildModelTargets(); + } + + private rebuildModelTargets(): void { + const targets = new Map[]>(); + const add = (type: string, definition: AgentModelDefinition): void => { + const list = targets.get(type); + if (list === undefined) { + targets.set(type, [definition]); + return; + } + if (!list.includes(definition)) list.push(definition); + }; + const domainOwners = new Map>(); + for (const definition of this.activeModelDefs.values()) { + for (const cls of definition.events) { + const owner = domainOwners.get(cls.type); + if (owner !== undefined && owner !== definition) { + throw new BugIndicatingError( + `Event '${cls.type}' is applied by both agent models '${owner.id}' and '${definition.id}'`, + ); + } + domainOwners.set(cls.type, definition); + add(cls.type, definition); + } + } + for (const [definition, attachment] of this.modelAttachments) { + if (this.activeModelDefs.get(definition.id) !== definition) continue; + for (const cls of attachment.appliers.keys()) add(cls.type, definition); + } + this.modelTargets = targets; + } + + private materializeUndoableModels(): void { + const space = this.space(); + if (space === undefined) return; + for (const definition of this.activeModelDefs.values()) { + if (!definition.undoable || this.modelAttachments.has(definition)) continue; + space.ensureModel(definition); + } + } + + private registerModel( + definition: AgentModelDefinition, + model: AgentModel, + ): void { + if (this.modelAttachments.has(definition)) return; + const domainAppliers = new Map, EventApplier>(); + for (const [cls, applier] of model._appliersTable()) { + domainAppliers.set(cls, (event) => applier.call(model, event)); + } + const customUndo = + model.onUndo === undefined ? undefined : (count: number): void => model.onUndo!(count); + const expanded = expandedModelAppliers( + definition.id, + definition.undoable, + domainAppliers, + customUndo, + ); + const appliers = new Map, ParticipantApplier>(); + for (const [cls, applier] of expanded) { + appliers.set(cls, (state, event, ctx) => { + model._enterWindow(state, ctx); + let windowResult: ReturnType['_exitWindow']>; + try { + applier(event, ctx); + } finally { + windowResult = model._exitWindow(); + } + return windowResult.replaced ? windowResult.replacement : undefined; + }); + } + const attachment: ParticipantAttachment = { + id: definition.id, + appliers, + meta: { checkpoints: [] }, + undoable: definition.undoable, + initial: model._state(), + getState: () => model._state(), + commit: (state) => { model._commitState(state); }, + }; + this.attachParticipant(attachment); + this.modelAttachments.set(definition, attachment); + this.rebuildModelTargets(); + } + + private materializeModel(definition: AgentModelDefinition): ParticipantAttachment { + const space = this.space(); + if (space === undefined) { + throw new BugIndicatingError( + `Agent model '${definition.id}' cannot materialize without an agent space`, + ); + } + space.ensureModel(definition); + const attachment = this.modelAttachments.get(definition); + if (attachment === undefined) { + throw new BugIndicatingError(`Agent model '${definition.id}' failed to attach`); + } + return attachment; + } + + dispatch(event: Event2): Promise { + const cls = event.constructor as Event2Class; + if ( + cls.agentDomain && + (this.agentScope === undefined || + (event as Event2 & AgentDomainTrait).agentId !== this.agentScope.agentId) + ) { + return Promise.reject( + new Error(`Agent event '${event.type}' does not match dispatcher lifecycle context`), + ); + } + if (this.dispatching) { + return new Promise((resolve, reject) => { + this.queue.push({ event, resolve, reject }); + }); + } + this.dispatching = true; + try { + this.runDispatch(event); + this.drainQueue(); + return Promise.resolve(); + } catch (error) { + for (const entry of this.queue.splice(0)) { + entry.reject(error); + } + return Promise.reject(error); + } finally { + this.queue.length = 0; + this.dispatching = false; + this.drainDepth = 0; + } + } + + private drainQueue(): void { + while (this.queue.length > 0) { + if (++this.drainDepth > MAX_DRAIN) { + throw new CycleError( + this.drainDepth, + this.queue.map((entry) => entry.event.type), + ); + } + const entry = this.queue.shift()!; + try { + this.runDispatch(entry.event); + entry.resolve(); + } catch (error) { + entry.reject(error); + throw error; + } + } + } + + enqueue(event: Event2): void { + this.queue.push({ + event, + resolve: () => {}, + reject: (error: unknown) => onUnexpectedError(error), + }); + } + + private runDispatch(event: Event2): void { + this.executeEvent(event, false); + } + + private executeEvent(event: Event2, silent: boolean, replayUndoable?: boolean): void { + const folds = this.folded.folds.get(event.type); + const prepared: PreparedFold[] = []; + if (folds !== undefined) { + for (const { key, fold } of folds) { + if ( + replayUndoable !== undefined && + (key.replayable.undoable !== undefined) !== replayUndoable + ) { + continue; + } + const meta = this.ensureMeta(key); + const ctx = new FoldContextImpl(this, silent); + const next = produce( + this.agentState.get(key), + (draft: any) => fold(draft, event, ctx), + ); + if (ctx.pendingUndo !== undefined && next !== this.agentState.get(key)) { + throw new BugIndicatingError( + `Fold of event '${event.type}' on state '${key.name}' both mutates and undoes to a checkpoint`, + ); + } + sanitizePendingUndo(ctx, meta); + prepared.push({ key, meta, ctx, next }); + } + } + const modelTargets = this.modelTargets.get(event.type); + if (modelTargets !== undefined) { + for (const definition of modelTargets) { + if (replayUndoable !== undefined && definition.undoable !== replayUndoable) continue; + if (!this.modelAttachments.has(definition)) this.materializeModel(definition); + } + } + const participantTargets = this.participantTargets.get(event.type); + const preparedParticipants: PreparedParticipant[] = []; + if (participantTargets !== undefined) { + for (const attachment of participantTargets) { + if (replayUndoable !== undefined && attachment.undoable !== replayUndoable) continue; + const applier = attachment.appliers.get(event.constructor as Event2Class); + if (applier === undefined) continue; + const ctx = new FoldContextImpl(this, silent); + const next = produce( + attachment.getState(), + (draft: any) => applier(draft, event, ctx), + ); + if (ctx.pendingUndo !== undefined && next !== attachment.getState()) { + throw new BugIndicatingError( + `Fold of event '${event.type}' on durable participant '${attachment.id}' both mutates and undoes to a checkpoint`, + ); + } + sanitizePendingUndo(ctx, attachment.meta); + preparedParticipants.push({ attachment, ctx, next }); + } + } + for (const p of prepared) { + this.commit(p.key, p.meta, p.ctx, p.next); + } + for (const p of preparedParticipants) { + this.commitParticipant(p.attachment, p.ctx, p.next); + } + if (silent) return; + const cls = event.constructor as Event2Class; + if (cls.durable) { + const dehydrator = folds?.find(({ key }) => key.replayable.blobs !== undefined)?.key + .replayable.blobs?.dehydrate; + this.wire.appendRecord(event.serialize(), dehydrator); + } + if (cls.observable && !this.disposed) { + this.eventBus.publish(event, this.agentScope?.agentContext); + } + } + + override dispose(): void { + this.disposed = true; + const pending = this.lateAttachments.splice(0); + if (pending.length > 0) { + const error = new Error('Event dispatcher disposed while a late attach was pending'); + for (const entry of pending) entry.reject(error); + } + this.space()?._detachHost(this.spaceHost); + super.dispose(); + } + + private commit( + key: ReplayableStateKey, + meta: StateMeta, + ctx: FoldContextImpl, + next: any, + ): void { + if (ctx.pendingUndo !== undefined) { + const targetIndex = meta.checkpoints.length - ctx.pendingUndo; + const snapshot = meta.checkpoints[targetIndex]!; + this.agentState.set(key, snapshot); + meta.checkpoints.length = targetIndex; + return; + } + this.agentState.set(key, next); + if (ctx.pendingClear) { + meta.checkpoints.length = 0; + } + if (ctx.pendingCheckpoint) { + meta.checkpoints.push(next); + } + } + + private commitParticipant( + attachment: ParticipantAttachment, + ctx: FoldContextImpl, + next: any, + ): void { + const meta = attachment.meta; + if (ctx.pendingUndo !== undefined) { + const targetIndex = meta.checkpoints.length - ctx.pendingUndo; + const snapshot = meta.checkpoints[targetIndex]!; + attachment.commit(snapshot); + meta.checkpoints.length = targetIndex; + return; + } + attachment.commit(next); + if (ctx.pendingClear) { + meta.checkpoints.length = 0; + } + if (ctx.pendingCheckpoint) { + meta.checkpoints.push(next); + } + } + + private ensureMeta(key: ReplayableStateKey): StateMeta { + let meta = this.metas.get(key); + if (meta === undefined) { + meta = { checkpoints: [] }; + this.metas.set(key, meta); + } + return meta; + } + + async restore(): Promise { + if (this.restorePhase === 'restoring') { + throw new BugIndicatingError( + `Agent state restore called while phase is ${this.restorePhase}`, + ); + } + const rerun = this.restorePhase !== 'new'; + this.restorePhase = 'restoring'; + if (rerun) this.dispatching = true; + try { + if (rerun) { + await this.wire.flush(); + this.resetReplayState(); + } + await this.replayRecords(true); + await this.replayRecords(false); + await this.rehydrateStates(); + this.restorePhase = 'ready'; + if (!this.didRunRestoreHooks) { + await this.hooks.onDidRestore.run({}); + this.didRunRestoreHooks = true; + } + if (rerun) { + this.drainQueue(); + } + await this.drainLateAttachments(); + } catch (error) { + this.restorePhase = 'failed'; + for (const pending of this.lateAttachments.splice(0)) pending.reject(error); + if (rerun) { + for (const entry of this.queue.splice(0)) entry.reject(error); + } + throw error; + } finally { + if (rerun) { + this.queue.length = 0; + this.dispatching = false; + this.drainDepth = 0; + } + } + } + + private async drainLateAttachments(): Promise { + for (const pending of this.lateAttachments.splice(0)) { + try { + pending.resolve(await this.attachLateNow(pending.participant)); + } catch (error) { + pending.reject(error); + } + } + } + + private resetReplayState(): void { + for (const key of this.agentState.replayableKeys()) { + this.agentState.set(key, key.initial()); + } + for (const attachment of this.participantAttachments.values()) { + attachment.commit(attachment.initial); + attachment.meta.checkpoints.length = 0; + } + this.metas.clear(); + } + + private async replayRecords(undoable: boolean): Promise { + const stream = undoable ? this.wire.readRestorable() : this.wire.readJournal(); + let recordIndex = 0; + for await (const record of stream) { + if (record.type === 'metadata') continue; + const cls = this.folded.events.get(record.type); + if (cls === undefined) { + if ( + !undoable && + !UNREPORTED_WIRE_RECORD_TYPES.has(record.type) && + !isHumanRecordType(record.type) + ) { + this.reportSkippedRecord(record.type, recordIndex, false); + } + recordIndex++; + continue; + } + let eventRecord = record; + if (cls.agentDomain) { + if (this.agentScope === undefined) { + if (!undoable) this.reportSkippedRecord(record.type, recordIndex, true); + recordIndex++; + continue; + } + const recordAgentId = record['agentId']; + if (recordAgentId === undefined) { + eventRecord = { ...record, agentId: this.agentScope.agentId }; + } else if (recordAgentId !== this.agentScope.agentId) { + if (!undoable) this.reportSkippedRecord(record.type, recordIndex, true); + recordIndex++; + continue; + } + } + const event = event2FromRecord(cls, eventRecord); + if (event === undefined) { + if (!undoable) this.reportSkippedRecord(record.type, recordIndex, true); + recordIndex++; + continue; + } + this.executeEvent(event, true, undoable); + recordIndex++; + } + } + + private reportSkippedRecord(type: string, index: number, malformed: boolean): void { + const message = malformed + ? `Malformed wire record type '${type}' skipped during restore` + : `Unknown wire record type '${type}' skipped during restore`; + if (malformed) { + onUnexpectedError( + new WireError(WireErrors.codes.WIRE_UNKNOWN_RECORD, message, { details: { type, index } }), + ); + return; + } + this.logger.warn(message, { code: WireErrors.codes.WIRE_UNKNOWN_RECORD, type, index }); + } + + private async rehydrateStates(): Promise { + const transform: PartsTransformer = (parts) => + this.blobService.loadParts(parts as readonly ContentPart[]) as Promise; + for (const key of this.folded.states) { + const codec = key.replayable.blobs; + if (codec?.rehydrate === undefined) continue; + this.agentState.set(key, Object.freeze(await codec.rehydrate(this.agentState.get(key), transform))); + } + } + + async flush(): Promise { + await this.wire.flush(); + } +} + +registerScopedService( + LifecycleScope.Agent, + IEventDispatcher, + EventDispatcherService, + ScopeActivation.OnScopeCreated, + 'state', +); diff --git a/packages/agent-core-v2/src/state/state.ts b/packages/agent-core-v2/src/state/state.ts new file mode 100644 index 0000000000000000000000000000000000000000..20a5a583708acec67f46c623a6694de0ca209236 --- /dev/null +++ b/packages/agent-core-v2/src/state/state.ts @@ -0,0 +1,308 @@ +import { enableMapSet, type Draft } from 'immer'; +import type { z } from 'zod'; + +import { BugIndicatingError } from '#/_base/errors/errors'; +import type { StateKey } from '#/_base/state/stateRegistry'; +import { Event2, registerEvent2Class, type Event2Class } from '#/app/event/event2'; +import type { PartsTransformer, RecordDehydrator } from '#/wire/record'; + +import { StateError, StateErrors } from './errors'; + +enableMapSet(); + +export type { StateKey } from '#/_base/state/stateRegistry'; +export type { PartsTransformer } from '#/wire/record'; + +export interface StateBlobCodec { + dehydrate: RecordDehydrator; + rehydrate(state: S, transform: PartsTransformer): S | Promise; +} + +export interface FoldContext { + readonly silent: boolean; + checkpoint(): void; + clearCheckpoints(): void; + undoToCheckpoint(count: number): void; + emit(event: Event2): void; +} + +export type StateFold = Event2> = ( + state: Draft, + event: E, + ctx: FoldContext, +) => S | void; + +export interface ReplayableOptions { + readonly schema: z.ZodType; + readonly durable?: boolean; + readonly blobs?: StateBlobCodec; +} + +export interface UndoableOptions { + readonly onUndo?: (state: Draft, count: number) => S | void; +} + +export interface ReplayableStateMeta { + readonly schema: z.ZodType; + readonly durable: boolean; + readonly blobs?: StateBlobCodec; + readonly undoable?: UndoableOptions; + readonly folds: ReadonlyMap, StateFold>; +} + +export interface ReplayableStateKey extends StateKey> { + readonly replayable: ReplayableStateMeta; + undoable(opts?: UndoableOptions): ReplayableStateKey; + on>(cls: Event2Class, fold: StateFold): ReplayableStateKey; +} + +export interface StateKeyBuilder extends StateKey { + replayable(opts: ReplayableOptions): ReplayableStateKey; +} + +class ReplayableStateKeyImpl implements ReplayableStateKey { + readonly snapshotExcluded = true; + readonly initial: () => DeepReadonly; + + private readonly meta: { + readonly schema: z.ZodType; + readonly durable: boolean; + readonly blobs?: StateBlobCodec; + undoable?: UndoableOptions; + readonly folds: Map, StateFold>; + }; + + constructor( + readonly name: string, + initial: () => S, + opts: ReplayableOptions, + ) { + this.initial = () => Object.freeze(initial()) as DeepReadonly; + this.meta = { + schema: opts.schema, + durable: opts.durable ?? true, + blobs: opts.blobs, + folds: new Map(), + }; + } + + get replayable(): ReplayableStateMeta { + return this.meta; + } + + undoable(opts?: UndoableOptions): ReplayableStateKey { + if (this.meta.undoable !== undefined) { + throw new BugIndicatingError(`State key '${this.name}' is already undoable`); + } + if (!this.meta.durable) { + throw new BugIndicatingError(`Transient state key '${this.name}' cannot be undoable`); + } + this.meta.undoable = opts ?? {}; + return this; + } + + on>(cls: Event2Class, fold: StateFold): ReplayableStateKey { + if (this.meta.folds.has(cls)) { + throw new StateError( + StateErrors.codes.STATE_DUPLICATE_FOLD, + `State '${this.name}' already folds event '${cls.type}'`, + { details: { state: this.name, type: cls.type } }, + ); + } + if (!this.meta.durable && cls.durable) { + throw new StateError( + StateErrors.codes.STATE_DURABILITY_MISMATCH, + `Transient state '${this.name}' cannot fold durable event '${cls.type}'`, + { details: { state: this.name, type: cls.type } }, + ); + } + registerEvent2Class(cls); + this.meta.folds.set(cls, fold as StateFold); + return this; + } +} + +class StateKeyBuilderImpl implements StateKeyBuilder { + constructor( + readonly name: string, + readonly initial: () => T, + ) {} + + replayable(opts: ReplayableOptions): ReplayableStateKey { + return new ReplayableStateKeyImpl(this.name, this.initial, opts); + } +} + +export function defineState(name: string, initial: () => T): StateKeyBuilder { + return new StateKeyBuilderImpl(name, initial); +} + +export interface UndoableProtocol { + readonly events: { + readonly appendMessage: Event2Class; + readonly applyCompaction: Event2Class; + readonly clear: Event2Class; + readonly undo: Event2Class; + }; + readonly isUndoAnchor: (message: unknown) => boolean; + readonly isValidUndoCount: (count: number) => boolean; +} + +let undoableProtocol: UndoableProtocol | undefined; + +export function registerUndoableProtocol(protocol: UndoableProtocol): void { + if (undoableProtocol !== undefined) { + throw new BugIndicatingError('The undoable protocol is already registered'); + } + undoableProtocol = protocol; + for (const cls of Object.values(protocol.events)) { + registerEvent2Class(cls); + } +} + +export function expandedStateFolds( + key: ReplayableStateKey, +): ReadonlyMap, StateFold> { + const meta = key.replayable; + if (meta.undoable === undefined) return meta.folds; + if (undoableProtocol === undefined) { + throw new BugIndicatingError( + `State key '${key.name}' is undoable but no undoable protocol is registered ` + + '(the contextMemory domain registers it at import time)', + ); + } + const protocol = undoableProtocol; + if (meta.folds.has(protocol.events.undo)) { + throw new BugIndicatingError( + `Undoable state key '${key.name}' must not fold the undo event itself; ` + + 'use .undoable({ onUndo }) to customize the rollback', + ); + } + const custom = meta.undoable.onUndo !== undefined; + const folds = new Map, StateFold>(meta.folds); + const domainAppend = folds.get(protocol.events.appendMessage); + folds.set(protocol.events.appendMessage, (state, event, ctx) => { + if (!custom && protocol.isUndoAnchor(event.message)) { + ctx.checkpoint(); + return; + } + return domainAppend?.(state, event, ctx); + }); + for (const cls of [protocol.events.applyCompaction, protocol.events.clear]) { + const domain = folds.get(cls); + folds.set(cls, (state, event, ctx) => { + ctx.clearCheckpoints(); + return domain?.(state, event, ctx); + }); + } + folds.set(protocol.events.undo, (state, event, ctx) => { + if (!protocol.isValidUndoCount(event.count)) return; + if (meta.undoable?.onUndo !== undefined) { + return meta.undoable.onUndo(state, event.count); + } + ctx.undoToCheckpoint(event.count); + }); + return folds; +} + +export type EventApplier = (event: any, ctx: FoldContext) => void; + +export function expandedModelAppliers( + owner: string, + undoable: boolean, + appliers: ReadonlyMap, EventApplier>, + onUndo: ((count: number) => void) | undefined, +): ReadonlyMap, EventApplier> { + if (!undoable) return appliers; + if (undoableProtocol === undefined) { + throw new BugIndicatingError( + `Agent model '${owner}' is undoable but no undoable protocol is registered ` + + '(the contextMemory domain registers it at import time)', + ); + } + const protocol = undoableProtocol; + if (appliers.has(protocol.events.undo)) { + throw new BugIndicatingError( + `Undoable agent model '${owner}' must not apply the undo event itself; ` + + 'override onUndo on the model to customize the rollback', + ); + } + const custom = onUndo !== undefined; + const expanded = new Map, EventApplier>(appliers); + const domainAppend = expanded.get(protocol.events.appendMessage); + expanded.set(protocol.events.appendMessage, (event, ctx) => { + if (!custom && protocol.isUndoAnchor(event.message)) { + ctx.checkpoint(); + return; + } + domainAppend?.(event, ctx); + }); + for (const cls of [protocol.events.applyCompaction, protocol.events.clear]) { + const domain = expanded.get(cls); + expanded.set(cls, (event, ctx) => { + ctx.clearCheckpoints(); + domain?.(event, ctx); + }); + } + expanded.set(protocol.events.undo, (event, ctx) => { + if (!protocol.isValidUndoCount(event.count)) return; + if (onUndo !== undefined) { + onUndo(event.count); + return; + } + ctx.undoToCheckpoint(event.count); + }); + return expanded; +} + +export function expandedRuntimeFolds( + owner: string, + undoable: boolean, + folds: ReadonlyMap, StateFold>, +): ReadonlyMap, StateFold> { + if (!undoable) return folds; + if (undoableProtocol === undefined) { + throw new BugIndicatingError( + `Agent runtime '${owner}' is undoable but no undoable protocol is registered ` + + '(the contextMemory domain registers it at import time)', + ); + } + const protocol = undoableProtocol; + if (folds.has(protocol.events.undo)) { + throw new BugIndicatingError( + `Undoable agent runtime '${owner}' must not fold the undo event itself`, + ); + } + const expanded = new Map(folds); + const domainAppend = expanded.get(protocol.events.appendMessage); + expanded.set(protocol.events.appendMessage, (state, event, ctx) => { + if (protocol.isUndoAnchor(event.message)) { + ctx.checkpoint(); + return; + } + return domainAppend?.(state, event, ctx); + }); + for (const cls of [protocol.events.applyCompaction, protocol.events.clear]) { + const domain = expanded.get(cls); + expanded.set(cls, (state, event, ctx) => { + ctx.clearCheckpoints(); + return domain?.(state, event, ctx); + }); + } + expanded.set(protocol.events.undo, (_state, event, ctx) => { + if (protocol.isValidUndoCount(event.count)) ctx.undoToCheckpoint(event.count); + }); + return expanded; +} + +export type DeepReadonly = T extends (...args: infer A) => infer R + ? (...args: A) => R + : T extends ReadonlyMap + ? ReadonlyMap, DeepReadonly> + : T extends ReadonlySet + ? ReadonlySet> + : T extends readonly (infer E)[] + ? ReadonlyArray> + : T extends object + ? { readonly [K in keyof T]: DeepReadonly } + : T; diff --git a/packages/agent-core-v2/src/state/stateContribution.ts b/packages/agent-core-v2/src/state/stateContribution.ts new file mode 100644 index 0000000000000000000000000000000000000000..321d81c1e778c3584b3c05224180dd1c9adc94f7 --- /dev/null +++ b/packages/agent-core-v2/src/state/stateContribution.ts @@ -0,0 +1,72 @@ +import { collection } from '#/_base/di/collection'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import { EventError, EventErrors } from '#/app/event/errors'; +import { EVENT2_REGISTRY, type Event2Class } from '#/app/event/event2'; + +import { + expandedStateFolds, + type ReplayableStateKey, + type StateFold, +} from './state'; + +export interface EventStateContributionRecord { + readonly events?: readonly Event2Class[]; +} + +export const EventStateContribution = collection('event-state'); + +export interface StateFoldRegistration { + readonly key: ReplayableStateKey; + readonly fold: StateFold; +} + +export interface FoldedEventStateRegistry { + readonly events: ReadonlyMap>; + readonly folds: ReadonlyMap; + readonly states: readonly ReplayableStateKey[]; +} + +export function foldEventStateContributions( + records: readonly EventStateContributionRecord[], + replayableKeys: readonly ReplayableStateKey[], +): FoldedEventStateRegistry { + const events = new Map>(); + const folds = new Map(); + const states: ReplayableStateKey[] = []; + const foldBuiltinLayer = (): void => { + for (const cls of EVENT2_REGISTRY.values()) { + events.set(cls.type, cls); + } + for (const key of replayableKeys) { + states.push(key); + for (const [cls, fold] of expandedStateFolds(key)) { + let list = folds.get(cls.type); + if (list === undefined) { + list = []; + folds.set(cls.type, list); + } + list.push({ key, fold }); + if (cls.durable && !events.has(cls.type)) { + events.set(cls.type, cls); + } + } + } + }; + foldBuiltinLayer(); + for (const record of records) { + for (const cls of record.events ?? []) { + if (events.has(cls.type)) { + onUnexpectedError( + new EventError( + EventErrors.codes.EVENT_DUPLICATE_EVENT, + `Duplicate event type contributed: '${cls.type}'; keeping the already-folded registration`, + { details: { type: cls.type } }, + ), + ); + continue; + } + events.set(cls.type, cls); + } + } + return { events, folds, states }; +} diff --git a/packages/agent-core-v2/src/tool/args-validator.ts b/packages/agent-core-v2/src/tool/args-validator.ts new file mode 100644 index 0000000000000000000000000000000000000000..3585f03f7ff237521df1c1cad2a9d4c4a6f98967 --- /dev/null +++ b/packages/agent-core-v2/src/tool/args-validator.ts @@ -0,0 +1,89 @@ +import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv'; +import Ajv2019 from 'ajv/dist/2019'; +import Ajv2020 from 'ajv/dist/2020'; +import addFormats from 'ajv-formats'; + +const DRAFT_07_AJV = new Ajv({ strict: false, allErrors: true }); +addFormats(DRAFT_07_AJV); + +const DRAFT_2019_AJV = new Ajv2019({ strict: false, allErrors: true }); +addFormats(DRAFT_2019_AJV); + +const DRAFT_2020_AJV = new Ajv2020({ strict: false, allErrors: true }); +addFormats(DRAFT_2020_AJV); + +const DRAFT_2019_KEYWORDS = new Set([ + 'dependentRequired', + 'dependentSchemas', + 'maxContains', + 'minContains', + 'unevaluatedItems', + 'unevaluatedProperties', + '$recursiveAnchor', + '$recursiveRef', +]); + +const DRAFT_2020_KEYWORDS = new Set(['prefixItems', '$dynamicAnchor', '$dynamicRef']); + +function ajvFor(schema: Record): Ajv | Ajv2019 | Ajv2020 { + const $schema = schema['$schema']; + if (typeof $schema === 'string') { + if ($schema.includes('2020-12')) return DRAFT_2020_AJV; + if ($schema.includes('2019-09')) return DRAFT_2019_AJV; + return DRAFT_07_AJV; + } + if (containsSchemaKeyword(schema, DRAFT_2020_KEYWORDS)) return DRAFT_2020_AJV; + if (containsSchemaKeyword(schema, DRAFT_2019_KEYWORDS)) return DRAFT_2019_AJV; + return DRAFT_07_AJV; +} + +function containsSchemaKeyword(value: unknown, keywords: ReadonlySet): boolean { + if (Array.isArray(value)) { + return value.some((item) => containsSchemaKeyword(item, keywords)); + } + if (typeof value !== 'object' || value === null) return false; + for (const [key, child] of Object.entries(value)) { + if (keywords.has(key)) return true; + if (containsSchemaKeyword(child, keywords)) return true; + } + return false; +} + +export type JsonType = null | number | string | boolean | JsonArray | JsonObject; + +export interface JsonArray extends Array {} + +export interface JsonObject extends Record {} + +export type ToolArgsValidator = ValidateFunction; + +function formatValidationError(error: ErrorObject): string { + if (error.keyword === 'required' && 'missingProperty' in error.params) { + return `must have required property '${String(error.params['missingProperty'])}'`; + } + + if (error.keyword === 'additionalProperties' && 'additionalProperty' in error.params) { + return `must NOT have additional property '${String(error.params['additionalProperty'])}'`; + } + + const path = error.instancePath ? `${error.instancePath} ` : ''; + return `${path}${error.message ?? 'is invalid'}`; +} + +export function compileToolArgsValidator(schema: Record): ToolArgsValidator { + return ajvFor(schema).compile(schema) as ToolArgsValidator; +} + +export function validateToolArgs(validator: ToolArgsValidator, args: JsonType): string | null { + const valid = validator(args); + if (valid) { + return null; + } + + const errors = validator.errors ?? []; + if (errors.length === 0) { + return 'Tool parameter validation failed'; + } + + return errors.map((error) => formatValidationError(error)).join('; '); +} diff --git a/packages/agent-core-v2/src/tool/input-schema.ts b/packages/agent-core-v2/src/tool/input-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..085ffad401a163339fea1ab4a0fb38eaf2e765d4 --- /dev/null +++ b/packages/agent-core-v2/src/tool/input-schema.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export function toInputJsonSchema(schema: z.ZodType): Record { + const jsonSchema = z.toJSONSchema(schema, { + target: 'draft-7', + io: 'input', + }); + closeObjectNodes(jsonSchema); + return jsonSchema; +} + +function closeObjectNodes(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) closeObjectNodes(item); + return; + } + if (typeof value !== 'object' || value === null) return; + const node = value as Record; + if (node['type'] === 'object' && node['additionalProperties'] === undefined) { + node['additionalProperties'] = false; + } + for (const child of Object.values(node)) { + closeObjectNodes(child); + } +} diff --git a/packages/agent-core-v2/src/tool/output-accumulator.ts b/packages/agent-core-v2/src/tool/output-accumulator.ts new file mode 100644 index 0000000000000000000000000000000000000000..31b8ae7ddfba347394f65c178a3e36e896825fcf --- /dev/null +++ b/packages/agent-core-v2/src/tool/output-accumulator.ts @@ -0,0 +1,91 @@ +import { + DEFAULT_TOOL_RESULT_MAX_CHARS, + DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS, + type ExecutableToolErrorResult, + type ExecutableToolSuccessResult, + type ToolResultSpill, +} from './toolContract'; + +export type ToolOutputAccumulatorResult = ( + | ExecutableToolErrorResult + | ExecutableToolSuccessResult +) & { + readonly output: string; + readonly brief?: string; +}; + +export class ToolOutputAccumulator { + private readonly buffer: string[] = []; + private retainedChars = 0; + private totalCharsValue = 0; + + get nChars(): number { + return this.retainedChars; + } + + get totalChars(): number { + return this.totalCharsValue; + } + + write(text: string): void { + this.totalCharsValue += text.length; + if (this.retainedChars >= DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS) return; + const remainingRetention = DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS - this.retainedChars; + const kept = text.length <= remainingRetention ? text : text.slice(0, remainingRetention); + this.buffer.push(kept); + this.retainedChars += kept.length; + } + + ok(message = '', options: { readonly brief?: string } = {}): ToolOutputAccumulatorResult { + let finalMessage = message; + if (finalMessage.length > 0 && !finalMessage.endsWith('.')) { + finalMessage += '.'; + } + const output = this.buffer.join(''); + return { + isError: false, + output: output.length === 0 ? finalMessage : output, + brief: options.brief, + spill: this.completionSpill(finalMessage), + }; + } + + error( + message: string, + options: { readonly brief?: string } = {}, + ): ToolOutputAccumulatorResult { + const output = this.buffer.join(''); + return { + isError: true, + output: + message.length === 0 + ? output + : output.length === 0 + ? message + : output.endsWith('\n') + ? `${output}${message}` + : `${output}\n${message}`, + brief: options.brief, + spill: this.retentionSpill(message), + }; + } + + private retentionSpill(suffix?: string): ToolResultSpill | undefined { + if (this.totalCharsValue <= this.retainedChars) return undefined; + return { + totalChars: this.totalCharsValue, + suffix: suffix !== undefined && suffix.length > 0 ? suffix : undefined, + }; + } + + private completionSpill(suffix: string): ToolResultSpill | undefined { + const retentionSpill = this.retentionSpill(); + if (retentionSpill !== undefined) { + return suffix.length > 0 ? { ...retentionSpill, suffix } : retentionSpill; + } + if (suffix.length === 0 || this.totalCharsValue <= DEFAULT_TOOL_RESULT_MAX_CHARS) { + return undefined; + } + return { suffix }; + } +} diff --git a/packages/agent-core-v2/src/tool/path-access.ts b/packages/agent-core-v2/src/tool/path-access.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e11c46313f1636ba427a22cf68484e69e8047d9 --- /dev/null +++ b/packages/agent-core-v2/src/tool/path-access.ts @@ -0,0 +1,319 @@ +import * as pathe from 'pathe'; + +import { + getShellPathBridge, + translateShellDrivePath, + type ShellPathBridge, +} from '#/_base/execEnv/shellPathBridge'; +import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; + +export interface WorkspaceConfig { + readonly workspaceDir: string; + readonly additionalDirs: readonly string[]; +} + +const SENSITIVE_BASENAMES = new Set([ + '.env', + 'id_rsa', + 'id_ed25519', + 'id_ecdsa', + 'credentials', +]); + +const SENSITIVE_PATH_SUFFIXES = [ + ['.aws', 'credentials'], + ['.gcp', 'credentials'], +]; + +const ENV_PREFIX = '.env.'; +const ENV_EXEMPTIONS = new Set(['.env.example', '.env.sample', '.env.template']); + +const SENSITIVE_BASENAME_PREFIXES = ['id_rsa', 'id_ed25519', 'id_ecdsa', 'credentials']; +const PUBLIC_KEY_BASENAMES = new Set(['id_rsa.pub', 'id_ed25519.pub', 'id_ecdsa.pub']); +export const SENSITIVE_DOT_VARIANT_SUFFIXES = [ + '.bak', + '.backup', + '.copy', + '.disabled', + '.key', + '.old', + '.orig', + '.pem', + '.save', + '.tmp', +] as const; +const SENSITIVE_DOT_VARIANT_SUFFIX_SET = new Set(SENSITIVE_DOT_VARIANT_SUFFIXES); + +function comparable(path: string): string { + return path.toLowerCase(); +} + +export function isSensitiveFile(path: string): boolean { + const name = pathe.basename(path); + const comparableName = comparable(name); + const comparablePath = comparable(path); + + if (ENV_EXEMPTIONS.has(comparableName)) return false; + if (PUBLIC_KEY_BASENAMES.has(comparableName)) return false; + if (SENSITIVE_BASENAMES.has(comparableName)) return true; + if (comparableName.startsWith(ENV_PREFIX)) return true; + + for (const prefix of SENSITIVE_BASENAME_PREFIXES) { + if (comparableName === prefix) return true; + if (comparableName.length > prefix.length && comparableName.startsWith(prefix)) { + const suffix = comparableName.slice(prefix.length); + const next = suffix[0]; + if (next === '-' || next === '_') return true; + if (next === '.' && SENSITIVE_DOT_VARIANT_SUFFIX_SET.has(suffix)) return true; + } + } + + for (const suffixParts of SENSITIVE_PATH_SUFFIXES) { + const suffix = suffixParts.join('/'); + const comparableSuffix = comparable(suffix); + if ( + comparablePath.endsWith(`/${comparableSuffix}`) || + comparablePath.includes(`/${comparableSuffix}/`) + ) { + return true; + } + } + + return false; +} + +export type PathClass = 'posix' | 'win32'; +export type PathSecurityCode = 'PATH_OUTSIDE_WORKSPACE' | 'PATH_SENSITIVE' | 'PATH_INVALID'; +export type PathAccessOperation = 'read' | 'write' | 'search'; +export type WorkspaceGuardMode = 'absolute-outside-allowed' | 'disabled'; + +export interface WorkspaceAccessPolicy { + readonly guardMode: WorkspaceGuardMode; + readonly checkSensitive: boolean; +} + +export const DEFAULT_WORKSPACE_ACCESS_POLICY: WorkspaceAccessPolicy = { + guardMode: 'absolute-outside-allowed', + checkSensitive: true, +}; + +export interface PathAccess { + readonly path: string; + readonly outsideWorkspace: boolean; +} + +export class PathSecurityError extends Error { + readonly code: PathSecurityCode; + readonly rawPath: string; + readonly canonicalPath: string; + + constructor(code: PathSecurityCode, rawPath: string, canonicalPath: string, message: string) { + super(message); + this.name = 'PathSecurityError'; + this.code = code; + this.rawPath = rawPath; + this.canonicalPath = canonicalPath; + } +} + +const DEFAULT_PATH_CLASS: PathClass = process.platform === 'win32' ? 'win32' : 'posix'; + +function isWin32DriveRelative(path: string): boolean { + return /^[A-Za-z]:(?:$|[^\\/])/.test(path); +} + +export function normalizeUserPath(path: string, pathClass: PathClass = DEFAULT_PATH_CLASS): string { + return pathClass === 'win32' ? translateShellDrivePath(path) : path; +} + +function expandUserPath(path: string, homeDir: string | undefined, pathClass: PathClass): string { + if (homeDir === undefined) return path; + if (path === '~') return homeDir; + if (path.startsWith('~/') || (pathClass === 'win32' && path.startsWith('~\\'))) { + return pathe.join(homeDir, path.slice(2)); + } + return path; +} + +export function canonicalizePath( + path: string, + cwd: string, + pathClass: PathClass = DEFAULT_PATH_CLASS, +): string { + if (path === '') { + throw new PathSecurityError('PATH_INVALID', path, path, 'Path cannot be empty'); + } + const normalizedPath = normalizeUserPath(path, pathClass); + if (pathClass === 'win32' && isWin32DriveRelative(normalizedPath)) { + throw new PathSecurityError( + 'PATH_INVALID', + path, + normalizedPath, + `"${path}" is a drive-relative Windows path. Use an absolute path like C:\\path or a path relative to the working directory.`, + ); + } + if (!pathe.isAbsolute(normalizedPath) && !pathe.isAbsolute(cwd)) { + throw new PathSecurityError( + 'PATH_INVALID', + path, + normalizedPath, + `Cannot resolve "${path}" against non-absolute cwd "${cwd}".`, + ); + } + const abs = pathe.isAbsolute(normalizedPath) ? normalizedPath : pathe.resolve(cwd, normalizedPath); + return pathe.normalize(abs); +} + +export function isWithinDirectory( + candidate: string, + base: string, + pathClass: PathClass = DEFAULT_PATH_CLASS, +): boolean { + const nc = pathe.normalize(candidate); + const nb = pathe.normalize(base); + const comparableCandidate = pathClass === 'win32' ? nc.toLowerCase() : nc; + const comparableBase = pathClass === 'win32' ? nb.toLowerCase() : nb; + if (comparableCandidate === comparableBase) return true; + const prefix = comparableBase.endsWith('/') ? comparableBase : comparableBase + '/'; + return comparableCandidate.startsWith(prefix); +} + +export function isWithinWorkspace( + candidate: string, + config: WorkspaceConfig, + pathClass: PathClass = DEFAULT_PATH_CLASS, +): boolean { + if (isWithinDirectory(candidate, config.workspaceDir, pathClass)) return true; + for (const dir of config.additionalDirs) { + if (isWithinDirectory(candidate, dir, pathClass)) return true; + } + return false; +} + +export function extendWorkspaceWithSkillRoots( + workspace: T, + skillRoots: readonly string[], + pathClass: PathClass = DEFAULT_PATH_CLASS, +): T { + const additionalDirs = [...workspace.additionalDirs]; + for (const root of skillRoots) { + if (isWithinDirectory(root, workspace.workspaceDir, pathClass)) continue; + if (additionalDirs.some((dir) => isWithinDirectory(root, dir, pathClass))) continue; + additionalDirs.push(root); + } + if (additionalDirs.length === workspace.additionalDirs.length) return workspace; + return { ...workspace, additionalDirs }; +} + +export interface AssertPathOptions { + readonly mode: PathAccessOperation; + readonly checkSensitive?: boolean | undefined; + readonly pathClass?: PathClass | undefined; +} + +export interface ResolvePathAccessOptions { + readonly operation: PathAccessOperation; + readonly policy?: WorkspaceAccessPolicy | undefined; + readonly pathClass?: PathClass | undefined; + readonly homeDir?: string; + readonly shellPathBridge?: ShellPathBridge; +} + +export interface ResolvePathAccessPathOptions { + readonly env: Pick< + IHostEnvironment, + 'pathClass' | 'homeDir' | 'osKind' | 'shellName' | 'shellPath' + >; + readonly workspace: WorkspaceConfig; + readonly operation: PathAccessOperation; + readonly policy?: WorkspaceAccessPolicy; + readonly expandHome?: boolean; +} + +function relativeOutsideMessage(path: string, operation: PathAccessOperation): string { + const verb = + operation === 'write' + ? 'write or edit a file' + : operation === 'search' + ? 'search' + : 'read a file'; + return ( + `"${path}" is not an absolute path. ` + + `You must provide an absolute path to ${verb} outside the working directory.` + ); +} + +export function resolvePathAccess( + path: string, + cwd: string, + config: WorkspaceConfig, + options: ResolvePathAccessOptions, +): PathAccess { + const pathClass = options.pathClass ?? DEFAULT_PATH_CLASS; + const normalizedPath = + options.shellPathBridge?.fromShellPath(path) ?? normalizeUserPath(path, pathClass); + const expandedPath = expandUserPath(normalizedPath, options.homeDir, pathClass); + const rawIsAbsolute = pathe.isAbsolute(expandedPath); + const canonical = canonicalizePath(expandedPath, cwd, pathClass); + const outsideWorkspace = !isWithinWorkspace(canonical, config, pathClass); + const policy = options.policy ?? DEFAULT_WORKSPACE_ACCESS_POLICY; + + if (policy.checkSensitive && isSensitiveFile(canonical)) { + throw new PathSecurityError( + 'PATH_SENSITIVE', + path, + canonical, + `"${path}" matches a sensitive-file pattern (env / credential / SSH key). ` + + `Access is blocked to protect secrets.`, + ); + } + + if (outsideWorkspace) { + switch (policy.guardMode) { + case 'absolute-outside-allowed': + if (!rawIsAbsolute) { + throw new PathSecurityError( + 'PATH_OUTSIDE_WORKSPACE', + path, + canonical, + relativeOutsideMessage(path, options.operation), + ); + } + break; + case 'disabled': + break; + } + } + + return { path: canonical, outsideWorkspace }; +} + +export function resolvePathAccessPath( + path: string, + options: ResolvePathAccessPathOptions, +): string { + const { env, workspace, operation, policy, expandHome = true } = options; + return resolvePathAccess(path, workspace.workspaceDir, workspace, { + operation, + policy, + pathClass: env.pathClass, + homeDir: expandHome ? env.homeDir : undefined, + shellPathBridge: env.pathClass === 'win32' ? getShellPathBridge(env) : undefined, + }).path; +} + +export function assertPathAllowed( + path: string, + cwd: string, + config: WorkspaceConfig, + options: AssertPathOptions, +): string { + return resolvePathAccess(path, cwd, config, { + operation: options.mode, + pathClass: options.pathClass, + policy: { + guardMode: 'absolute-outside-allowed', + checkSensitive: options.checkSensitive ?? DEFAULT_WORKSPACE_ACCESS_POLICY.checkSensitive, + }, + }).path; +} diff --git a/packages/agent-core-v2/src/tool/rule-match.ts b/packages/agent-core-v2/src/tool/rule-match.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f6dc1174da820292a25592812023f7a0bc6395e --- /dev/null +++ b/packages/agent-core-v2/src/tool/rule-match.ts @@ -0,0 +1,160 @@ +import { isAbsolute, join, parse } from 'pathe'; + +import picomatch from 'picomatch'; + +import { canonicalizePath, type PathClass } from './path-access'; + +export interface PermissionPathMatchOptions { + readonly cwd?: string; + readonly pathClass?: PathClass; + readonly homeDir?: string; + readonly caseInsensitivePaths?: boolean; +} + +interface PathMatchSemantics { + readonly pathClass: PathClass; +} + +export function globMatch(value: string, pattern: string, options?: { nocase?: boolean }): boolean { + if (picomatch.isMatch(value, pattern, options)) return true; + + const normalizedValue = stripLeadingDotSlash(value); + const normalizedPattern = stripLeadingDotSlash(pattern); + if (normalizedValue === value && normalizedPattern === pattern) return false; + return picomatch.isMatch(normalizedValue, normalizedPattern, options); +} + +function stripLeadingDotSlash(value: string): string { + return value.startsWith('./') ? value.slice(2) : value; +} + +export function pathGlobMatch( + value: string, + pattern: string, + pathOptions?: PermissionPathMatchOptions, +): boolean { + const semantics = pathMatchSemantics(value, pattern, pathOptions); + const nocase = pathOptions?.caseInsensitivePaths ?? true; + + if (globMatch(value, pattern, { nocase })) return true; + + for (const valueVariant of pathVariants(value, semantics, pathOptions)) { + for (const patternVariant of pathVariants(pattern, semantics, pathOptions)) { + if (globMatch(valueVariant, patternVariant, { nocase })) return true; + } + } + return false; +} + +function pathVariants( + value: string, + semantics: PathMatchSemantics, + pathOptions: PermissionPathMatchOptions | undefined, +): string[] { + const variants = new Set(); + addPathVariant(variants, value, semantics.pathClass); + addPathVariant(variants, stripLeadingDotPath(value, semantics.pathClass), semantics.pathClass); + + const canonical = canonicalizePathPattern(value, semantics, pathOptions); + if (canonical !== undefined) addPathVariant(variants, canonical, semantics.pathClass); + return Array.from(variants); +} + +function canonicalizePathPattern( + value: string, + semantics: PathMatchSemantics, + pathOptions: PermissionPathMatchOptions | undefined, +): string | undefined { + const expanded = expandUserPath(value, semantics.pathClass, pathOptions?.homeDir); + const cwd = pathOptions?.cwd ?? defaultCwdForPath(expanded); + if (cwd === undefined) return undefined; + try { + return canonicalizePath(expanded, cwd, semantics.pathClass); + } catch { + return undefined; + } +} + +function expandUserPath( + value: string, + pathClass: PathClass, + homeDir: string | undefined, +): string { + if (homeDir === undefined) return value; + if (value === '~') return homeDir; + if (value.startsWith('~/') || (pathClass === 'win32' && value.startsWith('~\\'))) { + return join(homeDir, value.slice(2)); + } + return value; +} + +function defaultCwdForPath(value: string): string | undefined { + if (!isAbsolute(value)) return undefined; + return parse(value).root; +} + +function pathMatchSemantics( + value: string, + pattern: string, + pathOptions: PermissionPathMatchOptions | undefined, +): PathMatchSemantics { + const pathClass = + pathOptions?.pathClass ?? + ([value, pattern].some((candidate) => { + return ( + /^[A-Za-z]:(?:[\\/]|$)/.test(candidate) || + candidate.startsWith('\\\\') || + candidate.includes('\\') + ); + }) + ? 'win32' + : 'posix'); + return { pathClass }; +} + +function addPathVariant(variants: Set, value: string, pathClass: PathClass): void { + variants.add(value); + if (pathClass === 'win32') variants.add(value.replaceAll('\\', '/')); +} + +function stripLeadingDotPath(value: string, pathClass: PathClass): string { + if (value.startsWith('./')) return value.slice(2); + if (pathClass === 'win32' && value.startsWith('.\\')) return value.slice(2); + return value; +} + +const GLOB_LITERAL_SPECIAL = /[\\*?[\]{}()!+@|]/g; + +export function literalRulePattern(toolName: string, subject: string): string { + return `${toolName}(${escapeRuleSubjectLiteral(subject)})`; +} + +export function escapeRuleSubjectLiteral(subject: string): string { + return subject.replace(GLOB_LITERAL_SPECIAL, '\\$&'); +} + +export function matchesGlobRuleSubject(ruleArgs: string, subject: string): boolean { + return matchRuleSubjects(ruleArgs, [subject], (pattern, value) => globMatch(value, pattern)); +} + +export function matchesPathRuleSubject( + ruleArgs: string, + subject: string, + options?: PermissionPathMatchOptions, +): boolean { + return matchRuleSubjects(ruleArgs, [subject], (pattern, value) => + pathGlobMatch(value, pattern, options), + ); +} + +function matchRuleSubjects( + ruleArgs: string, + subjects: readonly string[], + matchesPositivePattern: (pattern: string, subject: string) => boolean, +): boolean { + if (ruleArgs.length === 0) return true; + const negated = ruleArgs.startsWith('!'); + const positivePattern = negated ? ruleArgs.slice(1) : ruleArgs; + const hit = subjects.some((subject) => matchesPositivePattern(positivePattern, subject)); + return negated ? !hit : hit; +} diff --git a/packages/agent-core-v2/src/tool/tool-args-parse.ts b/packages/agent-core-v2/src/tool/tool-args-parse.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1a8d969a8127af4e076284f999bdfe18dec50d2 --- /dev/null +++ b/packages/agent-core-v2/src/tool/tool-args-parse.ts @@ -0,0 +1,21 @@ +export function parseToolCallArguments(raw: unknown): { + readonly data: unknown; + readonly parseFailed: boolean; + readonly error?: string; +} { + if (raw === null || raw === undefined || (typeof raw === 'string' && raw.length === 0)) { + return { data: {}, parseFailed: false }; + } + if (typeof raw !== 'string') { + return { data: raw, parseFailed: false }; + } + try { + return { data: JSON.parse(raw) as unknown, parseFailed: false }; + } catch (error) { + return { + data: {}, + parseFailed: true, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts new file mode 100644 index 0000000000000000000000000000000000000000..e319c4fa1c84d7af3776a8ab86529c3eed3c1f73 --- /dev/null +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -0,0 +1,241 @@ +import type { ContentPart, ToolCall, ToolDescription as Tool } from '#human/llm/message'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; + +export type ExecutableToolOutput = string | ContentPart[]; + +export const DEFAULT_TOOL_RESULT_MAX_CHARS = 50_000; + +export const DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS = 10_000_000; + +export interface ToolResultSpill { + readonly outputPath?: string; + readonly totalChars?: number; + readonly suffix?: string; +} + +export type ToolDeliveryKind = 'steer'; + +export interface ToolDeliveryMessage { + readonly role: 'user'; + readonly content: readonly ContentPart[]; + readonly toolCalls?: readonly ToolCall[]; + readonly origin?: unknown; +} + +export interface ToolDelivery { + readonly kind: ToolDeliveryKind; + readonly message: ToolDeliveryMessage; +} + +export interface ExecutableToolSuccessResult { + readonly output: ExecutableToolOutput; + readonly isError?: false | undefined; + readonly stopTurn?: boolean | undefined; + readonly stopTurnReason?: string; + readonly truncated?: boolean | undefined; + readonly note?: string; + readonly delivery?: ToolDelivery | undefined; + readonly spill?: ToolResultSpill; + readonly spillExempt?: true; +} + +export interface ExecutableToolErrorResult { + readonly output: ExecutableToolOutput; + readonly isError: true; + readonly stopTurn?: boolean | undefined; + readonly stopTurnReason?: string; + readonly truncated?: boolean | undefined; + readonly note?: string; + readonly delivery?: ToolDelivery | undefined; + readonly spill?: ToolResultSpill; + readonly spillExempt?: true; +} + +export type ExecutableToolResult = ExecutableToolSuccessResult | ExecutableToolErrorResult; + +export interface ToolUpdate { + kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom'; + text?: string | undefined; + percent?: number | undefined; + customKind?: string | undefined; + customData?: unknown; + replace?: boolean; +} + +export const MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE = 'mcp.oauth.authorization_url'; + +export interface ExecutableToolContext { + readonly turnId: number; + readonly toolCallId: string; + readonly trace?: LLMRequestTrace; + readonly metadata?: unknown; + readonly signal: AbortSignal; + readonly steerSignal?: AbortSignal; + readonly onUpdate?: ((update: ToolUpdate) => void) | undefined; + readonly onForegroundTaskStart?: ((taskId: string) => void) | undefined; +} + +export interface RunnableToolExecution { + readonly isError?: false | undefined; + readonly accesses?: ToolAccesses | undefined; + readonly display?: ToolInputDisplay | undefined; + readonly description?: string; + readonly stopBatchAfterThis?: boolean | undefined; + readonly approvalRule: string; + readonly matchesRule?: ((ruleArgs: string) => boolean) | undefined; + readonly execute: (ctx: ExecutableToolContext) => Promise; +} + +export type ToolExecution = RunnableToolExecution | ExecutableToolErrorResult; + +export interface ExecutableTool extends Tool { + resolveExecution(input: Input): ToolExecution | Promise; +} + +export type ToolSource = 'builtin' | 'user' | 'mcp'; +export type ToolDisclosure = 'inline' | 'deferred'; + +export interface ToolDefinition { + readonly name: string; + readonly description: string; + readonly parameters?: Record; + readonly source?: ToolSource; + readonly disclosure?: ToolDisclosure; + readonly info?: Record; +} + +export interface ToolInfo extends ToolDefinition { + readonly source: ToolSource; +} + +export interface AgentTool extends ExecutableTool { + readonly _serviceBrand: undefined; +} + +export type ToolResult = ExecutableToolResult & { + readonly description?: string; + readonly display?: ToolInputDisplay; + readonly approvalRule?: string; + readonly stopBatchAfterThis?: boolean; +}; + +export type ToolFileAccessOperation = 'read' | 'write' | 'readwrite' | 'search'; + +export interface ToolFileAccess { + readonly kind: 'file'; + readonly operation: ToolFileAccessOperation; + readonly path: string; + readonly recursive?: boolean; +} + +export interface ToolResourceAccessAll { + readonly kind: 'all'; +} + +export type ToolResourceAccess = ToolFileAccess | ToolResourceAccessAll; +export type ToolAccesses = readonly ToolResourceAccess[]; + +export const ToolAccesses = { + none(): ToolAccesses { + return []; + }, + + all(): ToolAccesses { + return [{ kind: 'all' }]; + }, + + file( + operation: ToolFileAccessOperation, + path: string, + options: { readonly recursive?: boolean } = {}, + ): ToolAccesses { + return [{ kind: 'file', operation, path, recursive: options.recursive }]; + }, + + readFile(path: string): ToolAccesses { + return ToolAccesses.file('read', path); + }, + + readTree(path: string): ToolAccesses { + return ToolAccesses.file('read', path, { recursive: true }); + }, + + writeFile(path: string): ToolAccesses { + return ToolAccesses.file('write', path); + }, + + writeTree(path: string): ToolAccesses { + return ToolAccesses.file('write', path, { recursive: true }); + }, + + readWriteFile(path: string): ToolAccesses { + return ToolAccesses.file('readwrite', path); + }, + + readWriteTree(path: string): ToolAccesses { + return ToolAccesses.file('readwrite', path, { recursive: true }); + }, + + searchTree(path: string): ToolAccesses { + return ToolAccesses.file('search', path, { recursive: true }); + }, + + conflict(left: ToolAccesses, right: ToolAccesses): boolean { + return left.some((leftAccess) => + right.some((rightAccess) => resourceAccessesConflict(leftAccess, rightAccess)), + ); + }, +}; + +function resourceAccessesConflict(left: ToolResourceAccess, right: ToolResourceAccess): boolean { + if (left.kind === 'all' || right.kind === 'all') return true; + if (!fileOperationsConflict(left.operation, right.operation)) return false; + return fileAccessesOverlap(left, right); +} + +function fileOperationsConflict( + left: ToolFileAccessOperation, + right: ToolFileAccessOperation, +): boolean { + return fileOperationWrites(left) || fileOperationWrites(right); +} + +function fileOperationWrites(operation: ToolFileAccessOperation): boolean { + switch (operation) { + case 'read': + case 'search': + return false; + case 'write': + case 'readwrite': + return true; + } +} + +function fileAccessesOverlap(left: ToolFileAccess, right: ToolFileAccess): boolean { + const leftPath = normalizePath(left.path); + const rightPath = normalizePath(right.path); + if (leftPath === rightPath) return true; + + const leftPrefix = leftPath.endsWith('/') ? leftPath : `${leftPath}/`; + const rightPrefix = rightPath.endsWith('/') ? rightPath : `${rightPath}/`; + return ( + (left.recursive === true && rightPath.startsWith(leftPrefix)) || + (right.recursive === true && leftPath.startsWith(rightPrefix)) + ); +} + +function normalizePath(path: string): string { + const normalized = path.replaceAll('\\', '/').replaceAll(/\/+/g, '/'); + const folded = normalized.toLowerCase(); + if (folded.length > 1 && folded.endsWith('/')) { + return folded.slice(0, -1); + } + return folded; +} + +const MCP_NAME_PREFIX = 'mcp__'; + +export function isMcpToolName(name: string): boolean { + return name.startsWith(MCP_NAME_PREFIX); +} diff --git a/packages/agent-core-v2/src/tool/toolInputDisplay.ts b/packages/agent-core-v2/src/tool/toolInputDisplay.ts new file mode 100644 index 0000000000000000000000000000000000000000..70263161b772ca46043e19310c73e38378f774b3 --- /dev/null +++ b/packages/agent-core-v2/src/tool/toolInputDisplay.ts @@ -0,0 +1,78 @@ +export type ToolInputDisplay = + | { + kind: 'command'; + command: string; + cwd?: string | undefined; + description?: string | undefined; + language?: 'bash' | undefined; + } + | { + kind: 'file_io'; + operation: 'read' | 'write' | 'edit' | 'glob' | 'grep'; + path: string; + detail?: string | undefined; + content?: string | undefined; + before?: string | undefined; + after?: string | undefined; + } + | { + kind: 'diff'; + path: string; + before: string; + after: string; + hunks?: number | undefined; + } + | { + kind: 'search'; + query: string; + scope?: string | undefined; + } + | { + kind: 'url_fetch'; + url: string; + method?: string | undefined; + } + | { + kind: 'agent_call'; + agent_name: string; + prompt: string; + background?: boolean | undefined; + } + | { + kind: 'skill_call'; + skill_name: string; + args?: string | undefined; + } + | { + kind: 'todo_list'; + items: { title: string; status: string }[]; + } + | { + kind: 'task'; + task_id: string; + status: string; + description: string; + task_kind?: string | undefined; + } + | { + kind: 'task_stop'; + task_id: string; + task_description: string; + } + | { + kind: 'plan_review'; + plan: string; + path?: string | undefined; + options?: readonly { label: string; description: string }[] | undefined; + } + | { + kind: 'goal_start'; + objective: string; + completionCriterion?: string | undefined; + mode: 'manual' | 'yolo'; + } + | { + kind: 'generic'; + summary: string; + detail?: unknown; + }; diff --git a/packages/agent-core-v2/src/wire/errors.ts b/packages/agent-core-v2/src/wire/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1d515a5dbde45b64090eac60659448af60af785 --- /dev/null +++ b/packages/agent-core-v2/src/wire/errors.ts @@ -0,0 +1,40 @@ +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; +import { Error2, type Error2Options } from '#/_base/errors/errors'; + +export const WireErrors = { + codes: { + WIRE_UNKNOWN_RECORD: 'wire.unknown_record', + WIRE_MIGRATION_MISSING: 'wire.migration_missing', + RECORDS_WRITE_FAILED: 'records.write_failed', + }, + info: { + 'wire.unknown_record': { + title: 'Unknown wire record', + retryable: false, + public: true, + action: 'The record was written by a newer version; upgrade or drop it.', + }, + 'wire.migration_missing': { + title: 'Wire migration missing', + retryable: false, + public: true, + action: 'The wire file predates the supported migration chain; start a new session.', + }, + 'records.write_failed': { + title: 'Wire journal write failed', + retryable: false, + public: true, + }, + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(WireErrors); + +export type WireErrorCode = (typeof WireErrors.codes)[keyof typeof WireErrors.codes]; + +export class WireError extends Error2 { + constructor(code: WireErrorCode, message: string, options?: Error2Options) { + super(code, message, options); + this.name = 'WireError'; + } +} diff --git a/packages/agent-core-v2/src/wire/human.ts b/packages/agent-core-v2/src/wire/human.ts new file mode 100644 index 0000000000000000000000000000000000000000..82ee26ba9f092eb85a7f6448dad6be70d6ed84f2 --- /dev/null +++ b/packages/agent-core-v2/src/wire/human.ts @@ -0,0 +1,30 @@ +import { AGENT_SWITCHED_TYPE } from './tree/tree'; + +export const HUMAN_AGENT_DOMAIN = 'agent'; + +export const AGENT_WIRE_RECORD_TYPES: ReadonlySet = new Set([ + AGENT_SWITCHED_TYPE, + 'agent.message.appended', + 'agent.turn.started', + 'agent.turn.ended', +]); + +const LEGACY_HUMAN_RECORD_PREFIX = 'human.'; + +export function isHumanRecordType(type: string): boolean { + return AGENT_WIRE_RECORD_TYPES.has(type) || type.startsWith(LEGACY_HUMAN_RECORD_PREFIX); +} + +export function humanRecordType(domain: string, type: string): string { + return `${domain}.${type}`; +} + +export function humanEventType(recordType: string, domain: string): string | undefined { + if (recordType === AGENT_SWITCHED_TYPE) return undefined; + for (const prefix of [`${domain}.`, `${LEGACY_HUMAN_RECORD_PREFIX}${domain}.`]) { + if (!recordType.startsWith(prefix)) continue; + const type = recordType.slice(prefix.length); + return type.length === 0 ? undefined : type; + } + return undefined; +} diff --git a/packages/agent-core-v2/src/wire/journal.ts b/packages/agent-core-v2/src/wire/journal.ts new file mode 100644 index 0000000000000000000000000000000000000000..17a3ddd0ae90e7f3f15f4eeed4e56ef2cfd92bf5 --- /dev/null +++ b/packages/agent-core-v2/src/wire/journal.ts @@ -0,0 +1,30 @@ +import type { RecordDehydrator, WireRecord } from './record'; + +export interface AgentJournalRef { + readonly tree: string; + readonly branch: string; +} + +export interface SwitchBranchInput { + readonly turns: number; + readonly reason?: string; + readonly fromTurnId?: number; +} + +export interface SwitchedBranch { + readonly branch: string; + readonly base: { readonly branch: string; readonly line: number }; + readonly edgeLine: number; + readonly forkLine: number; +} + +export interface IAgentJournal { + readonly journalRef: AgentJournalRef; + append(record: WireRecord, dehydrate?: RecordDehydrator): void; + read(): AsyncIterable; + readRaw(): AsyncIterable; + switchBranch(input: SwitchBranchInput): Promise; + branches(): readonly string[]; + nextSeq(): number; + settled(): Promise; +} diff --git a/packages/agent-core-v2/src/wire/record.ts b/packages/agent-core-v2/src/wire/record.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5fbd16d7152a8236e910239fd9c70aa2696556b --- /dev/null +++ b/packages/agent-core-v2/src/wire/record.ts @@ -0,0 +1,52 @@ +import { WIRE_PROTOCOL_VERSION } from './migration/migration'; + +export const AGENT_WIRE_RECORD_KEY = 'wire.jsonl'; + +export type PartsTransformer = (parts: readonly unknown[]) => Promise; + +export type RecordDehydrator = ( + record: WireRecord, + transform: PartsTransformer, +) => WireRecord | Promise; + +export interface WireLineRange { + readonly start: number; + readonly end: number; +} + +export interface WireRecord { + readonly type: string; + readonly time?: number; + readonly [key: string]: unknown; +} + +export interface WireMetadataRecord extends WireRecord { + readonly type: 'metadata'; + readonly protocol_version: string; + readonly created_at: number; +} + +export function isWireRecord(record: unknown): record is WireRecord { + return ( + record !== null && + typeof record === 'object' && + !Array.isArray(record) && + typeof (record as { type?: unknown }).type === 'string' + ); +} + +export function createWireMetadataRecord(now = Date.now()): WireMetadataRecord { + return { + type: 'metadata', + protocol_version: WIRE_PROTOCOL_VERSION, + created_at: now, + }; +} + +export function isWireMetadataRecord(record: WireRecord): record is WireMetadataRecord { + return ( + record.type === 'metadata' && + typeof record['protocol_version'] === 'string' && + typeof record['created_at'] === 'number' + ); +} diff --git a/packages/agent-core-v2/src/wire/repair.ts b/packages/agent-core-v2/src/wire/repair.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec40e4fc081a23c5716bdcff3d726fb2e6027370 --- /dev/null +++ b/packages/agent-core-v2/src/wire/repair.ts @@ -0,0 +1,78 @@ +import type { ILogService } from '#/_base/log/log'; +import type { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { + AppendLogTruncation, + IAppendLogStore, +} from '#/persistence/interface/appendLogStore'; +import type { IFileSystemStorageService } from '#/persistence/interface/storage'; + +export interface WireJournalRepairServices { + readonly appendLog: IAppendLogStore; + readonly storage: IFileSystemStorageService; + readonly log: ILogService; + readonly telemetry: ITelemetryService; +} + +export function wireJournalBackupKey(key: string): string { + return `${key}.bak`; +} + +export async function repairWireJournal( + services: WireJournalRepairServices, + scope: string, + key: string, + records: readonly unknown[], + truncation: AppendLogTruncation, +): Promise<'repaired' | 'failed'> { + const { appendLog, storage, log, telemetry } = services; + let backupCreated = false; + let outcome: 'repaired' | 'failed' = 'repaired'; + let droppedCount = 0; + let repairError: unknown; + try { + const original = await storage.read(scope, key); + if (original !== undefined) { + droppedCount = Math.max(0, countJournalLines(original) - records.length); + const backupKey = wireJournalBackupKey(key); + if ((await storage.size(scope, backupKey)) === undefined) { + await storage.write(scope, backupKey, original, { atomic: true }); + backupCreated = true; + } + } + await appendLog.rewrite(scope, key, records); + } catch (error) { + outcome = 'failed'; + repairError = error; + } + log.warn('corrupted wire journal truncated to its valid prefix', { + scope, + key, + lineNumber: truncation.lineNumber, + reason: truncation.reason, + outcome, + droppedCount, + backupCreated, + error: repairError instanceof Error ? repairError.message : undefined, + }); + telemetry.track2('wire_repair', { + kind: truncation.reason, + outcome, + dropped_count: droppedCount, + backup_created: backupCreated, + }); + return outcome; +} + +function countJournalLines(data: Uint8Array): number { + let lines = 0; + let hasContent = false; + for (const byte of data) { + if (byte === 0x0a) { + lines++; + hasContent = false; + } else { + hasContent = true; + } + } + return hasContent ? lines + 1 : lines; +} diff --git a/packages/agent-core-v2/src/wire/wire.ts b/packages/agent-core-v2/src/wire/wire.ts new file mode 100644 index 0000000000000000000000000000000000000000..cef68f629e766864400846f3981304f34337d9e8 --- /dev/null +++ b/packages/agent-core-v2/src/wire/wire.ts @@ -0,0 +1,25 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { IAgentJournal } from './journal'; +import type { RecordDehydrator, WireRecord } from './record'; + +export { ForkLineError, type ForkLineFailure } from './tree'; +import type { WireLine } from './tree'; + +export interface IWireService extends IAgentJournal { + readonly _serviceBrand: undefined; + + seal(): Promise; + appendRecord(record: WireRecord, dehydrate?: RecordDehydrator): void; + readJournal(): AsyncIterable; + readRestorable(): AsyncIterable; + readHumanChain(): readonly WireLine[]; + flush(): Promise; + drainPersisted(): Promise; + lineCount(): number; + lastContextClearLine(): number | undefined; + journalPath(): string | undefined; +} + +export const IWireService: ServiceIdentifier = + createDecorator('wireService'); diff --git a/packages/agent-core-v2/src/wire/wireService.ts b/packages/agent-core-v2/src/wire/wireService.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8ce23b800b2ea3527f532f082394283a8b78ce1 --- /dev/null +++ b/packages/agent-core-v2/src/wire/wireService.ts @@ -0,0 +1,600 @@ +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import { Service } from '#/_base/di/service'; +import { ILogService } from '#/_base/log/log'; +import { IAgentBlobService } from '#/agent/blob/agentBlobService'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { ContentPart } from '#human/llm/message'; +import { + type AppendLogTruncation, + IAppendLogStore, +} from '#/persistence/interface/appendLogStore'; +import { IFileSystemStorageService, StorageError, StorageErrors } from '#/persistence/interface/storage'; + +import { IWireService } from './wire'; +import { WireError, WireErrors } from './errors'; +import { isHumanRecordType } from './human'; +import { + type AgentJournalRef, + type IAgentJournal, + type SwitchedBranch, + type SwitchBranchInput, +} from './journal'; +import { repairWireJournal } from './repair'; +import { + activeChain, + AGENT_SWITCHED_TYPE, + branchForLine, + buildUndoSwitchRecords, + computeForkLine, + MAIN_BRANCH, + parseTree, + restorableChain, + type UndoSwitchRecords, + type WireLine, + type WireTree, +} from './tree'; +import { + WIRE_PROTOCOL_VERSION, + isNewerWireVersion, + migrateV1_4ToV1_5, + migrateWireRecord, + resolveWireMigrations, + type WireMigration, +} from './migration/migration'; +import { + AGENT_WIRE_RECORD_KEY, + createWireMetadataRecord, + isWireRecord, + isWireMetadataRecord, + type PartsTransformer, + type RecordDehydrator, + type WireRecord, +} from './record'; + +export class WireService extends Service implements IWireService, IAgentJournal { + declare readonly _serviceBrand: undefined; + + private readonly wireScope: string; + private lines = 0; + private lastClearLine: number | undefined; + private readonly agentId: string; + private persistQueue: Promise | undefined; + private pendingRepair: + | { readonly records: WireRecord[]; readonly truncation: AppendLogTruncation } + | undefined; + private persistError: Error | undefined; + private treeSnapshot: WireTree | undefined; + private writeGeneration = 0; + private lastReadLineCount = 0; + private modeTwoEntries: WireLine[] = []; + + constructor( + @IAgentScopeContext scopeContext: IAgentScopeContext, + @IAppendLogStore private readonly log: IAppendLogStore, + @IAgentBlobService private readonly blobService: IAgentBlobService, + @IFileSystemStorageService private readonly storage: IFileSystemStorageService, + @ILogService private readonly logger: ILogService, + @ITelemetryService private readonly telemetry: ITelemetryService, + ) { + super(); + this.wireScope = scopeContext.scope(); + this.agentId = scopeContext.agentId; + this._register(this.log.acquire(this.wireScope, AGENT_WIRE_RECORD_KEY)); + } + + async seal(): Promise { + const tolerate = { onTruncate: () => {} }; + for await (const record of this.log.read(this.wireScope, AGENT_WIRE_RECORD_KEY, tolerate)) { + void record; + return; + } + this.appendRecordLow(createWireMetadataRecord()); + } + + appendRecord(record: WireRecord, dehydrate?: RecordDehydrator): void { + if ( + this.pendingRepair === undefined && + dehydrate === undefined && + this.persistQueue === undefined + ) { + try { + this.appendRecordLow(record); + } catch (error) { + onUnexpectedError(error); + } + return; + } + const transform: PartsTransformer = (parts) => + this.blobService.offloadParts( + parts as readonly ContentPart[], + ) as Promise; + const queued = (this.persistQueue ?? Promise.resolve()) + .then(async () => { + if (this.pendingRepair !== undefined) { + await this.repairPendingJournal(); + } + const output = dehydrate === undefined ? record : await dehydrate(record, transform); + this.appendRecordLow(output); + }) + .catch((error: unknown) => onUnexpectedError(error)); + this.persistQueue = queued; + void queued.then(() => { + if (this.persistQueue === queued) this.persistQueue = undefined; + }); + } + + async *readJournal(): AsyncIterable { + for await (const { record } of this.readEntries()) { + yield record; + } + } + + get journalRef(): AgentJournalRef { + return { tree: this.wireScope, branch: this.treeSnapshot?.activeBranch ?? MAIN_BRANCH }; + } + + append(record: WireRecord, dehydrate?: RecordDehydrator): void { + this.appendRecord(record, dehydrate); + } + + async *read(): AsyncIterable { + const entries = await this.readStableEntries(); + const tree = parseTree(entries, entries.at(-1)?.line ?? 0); + this.treeSnapshot = tree; + this.reportTreeDiagnostics(tree); + for (const { record } of activeChain(entries, tree)) { + yield record; + } + } + + readRaw(): AsyncIterable { + return this.readJournal(); + } + + readHumanChain(): readonly WireLine[] { + const tree = parseTree(this.modeTwoEntries, this.lines); + return activeChain(this.modeTwoEntries, tree); + } + + async *readRestorable(): AsyncIterable { + const entries = await this.readStableEntries(); + const tree = parseTree(entries, entries.at(-1)?.line ?? 0); + this.treeSnapshot = tree; + this.reportTreeDiagnostics(tree); + for (const { record } of restorableChain(entries, tree)) { + yield record; + } + } + + async switchBranch(input: SwitchBranchInput): Promise { + let entries: WireLine[] | undefined; + for (let attempt = 0; attempt < 2 && entries === undefined; attempt++) { + await this.drainPersisted(); + const read = await this.readStableEntries(); + if (this.lines === this.lastReadLineCount) entries = read; + } + if (entries === undefined) { + throw new WireError( + WireErrors.codes.RECORDS_WRITE_FAILED, + 'Wire journal changed while switching branches', + { details: { scope: this.wireScope, lines: this.lines, read: this.lastReadLineCount } }, + ); + } + const lastLine = entries.at(-1)?.line ?? 0; + const tree = parseTree(entries, lastLine); + this.reportTreeDiagnostics(tree); + const forkLine = computeForkLine( + activeChain(entries, tree), + tree.pairedLegacyUndoLines, + input.turns, + ); + const base = { branch: branchForLine(tree, forkLine), line: forkLine }; + const branch = `b${tree.edges.length + 1}`; + const edgeLine = this.lines + 1; + const records = buildUndoSwitchRecords({ + agentId: this.agentId, + branch, + reason: input.reason ?? 'undo', + base, + turns: input.turns, + edgeLine, + fromTurnId: input.fromTurnId, + time: Date.now(), + }); + this.appendRecord(records.switched); + this.appendRecord(records.legacyUndo); + this.appendRecord(records.undone); + await this.flush(); + await this.assertSwitchTripleAppended(records, edgeLine); + const appended: WireLine[] = [ + { record: records.switched, line: edgeLine }, + { record: records.legacyUndo, line: edgeLine + 1 }, + { record: records.undone, line: edgeLine + 2 }, + ]; + this.treeSnapshot = parseTree([...entries, ...appended], edgeLine + 2); + return { branch, base, edgeLine, forkLine }; + } + + private async assertSwitchTripleAppended( + records: UndoSwitchRecords, + edgeLine: number, + ): Promise { + const tail: WireRecord[] = []; + let total = 0; + const tolerate = { onTruncate: () => {} }; + for await (const record of this.log.read( + this.wireScope, + AGENT_WIRE_RECORD_KEY, + tolerate, + )) { + total += 1; + tail.push(record); + if (tail.length > 3) tail.shift(); + } + const expected = [records.switched, records.legacyUndo, records.undone]; + const matches = + total === edgeLine + 2 && + tail.length === 3 && + tail.every((record, index) => recordsMatch(record, expected[index]!)); + if (matches) return; + throw new WireError( + WireErrors.codes.RECORDS_WRITE_FAILED, + 'Wire journal changed while the undo switch triple was appended', + { details: { scope: this.wireScope, lines: total, edgeLine } }, + ); + } + + private async readStableEntries(): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + const generation = this.writeGeneration; + const entries: WireLine[] = []; + for await (const entry of this.readEntries()) { + entries.push(entry); + } + if (this.writeGeneration === generation) return entries; + } + throw new WireError( + WireErrors.codes.RECORDS_WRITE_FAILED, + 'Wire journal kept rewriting while being read', + { details: { scope: this.wireScope } }, + ); + } + + private reportTreeDiagnostics(tree: WireTree): void { + for (const line of tree.diagnostics.malformedSwitchLines) { + onUnexpectedError( + new WireError( + WireErrors.codes.WIRE_UNKNOWN_RECORD, + 'Malformed agent.switched record ignored during tree projection', + { details: { scope: this.wireScope, type: AGENT_SWITCHED_TYPE, line } }, + ), + ); + } + for (const branch of tree.diagnostics.duplicateBranches) { + onUnexpectedError( + new WireError( + WireErrors.codes.WIRE_UNKNOWN_RECORD, + `Duplicate agent.switched branch '${branch}' ignored during tree projection`, + { details: { scope: this.wireScope, type: AGENT_SWITCHED_TYPE, branch } }, + ), + ); + } + } + + branches(): readonly string[] { + const tree = this.treeSnapshot; + if (tree === undefined) return [MAIN_BRANCH]; + return tree.segments.map((segment) => segment.branch); + } + + nextSeq(): number { + return this.lines + 1; + } + + settled(): Promise { + return this.drainPersisted(); + } + + private async *readEntries(): AsyncIterable { + let truncation: AppendLogTruncation | undefined; + const source = this.log.read(this.wireScope, AGENT_WIRE_RECORD_KEY, { + onTruncate: (info) => { + truncation = info; + }, + }); + let migrations: readonly WireMigration[] = []; + let rewrittenRecords: WireRecord[] | undefined; + let newerWireVersion = false; + let recordIndex = 0; + let lineCount = 0; + let hasRecords = false; + let legacyPlanRevisionMigrated = false; + const modeTwoEntries: WireLine[] = []; + const modeTwoLengthAtStart = this.modeTwoEntries.length; + + for await (const candidate of source) { + lineCount++; + this.lines = lineCount; + const sourceRecord: unknown = candidate; + if (!isWireRecord(sourceRecord)) { + this.reportSkippedRecord(undefined, recordIndex, true); + recordIndex++; + continue; + } + if (sourceRecord.type === 'context.clear') this.lastClearLine = lineCount; + if (!hasRecords) { + hasRecords = true; + if (sourceRecord.type !== 'metadata') { + rewrittenRecords = [createWireMetadataRecord()]; + migrations = [migrateV1_4ToV1_5]; + } else if (!isWireMetadataRecord(sourceRecord)) { + throw new StorageError( + StorageErrors.codes.STORAGE_CORRUPTED, + 'Agent wire metadata is malformed', + { details: { scope: this.wireScope, key: AGENT_WIRE_RECORD_KEY } }, + ); + } else if (isNewerWireVersion(sourceRecord.protocol_version)) { + newerWireVersion = true; + } else { + migrations = resolveWireMigrations(sourceRecord.protocol_version); + if (sourceRecord.protocol_version !== WIRE_PROTOCOL_VERSION) { + rewrittenRecords = []; + } + } + } + + const migratedRecord = migrateWireRecord(sourceRecord, migrations); + const record = + !newerWireVersion && migratedRecord.type === 'metadata' + ? { ...migratedRecord, protocol_version: WIRE_PROTOCOL_VERSION } + : migratedRecord; + const normalized = newerWireVersion + ? record + : this.normalizePlanRevisionRecord(record, recordIndex); + if ( + !newerWireVersion && + record.type === 'plan.revision' && + normalized !== undefined && + 'path' in record && + !('key' in record) + ) { + legacyPlanRevisionMigrated = true; + } + if (normalized === undefined) { + if (record.type === 'plan.revision') recordIndex++; + continue; + } + rewrittenRecords?.push(normalized); + if (isHumanRecordType(normalized.type)) { + modeTwoEntries.push({ record: normalized, line: lineCount }); + } + yield { record: normalized, line: lineCount }; + if (normalized.type !== 'metadata') { + recordIndex++; + } + } + + if (legacyPlanRevisionMigrated && rewrittenRecords === undefined) { + rewrittenRecords = await this.rebuildRewriteRecords(migrations, newerWireVersion); + } + if (!hasRecords) { + rewrittenRecords = [createWireMetadataRecord()]; + } + if (truncation !== undefined) { + await this.repairJournal(truncation, rewrittenRecords); + } else if (rewrittenRecords !== undefined) { + await this.log.rewrite(this.wireScope, AGENT_WIRE_RECORD_KEY, rewrittenRecords); + this.writeGeneration += 1; + this.lines = rewrittenRecords.length; + this.lastClearLine = lastContextClearLineOf(rewrittenRecords); + } + this.mergeModeTwoEntries(modeTwoEntries, this.modeTwoEntries.slice(modeTwoLengthAtStart), lineCount); + this.lastReadLineCount = lineCount; + } + + private mergeModeTwoEntries( + fresh: WireLine[], + appended: readonly WireLine[], + lineCount: number, + ): void { + let line = lineCount; + const merged = [...fresh]; + for (const entry of appended) { + if (fresh.some((candidate) => recordsMatch(candidate.record, entry.record))) continue; + line += 1; + merged.push({ record: entry.record, line }); + } + this.modeTwoEntries = merged; + } + + lineCount(): number { + return this.lines; + } + + lastContextClearLine(): number | undefined { + return this.lastClearLine; + } + + journalPath(): string | undefined { + return this.storage.pathFor(this.wireScope, AGENT_WIRE_RECORD_KEY); + } + + private async repairJournal( + truncation: AppendLogTruncation, + rewrittenRecords: WireRecord[] | undefined, + ): Promise { + let records: WireRecord[] = rewrittenRecords ?? []; + if (rewrittenRecords === undefined) { + const tolerate = { onTruncate: () => {} }; + for await (const record of this.log.read( + this.wireScope, + AGENT_WIRE_RECORD_KEY, + tolerate, + )) { + records.push(record); + } + } + const outcome = await repairWireJournal( + { + appendLog: this.log, + storage: this.storage, + log: this.logger, + telemetry: this.telemetry, + }, + this.wireScope, + AGENT_WIRE_RECORD_KEY, + records, + truncation, + ); + this.pendingRepair = outcome === 'failed' ? { records, truncation } : undefined; + if (outcome !== 'failed') { + this.writeGeneration += 1; + this.lines = records.length; + this.lastClearLine = lastContextClearLineOf(records); + } + } + + private async repairPendingJournal(): Promise { + const pending = this.pendingRepair; + if (pending === undefined) return; + await this.repairJournal(pending.truncation, pending.records); + if (this.pendingRepair !== undefined) { + const error = new WireError( + WireErrors.codes.RECORDS_WRITE_FAILED, + 'Wire journal repair did not complete; record was not appended', + { + details: { + scope: this.wireScope, + key: AGENT_WIRE_RECORD_KEY, + lineNumber: pending.truncation.lineNumber, + }, + }, + ); + this.persistError = error; + throw error; + } + } + + async drainPersisted(): Promise { + await this.persistQueue; + } + + async flush(): Promise { + await this.persistQueue; + const persistError = this.persistError; + this.persistError = undefined; + if (persistError !== undefined) throw persistError; + await this.log.flushLog(this.wireScope, AGENT_WIRE_RECORD_KEY); + } + + private async rebuildRewriteRecords( + migrations: readonly WireMigration[], + newerWireVersion: boolean, + ): Promise { + const records: WireRecord[] = []; + const tolerate = { onTruncate: () => {} }; + for await (const candidate of this.log.read( + this.wireScope, + AGENT_WIRE_RECORD_KEY, + tolerate, + )) { + if (!isWireRecord(candidate)) continue; + const migratedRecord = migrateWireRecord(candidate, migrations); + const record = + !newerWireVersion && migratedRecord.type === 'metadata' + ? { ...migratedRecord, protocol_version: WIRE_PROTOCOL_VERSION } + : migratedRecord; + const normalized = newerWireVersion + ? record + : this.normalizePlanRevisionRecord(record, 0, false); + if (normalized !== undefined) records.push(normalized); + } + return records; + } + + private normalizePlanRevisionRecord( + record: WireRecord, + index: number, + report = true, + ): WireRecord | undefined { + if (record.type !== 'plan.revision' || 'key' in record) return record; + if (!('path' in record) || typeof record['path'] !== 'string') { + if (report) { + this.telemetry.track2('wire_plan_revision_migrated', { + record_type: 'plan.revision', + legacy_field: 'path', + migration_outcome: 'skipped', + }); + this.reportSkippedRecord(record.type, index, true); + } + return undefined; + } + const key = extractLegacyPlanRevisionKey(record['path'], this.agentId); + if (report) { + this.telemetry.track2('wire_plan_revision_migrated', { + record_type: 'plan.revision', + legacy_field: 'path', + migration_outcome: key === undefined ? 'skipped' : 'migrated', + }); + } + if (key === undefined) { + if (report) this.reportSkippedRecord(record.type, index, true); + return undefined; + } + const { path: _path, ...rest } = record; + return { ...rest, key }; + } + + private reportSkippedRecord(type: string | undefined, index: number, malformed = false): void { + onUnexpectedError( + new WireError( + WireErrors.codes.WIRE_UNKNOWN_RECORD, + type === undefined + ? 'Malformed wire record skipped during restore' + : malformed + ? `Malformed wire record type '${type}' skipped during restore` + : `Unknown wire record type '${type}' skipped during restore`, + { details: { type, index } }, + ), + ); + } + + private appendRecordLow(record: WireRecord): void { + this.log.append(this.wireScope, AGENT_WIRE_RECORD_KEY, record, { + onError: onUnexpectedError, + }); + this.lines += 1; + if (isHumanRecordType(record.type)) { + this.modeTwoEntries.push({ record, line: this.lines }); + } + if (record.type === 'context.clear') this.lastClearLine = this.lines; + } +} + +function recordsMatch(a: WireRecord, b: WireRecord): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +function lastContextClearLineOf(records: readonly WireRecord[]): number | undefined { + for (let index = records.length - 1; index >= 0; index -= 1) { + if (records[index]!.type === 'context.clear') return index + 1; + } + return undefined; +} + +function extractLegacyPlanRevisionKey(path: string, agentId: string): string | undefined { + if (path.includes('\\')) return undefined; + const segments = path.split('/'); + if ( + segments.length < 8 || + segments[0] !== 'sessions' || + segments[3] !== 'agents' || + segments[4] !== agentId || + segments.slice(1, 3).some((segment) => segment.length === 0 || segment === '.' || segment === '..') + ) { + return undefined; + } + const key = segments.slice(5).join('/'); + return /^plan\/[^/]+\/v[0-9]+\.md$/.test(key) ? key : undefined; +} diff --git a/packages/agent-core-v2/test/hooks.test.ts b/packages/agent-core-v2/test/hooks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..59d169399118a9b9d442bc3d563f2679f2d6dad0 --- /dev/null +++ b/packages/agent-core-v2/test/hooks.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; + +import { OrderedHookSlot, createHooks } from '#/hooks'; + +describe('OrderedHookSlot', () => { + it('runs the terminal when no handlers are registered', async () => { + const slot = new OrderedHookSlot<{ value: number }>(); + let terminalRan = false; + await slot.run({ value: 0 }, async () => { + terminalRan = true; + }); + expect(terminalRan).toBe(true); + }); + + it('runs handlers in registration order and threads context', async () => { + const slot = new OrderedHookSlot<{ value: number }>(); + const order: string[] = []; + slot.register('a', async (ctx, next) => { + order.push('a'); + ctx.value += 1; + await next(); + }); + slot.register('b', async (ctx, next) => { + order.push('b'); + ctx.value += 10; + await next(); + }); + const ctx = { value: 0 }; + await slot.run(ctx); + expect(order).toEqual(['a', 'b']); + expect(ctx.value).toBe(11); + }); + + it('removes a handler with delete()', async () => { + const slot = new OrderedHookSlot>(); + const order: string[] = []; + slot.register('a', async (_ctx, next) => { + order.push('a'); + await next(); + }); + slot.register('b', async (_ctx, next) => { + order.push('b'); + await next(); + }); + expect(slot.delete('a')).toBe(true); + await slot.run({}); + expect(order).toEqual(['b']); + }); + + it('honors before / after ordering', async () => { + const slot = new OrderedHookSlot>(); + const order: string[] = []; + const mk = + (id: string) => + async (_ctx: Record, next: () => Promise) => { + order.push(id); + await next(); + }; + slot.register('a', mk('a')); + slot.register('c', mk('c')); + slot.register('b', mk('b'), { before: 'c' }); + await slot.run({}); + expect(order).toEqual(['a', 'b', 'c']); + }); + + it('re-runs the remaining chain when next() is called more than once', async () => { + const slot = new OrderedHookSlot>(); + let terminalRuns = 0; + slot.register('a', async (_ctx, next) => { + await next(); + await next(); + }); + await slot.run({}, async () => { + terminalRuns += 1; + }); + expect(terminalRuns).toBe(2); + }); + + it('skips downstream handlers and the terminal when next() is not called', async () => { + const slot = new OrderedHookSlot<{ value: number }>(); + const order: string[] = []; + slot.register('veto', () => { + order.push('veto'); + }); + slot.register('downstream', async (_ctx, next) => { + order.push('downstream'); + await next(); + }); + let terminalRan = false; + await slot.run({ value: 0 }, async () => { + terminalRan = true; + }); + expect(order).toEqual(['veto']); + expect(terminalRan).toBe(false); + }); + + it('re-runs the remaining chain and terminal on each next() call', async () => { + const slot = new OrderedHookSlot<{ attempt: number; result?: string }>(); + const attempts: number[] = []; + slot.register('retry', async (ctx, next) => { + ctx.attempt = 1; + await next(); + if (ctx.result === 'fail') { + ctx.attempt = 2; + await next(); + } + }); + slot.register('downstream', async (ctx, next) => { + attempts.push(ctx.attempt); + await next(); + }); + const ctx: { attempt: number; result?: string } = { attempt: 0 }; + await slot.run(ctx, async (current) => { + current.result = current.attempt === 1 ? 'fail' : 'ok'; + }); + expect(attempts).toEqual([1, 2]); + expect(ctx.result).toBe('ok'); + }); + + it('resumes the remaining chain and terminal with a forked context', async () => { + interface Ctx { + readonly id: number; + value: number; + result?: number; + } + const slot = new OrderedHookSlot(); + const seen: number[] = []; + slot.register('fork', async (ctx, next) => { + const fork: Ctx = { ...ctx, value: 100 }; + await next(fork); + ctx.result = fork.result; + }); + slot.register('downstream', async (ctx, next) => { + seen.push(ctx.value); + await next(); + }); + const original: Ctx = { id: 1, value: 0 }; + await slot.run(original, async (current) => { + current.result = current.value + 1; + }); + expect(seen).toEqual([100]); + expect(original.value).toBe(0); + expect(original.result).toBe(101); + }); + + it('propagates a fork through nested next() calls without arguments', async () => { + interface Ctx { + value: number; + } + const slot = new OrderedHookSlot(); + slot.register('fork', async (ctx, next) => { + await next({ ...ctx, value: 42 }); + }); + slot.register('middle', async (_ctx, next) => { + await next(); + }); + let terminalValue: number | undefined; + await slot.run({ value: 0 }, async (current) => { + terminalValue = current.value; + }); + expect(terminalValue).toBe(42); + }); + + it('runs forked branches concurrently', async () => { + interface Ctx { + input: number; + output?: number; + } + const slot = new OrderedHookSlot(); + slot.register('race', async (ctx, next) => { + const forks: Ctx[] = [ + { ...ctx, input: 1 }, + { ...ctx, input: 2 }, + ]; + await Promise.all(forks.map((fork) => next(fork))); + ctx.output = Math.max(...forks.map((fork) => fork.output ?? 0)); + }); + const ctx: Ctx = { input: 0 }; + await slot.run(ctx, async (current) => { + current.output = current.input * 10; + }); + expect(ctx.output).toBe(20); + }); +}); + +describe('createHooks', () => { + it('creates one slot per event key', () => { + type HookEvents = { start: { x: number }; stop: { y: number } }; + const hooks = createHooks(['start', 'stop']); + expect(hooks.start).toBeInstanceOf(OrderedHookSlot); + expect(hooks.stop).toBeInstanceOf(OrderedHookSlot); + }); +}); diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..483f41e9c62bea1380f66403a48bbee05b894b44 --- /dev/null +++ b/packages/agent-core-v2/test/index.test.ts @@ -0,0 +1,632 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + WIRE_PROTOCOL_VERSION, + EVENT2_REGISTRY, + IAgentContextMemoryService, + IAgentGoalService, + type ContextMessage, + type WireRecord, +} from '#/index'; +import { + InMemoryWireRecordPersistence, + createTestAgent, + testAgent, + type TestAgentContext, +} from './harness'; +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { + ContextAppendMessage, + ContextApplyCompaction, + ContextClear, + ContextUndo, +} from '#/agent/contextMemory/contextEvents'; +import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { TokenCountingMeasured } from '#/agent/tokenCounting/tokenCountingOps'; +import { TurnStepInterrupted } from '#/agent/loop/turnEvents'; +import { TurnStepRetrying } from '#/agent/loop/turnEvents'; +import { ToolsUpdateStore } from '#/features/todo/todoOps'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import type { Event2Class } from '#/app/event/event2'; +import { AGENT_WIRE_RECORD_KEY } from '#/wire/record'; +import { attachTodoService, registerTestAgentWire, registerTestEventDispatcher, restoreTestEventDispatcher } from './wire/stubs'; +import { BUILTIN_REPLAYABLE_STATE_KEYS } from './state/builtinReplayableKeys'; + +const V1_RECORD_TYPES: ReadonlySet = new Set([ + 'metadata', + 'forked', + 'turn.prompt', + 'turn.steer', + 'turn.cancel', + 'config.update', + 'permission.set_mode', + 'permission.record_approval_result', + 'full_compaction.begin', + 'full_compaction.cancel', + 'full_compaction.complete', + 'micro_compaction.apply', + 'plan_mode.enter', + 'plan_mode.cancel', + 'plan_mode.exit', + 'swarm_mode.enter', + 'swarm_mode.exit', + 'tools.register_user_tool', + 'tools.unregister_user_tool', + 'tools.set_active_tools', + 'tools.update_store', + 'usage.record', + 'context.append_message', + 'context.append_loop_event', + 'context.clear', + 'context.apply_compaction', + 'context.undo', + 'goal.create', + 'goal.update', + 'goal.clear', + 'llm.tools_snapshot', + 'llm.request', + 'mcp.tools_discovered', +]); +const V2_ONLY_RECORD_TYPES: ReadonlySet = new Set([ + 'tools.reset_active_tools', + 'profile.bind', +]); + +const V2_RECORD_TYPES: ReadonlySet = new Set([ + 'tower_mode.enter', + 'tower_mode.exit', + 'task.started', + 'task.terminated', + 'task.waitDelivered', + 'interaction.request', + 'interaction.resolved', + 'plan.revision', + 'file_history.tracked', + 'file_history.checkpoint', + 'interruptionReminder.recorded', + 'plugin.session_start', + 'runtime.set_binding', + 'turn.ended', + 'prompt.aborted', + 'prompt.completed', + 'prompt.steered', + 'token_counting.measured', + 'token_counting.truncated', + 'token_counting.rebased', + 'cron.add', + 'cron.delete', + 'cron.cursor', + 'token_counting.turn_recorded', + 'turn.step.retrying', + 'turn.step.interrupted', +]); + +describe('v1 wire vocabulary', () => { + const SCOPE = 'wire'; + + let disposables: DisposableStore; + let dispatcher: IEventDispatcher; + let log: IAppendLogStore; + + beforeEach(() => { + disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + ix.stub(IFileSystemStorageService, new InMemoryStorageService()); + ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + log = ix.get(IAppendLogStore); + registerTestAgentWire(ix, SCOPE, { log }); + dispatcher = registerTestEventDispatcher(ix); + }); + + afterEach(() => disposables.dispose()); + + async function readRecords(): Promise { + await dispatcher.flush(); + const out: WireRecord[] = []; + for await (const record of log.read(SCOPE, AGENT_WIRE_RECORD_KEY)) { + out.push(record); + } + return out; + } + + it('every durable event type is a known (v1 or v2) record type', () => { + for (const type of EVENT2_REGISTRY.keys()) { + expect( + V1_RECORD_TYPES.has(type) || + V2_ONLY_RECORD_TYPES.has(type) || + V2_RECORD_TYPES.has(type), + `event "${type}" persists an unregistered record type`, + ).toBe(true); + } + }); + + it('stamps persisted records with time, except the metadata envelope', async () => { + await dispatcher.restore(); + await dispatcher.dispatch( + new ToolsUpdateStore({ agentId: 'test-agent', key: 'todo', value: [{ title: 'x', status: 'pending' }] }), + ); + + const records = await readRecords(); + expect(records).toEqual([ + { + type: 'metadata', + protocol_version: WIRE_PROTOCOL_VERSION, + created_at: expect.any(Number), + }, + { + type: 'tools.update_store', + agentId: 'test-agent', + key: 'todo', + value: [{ title: 'x', status: 'pending' }], + time: expect.any(Number), + }, + ]); + }); + + it('persists step retrying and interrupted records with full payloads and replays them safely', async () => { + await dispatcher.restore(); + await dispatcher.dispatch( + new TurnStepRetrying({ + agentId: 'test-agent', + turnId: 1, + step: 2, + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 10, + delayMs: 500, + errorName: 'APIStatusError', + errorMessage: 'Overloaded', + statusCode: 429, + }), + ); + await dispatcher.dispatch( + new TurnStepInterrupted({ + agentId: 'test-agent', + turnId: 1, + step: 2, + reason: 'error', + message: 'boom', + }), + ); + const records = await readRecords(); + expect(records).toEqual([ + { + type: 'metadata', + protocol_version: WIRE_PROTOCOL_VERSION, + created_at: expect.any(Number), + }, + { + type: 'turn.step.retrying', + agentId: 'test-agent', + turnId: 1, + step: 2, + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 10, + delayMs: 500, + errorName: 'APIStatusError', + errorMessage: 'Overloaded', + statusCode: 429, + time: expect.any(Number), + }, + { + type: 'turn.step.interrupted', + agentId: 'test-agent', + turnId: 1, + step: 2, + reason: 'error', + message: 'boom', + time: expect.any(Number), + }, + ]); + + const store = new DisposableStore(); + disposables.add(store); + const ix2 = store.add(new TestInstantiationService()); + ix2.stub(IFileSystemStorageService, new InMemoryStorageService()); + ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + const log2 = ix2.get(IAppendLogStore); + registerTestAgentWire(ix2, SCOPE, { log: log2 }); + const fresh = registerTestEventDispatcher(ix2); + + await restoreTestEventDispatcher(fresh, log2, SCOPE, records); + + const replayed: WireRecord[] = []; + for await (const record of log2.read(SCOPE, AGENT_WIRE_RECORD_KEY)) { + replayed.push(record); + } + expect(replayed).toEqual(records); + }); + + it('round-trips the todo list through the persisted tools.update_store record', async () => { + await dispatcher.dispatch( + new ToolsUpdateStore({ agentId: 'test-agent', key: 'todo', value: [{ title: 'restore me', status: 'in_progress' }] }), + ); + const records = await readRecords(); + + const store = new DisposableStore(); + disposables.add(store); + const ix2 = store.add(new TestInstantiationService()); + ix2.stub(IFileSystemStorageService, new InMemoryStorageService()); + ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + const log2 = ix2.get(IAppendLogStore); + registerTestAgentWire(ix2, SCOPE, { log: log2 }); + const fresh = registerTestEventDispatcher(ix2); + const todo = attachTodoService(ix2); + store.add({ dispose: () => { todo.dispose(); } }); + + await restoreTestEventDispatcher(fresh, log2, SCOPE, records); + + expect(todo.get()).toEqual([ + { title: 'restore me', status: 'in_progress' }, + ]); + }); +}); + +describe('conversation-time checkpoint registration', () => { + const CHECKPOINT_EXEMPT_STATES: ReadonlySet = new Set([ + 'goalForkNotice', + 'turn', + 'fullCompaction.wireRanges', + ]); + const CONTEXT_OWNER_STATE = 'contextMemory'; + const CONTEXT_EVENTS: readonly Event2Class[] = [ + ContextAppendMessage, + ContextApplyCompaction, + ContextClear, + ContextUndo, + ]; + + it('registers every context-reacting state as checkpointed or explicitly exempt', () => { + const violations: string[] = []; + let entries = 0; + const undoable = BUILTIN_REPLAYABLE_STATE_KEYS.filter( + (key) => key.replayable.undoable !== undefined, + ); + for (const key of BUILTIN_REPLAYABLE_STATE_KEYS) { + if (key.name === CONTEXT_OWNER_STATE) continue; + if (!CONTEXT_EVENTS.some((cls) => key.replayable.folds.has(cls))) continue; + entries += 1; + if (undoable.includes(key)) continue; + if (CHECKPOINT_EXEMPT_STATES.has(key.name)) continue; + violations.push(key.name); + } + expect(entries).toBeGreaterThan(0); + expect(violations).toEqual([]); + }); +}); + +describe('AgentRecords persistence metadata', () => { + let context: IAgentContextMemoryService; + let tokenCounting: TestAgentContext['tokenCounting']; + let ctx: TestAgentContext; + let expectResumeMatches: boolean; + let persistence: RecordingInMemoryWireRecordPersistence; + + beforeEach(() => { + expectResumeMatches = true; + persistence = new RecordingInMemoryWireRecordPersistence(); + ctx = createTestAgent({ persistence, autoConfigure: false }); + context = ctx.get(IAgentContextMemoryService); + tokenCounting = ctx.tokenCounting; + }); + + afterEach(async () => { + try { + if (expectResumeMatches) { + await ctx.expectResumeMatches(); + } + } finally { + await ctx.dispose(); + } + }); + + it('heals an envelope-less stream on restore instead of rejecting it', async () => { + persistence.records.push( + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'orphaned prompt' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + ); + + expectResumeMatches = false; + await ctx.restorePersisted(); + + expect(persistence.records.map((record) => record.type)).toEqual([ + 'metadata', + 'context.append_message', + ]); + expect(persistence.records[0]).toMatchObject({ + type: 'metadata', + protocol_version: WIRE_PROTOCOL_VERSION, + }); + expect(ctx.context.get()).toHaveLength(1); + }); + + it('restores existing metadata records without rewriting them', async () => { + persistence.records.push( + { + type: 'metadata', + protocol_version: WIRE_PROTOCOL_VERSION, + created_at: 1, + }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'restored' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + ); + + await ctx.restorePersisted(); + + expect(persistence.rewrites).toEqual([]); + expect(persistence.records.filter((record) => record.type === 'metadata')).toHaveLength(1); + }); + + it('keeps restore history stable after a consumer stops reading the journal early', async () => { + persistence.records.push( + { type: 'metadata', protocol_version: WIRE_PROTOCOL_VERSION, created_at: 1 }, + ...['first', 'second'].map((text) => ({ + type: 'context.append_message', + message: { + role: 'user', content: [{ type: 'text', text }], toolCalls: [], origin: { kind: 'user' }, + }, + })), + ); + for await (const record of ctx.get(IAppendLogStore).read('', AGENT_WIRE_RECORD_KEY)) { + if (record.type === 'context.append_message') break; + } + await ctx.restorePersisted(); + expect(ctx.context.get()).toHaveLength(2); + }); + + it('rewrites migrated records to the current wire version after replay', async () => { + persistence.records.push( + { + type: 'metadata', + protocol_version: '1.0', + created_at: 1, + }, + { + type: 'context.append_message', + message: { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'call_legacy_bash', + function: { + name: 'Bash', + arguments: '{"command":"pwd"}', + }, + }, + ], + }, + } as unknown as WireRecord, + ); + + await ctx.restorePersisted(); + + expect(persistence.rewrites).toHaveLength(1); + expect(persistence.records[0]).toMatchObject({ + type: 'metadata', + protocol_version: WIRE_PROTOCOL_VERSION, + }); + const migrated = persistence.records[1] as unknown as { + readonly message: { + readonly toolCalls: readonly Record[]; + }; + }; + expect(persistence.records[1]?.type).toBe('context.append_message'); + expect(migrated.message.toolCalls[0]).toMatchObject({ + name: 'Bash', + arguments: '{"command":"pwd"}', + }); + expect(migrated.message.toolCalls[0]?.['function']).toBeUndefined(); + }); + + it('replays a newer wire version without rewriting its metadata', async () => { + persistence.records.push( + { + type: 'metadata', + protocol_version: '9.9', + created_at: 1, + }, + ); + + await expect(ctx.restorePersisted()).resolves.toBeUndefined(); + expect(persistence.records[0]).toMatchObject({ + type: 'metadata', + protocol_version: '9.9', + }); + }); + + it('rejects replaying records without a registered migration path', async () => { + persistence.records.push( + { + type: 'metadata', + protocol_version: '0.9', + created_at: 1, + }, + ); + + expectResumeMatches = false; + await expect(ctx.restorePersisted()).rejects.toThrow('Missing wire migration for version 0.9'); + }); + + it('restores goal.* records during replay', async () => { + persistence.records.push( + { type: 'metadata', protocol_version: WIRE_PROTOCOL_VERSION, created_at: 1 }, + { + type: 'goal.create', + goalId: 'g1', + objective: 'do work', + completionCriterion: 'tests pass', + }, + { type: 'goal.update', budgetLimits: { turnBudget: 20 } }, + { type: 'goal.update', tokensUsed: 5, wallClockMs: 0 }, + { type: 'goal.update', turnsUsed: 1 }, + { type: 'goal.update', status: 'blocked', reason: 'needs credentials', actor: 'model' }, + ); + + await expect(ctx.restorePersisted()).resolves.toBeUndefined(); + expect(context.get()).toHaveLength(0); + expect(ctx.get(IAgentGoalService).getGoal().goal).toMatchObject({ + goalId: 'g1', + objective: 'do work', + completionCriterion: 'tests pass', + status: 'blocked', + turnsUsed: 1, + tokensUsed: 5, + terminalReason: 'needs credentials', + }); + }); + + it('restores forked records as fork boundaries that clear copied goals', async () => { + persistence.records.push( + { type: 'metadata', protocol_version: WIRE_PROTOCOL_VERSION, created_at: 1 }, + { + type: 'goal.create', + goalId: 'source-goal', + objective: 'source work', + }, + { type: 'forked', time: 2 }, + ); + + await expect(ctx.restorePersisted()).resolves.toBeUndefined(); + expect(persistence.records.slice(0, 3).map((record) => record.type)).toEqual([ + 'metadata', + 'goal.create', + 'forked', + ]); + expect(ctx.get(IAgentGoalService).getGoal().goal).toBeNull(); + const reminder = context.get().at(-1); + expect(reminder?.origin).toEqual({ + kind: 'injection', + variant: 'goal_fork_cleared', + }); + expect(JSON.stringify(reminder?.content)).toContain('This fork does not have a current goal.'); + }); + + it('keeps goals created after the forked boundary', async () => { + persistence.records.push( + { type: 'metadata', protocol_version: WIRE_PROTOCOL_VERSION, created_at: 1 }, + { + type: 'goal.create', + goalId: 'source-goal', + objective: 'source work', + }, + { type: 'forked', time: 2 }, + { + type: 'goal.create', + goalId: 'fork-goal', + objective: 'fork work', + }, + ); + + await expect(ctx.restorePersisted()).resolves.toBeUndefined(); + expect(ctx.get(IAgentGoalService).getGoal().goal).toMatchObject({ + goalId: 'fork-goal', + objective: 'fork work', + }); + expect(context.get().at(-1)?.origin).toEqual({ + kind: 'injection', + variant: 'goal_fork_cleared', + }); + }); + + it('does not add a fork-cleared reminder when a forked record has no copied goal', async () => { + persistence.records.push( + { type: 'metadata', protocol_version: WIRE_PROTOCOL_VERSION, created_at: 1 }, + { type: 'forked', time: 2 }, + ); + + await expect(ctx.restorePersisted()).resolves.toBeUndefined(); + expect(context.get()).toHaveLength(0); + }); + + it('keeps context size tracking live across runtime restore', async () => { + await ctx.restore([ + { type: 'metadata', protocol_version: WIRE_PROTOCOL_VERSION, created_at: 1 }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'restored prompt' }], + toolCalls: [], + }, + }, + { + type: 'usage.record', + model: 'restored-model', + usageScope: 'turn', + usage: { + inputOther: 40, + output: 2, + inputCacheRead: 0, + inputCacheCreation: 0, + }, + }, + ]); + + expect(context.get()).toHaveLength(1); + const restored = tokenCounting.get(); + expect(restored.measured).toBe(0); + expect(restored.size).toBe(restored.estimated); + expect(restored.size).toBeGreaterThan(0); + + await ctx.dispatcher.dispatch( + new TokenCountingMeasured({ agentId: 'main', length: 1, tokens: 42 }), + ); + expect(tokenCounting.get()).toEqual({ + size: 42, + measured: 42, + estimated: 0, + }); + }); +}); + +describe.skip('agent replay range build', () => { +}); + +class RecordingInMemoryWireRecordPersistence extends InMemoryWireRecordPersistence { + readonly rewrites: WireRecord[][] = []; + + override rewrite(records: readonly WireRecord[]): void { + this.rewrites.push([...records]); + super.rewrite(records); + } +} + + +function userMessage(text: string): ContextMessage { + return { + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + }; +} + +function compactionSummaryMessage(text: string): ContextMessage { + return { + role: 'assistant', + content: [{ type: 'text', text }], + toolCalls: [], + origin: { kind: 'compaction_summary' }, + }; +} diff --git a/packages/agent-core-v2/test/setup.ts b/packages/agent-core-v2/test/setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..b81ec9f52f8096d89175db5e24cf1fb826963898 --- /dev/null +++ b/packages/agent-core-v2/test/setup.ts @@ -0,0 +1,7 @@ +for (const key of Object.keys(process.env)) { + if (key.startsWith('KIMI_CODE_')) { + delete process.env[key]; + } +} + +process.env['KIMI_CODE_PERSISTENCE_MINIDB_READMODEL'] = 'false'; diff --git a/packages/agent-core-v2/test/stubs.ts b/packages/agent-core-v2/test/stubs.ts new file mode 100644 index 0000000000000000000000000000000000000000..848a569f24cfe270fd15e86f6d451c1940df7eec --- /dev/null +++ b/packages/agent-core-v2/test/stubs.ts @@ -0,0 +1,145 @@ +import { Emitter, type Event } from '#/_base/event'; +import type { IOAuthService } from '#/app/auth/auth'; +import { + type ConfigChangedEvent, + type ConfigDiagnostic, + type ConfigInspectValue, + IConfigService, + type ResolvedConfig, +} from '#/app/config/config'; +import type { IModelOAuthTokens } from '#/llm-adapter/model/model-oauth'; + +export class StubConfigService implements IConfigService { + declare readonly _serviceBrand: undefined; + readonly ready = Promise.resolve(); + private readonly _onDidChange = new Emitter(); + readonly onDidChangeConfiguration: Event = this._onDidChange.event; + readonly onDidSectionChange: Event = this._onDidChange.event; + private readonly _onDidChangeDiagnostics = new Emitter(); + readonly onDidChangeDiagnostics: Event = + this._onDidChangeDiagnostics.event; + private readonly _values = new Map(); + + constructor(initial?: Record) { + for (const [domain, value] of Object.entries(initial ?? {})) { + this._values.set(domain, value); + } + } + + get(domain: string): T { + return this._values.get(domain) as T; + } + + inspect(domain: string): ConfigInspectValue { + return { + value: this._values.get(domain) as T | undefined, + defaultValue: undefined, + userValue: this._values.get(domain) as T | undefined, + memoryValue: undefined, + }; + } + + getAll(): ResolvedConfig { + return Object.fromEntries(this._values) as ResolvedConfig; + } + + set(domain: string, patch: unknown): Promise { + const previousValue = this._values.get(domain); + const value = + patch !== null && typeof patch === 'object' + ? { ...(previousValue as Record | undefined), ...patch } + : patch; + this._values.set(domain, value); + this._onDidChange.fire({ domain, source: 'set', value, previousValue }); + return Promise.resolve(); + } + + replace(domain: string, value: unknown): Promise { + const previousValue = this._values.get(domain); + if (value === undefined || value === null) { + this._values.delete(domain); + } else { + this._values.set(domain, value); + } + this._onDidChange.fire({ domain, source: 'set', value, previousValue }); + return Promise.resolve(); + } + + replaceSections(sections: Readonly>): Promise { + for (const [domain, value] of Object.entries(sections)) { + const previousValue = this._values.get(domain); + if (value === undefined || value === null) { + this._values.delete(domain); + } else { + this._values.set(domain, value); + } + this._onDidChange.fire({ domain, source: 'set', value, previousValue }); + } + return Promise.resolve(); + } + + setSilent(domain: string, value: unknown): void { + if (value === undefined) { + this._values.delete(domain); + } else { + this._values.set(domain, value); + } + } + + reload(): Promise { + return Promise.resolve(); + } + + diagnostics(): readonly ConfigDiagnostic[] { + return []; + } +} + +export interface StubTokenProvider { + getAccessToken(options?: { force?: boolean }): Promise; + readonly calls: Array<{ force?: boolean }>; +} + +export function stubTokenProvider(tokens: readonly string[]): StubTokenProvider { + const calls: Array<{ force?: boolean }> = []; + let index = 0; + return { + calls, + getAccessToken(options?: { force?: boolean }) { + calls.push(options ?? {}); + const token = tokens[Math.min(index, tokens.length - 1)]; + index += 1; + return Promise.resolve(token ?? ''); + }, + }; +} + +export function stubOAuthService(tokenProvider?: StubTokenProvider): IOAuthService { + return { + _serviceBrand: undefined, + startLogin: () => Promise.reject(new Error('not implemented')), + getFlow: () => undefined, + cancelLogin: () => Promise.reject(new Error('not implemented')), + logout: () => Promise.reject(new Error('not implemented')), + status: () => Promise.resolve({ loggedIn: false }), + refreshOAuthProviderModels: () => Promise.reject(new Error('not implemented')), + resolveTokenProvider: () => tokenProvider, + getCachedAccessToken: () => Promise.resolve(undefined), + } as unknown as IOAuthService; +} + +export function stubModelOAuthTokens( + tokenProvider?: StubTokenProvider, + cachedToken?: string, +): IModelOAuthTokens { + return { + _serviceBrand: undefined, + hasCachedAccessToken: () => Promise.resolve(cachedToken !== undefined), + getAccessToken: (_provider, _oauthRef, options) => + tokenProvider === undefined + ? Promise.reject(new Error('auth.login_required')) + : tokenProvider.getAccessToken( + options?.force === true ? { force: true } : undefined, + ), + }; +} diff --git a/packages/kaos/src/current.ts b/packages/kaos/src/current.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7c62e0abd3ab2825b34e3160b3051dd68c82a96 --- /dev/null +++ b/packages/kaos/src/current.ts @@ -0,0 +1,127 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +import { KaosError } from './errors'; +import type { Kaos } from './kaos'; +import type { KaosProcess } from './process'; +import type { StatResult } from './types'; + +const kaosStorage = new AsyncLocalStorage(); + +/** + * Return the {@link Kaos} instance bound to the current async context. + * + * Throws if nothing is bound — callers must wrap their entry point in + * {@link runWithKaos} or call {@link setCurrentKaos} once at startup. + */ +export function getCurrentKaos(): Kaos { + const store = kaosStorage.getStore(); + if (store === undefined) { + throw new KaosError( + 'No Kaos is bound to the current async context. Call `setCurrentKaos(await LocalKaos.create())` once at startup, or wrap the call in `runWithKaos(...)`.', + ); + } + return store; +} + +/** + * Bind `kaos` as the current instance for the running async context tree. + * Intended for a one-shot call at process startup (e.g. in a test setup + * file). Subsequent code in the same context — including nested awaits — + * resolves {@link getCurrentKaos} to this instance unless overridden by + * {@link runWithKaos}. + */ +export function setCurrentKaos(kaos: Kaos): void { + kaosStorage.enterWith(kaos); +} + +/** + * Run `fn` with `kaos` bound as the current Kaos instance for its async + * subtree. Concurrent calls do not pollute each other — bindings are + * scoped to the {@link AsyncLocalStorage} context. + */ +export function runWithKaos(kaos: Kaos, fn: () => T): T { + return kaosStorage.run(kaos, fn); +} + +// Module-level convenience functions for the current Kaos instance. + +export function readText( + path: string, + options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, +): Promise { + return getCurrentKaos().readText(path, options); +} + +export function writeText( + path: string, + data: string, + options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding }, +): Promise { + return getCurrentKaos().writeText(path, data, options); +} + +export function readLines( + path: string, + options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, +): AsyncGenerator { + return getCurrentKaos().readLines(path, options); +} + +export function exec(...args: string[]): Promise { + return getCurrentKaos().exec(...args); +} + +export function readBytes(path: string, n?: number): Promise { + return getCurrentKaos().readBytes(path, n); +} + +export function writeBytes(path: string, data: Buffer): Promise { + return getCurrentKaos().writeBytes(path, data); +} + +export function stat(path: string, options?: { followSymlinks?: boolean }): Promise { + return getCurrentKaos().stat(path, options); +} + +export function mkdir( + path: string, + options?: { parents?: boolean; existOk?: boolean }, +): Promise { + return getCurrentKaos().mkdir(path, options); +} + +export function iterdir(path: string): AsyncGenerator { + return getCurrentKaos().iterdir(path); +} + +export function glob( + path: string, + pattern: string, + options?: { caseSensitive?: boolean }, +): AsyncGenerator { + return getCurrentKaos().glob(path, pattern, options); +} + +export function chdir(path: string): Promise { + return getCurrentKaos().chdir(path); +} + +export function getcwd(): string { + return getCurrentKaos().getcwd(); +} + +export function gethome(): string { + return getCurrentKaos().gethome(); +} + +export function normpath(path: string): string { + return getCurrentKaos().normpath(path); +} + +export function pathClass(): 'posix' | 'win32' { + return getCurrentKaos().pathClass(); +} + +export function execWithEnv(args: string[], env?: Record): Promise { + return getCurrentKaos().execWithEnv(args, env); +} diff --git a/packages/kaos/src/environment.ts b/packages/kaos/src/environment.ts new file mode 100644 index 0000000000000000000000000000000000000000..29accd82a9cac5d49b362e19cb0c4ffbdae23f09 --- /dev/null +++ b/packages/kaos/src/environment.ts @@ -0,0 +1,314 @@ +/** + * Environment — cross-platform probe of OS / shell. + * + * Detection is a pure function of injected probes (`platform` / `arch` / + * `release` / `env` / `isFile` / `execFileText`) so the same suite runs + * identically on any host OS. `detectEnvironmentFromNode()` bundles the Node + * defaults for production callers. + * + * On Windows the probe expects bash from Git for Windows or MSYS2. If it + * cannot be located the function throws `KaosShellNotFoundError`; the SDK + * layer can wrap that into a user-facing install hint. Set + * `KIMI_SHELL_PATH` to override. + */ + +import { execFile as nodeExecFile } from 'node:child_process'; +import { constants as fsConstants } from 'node:fs'; +import { access } from 'node:fs/promises'; +import * as nodeOs from 'node:os'; +import * as nodePath from 'node:path'; + +import { KaosShellNotFoundError } from './errors'; + +// `OsKind` carries 'macOS' / 'Linux' / 'Windows' for known platforms and +// falls back to the raw `process.platform` string for unknown ones (e.g. +// 'freebsd'). Typed as `string` so the union isn't inhabited-by-string. +export type OsKind = string; +export type ShellName = 'bash' | 'sh'; + +export interface Environment { + readonly osKind: OsKind; + readonly osArch: string; + readonly osVersion: string; + readonly shellName: ShellName; + readonly shellPath: string; +} + +export interface EnvironmentDeps { + // Accepts the full Node `Platform` enum plus arbitrary strings for + // forward-compatible OS kinds. + readonly platform: string; + readonly arch: string; + readonly release: string; + readonly env: Record; + readonly isFile: (path: string) => Promise; + readonly execFileText: ( + file: string, + args: readonly string[], + timeoutMs: number, + ) => Promise; +} + +const GIT_EXEC_PATH_TIMEOUT_MS = 5_000; + +const MINGW_PREFIX_SET: ReadonlySet = new Set([ + 'mingw32', + 'mingw64', + 'ucrt64', + 'clang64', + 'clangarm64', +]); + +function resolveOsKind(platform: string): OsKind { + switch (platform) { + case 'darwin': + return 'macOS'; + case 'linux': + return 'Linux'; + case 'win32': + return 'Windows'; + default: + return platform; + } +} + +export async function detectEnvironment(deps: EnvironmentDeps): Promise { + const osKind = resolveOsKind(deps.platform); + const osArch = deps.arch; + const osVersion = deps.release; + + if (deps.platform === 'win32') { + const shellPath = await locateWindowsGitBash(deps); + return { osKind, osArch, osVersion, shellName: 'bash', shellPath }; + } + + const candidates: readonly string[] = ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash']; + let found: string | undefined; + for (const p of candidates) { + if (await deps.isFile(p)) { + found = p; + break; + } + } + if (found !== undefined) { + return { osKind, osArch, osVersion, shellName: 'bash', shellPath: found }; + } + return { osKind, osArch, osVersion, shellName: 'sh', shellPath: '/bin/sh' }; +} + +async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { + const checked: string[] = []; + + const override = deps.env['KIMI_SHELL_PATH']?.trim(); + if (override !== undefined && override.length > 0) { + checked.push(override); + if (await deps.isFile(override)) { + return override; + } + } + + const gitExecutables = await findExecutablesOnPath( + 'git.exe', + deps.env['PATH'], + deps.platform, + deps.isFile, + ); + + for (const gitExe of gitExecutables) { + const inferred = gitBashCandidatesFromGitExe(gitExe); + if (inferred !== undefined) { + for (const candidate of inferred) { + checked.push(candidate); + if (await deps.isFile(candidate)) { + return candidate; + } + } + } + + const gitExecPath = await readGitExecPath(deps, gitExe); + if (gitExecPath === undefined) { + continue; + } + for (const candidate of gitBashCandidatesFromGitExecPath(gitExecPath)) { + checked.push(candidate); + if (await deps.isFile(candidate)) { + return candidate; + } + } + } + + const candidates: string[] = [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', + ]; + const localAppData = deps.env['LOCALAPPDATA']?.trim(); + if (localAppData !== undefined && localAppData.length > 0) { + candidates.push(`${localAppData}\\Programs\\Git\\bin\\bash.exe`); + candidates.push(`${localAppData}\\Programs\\Git\\usr\\bin\\bash.exe`); + } + for (const candidate of candidates) { + checked.push(candidate); + if (await deps.isFile(candidate)) { + return candidate; + } + } + + throw new KaosShellNotFoundError( + `Git Bash was not found on this Windows host. Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe. Checked: ${checked.join(', ')}.`, + ); +} + +async function readGitExecPath( + deps: EnvironmentDeps, + gitExe: string, +): Promise { + if (deps.platform === 'win32' && !isAbsoluteWindowsPath(gitExe)) return undefined; + + const stdout = await deps.execFileText(gitExe, ['--exec-path'], GIT_EXEC_PATH_TIMEOUT_MS); + if (stdout === undefined) return undefined; + + for (const line of stdout.split(/\r?\n/)) { + const execPath = line.trim(); + if (execPath.length > 0) { + return execPath; + } + } + return undefined; +} + +// Most Git for Windows installs put `git.exe` in `\cmd\git.exe`, +// with bash at `\bin\bash.exe`. Portable installs sometimes put +// both in `\bin\`. Only infer from those anchored layouts; package +// manager shims live elsewhere and must resolve through `git --exec-path`. +function gitBashCandidatesFromGitExe(gitExe: string): readonly string[] | undefined { + const normalizedGitExe = nodePath.win32.normalize(normalizeWindowsPath(gitExe)); + const gitDir = nodePath.win32.dirname(normalizedGitExe); + const gitDirName = nodePath.win32.basename(gitDir).toLowerCase(); + if (gitDirName !== 'cmd' && gitDirName !== 'bin') { + return undefined; + } + return gitBashCandidatesFromGitRoot(nodePath.win32.dirname(gitDir)); +} + +function gitBashCandidatesFromGitExecPath(execPath: string): readonly string[] { + const normalized = nodePath.win32.normalize(normalizeWindowsPath(execPath)); + const parts = normalized.split('\\'); + for (let i = parts.length - 1; i >= 0; i -= 1) { + const segment = parts[i]?.toLowerCase(); + if (segment !== undefined && MINGW_PREFIX_SET.has(segment)) { + const root = parts.slice(0, i).join('\\'); + if (root.length > 0) { + return gitBashCandidatesFromGitRoot(root); + } + } + } + + return gitBashCandidatesFromGitRoot(nodePath.win32.join(normalized, '..', '..')); +} + +function gitBashCandidatesFromGitRoot(root: string): readonly string[] { + return [ + nodePath.win32.normalize(nodePath.win32.join(root, 'bin', 'bash.exe')), + nodePath.win32.normalize(nodePath.win32.join(root, 'usr', 'bin', 'bash.exe')), + ]; +} + +function normalizeWindowsPath(path: string): string { + return path.replaceAll('/', '\\'); +} + +function isAbsoluteWindowsPath(path: string): boolean { + return nodePath.win32.isAbsolute(normalizeWindowsPath(path)); +} + +function dedupeWindowsPaths(paths: readonly string[]): readonly string[] { + const deduped: string[] = []; + const seen = new Set(); + for (const path of paths) { + const key = normalizeWindowsPath(path).toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(path); + } + return deduped; +} + +/** + * Production convenience — derive the deps bag from Node's ambient surface. + * + * The result is memoised: subsequent calls return the original promise. + * `Environment` is immutable for the lifetime of the process (it derives + * from `process.platform`, `process.arch`, `os.release()`, and one-time + * shell-path discovery), so caching is sound. Tests that need to probe + * with different inputs should call {@link detectEnvironment} directly + * with an injected deps bag. + */ +let detectedEnvironment: Promise | undefined; + +export function detectEnvironmentFromNode(): Promise { + if (detectedEnvironment !== undefined) return detectedEnvironment; + const platform = process.platform; + const env = process.env as Record; + const isFile = async (path: string): Promise => { + try { + await access(path, fsConstants.F_OK); + return true; + } catch { + return false; + } + }; + detectedEnvironment = detectEnvironment({ + platform, + arch: process.arch, + release: nodeOs.release(), + env, + isFile, + execFileText, + }); + return detectedEnvironment; +} + +async function findExecutablesOnPath( + name: string, + pathEnv: string | undefined, + platform: string, + isFile: (p: string) => Promise, +): Promise { + if (pathEnv === undefined || pathEnv.length === 0) return []; + const listSep = platform === 'win32' ? ';' : ':'; + const dirSep = platform === 'win32' ? '\\' : '/'; + const paths: string[] = []; + for (const rawDir of pathEnv.split(listSep)) { + const dir = rawDir.trim(); + if (dir.length === 0) continue; + if (platform === 'win32' && !isAbsoluteWindowsPath(dir)) continue; + const candidate = dir.endsWith(dirSep) ? `${dir}${name}` : `${dir}${dirSep}${name}`; + if (await isFile(candidate)) { + paths.push(candidate); + } + } + return platform === 'win32' ? dedupeWindowsPaths(paths) : paths; +} + +export async function execFileText( + file: string, + args: readonly string[], + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + nodeExecFile( + file, + [...args], + { encoding: 'utf8', timeout: timeoutMs, windowsHide: true }, + (error, stdout) => { + if (error !== null) { + resolve(undefined); + return; + } + resolve(stdout); + }, + ); + }); +} diff --git a/packages/kaos/src/errors.ts b/packages/kaos/src/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..0283c9b01d47800fb94867981a03dc90ccb1a0d2 --- /dev/null +++ b/packages/kaos/src/errors.ts @@ -0,0 +1,41 @@ +/** + * Base error class for the kaos package. + */ +export class KaosError extends Error { + constructor(message: string) { + super(message); + this.name = 'KaosError'; + } +} + +/** + * Equivalent to Python's ValueError — indicates an invalid argument was passed. + */ +export class KaosValueError extends KaosError { + constructor(message: string) { + super(message); + this.name = 'KaosValueError'; + } +} + +/** + * Equivalent to Python's FileExistsError — indicates a file or directory already exists. + */ +export class KaosFileExistsError extends KaosError { + constructor(message: string) { + super(message); + this.name = 'KaosFileExistsError'; + } +} + +/** + * Thrown by `detectEnvironment` on Windows when no Git Bash install can be + * located. Carries the list of paths that were probed so callers can include + * them in install hints. + */ +export class KaosShellNotFoundError extends KaosError { + constructor(message: string) { + super(message); + this.name = 'KaosShellNotFoundError'; + } +} diff --git a/packages/kaos/src/index.ts b/packages/kaos/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c27584ebdedca975c50a077d31873b5d50c3cfc2 --- /dev/null +++ b/packages/kaos/src/index.ts @@ -0,0 +1,44 @@ +export type { StatResult } from './types'; +export type { KaosProcess } from './process'; +export type { Kaos } from './kaos'; +export type { + Environment, + EnvironmentDeps, + OsKind, + ShellName, +} from './environment'; +export { detectEnvironment, detectEnvironmentFromNode } from './environment'; +export type { + ShellPathBridge, + ShellPathBridgeDeps, + ShellPathBridgeEnv, +} from './shell-path-bridge'; +export { createShellPathBridge, getShellPathBridge, translateShellDrivePath } from './shell-path-bridge'; +export { + KaosError, + KaosValueError, + KaosFileExistsError, + KaosShellNotFoundError, +} from './errors'; +export { LocalKaos } from './local'; +export { + chdir, + exec, + execWithEnv, + getCurrentKaos, + getcwd, + gethome, + glob, + iterdir, + mkdir, + normpath, + pathClass, + readBytes, + readLines, + readText, + runWithKaos, + setCurrentKaos, + stat, + writeBytes, + writeText, +} from './current'; diff --git a/packages/kaos/src/internal.ts b/packages/kaos/src/internal.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c89acf27ea322921fa0e1000fb51a6b2f680cec --- /dev/null +++ b/packages/kaos/src/internal.ts @@ -0,0 +1,296 @@ +import { Readable } from 'node:stream'; + +function isUtf8Continuation(byte: number): boolean { + return byte >= 0x80 && byte <= 0xbf; +} + +function decodeUtf8Ignore(data: Buffer): string { + let output = ''; + let i = 0; + + while (i < data.length) { + const b0 = data[i]; + if (b0 === undefined) break; + + if (b0 <= 0x7f) { + output += String.fromCodePoint(b0); + i += 1; + continue; + } + + if (b0 >= 0xc2 && b0 <= 0xdf) { + const b1 = data[i + 1]; + if (b1 !== undefined && isUtf8Continuation(b1)) { + output += String.fromCodePoint(((b0 & 0x1f) << 6) | (b1 & 0x3f)); + i += 2; + continue; + } + i += 1; + continue; + } + + if (b0 >= 0xe0 && b0 <= 0xef) { + const b1 = data[i + 1]; + const b2 = data[i + 2]; + const validSecond = + b1 !== undefined && + ((b0 === 0xe0 && b1 >= 0xa0 && b1 <= 0xbf) || + (b0 >= 0xe1 && b0 <= 0xec && isUtf8Continuation(b1)) || + (b0 === 0xed && b1 >= 0x80 && b1 <= 0x9f) || + (b0 >= 0xee && b0 <= 0xef && isUtf8Continuation(b1))); + + if (validSecond && b2 !== undefined && isUtf8Continuation(b2)) { + output += String.fromCodePoint(((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f)); + i += 3; + continue; + } + i += 1; + continue; + } + + if (b0 >= 0xf0 && b0 <= 0xf4) { + const b1 = data[i + 1]; + const b2 = data[i + 2]; + const b3 = data[i + 3]; + const validSecond = + b1 !== undefined && + ((b0 === 0xf0 && b1 >= 0x90 && b1 <= 0xbf) || + (b0 >= 0xf1 && b0 <= 0xf3 && isUtf8Continuation(b1)) || + (b0 === 0xf4 && b1 >= 0x80 && b1 <= 0x8f)); + + if ( + validSecond && + b2 !== undefined && + b3 !== undefined && + isUtf8Continuation(b2) && + isUtf8Continuation(b3) + ) { + output += String.fromCodePoint( + ((b0 & 0x07) << 18) | ((b1 & 0x3f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f), + ); + i += 4; + continue; + } + i += 1; + continue; + } + + i += 1; + } + + return output; +} + +function decodeUtf16LeIgnore(data: Buffer): string { + let output = ''; + let i = 0; + + while (i + 1 < data.length) { + const first = data[i]; + const second = data[i + 1]; + if (first === undefined || second === undefined) break; + + const codeUnit = first | (second << 8); + + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const lowFirst = data[i + 2]; + const lowSecond = data[i + 3]; + if (lowFirst !== undefined && lowSecond !== undefined) { + const low = lowFirst | (lowSecond << 8); + if (low >= 0xdc00 && low <= 0xdfff) { + const codePoint = 0x10000 + ((codeUnit - 0xd800) << 10) + (low - 0xdc00); + output += String.fromCodePoint(codePoint); + i += 4; + continue; + } + } + i += 2; + continue; + } + + if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + i += 2; + continue; + } + + output += String.fromCodePoint(codeUnit); + i += 2; + } + + return output; +} + +/** + * Decode a Buffer into a string with Python-compatible `errors` handling. + * + * - `'strict'` (default): throw on invalid sequences (via TextDecoder `fatal: true`) + * - `'replace'`: substitute each invalid sequence with U+FFFD (TextDecoder default) + * - `'ignore'`: drop invalid input sequences while preserving valid U+FFFD characters + * + * Falls back to `Buffer.toString(encoding)` for encodings TextDecoder does not + * support (e.g. `hex`, `base64`, `binary`, `latin1`) — those are lossless + * byte-to-character mappings so `errors` has no effect. + * @internal + */ +export function decodeTextWithErrors( + data: Buffer, + encoding: BufferEncoding, + errors: 'strict' | 'replace' | 'ignore' = 'strict', + ignoreBOM: boolean = false, +): string { + // Map Node's BufferEncoding names to Web TextDecoder labels where the two + // diverge. Only UTF-family encodings participate in the strict/replace/ + // ignore dance; the others are lossless and use Buffer.toString directly. + let webLabel: string | undefined; + // eslint-disable-next-line typescript-eslint/switch-exhaustiveness-check + switch (encoding) { + case 'utf-8': + case 'utf8': + webLabel = 'utf-8'; + break; + case 'utf16le': + case 'ucs2': + case 'ucs-2': + webLabel = 'utf-16le'; + break; + default: + webLabel = undefined; + } + + if (webLabel === undefined) { + // Non-UTF encodings (hex/base64/latin1/binary/ascii) are lossless byte↔ + // character mappings; `errors` is meaningless for them. Return raw. + return data.toString(encoding); + } + + if (errors === 'strict') { + return new TextDecoder(webLabel, { fatal: true, ignoreBOM }).decode(data); + } + + // 'ignore' must skip invalid input bytes/code units, not delete every + // replacement character in the decoded output. A file can contain a valid + // U+FFFD, and Python preserves it under errors="ignore". + if (errors === 'ignore') { + return webLabel === 'utf-8' ? decodeUtf8Ignore(data) : decodeUtf16LeIgnore(data); + } + + // 'replace' → substitute each invalid sequence with U+FFFD (default). + return new TextDecoder(webLabel, { fatal: false, ignoreBOM }).decode(data); +} + +/** + * Convert a glob pattern segment (e.g. "*.txt", "file?.log") into a RegExp. + * Mirrors Python pathlib behavior: includes dotfiles, case-sensitive by default. + * @internal + */ +export function globPatternToRegex(pattern: string, caseSensitive: boolean): RegExp { + let regex = '^'; + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i]; + if (ch === undefined) break; + switch (ch) { + case '*': + regex += '[^/]*'; + break; + case '?': + regex += '[^/]'; + break; + case '[': { + const end = pattern.indexOf(']', i + 1); + if (end === -1) { + regex += '\\['; + } else { + // Glob character classes only use `!` for negation. A literal + // leading `^` must remain literal even though JS regex char + // classes treat it as negation in the first position. + let charClass = pattern.slice(i + 1, end); + // Escape backslashes inside the class so a trailing backslash + // does not accidentally escape the closing `]`. + charClass = charClass.replace(/\\/g, '\\\\'); + if (charClass.startsWith('!')) { + charClass = '^' + charClass.slice(1); + } else if (charClass.startsWith('^')) { + charClass = '\\' + charClass; + } + regex += '[' + charClass + ']'; + i = end; + } + break; + } + case '\\': { + if (i + 1 < pattern.length) { + const next = pattern.charAt(i + 1); + regex += next.replaceAll(/[{}()+.\\[\]^$|]/g, '\\$&'); + // Advance past the escaped character so it is not processed + // again as a regex metacharacter. match literally. + i++; + } else { + regex += '\\\\'; + } + break; + } + default: + regex += ch.replaceAll(/[{}()+.\\[\]^$|]/g, '\\$&'); + } + } + regex += '$'; + return new RegExp(regex, caseSensitive ? '' : 'i'); +} + +/** + * A Readable wrapper that preserves source backpressure while still allowing + * consumers to read buffered output after the source has ended. + * @internal + */ +export class BufferedReadable extends Readable { + private readonly _source: Readable; + private _ended: boolean = false; + + constructor(source: Readable) { + // Keep a modest prefetch window so wait()-then-read still works for + // common small/medium outputs without draining unboundedly. + super({ highWaterMark: 128 * 1024 }); + this._source = source; + this._source.on('data', this._onData); + this._source.on('end', this._onEnd); + this._source.on('close', this._onClose); + this._source.on('error', this._onError); + } + + override _read(): void { + if (!this._ended && !this.destroyed) { + this._source.resume(); + } + } + + override _destroy(error: Error | null, callback: (error?: Error | null) => void): void { + this._source.off('data', this._onData); + this._source.off('end', this._onEnd); + this._source.off('close', this._onClose); + this._source.off('error', this._onError); + this._source.destroy(); + callback(error); + } + + private readonly _onData = (chunk: string | Uint8Array): void => { + if (!this.push(chunk)) { + this._source.pause(); + } + }; + + private readonly _onEnd = (): void => { + this._ended = true; + this.push(null); + }; + + private readonly _onClose = (): void => { + if (!this._ended) { + this._ended = true; + this.push(null); + } + }; + + private readonly _onError = (error: Error): void => { + this.destroy(error); + }; +} diff --git a/packages/kaos/src/kaos.ts b/packages/kaos/src/kaos.ts new file mode 100644 index 0000000000000000000000000000000000000000..e674418ceba7974ed97630eab50c3f09ecd96a8a --- /dev/null +++ b/packages/kaos/src/kaos.ts @@ -0,0 +1,97 @@ +import type { Environment } from './environment'; +import type { KaosProcess } from './process'; +import type { StatResult } from './types'; + +/** + * Kimi Agent Operating System (KAOS) interface. + * + * This is the core abstraction that allows the agent to interact with + * different execution environments (local, SSH, containers, etc.) + * through a unified API. + */ +export interface Kaos { + /** Human-readable name for this environment (e.g. `"local"`, `"ssh:host"`). */ + readonly name: string; + + /** + * OS / shell probe describing the target environment. Populated by the + * concrete Kaos implementation (e.g. `detectEnvironmentFromNode()` for + * `LocalKaos`, a remote probe for `SSHKaos`). + */ + readonly osEnv: Environment; + + // ── Path operations (sync) ────────────────────────────────────────── + + /** Return the path style used by this environment. */ + pathClass(): 'posix' | 'win32'; + /** Normalize the given path string (resolve `.` / `..` segments). */ + normpath(path: string): string; + /** Return the home directory of the current user. */ + gethome(): string; + /** Return the current working directory. */ + getcwd(): string; + + // ── Directory operations (async) ──────────────────────────────────── + + /** Change the working directory to `path`. */ + chdir(path: string): Promise; + /** Return a new Kaos with the given `cwd`. */ + withCwd(cwd: string): Kaos; + /** + * Return a new Kaos that overlays `env` onto every spawned process. + * + * The provided record is read when a process is spawned, so callers may + * mutate a stable record to update future executions. + */ + withEnv(env: Record): Kaos; + /** Return stat metadata for `path`. */ + stat(path: string, options?: { followSymlinks?: boolean }): Promise; + /** Yield entry names in the directory at `path`. */ + iterdir(path: string): AsyncGenerator; + /** Yield paths matching `pattern` under `path`. */ + glob( + path: string, + pattern: string, + options?: { caseSensitive?: boolean }, + ): AsyncGenerator; + + // ── File operations (async) ───────────────────────────────────────── + + /** Read up to `n` bytes from `path` (all bytes if `n` is omitted). */ + readBytes(path: string, n?: number): Promise; + /** + * Read the file at `path` as a string. + * + * `errors` controls how decode errors are handled — mirrors Python's + * `open(..., errors=)` parameter: + * - `'strict'` (default): throw on any invalid byte for the encoding + * - `'replace'`: substitute each invalid byte with U+FFFD (REPLACEMENT CHARACTER) + * - `'ignore'`: drop invalid bytes silently + */ + readText( + path: string, + options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, + ): Promise; + /** Yield lines from the file at `path` one by one. */ + readLines( + path: string, + options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, + ): AsyncGenerator; + /** Write raw bytes to `path`, returning the number of bytes written. */ + writeBytes(path: string, data: Buffer): Promise; + /** Write text to `path`, returning the number of characters written. */ + writeText( + path: string, + data: string, + options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding }, + ): Promise; + /** Create a directory at `path`. */ + mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise; + + // ── Process execution ─────────────────────────────────────────────── + + /** Spawn a process with the given arguments. */ + exec(...args: string[]): Promise; + /** Spawn a process with explicit environment variables. */ + execWithEnv(args: string[], env?: Record): Promise; +} diff --git a/packages/kaos/src/local.ts b/packages/kaos/src/local.ts new file mode 100644 index 0000000000000000000000000000000000000000..92ff99b67a26466ff305841f5561d5c71c801934 --- /dev/null +++ b/packages/kaos/src/local.ts @@ -0,0 +1,901 @@ +import type { ChildProcess, SpawnOptions } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { + appendFile, + lstat, + mkdir, + open, + readdir, + readFile, + stat, + writeFile, +} from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join, normalize } from 'pathe'; +import type { Readable, Writable } from 'node:stream'; + +import { detectEnvironmentFromNode, type Environment } from './environment'; +import { KaosFileExistsError } from './errors'; +import { BufferedReadable, decodeTextWithErrors, globPatternToRegex } from './internal'; +import type { Kaos } from './kaos'; +import { applyLoginShellPathFromNode } from './login-shell-path'; +import type { KaosProcess } from './process'; +import type { StatResult } from './types'; + +const isWindows: boolean = process.platform === 'win32'; +const READ_CHUNK_SIZE = 64 * 1024; + +type TextDecodeErrors = 'strict' | 'replace' | 'ignore'; + +interface LineEndingFlags { + hasCrLf: boolean; + hasLf: boolean; + hasLoneCr: boolean; +} + +interface TextFileScan { + totalLines: number; + endsWithNewline: boolean; + hasNul: boolean; + lineEndingFlags: LineEndingFlags; +} + +/** + * Build the `(dev, ino)` cycle-detection key used by `_globWalk`'s + * visited set. Returns `null` when `ino` is 0, which Node returns on + * filesystems that don't carry inodes (Windows FAT/exFAT, some SMB/NFS + * mounts). A null key signals "no reliable identity for this dir" so + * the caller skips visited tracking for that descent — cycle safety + * is weakened on those filesystems, but normal walking works instead + * of every directory colliding on the shared key `":0"`. + */ +function cycleKey(s: { dev: number; ino: number }): string | null { + if (s.ino === 0) return null; + return `${String(s.dev)}:${String(s.ino)}`; +} + +export function buildLocalSpawnOptions( + isWindows: boolean, + cwd: string, + env: Record | undefined, +): SpawnOptions { + return { + cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + detached: !isWindows, + windowsHide: true, + }; +} + +class LocalProcess implements KaosProcess { + readonly stdin: Writable; + readonly stdout: Readable; + readonly stderr: Readable; + readonly pid: number; + + private readonly _child: ChildProcess; + private _exitCode: number | null = null; + private readonly _exitPromise: Promise; + private _disposed = false; + + constructor(child: ChildProcess) { + if (child.stdin === null || child.stdout === null || child.stderr === null) { + throw new Error('Process must be created with stdin/stdout/stderr pipes.'); + } + + this._child = child; + this.stdin = child.stdin; + this.stdout = new BufferedReadable(child.stdout); + this.stderr = new BufferedReadable(child.stderr); + this.pid = child.pid ?? -1; + + this._exitPromise = new Promise((resolve, reject) => { + child.on('exit', (code: number | null) => { + this._exitCode = code ?? -1; + resolve(this._exitCode); + }); + child.on('error', (error: Error) => { + reject(error); + }); + }); + } + + get exitCode(): number | null { + return this._exitCode; + } + + async wait(): Promise { + return this._exitPromise; + } + + kill(signal?: NodeJS.Signals): Promise { + // Reject if the process never actually started (spawn failed). + // pid <= 0 indicates ChildProcess.pid was undefined, which happens + // when spawn() fails to find/execute the command. Calling + // process.kill(-1, ...) on POSIX would signal the entire process + // group, potentially killing unrelated processes. + if (this.pid <= 0) { + return Promise.resolve(); + } + + // On Windows, `ChildProcess.kill()` only signals the shell parent, leaving + // grandchildren alive, so terminate the whole process tree with + // `taskkill /T`. A graceful `taskkill /T` (no `/F`) does not actually + // terminate a console node.exe tree, and Windows has no real graceful + // signal for it — Node's own `ChildProcess.kill()` is always a forceful + // TerminateProcess on Windows — so always force-terminate the tree. + if (isWindows) { + const taskkillArgs = ['/T', '/F', '/PID', String(this.pid)]; + return new Promise((resolve) => { + const killer = spawn('taskkill', taskkillArgs, { + stdio: 'ignore', + windowsHide: true, + }); + const done = (): void => { + resolve(); + }; + killer.once('error', done); + killer.once('close', done); + }); + } + + // On POSIX, `detached:true` makes the child a process-group leader + // (pgid === pid). A plain `ChildProcess.kill()` still only signals the + // direct child, so a shell like `bash -c 'sleep 100 & sleep 100'` leaves + // grandchildren orphaned. `process.kill(-pid, signal)` signals the group + // (negative pid = process-group id under POSIX kill(2)). + try { + process.kill(-this.pid, signal ?? 'SIGTERM'); + } catch (error) { + const err = error as NodeJS.ErrnoException; + // ESRCH = group already gone (child exited + reaped between + // `wait()` racing spawn + this call). Treat as successful kill. + if (err.code === 'ESRCH') return Promise.resolve(); + // EPERM is typically a misconfiguration (e.g. non-detached + // spawn earlier in the file); fall back to direct `.kill()` so + // we at least signal the direct child instead of throwing. + if (err.code === 'EPERM') { + try { + this._child.kill(signal ?? 'SIGTERM'); + } catch { + /* best effort */ + } + return Promise.resolve(); + } + throw error; + } + return Promise.resolve(); + } + + dispose(): void { + if (this._disposed) return; + this._disposed = true; + this.stdin.destroy(); + this.stdout.destroy(); + this.stderr.destroy(); + } +} + +/** + * A KAOS implementation that directly interacts with the local filesystem. + * + * Note: LocalKaos maintains its own per-instance working directory (`_cwd`) + * rather than mutating `process.cwd()`. This lets multiple LocalKaos instances + * coexist with independent cwds (e.g. when switching contexts via + * `runWithKaos`) without cross-polluting each other's relative-path resolution. + */ +export class LocalKaos implements Kaos { + readonly name: string = 'local'; + readonly osEnv: Environment; + private _cwd: string; + private readonly _envLayers: readonly Record[]; + + private constructor( + osEnv: Environment, + cwd?: string, + envLayers: readonly Record[] = [], + ) { + // After construction we never touch `process.cwd()` / `process.chdir()` + // — all path resolution goes through `this._cwd`. The default seeds + // from `process.cwd()` but callers can pin to anything via `withCwd` + // (or supplying `cwd` directly). + this._cwd = normalize(cwd ?? process.cwd()); + this.osEnv = osEnv; + this._envLayers = envLayers; + } + + /** + * Construct a fresh `LocalKaos` after probing the host environment. + * + * Each call returns a new instance with its own `_cwd`; concurrent + * callers can therefore operate on independent working directories + * without polluting one another. + */ + static async create(): Promise { + // Enrich process.env.PATH from the user's login shell so spawned + // commands find user-installed tools (e.g. Homebrew's gh) even when + // kimi-code itself was launched without the full profile PATH. Both + // probes are memoised, independent, and run concurrently. + const [osEnv] = await Promise.all([detectEnvironmentFromNode(), applyLoginShellPathFromNode()]); + return new LocalKaos(osEnv); + } + + withCwd(cwd: string): LocalKaos { + return new LocalKaos(this.osEnv, cwd, this._envLayers); + } + + withEnv(env: Record): LocalKaos { + return new LocalKaos(this.osEnv, this._cwd, [...this._envLayers, env]); + } + + private _resolvePath(path: string): string { + if (isAbsolute(path)) return normalize(path); + return join(this._cwd, path); + } + + pathClass(): 'posix' | 'win32' { + return isWindows ? 'win32' : 'posix'; + } + + normpath(path: string): string { + return normalize(path); + } + + gethome(): string { + return normalize(homedir()); + } + + getcwd(): string { + return this._cwd; + } + + /** + * Change the working directory of this LocalKaos instance. + * + * Unlike Python's `os.chdir`, this is instance-scoped and never touches + * `process.cwd()`. Child processes spawned via {@link exec} inherit this + * instance's `_cwd`; concurrent LocalKaos instances each carry their own + * independent cwd. If you need Python-compatible process-global cwd, + * call `process.chdir(x)` directly. + */ + async chdir(path: string): Promise { + const resolved = this._resolvePath(path); + const s = await stat(resolved); + if (!s.isDirectory()) { + throw new Error(`Not a directory: ${resolved}`); + } + this._cwd = resolved; + } + + async stat(path: string, options?: { followSymlinks?: boolean }): Promise { + const resolved = this._resolvePath(path); + const followSymlinks = options?.followSymlinks ?? true; + const s = followSymlinks ? await stat(resolved) : await lstat(resolved); + return { + stMode: s.mode, + stIno: s.ino, + stDev: s.dev, + stNlink: s.nlink, + stUid: s.uid, + stGid: s.gid, + stSize: s.size, + stAtime: s.atimeMs / 1000, + stMtime: s.mtimeMs / 1000, + stCtime: isWindows ? s.birthtimeMs / 1000 : s.ctimeMs / 1000, + }; + } + + async *iterdir(path: string): AsyncGenerator { + const resolved = this._resolvePath(path); + const entries = await readdir(resolved); + for (const entry of entries) { + // Use join so root paths like "/" or "C:\\" don't produce "//entry" + // or "C:\\\\entry" — join normalizes trailing separators correctly. + yield join(resolved, entry); + } + } + + async *glob( + path: string, + pattern: string, + options?: { caseSensitive?: boolean }, + ): AsyncGenerator { + const resolved = this._resolvePath(path); + const caseSensitive = options?.caseSensitive ?? true; + const patternParts = pattern.split('/'); + // Seed `visited` with basePath's own inode so that a symlink inside + // basePath that points back at basePath is caught on its first + // encounter (not on the second level — the "+1 depth" off-by-one + // that would otherwise leak if the caller globs directly from the + // loop root). `stat` failure here is tolerated: `_globWalk` will + // hit the same error via readdir and return empty. + const initVisited = new Set(); + try { + const rootStat = await stat(resolved); + const rootKey = cycleKey(rootStat); + if (rootKey !== null) initVisited.add(rootKey); + } catch { + // base does not exist / not accessible — walker handles via its own catch + } + yield* this._globWalk(resolved, patternParts, caseSensitive, initVisited); + } + + // `visited` holds the `(stDev, stIno)` keys of directories on the + // current descent path. Before recursing into a subdirectory, we + // check its key against `visited`; if present we skip it (cycle + // detected) and otherwise recurse with a fresh Set containing the + // additional key. The per-recurse copy gives the check path-local + // semantics: two legitimate symlinks to the same target in separate + // branches both traverse, which is more permissive than Python stdlib + // while still cycle-safe. + // Same-directory self-recursion (e.g. `**` matching zero dirs with + // pattern tail) passes `visited` unchanged — no descent, no cycle + // risk. + // + // Windows note: Node's `fs.Stats.ino` returns `0` on filesystems + // that don't support inodes (FAT/exFAT, some SMB/NFS mounts). If we + // keyed on `ino=0`, every directory on such a drive would share the + // key `":0"` and the first would "visit" all others. The + // module-level `cycleKey` helper returns `null` in that case, which + // causes the call sites to skip visited tracking for that descent + // — cycle safety is lost on those filesystems, but normal walking + // works. + private async *_globWalk( + basePath: string, + patternParts: string[], + caseSensitive: boolean, + visited: Set, + ): AsyncGenerator { + if (patternParts.length === 0) { + return; + } + + const [currentPattern, ...remainingParts] = patternParts; + + if (currentPattern === '**') { + // `**` matches zero or more directory components. + // + // There are exactly two cases to handle: + // (a) `**` matches zero directories → continue at basePath with the + // remaining pattern parts (or yield basePath itself when `**` + // is the final segment). + // (b) `**` matches one or more directories → recurse into each + // subdirectory, keeping `**` (i.e. the full patternParts) at + // the front. The "zero directories" case is then re-evaluated + // at the subdirectory level by that recursive call. + // + // We must NOT additionally recurse with `remainingParts` on + // subdirectories — that would double-count every match at depth ≥ 1 + // because case (a) inside the child recursion already yields those + // results. + if (remainingParts.length > 0) { + yield* this._globWalk(basePath, remainingParts, caseSensitive, visited); + } else { + // Pattern ends with `**`: yield basePath itself (zero-dir match). + yield basePath; + } + + let entries: string[]; + try { + entries = await readdir(basePath); + } catch { + return; + } + + for (const entry of entries) { + // Use join to avoid "//entry" when basePath is a filesystem root. + const fullPath = join(basePath, entry); + let entryStat; + try { + entryStat = await stat(fullPath); + } catch { + continue; + } + if (entryStat.isDirectory()) { + const key = cycleKey(entryStat); + if (key !== null && visited.has(key)) continue; + yield* this._globWalk( + fullPath, + patternParts, + caseSensitive, + key !== null ? new Set([...visited, key]) : visited, + ); + } else if (remainingParts.length === 0) { + // Pattern ends with `**`: non-directory entries match too + // (since `**` matches "anything"). + yield fullPath; + } + } + } else { + const regex = globPatternToRegex(currentPattern ?? '', caseSensitive); + + let entries: string[]; + try { + entries = await readdir(basePath); + } catch { + return; + } + + for (const entry of entries) { + if (!regex.test(entry)) { + continue; + } + + // Use join to avoid "//entry" when basePath is a filesystem root. + const fullPath = join(basePath, entry); + + if (remainingParts.length === 0) { + yield fullPath; + } else { + let entryStat; + try { + entryStat = await stat(fullPath); + } catch { + continue; + } + if (entryStat.isDirectory()) { + const key = cycleKey(entryStat); + if (key !== null && visited.has(key)) continue; + yield* this._globWalk( + fullPath, + remainingParts, + caseSensitive, + key !== null ? new Set([...visited, key]) : visited, + ); + } + } + } + } + } + + async readBytes(path: string, n?: number): Promise { + const resolved = this._resolvePath(path); + if (n === undefined) { + return Buffer.from(await readFile(resolved)); + } + const fh = await open(resolved, 'r'); + try { + const buf = Buffer.alloc(n); + const { bytesRead } = await fh.read(buf, 0, n, 0); + return buf.subarray(0, bytesRead); + } finally { + await fh.close(); + } + } + + async readText( + path: string, + options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, + ): Promise { + const resolved = this._resolvePath(path); + const encoding = options?.encoding ?? 'utf-8'; + const errors = options?.errors ?? 'strict'; + const data = await readFile(resolved); + return decodeTextWithErrors(data, encoding, errors); + } + + async *readLines( + path: string, + options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors }, + ): AsyncGenerator { + const resolved = this._resolvePath(path); + const encoding = options?.encoding ?? 'utf-8'; + const errors = options?.errors ?? 'strict'; + + if (!isUtf8Encoding(encoding)) { + const content = decodeTextWithErrors(await readFile(resolved), encoding, errors); + yield* splitLinesKeepingTerminator(content); + return; + } + + yield* this._readUtf8Lines(resolved, errors); + } + + async scanTextFile(path: string): Promise { + const resolved = this._resolvePath(path); + const fh = await open(resolved, 'r'); + try { + const buf = Buffer.alloc(READ_CHUNK_SIZE); + const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; + const validator = createUtf8Validator(); + let totalLines = 0; + let totalBytes = 0; + let endsWithNewline = false; + let hasNul = false; + let prevWasCr = false; + + while (true) { + const { bytesRead } = await fh.read(buf, 0, buf.length, null); + if (bytesRead === 0) break; + const chunk = buf.subarray(0, bytesRead); + validator.write(chunk); + for (let i = 0; i < chunk.length; i += 1) { + const byte = chunk[i]; + if (byte === undefined) continue; + if (byte === 0) hasNul = true; + if (byte === 0x0a) totalLines += 1; + } + prevWasCr = updateLineEndingFlagsFromBytes(flags, chunk, prevWasCr); + totalBytes += bytesRead; + endsWithNewline = chunk[bytesRead - 1] === 0x0a; + } + + if (prevWasCr) flags.hasLoneCr = true; + validator.end(); + if (totalBytes > 0 && !endsWithNewline) totalLines += 1; + return { totalLines, endsWithNewline, hasNul, lineEndingFlags: flags }; + } finally { + await fh.close(); + } + } + + async *readLineRange( + path: string, + options: { startLine: number; maxLines: number; errors?: TextDecodeErrors }, + ): AsyncGenerator { + const resolved = this._resolvePath(path); + const errors = options.errors ?? 'strict'; + yield* this._readUtf8Lines(resolved, errors, { + startLine: options.startLine, + maxLines: options.maxLines, + }); + } + + async *readTailLines( + path: string, + options: { tailCount: number; errors?: TextDecodeErrors }, + ): AsyncGenerator { + if (options.tailCount <= 0) return; + const resolved = this._resolvePath(path); + const errors = options.errors ?? 'strict'; + const fh = await open(resolved, 'r'); + try { + const s = await fh.stat(); + if (s.size === 0) return; + + let pos = s.size; + let foundLf = 0; + let startOffset = 0; + let needLf = options.tailCount; + let sawTailBlock = false; + + while (pos > 0 && foundLf < needLf) { + const readSize = Math.min(READ_CHUNK_SIZE, pos); + pos -= readSize; + const buf = Buffer.alloc(readSize); + await fh.read(buf, 0, readSize, pos); + if (!sawTailBlock) { + sawTailBlock = true; + const endsWithNewline = buf[readSize - 1] === 0x0a; + needLf = endsWithNewline ? options.tailCount + 1 : options.tailCount; + } + for (let i = readSize - 1; i >= 0; i -= 1) { + const byte = buf[i]; + if (byte !== 0x0a) continue; + foundLf += 1; + if (foundLf === needLf) { + startOffset = pos + i + 1; + break; + } + } + } + + if (foundLf < needLf) startOffset = 0; + const data = await readRange(fh, startOffset, s.size - startOffset); + const text = decodeTextWithErrors(data, 'utf-8', errors, startOffset !== 0); + yield* splitLinesKeepingTerminator(text); + } finally { + await fh.close(); + } + } + + private async *_readUtf8Lines( + resolved: string, + errors: TextDecodeErrors, + range?: { startLine?: number; maxLines?: number }, + ): AsyncGenerator { + const startLine = range?.startLine ?? 1; + const maxLines = range?.maxLines ?? Number.POSITIVE_INFINITY; + const fh = await open(resolved, 'r'); + try { + const buf = Buffer.alloc(READ_CHUNK_SIZE); + let pending: Buffer[] = []; + let pendingOffset = 0; + let fileOffset = 0; + let lineNo = 1; + let yielded = 0; + + while (true) { + const { bytesRead } = await fh.read(buf, 0, buf.length, null); + if (bytesRead === 0) break; + const chunk = buf.subarray(0, bytesRead); + let lineStart = 0; + + for (let i = 0; i < chunk.length; i += 1) { + const byte = chunk[i]; + if (byte !== 0x0a) continue; + const piece = chunk.subarray(lineStart, i + 1); + const lineOffset = pending.length === 0 ? fileOffset + lineStart : pendingOffset; + const line = pending.length === 0 ? piece : Buffer.concat([...pending, piece]); + if (lineNo >= startLine) { + yield decodeTextWithErrors(line, 'utf-8', errors, lineOffset !== 0); + yielded += 1; + if (yielded >= maxLines) return; + } + pending = []; + lineStart = i + 1; + lineNo += 1; + } + + if (lineStart < chunk.length) { + const tail = Buffer.from(chunk.subarray(lineStart)); + if (pending.length === 0) pendingOffset = fileOffset + lineStart; + pending.push(tail); + } + fileOffset += bytesRead; + } + + if (pending.length > 0) { + const line = Buffer.concat(pending); + if (lineNo >= startLine) { + yield decodeTextWithErrors(line, 'utf-8', errors, pendingOffset !== 0); + } + } + } finally { + await fh.close(); + } + } + + async writeBytes(path: string, data: Buffer): Promise { + const resolved = this._resolvePath(path); + await writeFile(resolved, data); + return data.length; + } + + async writeText( + path: string, + data: string, + options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding }, + ): Promise { + const resolved = this._resolvePath(path); + const encoding = options?.encoding ?? 'utf-8'; + const mode = options?.mode ?? 'w'; + if (mode === 'a') { + await appendFile(resolved, data, encoding); + } else { + await writeFile(resolved, data, encoding); + } + return data.length; + } + + async mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise { + const resolved = this._resolvePath(path); + const parents = options?.parents ?? false; + const existOk = options?.existOk ?? false; + + if (parents) { + // `fs.mkdir(..., { recursive: true })` silently succeeds when the + // target already exists — it does NOT raise EEXIST. To honor the + // `existOk: false` semantics, we must probe for existence ourselves + // before delegating to the recursive mkdir. + if (!existOk) { + try { + const s = await stat(resolved); + if (s.isDirectory()) { + throw new KaosFileExistsError(`${resolved} already exists`); + } + // Path exists but is not a directory — let `mkdir` surface the + // appropriate error (EEXIST/ENOTDIR) below. + } catch (error: unknown) { + if (error instanceof KaosFileExistsError) throw error; + const err = error as NodeJS.ErrnoException; + if (err.code !== 'ENOENT') throw error; + // ENOENT: target doesn't exist yet — proceed to mkdir. + } + } + await mkdir(resolved, { recursive: true }); + return; + } + + // Non-recursive: fs.mkdir naturally throws EEXIST on collision. + try { + await mkdir(resolved); + } catch (error: unknown) { + if ( + existOk && + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'EEXIST' + ) { + // `existOk` only applies when the conflicting path is itself a + // directory. If a regular file (or other non-directory) already + // occupies the path, silently returning would be a lie — the + // requested directory still does not exist. Surface the conflict + // explicitly so callers cannot mistake "file collision" for + // "directory already present". + const s = await stat(resolved); + if (!s.isDirectory()) { + throw new KaosFileExistsError(`${resolved} already exists but is not a directory`); + } + return; + } + throw error; + } + } + + async exec(...args: string[]): Promise { + const command = args[0]; + if (command === undefined) { + throw new Error('LocalKaos.exec(): at least one argument (the command to run) is required.'); + } + const restArgs = args.slice(1); + const child = spawn( + command, + restArgs, + buildLocalSpawnOptions(isWindows, this._cwd, this._buildExecEnv()), + ); + await waitForSpawn(child); + return new LocalProcess(child); + } + + async execWithEnv(args: string[], env?: Record): Promise { + const command = args[0]; + if (command === undefined) { + throw new Error( + 'LocalKaos.execWithEnv(): at least one argument (the command to run) is required.', + ); + } + const restArgs = args.slice(1); + const child = spawn( + command, + restArgs, + buildLocalSpawnOptions(isWindows, this._cwd, this._buildExecEnv(env)), + ); + await waitForSpawn(child); + return new LocalProcess(child); + } + + private _buildExecEnv(invocationEnv?: Record): Record | undefined { + if (this._envLayers.length === 0) return invocationEnv; + const merged: Record = { + ...(process.env as Record), + ...invocationEnv, + }; + for (const layer of this._envLayers) { + Object.assign(merged, layer); + } + return merged; + } +} + +function isUtf8Encoding(encoding: BufferEncoding): boolean { + return encoding === 'utf-8' || encoding === 'utf8'; +} + +function* splitLinesKeepingTerminator(text: string): Generator { + if (text.length === 0) return; + let start = 0; + for (let i = 0; i < text.length; i += 1) { + if (text.codePointAt(i) === 0x0a) { + yield text.slice(start, i + 1); + start = i + 1; + } + } + if (start < text.length) { + yield text.slice(start); + } +} + +function updateLineEndingFlagsFromBytes( + flags: LineEndingFlags, + chunk: Buffer, + prevWasCr: boolean, +): boolean { + for (let i = 0; i < chunk.length; i += 1) { + const byte = chunk[i]; + if (byte === undefined) continue; + if (byte === 0x0d) { + if (prevWasCr) flags.hasLoneCr = true; + prevWasCr = true; + } else if (byte === 0x0a) { + if (prevWasCr) { + flags.hasCrLf = true; + } else { + flags.hasLf = true; + } + prevWasCr = false; + } else { + if (prevWasCr) flags.hasLoneCr = true; + prevWasCr = false; + } + } + return prevWasCr; +} + +function createUtf8Validator(): { write(chunk: Buffer): void; end(): void } { + let needed = 0; + let lower = 0x80; + let upper = 0xbf; + + const fail = (): never => { + throw new TypeError('Invalid UTF-8 data'); + }; + + return { + write(chunk: Buffer): void { + for (let i = 0; i < chunk.length; i += 1) { + const byte = chunk[i]; + if (byte === undefined) continue; + if (needed === 0) { + if (byte <= 0x7f) continue; + if (byte >= 0xc2 && byte <= 0xdf) { + needed = 1; + } else if (byte === 0xe0) { + needed = 2; + lower = 0xa0; + } else if (byte >= 0xe1 && byte <= 0xec) { + needed = 2; + } else if (byte === 0xed) { + needed = 2; + upper = 0x9f; + } else if (byte >= 0xee && byte <= 0xef) { + needed = 2; + } else if (byte === 0xf0) { + needed = 3; + lower = 0x90; + } else if (byte >= 0xf1 && byte <= 0xf3) { + needed = 3; + } else if (byte === 0xf4) { + needed = 3; + upper = 0x8f; + } else { + fail(); + } + } else { + if (byte < lower || byte > upper) fail(); + lower = 0x80; + upper = 0xbf; + needed -= 1; + } + } + }, + end(): void { + if (needed !== 0) fail(); + }, + }; +} + +async function readRange( + fh: Awaited>, + start: number, + length: number, +): Promise { + const data = Buffer.alloc(length); + let offset = 0; + while (offset < length) { + const { bytesRead } = await fh.read(data, offset, length - offset, start + offset); + if (bytesRead === 0) break; + offset += bytesRead; + } + return offset === length ? data : data.subarray(0, offset); +} + +// Wait for a freshly spawned ChildProcess to either emit 'spawn' (success) or +// 'error' (ENOENT / EACCES / etc.). Until this resolves, callers should not +// assume the child is running — they may otherwise write to the stdin of a +// process that never existed. +function waitForSpawn(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + const onSpawn = (): void => { + child.off('error', onError); + resolve(); + }; + const onError = (err: Error): void => { + child.off('spawn', onSpawn); + reject(err); + }; + child.once('spawn', onSpawn); + child.once('error', onError); + }); +} diff --git a/packages/kaos/src/login-shell-path.ts b/packages/kaos/src/login-shell-path.ts new file mode 100644 index 0000000000000000000000000000000000000000..1983c8058c609ecae879ba54bc1232fa8b4ab5da --- /dev/null +++ b/packages/kaos/src/login-shell-path.ts @@ -0,0 +1,161 @@ +/** + * Login-shell PATH probe — enrich `process.env.PATH` with entries from the + * user's login shell. + * + * When kimi-code is launched from a context that skipped the user's shell + * profile (GUI launchers, non-login parent shells), `process.env.PATH` + * misses entries like `/opt/homebrew/bin`, so commands spawned by the Bash + * tool can't find tools the user has in their interactive shell (e.g. + * `gh`). We run the user's login shell once (`$SHELL -l -c /usr/bin/env`), + * extract its PATH, and append the entries the current PATH lacks. Existing + * entries keep their order and priority; failures (no resolvable shell, + * hung or broken profile) silently leave PATH untouched. + * + * launchd/daemon launches can leave `$SHELL` unset or blank, so + * the probe falls back to the OS account's login shell from the user + * database before giving up. + * + * Like `detectEnvironment`, the probe is a pure function of injected deps + * so the suite runs identically on any host. Windows is skipped: the + * problem is specific to POSIX login-shell profiles. + */ + +import { userInfo } from 'node:os'; + +import { execFileText } from './environment'; + +export interface LoginShellPathDeps { + readonly platform: string; + readonly env: Record; + /** Login shell from the OS user database; fallback when $SHELL is unset. */ + readonly userShell: () => string | undefined; + readonly execFileText: ( + file: string, + args: readonly string[], + timeoutMs: number, + ) => Promise; +} + +const LOGIN_SHELL_ENV_TIMEOUT_MS = 5_000; + +/** + * Run the user's login shell and return its PATH, or `undefined` when the + * probe does not apply (Windows, no resolvable shell) or fails (spawn + * error, timeout, no PATH in the output). + */ +export async function probeLoginShellPath(deps: LoginShellPathDeps): Promise { + if (deps.platform === 'win32') return undefined; + // A set-but-blank $SHELL (some daemon/launchd envs) must also fall back. + const envShell = deps.env['SHELL']?.trim(); + const shell = envShell === undefined || envShell.length === 0 ? deps.userShell() : envShell; + if (shell === undefined || shell.length === 0) return undefined; + + // `env` prints the resolved environment in every shell dialect, unlike + // `echo $PATH`, which fish would join with spaces. Invoke it by absolute + // path: a bare `env` resolves through the inherited PATH — which may + // carry cwd-dependent components — from the workspace cwd, so a + // repo-planted `env` binary could run at session startup and feed us an + // arbitrary PATH. The absolute path also bypasses profile function + // shadowing, and /usr/bin/env is guaranteed on every mainstream POSIX + // system (it is the canonical shebang interpreter path). + const stdout = await deps.execFileText( + shell, + ['-l', '-c', '/usr/bin/env'], + LOGIN_SHELL_ENV_TIMEOUT_MS, + ); + if (stdout === undefined) return undefined; + + // Profile output lands on stdout before `env` runs, so keep the last + // PATH= line. + let path: string | undefined; + for (const line of stdout.split('\n')) { + if (line.startsWith('PATH=')) { + path = line.slice('PATH='.length).trim(); + } + } + if (path === undefined || path.length === 0) return undefined; + return path; +} + +/** + * Union of the current PATH and the login-shell PATH: the current PATH + * string is kept verbatim — including empty components, which POSIX + * command lookup treats as the current directory — and login-shell + * entries the current PATH lacks are appended in their own order. When + * nothing is missing the current string is returned unchanged. Only + * absolute login-shell entries are imported: empty, `.`, and relative + * components are all cwd-dependent lookup, and appending one the user + * did not already have would widen their search path — LocalKaos runs + * commands from arbitrary workspace directories. + */ +export function mergeLoginShellPath( + currentPath: string | undefined, + loginShellPath: string, +): string { + const current = currentPath ?? ''; + const seen = new Set(current.split(':').filter((entry) => entry.length > 0)); + const additions: string[] = []; + for (const entry of loginShellPath.split(':')) { + // The probe only runs on POSIX (win32 bails before merging), so a + // leading slash is a sufficient absoluteness test. Empty components + // fail it too. + if (!entry.startsWith('/') || seen.has(entry)) continue; + seen.add(entry); + additions.push(entry); + } + if (additions.length === 0) return current; + // `undefined` means "no PATH at all", so the additions stand alone; '' + // is a real (cwd-only) PATH whose empty component must survive as a + // leading colon. + if (currentPath === undefined) return additions.join(':'); + return `${current}:${additions.join(':')}`; +} + +/** Probe the login shell and merge its PATH into `deps.env['PATH']`. */ +export async function applyLoginShellPath(deps: LoginShellPathDeps): Promise { + const loginShellPath = await probeLoginShellPath(deps); + if (loginShellPath === undefined) return; + const currentPath = deps.env['PATH']; + const merged = mergeLoginShellPath(currentPath, loginShellPath); + // Only write when something was appended — an unset PATH must stay + // unset (assigning '' would turn "implementation default search path" + // into "cwd-only lookup"), and a set PATH must not be rewritten. + if (merged === (currentPath ?? '')) return; + deps.env['PATH'] = merged; +} + +/** + * Production convenience — apply the probe to `process.env` once per + * process. Memoised like `detectEnvironmentFromNode`: the login-shell PATH + * does not change for the lifetime of the process, and repeated + * `LocalKaos.create()` calls must not re-spawn the shell. + */ +/** + * Login shell from the OS user database (`/etc/passwd` via getpwuid on + * Linux, Directory Services on macOS). `userInfo()` throws when the uid + * has no database entry (e.g. containers running an arbitrary uid), and + * service accounts may carry `/usr/sbin/nologin` — the latter needs no + * special casing here because probing it simply fails and degrades + * silently. + */ +function userShellFromNode(): string | undefined { + try { + const shell = userInfo().shell; + return shell === null || shell.length === 0 ? undefined : shell; + } catch { + return undefined; + } +} + +let appliedLoginShellPath: Promise | undefined; + +export function applyLoginShellPathFromNode(): Promise { + if (appliedLoginShellPath !== undefined) return appliedLoginShellPath; + appliedLoginShellPath = applyLoginShellPath({ + platform: process.platform, + env: process.env as Record, + userShell: userShellFromNode, + execFileText, + }); + return appliedLoginShellPath; +} diff --git a/packages/kaos/src/process.ts b/packages/kaos/src/process.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a19ecc70cd56b0324d192c1d4f27373483cdf42 --- /dev/null +++ b/packages/kaos/src/process.ts @@ -0,0 +1,27 @@ +import type { Readable, Writable } from 'node:stream'; + +/** + * A running process spawned by a {@link Kaos} environment. + * + * Provides access to standard I/O streams, the process ID, and lifecycle + * management (wait / kill). The interface is intentionally minimal so it + * can be backed by local child processes, SSH sessions, or container runtimes. + */ +export interface KaosProcess { + /** Writable stream connected to the process's standard input. */ + readonly stdin: Writable; + /** Readable stream for the process's standard output. */ + readonly stdout: Readable; + /** Readable stream for the process's standard error. */ + readonly stderr: Readable; + /** Operating-system process ID. */ + readonly pid: number; + /** Exit code if the process has already terminated, otherwise `null`. */ + readonly exitCode: number | null; + /** Wait for the process to exit and return its exit code. */ + wait(): Promise; + /** Send a signal to the process (defaults to `SIGTERM`). */ + kill(signal?: NodeJS.Signals): Promise; + /** Release stdin/stdout/stderr resources owned by this process wrapper. */ + dispose(): Promise | void; +} diff --git a/packages/kaos/src/shell-path-bridge.ts b/packages/kaos/src/shell-path-bridge.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9402eaa8b0b65c134208a8640d600538c8c4276 --- /dev/null +++ b/packages/kaos/src/shell-path-bridge.ts @@ -0,0 +1,189 @@ +/** + * Shell path bridge — translate between native win32 paths and the POSIX + * path dialect spoken by the MSYS2 / Git Bash shell. + * + * The msys runtime gives the shell a POSIX path view native Node.js cannot + * resolve (`/c/Users/x` is `C:\Users\x`; `/tmp/x` is `%TEMP%\x`, not + * `/tmp`). `toShellPath` renders native paths for bash command + * lines; `fromShellPath` resolves model/shell-supplied paths for fs access, + * translating drive-letter forms lexically and other root-relative paths + * through `cygpath -w` next to the probed bash. Anything unconvertible + * passes through unchanged, and both directions are identity outside win32 + * bash. + * + * Synchronous and self-contained (node builtins only): + * `createShellPathBridge` takes injectable deps for tests; + * `getShellPathBridge` bundles the Node defaults, memoised per env object. + */ + +import { execFileSync as nodeExecFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import * as nodePath from 'node:path'; + +import type { Environment } from './environment'; + +export interface ShellPathBridge { + /** Native win32 path → shell dialect, for building bash commands. Identity on posix. */ + toShellPath(nativePath: string): string; + /** Model/shell-supplied path → native, for fs access. Identity when not convertible. */ + fromShellPath(path: string): string; +} + +export type ShellPathBridgeEnv = Pick; + +export interface ShellPathBridgeDeps { + readonly execFileSync: (file: string, args: readonly string[]) => string; + readonly isFile: (path: string) => boolean; +} + +const CYGPATH_TIMEOUT_MS = 5_000; + +const DRIVE_COLON_RE = /^\/([a-zA-Z]):(?:[\\/]|$)/; +const CYGDRIVE_RE = /^\/cygdrive\/([a-zA-Z])(?:\/|$)/; +const DRIVE_RE = /^\/([a-zA-Z])(?:\/|$)/; + +// cygpath semantics are undefined for the virtual filesystems. +const VIRTUAL_FS_PREFIXES: readonly string[] = ['/dev/', '/proc/', '/sys/']; + +const WIN32_DRIVE_ABSOLUTE_RE = /^[A-Za-z]:[\\/]/; + +function joinDrive(letter: string, rest: string): string { + const normalizedRest = rest.replaceAll('\\', '/'); + return normalizedRest === '' + ? `${letter.toUpperCase()}:/` + : `${letter.toUpperCase()}:${normalizedRest}`; +} + +/** + * Lexical translation of shell-dialect drive paths (`/c/x`, `/c:/x`, + * `/cygdrive/c/x`) to native win32 form — pure string rewriting, no cygpath + * involved. Anything else is returned unchanged. + */ +export function translateShellDrivePath(path: string): string { + const colonMatch = DRIVE_COLON_RE.exec(path); + if (colonMatch !== null) { + return joinDrive(colonMatch[1]!, path.slice(3)); + } + const cygdriveMatch = CYGDRIVE_RE.exec(path); + if (cygdriveMatch !== null) { + return joinDrive(cygdriveMatch[1]!, path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length)); + } + const driveMatch = DRIVE_RE.exec(path); + if (driveMatch !== null) { + return joinDrive(driveMatch[1]!, path.slice(2)); + } + return path; +} + +export function createShellPathBridge( + env: ShellPathBridgeEnv, + deps: ShellPathBridgeDeps, +): ShellPathBridge { + const enabled = env.osKind === 'Windows' && env.shellName === 'bash'; + + // Lazily located on first use; `null` = not found → permanent pass-through. + let cygpathExe: string | null | undefined; + // Cache successes only: a missing cygpath.exe is a stable fact, but an + // execution failure may be transient. First-segment caching is exact for + // default (first-level) mount tables; deeper user mounts are out of scope. + const segmentCache = new Map(); + + function locateCygpath(): string | null { + if (cygpathExe !== undefined) return cygpathExe; + const shellDir = nodePath.win32.dirname(env.shellPath); + const candidates = [nodePath.win32.join(shellDir, 'cygpath.exe')]; + if (nodePath.win32.basename(shellDir).toLowerCase() === 'bin') { + candidates.push(nodePath.win32.join(shellDir, '..', 'usr', 'bin', 'cygpath.exe')); + } + cygpathExe = candidates.find((candidate) => deps.isFile(candidate)) ?? null; + return cygpathExe; + } + + function resolveRootSegment(firstSegment: string): string | null { + const cached = segmentCache.get(firstSegment); + if (cached !== undefined) return cached; + + const exe = locateCygpath(); + if (exe === null) return null; + let resolved: string; + try { + const output = deps.execFileSync(exe, ['-w', '-C', 'UTF8', '--', `/${firstSegment}`]); + // cygpath appends a newline and may emit a trailing separator (`D:\`). + const trimmed = output.replace(/\r?\n$/, ''); + if (!WIN32_DRIVE_ABSOLUTE_RE.test(trimmed) && !trimmed.startsWith('\\\\')) return null; + resolved = trimmed.replace(/[\\/]$/, ''); + } catch { + return null; + } + segmentCache.set(firstSegment, resolved); + return resolved; + } + + function fromShellPath(path: string): string { + if (!enabled) return path; + + // Keep UNC out first: posix.normalize would collapse the leading `//`. + if (path.startsWith('//')) return path; + + if (path.startsWith('/')) { + // Fold dot segments first: `/tmp/..` is `/` in the shell VFS, not `%TEMP%\..`. + const normalized = nodePath.posix.normalize(path); + const lexical = translateShellDrivePath(normalized); + if (lexical !== normalized) return lexical; + if (normalized === '/') return normalized; + if (VIRTUAL_FS_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return normalized; + const firstSegment = normalized.slice(1).split('/')[0]!; + const prefix = resolveRootSegment(firstSegment); + if (prefix === null) return normalized; + const remainder = normalized.slice(firstSegment.length + 1); + const joined = `${prefix}${remainder}`.replaceAll('\\', '/'); + // A mounted drive root resolved from a bare segment (`D:`) stays absolute. + return /^[A-Za-z]:$/.test(joined) ? `${joined}/` : joined; + } + + return path; + } + + function toShellPath(nativePath: string): string { + if (!enabled) return nativePath; + + if (nativePath.startsWith('\\\\')) { + return nativePath.replaceAll('\\', '/'); + } + + const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(nativePath); + if (driveMatch !== null) { + const drive = driveMatch[1]!.toLowerCase(); + const rest = nativePath.slice(2).replaceAll('\\', '/'); + return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`; + } + + return nativePath.replaceAll('\\', '/'); + } + + return { toShellPath, fromShellPath }; +} + +const bridgeCache = new Map(); + +/** + * Production convenience — Node's ambient `execFileSync` / `existsSync`, + * memoised per shell identity so call sites that wrap the same probed + * environment in a fresh object still share one bridge. + */ +export function getShellPathBridge(env: ShellPathBridgeEnv): ShellPathBridge { + const key = `${env.osKind} ${env.shellName} ${env.shellPath}`; + const cached = bridgeCache.get(key); + if (cached !== undefined) return cached; + const bridge = createShellPathBridge(env, { + execFileSync: (file, args) => + nodeExecFileSync(file, [...args], { + encoding: 'utf8', + timeout: CYGPATH_TIMEOUT_MS, + windowsHide: true, + }), + isFile: (path) => existsSync(path), + }); + bridgeCache.set(key, bridge); + return bridge; +} diff --git a/packages/kaos/src/ssh.ts b/packages/kaos/src/ssh.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b897f9aa184c7a8492dca5fa660e89da9591dc3 --- /dev/null +++ b/packages/kaos/src/ssh.ts @@ -0,0 +1,945 @@ +import { readFile } from 'node:fs/promises'; +import { isAbsolute, join, normalize, resolve } from 'pathe'; +import type { Readable, Writable } from 'node:stream'; + +import * as ssh2 from 'ssh2'; +import type { + AnyAuthMethod, + Client, + ClientChannel, + ConnectConfig, + SFTPWrapper, + Stats as SFTPStats, +} from 'ssh2'; + +import type { Environment } from './environment'; +import { KaosError, KaosFileExistsError, KaosValueError } from './errors'; +import { BufferedReadable, decodeTextWithErrors, globPatternToRegex } from './internal'; +import type { Kaos } from './kaos'; +import type { KaosProcess } from './process'; +import type { StatResult } from './types'; + +// ── stat mode constants ──────────────────────────────────────────────── +const S_IFMT = 0o170000; +const S_IFREG = 0o100000; +const S_IFDIR = 0o040000; +const S_IFLNK = 0o120000; +const S_IFSOCK = 0o140000; +const S_IFCHR = 0o020000; +const S_IFBLK = 0o060000; +const S_IFIFO = 0o010000; + +const DEFAULT_SFTP_STATUS_CODE = { + BAD_MESSAGE: 5, + CONNECTION_LOST: 7, + FAILURE: 4, + NO_CONNECTION: 6, + NO_SUCH_FILE: 2, + OP_UNSUPPORTED: 8, + PERMISSION_DENIED: 3, +} as const; + +// ── SSH options ──────────────────────────────────────────────────────── + +/** + * Advanced ssh2 connect options that may be passed through `SSHKaosOptions.extraOptions`. + * + * Excludes fields that SSHKaos manages itself (`host`, `port`, `username`, + * `password`, `privateKey`, `authHandler`, `hostVerifier`) — those are derived + * from the top-level `SSHKaosOptions` fields and cannot be overridden here. + */ +export type SSHKaosExtraOptions = Omit< + ConnectConfig, + 'host' | 'port' | 'username' | 'password' | 'privateKey' | 'authHandler' | 'hostVerifier' +>; + +export interface SSHKaosOptions { + host: string; + port?: number; + username: string; + password?: string; + keyPaths?: string[]; + keyContents?: string[]; + cwd?: string; + /** + * Pass-through for advanced ssh2 `ConnectConfig` fields such as `algorithms`, + * `keepaliveInterval`, `readyTimeout`, `debug`, `tryKeyboard`, `agent`, etc. + * + * Managed fields (`host`, `port`, `username`, `password`, `privateKey`, + * `authHandler`, `hostVerifier`) are excluded from this type and will take + * precedence over anything set here. + */ + extraOptions?: SSHKaosExtraOptions; +} + +// ── SSH error types ─────────────────────────────────────────────────── + +export class KaosSSHError extends KaosError { + readonly code: number | undefined; + + constructor(message: string, code?: number) { + super(message); + this.name = 'KaosSSHError'; + this.code = code; + } +} + +export class KaosFileNotFoundError extends KaosSSHError { + constructor(message: string, code?: number) { + super(message, code); + this.name = 'KaosFileNotFoundError'; + } +} + +export class KaosPermissionError extends KaosSSHError { + constructor(message: string, code?: number) { + super(message, code); + this.name = 'KaosPermissionError'; + } +} + +export class KaosConnectionError extends KaosSSHError { + constructor(message: string, code?: number) { + super(message, code); + this.name = 'KaosConnectionError'; + } +} + +// ── shell quoting ────────────────────────────────────────────────────── + +/** + * Shell-escape a single argument (POSIX sh compatible). + * Mirrors Python's shlex.quote(). + */ +function shellQuote(arg: string): string { + if (arg === '') return "''"; + // If the string is safe (only contains safe chars), return as-is + if (/^[A-Za-z0-9_./:=@%^,+-]+$/.test(arg)) return arg; + // Otherwise wrap in single quotes, escaping any embedded single quotes + return "'" + arg.replaceAll("'", "'\"'\"'") + "'"; +} + +// ── stat mode builder ────────────────────────────────────────────────── + +/** + * Build a POSIX st_mode from SFTP Stats. + * ssh2's Stats has .mode which already includes both file-type bits and + * permission bits, but we also check the boolean helpers as a fallback. + */ +function buildStMode(attrs: SFTPStats): number { + const raw = attrs.mode; + // If mode already contains file-type bits, return as-is + if ((raw & S_IFMT) !== 0) return raw; + + // Derive file-type bits from the is* helpers + let typeBits = 0; + if (attrs.isDirectory()) typeBits = S_IFDIR; + else if (attrs.isFile()) typeBits = S_IFREG; + else if (attrs.isSymbolicLink()) typeBits = S_IFLNK; + else if (attrs.isSocket()) typeBits = S_IFSOCK; + else if (attrs.isCharacterDevice()) typeBits = S_IFCHR; + else if (attrs.isBlockDevice()) typeBits = S_IFBLK; + else if (attrs.isFIFO()) typeBits = S_IFIFO; + + return (raw & ~S_IFMT) | typeBits; +} + +function getSftpStatusCode(): typeof DEFAULT_SFTP_STATUS_CODE { + return { + ...DEFAULT_SFTP_STATUS_CODE, + ...ssh2.utils?.sftp?.STATUS_CODE, + }; +} + +function getErrorCode(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) { + return undefined; + } + const { code } = error as { code?: unknown }; + return typeof code === 'number' ? code : undefined; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function mapSftpError(operation: string, error: unknown): KaosSSHError { + const code = getErrorCode(error); + const message = `${operation} failed: ${getErrorMessage(error)}`; + const statusCode = getSftpStatusCode(); + + if (code === statusCode.NO_SUCH_FILE) { + return new KaosFileNotFoundError(message, code); + } + if (code === statusCode.PERMISSION_DENIED) { + return new KaosPermissionError(message, code); + } + if (code === statusCode.NO_CONNECTION || code === statusCode.CONNECTION_LOST) { + return new KaosConnectionError(message, code); + } + return new KaosSSHError(message, code); +} + +function buildAuthHandler( + username: string, + privateKeys: readonly (Buffer | string)[], + password?: string, +): ConnectConfig['authHandler'] { + const authQueue: AnyAuthMethod[] = privateKeys.map((key) => ({ + key, + type: 'publickey', + username, + })); + if (password !== undefined) { + authQueue.push({ + password, + type: 'password', + username, + }); + } + + let index = 0; + return (_authsLeft, _partialSuccess, next) => { + const nextAuth = authQueue[index]; + index += 1; + const nextWithFalse = next as (auth: AnyAuthMethod | false) => void; + nextWithFalse(nextAuth ?? false); + }; +} + +// ── SSH process ──────────────────────────────────────────────────────── + +/** Exported for unit tests only. Do not use directly. */ +export class SSHProcess implements KaosProcess { + readonly stdin: Writable; + readonly stdout: Readable; + readonly stderr: Readable; + readonly pid: number = -1; + + private _exitCode: number | null = null; + private readonly _exitPromise: Promise; + private readonly _channel: ClientChannel; + private _disposed = false; + + constructor(channel: ClientChannel) { + this._channel = channel; + this.stdin = channel; + this.stdout = new BufferedReadable(channel as unknown as Readable); + this.stderr = new BufferedReadable(channel.stderr); + + this._exitPromise = new Promise((resolve) => { + // Listen to 'close' on the channel, not 'exit', to ensure all + // buffered output is flushed before we resolve. + channel.on('close', (code: number | null) => { + // Some ssh2 backends surface the exit status only on 'close'. + this._exitCode ??= code ?? 1; + resolve(this._exitCode); + }); + channel.on('exit', (code: number | null) => { + this._exitCode = code ?? 1; + }); + }); + } + + get exitCode(): number | null { + return this._exitCode; + } + + async wait(): Promise { + return this._exitPromise; + } + + kill(signal?: NodeJS.Signals): Promise { + // SSH signals must be stripped of the "SIG" prefix (RFC 4254 §6.9): + // e.g. 'SIGTERM' → 'TERM', 'SIGKILL' → 'KILL', 'SIGINT' → 'INT'. + // Honor the caller's requested signal so that remote processes can + // perform graceful shutdown on SIGTERM/SIGINT. + const rawSignal = signal ?? 'SIGTERM'; + const sshSignal = rawSignal.startsWith('SIG') ? rawSignal.slice(3) : rawSignal; + this._channel.signal(sshSignal); + return Promise.resolve(); + } + + dispose(): void { + if (this._disposed) return; + this._disposed = true; + this.stdin.destroy(); + this.stdout.destroy(); + this.stderr.destroy(); + } +} + +// ── Promisified SSH helpers ──────────────────────────────────────────── + +function connectClient(config: ConnectConfig): Promise { + const client = new ssh2.Client(); + return new Promise((resolve, reject) => { + client.on('ready', () => { + resolve(client); + }); + client.on('error', (err: Error) => { + reject(err); + }); + client.connect(config); + }); +} + +function getSftp(client: Client): Promise { + return new Promise((resolve, reject) => { + client.sftp((err, sftp) => { + if (err) { + reject(err); + } else { + resolve(sftp); + } + }); + }); +} + +// Every promisified SFTP helper funnels rejections through `mapSftpError` so +// callers see a KaosSSHError subclass (KaosFileNotFoundError / KaosPermissionError / +// KaosConnectionError / generic KaosSSHError) instead of the raw ssh2 error. +// The operation label is the underlying SFTP RPC name — it shows up in the +// error message for debugging and is the same label used by `stat()` before +// this was hoisted into the helpers. + +function sftpRealpath(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.realpath(path, (err, absPath) => { + if (err) { + reject(mapSftpError('realpath', err)); + } else { + resolve(absPath); + } + }); + }); +} + +function sftpStat(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.stat(path, (err, stats) => { + if (err) { + reject(mapSftpError('stat', err)); + } else { + resolve(stats); + } + }); + }); +} + +function sftpLstat(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.lstat(path, (err, stats) => { + if (err) { + reject(mapSftpError('lstat', err)); + } else { + resolve(stats); + } + }); + }); +} + +interface SFTPFileEntry { + filename: string; + attrs: SFTPStats; +} + +function sftpReaddir(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.readdir(path, (err, list) => { + if (err) { + reject(mapSftpError('readdir', err)); + } else { + resolve(list as SFTPFileEntry[]); + } + }); + }); +} + +function sftpMkdir(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.mkdir(path, (err) => { + if (err) { + reject(mapSftpError('mkdir', err)); + } else { + resolve(); + } + }); + }); +} + +function sftpExists(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve) => { + sftp.exists(path, (exists) => { + resolve(exists); + }); + }); +} + +function sftpReadFile(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.readFile(path, (err, data) => { + if (err) { + reject(mapSftpError('readFile', err)); + } else { + resolve(data); + } + }); + }); +} + +function sftpWriteFile(sftp: SFTPWrapper, path: string, data: string | Buffer): Promise { + return new Promise((resolve, reject) => { + sftp.writeFile(path, data, (err) => { + if (err) { + reject(mapSftpError('writeFile', err)); + } else { + resolve(); + } + }); + }); +} + +function sftpAppendFile(sftp: SFTPWrapper, path: string, data: string | Buffer): Promise { + return new Promise((resolve, reject) => { + sftp.appendFile(path, data, (err) => { + if (err) { + reject(mapSftpError('appendFile', err)); + } else { + resolve(); + } + }); + }); +} + +function clientExec(client: Client, command: string): Promise { + return new Promise((resolve, reject) => { + client.exec(command, (err: Error | undefined, channel: ClientChannel) => { + if (err) { + reject(err); + } else { + resolve(channel); + } + }); + }); +} + +// ── SSHKaos ──────────────────────────────────────────────────────────── + +/** + * A KAOS implementation that interacts with a remote machine via SSH and SFTP. + */ +export class SSHKaos implements Kaos { + readonly name: string = 'ssh'; + + private _client: Client; + private _sftp: SFTPWrapper; + private _home: string; + private _cwd: string; + private readonly _envLayers: readonly Record[]; + + // Stub: real wiring (probing the remote host via `uname` / `$SHELL` over the + // SSH transport) is deferred. + get osEnv(): Environment { + throw new KaosError( + 'SSHKaos.osEnv is not yet wired — remote environment probing is not implemented.', + ); + } + + private constructor( + client: Client, + sftp: SFTPWrapper, + home: string, + cwd: string, + envLayers: readonly Record[] = [], + ) { + this._client = client; + this._sftp = sftp; + this._home = home; + this._cwd = cwd; + this._envLayers = envLayers; + } + + withCwd(cwd: string): SSHKaos { + return new SSHKaos(this._client, this._sftp, this._home, cwd, this._envLayers); + } + + withEnv(env: Record): SSHKaos { + return new SSHKaos(this._client, this._sftp, this._home, this._cwd, [...this._envLayers, env]); + } + + private _resolvePath(path: string): string { + if (isAbsolute(path)) return path; + return join(this._cwd, path); + } + + /** + * Factory method to create an SSHKaos instance. + * Establishes the SSH connection and SFTP session. + */ + static async create(options: SSHKaosOptions): Promise { + // Start from extraOptions (advanced ssh2 options) so our managed fields + // below take precedence. + const config: ConnectConfig = { + ...options.extraOptions, + host: options.host, + port: options.port ?? 22, + username: options.username, + }; + + if (options.password !== undefined) { + config.password = options.password; + } + + // Build private keys from keyContents and keyPaths + const privateKeys: (Buffer | string)[] = []; + if (options.keyContents) { + for (const content of options.keyContents) { + privateKeys.push(content); + } + } + if (options.keyPaths) { + const keyPromises = options.keyPaths.map((keyPath) => readFile(keyPath, 'utf-8')); + const keyData = await Promise.all(keyPromises); + for (const key of keyData) { + privateKeys.push(key); + } + } + if (privateKeys.length > 0) { + const authHandler = buildAuthHandler(options.username, privateKeys, options.password); + if (authHandler !== undefined) { + config.authHandler = authHandler; + } + } + + // Disable host key verification (like asyncssh known_hosts=None) + config.hostVerifier = () => true; + + const client = await connectClient(config); + try { + const sftp = await getSftp(client); + + // Determine home and cwd + const home = await sftpRealpath(sftp, '.'); + let cwd: string; + if (options.cwd === undefined) { + cwd = home; + } else { + cwd = await sftpRealpath(sftp, options.cwd); + const attrs = await sftpStat(sftp, cwd); + if (!attrs.isDirectory()) { + throw new KaosValueError(`${cwd} is not a directory`); + } + } + + return new SSHKaos(client, sftp, home, cwd); + } catch (error) { + client.end(); + throw error; + } + } + + // ── Path operations (sync) ───────────────────────────────────────── + + pathClass(): 'posix' | 'win32' { + return 'posix'; + } + + normpath(path: string): string { + return normalize(path); + } + + gethome(): string { + return this._home; + } + + getcwd(): string { + return this._cwd; + } + + // ── Directory operations (async) ─────────────────────────────────── + + async chdir(path: string): Promise { + let target: string; + if (isAbsolute(path)) { + target = path; + } else { + target = resolve(this._cwd, path); + } + // Resolve to the real path via SFTP + const resolved = await sftpRealpath(this._sftp, target); + // Verify the resolved target is actually a directory. Without this + // guard, `realpath` happily returns file paths, causing later relative + // reads/writes/execs to treat a regular file as a working directory. + const attrs = await sftpStat(this._sftp, resolved); + if (!attrs.isDirectory()) { + throw new KaosValueError(`${resolved} is not a directory`); + } + this._cwd = resolved; + } + + async stat(path: string, options?: { followSymlinks?: boolean }): Promise { + const resolved = this._resolvePath(path); + const followSymlinks = options?.followSymlinks ?? true; + // sftpStat / sftpLstat already wrap errors via mapSftpError. + const st = followSymlinks + ? await sftpStat(this._sftp, resolved) + : await sftpLstat(this._sftp, resolved); + + return { + stMode: buildStMode(st), + // SFTP does not provide inode + stIno: 0, + // SFTP does not provide device + stDev: 0, + // ssh2 Stats does not expose nlink + stNlink: 0, + stUid: st.uid, + stGid: st.gid, + stSize: st.size, + stAtime: st.atime, + stMtime: st.mtime, + // SFTP v3 has no ctime, fallback to mtime + stCtime: st.mtime, + }; + } + + async *iterdir(path: string): AsyncGenerator { + const resolved = this._resolvePath(path); + const entries = await sftpReaddir(this._sftp, resolved); + for (const entry of entries) { + if (entry.filename === '.' || entry.filename === '..') continue; + yield join(resolved, entry.filename); + } + } + + async *glob( + path: string, + pattern: string, + options?: { caseSensitive?: boolean }, + ): AsyncGenerator { + const resolved = this._resolvePath(path); + const caseSensitive = options?.caseSensitive ?? true; + if (!caseSensitive) { + throw new KaosValueError('Case insensitive glob is not supported in current environment'); + } + // Use local glob implementation over SFTP readdir + const patternParts = pattern.split('/'); + yield* this._globWalk(resolved, patternParts, caseSensitive); + } + + private async *_globWalk( + basePath: string, + patternParts: string[], + caseSensitive: boolean, + ): AsyncGenerator { + if (patternParts.length === 0) return; + + const [currentPattern, ...remainingParts] = patternParts; + + if (currentPattern === '**') { + // `**` matches zero or more directory components. + // + // Two cases to handle: + // (a) `**` matches zero directories → continue at basePath with + // the remaining pattern parts (or yield basePath when `**` is + // the final segment). + // (b) `**` matches one or more directories → recurse into each + // subdirectory, keeping `**` (the full patternParts) at the + // front. The "zero directories" case is re-evaluated at the + // subdirectory level by that recursive call. + // + // Do NOT additionally recurse with `remainingParts` on subdirectories + // — that would double-count matches at depth ≥ 1 because case (a) + // inside the child recursion already yields those results. + if (remainingParts.length > 0) { + yield* this._globWalk(basePath, remainingParts, caseSensitive); + } else { + // Pattern ends with `**`: yield basePath itself (zero-dir match). + yield basePath; + } + + let entries: SFTPFileEntry[]; + try { + entries = await sftpReaddir(this._sftp, basePath); + } catch { + return; + } + + for (const entry of entries) { + if (entry.filename === '.' || entry.filename === '..') continue; + const fullPath = join(basePath, entry.filename); + if (entry.attrs.isDirectory()) { + yield* this._globWalk(fullPath, patternParts, caseSensitive); + } else if (remainingParts.length === 0) { + // Pattern ends with `**`: non-directory entries match too. + yield fullPath; + } + } + } else { + const regex = globPatternToRegex(currentPattern ?? '', caseSensitive); + + let entries: SFTPFileEntry[]; + try { + entries = await sftpReaddir(this._sftp, basePath); + } catch { + return; + } + + for (const entry of entries) { + if (entry.filename === '.' || entry.filename === '..') continue; + if (!regex.test(entry.filename)) continue; + + const fullPath = join(basePath, entry.filename); + + if (remainingParts.length === 0) { + yield fullPath; + } else if (entry.attrs.isDirectory()) { + yield* this._globWalk(fullPath, remainingParts, caseSensitive); + } + } + } + } + + // ── File operations (async) ──────────────────────────────────────── + + async readBytes(path: string, n?: number): Promise { + const data = await sftpReadFile(this._sftp, this._resolvePath(path)); + if (n === undefined) return data; + return data.subarray(0, n); + } + + async readText( + path: string, + options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, + ): Promise { + const encoding = options?.encoding ?? 'utf-8'; + const errors = options?.errors ?? 'strict'; + const data = await sftpReadFile(this._sftp, this._resolvePath(path)); + return decodeTextWithErrors(data, encoding, errors); + } + + async *readLines( + path: string, + options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, + ): AsyncGenerator { + // SFTP does not support streaming line reads; read all then split. + // Match Python's splitlines() semantics: returned lines do NOT include + // the line terminator, and a trailing newline does not create an extra + // empty line. + const text = await this.readText(this._resolvePath(path), options); + if (text === '') { + return; + } + + const lines = text.split(/\r\n|[\n\r]/u); + if (/(?:\r\n|[\n\r])$/u.test(text)) { + lines.pop(); + } + for (const line of lines) { + yield line ?? ''; + } + } + + async writeBytes(path: string, data: Buffer): Promise { + await sftpWriteFile(this._sftp, this._resolvePath(path), data); + return data.length; + } + + async writeText( + path: string, + data: string, + options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding }, + ): Promise { + const resolved = this._resolvePath(path); + const mode = options?.mode ?? 'w'; + const encoding = options?.encoding ?? 'utf-8'; + const buf = Buffer.from(data, encoding); + if (mode === 'a') { + await sftpAppendFile(this._sftp, resolved, buf); + } else { + await sftpWriteFile(this._sftp, resolved, buf); + } + return data.length; + } + + async mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise { + const resolved = this._resolvePath(path); + const parents = options?.parents ?? false; + const existOk = options?.existOk ?? false; + + if (parents) { + await this._mkdirRecursive(resolved, existOk); + } else { + const exists = await sftpExists(this._sftp, resolved); + if (exists) { + if (!existOk) { + throw new KaosFileExistsError(`${resolved} already exists`); + } + // `existOk` only applies when the conflicting path is itself a + // directory. A regular file sitting at the target path is still + // a conflict — we must not pretend mkdir succeeded. + const st = await sftpStat(this._sftp, resolved); + if (!st.isDirectory()) { + throw new KaosFileExistsError(`${resolved} already exists but is not a directory`); + } + return; + } + await sftpMkdir(this._sftp, resolved); + } + } + + private async _mkdirRecursive(path: string, existOk: boolean): Promise { + // Split path into components and create each level. + const parts = path.split('/').filter(Boolean); + let current = path.startsWith('/') ? '/' : ''; + const lastIndex = parts.length - 1; + for (const [i, part] of parts.entries()) { + current = current ? join(current, part) : part; + + const isFinal = i === lastIndex; + + // eslint-disable-next-line no-await-in-loop + const exists = await sftpExists(this._sftp, current); + if (exists) { + // For intermediate components, it's fine (and expected) for the + // path to already exist. For the final target, honor `existOk`. + if (isFinal && !existOk) { + throw new KaosFileExistsError(`${current} already exists`); + } + // Regardless of whether this is an intermediate or the final + // component, an existing path must actually be a directory. + // An intermediate non-directory would cause the next `sftpMkdir` + // to fail with a confusing error; a final non-directory would + // otherwise be silently accepted when `existOk` is true. + // eslint-disable-next-line no-await-in-loop + const st = await sftpStat(this._sftp, current); + if (!st.isDirectory()) { + throw new KaosFileExistsError(`${current} already exists but is not a directory`); + } + continue; + } + try { + // eslint-disable-next-line no-await-in-loop + await sftpMkdir(this._sftp, current); + } catch (error) { + // Race condition: another process may have created it. + // eslint-disable-next-line no-await-in-loop + const nowExists = await sftpExists(this._sftp, current); + if (!nowExists) throw new Error(`Failed to create directory: ${current}`, { cause: error }); + // A raced path must still be a directory. Another process may have + // created a regular file at the same pathname after our exists() + // check but before mkdir(), which must remain a hard conflict. + // eslint-disable-next-line no-await-in-loop + const st = await sftpStat(this._sftp, current); + if (!st.isDirectory()) { + throw new KaosFileExistsError(`${current} already exists but is not a directory`); + } + // If the final component lost a race and existOk=false, surface the + // conflict to match the non-race path above. + if (isFinal && !existOk) { + throw new KaosFileExistsError(`${current} already exists`); + } + } + } + } + + // ── Process execution ────────────────────────────────────────────── + + exec(...args: string[]): Promise { + if (args.length === 0) { + throw new KaosValueError( + 'SSHKaos.exec(): at least one argument (the command to run) is required.', + ); + } + return this._execInternal(args, this._buildExecEnv()); + } + + execWithEnv(args: string[], env?: Record): Promise { + if (args.length === 0) { + throw new KaosValueError( + 'SSHKaos.execWithEnv(): at least one argument (the command to run) is required.', + ); + } + return this._execInternal(args, this._buildExecEnv(env)); + } + + private _buildExecEnv(invocationEnv?: Record): Record | undefined { + if (this._envLayers.length === 0) return invocationEnv; + const merged: Record = { ...invocationEnv }; + for (const layer of this._envLayers) { + Object.assign(merged, layer); + } + return merged; + } + + /** + * Build the full remote shell command string that will be handed to + * `client.exec`. Exposed as a static so it can be unit-tested without + * needing a live SSH connection — see `ssh.test.ts`. + * + * Shape: `cd '' && KEY1='v1' KEY2='v2' ...` + * + * Environment variables are injected as POSIX inline assignments instead + * of being passed through ssh2's `ExecOptions.env`. The env-request path + * silently drops anything not whitelisted by sshd's `AcceptEnv` directive + * (stock OpenSSH only allows LANG/LC_*), which is a well-known footgun + * inherited from the Python / asyncssh implementation. Inline assignments + * run inside the remote shell itself, so they bypass AcceptEnv entirely + * and reach the command regardless of server configuration. + */ + private static _buildExecCommand( + args: string[], + cwd: string, + env?: Record, + ): string { + let command = args.map((arg) => shellQuote(arg)).join(' '); + + if (env !== undefined) { + const assignments: string[] = []; + for (const [key, value] of Object.entries(env)) { + // Reject anything that isn't a POSIX-valid shell variable name so + // the injected prefix can never become a shell-injection vector. + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + throw new KaosValueError( + `SSHKaos.execWithEnv(): invalid env variable name ${JSON.stringify(key)}`, + ); + } + assignments.push(`${key}=${shellQuote(value)}`); + } + if (assignments.length > 0) { + command = `${assignments.join(' ')} ${command}`; + } + } + + if (cwd !== '') { + command = `cd ${shellQuote(cwd)} && ${command}`; + } + + return command; + } + + private async _execInternal(args: string[], env?: Record): Promise { + const command = SSHKaos._buildExecCommand(args, this._cwd, env); + const channel = await clientExec(this._client, command); + return new SSHProcess(channel); + } + + // ── SSH lifecycle ────────────────────────────────────────────────── + + /** + * Close the SSH connection. After this, the SSHKaos instance is unusable. + */ + close(): Promise { + this._sftp.end(); + return new Promise((resolve) => { + this._client.once('close', () => { + resolve(); + }); + this._client.end(); + }); + } +} diff --git a/packages/kaos/src/types.ts b/packages/kaos/src/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ef03d33d0b3447b9850547cbe6d7069c2caf4ce --- /dev/null +++ b/packages/kaos/src/types.ts @@ -0,0 +1,15 @@ +/** + * KAOS stat result, mirroring Python's os.stat_result fields. + */ +export interface StatResult { + stMode: number; + stIno: number; + stDev: number; + stNlink: number; + stUid: number; + stGid: number; + stSize: number; + stAtime: number; + stMtime: number; + stCtime: number; +} diff --git a/packages/kaos/test/cmd.test.ts b/packages/kaos/test/cmd.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e3ac3c4b9d5099ec70fa5318149fbdb4fd93dfd --- /dev/null +++ b/packages/kaos/test/cmd.test.ts @@ -0,0 +1,111 @@ +import { mkdtemp, rm, stat as fsStat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { Kaos } from '#/kaos'; +import { LocalKaos } from '#/local'; +import type { KaosProcess } from '#/process'; + +/** + * Helper to run a cmd.exe command and collect stdout/stderr/exitCode. + * Prepends `chcp 65001>nul &` to ensure UTF-8 output. + */ +async function runCmd( + kaos: Kaos, + command: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc: KaosProcess = await kaos.exec('cmd.exe', '/c', `chcp 65001>nul & ${command}`); + + proc.stdin.end(); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + const stdoutDone = new Promise((resolve) => { + proc.stdout.on('data', (chunk: Buffer) => { + stdoutChunks.push(chunk); + }); + proc.stdout.on('end', () => { + resolve(); + }); + }); + + const stderrDone = new Promise((resolve) => { + proc.stderr.on('data', (chunk: Buffer) => { + stderrChunks.push(chunk); + }); + proc.stderr.on('end', () => { + resolve(); + }); + }); + + const exitCode = await proc.wait(); + await stdoutDone; + await stderrDone; + + return { + stdout: Buffer.concat(stdoutChunks).toString('utf-8'), + stderr: Buffer.concat(stderrChunks).toString('utf-8'), + exitCode, + }; +} + +describe.skipIf(process.platform !== 'win32')('LocalKaos cmd.exe', () => { + let kaos: Kaos; + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kaos-cmd-')); + kaos = await LocalKaos.create(); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('should run a simple command', async () => { + const { exitCode, stdout, stderr } = await runCmd(kaos, 'echo Hello Windows'); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe('Hello Windows'); + expect(stderr).toBe(''); + }); + + it('should handle command with error exit', async () => { + // `exit /b 1` must produce neither stdout nor stderr — pinning that + // keeps us honest if cmd.exe or the chcp prefix ever leaks output. + const { exitCode, stdout, stderr } = await runCmd(kaos, 'exit /b 1'); + expect(exitCode).toBe(1); + expect(stdout).toBe(''); + expect(stderr).toBe(''); + }); + + it('should support command chaining', async () => { + const { exitCode, stdout, stderr } = await runCmd(kaos, 'echo First&& echo Second'); + expect(exitCode).toBe(0); + expect(stdout.replaceAll('\r\n', '\n')).toBe('First\nSecond\n'); + expect(stderr).toBe(''); + }); + + it('should perform file operations', async () => { + // Write via kaos (avoids cmd.exe redirect quoting quirks on Windows where + // Node's auto-escaping of the redirected path breaks the command), then + // read back via `type` and pin the exact stdout byte-for-byte. + const filePath = join(tmpDir, 'test_file.txt').replaceAll('/', '\\'); + + await kaos.writeText(filePath, 'Test content\r\n'); + + const statInfo = await fsStat(filePath); + expect(statInfo.isFile()).toBe(true); + + // The path contains no spaces (tmpDir is under the 8.3 short-name temp + // dir), so pass it unquoted: wrapping it in `"…"` would make Node's + // Windows arg-quoting escape the inner quotes to `\"`, which cmd.exe + // does not unescape — leaving `type` looking for a literal `\"…\"`. + const read = await runCmd(kaos, `type ${filePath}`); + expect(read.exitCode).toBe(0); + expect(read.stdout).toBe('Test content\r\n'); + expect(read.stderr).toBe(''); + }); +}); diff --git a/packages/kaos/test/current.test.ts b/packages/kaos/test/current.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf814babe1fc537ec8c1d707e921c9fc7b2d62d4 --- /dev/null +++ b/packages/kaos/test/current.test.ts @@ -0,0 +1,87 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + execWithEnv, + getCurrentKaos, + LocalKaos, + normpath, + pathClass, + readLines, + readText, + writeText, +} from '#/index'; + +describe('getCurrentKaos', () => { + it('returns the LocalKaos bound by the test setup', () => { + const kaos = getCurrentKaos(); + expect(kaos).toBeInstanceOf(LocalKaos); + expect(kaos.name).toBe('local'); + }); +}); + +describe('module-level proxy functions', () => { + it('normpath delegates to the current kaos instance', () => { + // LocalKaos on posix normalizes '/foo/../bar' to '/bar' + const result = normpath('/foo/../bar'); + expect(typeof result).toBe('string'); + expect(result.endsWith('bar')).toBe(true); + }); + + it('pathClass returns posix or win32 from the current kaos', () => { + const result = pathClass(); + expect(result === 'posix' || result === 'win32').toBe(true); + }); + + it('readLines proxies to the current kaos and yields lines', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kaos-readlines-')); + try { + const filePath = join(dir, 'lines.txt'); + await writeText(filePath, 'alpha\nbravo\ncharlie'); + const collected: string[] = []; + for await (const line of readLines(filePath)) { + collected.push(line); + } + // readLines preserves newline terminators on each line. + expect(collected).toEqual(['alpha\n', 'bravo\n', 'charlie']); + expect(collected.join('')).toBe('alpha\nbravo\ncharlie'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('writeText accepts an encoding option through the module-level proxy', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kaos-writetext-enc-')); + try { + const filePath = join(dir, 'enc.txt'); + // Pass a non-default encoding to prove the option flows through the + // proxy signature without a TypeScript error. + await writeText(filePath, 'hello-latin1', { encoding: 'latin1' }); + const contents = await readText(filePath, { encoding: 'latin1' }); + expect(contents).toBe('hello-latin1'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('execWithEnv proxies to the current kaos', async () => { + // Use the real LocalKaos to run `env | grep CUSTOM_VAR` + const proc = await execWithEnv(['sh', '-c', 'echo "$CUSTOM_VAR"'], { + CUSTOM_VAR: 'proxy_test_value', + // Preserve PATH so sh can be found + PATH: process.env['PATH'] ?? '/usr/bin:/bin', + }); + + const chunks: Buffer[] = []; + for await (const chunk of proc.stdout) { + chunks.push(chunk as Buffer); + } + const stdout = Buffer.concat(chunks).toString('utf-8').trim(); + await proc.wait(); + + expect(stdout).toBe('proxy_test_value'); + }); +}); diff --git a/packages/kaos/test/e2e/concurrent-operations.test.ts b/packages/kaos/test/e2e/concurrent-operations.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1bf3475ca2b7384d6406a0b55bc6e22d0e5c3625 --- /dev/null +++ b/packages/kaos/test/e2e/concurrent-operations.test.ts @@ -0,0 +1,234 @@ +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { LocalKaos } from '#/local'; + +// ── Tests ───────────────────────────────────────────────────────────── + +describe('e2e: concurrent operations', () => { + let kaos: LocalKaos; + let tempDir: string; + let originalCwd: string; + + beforeEach(async () => { + kaos = await LocalKaos.create(); + originalCwd = process.cwd(); + tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-concurrent-'))); + process.chdir(tempDir); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('concurrent reads and writes on different files', () => { + it('10 concurrent writeText + readText on separate files → all consistent', async () => { + const count = 10; + const promises = Array.from({ length: count }, async (_, i): Promise => { + const filePath = join(tempDir, `file-${i}.txt`); + const content = `content-${i}-${'data'.repeat(100)}`; + await kaos.writeText(filePath, content); + const readBack = await kaos.readText(filePath); + expect(readBack).toBe(content); + }); + + await Promise.all(promises); + }); + }); + + describe('concurrent writes to the same file', () => { + it('sequential writes to same file → last write wins', async () => { + const filePath = join(tempDir, 'shared.txt'); + const writes = 20; + + // Write sequentially to guarantee ordering + for (let i = 0; i < writes; i++) { + await kaos.writeText(filePath, `version-${i}`); + } + + // The file should have the last written content + const content = await kaos.readText(filePath); + expect(content).toBe(`version-${writes - 1}`); + }); + + it('concurrent appends to same file → all data present', async () => { + const filePath = join(tempDir, 'append-target.txt'); + await kaos.writeText(filePath, ''); + + const count = 20; + const promises: Promise[] = []; + + for (let i = 0; i < count; i++) { + promises.push(kaos.writeText(filePath, `line-${i}\n`, { mode: 'a' })); + } + + await Promise.all(promises); + + const content = await kaos.readText(filePath); + const lines = content.trimEnd().split('\n'); + + // All lines should be present (order may vary due to concurrency) + expect(lines).toHaveLength(count); + const lineSet = new Set(lines); + for (let i = 0; i < count; i++) { + expect(lineSet.has(`line-${i}`)).toBe(true); + } + }); + }); + + describe('concurrent exec of multiple subprocesses', () => { + it('5 concurrent node processes → all complete independently', async () => { + const count = 5; + const promises = Array.from( + { length: count }, + async (_, i): Promise<{ index: number; exitCode: number; stdout: string }> => { + const code = `process.stdout.write('proc-${i}');`; + const proc = await kaos.exec('node', '-e', code); + const exitCode = await proc.wait(); + + const chunks: Buffer[] = []; + for await (const chunk of proc.stdout) { + chunks.push(Buffer.from(chunk as Buffer)); + } + const stdout = Buffer.concat(chunks).toString('utf-8'); + + return { index: i, exitCode, stdout }; + }, + ); + + const results = await Promise.all(promises); + + for (const result of results) { + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(`proc-${result.index}`); + } + }); + + it('concurrent processes with different exit codes', async () => { + const exitCodes = [0, 1, 2, 42, 0]; + const promises = exitCodes.map(async (code) => { + const proc = await kaos.exec('node', '-e', `process.exit(${code})`); + return proc.wait(); + }); + + const results = await Promise.all(promises); + expect(results).toEqual(exitCodes); + }); + }); + + describe('concurrent iterdir + file creation', () => { + it('iterdir does not crash when files are being created concurrently', async () => { + // Pre-create some files + for (let i = 0; i < 5; i++) { + await kaos.writeText(join(tempDir, `existing-${i}.txt`), `data-${i}`); + } + + // Start iterdir and file creation concurrently + const iterdirPromise = (async (): Promise => { + const entries: string[] = []; + for await (const entry of kaos.iterdir(tempDir)) { + entries.push(entry); + } + return entries; + })(); + + const creationPromise = (async (): Promise => { + for (let i = 0; i < 5; i++) { + await kaos.writeText(join(tempDir, `new-${i}.txt`), `new-data-${i}`); + } + })(); + + const [entries] = await Promise.all([iterdirPromise, creationPromise]); + + // iterdir should return at least the pre-existing files + // (new files may or may not be included depending on timing) + expect(entries.length).toBeGreaterThanOrEqual(5); + }); + }); + + describe('concurrent glob operations', () => { + it('multiple concurrent globs return correct results', async () => { + // Create files with different extensions + await kaos.writeText(join(tempDir, 'a.ts'), 'ts'); + await kaos.writeText(join(tempDir, 'b.ts'), 'ts'); + await kaos.writeText(join(tempDir, 'c.js'), 'js'); + await kaos.writeText(join(tempDir, 'd.js'), 'js'); + await kaos.writeText(join(tempDir, 'e.json'), 'json'); + + const [tsFiles, jsFiles, jsonFiles] = await Promise.all([ + collectGlob(kaos, tempDir, '*.ts'), + collectGlob(kaos, tempDir, '*.js'), + collectGlob(kaos, tempDir, '*.json'), + ]); + + expect(tsFiles.toSorted()).toEqual([join(tempDir, 'a.ts'), join(tempDir, 'b.ts')].toSorted()); + expect(jsFiles.toSorted()).toEqual([join(tempDir, 'c.js'), join(tempDir, 'd.js')].toSorted()); + expect(jsonFiles).toEqual([join(tempDir, 'e.json')]); + }); + + it('10 concurrent glob(*.txt) on same directory → consistent results', async () => { + // Use a flat glob pattern to avoid ** duplication behavior + await kaos.writeText(join(tempDir, 'a.txt'), 'a'); + await kaos.writeText(join(tempDir, 'b.txt'), 'b'); + await kaos.writeText(join(tempDir, 'c.txt'), 'c'); + + const expected = [ + join(tempDir, 'a.txt'), + join(tempDir, 'b.txt'), + join(tempDir, 'c.txt'), + ].toSorted(); + + const promises: Promise[] = []; + for (let i = 0; i < 10; i++) { + promises.push(collectGlob(kaos, tempDir, '*.txt')); + } + + const results = await Promise.all(promises); + + for (const result of results) { + expect(result.toSorted()).toEqual(expected); + } + }); + }); + + describe('concurrent mixed operations', () => { + it('read + write + stat + iterdir concurrently on same directory', async () => { + const filePath = join(tempDir, 'mixed.txt'); + await kaos.writeText(filePath, 'initial'); + + const [readResult, _writeResult, statResult, entries] = await Promise.all([ + kaos.readText(filePath), + kaos.writeText(join(tempDir, 'another.txt'), 'other'), + kaos.stat(filePath), + collectIterdir(kaos, tempDir), + ]); + + // readResult might be 'initial' (read before write) or a valid string + expect(typeof readResult).toBe('string'); + expect(statResult.stSize).toBeGreaterThan(0); + expect(entries.length).toBeGreaterThanOrEqual(1); + }); + }); +}); + +// ── Helper functions ────────────────────────────────────────────────── + +async function collectGlob(kaos: LocalKaos, path: string, pattern: string): Promise { + const results: string[] = []; + for await (const entry of kaos.glob(path, pattern)) { + results.push(entry); + } + return results; +} + +async function collectIterdir(kaos: LocalKaos, path: string): Promise { + const results: string[] = []; + for await (const entry of kaos.iterdir(path)) { + results.push(entry); + } + return results; +} diff --git a/packages/kaos/test/e2e/exec-edge-cases.test.ts b/packages/kaos/test/e2e/exec-edge-cases.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfc5fbd84b6749930d94d1a05926fbe1aa200b58 --- /dev/null +++ b/packages/kaos/test/e2e/exec-edge-cases.test.ts @@ -0,0 +1,225 @@ +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { LocalKaos } from '#/local'; + +// ── E2E: exec edge cases ────────────────────────────────────────────── +// +// Covers exec() scenarios that the other kaos suites do not touch: +// +// - spawning a non-existent command and safely awaiting the error, +// - killing a running child with SIGTERM, +// - closing stdin while the child is still alive, +// - >10MB stdout throughput without dropping or corrupting bytes, +// - proving that each LocalKaos instance carries its OWN cwd into +// concurrent child processes (isolation invariant). + +// ── Helpers ─────────────────────────────────────────────────────────── + +async function streamToString(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk as Buffer)); + } + return Buffer.concat(chunks).toString('utf-8'); +} + +async function streamByteLength(stream: NodeJS.ReadableStream): Promise { + let n = 0; + for await (const chunk of stream) { + n += (chunk as Buffer).length; + } + return n; +} + +// ── Tests ───────────────────────────────────────────────────────────── + +describe('e2e: exec edge cases', () => { + let kaos: LocalKaos; + let tempDir: string; + let originalCwd: string; + + beforeEach(async () => { + kaos = await LocalKaos.create(); + originalCwd = process.cwd(); + tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-exec-edge-'))); + await kaos.chdir(tempDir); + }); + + afterEach(async () => { + // Restore original cwd in case any test accidentally mutated it + // (though LocalKaos should never touch process.cwd()). + process.chdir(originalCwd); + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('spawning a non-existent command', () => { + it('exec() rejects promptly when the binary does not exist (never hangs)', async () => { + // Contract: LocalKaos.exec() awaits the child's 'spawn' or 'error' event + // before returning, so a missing binary becomes a synchronous rejection + // rather than a ghost process handle. + await expect(kaos.exec('this-binary-does-not-exist-kaos-edge-test-12345')).rejects.toThrow( + /ENOENT|ENOTFOUND|not found|spawn/i, + ); + }); + }); + + describe('kill() terminates a running child', () => { + it.skipIf(process.platform === 'win32')('long-running child can be killed with SIGTERM', async () => { + // A node script that sleeps forever. + const proc = await kaos.exec('node', '-e', 'setInterval(() => {}, 1000 * 60);'); + + expect(proc.pid).toBeGreaterThan(0); + + // Give the child a moment to actually start. + await new Promise((r) => setTimeout(r, 20)); + + await proc.kill('SIGTERM'); + + const exitCode = await proc.wait(); + // SIGTERM typically produces exitCode = null → -1 under our wrapper, + // or 143 (128 + 15). Either way, it must NOT be 0. + expect(exitCode).not.toBe(0); + }); + + it('kill() after the child has already exited is a no-op (no ESRCH leak)', async () => { + const proc = await kaos.exec('node', '-e', 'process.exit(0);'); + const exitCode = await proc.wait(); + expect(exitCode).toBe(0); + + // Killing an already-exited process must not throw. + await expect(proc.kill('SIGTERM')).resolves.toBeUndefined(); + }); + }); + + describe('stdin lifecycle', () => { + it('closing stdin while the child keeps running does not corrupt stdout', async () => { + // Child reads stdin until EOF, then emits "done:". + const proc = await kaos.exec( + 'node', + '-e', + ` + let total = 0; + process.stdin.on('data', (chunk) => { total += chunk.length; }); + process.stdin.on('end', () => { + // Intentionally wait before writing, simulating child work + // continuing after stdin EOF. + setTimeout(() => { + process.stdout.write('done:' + total); + process.exit(0); + }, 20); + }); + `, + ); + + proc.stdin.write('abc'); + proc.stdin.write('def'); + proc.stdin.end(); + + const stdout = await streamToString(proc.stdout); + const exitCode = await proc.wait(); + + expect(exitCode).toBe(0); + expect(stdout).toBe('done:6'); + }); + }); + + describe('large stdout throughput', () => { + it('>10MB of stdout streams through without byte loss', async () => { + // 10.5MB: 10500 writes of a 1KB payload. Using a tight loop in the + // child guarantees the OS pipe buffer gets exercised and we exit + // the BufferedReadable backpressure path. + const targetKB = 10500; + const proc = await kaos.exec( + 'node', + '-e', + ` + const chunk = Buffer.alloc(1024, 0x61); // 1KB of 'a' + let written = 0; + function tick() { + while (written < ${targetKB} - 1) { + const ok = process.stdout.write(chunk); + written++; + if (!ok) { + process.stdout.once('drain', tick); + return; + } + } + // Final write with callback guarantees bytes are flushed to the + // pipe before we exit. Without this, process.exit() races with + // the internal libuv write queue and drops the tail. + process.stdout.write(chunk, () => process.exit(0)); + } + tick(); + `, + ); + + // Read stream and wait concurrently to avoid races where wait() resolves + // before the stream pipe has flushed (BufferedReadable continues to + // accumulate after the child exits, but the consumer must be drained + // explicitly before checking length). + const [stdoutLen, exitCode] = await Promise.all([streamByteLength(proc.stdout), proc.wait()]); + + expect(exitCode).toBe(0); + expect(stdoutLen).toBe(targetKB * 1024); + }); + }); + + describe('cwd isolation for concurrent instances', () => { + it('two LocalKaos instances with different cwds run concurrent child processes that each see their own cwd', async () => { + const subA = join(tempDir, 'A'); + const subB = join(tempDir, 'B'); + + await kaos.mkdir(subA); + await kaos.mkdir(subB); + + const kaosA = await LocalKaos.create(); + const kaosB = await LocalKaos.create(); + await kaosA.chdir(subA); + await kaosB.chdir(subB); + + // Verify process.cwd() is NOT mutated by chdir. + expect(process.cwd()).not.toBe(subA); + expect(process.cwd()).not.toBe(subB); + + // Run concurrently: each child prints its cwd to stdout. + const [procA, procB] = await Promise.all([ + kaosA.exec('node', '-e', 'process.stdout.write(process.cwd())'), + kaosB.exec('node', '-e', 'process.stdout.write(process.cwd())'), + ]); + + const [outA, outB, exitA, exitB] = await Promise.all([ + streamToString(procA.stdout), + streamToString(procB.stdout), + procA.wait(), + procB.wait(), + ]); + + expect(exitA).toBe(0); + expect(exitB).toBe(0); + + // Each child's cwd MUST equal its kaos instance's cwd. + // On macOS `tmpdir()` can be either `/var/folders/...` or + // `/private/var/folders/...`. We already realpath'd tempDir so + // string equality should hold. + expect(outA).toBe(subA); + expect(outB).toBe(subB); + }); + + it('execWithEnv honors the per-instance cwd and injects env vars', async () => { + const proc = await kaos.execWithEnv( + ['node', '-e', 'process.stdout.write(process.env.KAOS_TEST_MARKER + "|" + process.cwd())'], + { KAOS_TEST_MARKER: 'beacon42', PATH: process.env['PATH'] ?? '' }, + ); + const stdout = await streamToString(proc.stdout); + const exitCode = await proc.wait(); + + expect(exitCode).toBe(0); + expect(stdout.startsWith('beacon42|')).toBe(true); + expect(stdout.endsWith(tempDir)).toBe(true); + }); + }); +}); diff --git a/packages/kaos/test/e2e/glob-boundaries-parity.test.ts b/packages/kaos/test/e2e/glob-boundaries-parity.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..578b47a33ff8400f2b209fc393ca601eb370fb98 --- /dev/null +++ b/packages/kaos/test/e2e/glob-boundaries-parity.test.ts @@ -0,0 +1,66 @@ +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { LocalKaos } from '#/local'; + +describe('e2e: glob parity boundaries', () => { + let kaos: LocalKaos; + let tempDir: string; + + beforeEach(async () => { + kaos = await LocalKaos.create(); + tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-glob-'))); + await kaos.chdir(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('** traverses hidden directories and yields each nested match only once', async () => { + await kaos.mkdir(join(tempDir, 'visible', 'nested'), { parents: true }); + await kaos.mkdir(join(tempDir, '.hidden-root'), { parents: true }); + await kaos.mkdir(join(tempDir, 'visible', '.hidden-dir'), { parents: true }); + + await kaos.writeText(join(tempDir, 'root-visible.txt'), 'root-visible'); + await kaos.writeText(join(tempDir, '.hidden-root', 'root-hidden.txt'), 'root-hidden'); + await kaos.writeText(join(tempDir, 'visible', 'nested', 'deep.txt'), 'deep'); + await kaos.writeText(join(tempDir, 'visible', '.hidden-dir', 'secret.txt'), 'secret'); + await kaos.writeText(join(tempDir, 'visible', '.hidden-dir', 'skip.log'), 'skip'); + + const results: string[] = []; + for await (const entry of kaos.glob(tempDir, '**/*.txt')) { + results.push(entry); + } + + expect(results).toHaveLength(4); + expect(new Set(results).size).toBe(4); + expect(results).toEqual( + expect.arrayContaining([ + join(tempDir, 'root-visible.txt'), + join(tempDir, '.hidden-root', 'root-hidden.txt'), + join(tempDir, 'visible', 'nested', 'deep.txt'), + join(tempDir, 'visible', '.hidden-dir', 'secret.txt'), + ]), + ); + expect(results.some((entry) => entry.endsWith('skip.log'))).toBe(false); + }); + + it('root-level glob includes hidden dotfiles', async () => { + await kaos.writeText(join(tempDir, '.hidden.txt'), 'hidden'); + await kaos.writeText(join(tempDir, 'visible.txt'), 'visible'); + await kaos.writeText(join(tempDir, 'visible.log'), 'log'); + + const results: string[] = []; + for await (const entry of kaos.glob(tempDir, '*.txt')) { + results.push(entry); + } + + expect(new Set(results)).toEqual( + new Set([join(tempDir, '.hidden.txt'), join(tempDir, 'visible.txt')]), + ); + }); +}); diff --git a/packages/kaos/test/e2e/process-lifecycle.test.ts b/packages/kaos/test/e2e/process-lifecycle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a943c4628860a4f6da3450c0584c7dc472e5b45c --- /dev/null +++ b/packages/kaos/test/e2e/process-lifecycle.test.ts @@ -0,0 +1,342 @@ +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { LocalKaos } from '#/local'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +// ── Helper ──────────────────────────────────────────────────────────── + +async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk as Buffer)); + } + return Buffer.concat(chunks); +} + +// ── Tests ───────────────────────────────────────────────────────────── + +describe('e2e: process lifecycle', () => { + let kaos: LocalKaos; + let tempDir: string; + let originalCwd: string; + + beforeEach(async () => { + kaos = await LocalKaos.create(); + originalCwd = process.cwd(); + tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-proc-'))); + process.chdir(tempDir); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('stdin → stdout → wait → exitCode', () => { + it('write to stdin, read from stdout, wait for exit', async () => { + // Node script that reads stdin and echoes it to stdout + const code = ` + let data = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => { data += chunk; }); + process.stdin.on('end', () => { + process.stdout.write('echo:' + data); + }); + `; + const proc = await kaos.exec('node', '-e', code); + + // Write to stdin + proc.stdin.write('hello from test'); + proc.stdin.end(); + + // Wait for process to complete + const exitCode = await proc.wait(); + expect(exitCode).toBe(0); + + // Read stdout + const stdout = await streamToBuffer(proc.stdout); + expect(stdout.toString('utf-8')).toBe('echo:hello from test'); + }); + + it('multiple stdin writes before end', async () => { + const code = ` + let data = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => { data += chunk; }); + process.stdin.on('end', () => { + process.stdout.write(data.toUpperCase()); + }); + `; + const proc = await kaos.exec('node', '-e', code); + + proc.stdin.write('hello '); + proc.stdin.write('world '); + proc.stdin.write('test'); + proc.stdin.end(); + + const exitCode = await proc.wait(); + expect(exitCode).toBe(0); + + const stdout = await streamToBuffer(proc.stdout); + expect(stdout.toString('utf-8')).toBe('HELLO WORLD TEST'); + }); + + it('exitCode is null before wait, correct after wait', async () => { + const proc = await kaos.exec('node', '-e', 'process.exit(42)'); + + // Before wait, exitCode may be null + // (it could also be set if process is very fast, so we just check after wait) + const exitCode = await proc.wait(); + expect(exitCode).toBe(42); + expect(proc.exitCode).toBe(42); + }); + }); + + describe('long-running process → kill', () => { + it.skipIf(process.platform === 'win32')('start → verify running → kill → confirm exit', async () => { + // Process that runs indefinitely + const code = ` + process.stdout.write('started\\n'); + setInterval(() => {}, 1000); + `; + const proc = await kaos.exec('node', '-e', code); + + expect(proc.pid).toBeGreaterThan(0); + + // Kill it + await proc.kill('SIGTERM'); + + const exitCode = await proc.wait(); + // On SIGTERM, node typically exits with non-zero + expect(typeof exitCode).toBe('number'); + }); + + it('kill with SIGKILL → immediate termination', async () => { + const code = ` + // Trap SIGTERM to test that SIGKILL forces exit + process.on('SIGTERM', () => { /* ignore */ }); + process.stdout.write('alive\\n'); + setInterval(() => {}, 1000); + `; + const proc = await kaos.exec('node', '-e', code); + + await proc.kill('SIGKILL'); + const exitCode = await proc.wait(); + + // SIGKILL cannot be caught - process is terminated + expect(exitCode).not.toBe(0); + }); + + it('multiple wait() calls return same exit code', async () => { + const proc = await kaos.exec('node', '-e', 'process.exit(7)'); + + const code1 = await proc.wait(); + const code2 = await proc.wait(); + const code3 = await proc.wait(); + + expect(code1).toBe(7); + expect(code2).toBe(7); + expect(code3).toBe(7); + }); + }); + + describe('stdin close → natural exit', () => { + it('closing stdin causes stdin-reading process to exit naturally', async () => { + // Process that exits when stdin closes + const code = ` + process.stdin.resume(); + process.stdin.on('end', () => { + process.stdout.write('stdin closed'); + process.exit(0); + }); + `; + const proc = await kaos.exec('node', '-e', code); + + // Close stdin immediately + proc.stdin.end(); + + const exitCode = await proc.wait(); + expect(exitCode).toBe(0); + + const stdout = await streamToBuffer(proc.stdout); + expect(stdout.toString('utf-8')).toBe('stdin closed'); + }); + + it('cat-like process exits when stdin is closed', async () => { + const code = ` + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => { + process.stdout.write(chunk); + }); + process.stdin.on('end', () => { + process.exit(0); + }); + `; + const proc = await kaos.exec('node', '-e', code); + + proc.stdin.write('line1\n'); + proc.stdin.write('line2\n'); + proc.stdin.end(); + + const exitCode = await proc.wait(); + expect(exitCode).toBe(0); + + const stdout = await streamToBuffer(proc.stdout); + expect(stdout.toString('utf-8')).toBe('line1\nline2\n'); + }); + }); + + describe('process.pid validity', () => { + it('pid is a positive integer', async () => { + const proc = await kaos.exec('node', '-e', 'process.exit(0)'); + expect(proc.pid).toBeGreaterThan(0); + expect(Number.isInteger(proc.pid)).toBe(true); + + await proc.wait(); + }); + + it('different processes have different pids', async () => { + const proc1 = await kaos.exec('node', '-e', 'process.exit(0)'); + const proc2 = await kaos.exec('node', '-e', 'process.exit(0)'); + + expect(proc1.pid).not.toBe(proc2.pid); + + await Promise.all([proc1.wait(), proc2.wait()]); + }); + + it('pid matches the actual child process pid', async () => { + // Have the child process report its own pid + const code = `process.stdout.write(String(process.pid))`; + const proc = await kaos.exec('node', '-e', code); + await proc.wait(); + + const stdout = await streamToBuffer(proc.stdout); + const reportedPid = Number(stdout.toString('utf-8')); + + expect(proc.pid).toBe(reportedPid); + }); + }); + + describe('exec failure (command not found)', () => { + it('non-existent command → error on wait()', async () => { + // exec() itself may succeed (spawn returns) but wait() should + // report the error or the process should fail + try { + const proc = await kaos.exec('this-command-absolutely-does-not-exist-xyz-123'); + // If exec resolves, wait should give a rejection + await expect(proc.wait()).rejects.toThrow(); + } catch (error: unknown) { + // If exec itself throws, that's also acceptable + expect(error).toBeInstanceOf(Error); + } + }); + + it('exec with empty arguments rejects', async () => { + // exec() is now async, so validation errors (missing command) surface + // as rejected promises rather than synchronous throws. + await expect(kaos.exec()).rejects.toThrow(/at least one argument/); + }); + }); + + describe('execWithEnv', () => { + it('passes custom environment variables to the child process', async () => { + const code = `process.stdout.write(process.env.MY_VAR || 'undefined')`; + const proc = await kaos.execWithEnv(['node', '-e', code], { + ...(process.env as Record), + MY_VAR: 'test-value-123', + }); + const exitCode = await proc.wait(); + expect(exitCode).toBe(0); + + const stdout = await streamToBuffer(proc.stdout); + expect(stdout.toString('utf-8')).toBe('test-value-123'); + }); + + it('custom env overrides specific variables', async () => { + // Passing a custom env with PATH so node can still run, + // but with a custom variable that overrides a default one. + const code = `process.stdout.write(process.env.MY_CUSTOM || 'missing')`; + const proc = await kaos.execWithEnv(['node', '-e', code], { + PATH: process.env['PATH'] ?? '', + MY_CUSTOM: 'overridden-value', + }); + const exitCode = await proc.wait(); + expect(exitCode).toBe(0); + + const stdout = await streamToBuffer(proc.stdout); + expect(stdout.toString('utf-8')).toBe('overridden-value'); + }); + }); + + describe('process output ordering', () => { + it('stdout preserves line order for sequential writes', async () => { + const code = ` + for (let i = 0; i < 100; i++) { + process.stdout.write(i + '\\n'); + } + `; + const proc = await kaos.exec('node', '-e', code); + const exitCode = await proc.wait(); + expect(exitCode).toBe(0); + + const stdout = await streamToBuffer(proc.stdout); + const lines = stdout.toString('utf-8').trimEnd().split('\n'); + + expect(lines).toHaveLength(100); + for (let i = 0; i < 100; i++) { + expect(lines[i]).toBe(String(i)); + } + }); + + it('stderr preserves line order for sequential writes', async () => { + const code = ` + for (let i = 0; i < 50; i++) { + process.stderr.write('err-' + i + '\\n'); + } + `; + const proc = await kaos.exec('node', '-e', code); + const exitCode = await proc.wait(); + expect(exitCode).toBe(0); + + const stderr = await streamToBuffer(proc.stderr); + const lines = stderr.toString('utf-8').trimEnd().split('\n'); + + expect(lines).toHaveLength(50); + for (let i = 0; i < 50; i++) { + expect(lines[i]).toBe(`err-${i}`); + } + }); + }); + + describe('process exit codes', () => { + it('exit code 0 for successful process', async () => { + const proc = await kaos.exec('node', '-e', 'process.exit(0)'); + expect(await proc.wait()).toBe(0); + }); + + it('exit code 1 for generic failure', async () => { + const proc = await kaos.exec('node', '-e', 'process.exit(1)'); + expect(await proc.wait()).toBe(1); + }); + + it('custom exit codes (2, 42, 127, 255)', async () => { + for (const code of [2, 42, 127, 255]) { + const proc = await kaos.exec('node', '-e', `process.exit(${code})`); + expect(await proc.wait()).toBe(code); + } + }); + + it('uncaught exception results in exit code 1', async () => { + const code = `throw new Error('uncaught')`; + const proc = await kaos.exec('node', '-e', code); + const exitCode = await proc.wait(); + expect(exitCode).toBe(1); + + // stderr should contain the error message + const stderr = await streamToBuffer(proc.stderr); + expect(stderr.toString('utf-8')).toContain('uncaught'); + }); + }); +}); diff --git a/packages/kaos/test/e2e/ssh-mock.test.ts b/packages/kaos/test/e2e/ssh-mock.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3b66ed41d6ef9bfcf27b3eedba6fbee77e5d932 --- /dev/null +++ b/packages/kaos/test/e2e/ssh-mock.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; + +// ── Tests: SSHKaos parameter validation ────────────────────────────── +// +// SSHKaos.create() requires a live SSH connection to proceed past +// connectClient(). These tests verify the parameter validation and +// shell quoting logic without needing a real SSH server. + +describe('e2e: SSH mock tests', () => { + describe('SSHKaos.create() parameter validation', () => { + it('missing host -> connect attempt with empty host', async () => { + // SSHKaos.create() passes the host directly to ssh2's connect(). + // With an empty/undefined host, ssh2 will fail to connect. + // We verify the error is thrown. + const { SSHKaos } = await import('#/ssh'); + + await expect( + SSHKaos.create({ + host: '', + username: 'testuser', + }), + ).rejects.toThrow(); + }); + + it('missing username -> connect attempt with empty username', async () => { + const { SSHKaos } = await import('#/ssh'); + + await expect( + SSHKaos.create({ + host: '127.0.0.1', + port: 99999, // Use an unlikely port to ensure fast failure + username: '', + }), + ).rejects.toThrow(); + }); + + it('invalid port -> connection error', async () => { + const { SSHKaos } = await import('#/ssh'); + + // Port 1 is unlikely to have an SSH server; should fail quickly + await expect( + SSHKaos.create({ + host: '127.0.0.1', + port: 1, + username: 'testuser', + }), + ).rejects.toThrow(); + }); + }); + + describe('shell quoting logic', () => { + // We test the shellQuote function indirectly by examining its behavior. + // The function is not exported, but we can verify its contract: + // - Empty string -> '' + // - Safe chars -> unchanged + // - Special chars -> single-quoted with embedded quote escaping + + it('shellQuote rules verified via regex match', () => { + // The shellQuote regex for safe chars: /^[A-Za-z0-9_./:=@%^,+-]+$/ + const safeRegex = /^[A-Za-z0-9_./:=@%^,+-]+$/; + + // These should be considered safe (no quoting needed) + expect(safeRegex.test('simple')).toBe(true); + expect(safeRegex.test('/usr/bin/node')).toBe(true); + expect(safeRegex.test('key=value')).toBe(true); + expect(safeRegex.test('file.txt')).toBe(true); + + // These should need quoting + expect(safeRegex.test('')).toBe(false); + expect(safeRegex.test('hello world')).toBe(false); + expect(safeRegex.test("it's")).toBe(false); + expect(safeRegex.test('$HOME')).toBe(false); + expect(safeRegex.test('a|b')).toBe(false); + expect(safeRegex.test('a"b')).toBe(false); + expect(safeRegex.test('a;b')).toBe(false); + expect(safeRegex.test('a&b')).toBe(false); + expect(safeRegex.test('a`b')).toBe(false); + }); + + it('shellQuote single-quote escaping pattern is correct', () => { + // shellQuote wraps in single quotes, replacing ' with '"'"' + // Verify the pattern: 'text'"'"'more' handles embedded quotes + const input = "it's a test"; + const escaped = "'" + input.replaceAll("'", "'\"'\"'") + "'"; + + expect(escaped).toBe("'it'\"'\"'s a test'"); + // This should be safe to use in a shell command + expect(escaped).toContain("'\"'\"'"); + }); + + it('special characters that need quoting', () => { + const specialChars = [' ', "'", '"', '$', '|', '&', ';', '`', '(', ')', '{', '}', '<', '>']; + const safeRegex = /^[A-Za-z0-9_./:=@%^,+-]+$/; + + for (const ch of specialChars) { + expect(safeRegex.test(`arg${ch}value`)).toBe(false); + } + }); + + it('empty string quoting produces two single quotes', () => { + // shellQuote('') should return "''" + const emptyQuoted = "''"; + expect(emptyQuoted).toBe("''"); + expect(emptyQuoted.length).toBe(2); + }); + }); + + describe('SSHKaosOptions type constraints', () => { + it('options structure has required fields', () => { + // Verify at compile time and runtime that SSHKaosOptions + // requires host and username + const validOptions = { + host: 'example.com', + username: 'user', + }; + + expect(validOptions.host).toBe('example.com'); + expect(validOptions.username).toBe('user'); + }); + + it('options with all optional fields', () => { + const fullOptions = { + host: 'example.com', + port: 2222, + username: 'user', + password: 'pass', + keyPaths: ['/path/to/key'], + keyContents: ['ssh-rsa AAAA...'], + cwd: '/home/user', + }; + + expect(fullOptions.port).toBe(2222); + expect(fullOptions.password).toBe('pass'); + expect(fullOptions.keyPaths).toHaveLength(1); + expect(fullOptions.keyContents).toHaveLength(1); + expect(fullOptions.cwd).toBe('/home/user'); + }); + }); +}); diff --git a/packages/kaos/test/e2e/ssh-resolve-path.test.ts b/packages/kaos/test/e2e/ssh-resolve-path.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9623414fb6357539198ba98e66543f5d636dd60 --- /dev/null +++ b/packages/kaos/test/e2e/ssh-resolve-path.test.ts @@ -0,0 +1,450 @@ +import type { Client, SFTPWrapper, Stats as SFTPStats } from 'ssh2'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { KaosFileExistsError } from '#/errors'; +import { KaosFileNotFoundError, SSHKaos } from '#/ssh'; + +// ── SSH path resolution: mock SFTP harness ──────────────────────────── +// +// This test file validates that SSHKaos file/dir operations correctly +// resolve *relative* paths against the instance's current working +// directory (`_cwd`) before handing the path off to SFTP. +// +// We mock the underlying `SFTPWrapper` so no network traffic is needed. +// Every SFTP method (readFile, writeFile, stat, lstat, mkdir, appendFile, +// exists, readdir, realpath) records the absolute path it receives, and +// returns a deterministic response. After each SSHKaos operation we +// inspect the recorded path to ensure the resolution worked. +// +// The private SSHKaos constructor is bypassed via `Reflect.construct` — +// TypeScript `private` is compile-time only, and we need a test-only +// instance that is not backed by a real SSH connection. + +// ── Mock helpers ────────────────────────────────────────────────────── + +interface MockSFTP { + calls: { method: string; path: string; data?: string | Buffer }[]; + files: Map; + dirs: Set; + mkdirFailures: Map; +} + +function makeMockStats(isDir: boolean, size: number = 0): SFTPStats { + return { + mode: isDir ? 0o040755 : 0o100644, + uid: 1000, + gid: 1000, + size, + atime: 0, + mtime: 0, + isDirectory: () => isDir, + isFile: () => !isDir, + isSymbolicLink: () => false, + isSocket: () => false, + isCharacterDevice: () => false, + isBlockDevice: () => false, + isFIFO: () => false, + } as unknown as SFTPStats; +} + +function createMockSftp(state: MockSFTP): SFTPWrapper { + // Only stub the SFTP methods used by the tests below. + const sftp = { + realpath(path: string, cb: (err: unknown, absPath: string) => void): void { + state.calls.push({ method: 'realpath', path }); + // Mock realpath: if path is already absolute, echo it. + // Otherwise it would be relative — but since SSHKaos always + // resolves first, we should never see a relative input here. + cb(null, path); + }, + stat(path: string, cb: (err: unknown, stats: SFTPStats) => void): void { + state.calls.push({ method: 'stat', path }); + if (state.dirs.has(path)) { + cb(null, makeMockStats(true)); + return; + } + const buf = state.files.get(path); + if (buf !== undefined) { + cb(null, makeMockStats(false, buf.length)); + return; + } + // Missing — surface an ENOENT-shaped error. + const err = Object.assign(new Error(`ENOENT: ${path}`), { code: 2 }); + cb(err, undefined as unknown as SFTPStats); + }, + lstat(path: string, cb: (err: unknown, stats: SFTPStats) => void): void { + state.calls.push({ method: 'lstat', path }); + sftp.stat(path, cb); + }, + readFile(path: string, cb: (err: unknown, data: Buffer) => void): void { + state.calls.push({ method: 'readFile', path }); + const buf = state.files.get(path); + if (buf === undefined) { + cb(new Error(`ENOENT: ${path}`), Buffer.alloc(0)); + return; + } + cb(null, buf); + }, + writeFile(path: string, data: string | Buffer, cb: (err: unknown) => void): void { + state.calls.push({ method: 'writeFile', path, data }); + const buf = typeof data === 'string' ? Buffer.from(data) : data; + state.files.set(path, buf); + cb(null); + }, + appendFile(path: string, data: string | Buffer, cb: (err: unknown) => void): void { + state.calls.push({ method: 'appendFile', path, data }); + const buf = typeof data === 'string' ? Buffer.from(data) : data; + const existing = state.files.get(path) ?? Buffer.alloc(0); + state.files.set(path, Buffer.concat([existing, buf])); + cb(null); + }, + exists(path: string, cb: (exists: boolean) => void): void { + state.calls.push({ method: 'exists', path }); + cb(state.files.has(path) || state.dirs.has(path)); + }, + mkdir(path: string, cb: (err: unknown) => void): void { + state.calls.push({ method: 'mkdir', path }); + const failure = state.mkdirFailures.get(path); + if (failure) { + if (failure.materializeAs === 'dir') { + state.dirs.add(path); + } else { + state.files.set(path, Buffer.from('collision')); + } + cb(failure.error); + return; + } + state.dirs.add(path); + cb(null); + }, + readdir( + path: string, + cb: (err: unknown, list: { filename: string; attrs: SFTPStats }[]) => void, + ): void { + state.calls.push({ method: 'readdir', path }); + // Return empty listing for simplicity. + cb(null, []); + }, + end(): void { + // no-op + }, + }; + return sftp as unknown as SFTPWrapper; +} + +function createMockClient(): Client { + return { + end(): void { + // no-op + }, + } as unknown as Client; +} + +/** + * Construct an SSHKaos with the private constructor bypassed. TS + * `private` is compile-time only — runtime reflection works. We use + * `Reflect.construct` to hand-build an instance seeded with the mock + * SFTP wrapper and a chosen home/cwd. + */ +function createMockedKaos(sftp: SFTPWrapper, home: string, cwd: string): SSHKaos { + const client = createMockClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const CtorAny = SSHKaos as any; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + return new CtorAny(client, sftp, home, cwd) as SSHKaos; +} + +// ── Tests ───────────────────────────────────────────────────────────── + +describe('e2e: SSHKaos relative path resolution after chdir (mocked SFTP)', () => { + let state: MockSFTP; + let sftp: SFTPWrapper; + let kaos: SSHKaos; + + beforeEach(() => { + state = { + calls: [], + files: new Map(), + dirs: new Set(), + mkdirFailures: new Map(), + }; + sftp = createMockSftp(state); + kaos = createMockedKaos(sftp, '/home/user', '/home/user'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('readText / readBytes / readLines', () => { + it('readText resolves relative path against current cwd', async () => { + // chdir to /remote/tmp; relative "file.txt" must resolve to /remote/tmp/file.txt. + state.dirs.add('/remote/tmp'); + await kaos.chdir('/remote/tmp'); + + state.files.set('/remote/tmp/file.txt', Buffer.from('hello')); + + const text = await kaos.readText('file.txt'); + expect(text).toBe('hello'); + + // Verify the SFTP readFile call received the absolute resolved path. + const readCalls = state.calls.filter((c) => c.method === 'readFile'); + expect(readCalls).toHaveLength(1); + expect(readCalls[0]!.path).toBe('/remote/tmp/file.txt'); + }); + + it('readText accepts absolute path unchanged', async () => { + state.dirs.add('/remote/tmp'); + await kaos.chdir('/remote/tmp'); + state.files.set('/etc/hosts', Buffer.from('127.0.0.1 localhost')); + + const text = await kaos.readText('/etc/hosts'); + expect(text).toBe('127.0.0.1 localhost'); + + const readCalls = state.calls.filter((c) => c.method === 'readFile'); + expect(readCalls.some((c) => c.path === '/etc/hosts')).toBe(true); + // And NOT /remote/tmp/etc/hosts + expect(readCalls.some((c) => c.path === '/remote/tmp/etc/hosts')).toBe(false); + }); + + it('readBytes resolves relative path against current cwd', async () => { + state.dirs.add('/opt/data'); + await kaos.chdir('/opt/data'); + state.files.set('/opt/data/blob.bin', Buffer.from([1, 2, 3, 4])); + + const bytes = await kaos.readBytes('blob.bin'); + expect(Array.from(bytes)).toEqual([1, 2, 3, 4]); + + const readCalls = state.calls.filter((c) => c.method === 'readFile'); + expect(readCalls.some((c) => c.path === '/opt/data/blob.bin')).toBe(true); + }); + + it('readLines resolves relative path against current cwd', async () => { + state.dirs.add('/var/log'); + await kaos.chdir('/var/log'); + state.files.set('/var/log/app.log', Buffer.from('line1\nline2\nline3')); + + const lines: string[] = []; + for await (const line of kaos.readLines('app.log')) { + lines.push(line); + } + expect(lines).toEqual(['line1', 'line2', 'line3']); + + const readCalls = state.calls.filter((c) => c.method === 'readFile'); + expect(readCalls.some((c) => c.path === '/var/log/app.log')).toBe(true); + }); + + it('readLines strips CRLF terminators', async () => { + state.dirs.add('/var/log'); + await kaos.chdir('/var/log'); + state.files.set('/var/log/app-crlf.log', Buffer.from('line1\r\nline2\r\n')); + + const lines: string[] = []; + for await (const line of kaos.readLines('app-crlf.log')) { + lines.push(line); + } + + expect(lines).toEqual(['line1', 'line2']); + }); + }); + + describe('writeText / writeBytes / appendFile', () => { + it('writeText resolves relative path against current cwd', async () => { + state.dirs.add('/workspace'); + await kaos.chdir('/workspace'); + + const n = await kaos.writeText('out.txt', 'hello world'); + expect(n).toBe('hello world'.length); + + const writeCalls = state.calls.filter((c) => c.method === 'writeFile'); + expect(writeCalls).toHaveLength(1); + expect(writeCalls[0]!.path).toBe('/workspace/out.txt'); + + // Mock file store now contains the file at the resolved path. + expect(state.files.get('/workspace/out.txt')?.toString('utf-8')).toBe('hello world'); + }); + + it('writeBytes resolves relative path against current cwd', async () => { + state.dirs.add('/workspace'); + await kaos.chdir('/workspace'); + + const data = Buffer.from([0xde, 0xad, 0xbe, 0xef]); + const n = await kaos.writeBytes('blob.bin', data); + expect(n).toBe(4); + + const writeCalls = state.calls.filter((c) => c.method === 'writeFile'); + expect(writeCalls).toHaveLength(1); + expect(writeCalls[0]!.path).toBe('/workspace/blob.bin'); + }); + + it('writeText with mode=a uses appendFile and resolves path', async () => { + state.dirs.add('/logs'); + await kaos.chdir('/logs'); + + await kaos.writeText('a.log', 'line1\n', { mode: 'a' }); + await kaos.writeText('a.log', 'line2\n', { mode: 'a' }); + + const appendCalls = state.calls.filter((c) => c.method === 'appendFile'); + expect(appendCalls).toHaveLength(2); + expect(appendCalls[0]!.path).toBe('/logs/a.log'); + expect(appendCalls[1]!.path).toBe('/logs/a.log'); + + expect(state.files.get('/logs/a.log')?.toString('utf-8')).toBe('line1\nline2\n'); + }); + }); + + describe('stat / mkdir', () => { + it('stat resolves relative path against current cwd', async () => { + state.dirs.add('/srv'); + await kaos.chdir('/srv'); + state.files.set('/srv/config.json', Buffer.from('{}')); + + const result = await kaos.stat('config.json'); + expect(result.stSize).toBe(2); + + const statCalls = state.calls.filter((c) => c.method === 'stat'); + expect(statCalls.some((c) => c.path === '/srv/config.json')).toBe(true); + }); + + it('stat preserves NO_SUCH_FILE as KaosFileNotFoundError', async () => { + state.dirs.add('/srv'); + await kaos.chdir('/srv'); + + await expect(kaos.stat('missing.json')).rejects.toBeInstanceOf(KaosFileNotFoundError); + await expect(kaos.stat('missing.json')).rejects.toMatchObject({ code: 2 }); + }); + + it('stat with followSymlinks=false uses lstat and resolves path', async () => { + state.dirs.add('/srv'); + await kaos.chdir('/srv'); + state.files.set('/srv/link', Buffer.from('data')); + + await kaos.stat('link', { followSymlinks: false }); + + const lstatCalls = state.calls.filter((c) => c.method === 'lstat'); + expect(lstatCalls).toHaveLength(1); + expect(lstatCalls[0]!.path).toBe('/srv/link'); + }); + + it('mkdir resolves relative path against current cwd', async () => { + state.dirs.add('/workspace'); + await kaos.chdir('/workspace'); + + await kaos.mkdir('newdir'); + + const mkdirCalls = state.calls.filter((c) => c.method === 'mkdir'); + expect(mkdirCalls).toHaveLength(1); + expect(mkdirCalls[0]!.path).toBe('/workspace/newdir'); + }); + + it('mkdir with parents=true resolves and creates intermediate dirs', async () => { + state.dirs.add('/workspace'); + await kaos.chdir('/workspace'); + + await kaos.mkdir('a/b/c', { parents: true }); + + const mkdirCalls = state.calls.filter((c) => c.method === 'mkdir'); + const mkdirPaths = mkdirCalls.map((c) => c.path); + expect(mkdirPaths).toContain('/workspace/a'); + expect(mkdirPaths).toContain('/workspace/a/b'); + expect(mkdirPaths).toContain('/workspace/a/b/c'); + expect(mkdirPaths.every((path) => !path.includes('//'))).toBe(true); + }); + + it('mkdir existOk=true is idempotent against the resolved path', async () => { + state.dirs.add('/workspace'); + await kaos.chdir('/workspace'); + // Pre-seed the target directory. + state.dirs.add('/workspace/existing'); + + await expect(kaos.mkdir('existing', { existOk: true })).resolves.toBeUndefined(); + + // Without existOk, it should throw. + await expect(kaos.mkdir('existing', { existOk: false })).rejects.toThrow(); + }); + + it('mkdir with parents=true rejects a raced file collision when existOk=true', async () => { + state.dirs.add('/workspace'); + await kaos.chdir('/workspace'); + + const racePaths = ['/workspace/collision', '//workspace/collision']; + for (const racePath of racePaths) { + state.mkdirFailures.set(racePath, { + error: new Error('EEXIST'), + materializeAs: 'file', + }); + } + + await expect( + kaos.mkdir('collision', { parents: true, existOk: true }), + ).rejects.toBeInstanceOf(KaosFileExistsError); + }); + }); + + describe('iterdir', () => { + it('iterdir with relative "." resolves to cwd', async () => { + state.dirs.add('/workspace'); + await kaos.chdir('/workspace'); + + const entries: string[] = []; + for await (const entry of kaos.iterdir('.')) { + entries.push(entry); + } + // (readdir mock returns empty list; we're asserting the path passed to readdir) + + const readdirCalls = state.calls.filter((c) => c.method === 'readdir'); + expect(readdirCalls).toHaveLength(1); + // posix.join('/workspace', '.') normalises to '/workspace' + expect(readdirCalls[0]!.path).toBe('/workspace'); + expect(entries).toEqual([]); + }); + + it('iterdir with named relative subdir resolves under cwd', async () => { + state.dirs.add('/workspace'); + state.dirs.add('/workspace/sub'); + await kaos.chdir('/workspace'); + + const entries: string[] = []; + for await (const entry of kaos.iterdir('sub')) { + entries.push(entry); + } + + const readdirCalls = state.calls.filter((c) => c.method === 'readdir'); + expect(readdirCalls).toHaveLength(1); + expect(readdirCalls[0]!.path).toBe('/workspace/sub'); + expect(entries).toEqual([]); + }); + }); + + describe('chdir follows realpath and affects subsequent relative ops', () => { + it('consecutive chdir + readText rebase the working directory each time', async () => { + state.dirs.add('/a'); + state.dirs.add('/b'); + state.files.set('/a/one.txt', Buffer.from('A')); + state.files.set('/b/two.txt', Buffer.from('B')); + + await kaos.chdir('/a'); + expect(await kaos.readText('one.txt')).toBe('A'); + + await kaos.chdir('/b'); + expect(await kaos.readText('two.txt')).toBe('B'); + + // After chdir(/b), a relative path that only exists under /a + // must NOT resolve — confirming we've truly rebased. + await expect(kaos.readText('one.txt')).rejects.toThrow(); + }); + + it('chdir with a relative argument resolves against the prior cwd', async () => { + state.dirs.add('/home/user'); + state.dirs.add('/home/user/project'); + + await kaos.chdir('/home/user'); + await kaos.chdir('project'); + + expect(kaos.getcwd()).toBe('/home/user/project'); + + state.files.set('/home/user/project/README.md', Buffer.from('readme')); + expect(await kaos.readText('README.md')).toBe('readme'); + }); + }); +}); diff --git a/packages/kaos/test/e2e/symlink-stat-parity.test.ts b/packages/kaos/test/e2e/symlink-stat-parity.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a91d668a17312c3f0a52fe4af29a56668b490b8 --- /dev/null +++ b/packages/kaos/test/e2e/symlink-stat-parity.test.ts @@ -0,0 +1,67 @@ +import { mkdtemp, realpath, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { LocalKaos } from '#/local'; + +const S_IFMT = 0o170000; +const S_IFDIR = 0o040000; +const S_IFLNK = 0o120000; +const S_IFREG = 0o100000; + +describe.skipIf(process.platform === 'win32')('e2e: symlink stat parity', () => { + let kaos: LocalKaos; + let tempDir: string; + + beforeEach(async () => { + kaos = await LocalKaos.create(); + tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-symlink-'))); + await kaos.chdir(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('follows file symlinks by default and preserves lstat metadata when followSymlinks=false', async () => { + const targetFile = join(tempDir, 'target.txt'); + const linkFile = join(tempDir, 'target-link.txt'); + const payload = 'payload through symlink'; + + await kaos.writeText(targetFile, payload); + await symlink(targetFile, linkFile); + + const linkStat = await kaos.stat(linkFile, { followSymlinks: false }); + expect(linkStat.stMode & S_IFMT).toBe(S_IFLNK); + + const resolvedStat = await kaos.stat(linkFile); + expect(resolvedStat.stMode & S_IFMT).toBe(S_IFREG); + expect(resolvedStat.stSize).toBe(Buffer.byteLength(payload, 'utf-8')); + expect(await kaos.readText(linkFile)).toBe(payload); + }); + + it('follows directory symlinks while lstat still reports a symlink', async () => { + const targetDir = join(tempDir, 'target-dir'); + const linkDir = join(tempDir, 'target-dir-link'); + const nestedFile = join(targetDir, 'nested.txt'); + + await kaos.mkdir(targetDir); + await kaos.writeText(nestedFile, 'directory payload'); + await symlink(targetDir, linkDir); + + const linkStat = await kaos.stat(linkDir, { followSymlinks: false }); + expect(linkStat.stMode & S_IFMT).toBe(S_IFLNK); + + const resolvedStat = await kaos.stat(linkDir); + expect(resolvedStat.stMode & S_IFMT).toBe(S_IFDIR); + + const entries: string[] = []; + for await (const entry of kaos.iterdir(linkDir)) { + entries.push(entry); + } + + expect(entries).toEqual([join(linkDir, 'nested.txt')]); + }); +}); diff --git a/packages/kaos/test/environment.test.ts b/packages/kaos/test/environment.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fee6947c85cd94c8b5106c463c76fad1d171fe85 --- /dev/null +++ b/packages/kaos/test/environment.test.ts @@ -0,0 +1,451 @@ +/** + * Environment detection. + * + * Pins the cross-platform shape of `detectEnvironment()`: + * + * - macOS / Linux / Windows / unknown → `osKind` + * - POSIX path probing prefers /bin/bash, falls back to /usr/bin/bash, + * /usr/local/bin/bash, then /bin/sh (with shellName 'sh'). + * - Windows resolves Git Bash via `KIMI_SHELL_PATH`, `git.exe` on PATH + * (including `git --exec-path` for shims), or well-known install + * locations; throws `KaosShellNotFoundError` + * if none are present. + * - `osArch` / `osVersion` are populated from the Node OS APIs. + * + * All tests expect `detectEnvironment()` to be a pure function of + * injected platform probes (no ambient state) so the same suite runs + * identically on macOS/Linux/Windows CI runners. + */ + +import { describe, expect, it } from 'vitest'; + +import { + detectEnvironment, + type Environment, + type OsKind, + type ShellName, +} from '#/environment'; +import { KaosShellNotFoundError } from '#/errors'; + +interface StubOpts { + readonly platform: NodeJS.Platform; + readonly arch?: string; + readonly release?: string; + readonly env?: Record; + readonly existingPaths?: readonly string[]; + readonly execFileResults?: Readonly>; + readonly execFileText?: Parameters[0]['execFileText']; +} + +/** Build a stub deps bag mimicking Node's `os` + `process` surface. */ +function stubDeps(opts: StubOpts): Parameters[0] { + const existing = new Set(opts.existingPaths ?? []); + return { + platform: opts.platform, + arch: opts.arch ?? 'x86_64', + release: opts.release ?? '1.2.3', + env: opts.env ?? {}, + isFile: async (path: string) => existing.has(path), + execFileText: + opts.execFileText ?? + (async (file: string, args: readonly string[]) => opts.execFileResults?.[execFileKey(file, args)]), + }; +} + +function execFileKey(file: string, args: readonly string[]): string { + return [file, ...args].join('\0'); +} + +describe('detectEnvironment', () => { + it('reports osKind "macOS" on darwin', async () => { + const env: Environment = await detectEnvironment( + stubDeps({ + platform: 'darwin', + arch: 'arm64', + release: '23.4.0', + existingPaths: ['/bin/bash'], + }), + ); + expect(env.osKind satisfies OsKind).toBe('macOS'); + expect(env.osArch).toBe('arm64'); + expect(env.osVersion).toBe('23.4.0'); + }); + + it('reports osKind "Linux" on linux', async () => { + const env = await detectEnvironment( + stubDeps({ platform: 'linux', existingPaths: ['/bin/bash'] }), + ); + expect(env.osKind).toBe('Linux'); + }); + + it('reports osKind "Windows" on win32', async () => { + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + existingPaths: ['C:\\Program Files\\Git\\bin\\bash.exe'], + }), + ); + expect(env.osKind).toBe('Windows'); + }); + + it('passes through unknown platform string verbatim', async () => { + const env = await detectEnvironment( + stubDeps({ platform: 'freebsd' as NodeJS.Platform, existingPaths: ['/bin/sh'] }), + ); + // Python `Environment.detect` returns `platform.system()` verbatim + // for unknown OS strings; TS mirrors that behaviour. + expect(env.osKind).toBe('freebsd'); + }); + + // ── POSIX shell probing ──────────────────────────────────────────── + + it('prefers /bin/bash when it exists (shellName=bash)', async () => { + const env = await detectEnvironment( + stubDeps({ + platform: 'linux', + existingPaths: ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash'], + }), + ); + expect(env.shellName satisfies ShellName).toBe('bash'); + expect(env.shellPath).toBe('/bin/bash'); + }); + + it('falls back to /usr/bin/bash when /bin/bash is missing', async () => { + const env = await detectEnvironment( + stubDeps({ + platform: 'linux', + existingPaths: ['/usr/bin/bash', '/usr/local/bin/bash'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('/usr/bin/bash'); + }); + + it('falls back to /usr/local/bin/bash when /bin and /usr/bin are missing', async () => { + const env = await detectEnvironment( + stubDeps({ + platform: 'linux', + existingPaths: ['/usr/local/bin/bash'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('/usr/local/bin/bash'); + }); + + it('falls back to /bin/sh with shellName=sh when no bash is found', async () => { + const env = await detectEnvironment(stubDeps({ platform: 'linux', existingPaths: [] })); + expect(env.shellName).toBe('sh'); + expect(env.shellPath).toBe('/bin/sh'); + }); + + // ── Windows Git Bash probing ─────────────────────────────────────── + + it('uses KIMI_SHELL_PATH override when set and the file exists', async () => { + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { KIMI_SHELL_PATH: 'D:\\custom\\bash.exe' }, + existingPaths: ['D:\\custom\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe'], + }), + ); + expect(env.shellName satisfies ShellName).toBe('bash'); + expect(env.shellPath).toBe('D:\\custom\\bash.exe'); + }); + + it('infers Git Bash from git.exe on PATH when override is absent', async () => { + const gitExe = 'C:\\Program Files\\Git\\cmd\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Program Files\\Git\\cmd' }, + existingPaths: [gitExe, 'C:\\Program Files\\Git\\bin\\bash.exe'], + execFileText: async (file: string) => { + throw new Error(`unexpected execFileText call for ${file}`); + }, + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Program Files\\Git\\bin\\bash.exe'); + }); + + it('resolves a Scoop git shim through git --exec-path', async () => { + const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Users\\me\\scoop\\shims' }, + execFileResults: { + [execFileKey(gitExe, ['--exec-path'])]: + 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', + }, + existingPaths: [gitExe, 'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'); + }); + + it('resolves a Scoop git shim through usr/bin when bin/bash.exe is missing', async () => { + const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Users\\me\\scoop\\shims' }, + execFileResults: { + [execFileKey(gitExe, ['--exec-path'])]: + 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', + }, + existingPaths: [gitExe, 'C:\\Users\\me\\scoop\\apps\\git\\current\\usr\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\usr\\bin\\bash.exe'); + }); + + it('resolves MSYS2 ucrt64 native git through git --exec-path', async () => { + const gitExe = 'C:\\msys64\\ucrt64\\bin\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\msys64\\ucrt64\\bin' }, + execFileResults: { + [execFileKey(gitExe, ['--exec-path'])]: 'C:/msys64/ucrt64/libexec/git-core\n', + }, + existingPaths: [gitExe, 'C:\\msys64\\usr\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\msys64\\usr\\bin\\bash.exe'); + }); + + it('resolves MSYS2 clang64 native git through git --exec-path', async () => { + const gitExe = 'C:\\msys64\\clang64\\bin\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\msys64\\clang64\\bin' }, + execFileResults: { + [execFileKey(gitExe, ['--exec-path'])]: 'C:/msys64/clang64/libexec/git-core\n', + }, + existingPaths: [gitExe, 'C:\\msys64\\usr\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\msys64\\usr\\bin\\bash.exe'); + }); + + it('resolves MSYS2 clangarm64 native git through git --exec-path', async () => { + const gitExe = 'C:\\msys64\\clangarm64\\bin\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\msys64\\clangarm64\\bin' }, + execFileResults: { + [execFileKey(gitExe, ['--exec-path'])]: 'C:/msys64/clangarm64/libexec/git-core\n', + }, + existingPaths: [gitExe, 'C:\\msys64\\usr\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\msys64\\usr\\bin\\bash.exe'); + }); + + it('does not treat shim-adjacent bash.exe as the Git installation shell', async () => { + const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Users\\me\\scoop\\shims' }, + execFileResults: { + [execFileKey(gitExe, ['--exec-path'])]: + 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', + }, + existingPaths: [ + gitExe, + 'C:\\Users\\me\\scoop\\bin\\bash.exe', + 'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe', + ], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'); + }); + + it('checks later git.exe matches when the first one cannot resolve Git Bash', async () => { + const scoopGit = 'C:\\Users\\me\\scoop\\shims\\git.exe'; + const portableGit = 'D:\\PortableGit\\cmd\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Users\\me\\scoop\\shims;D:\\PortableGit\\cmd' }, + existingPaths: [scoopGit, portableGit, 'D:\\PortableGit\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('D:\\PortableGit\\bin\\bash.exe'); + }); + + it('keeps PATH order when an earlier shim resolves through git --exec-path', async () => { + const scoopGit = 'C:\\Users\\me\\scoop\\shims\\git.exe'; + const portableGit = 'D:\\PortableGit\\cmd\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Users\\me\\scoop\\shims;D:\\PortableGit\\cmd' }, + execFileResults: { + [execFileKey(scoopGit, ['--exec-path'])]: + 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', + }, + existingPaths: [ + scoopGit, + portableGit, + 'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe', + 'D:\\PortableGit\\bin\\bash.exe', + ], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'); + }); + + it('skips relative Windows PATH entries before git --exec-path probing', async () => { + const relativeGit = 'tools\\git.exe'; + const error = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'tools' }, + existingPaths: [relativeGit], + execFileText: async (file: string) => { + throw new Error(`unexpected execFileText call for ${file}`); + }, + }), + ).then( + () => { + throw new Error('expected throw'); + }, + (error: unknown) => error, + ); + expect(error).toBeInstanceOf(KaosShellNotFoundError); + }); + + it('scans PATH directly for git.exe candidates', async () => { + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'D:\\PortableGit\\cmd' }, + existingPaths: ['D:\\PortableGit\\cmd\\git.exe', 'D:\\PortableGit\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('D:\\PortableGit\\bin\\bash.exe'); + }); + + it('infers Git Bash from usr/bin when bin/bash.exe is missing', async () => { + const gitExe = 'D:\\Program Files\\Git\\cmd\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'D:\\Program Files\\Git\\cmd' }, + existingPaths: [gitExe, 'D:\\Program Files\\Git\\usr\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('D:\\Program Files\\Git\\usr\\bin\\bash.exe'); + }); + + it('falls back to the well-known Program Files install location', async () => { + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + existingPaths: ['C:\\Program Files\\Git\\bin\\bash.exe'], + }), + ); + expect(env.shellPath).toBe('C:\\Program Files\\Git\\bin\\bash.exe'); + }); + + it('falls back to usr/bin under Program Files when bin/bash.exe is missing', async () => { + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + existingPaths: ['C:\\Program Files\\Git\\usr\\bin\\bash.exe'], + }), + ); + expect(env.shellPath).toBe('C:\\Program Files\\Git\\usr\\bin\\bash.exe'); + }); + + it('falls back to LOCALAPPDATA install when present', async () => { + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local' }, + existingPaths: ['C:\\Users\\me\\AppData\\Local\\Programs\\Git\\bin\\bash.exe'], + }), + ); + expect(env.shellPath).toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Git\\bin\\bash.exe'); + }); + + it('falls back to usr/bin under LOCALAPPDATA when bin/bash.exe is missing', async () => { + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local' }, + existingPaths: ['C:\\Users\\me\\AppData\\Local\\Programs\\Git\\usr\\bin\\bash.exe'], + }), + ); + expect(env.shellPath).toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Git\\usr\\bin\\bash.exe'); + }); + + it('throws KaosShellNotFoundError when no Git Bash candidate is found', async () => { + const error = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local' }, + existingPaths: [], + }), + ).then( + () => { + throw new Error('expected throw'); + }, + (error: unknown) => error, + ); + expect(error).toBeInstanceOf(KaosShellNotFoundError); + }); + + it('includes attempted paths in the thrown error message', async () => { + const error = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { KIMI_SHELL_PATH: 'D:\\custom\\bash.exe' }, + existingPaths: [], + }), + ).then( + () => { + throw new Error('expected throw'); + }, + (error: unknown) => error as KaosShellNotFoundError, + ); + expect(error.message).toContain('D:\\custom\\bash.exe'); + expect(error.message).toContain('C:\\Program Files\\Git\\bin\\bash.exe'); + expect(error.message).toContain('C:\\Program Files\\Git\\usr\\bin\\bash.exe'); + }); + + // ── arch / version passthrough ───────────────────────────────────── + + it('reports osArch verbatim from the injected probe', async () => { + const env = await detectEnvironment( + stubDeps({ platform: 'darwin', arch: 'arm64', existingPaths: ['/bin/bash'] }), + ); + expect(env.osArch).toBe('arm64'); + }); + + it('reports osVersion verbatim from the injected probe', async () => { + const env = await detectEnvironment( + stubDeps({ + platform: 'linux', + release: '6.1.0-test', + existingPaths: ['/bin/bash'], + }), + ); + expect(env.osVersion).toBe('6.1.0-test'); + }); +}); diff --git a/packages/kaos/test/fixtures/killtree.cjs b/packages/kaos/test/fixtures/killtree.cjs new file mode 100644 index 0000000000000000000000000000000000000000..c89e84c9a292c8435b0a2e0f27d39683b62283a2 --- /dev/null +++ b/packages/kaos/test/fixtures/killtree.cjs @@ -0,0 +1,31 @@ +// Helper for the Windows process-tree kill test (test/local.test.ts). +// +// Boots a parent → child → grandchild chain and writes the grandchild's pid +// to the path passed as `process.argv[2]`, then idles so the test can kill +// the parent and assert the grandchild is reaped too. +// +// Lives in a real file (rather than an inline `node -e` string) so the path +// travels via argv instead of being embedded in nested template literals — +// inline multi-line `node -e` with backslash paths gets mangled by Node's +// Windows arg-quoting and by JS string escapes (`\f` etc.). + +const { spawn } = require('node:child_process'); + +const pidPath = process.argv[2]; +if (!pidPath) { + process.exit(2); +} + +// Single-line child code: no newlines, no nested template literals, and the +// pid path comes from argv (not a string literal), so it survives Windows +// arg-quoting intact. +const childCode = [ + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + "const g = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)']);", + 'writeFileSync(process.argv[1], String(g.pid));', + 'setInterval(() => {}, 1000);', +].join(' '); + +spawn(process.execPath, ['-e', childCode, pidPath], { stdio: 'inherit' }); +setInterval(() => {}, 1000); diff --git a/packages/kaos/test/internal.test.ts b/packages/kaos/test/internal.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f38d3107cb843274451e786bdf0230fe488f35f6 --- /dev/null +++ b/packages/kaos/test/internal.test.ts @@ -0,0 +1,283 @@ +import { PassThrough } from 'node:stream'; +import { setImmediate as defer, setTimeout as delay } from 'node:timers/promises'; + +import { describe, expect, it } from 'vitest'; + +import { BufferedReadable, decodeTextWithErrors, globPatternToRegex } from '#/internal'; + +async function collectBytes(readable: AsyncIterable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of readable) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return Promise.race([ + promise, + (async () => { + await delay(timeoutMs); + throw new Error(`timed out after ${timeoutMs}ms`); + })(), + ]); +} + +describe('BufferedReadable', () => { + it('preserves source backpressure until the consumer starts reading', () => { + const source = new PassThrough({ highWaterMark: 64 * 1024 }); + const buffered = new BufferedReadable(source); + const chunk = Buffer.alloc(32 * 1024, 0x61); + + let writes = 0; + let writeOk = true; + while (writes < 20 && writeOk) { + writeOk = source.write(chunk); + writes++; + } + + buffered.destroy(); + source.destroy(); + + expect(writeOk).toBe(false); + expect(writes).toBeLessThan(10); + }); + + it('stops pushing when its readable buffer reaches highWaterMark', async () => { + const source = new PassThrough(); + const chunk = Buffer.alloc(32 * 1024, 0x62); + + for (let index = 0; index < 10; index++) { + source.write(chunk); + } + source.end(); + + const buffered = new BufferedReadable(source); + buffered.read(0); + await defer(); + + expect(buffered.readableLength).toBeLessThanOrEqual(buffered.readableHighWaterMark); + + const output = await collectBytes(buffered); + expect(output).toHaveLength(chunk.length * 10); + }); + + it('ends async iteration when the source closes without emitting end', async () => { + const source = new PassThrough(); + const buffered = new BufferedReadable(source); + const outputPromise = collectBytes(buffered).then((output) => output.toString('utf8')); + + await defer(); + source.write('hello'); + await defer(); + source.destroy(); + + await expect(withTimeout(outputPromise, 250)).resolves.toBe('hello'); + }); + + it('propagates a source error through destroy()', async () => { + // When the source emits an 'error' event, BufferedReadable must tear + // itself down with the same error so consumers see the failure rather + // than waiting forever for data. + const source = new PassThrough(); + const buffered = new BufferedReadable(source); + const boom = new Error('source boom'); + + const errorReceived = new Promise((resolve) => { + buffered.on('error', (err: Error) => { + resolve(err); + }); + }); + + source.emit('error', boom); + + const received = await withTimeout(errorReceived, 250); + expect(received).toBe(boom); + expect(buffered.destroyed).toBe(true); + }); + + it('destroys the underlying source when the wrapper is destroyed', () => { + const source = new PassThrough(); + const buffered = new BufferedReadable(source); + + buffered.destroy(); + + expect(source.destroyed).toBe(true); + }); +}); + +describe('decodeTextWithErrors', () => { + it('decodes utf-16le content under strict mode', () => { + // Covers the utf16le / ucs2 / ucs-2 alias branches that the readText + // tests (which only exercise utf-8) never touch. + const data = Buffer.from('hello', 'utf16le'); + expect(decodeTextWithErrors(data, 'utf16le')).toBe('hello'); + }); + + it('accepts the ucs2 and ucs-2 encoding aliases', () => { + const data = Buffer.from('hello', 'utf16le'); + expect(decodeTextWithErrors(data, 'ucs2')).toBe('hello'); + expect(decodeTextWithErrors(data, 'ucs-2' as BufferEncoding)).toBe('hello'); + }); + + it('falls back to Buffer.toString for non-UTF encodings', () => { + // hex / base64 / latin1 are lossless byte↔character mappings so `errors` + // has no effect; the helper must take the non-TextDecoder branch. + const data = Buffer.from([0x68, 0x69]); // 'hi' in latin1 + expect(decodeTextWithErrors(data, 'latin1')).toBe('hi'); + expect(decodeTextWithErrors(data, 'hex')).toBe('6869'); + }); + + it('preserves valid U+FFFD when ignoring invalid utf-8 bytes', () => { + const data = Buffer.concat([ + Buffer.from('A\uFFFDB', 'utf-8'), + Buffer.from([0xff]), + Buffer.from('C', 'utf-8'), + ]); + + expect(decodeTextWithErrors(data, 'utf-8', 'ignore')).toBe('A\uFFFDBC'); + }); + + it('matches Python ignore behavior for invalid utf-8 sequence boundaries', () => { + const cases: Buffer[] = [ + Buffer.from([0xc2, 0x41]), + Buffer.from([0xe0, 0xa0, 0x41]), + Buffer.from([0xf0, 0x90, 0x80, 0x41]), + Buffer.from([0xed, 0xa0, 0x80, 0x41]), + ]; + + for (const data of cases) { + expect(decodeTextWithErrors(data, 'utf-8', 'ignore')).toBe('A'); + } + }); + + it('preserves valid U+FFFD when ignoring invalid utf-16le code units', () => { + const data = Buffer.concat([ + Buffer.from('A\uFFFDB', 'utf16le'), + Buffer.from([0x3d, 0xd8]), // lone high surrogate + Buffer.from('C', 'utf16le'), + Buffer.from([0xff]), // trailing half code unit + ]); + + expect(decodeTextWithErrors(data, 'utf16le', 'ignore')).toBe('A\uFFFDBC'); + }); + + it('matches Python ignore behavior for utf-16le surrogate boundaries', () => { + const emojiPair = Buffer.from([0x3d, 0xd8, 0x00, 0xde]); + expect(decodeTextWithErrors(emojiPair, 'utf16le', 'ignore')).toBe('\u{1F600}'); + + const highThenNormalThenLow = Buffer.from([0x3d, 0xd8, 0x58, 0x00, 0x00, 0xdc]); + expect(decodeTextWithErrors(highThenNormalThenLow, 'utf16le', 'ignore')).toBe('X'); + }); +}); + +describe('globPatternToRegex additional cases', () => { + it('converts ? to a single-character class that excludes /', () => { + const regex = globPatternToRegex('f?o.txt', true); + expect(regex.test('foo.txt')).toBe(true); + expect(regex.test('fao.txt')).toBe(true); + // ? must match exactly one character + expect(regex.test('fo.txt')).toBe(false); + // ? must not cross path segments + expect(regex.test('f/o.txt')).toBe(false); + }); + + it('treats an unclosed [ as a literal bracket', () => { + // Mirrors Python fnmatch/glob: a bare `[` with no closing `]` is + // re-emitted as an escaped literal instead of starting a char class. + const regex = globPatternToRegex('file[', true); + expect(regex.test('file[')).toBe(true); + expect(regex.test('file]')).toBe(false); + }); + + it('escapes regex metacharacters in the default branch', () => { + // `+`, `.`, `(`, `)`, `$`, `^`, `|`, `{`, `}`, `\` must be escaped so + // `a+b.c$d(e)` in a glob pattern matches that literal filename. + const regex = globPatternToRegex('a+b.c$d(e)', true); + expect(regex.test('a+b.c$d(e)')).toBe(true); + // Without escaping, `+` would make `ab` match via one-or-more repetition. + expect(regex.test('ab.c$d(e)')).toBe(false); + }); + + it('treats backslash as an escape character for the next literal', () => { + // `\[` and `\]` should match literal brackets in the filename. + const regex = globPatternToRegex('special \\[bracket\\].ts', true); + expect(regex.test('special [bracket].ts')).toBe(true); + expect(regex.test('special bracket.ts')).toBe(false); + }); + + it('escapes backslashes inside character classes to avoid an unterminated regex', () => { + // A trailing backslash inside a glob char class (`[abc\\]`) would + // escape the closing `]` in the resulting regex. We escape it so the + // regex stays valid and the backslash is treated as a literal member. + const regex = globPatternToRegex('file[abc\\].txt', true); + expect(regex.test('filea.txt')).toBe(true); + expect(regex.test('file\\.txt')).toBe(true); + expect(regex.test('filez.txt')).toBe(false); + }); +}); + +describe('globPatternToRegex', () => { + it('treats [!...] as a negated character class', () => { + const regex = globPatternToRegex('[!a].txt', true); + + expect(regex.test('a.txt')).toBe(false); + expect(regex.test('b.txt')).toBe(true); + }); + + it('treats ^ as a literal character inside glob character classes', () => { + const regex = globPatternToRegex('[^a].txt', true); + + expect(regex.test('^.txt')).toBe(true); + expect(regex.test('a.txt')).toBe(true); + expect(regex.test('b.txt')).toBe(false); + }); + + describe('glob semantic compatibility (Python parity)', () => { + it('treats brace expansion syntax as literal characters', () => { + const regex = globPatternToRegex('*.{js,ts}', true); + + expect(regex.test('file.js')).toBe(false); + expect(regex.test('file.ts')).toBe(false); + expect(regex.test('file.{js,ts}')).toBe(true); + }); + + it('treats leading ^ as a literal character inside character classes', () => { + const regex = globPatternToRegex('[^a].txt', true); + + expect(regex.test('^.txt')).toBe(true); + expect(regex.test('a.txt')).toBe(true); + expect(regex.test('b.txt')).toBe(false); + }); + + it('uses ! for character class negation', () => { + const regex = globPatternToRegex('[!a].txt', true); + + expect(regex.test('b.txt')).toBe(true); + expect(regex.test('a.txt')).toBe(false); + }); + + it('matches hidden files with dot-prefixed patterns', () => { + const regex = globPatternToRegex('.*', true); + + expect(regex.test('.hidden')).toBe(true); + expect(regex.test('.config')).toBe(true); + }); + + it.skip('Python treats **/foo.txt as recursive; current helper is segment-based and does not implement zero-or-more directories', () => { + const regex = globPatternToRegex('**/foo.txt', true); + + expect(regex.test('foo.txt')).toBe(true); + expect(regex.test('a/foo.txt')).toBe(true); + expect(regex.test('a/b/foo.txt')).toBe(true); + }); + + it('keeps single-star matching to a single path segment', () => { + const regex = globPatternToRegex('*/foo.txt', true); + + expect(regex.test('a/foo.txt')).toBe(true); + expect(regex.test('foo.txt')).toBe(false); + expect(regex.test('a/b/foo.txt')).toBe(false); + }); + }); +}); diff --git a/packages/kaos/test/local.test.ts b/packages/kaos/test/local.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..acaa13ba118ab995882d476f5d74d22613c43ab4 --- /dev/null +++ b/packages/kaos/test/local.test.ts @@ -0,0 +1,1059 @@ +import { mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { KaosFileExistsError } from '#/errors'; +import { LocalKaos } from '#/local'; +import { afterEach, beforeEach, describe, expect, it, test } from 'vitest'; + +// LocalKaos normalizes every path to forward slashes (pathe). Mirror that in +// path assertions so they hold on Windows, where node:path/node:os produce +// backslashes. +const toPosix = (p: string): string => p.replaceAll('\\', '/'); + +function nodeArgs(code: string): string[] { + return ['node', '-e', code]; +} + +describe('LocalKaos', () => { + let kaos: LocalKaos; + let tempDir: string; + + beforeEach(async () => { + kaos = await LocalKaos.create(); + tempDir = toPosix(await realpath(await mkdtemp(join(tmpdir(), 'kaos-test-')))); + await kaos.chdir(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('pathClass, gethome, getcwd', () => { + it('should return posix or win32 pathClass', () => { + const cls = kaos.pathClass(); + if (process.platform === 'win32') { + expect(cls).toBe('win32'); + } else { + expect(cls).toBe('posix'); + } + }); + + it('should return the home directory', () => { + // Python test_local_kaos.py pins `str(gethome()) == str(Path.home())`; + // asserting length > 0 alone was too weak — a stub returning any + // non-empty string would pass. + const home = kaos.gethome(); + expect(home).toBe(toPosix(homedir())); + }); + + it('should return the current working directory', () => { + const cwd = kaos.getcwd(); + expect(cwd).toBe(tempDir); + }); + }); + + describe('chdir + stat', () => { + it('should change directory and stat a file', async () => { + const nested = toPosix(join(tempDir, 'nested')); + await kaos.mkdir(nested); + + await kaos.chdir(nested); + expect(kaos.getcwd()).toBe(nested); + + const filePath = join(nested, 'file.txt'); + await kaos.writeText(filePath, 'hello world'); + + const statResult = await kaos.stat(filePath); + expect(statResult.stSize).toBe(Buffer.byteLength('hello world', 'utf-8')); + }); + + it('should reject chdir into a regular file', async () => { + // chdir must explicitly refuse targets that resolve to non-directories + // so relative I/O calls after the chdir do not silently treat a file + // path as a working directory. + const filePath = join(tempDir, 'not-a-dir.txt'); + await kaos.writeText(filePath, 'content'); + await expect(kaos.chdir(filePath)).rejects.toThrow(/Not a directory/); + }); + + it('should accept backslashes as path separators', async () => { + const nested = join(tempDir, 'backslash-test'); + await kaos.mkdir(nested); + const filePath = join(nested, 'file.txt'); + await kaos.writeText(filePath, 'hello'); + + // Use backslashes — they should be treated as forward slashes. + const backslashPath = filePath.replaceAll('/', '\\'); + const statResult = await kaos.stat(backslashPath); + expect(statResult.stSize).toBe(Buffer.byteLength('hello', 'utf-8')); + }); + }); + + describe('iterdir path normalization', () => { + it('should produce normalized paths even when the argument has a trailing separator', async () => { + // Regression: previously, iterdir manually concatenated `resolved + sep + // + entry`, which produced `//entry` for roots like `/` and `C:\\entry` + // for Windows drives. Using pathJoin correctly collapses the extra + // separator. + await kaos.writeText(join(tempDir, 'file.txt'), 'x'); + + const entries: string[] = []; + // Pass tempDir with an explicit trailing slash to simulate the root + // edge case without needing a writable filesystem root in the test. + for await (const entry of kaos.iterdir(tempDir + '/')) { + entries.push(entry); + } + + expect(entries).toContain(toPosix(join(tempDir, 'file.txt'))); + // No entry should contain duplicated separators. + expect(entries.every((e) => !e.includes('//'))).toBe(true); + }); + }); + + describe('iterdir + glob', () => { + it('should list directory entries and match glob patterns', async () => { + await kaos.mkdir(join(tempDir, 'alpha')); + await kaos.writeText(join(tempDir, 'bravo.txt'), 'bravo'); + await kaos.writeText(join(tempDir, 'charlie.TXT'), 'charlie'); + + const entries: string[] = []; + for await (const entry of kaos.iterdir(tempDir)) { + entries.push(entry); + } + const names = entries.map((e) => e.split('/').pop()!); + expect(new Set(names)).toEqual(new Set(['alpha', 'bravo.txt', 'charlie.TXT'])); + + const matched: string[] = []; + for await (const entry of kaos.glob(tempDir, '*.txt')) { + matched.push(entry); + } + const matchedNames = matched.map((e) => e.split('/').pop()!); + expect(new Set(matchedNames)).toEqual(new Set(['bravo.txt'])); + }); + }); + + describe('glob hidden files', () => { + it('should include hidden files in glob results', async () => { + await kaos.writeText(join(tempDir, '.gitlab-ci.yml'), 'stages: [build]'); + await kaos.writeText(join(tempDir, 'config.yml'), 'key: value'); + + const matched: string[] = []; + for await (const entry of kaos.glob(tempDir, '*.yml')) { + matched.push(entry); + } + const names = matched.map((e) => e.split('/').pop()!); + expect(names).toContain('.gitlab-ci.yml'); + expect(names).toContain('config.yml'); + }); + + it('should glob through hidden directories with ** pattern', async () => { + await kaos.mkdir(join(tempDir, 'src')); + await kaos.mkdir(join(tempDir, 'src', '.config')); + await kaos.writeText(join(tempDir, 'src', '.config', 'settings.yml'), 'debug: true'); + await kaos.writeText(join(tempDir, 'src', 'main.ts'), 'pass'); + + const deepMatched: string[] = []; + for await (const entry of kaos.glob(tempDir, 'src/**/*.yml')) { + deepMatched.push(entry); + } + expect(deepMatched.some((p) => p.includes('.config'))).toBe(true); + }); + }); + + describe('readText/writeText', () => { + it('should write, read, append, and readLines', async () => { + const filePath = join(tempDir, 'note.txt'); + + const written = await kaos.writeText(filePath, 'line1'); + expect(written).toBe('line1'.length); + + const content = await kaos.readText(filePath); + expect(content).toBe('line1'); + + await kaos.writeText(filePath, '\nline2', { mode: 'a' }); + + const lines: string[] = []; + for await (const line of kaos.readLines(filePath)) { + lines.push(line); + } + expect(lines.join('')).toBe('line1\nline2'); + }); + }); + + describe('readLines streaming', () => { + async function collectLines(path: string, options?: Parameters[1]) { + const lines: string[] = []; + for await (const line of kaos.readLines(path, options)) { + lines.push(line); + } + return lines; + } + + it('preserves content exactly across representative line endings', async () => { + const fixtures: Array<[string, string]> = [ + ['multiline', 'line1\nline2\nline3\n'], + ['no trailing newline', 'line1\nline2'], + ['single line', 'only'], + ['single newline', '\n'], + ['empty', ''], + ['crlf', 'a\r\nb\r\n'], + ['lone cr', 'a\rB\n'], + ]; + for (const [name, content] of fixtures) { + const filePath = join(tempDir, `${name}.txt`); + await kaos.writeText(filePath, content); + expect((await collectLines(filePath)).join('')).toBe(content); + } + }); + + it('preserves multibyte characters and long single lines across chunk boundaries', async () => { + const filePath = join(tempDir, 'boundary.txt'); + const content = `${'a'.repeat(65535)}😀\n${'x'.repeat(200000)}`; + await kaos.writeText(filePath, content); + await expect(collectLines(filePath)).resolves.toEqual([ + `${'a'.repeat(65535)}😀\n`, + 'x'.repeat(200000), + ]); + }); + + it('preserves U+FEFF at the start of a non-first line', async () => { + const filePath = join(tempDir, 'bom-line.txt'); + const content = 'a\n\uFEFFb\n'; + await kaos.writeText(filePath, content); + await expect(collectLines(filePath)).resolves.toEqual(['a\n', '\uFEFFb\n']); + }); + + it('keeps utf16le and hex on the decode-then-split path', async () => { + const utf16Path = join(tempDir, 'utf16le.txt'); + await kaos.writeBytes(utf16Path, Buffer.from('a\n\u0A41\n', 'utf16le')); + await expect(collectLines(utf16Path, { encoding: 'utf16le' })).resolves.toEqual([ + 'a\n', + 'ੁ\n', + ]); + + const hexPath = join(tempDir, 'hex.txt'); + await kaos.writeBytes(hexPath, Buffer.from('a\nb')); + await expect(collectLines(hexPath, { encoding: 'hex' })).resolves.toEqual(['610a62']); + }); + + it('throws lazily when strict UTF-8 errors appear after the first line', async () => { + const filePath = join(tempDir, 'invalid-after-first-line.txt'); + await kaos.writeBytes(filePath, Buffer.concat([Buffer.from('ok\n', 'utf-8'), Buffer.from([0xff])])); + const gen = kaos.readLines(filePath); + await expect(gen.next()).resolves.toMatchObject({ value: 'ok\n', done: false }); + await expect(gen.next()).rejects.toThrow(); + }); + }); + + describe('scanTextFile', () => { + it('counts lines and classifies line endings', async () => { + const lf = join(tempDir, 'lf.txt'); + await kaos.writeText(lf, 'a\nb'); + await expect(kaos.scanTextFile(lf)).resolves.toMatchObject({ + totalLines: 2, + endsWithNewline: false, + hasNul: false, + lineEndingFlags: { hasCrLf: false, hasLf: true, hasLoneCr: false }, + }); + + const crlf = join(tempDir, 'crlf.txt'); + await kaos.writeText(crlf, 'a\r\nb\r\n'); + await expect(kaos.scanTextFile(crlf)).resolves.toMatchObject({ + totalLines: 2, + endsWithNewline: true, + lineEndingFlags: { hasCrLf: true, hasLf: false, hasLoneCr: false }, + }); + + const loneCr = join(tempDir, 'lone-cr.txt'); + await kaos.writeText(loneCr, 'a\rB\n'); + await expect(kaos.scanTextFile(loneCr)).resolves.toMatchObject({ + totalLines: 1, + lineEndingFlags: { hasCrLf: false, hasLf: true, hasLoneCr: true }, + }); + }); + + it('detects NUL and invalid UTF-8', async () => { + const nul = join(tempDir, 'nul.txt'); + await kaos.writeBytes(nul, Buffer.from('a\u0000b\n', 'utf-8')); + await expect(kaos.scanTextFile(nul)).resolves.toMatchObject({ hasNul: true }); + + const invalid = join(tempDir, 'invalid.txt'); + await kaos.writeBytes(invalid, Buffer.from([0xff])); + await expect(kaos.scanTextFile(invalid)).rejects.toThrow(); + }); + }); + + describe('readLineRange', () => { + async function collectRange(path: string, startLine: number, maxLines: number) { + const lines: string[] = []; + for await (const line of kaos.readLineRange(path, { startLine, maxLines })) { + lines.push(line); + } + return lines; + } + + it('reads only the requested line window', async () => { + const filePath = join(tempDir, 'range.txt'); + await kaos.writeText(filePath, 'a\nb\nc\nd\n'); + await expect(collectRange(filePath, 2, 2)).resolves.toEqual(['b\n', 'c\n']); + await expect(collectRange(filePath, 5, 2)).resolves.toEqual([]); + }); + + it('preserves U+FEFF at the start of a ranged non-first line', async () => { + const filePath = join(tempDir, 'range-bom.txt'); + await kaos.writeText(filePath, 'a\n\uFEFFb\n'); + await expect(collectRange(filePath, 2, 1)).resolves.toEqual(['\uFEFFb\n']); + }); + }); + + describe('readTailLines', () => { + async function collectTail(path: string, tailCount: number) { + const lines: string[] = []; + for await (const line of kaos.readTailLines(path, { tailCount })) { + lines.push(line); + } + return lines; + } + + it('reads last lines with and without trailing newline', async () => { + const trailing = join(tempDir, 'tail-trailing.txt'); + await kaos.writeText(trailing, 'a\nb\nc\n'); + await expect(collectTail(trailing, 2)).resolves.toEqual(['b\n', 'c\n']); + + const noTrailing = join(tempDir, 'tail-no-trailing.txt'); + await kaos.writeText(noTrailing, 'a\nb\nc'); + await expect(collectTail(noTrailing, 2)).resolves.toEqual(['b\n', 'c']); + }); + + it('returns the whole file when tailCount exceeds line count', async () => { + const filePath = join(tempDir, 'tail-short.txt'); + await kaos.writeText(filePath, 'a\nb\n'); + await expect(collectTail(filePath, 5)).resolves.toEqual(['a\n', 'b\n']); + }); + + it('preserves CRLF and U+FEFF in tail lines', async () => { + const filePath = join(tempDir, 'tail-crlf-bom.txt'); + await kaos.writeText(filePath, 'a\r\n\uFEFFb\r\n'); + await expect(collectTail(filePath, 1)).resolves.toEqual(['\uFEFFb\r\n']); + }); + }); + + describe('readText errors parameter (Python compat)', () => { + // A file with a valid UTF-8 prefix "中", an invalid standalone byte 0xff, + // and a valid UTF-8 suffix "文". Under strict decoding this throws. + const invalidBytes = Buffer.concat([ + Buffer.from([0xe4, 0xb8, 0xad]), // 中 + Buffer.from([0xff]), + Buffer.from([0xe6, 0x96, 0x87]), // 文 + ]); + + it('throws on invalid utf-8 with errors="strict" (default)', async () => { + const filePath = join(tempDir, 'invalid.txt'); + await kaos.writeBytes(filePath, invalidBytes); + + await expect(kaos.readText(filePath)).rejects.toThrow(); + await expect(kaos.readText(filePath, { errors: 'strict' })).rejects.toThrow(); + }); + + it('returns U+FFFD replacement characters with errors="replace"', async () => { + const filePath = join(tempDir, 'replace.txt'); + await kaos.writeBytes(filePath, invalidBytes); + + const content = await kaos.readText(filePath, { errors: 'replace' }); + expect(content).toContain('\uFFFD'); + expect(content).toContain('中'); + expect(content).toContain('文'); + }); + + it('drops invalid bytes with errors="ignore"', async () => { + const filePath = join(tempDir, 'ignore.txt'); + await kaos.writeBytes(filePath, invalidBytes); + + const content = await kaos.readText(filePath, { errors: 'ignore' }); + expect(content).toBe('中文'); + expect(content).not.toContain('\uFFFD'); + }); + + it('preserves valid U+FFFD characters with errors="ignore"', async () => { + const filePath = join(tempDir, 'ignore-valid-replacement.txt'); + const data = Buffer.concat([ + Buffer.from('A\uFFFDB', 'utf-8'), + Buffer.from([0xff]), + Buffer.from('C', 'utf-8'), + ]); + await kaos.writeBytes(filePath, data); + + const content = await kaos.readText(filePath, { errors: 'ignore' }); + expect(content).toBe('A\uFFFDBC'); + }); + }); + + describe('LF preservation', () => { + it('should not convert LF to CRLF', async () => { + const filePath = join(tempDir, 'lf.txt'); + await kaos.writeText(filePath, 'hello\nworld\n'); + + const raw = await kaos.readBytes(filePath); + expect(raw).toEqual(Buffer.from('hello\nworld\n')); + }); + }); + + describe('CRLF preservation', () => { + it('should preserve CRLF line endings', async () => { + const filePath = join(tempDir, 'crlf.txt'); + await kaos.writeText(filePath, 'hello\r\nworld\r\n'); + + const raw = await kaos.readBytes(filePath); + expect(raw).toEqual(Buffer.from('hello\r\nworld\r\n')); + }); + }); + + describe('mkdir recursive', () => { + it('should create nested directories with parents option', async () => { + const nested = join(tempDir, 'a', 'b', 'c'); + await kaos.mkdir(nested, { parents: true }); + + const s = await kaos.stat(nested); + // Check it's a directory (mode has the directory bit set) + // S_IFDIR = 0o040000 + expect(s.stMode & 0o170000).toBe(0o040000); + }); + + it('should throw when parents:true + existOk:false on existing dir', async () => { + // Regression: fs.mkdir({ recursive: true }) silently succeeds on an + // existing directory. mkdir({ parents: true, existOk: false }) must + // still reject to match the advertised semantics. + const existing = join(tempDir, 'existing'); + await kaos.mkdir(existing); + + await expect(kaos.mkdir(existing, { parents: true, existOk: false })).rejects.toThrow(); + }); + + it('should succeed when parents:true + existOk:true on existing dir', async () => { + const existing = join(tempDir, 'existing'); + await kaos.mkdir(existing); + + await expect(kaos.mkdir(existing, { parents: true, existOk: true })).resolves.toBeUndefined(); + }); + + it('should throw when existOk:true but conflicting path is a file', async () => { + // If the target path already exists as a regular file, `existOk` must + // not silently "succeed" because there is still no directory there. + const filePath = join(tempDir, 'not-a-dir.txt'); + await kaos.writeText(filePath, 'hello'); + + await expect(kaos.mkdir(filePath, { existOk: true })).rejects.toBeInstanceOf( + KaosFileExistsError, + ); + }); + }); + + describe('glob character class negation', () => { + it('[!a] should match non-a files (glob negation, not literal `!`)', async () => { + // Glob character classes use `!` for negation, unlike JavaScript regex. + await kaos.writeText(join(tempDir, 'a.txt'), ''); + await kaos.writeText(join(tempDir, 'b.txt'), ''); + await kaos.writeText(join(tempDir, '!.txt'), ''); + + const matches: string[] = []; + for await (const m of kaos.glob(tempDir, '[!a].txt')) { + matches.push(m); + } + const names = new Set(matches.map((p) => p.split(/[/\\]/).pop()!)); + + expect(names.has('a.txt')).toBe(false); + expect(names.has('b.txt')).toBe(true); + expect(names.has('!.txt')).toBe(true); + }); + }); + + describe('glob ** pattern', () => { + it('should not return duplicates when ** matches nested paths', async () => { + // Regression for the `**` double-recursion bug: a single file nested + // two levels deep must appear exactly once, not 2^depth times. + await kaos.mkdir(join(tempDir, 'a', 'b'), { parents: true }); + await kaos.writeText(join(tempDir, 'a', 'b', 'file.txt'), 'content'); + + const matches: string[] = []; + for await (const m of kaos.glob(tempDir, '**/*.txt')) { + matches.push(m); + } + + expect(matches).toHaveLength(1); + expect(new Set(matches).size).toBe(matches.length); + }); + + it('should match ** at multiple depths without duplicates', async () => { + await kaos.mkdir(join(tempDir, 'a', 'b', 'c'), { parents: true }); + await kaos.writeText(join(tempDir, 'root.txt'), ''); + await kaos.writeText(join(tempDir, 'a', 'mid.txt'), ''); + await kaos.writeText(join(tempDir, 'a', 'b', 'c', 'deep.txt'), ''); + + const matches: string[] = []; + for await (const m of kaos.glob(tempDir, '**/*.txt')) { + matches.push(m); + } + + expect(matches).toHaveLength(3); + expect(new Set(matches).size).toBe(3); + const names = new Set(matches.map((p) => p.split(/[/\\]/).pop()!)); + expect(names).toEqual(new Set(['root.txt', 'mid.txt', 'deep.txt'])); + }); + + it('should not duplicate matches for deep ** patterns', async () => { + // Before the fix, each added depth level doubled the duplicate count. + // Build a 4-level-deep tree with a file at the bottom. + await kaos.mkdir(join(tempDir, 'l1', 'l2', 'l3', 'l4'), { parents: true }); + await kaos.writeText(join(tempDir, 'l1', 'l2', 'l3', 'l4', 'deep.txt'), 'x'); + + const matches: string[] = []; + for await (const m of kaos.glob(tempDir, '**/*.txt')) { + matches.push(m); + } + + expect(matches).toHaveLength(1); + }); + + it('should yield basePath and every recursive entry when the whole pattern is **', async () => { + // A bare `**` pattern enters the final-segment branch of _globWalk and + // must (a) emit basePath itself for the zero-directory match and + // (b) walk every file/dir below it as additional matches. + await kaos.mkdir(join(tempDir, 'sub')); + await kaos.writeText(join(tempDir, 'root.txt'), 'r'); + await kaos.writeText(join(tempDir, 'sub', 'nested.txt'), 'n'); + + const matches: string[] = []; + for await (const m of kaos.glob(tempDir, '**')) { + matches.push(m); + } + + const names = new Set(matches.map((p) => p.split(/[/\\]/).pop() ?? '')); + expect(matches).toContain(tempDir); + expect(names.has('root.txt')).toBe(true); + expect(names.has('nested.txt')).toBe(true); + expect(names.has('sub')).toBe(true); + }); + }); + + // ── Symlink cycle safety ──────────────────────────────────────────── + // + // These tests use real filesystem symlinks. Note: macOS/Linux apply + // SYMLOOP_MAX (~40 components) at the kernel level, so an unfixed + // walker doesn't hang forever — it yields a bounded-but-large number + // of cyclic paths (observed ~16 for a self-loop) before ELOOP. The + // assertions here are therefore tight (single-digit expected counts) + // so they distinguish "OS-ELOOP bailout" (buggy) from "app-level + // cycle detection" (fixed). HARD_STOP is a final safety belt in case + // a future kernel allows deeper symlink chains — tests shouldn't hang. + describe('glob symlink cycle safety', () => { + const HARD_STOP = 1000; + + it('T-C1 self-symlink cycle yields exactly one match', async () => { + const { symlink, writeFile, mkdir } = await import('node:fs/promises'); + // tempDir/ring/self → tempDir/ring (self-loop dir) + // tempDir/ring/leaf.txt (the only real file) + const ring = join(tempDir, 'ring'); + await mkdir(ring); + await writeFile(join(ring, 'leaf.txt'), 'real'); + await symlink(ring, join(ring, 'self')); + + const matches: string[] = []; + for await (const m of kaos.glob(tempDir, '**/*.txt')) { + matches.push(m); + if (matches.length >= HARD_STOP) break; + } + // Fixed: visited-inode detects the self-loop on the first recurse + // into `ring/self` (whose resolved inode matches `ring`'s), so the + // walker yields `ring/leaf.txt` exactly once. + // Unfixed: ~16 copies like `ring/self/self/.../leaf.txt` before + // the kernel's SYMLOOP_MAX trips. + expect(matches).toHaveLength(1); + expect(matches[0]!.endsWith('leaf.txt')).toBe(true); + }); + + it('T-C2 mutual cycle (A/to_b→B, B/to_a→A) yields only finite real reaches', async () => { + const { symlink, writeFile, mkdir } = await import('node:fs/promises'); + const a = join(tempDir, 'A'); + const b = join(tempDir, 'B'); + await mkdir(a); + await mkdir(b); + await writeFile(join(a, 'aleaf.txt'), 'a'); + await writeFile(join(b, 'bleaf.txt'), 'b'); + await symlink(b, join(a, 'to_b')); + await symlink(a, join(b, 'to_a')); + + const matches: string[] = []; + for await (const m of kaos.glob(tempDir, '**/*.txt')) { + matches.push(m); + if (matches.length >= HARD_STOP) break; + } + // Fixed: path-local visited allows each legitimate descent (A→to_b + // which hits a fresh B, and B→to_a which hits a fresh A) but + // blocks the close-the-loop second step. Expected exactly 4: + // root/A/aleaf.txt, root/A/to_b/bleaf.txt, + // root/B/bleaf.txt, root/B/to_a/aleaf.txt. + // Unfixed: kernel ELOOP bailout yields many more (observed ~66 + // on macOS). A shared-visited (non-path-local) impl would yield + // only 2 because entering B from the walker's root already marks + // B as visited before A→to_b is traversed. + expect(matches).toHaveLength(4); + expect(matches.filter((p) => p.endsWith('aleaf.txt'))).toHaveLength(2); + expect(matches.filter((p) => p.endsWith('bleaf.txt'))).toHaveLength(2); + }); + + it('T-C3 legit non-cyclic symlink to a sibling tree is followed', async () => { + const { symlink, writeFile, mkdir } = await import('node:fs/promises'); + // target/ is a sibling not under root/, reached only via root/shortcut. + const root = join(tempDir, 'root'); + const target = join(tempDir, 'target'); + await mkdir(root); + await mkdir(target); + await writeFile(join(target, 'reachable.txt'), 'hi'); + await symlink(target, join(root, 'shortcut')); + + const matches: string[] = []; + for await (const m of kaos.glob(root, '**/*.txt')) { + matches.push(m); + } + // User-created symlinks to legitimate subtrees should still be followed; + // cycle detection only trips on actual cycles. + expect(matches.some((p) => p.endsWith('reachable.txt'))).toBe(true); + }); + + it('T-C4 broken symlink does not crash the walk', async () => { + const { symlink, writeFile, mkdir } = await import('node:fs/promises'); + const root = join(tempDir, 'broken-root'); + await mkdir(root); + await writeFile(join(root, 'real.txt'), 'r'); + // Points at a path that doesn't exist. + await symlink(join(tempDir, 'does-not-exist'), join(root, 'dangling')); + + const matches: string[] = []; + // Pattern must not match "dangling" (no .txt); we want the real + // file yielded and the walker to not throw on the broken symlink + // (whose stat() rejects). + for await (const m of kaos.glob(root, '**/*.txt')) { + matches.push(m); + } + expect(matches.some((p) => p.endsWith('real.txt'))).toBe(true); + }); + + it('T-C5 regression — non-symlink tree results are unchanged', async () => { + // Plain, non-symlink trees should not be filtered by cycle tracking. + await kaos.mkdir(join(tempDir, 'a', 'b', 'c'), { parents: true }); + await kaos.writeText(join(tempDir, 'r1.txt'), ''); + await kaos.writeText(join(tempDir, 'a', 'r2.txt'), ''); + await kaos.writeText(join(tempDir, 'a', 'b', 'c', 'r3.txt'), ''); + + const matches: string[] = []; + for await (const m of kaos.glob(tempDir, '**/*.txt')) { + matches.push(m); + } + expect(matches).toHaveLength(3); + const names = new Set(matches.map((p) => p.split(/[/\\]/).pop()!)); + expect(names).toEqual(new Set(['r1.txt', 'r2.txt', 'r3.txt'])); + }); + + it('T-C6 two non-cyclic symlinks to same target both traverse (path-local visited)', async () => { + const { symlink, writeFile, mkdir } = await import('node:fs/promises'); + // root/a → target, root/b → target. Both are legitimate (not + // cycles) — the user deliberately aliased the target twice. + const root = join(tempDir, 'root-aliases'); + const target = join(tempDir, 'aliased-target'); + await mkdir(root); + await mkdir(target); + await writeFile(join(target, 'shared.txt'), 'x'); + await symlink(target, join(root, 'a')); + await symlink(target, join(root, 'b')); + + const matches: string[] = []; + for await (const m of kaos.glob(root, '**/*.txt')) { + matches.push(m); + } + // Each alias branch has its own visited set copy, so both aliased paths + // surface. A shared visited set would yield only one of them. + expect(matches.some((p) => p.endsWith(`a/shared.txt`) || p.endsWith(`a\\shared.txt`))).toBe( + true, + ); + expect(matches.some((p) => p.endsWith(`b/shared.txt`) || p.endsWith(`b\\shared.txt`))).toBe( + true, + ); + }); + }); + + describe('readBytes/writeBytes', () => { + it('should round-trip binary data', async () => { + const filePath = join(tempDir, 'data.bin'); + const data = Buffer.from([0x00, 0x01, 0x02, 0xff]); + + const written = await kaos.writeBytes(filePath, data); + expect(written).toBe(4); + + const read = await kaos.readBytes(filePath); + expect(Buffer.compare(read, data)).toBe(0); + }); + }); + + describe('exec streaming', () => { + it('should run a command and stream stdout/stderr', async () => { + const code = `process.stdout.write('hello\\n'); process.stderr.write('stderr line\\n');`; + const proc = await kaos.exec(...nodeArgs(code)); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + const stdoutDone = new Promise((resolve) => { + proc.stdout.on('data', (chunk: Buffer) => { + stdoutChunks.push(chunk); + }); + proc.stdout.on('end', () => { + resolve(); + }); + }); + + const stderrDone = new Promise((resolve) => { + proc.stderr.on('data', (chunk: Buffer) => { + stderrChunks.push(chunk); + }); + proc.stderr.on('end', () => { + resolve(); + }); + }); + + const exitCode = await proc.wait(); + await stdoutDone; + await stderrDone; + + expect(exitCode).toBe(0); + expect(Buffer.concat(stdoutChunks).toString('utf-8').trim()).toBe('hello'); + expect(Buffer.concat(stderrChunks).toString('utf-8').trim()).toBe('stderr line'); + }); + }); + + describe('exec wait-before-read', () => { + it('should buffer output and allow reading after wait', async () => { + const code = `process.stdout.write('hello\\n'); process.stderr.write('stderr line\\n');`; + const proc = await kaos.exec(...nodeArgs(code)); + + const exitCode = await proc.wait(); + expect(exitCode).toBe(0); + + // Read streams after process has already exited + const stdoutData = await streamToBuffer(proc.stdout); + const stderrData = await streamToBuffer(proc.stderr); + + expect(stdoutData.toString('utf-8').trim()).toBe('hello'); + expect(stderrData.toString('utf-8').trim()).toBe('stderr line'); + }); + }); + + describe('exec non-zero exit', () => { + it('should return the correct exit code', async () => { + const proc = await kaos.exec(...nodeArgs('process.exit(7)')); + const exitCode = await proc.wait(); + expect(exitCode).toBe(7); + expect(proc.exitCode).toBe(7); + }); + }); + + describe('exec spawn failure', () => { + it('should reject when the binary does not exist', async () => { + await expect(kaos.exec('/absolutely/non-existent/binary')).rejects.toThrow(); + }); + + it('should reject exec() with no arguments', async () => { + // exec(...args) requires at least one argument (the command name). + // Cast through the loose signature so the call even compiles. + await expect((kaos.exec as () => Promise)()).rejects.toThrow( + /at least one argument/, + ); + }); + + it('should reject execWithEnv() with an empty args array', async () => { + // Mirrors the exec() guard: execWithEnv must also demand at least + // one argument (the command itself). + await expect(kaos.execWithEnv([])).rejects.toThrow(/at least one argument/); + }); + }); + + describe('exec timeout', () => { + it('dispose destroys process stdio without killing the process', async () => { + const proc = await kaos.exec(...nodeArgs('setTimeout(() => {}, 10000);')); + + await proc.dispose(); + await proc.dispose(); + + expect(proc.exitCode).toBeNull(); + expect(proc.stdout.destroyed).toBe(true); + expect(proc.stderr.destroyed).toBe(true); + + await proc.kill('SIGKILL'); + await proc.wait(); + }); + + it('should allow killing a long-running process', async () => { + const code = `setTimeout(() => {}, 10000);`; + const proc = await kaos.exec(...nodeArgs(code)); + + expect(proc.pid).toBeGreaterThan(0); + + // Use a short timeout via Promise.race + const result = await Promise.race([ + proc.wait().then((exitCode) => ({ kind: 'exited' as const, code: exitCode })), + new Promise<{ kind: 'timeout' }>((resolve) => + setTimeout(() => { + resolve({ kind: 'timeout' }); + }, 50), + ), + ]); + + expect(result.kind).toBe('timeout'); + + // Kill the process + await proc.kill('SIGKILL'); + const exitCode = await proc.wait(); + // On Unix, killed processes typically have negative exit or 137 + expect(exitCode).not.toBe(0); + }); + }); + + describe('withEnv', () => { + it('overlays every spawned process and can be updated in place', async () => { + const env = { + KAOS_BASE_ENV: 'initial', + KAOS_COLLISION_ENV: 'configured', + }; + const envKaos = kaos.withEnv(env); + const printEnv = + 'process.stdout.write(`${process.env.KAOS_BASE_ENV}|${process.env.KAOS_COLLISION_ENV}|${process.env.KAOS_CALL_ENV}`)'; + + const first = await envKaos.exec('node', '-e', printEnv); + expect(await first.wait()).toBe(0); + expect((await streamToBuffer(first.stdout)).toString('utf-8')).toBe('initial|configured|undefined'); + + const second = await envKaos.execWithEnv(['node', '-e', printEnv], { + ...(process.env as Record), + KAOS_COLLISION_ENV: 'host', + KAOS_CALL_ENV: 'call', + }); + expect(await second.wait()).toBe(0); + expect((await streamToBuffer(second.stdout)).toString('utf-8')).toBe('initial|configured|call'); + + env.KAOS_BASE_ENV = 'updated'; + const third = await envKaos.exec('node', '-e', printEnv); + expect(await third.wait()).toBe(0); + expect((await streamToBuffer(third.stdout)).toString('utf-8')).toBe('updated|configured|undefined'); + }); + }); +}); + +describe('LocalKaos instance isolation', () => { + test('instances have isolated cwds (no process.cwd pollution)', async () => { + const kaosA = await LocalKaos.create(); + const kaosB = await LocalKaos.create(); + + const tmpA = toPosix(await realpath(await mkdtemp(join(tmpdir(), 'kaos-a-')))); + const tmpB = toPosix(await realpath(await mkdtemp(join(tmpdir(), 'kaos-b-')))); + + try { + await kaosA.chdir(tmpA); + await kaosB.chdir(tmpB); + + // kaosA.chdir must not affect kaosB's cwd (no process.chdir pollution). + expect(kaosA.getcwd()).toBe(tmpA); + expect(kaosB.getcwd()).toBe(tmpB); + + // Write a file named "marker.txt" in each cwd using a relative path. + await kaosA.writeText('marker.txt', 'A'); + await kaosB.writeText('marker.txt', 'B'); + + // Read back via each kaos — each should get its own version. + expect(await kaosA.readText('marker.txt')).toBe('A'); + expect(await kaosB.readText('marker.txt')).toBe('B'); + + // exec() should also honour the instance cwd. + const procA = await kaosA.exec('node', '-e', 'process.stdout.write(process.cwd())'); + const procB = await kaosB.exec('node', '-e', 'process.stdout.write(process.cwd())'); + await procA.wait(); + await procB.wait(); + const outA = await streamToBuffer(procA.stdout); + const outB = await streamToBuffer(procB.stdout); + expect(toPosix(outA.toString('utf-8'))).toBe(tmpA); + expect(toPosix(outB.toString('utf-8'))).toBe(tmpB); + } finally { + await rm(tmpA, { recursive: true, force: true }); + await rm(tmpB, { recursive: true, force: true }); + } + }); +}); + +describe('LocalProcess.kill safety', () => { + test('kill() is safe when spawn failed (pid -1 must not signal process group)', async () => { + const kaos = await LocalKaos.create(); + + // Try to spawn a nonexistent command. Node's spawn() returns a + // ChildProcess immediately with pid=undefined; the "error" event + // arrives asynchronously. + let proc; + try { + proc = await kaos.exec('this-command-does-not-exist-xyz123'); + } catch { + // If the environment threw synchronously, there's nothing to kill. + return; + } + + // If pid is -1, kill must be a no-op and must NOT call + // process.kill(-1, ...) which would signal the entire process group. + if (proc.pid <= 0) { + await expect(proc.kill('SIGTERM')).resolves.toBeUndefined(); + } + + // Drain error event so the test runner doesn't leak unhandled errors. + try { + await proc.wait(); + } catch { + // Expected for nonexistent commands. + } + }); + + test('kill() handles already-exited process gracefully (ESRCH ignored)', async () => { + const kaos = await LocalKaos.create(); + const proc = await kaos.exec('node', '-e', 'process.exit(0)'); + await proc.wait(); + + // Calling kill after exit should not throw — ESRCH is ignored. + await expect(proc.kill('SIGTERM')).resolves.toBeUndefined(); + }); + + // ── Windows process-tree kill ─────────────────────────────────────── + // + // On Windows the Node default kills only the shell parent; grandchildren can + // leak and run beyond the two-phase kill grace window. + // + // This test boots a nested process chain (parent → child → grandchild) + // and asserts that `proc.kill('SIGTERM')` tears the whole tree down. + // The grandchild is probed via its pidfile: if the file still names a + // live pid after the kill, the tree leaked. + test.skipIf(process.platform !== 'win32')( + 'kill() terminates the grandchild on Windows (process tree)', + async () => { + const kaos = await LocalKaos.create(); + const tmp = await realpath(await mkdtemp(join(tmpdir(), 'kaos-killtree-'))); + try { + // Run the parent → child → grandchild chain from a real script file + // (see test/fixtures/killtree.cjs) with the pidfile path passed via + // argv. Inline multi-line `node -e` strings get mangled on Windows by + // Node's arg-quoting and by JS string escapes, so the pidfile was + // never written and the test read ENOENT. + const pidPath = join(tmp, 'grandchild.pid'); + const scriptPath = fileURLToPath(new URL('./fixtures/killtree.cjs', import.meta.url)); + const proc = await kaos.exec('node', scriptPath, pidPath); + const start = Date.now(); + while (Date.now() - start < 5000) { + try { + if ((await stat(pidPath)).isFile()) break; + } catch { + /* not yet */ + } + await new Promise((r) => setTimeout(r, 50)); + } + const grandchildPid = Number.parseInt((await readFile(pidPath, 'utf-8')).trim(), 10); + expect(Number.isNaN(grandchildPid)).toBe(false); + + // Kill parent — on Windows this currently leaks the grandchild + // unless `taskkill /T /F` (or equivalent) is used. + await proc.kill('SIGTERM'); + await proc.wait(); + + // Give the OS up to 2s to reap the grandchild. + const reaped = await (async (): Promise => { + for (let i = 0; i < 40; i += 1) { + try { + process.kill(grandchildPid, 0); // "is it still alive?" + } catch { + return true; // ESRCH — grandchild gone + } + await new Promise((r) => setTimeout(r, 50)); + } + return false; + })(); + + expect(reaped).toBe(true); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }, + 30_000, + ); + + // ── POSIX process-group kill ──────────────────────────────────────── + // + // Structure mirrors the Windows grandchild test above: a Node parent + // spawns a child that spawns a long-running grandchild and writes its + // pid to a file. After `proc.kill('SIGTERM')`, the grandchild must + // be gone within a generous (~2 s) reap window. + test.skipIf(process.platform === 'win32')( + 'kill() terminates the grandchild on POSIX (process tree)', + async () => { + const kaos = await LocalKaos.create(); + const tmp = await realpath(await mkdtemp(join(tmpdir(), 'kaos-killtree-posix-'))); + try { + const pidFile = join(tmp, 'grandchild.pid'); + // `exec('bash', '-c', …)` spawns bash as the direct child; the + // embedded node chain spawns a long-running grandchild under it. + // The grandchild writes its pid so we can poll liveness. + const script = ` + node -e 'const { spawn } = require("node:child_process"); + const { writeFileSync } = require("node:fs"); + const g = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"]); + writeFileSync(${JSON.stringify(pidFile)}, String(g.pid)); + setInterval(() => {}, 1000);' + `; + const proc = await kaos.exec('bash', '-c', script); + + const { stat, readFile } = await import('node:fs/promises'); + const start = Date.now(); + while (Date.now() - start < 5000) { + try { + if ((await stat(pidFile)).isFile()) break; + } catch { + /* not yet */ + } + await new Promise((r) => setTimeout(r, 50)); + } + const grandchildPid = Number.parseInt((await readFile(pidFile, 'utf-8')).trim(), 10); + expect(Number.isNaN(grandchildPid)).toBe(false); + + await proc.kill('SIGTERM'); + await proc.wait(); + + const reaped = await (async (): Promise => { + for (let i = 0; i < 40; i += 1) { + try { + process.kill(grandchildPid, 0); + } catch { + return true; + } + await new Promise((r) => setTimeout(r, 50)); + } + return false; + })(); + + expect(reaped).toBe(true); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }, + 30_000, + ); +}); + +async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk as Buffer)); + } + return Buffer.concat(chunks); +} diff --git a/packages/kaos/test/login-shell-path.test.ts b/packages/kaos/test/login-shell-path.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b2d6fbde5ef24a8d2e0a600c3b8c9802a2e82b6 --- /dev/null +++ b/packages/kaos/test/login-shell-path.test.ts @@ -0,0 +1,236 @@ +/** + * Login-shell PATH enrichment. + * + * Reproduces the "Bash tool can't find local `gh`" report: when kimi-code + * is launched from a context that skipped the user's shell profile (GUI + * launcher, non-login parent shell), `process.env.PATH` misses entries + * like `/opt/homebrew/bin`, so every command spawned by the Bash tool + * inherits the impoverished PATH. + * + * `LocalKaos.create()` must probe the user's login shell (`$SHELL -l -c + * /usr/bin/env`, falling back to the OS account's login shell when $SHELL + * is unset or blank) once and append the missing PATH entries to + * `process.env.PATH` — without reordering or overriding what is already + * there. Probe failures (no resolvable shell, hung or broken profile) + * must leave PATH untouched. + * + * The probe/merge unit tests are pure (injected deps) and run on every + * platform. The end-to-end LocalKaos suite spawns a stub shell and is + * skipped on Windows: the problem is specific to POSIX login-shell + * profiles, and the probe must not run there. + */ + +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + applyLoginShellPath, + type LoginShellPathDeps, + mergeLoginShellPath, + probeLoginShellPath, +} from '#/login-shell-path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface StubOpts { + readonly platform?: string; + readonly env?: Record; + readonly execFileResult?: string | undefined; + readonly execFileText?: LoginShellPathDeps['execFileText']; + readonly userShell?: string | undefined; +} + +/** Build a stub deps bag; records `execFileText` invocations in `calls`. */ +function stubDeps(opts: StubOpts): { deps: LoginShellPathDeps; calls: unknown[][] } { + const calls: unknown[][] = []; + return { + calls, + deps: { + platform: opts.platform ?? 'darwin', + env: opts.env ?? { SHELL: '/bin/zsh' }, + userShell: () => opts.userShell, + execFileText: + opts.execFileText ?? + (async (file, args, timeoutMs) => { + calls.push([file, args, timeoutMs]); + return opts.execFileResult; + }), + }, + }; +} + +describe('probeLoginShellPath', () => { + it('runs $SHELL -l -c /usr/bin/env and returns its PATH', async () => { + const { deps, calls } = stubDeps({ + execFileResult: 'HOME=/Users/u\nPATH=/opt/homebrew/bin:/usr/bin:/bin\nTERM=dumb\n', + }); + await expect(probeLoginShellPath(deps)).resolves.toBe('/opt/homebrew/bin:/usr/bin:/bin'); + // env must be invoked by absolute path: a bare `env` resolves through + // the inherited (possibly cwd-dependent) PATH from the workspace cwd, + // so a repo-planted `env` binary could run at session startup. + expect(calls).toEqual([['/bin/zsh', ['-l', '-c', '/usr/bin/env'], 5_000]]); + }); + + it('keeps the last PATH= line, ignoring profile noise printed earlier', async () => { + const { deps } = stubDeps({ + execFileResult: 'PATH=/from-profile-echo\nsome profile banner\nPATH=/real/bin:/usr/bin\n', + }); + await expect(probeLoginShellPath(deps)).resolves.toBe('/real/bin:/usr/bin'); + }); + + it('returns undefined on Windows without spawning anything', async () => { + const { deps, calls } = stubDeps({ platform: 'win32', execFileResult: 'PATH=/x' }); + await expect(probeLoginShellPath(deps)).resolves.toBeUndefined(); + expect(calls).toEqual([]); + }); + + it('falls back to the account login shell when SHELL is unset or blank', async () => { + // launchd/daemon launches can leave $SHELL unset or blank (the very + // contexts whose PATH is impoverished); the probe must then use the + // OS account's login shell instead of giving up. + for (const env of [{}, { SHELL: '' }, { SHELL: ' ' }]) { + const { deps, calls } = stubDeps({ + env, + userShell: '/bin/zsh', + execFileResult: 'PATH=/opt/homebrew/bin:/usr/bin\n', + }); + await expect(probeLoginShellPath(deps)).resolves.toBe('/opt/homebrew/bin:/usr/bin'); + expect(calls).toEqual([['/bin/zsh', ['-l', '-c', '/usr/bin/env'], 5_000]]); + } + }); + + it('returns undefined when SHELL is unset and no account shell is available', async () => { + for (const env of [{}, { SHELL: '' }, { SHELL: ' ' }]) { + const { deps, calls } = stubDeps({ env, execFileResult: 'PATH=/x' }); + await expect(probeLoginShellPath(deps)).resolves.toBeUndefined(); + expect(calls).toEqual([]); + } + }); + + it('returns undefined when the shell fails or times out', async () => { + const { deps } = stubDeps({ execFileResult: undefined }); + await expect(probeLoginShellPath(deps)).resolves.toBeUndefined(); + }); + + it('returns undefined when the output has no PATH line', async () => { + const { deps } = stubDeps({ execFileResult: 'HOME=/Users/u\nTERM=dumb\n' }); + await expect(probeLoginShellPath(deps)).resolves.toBeUndefined(); + }); +}); + +describe('mergeLoginShellPath', () => { + it('appends entries the current PATH lacks, keeping current priority', () => { + expect(mergeLoginShellPath('/usr/bin:/bin', '/opt/homebrew/bin:/usr/bin:/extra')).toBe( + '/usr/bin:/bin:/opt/homebrew/bin:/extra', + ); + }); + + it('returns the current PATH string verbatim when nothing is missing', () => { + // Strict identity, including empty components and duplicates the user + // already has — a no-op merge must not normalize anything. + expect(mergeLoginShellPath('/a::/b:/a:', '/b:/a')).toBe('/a::/b:/a:'); + }); + + it('preserves empty components (cwd lookup) in the current PATH while appending', () => { + // POSIX treats a leading colon, trailing colon, or double colon as + // "search the current directory"; merging must not strip that. + expect(mergeLoginShellPath(':/usr/bin', '/new')).toBe(':/usr/bin:/new'); + expect(mergeLoginShellPath('/usr/bin:', '/new')).toBe('/usr/bin::/new'); + expect(mergeLoginShellPath('/a::/b', '/c')).toBe('/a::/b:/c'); + // A set-but-empty PATH is cwd-only lookup; the empty component stays first. + expect(mergeLoginShellPath('', '/a')).toBe(':/a'); + }); + + it('handles an unset current PATH', () => { + expect(mergeLoginShellPath(undefined, '/a:/b')).toBe('/a:/b'); + }); + + it('skips empty and duplicate login-shell entries', () => { + // Empty login-shell components are never imported: appending a cwd + // lookup the user did not already have would widen their search path. + expect(mergeLoginShellPath('/a', ':/b::/a:')).toBe('/a:/b'); + }); + + it('skips relative login-shell entries', () => { + // `.` and relative components are cwd-dependent lookup with another + // spelling — LocalKaos runs commands from arbitrary workspace + // directories, so importing one would let a command name resolve from + // an untrusted project cwd. Only absolute entries may be appended. + expect(mergeLoginShellPath('/a', '.:bin:../x:/b')).toBe('/a:/b'); + }); +}); + +describe('applyLoginShellPath', () => { + it('merges the probed PATH into the env bag', async () => { + const env: Record = { SHELL: '/bin/zsh', PATH: '/usr/bin' }; + const { deps } = stubDeps({ env, execFileResult: 'PATH=/opt/homebrew/bin:/usr/bin\n' }); + await applyLoginShellPath(deps); + expect(env['PATH']).toBe('/usr/bin:/opt/homebrew/bin'); + }); + + it('leaves PATH untouched when the probe fails', async () => { + const env: Record = { SHELL: '/bin/zsh', PATH: '/usr/bin' }; + const { deps } = stubDeps({ env, execFileResult: undefined }); + await applyLoginShellPath(deps); + expect(env['PATH']).toBe('/usr/bin'); + }); + + it('does not set an unset PATH when the login shell contributes nothing', async () => { + // Pathological but possible: the login-shell PATH holds only empty + // components. Writing '' back would turn "unset" (implementation + // default search path) into "cwd-only lookup". + const env: Record = { SHELL: '/bin/zsh' }; + const { deps } = stubDeps({ env, execFileResult: 'PATH=:::\n' }); + await applyLoginShellPath(deps); + expect('PATH' in env).toBe(false); + }); +}); + +describe.skipIf(process.platform === 'win32')('LocalKaos login-shell PATH enrichment', () => { + let tempDir: string; + let originalPath: string | undefined; + let originalShell: string | undefined; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'kaos-login-path-')); + originalPath = process.env['PATH']; + originalShell = process.env['SHELL']; + }); + + afterEach(async () => { + restoreEnv('PATH', originalPath); + restoreEnv('SHELL', originalShell); + await rm(tempDir, { recursive: true, force: true }); + }); + + it('appends login-shell PATH entries missing from process.env.PATH', async () => { + const extraDir = join(tempDir, 'login-only-bin'); + const stubShell = join(tempDir, 'stub-shell.sh'); + // Stands in for the user's login shell: whatever flags it is invoked + // with, it reports an environment whose PATH carries an entry the + // kimi-code process does not have. + await writeFile(stubShell, `#!/bin/sh\necho "HOME=$HOME"\necho "PATH=${extraDir}:/usr/bin:/bin"\n`); + await chmod(stubShell, 0o755); + process.env['SHELL'] = stubShell; + + // The suite's setup.ts already ran LocalKaos.create() with the real + // $SHELL, consuming the memoised probe. Import a fresh module graph so + // this create() probes the stub shell instead. + vi.resetModules(); + const { LocalKaos } = await import('#/local'); + await LocalKaos.create(); + + const entries = (process.env['PATH'] ?? '').split(':'); + expect(entries).toContain(extraDir); + // Existing entries keep priority: the login-shell extras are appended. + expect(process.env['PATH']?.startsWith(originalPath ?? '')).toBe(true); + }); +}); + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} diff --git a/packages/kaos/test/setup.ts b/packages/kaos/test/setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac395327098893bcc0769444ff19e6b02876c5d6 --- /dev/null +++ b/packages/kaos/test/setup.ts @@ -0,0 +1,14 @@ +import { beforeEach } from 'vitest'; + +import { setCurrentKaos } from '#/current'; +import { LocalKaos } from '#/local'; + +const kaos = await LocalKaos.create(); + +// Bind synchronously in `beforeEach`. `enterWith` mutates the running async +// context; vitest's test body is awaited next from the same chain, so it +// inherits the binding. An `await` inside `beforeEach` would push the bind +// into a child context that the test body wouldn't see. +beforeEach(() => { + setCurrentKaos(kaos); +}); diff --git a/packages/kaos/test/shell-path-bridge.test.ts b/packages/kaos/test/shell-path-bridge.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1ae756dde2abc0abeb9635eb5207662d42fa220 --- /dev/null +++ b/packages/kaos/test/shell-path-bridge.test.ts @@ -0,0 +1,267 @@ +/** + * Shell path bridge — drives `createShellPathBridge` with injected + * `execFileSync` / `isFile` fakes (no real processes): lexical drive forms, + * pass-through tiers, cygpath resolution and caching, `toShellPath`. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { + createShellPathBridge, + type ShellPathBridgeDeps, + type ShellPathBridgeEnv, +} from '#/shell-path-bridge'; + +const WINDOWS_ENV: ShellPathBridgeEnv = { + osKind: 'Windows', + shellName: 'bash', + shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe', +}; + +const POSIX_ENV: ShellPathBridgeEnv = { + osKind: 'Linux', + shellName: 'bash', + shellPath: '/bin/bash', +}; + +// cygpath.exe candidates probed for `C:\Program Files\Git\bin\bash.exe`. +const BIN_CYGPATH = 'C:\\Program Files\\Git\\bin\\cygpath.exe'; +const USR_BIN_CYGPATH = 'C:\\Program Files\\Git\\usr\\bin\\cygpath.exe'; + +interface StubOpts { + readonly existingPaths?: readonly string[]; + readonly execFileResults?: Readonly>; + readonly execFileSync?: ShellPathBridgeDeps['execFileSync']; +} + +function stubDeps(opts: StubOpts = {}) { + const existing = new Set(opts.existingPaths ?? []); + const execFileSync = vi.fn( + opts.execFileSync ?? + ((file: string, args: readonly string[]): string => { + const result = opts.execFileResults?.[[file, ...args].join(' ')]; + if (result === undefined) throw new Error(`unexpected execFileSync: ${file}`); + return result; + }), + ); + const deps: ShellPathBridgeDeps = { + execFileSync, + isFile: (path: string) => existing.has(path), + }; + return { deps, execFileSync }; +} + +function cygpathKey(firstSegment: string): string { + return `${USR_BIN_CYGPATH} -w -C UTF8 -- /${firstSegment}`; +} + +describe('fromShellPath lexical drive forms', () => { + const cases: ReadonlyArray = [ + ['/c:/Users/foo', 'C:/Users/foo'], + ['/c:', 'C:/'], + ['/cygdrive/c/Users/foo', 'C:/Users/foo'], + ['/cygdrive/d', 'D:/'], + ['/c/Users/foo', 'C:/Users/foo'], + ['/C/Users/foo', 'C:/Users/foo'], + ['/c/', 'C:/'], + ['/c', 'C:/'], + ]; + + for (const [input, expected] of cases) { + it(`rewrites "${input}"`, () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + expect(bridge.fromShellPath(input)).toBe(expected); + expect(execFileSync).not.toHaveBeenCalled(); + }); + } +}); + +describe('fromShellPath pass-through', () => { + it.each(['/dev/null', '/dev/pty0', '/proc/self/status', '/sys/kernel'])( + 'leaves virtual-fs path %s unchanged', + (input) => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + expect(bridge.fromShellPath(input)).toBe(input); + expect(execFileSync).not.toHaveBeenCalled(); + }, + ); + + it.each([ + '/', + '//server/share', + '//server/share/file.txt', + 'relative/path', + 'relative\\path', + 'file.txt', + 'C:\\Users\\foo', + 'C:/Users/foo', + '~/Documents', + ])('leaves %s unchanged without consulting cygpath', (input) => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + expect(bridge.fromShellPath(input)).toBe(input); + expect(execFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('fromShellPath cygpath resolution', () => { + it('resolves a root-relative path through cygpath and caches per first segment', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileResults: { + [cygpathKey('tmp')]: 'C:\\Users\\me\\AppData\\Local\\Temp\\\n', + }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/tmp/scratch/a.txt')).toBe( + 'C:/Users/me/AppData/Local/Temp/scratch/a.txt', + ); + expect(bridge.fromShellPath('/tmp/other')).toBe('C:/Users/me/AppData/Local/Temp/other'); + expect(bridge.fromShellPath('/tmp')).toBe('C:/Users/me/AppData/Local/Temp'); + expect(execFileSync).toHaveBeenCalledTimes(1); + expect(execFileSync).toHaveBeenCalledWith(USR_BIN_CYGPATH, [ + '-w', + '-C', + 'UTF8', + '--', + '/tmp', + ]); + }); + + it('folds dot segments before resolving the mount segment', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileResults: { + [cygpathKey('tmp')]: 'C:\\Users\\me\\AppData\\Local\\Temp\n', + [cygpathKey('home')]: 'C:\\Program Files\\Git\\home\n', + }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/./tmp/note.txt')).toBe( + 'C:/Users/me/AppData/Local/Temp/note.txt', + ); + expect(bridge.fromShellPath('/../tmp/note.txt')).toBe( + 'C:/Users/me/AppData/Local/Temp/note.txt', + ); + expect(bridge.fromShellPath('/tmp/../home/x.txt')).toBe('C:/Program Files/Git/home/x.txt'); + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + + it('folds dot segments before lexical drive translation', () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/./c/Projects')).toBe('C:/Projects'); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it.each(['/.', '/..'])('normalizes %s to / without consulting cygpath', (input) => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath(input)).toBe('/'); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it('resolves a drive-root mount and keeps it absolute', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileResults: { [cygpathKey('work')]: 'D:\\\n' }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/work/app')).toBe('D:/app'); + expect(bridge.fromShellPath('/work')).toBe('D:/'); + expect(execFileSync).toHaveBeenCalledTimes(1); + }); + + it('prefers cygpath.exe next to bash.exe when present', () => { + const key = `${BIN_CYGPATH} -w -C UTF8 -- /home`; + const { deps, execFileSync } = stubDeps({ + existingPaths: [BIN_CYGPATH, USR_BIN_CYGPATH], + execFileResults: { [key]: 'C:\\Users\n' }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/home/u/f.txt')).toBe('C:/Users/u/f.txt'); + expect(execFileSync).toHaveBeenCalledTimes(1); + expect(execFileSync).toHaveBeenCalledWith(BIN_CYGPATH, ['-w', '-C', 'UTF8', '--', '/home']); + }); + + it('passes through and retries on the next access when cygpath fails', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileSync: () => { + throw new Error('cygpath exited 1'); + }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); + expect(bridge.fromShellPath('/tmp/y')).toBe('/tmp/y'); + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + + it('passes through and retries when cygpath output is not an absolute win32 path', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileResults: { [cygpathKey('tmp')]: 'not a win32 path\n' }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); + expect(bridge.fromShellPath('/tmp/y')).toBe('/tmp/y'); + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + + it('passes through without spawning when cygpath.exe is missing', () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); + expect(bridge.fromShellPath('/home/u')).toBe('/home/u'); + expect(execFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('identity outside win32 bash', () => { + it('is identity on posix', () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(POSIX_ENV, deps); + expect(bridge.fromShellPath('/c/Users/foo')).toBe('/c/Users/foo'); + expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); + expect(bridge.toShellPath('C:\\Users\\foo')).toBe('C:\\Users\\foo'); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it('is identity on Windows without bash', () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge( + { osKind: 'Windows', shellName: 'sh', shellPath: 'C:\\sh.exe' }, + deps, + ); + expect(bridge.fromShellPath('/c/Users/foo')).toBe('/c/Users/foo'); + expect(bridge.toShellPath('C:\\Users\\foo')).toBe('C:\\Users\\foo'); + expect(execFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('toShellPath', () => { + it.each([ + ['C:\\Users\\foo', '/c/Users/foo'], + ['C:/Users/foo', '/c/Users/foo'], + ['C:\\', '/c/'], + ['D:\\Projects', '/d/Projects'], + ['\\\\server\\share\\dir', '//server/share/dir'], + ['relative\\path', 'relative/path'], + ['already/posix', 'already/posix'], + ])('maps %s → %s', (input, expected) => { + const { deps } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + expect(bridge.toShellPath(input)).toBe(expected); + }); +}); diff --git a/packages/kaos/test/shell.test.ts b/packages/kaos/test/shell.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..35cbd33ab9118d986ec67ddbb5bb17c416285d1f --- /dev/null +++ b/packages/kaos/test/shell.test.ts @@ -0,0 +1,241 @@ +import { mkdtemp, rm, stat as fsStat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { Kaos } from '#/kaos'; +import { LocalKaos } from '#/local'; +import type { KaosProcess } from '#/process'; + +/** + * Helper to run a shell command via /bin/sh -c and collect stdout/stderr/exitCode. + * Since the new Kaos.exec(...args) doesn't take options, timeout is implemented + * by killing the process after the given duration. + */ +async function runSh( + kaos: Kaos, + command: string, + options?: { timeout?: number; stdinData?: string }, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc: KaosProcess = await kaos.exec('/bin/sh', '-c', command); + + // Set up timeout if requested + let timedOut = false; + let timer: ReturnType | undefined; + if (options?.timeout !== undefined) { + timer = setTimeout(() => { + timedOut = true; + void proc.kill('SIGKILL'); + }, options.timeout); + } + + // If stdinData is provided, write it and close stdin + if (options?.stdinData !== undefined) { + proc.stdin.write(options.stdinData); + proc.stdin.end(); + } else { + proc.stdin.end(); + } + + // Collect stdout and stderr concurrently with waiting for process exit + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + const stdoutDone = new Promise((resolve) => { + proc.stdout.on('data', (chunk: Buffer) => { + stdoutChunks.push(chunk); + }); + proc.stdout.on('end', () => { + resolve(); + }); + }); + + const stderrDone = new Promise((resolve) => { + proc.stderr.on('data', (chunk: Buffer) => { + stderrChunks.push(chunk); + }); + proc.stderr.on('end', () => { + resolve(); + }); + }); + + const exitCode = await proc.wait(); + await stdoutDone; + await stderrDone; + + if (timer !== undefined) { + clearTimeout(timer); + } + + return { + stdout: Buffer.concat(stdoutChunks).toString('utf-8'), + stderr: Buffer.concat(stderrChunks).toString('utf-8'), + exitCode: timedOut ? -1 : exitCode, + }; +} + +describe.skipIf(process.platform === 'win32')('LocalKaos shell operations', () => { + let kaos: Kaos; + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kaos-shell-')); + kaos = await LocalKaos.create(); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + // NOTE: These tests mirror Python test_local_kaos_sh.py one-for-one. + // Python pins stderr to '' on every non-error case and uses inline_snapshot + // for exact stdout comparisons — the TS side now matches that strength so + // any future drift (e.g. a rogue newline or a leaked warning) is caught. + + it('should run a simple command', async () => { + const result = await runSh(kaos, "echo 'Hello World'"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('Hello World\n'); + expect(result.stderr).toBe(''); + }); + + it('should handle command with error', async () => { + const result = await runSh(kaos, 'ls /nonexistent/directory'); + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('No such file or directory'); + }); + + it('should support command chaining with &&', async () => { + const result = await runSh(kaos, "echo 'First' && echo 'Second'"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('First\nSecond\n'); + expect(result.stderr).toBe(''); + }); + + it('should support command pipe', async () => { + const result = await runSh(kaos, "echo 'Hello World' | wc -w"); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('2'); + expect(result.stderr).toBe(''); + }); + + it('should handle command with timeout (completes before timeout)', async () => { + // Python asserts stdout='' for `sleep 0.1` so pin that exactly — if the + // helper ever introduces its own chatter we want to hear about it. + const result = await runSh(kaos, 'sleep 0.1', { timeout: 5000 }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + }); + + it('should handle timeout expiration', async () => { + // Python raises TimeoutError from its helper; the TS helper surfaces the + // same condition as exitCode === -1 after force-killing the process. + // The contract pinned here is "super-short timeout kills a long sleep". + const result = await runSh(kaos, 'sleep 60', { timeout: 100 }); + expect(result.exitCode).toBe(-1); + }); + + it('should pass environment variables to shell', async () => { + const result = await runSh(kaos, 'TEST_VAR=\'test_value\'; export TEST_VAR; echo "$TEST_VAR"'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('test_value\n'); + expect(result.stderr).toBe(''); + }); + + it('should perform file operations', async () => { + // Mirror Python test_file_operations: two separate kaos.exec calls so + // that the "file lands on disk between calls" invariant is actually + // exercised, plus explicit stat() check. + const filePath = join(tmpDir, 'test_file.txt'); + + const write = await runSh(kaos, `echo 'Test content' > "${filePath}"`); + expect(write.exitCode).toBe(0); + expect(write.stdout).toBe(''); + expect(write.stderr).toBe(''); + + const statInfo = await fsStat(filePath); + expect(statInfo.isFile()).toBe(true); + + const read = await runSh(kaos, `cat "${filePath}"`); + expect(read.exitCode).toBe(0); + expect(read.stdout).toBe('Test content\n'); + expect(read.stderr).toBe(''); + }); + + it('should handle stdin data', async () => { + // Mirror Python test_command_reads_stdin: use the shell `read` builtin, + // which requires a newline-terminated input. Previously the TS version + // was a trivial `cat` passthrough that did not exercise `read`. + const result = await runSh(kaos, 'read value; printf \'%s\\n\' "$value"', { + stdinData: 'from stdin\n', + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('from stdin\n'); + expect(result.stderr).toBe(''); + }); + + it('should execute commands sequentially with ;', async () => { + const result = await runSh(kaos, "echo 'One'; echo 'Two'"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('One\nTwo\n'); + expect(result.stderr).toBe(''); + }); + + it('should support conditional execution with ||', async () => { + const result = await runSh(kaos, "false || echo 'Success'"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('Success\n'); + expect(result.stderr).toBe(''); + }); + + it('should support multiple pipes', async () => { + const result = await runSh(kaos, "printf '1\\n2\\n3\\n' | grep '2' | wc -l"); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('1'); + expect(result.stderr).toBe(''); + }); + + it('should handle text processing with sed', async () => { + const result = await runSh(kaos, "echo 'apple banana cherry' | sed 's/banana/orange/'"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('apple orange cherry\n'); + expect(result.stderr).toBe(''); + }); + + it('should support command substitution', async () => { + const result = await runSh(kaos, 'echo "Result: $(echo hello)"'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('Result: hello\n'); + expect(result.stderr).toBe(''); + }); + + it('should support arithmetic substitution', async () => { + const result = await runSh(kaos, 'echo "Answer: $((2 + 2))"'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('Answer: 4\n'); + expect(result.stderr).toBe(''); + }); + + it('should handle very long output', async () => { + const result = await runSh(kaos, 'seq 1 100 | head -50'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('1'); + expect(result.stdout).toContain('50'); + expect(result.stdout).not.toContain('51'); + expect(result.stderr).toBe(''); + }); + + it('should read multiple lines from stdin', async () => { + const result = await runSh( + kaos, + 'count=0; while IFS= read -r _; do count=$((count+1)); done; printf \'%s\\n\' "$count"', + { stdinData: 'alpha\nbeta\ngamma\n' }, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('3'); + expect(result.stderr).toBe(''); + }); +}); diff --git a/packages/kaos/test/spawn-options.test.ts b/packages/kaos/test/spawn-options.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..32137228028ddce16d05f19e2e7ffbae7a7fc6bd --- /dev/null +++ b/packages/kaos/test/spawn-options.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { buildLocalSpawnOptions } from '#/local'; + +// Regression coverage for the "every command pops an empty console window on +// Windows" bug. `child_process.spawn` defaults `windowsHide` to `false`; on +// Windows that makes Node allocate a *visible* console for each child process +// the agent spawns through `BashTool` → `LocalKaos.exec`/`execWithEnv`. The +// fix is to pass `windowsHide: true`. The flag is only observable on Windows, +// so we assert the spawn options builder directly. + +describe('buildLocalSpawnOptions (Windows console-window regression)', () => { + it('sets windowsHide:true on Windows so commands do not flash a console', () => { + const options = buildLocalSpawnOptions(true, 'C:\\repo', undefined); + expect(options.windowsHide).toBe(true); + }); + + it('sets windowsHide:true on POSIX too (it is ignored there, kept unconditional)', () => { + const options = buildLocalSpawnOptions(false, '/repo', undefined); + expect(options.windowsHide).toBe(true); + }); + + it('keeps detached platform-conditional (POSIX tree-kill vs Windows taskkill /T)', () => { + expect(buildLocalSpawnOptions(true, 'C:\\repo', undefined).detached).toBe(false); + expect(buildLocalSpawnOptions(false, '/repo', undefined).detached).toBe(true); + }); + + it('pipes stdin/stdout/stderr and forwards cwd + env', () => { + const env = { FOO: 'bar' }; + const options = buildLocalSpawnOptions(false, '/repo', env); + expect(options.stdio).toEqual(['pipe', 'pipe', 'pipe']); + expect(options.cwd).toBe('/repo'); + expect(options.env).toBe(env); + }); +}); diff --git a/packages/kaos/test/ssh-create.test.ts b/packages/kaos/test/ssh-create.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..72135eaf39e37ff18243b5d92902a3649d395d23 --- /dev/null +++ b/packages/kaos/test/ssh-create.test.ts @@ -0,0 +1,375 @@ +import { EventEmitter } from 'node:events'; + +import type { AnyAuthMethod, ConnectConfig, SFTPWrapper, Stats as SFTPStats } from 'ssh2'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { SSHKaos as SSHKaosType } from '#/ssh'; + +interface CreateHarnessState { + attemptedKeys: string[]; + connectConfigs: ConnectConfig[]; + endCalls: number; + readFileCalls: string[]; +} + +interface CreateHarnessOptions { + onConnect?: (client: EventEmitter, config: ConnectConfig, state: CreateHarnessState) => void; + readFileValues?: Record; + sftp?: SFTPWrapper; + sftpError?: Error; +} + +function makeStats(isDirectory: boolean): SFTPStats { + return { + mode: isDirectory ? 0o040755 : 0o100644, + uid: 1000, + gid: 1000, + size: 0, + atime: 0, + mtime: 0, + isDirectory: () => isDirectory, + isFile: () => !isDirectory, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isSymbolicLink: () => false, + isFIFO: () => false, + isSocket: () => false, + }; +} + +function createSuccessfulSftp(): SFTPWrapper { + return { + realpath(path: string, callback: (err: Error | undefined, absPath: string) => void): void { + if (path === '.') { + callback(undefined, '/home/tester'); + return; + } + callback(undefined, path); + }, + stat(path: string, callback: (err: Error | undefined, stats: SFTPStats) => void): void { + callback(undefined, makeStats(path !== '/home/tester/file.txt')); + }, + end(): void { + // no-op + }, + } as SFTPWrapper; +} + +async function loadSSHModule(options: CreateHarnessOptions = {}): Promise<{ + SSHKaos: typeof SSHKaosType; + state: CreateHarnessState; +}> { + vi.resetModules(); + + const state: CreateHarnessState = { + attemptedKeys: [], + connectConfigs: [], + endCalls: 0, + readFileCalls: [], + }; + + class MockClient extends EventEmitter { + connect(config: ConnectConfig): void { + state.connectConfigs.push(config); + if (options.onConnect) { + options.onConnect(this, config, state); + return; + } + queueMicrotask(() => { + this.emit('ready'); + }); + } + + end(): void { + state.endCalls += 1; + queueMicrotask(() => { + this.emit('close'); + }); + } + + sftp(callback: (err?: Error, sftp?: SFTPWrapper) => void): void { + if (options.sftpError) { + callback(options.sftpError); + return; + } + callback(undefined, options.sftp ?? createSuccessfulSftp()); + } + } + + vi.doMock('ssh2', () => ({ + Client: MockClient, + utils: { + sftp: { + STATUS_CODE: { + NO_SUCH_FILE: 2, + PERMISSION_DENIED: 3, + FAILURE: 4, + NO_CONNECTION: 6, + CONNECTION_LOST: 7, + OP_UNSUPPORTED: 8, + }, + }, + }, + })); + + vi.doMock('node:fs/promises', () => ({ + readFile: vi.fn(async (path: string) => { + state.readFileCalls.push(path); + const value = options.readFileValues?.[path]; + if (value === undefined) { + throw new Error(`Unexpected readFile(${path})`); + } + return value; + }), + })); + + const { SSHKaos } = await import('#/ssh'); + return { SSHKaos, state }; +} + +afterEach(() => { + vi.doUnmock('ssh2'); + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe('SSHKaos.create()', () => { + it('initializes cwd equal to gethome() when no cwd option is passed', async () => { + // Pins the Python test_ssh_kaos.py::test_pathclass_home_and_cwd invariant: + // on a fresh SSH connection without an explicit cwd, `getcwd()` must equal + // `gethome()`. The smoke-level SSH suite can't cover this because its + // beforeEach chdirs into a per-test remote dir; this mock harness lets us + // check the invariant without a live SSH server. + const { SSHKaos } = await loadSSHModule(); + + const ssh = await SSHKaos.create({ + host: 'example.com', + username: 'tester', + }); + + expect(ssh.pathClass()).toBe('posix'); + expect(ssh.gethome()).toBe('/home/tester'); + expect(ssh.getcwd()).toBe('/home/tester'); + expect(ssh.getcwd()).toBe(ssh.gethome()); + }); + + it('rejects a cwd that resolves to a regular file', async () => { + const sftp = { + realpath(path: string, callback: (err: Error | undefined, absPath: string) => void): void { + if (path === '.') { + callback(undefined, '/home/tester'); + return; + } + callback(undefined, '/home/tester/file.txt'); + }, + stat(_path: string, callback: (err: Error | undefined, stats: SFTPStats) => void): void { + callback(undefined, makeStats(false)); + }, + end(): void { + // no-op + }, + } as SFTPWrapper; + + const { SSHKaos, state } = await loadSSHModule({ sftp }); + + const error = await SSHKaos.create({ + host: 'example.com', + username: 'tester', + cwd: 'file.txt', + }).catch((error: unknown) => error); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).name).toBe('KaosValueError'); + expect((error as Error).message).toMatch(/not a directory/); + expect(state.endCalls).toBe(1); + }); + + it('tries multiple private keys via authHandler until one succeeds', async () => { + const { SSHKaos, state } = await loadSSHModule({ + readFileValues: { + '/keys/second': 'second-key', + }, + onConnect(client, config, harnessState) { + const authHandler = config.authHandler; + if (typeof authHandler !== 'function') { + client.emit('error', new Error('missing authHandler')); + return; + } + + const requestNextKey = (): void => { + authHandler([], false, (auth: string | AnyAuthMethod | false) => { + expect(auth).not.toBe(false); + expect(typeof auth).toBe('object'); + if (auth === false || typeof auth !== 'object') { + client.emit('error', new Error('authHandler returned no auth method')); + return; + } + if (auth.type !== 'publickey') { + client.emit('error', new Error(`unexpected auth type: ${auth.type}`)); + return; + } + const key = + typeof auth.key === 'string' + ? auth.key + : Buffer.isBuffer(auth.key) + ? auth.key.toString('utf-8') + : JSON.stringify(auth.key); + harnessState.attemptedKeys.push(key); + if (key === 'first-key') { + requestNextKey(); + return; + } + queueMicrotask(() => { + client.emit('ready'); + }); + }); + }; + + requestNextKey(); + }, + }); + + const ssh = await SSHKaos.create({ + host: 'example.com', + username: 'tester', + keyContents: ['first-key'], + keyPaths: ['/keys/second'], + }); + + expect(ssh.getcwd()).toBe('/home/tester'); + expect(state.readFileCalls).toEqual(['/keys/second']); + expect(state.attemptedKeys).toEqual(['first-key', 'second-key']); + expect(state.connectConfigs[0]?.authHandler).toBeTypeOf('function'); + expect(state.connectConfigs[0]?.privateKey).toBeUndefined(); + }); + + it('ends the client when opening SFTP fails after connect succeeds', async () => { + const { SSHKaos, state } = await loadSSHModule({ + sftpError: new Error('sftp open failed'), + }); + + await expect( + SSHKaos.create({ + host: 'example.com', + username: 'tester', + }), + ).rejects.toThrow(/sftp open failed/); + expect(state.endCalls).toBe(1); + }); + + it('merges extraOptions into the ssh2 ConnectConfig', async () => { + const { SSHKaos, state } = await loadSSHModule(); + + await SSHKaos.create({ + host: 'example.com', + username: 'tester', + extraOptions: { + keepaliveInterval: 10_000, + readyTimeout: 5_000, + // Cast to the ssh2 Algorithms shape — deliberately narrow for the test + // harness so we can assert pass-through without pulling the full ssh2 + // type graph. + algorithms: { cipher: ['aes256-ctr'] } as never, + }, + }); + + const cfg = state.connectConfigs[0]; + expect(cfg).toBeDefined(); + expect(cfg?.keepaliveInterval).toBe(10_000); + expect(cfg?.readyTimeout).toBe(5_000); + expect(cfg?.algorithms).toEqual({ cipher: ['aes256-ctr'] }); + // Managed fields must still come from top-level options. + expect(cfg?.host).toBe('example.com'); + expect(cfg?.username).toBe('tester'); + }); + + it('managed fields override extraOptions when both are specified', async () => { + const { SSHKaos, state } = await loadSSHModule(); + + await SSHKaos.create({ + host: 'managed.example.com', + username: 'managed', + extraOptions: { + // Malicious / accidental attempts to override managed fields must + // NOT win — the top-level values take precedence. + host: 'attacker.example.com', + username: 'attacker', + port: 1234, + } as never, + }); + + const cfg = state.connectConfigs[0]; + expect(cfg?.host).toBe('managed.example.com'); + expect(cfg?.username).toBe('managed'); + expect(cfg?.port).toBe(22); // default, not the 1234 from extraOptions + }); + + it('forwards a password-only connection without building an authHandler', async () => { + // Password auth without any private keys must wire ssh2 ConnectConfig.password + // directly rather than constructing an authHandler (the handler is only + // needed when we're rotating through multiple private keys). + const { SSHKaos, state } = await loadSSHModule(); + + const ssh = await SSHKaos.create({ + host: 'example.com', + username: 'tester', + password: 'hunter2', + }); + + const cfg = state.connectConfigs[0]; + expect(cfg?.password).toBe('hunter2'); + expect(cfg?.authHandler).toBeUndefined(); + expect(ssh.getcwd()).toBe('/home/tester'); + }); + + it('queues password after private keys when both are provided', async () => { + // When the user supplies both keyContents and a password, buildAuthHandler + // should try every private key first, then fall back to password auth. + // We observe this by walking the handler to exhaustion and recording + // which auth entries it yields. + const yielded: string[] = []; + const { SSHKaos } = await loadSSHModule({ + onConnect(client, config) { + const handler = config.authHandler; + if (typeof handler !== 'function') { + client.emit('error', new Error('missing authHandler')); + return; + } + + // Drain the handler — it yields one auth method per call until it + // runs out, at which point it must emit `false` to signal "no more + // auth methods" (but we stop as soon as we see the password entry). + const pump = (): void => { + handler([], false, (auth: string | AnyAuthMethod | false) => { + if (auth === false) { + client.emit('error', new Error('unexpected end of auth queue')); + return; + } + if (typeof auth !== 'object') { + client.emit('error', new Error(`unexpected auth: ${auth}`)); + return; + } + yielded.push(auth.type); + if (auth.type === 'password') { + queueMicrotask(() => { + client.emit('ready'); + }); + return; + } + pump(); + }); + }; + pump(); + }, + }); + + await SSHKaos.create({ + host: 'example.com', + username: 'tester', + keyContents: ['only-key'], + password: 'hunter2', + }); + + expect(yielded).toEqual(['publickey', 'password']); + }); +}); diff --git a/packages/kaos/test/ssh-process.test.ts b/packages/kaos/test/ssh-process.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..010ecf49fa4067aee9b6a44bd61b82c02a802c0b --- /dev/null +++ b/packages/kaos/test/ssh-process.test.ts @@ -0,0 +1,223 @@ +import { spawn } from 'node:child_process'; +import { PassThrough } from 'node:stream'; + +import { describe, expect, test } from 'vitest'; + +import { SSHProcess } from '#/ssh'; + +/** + * Build a minimal fake ssh2 ClientChannel that satisfies SSHProcess's needs: + * + * - behaves as a Readable (for stdout) via PassThrough + * - exposes .stderr as a Readable via a second PassThrough + * - records .signal() and .close() calls + * - emits 'close' / 'exit' on-demand + */ +function createFakeChannel(): { + channel: unknown; + signalCalls: string[]; + closeCalls: number; + emitClose: () => void; + emitExit: (code: number) => void; +} { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const signalCalls: string[] = []; + let closeCalls = 0; + + // Listeners registered via channel.on(...) + const listeners = new Map void>>(); + + const channel = Object.assign(stdout, { + stderr, + signal(name: string): void { + signalCalls.push(name); + }, + close(): void { + closeCalls++; + }, + // Override .on to capture lifecycle listeners ('close', 'exit') while + // still letting the underlying Readable receive 'data'/'end'/'error'. + on(event: string, cb: (...args: unknown[]) => void): unknown { + if (event === 'close' || event === 'exit') { + let arr = listeners.get(event); + if (!arr) { + arr = []; + listeners.set(event, arr); + } + arr.push(cb); + return channel; + } + return PassThrough.prototype.on.call(stdout, event, cb); + }, + }); + + function emit(event: string, ...args: unknown[]): void { + const arr = listeners.get(event); + if (!arr) return; + for (const cb of arr) { + cb(...args); + } + } + + return { + channel, + signalCalls, + get closeCalls() { + return closeCalls; + }, + emitClose: () => { + emit('close'); + }, + emitExit: (code: number) => { + emit('exit', code); + }, + }; +} + +function createChildBackedChannel(): { channel: unknown } { + const child = spawn(process.execPath, [ + '-e', + [ + "process.on('SIGTERM', () => {", + " console.log('cleanup done');", + ' process.exit(42);', + '});', + "console.log('ready');", + 'setInterval(() => {}, 1000);', + ].join('\n'), + ]); + + const stdout = new PassThrough(); + child.stdout.pipe(stdout); + + const channel = Object.assign(stdout, { + stderr: child.stderr, + signal(name: string): void { + child.kill(`SIG${name}` as NodeJS.Signals); + }, + close(): void { + child.kill('SIGTERM'); + }, + on(event: string, listener: (...args: unknown[]) => void): unknown { + if (event === 'exit' || event === 'close') { + child.on(event, listener as (...args: [number | null]) => void); + return channel; + } + return PassThrough.prototype.on.call(stdout, event, listener); + }, + }); + + return { channel }; +} + +describe('SSHProcess.kill()', () => { + test('kill("SIGTERM") sends "TERM" to channel.signal (strips SIG prefix)', async () => { + const fake = createFakeChannel(); + const proc = new SSHProcess(fake.channel as never); + + await proc.kill('SIGTERM'); + + expect(fake.signalCalls).toEqual(['TERM']); + expect(fake.closeCalls).toBe(0); + }); + + test('kill("SIGINT") sends "INT"', async () => { + const fake = createFakeChannel(); + const proc = new SSHProcess(fake.channel as never); + + await proc.kill('SIGINT'); + + expect(fake.signalCalls).toEqual(['INT']); + }); + + test('kill("SIGKILL") sends "KILL"', async () => { + const fake = createFakeChannel(); + const proc = new SSHProcess(fake.channel as never); + + await proc.kill('SIGKILL'); + + expect(fake.signalCalls).toEqual(['KILL']); + }); + + test('kill() with no signal defaults to "TERM"', async () => { + const fake = createFakeChannel(); + const proc = new SSHProcess(fake.channel as never); + + await proc.kill(); + + expect(fake.signalCalls).toEqual(['TERM']); + }); + + test('kill() with a signal that does not start with "SIG" is passed through unchanged', async () => { + const fake = createFakeChannel(); + const proc = new SSHProcess(fake.channel as never); + + // Cast through unknown because NodeJS.Signals is a string-literal type. + await proc.kill('USR1' as unknown as NodeJS.Signals); + + expect(fake.signalCalls).toEqual(['USR1']); + }); + + test('wait() resolves with the exit code emitted before close', async () => { + const fake = createFakeChannel(); + const proc = new SSHProcess(fake.channel as never); + + // Fire exit first, then close. + fake.emitExit(42); + fake.emitClose(); + + const code = await proc.wait(); + expect(code).toBe(42); + expect(proc.exitCode).toBe(42); + }); + + test('wait() resolves with 1 (abnormal) when close arrives without exit', async () => { + const fake = createFakeChannel(); + const proc = new SSHProcess(fake.channel as never); + + fake.emitClose(); + + const code = await proc.wait(); + expect(code).toBe(1); + expect(proc.exitCode).toBe(1); + }); + + test.skipIf(process.platform === 'win32')('kill(SIGTERM) preserves cleanup output and the real exit status', async () => { + const { channel } = createChildBackedChannel(); + const proc = new SSHProcess(channel as never); + const stdoutChunks: Buffer[] = []; + + proc.stdout.on('data', (chunk: Buffer) => { + stdoutChunks.push(Buffer.from(chunk)); + }); + const stdoutEnded = new Promise((resolve) => { + proc.stdout.on('end', () => { + resolve(); + }); + }); + + const firstChunk = await new Promise((resolve) => { + proc.stdout.once('data', (chunk: Buffer) => { + resolve(chunk); + }); + }); + expect(firstChunk.toString()).toContain('ready'); + + await proc.kill('SIGTERM'); + + const exitCode = await proc.wait(); + await Promise.race([ + stdoutEnded, + new Promise((resolve) => { + setTimeout(resolve, 250); + }), + ]); + const stdout = Buffer.concat(stdoutChunks).toString('utf-8'); + + expect(exitCode).toBe(42); + expect(proc.exitCode).toBe(42); + expect(stdout).toContain('ready'); + expect(stdout).toContain('cleanup done'); + }, 10000); +}); diff --git a/packages/kaos/test/ssh.test.ts b/packages/kaos/test/ssh.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e506e3f1f79eb9d0475aa0f383fa099691005bc8 --- /dev/null +++ b/packages/kaos/test/ssh.test.ts @@ -0,0 +1,1472 @@ +import { EventEmitter } from 'node:events'; + +import { KaosFileExistsError, KaosValueError } from '#/errors'; +import { + KaosConnectionError, + KaosFileNotFoundError, + KaosPermissionError, + KaosSSHError, + SSHKaos, +} from '#/ssh'; +import type { StatResult } from '#/types'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test } from 'vitest'; + +// Environment variable configuration for SSH connection +const SSH_SMOKE = process.env['KAOS_SSH_SMOKE'] === '1'; +const SSH_HOST = process.env['KAOS_SSH_HOST'] ?? '127.0.0.1'; +const SSH_PORT = Number(process.env['KAOS_SSH_PORT'] ?? '22'); +const SSH_USERNAME = process.env['KAOS_SSH_USERNAME']; +const SSH_PASSWORD = process.env['KAOS_SSH_PASSWORD']; +const SSH_KEY_PATHS = process.env['KAOS_SSH_KEY_PATHS']?.split(',').filter(Boolean); +const SSH_KEY_CONTENTS = process.env['KAOS_SSH_KEY_CONTENTS']?.split('|||').filter(Boolean); + +// S_IFMT mask and file type constants +const S_IFMT = 0o170000; +const S_IFDIR = 0o040000; +const S_IFREG = 0o100000; + +async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk as Buffer)); + } + return Buffer.concat(chunks); +} + +// Explicit opt-in smoke: set KAOS_SSH_SMOKE=1 plus SSH credentials. +describe.skipIf(process.platform === 'win32' || !SSH_SMOKE)('SSHKaos smoke', () => { + let sshKaos: SSHKaos; + let remoteBase = ''; + + beforeAll(async () => { + if (SSH_USERNAME === undefined) { + throw new Error('KAOS_SSH_SMOKE=1 requires KAOS_SSH_USERNAME'); + } + + // Dynamic import to avoid compilation errors when ssh2 is not available + const { SSHKaos: SSHKaosClass } = await import('#/ssh'); + sshKaos = await SSHKaosClass.create({ + host: SSH_HOST, + port: SSH_PORT, + username: SSH_USERNAME, + password: SSH_PASSWORD, + keyPaths: SSH_KEY_PATHS, + keyContents: SSH_KEY_CONTENTS, + }); + }); + + beforeEach(async () => { + // Create an isolated remote directory for each test + const uuid = Math.random().toString(36).slice(2); + remoteBase = `${sshKaos.gethome()}/.kaos_test_${process.pid}_${uuid}`; + await sshKaos.mkdir(remoteBase, { parents: true, existOk: true }); + await sshKaos.chdir(remoteBase); + }); + + afterEach(async () => { + // Cleanup the remote directory best-effort, but always restore cwd. + if (remoteBase.length > 0) { + try { + const proc = await sshKaos.exec('rm', '-rf', remoteBase); + await proc.wait(); + } finally { + remoteBase = ''; + await sshKaos.chdir(sshKaos.gethome()); + } + } + }); + + afterAll(async () => { + if (sshKaos) await sshKaos.close(); + }); + + test('pathClass, home, and cwd', () => { + const home = sshKaos.gethome(); + const cwd = sshKaos.getcwd(); + + expect(sshKaos.pathClass()).toBe('posix'); + expect(home.length).toBeGreaterThan(0); + expect(cwd.length).toBeGreaterThan(0); + // Home should be absolute + expect(home.startsWith('/')).toBe(true); + // cwd should be absolute + expect(cwd.startsWith('/')).toBe(true); + }); + + test('cwd defaults to home immediately after connect', () => { + // A freshly connected session sets cwd to the remote home directory, + // so the two must be string-equal before any chdir. + expect(sshKaos.getcwd()).toBe(sshKaos.gethome()); + }); + + test('chdir updates real path', async () => { + await sshKaos.chdir(remoteBase); + expect(sshKaos.getcwd()).toBe(remoteBase); + + await sshKaos.mkdir(remoteBase + '/child', { existOk: true }); + await sshKaos.chdir('child'); + expect(sshKaos.getcwd()).toBe(remoteBase + '/child'); + + await sshKaos.chdir('..'); + expect(sshKaos.getcwd()).toBe(remoteBase); + }); + + test('exec respects cwd', async () => { + await sshKaos.chdir(remoteBase); + + const proc = await sshKaos.exec('pwd'); + const out = (await streamToBuffer(proc.stdout)).toString().trim(); + const code = await proc.wait(); + + expect(code).toBe(0); + expect(out).toBe(remoteBase); + }); + + test('exec wait before read', async () => { + const proc = await sshKaos.exec('echo', 'output'); + + const exitCode = await proc.wait(); + const output = (await streamToBuffer(proc.stdout)).toString().trim(); + + expect(exitCode).toBe(0); + expect(output).toBe('output'); + }); + + test('mkdir respects existOk', async () => { + const nestedDir = remoteBase + '/deep/level'; + + await sshKaos.mkdir(nestedDir, { parents: true, existOk: false }); + + // Python test_mkdir_respects_exist_ok pins `pytest.raises(FileExistsError)` + // — match that strength by asserting the specific KaosFileExistsError class + // rather than any throwable. + await expect(sshKaos.mkdir(nestedDir, { existOk: false })).rejects.toBeInstanceOf( + KaosFileExistsError, + ); + + await sshKaos.mkdir(nestedDir, { parents: true, existOk: true }); + }); + + test('stat reports directory and file metadata', async () => { + const dirStat = await sshKaos.stat(remoteBase, { followSymlinks: false }); + expect((dirStat.stMode & S_IFMT) === S_IFDIR).toBe(true); + + const filePath = remoteBase + '/payload.txt'; + const payload = 'metadata'; + await sshKaos.writeText(filePath, payload); + + const fileStat = await sshKaos.stat(filePath); + expect((fileStat.stMode & S_IFMT) === S_IFREG).toBe(true); + expect(fileStat.stSize).toBe(payload.length); + expect(fileStat.stNlink).toBeGreaterThanOrEqual(0); + }); + + test('file roundtrip via SSH', async () => { + await sshKaos.chdir(remoteBase); + + const textPath = remoteBase + '/text.txt'; + const bytesPath = remoteBase + '/blob.bin'; + + const textPayload = 'Hello SSH\n'; + const appended = 'More data\n'; + const written = await sshKaos.writeText(textPath, textPayload); + expect(written).toBe(textPayload.length); + + const appendedLen = await sshKaos.writeText(textPath, appended, { mode: 'a' }); + expect(appendedLen).toBe(appended.length); + + const fullText = await sshKaos.readText(textPath); + expect(fullText).toBe(textPayload + appended); + + const lines: string[] = []; + for await (const line of sshKaos.readLines(textPath)) { + lines.push(line); + } + expect(lines).toEqual(['Hello SSH', 'More data']); + + const bytesPayload = Buffer.from(Array.from({ length: 32 }, (_, i) => i)); + const bytesWritten = await sshKaos.writeBytes(bytesPath, bytesPayload); + expect(bytesWritten).toBe(bytesPayload.length); + + const roundtrip = await sshKaos.readBytes(bytesPath); + expect(Buffer.compare(roundtrip, bytesPayload)).toBe(0); + + expect(sshKaos.getcwd()).toBe(remoteBase); + }); + + test('iterdir lists child entries', async () => { + await sshKaos.writeText(remoteBase + '/file1.txt', '1'); + await sshKaos.writeText(remoteBase + '/file2.log', '2'); + await sshKaos.mkdir(remoteBase + '/subdir', { existOk: true }); + + const entries: string[] = []; + for await (const entry of sshKaos.iterdir(remoteBase)) { + entries.push(entry); + } + const names = new Set(entries.map((e) => e.split('/').pop()!)); + + expect(names).toEqual(new Set(['file1.txt', 'file2.log', 'subdir'])); + }); + + test('glob is case sensitive', async () => { + await sshKaos.writeText(remoteBase + '/file.log', 'lowercase'); + await sshKaos.writeText(remoteBase + '/FILE.LOG', 'uppercase'); + + const matches = new Set(); + for await (const path of sshKaos.glob(remoteBase, '*.log')) { + matches.add(path); + } + expect(matches.has(remoteBase + '/file.log')).toBe(true); + expect(matches.has(remoteBase + '/FILE.LOG')).toBe(false); + + await expect(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of sshKaos.glob(remoteBase, '*.log', { caseSensitive: false })) { + // should throw before yielding + } + }).rejects.toThrow('Case insensitive glob is not supported'); + }); + + test('exec streams stdout and stderr', async () => { + const proc = await sshKaos.exec('sh', '-c', "printf 'out\\n' && printf 'err\\n' 1>&2"); + + const [stdoutData, stderrData] = await Promise.all([ + streamToBuffer(proc.stdout), + streamToBuffer(proc.stderr), + ]); + const exitCode = await proc.wait(); + + expect(exitCode).toBe(0); + expect(proc.exitCode).toBe(0); + expect(stdoutData.toString().trim()).toBe('out'); + expect(stderrData.toString().trim()).toBe('err'); + }); + + // NOTE: these execWithEnv tests require the remote sshd to accept the + // injected variable names via its `AcceptEnv` directive (or an equivalent + // mechanism). Stock OpenSSH only whitelists LANG/LC_*; if the test server + // is not configured to accept KAOS_TEST_*, these tests will fail — which + // is exactly the signal we want (it reveals the silent env-drop bug that + // the Python version has). + test('execWithEnv delivers a single env var to the remote process', async () => { + const proc = await sshKaos.execWithEnv(['sh', '-c', 'printf "%s" "${KAOS_TEST_MARKER}"'], { + KAOS_TEST_MARKER: 'beacon42', + }); + const out = (await streamToBuffer(proc.stdout)).toString(); + const code = await proc.wait(); + + expect(code).toBe(0); + expect(out).toBe('beacon42'); + }); + + test('execWithEnv delivers multiple env vars', async () => { + const proc = await sshKaos.execWithEnv( + ['sh', '-c', 'printf "%s|%s" "${KAOS_TEST_A}" "${KAOS_TEST_B}"'], + { KAOS_TEST_A: 'hello', KAOS_TEST_B: 'world' }, + ); + const out = (await streamToBuffer(proc.stdout)).toString(); + const code = await proc.wait(); + + expect(code).toBe(0); + expect(out).toBe('hello|world'); + }); + + test('execWithEnv preserves values with shell metacharacters', async () => { + // Single quotes, dollar signs, backticks, pipes, ampersands, redirects, + // double quotes, and a backslash — anything an unsafe impl might mangle. + const value = `it's $HOME \`id\`; | & < > " \\`; + const proc = await sshKaos.execWithEnv(['sh', '-c', 'printf "%s" "${KAOS_TEST_VALUE}"'], { + KAOS_TEST_VALUE: value, + }); + const out = (await streamToBuffer(proc.stdout)).toString(); + const code = await proc.wait(); + + expect(code).toBe(0); + expect(out).toBe(value); + }); + + test('exec rejects empty command', async () => { + await expect((sshKaos.exec as (...args: string[]) => Promise)()).rejects.toThrow(); + }); + + test('process kill updates returncode', async () => { + const proc = await sshKaos.exec('sh', '-c', 'echo ready; sleep 30'); + + // Read the first line to know the process has started + const firstChunk = await new Promise((resolve) => { + proc.stdout.once('data', (chunk: Buffer) => { + resolve(chunk); + }); + }); + expect(firstChunk.toString().trim()).toBe('ready'); + expect(proc.exitCode).toBeNull(); + + await proc.kill(); + const exitCode = await proc.wait(); + + expect(exitCode).not.toBe(0); + expect(proc.exitCode).toBe(exitCode); + expect(proc.pid).toBe(-1); + }); +}); + +// These tests don't need a live SSH connection — they exercise the +// argument-validation guards that run before any network I/O. We invoke the +// methods through the prototype so no real instance is constructed. +describe('SSHKaos argument validation', () => { + it('exec() throws with the correct class name when args is empty', () => { + const fakeThis = {} as SSHKaos; + expect(() => SSHKaos.prototype.exec.call(fakeThis)).toThrow(KaosValueError); + expect(() => SSHKaos.prototype.exec.call(fakeThis)).toThrow(/SSHKaos\.exec\(\)/); + }); + + it('execWithEnv() throws with the correct class name when args is empty', () => { + const fakeThis = {} as SSHKaos; + expect(() => SSHKaos.prototype.execWithEnv.call(fakeThis, [])).toThrow(KaosValueError); + expect(() => SSHKaos.prototype.execWithEnv.call(fakeThis, [])).toThrow( + /SSHKaos\.execWithEnv\(\)/, + ); + }); + + // glob() is an async generator, so the caseSensitive=false guard fires on + // the first pull rather than at call-time. We verify both the error class + // (KaosValueError) and the fact that it rejects before touching SFTP. + it('glob(caseSensitive: false) rejects with KaosValueError', async () => { + const instance = Object.create(SSHKaos.prototype) as SSHKaos; + const internal = instance as unknown as { _cwd: string; _sftp: unknown }; + internal._cwd = '/tmp'; + internal._sftp = {}; + + const gen = instance.glob('/some/path', '*', { caseSensitive: false }); + await expect(gen.next()).rejects.toBeInstanceOf(KaosValueError); + }); +}); + +// chdir should refuse to treat a regular file (or anything that isn't a +// directory) as the new working directory. Without this guard, `sftp.realpath` +// happily returns file paths and later relative reads/writes/execs would +// resolve against a file — silently wrong. We exercise this by constructing +// a fake SFTP that returns a file stat so the test needs no live SSH. +describe('SSHKaos.chdir directory validation', () => { + // Minimal SFTPWrapper stub with only the methods chdir needs. + function makeFakeSftp(target: string, isDir: boolean): unknown { + return { + realpath(_path: string, cb: (err: Error | null | undefined, absPath: string) => void): void { + cb(null, target); + }, + stat( + _path: string, + cb: (err: Error | null | undefined, stats: Record) => void, + ): void { + cb(null, { + mode: isDir ? 0o040755 : 0o100644, + size: 0, + uid: 0, + gid: 0, + atime: 0, + mtime: 0, + isDirectory: () => isDir, + isFile: () => !isDir, + isSymbolicLink: () => false, + isSocket: () => false, + isCharacterDevice: () => false, + isBlockDevice: () => false, + isFIFO: () => false, + }); + }, + }; + } + + function makeFakeInstance(sftp: unknown, cwd: string): SSHKaos { + // Bypass the real constructor (which requires a live ssh2 client) and + // populate just the private fields that chdir touches. + const instance = Object.create(SSHKaos.prototype) as SSHKaos; + const internal = instance as unknown as { _sftp: unknown; _cwd: string }; + internal._sftp = sftp; + internal._cwd = cwd; + return instance; + } + + it('rejects a target that resolves to a regular file', async () => { + const target = '/tmp/not-a-dir.txt'; + const sftp = makeFakeSftp(target, /*isDir=*/ false); + const kaos = makeFakeInstance(sftp, '/tmp'); + + await expect(kaos.chdir(target)).rejects.toThrow(KaosValueError); + await expect(kaos.chdir(target)).rejects.toThrow(/not a directory/); + // cwd must remain unchanged on failure. + expect(kaos.getcwd()).toBe('/tmp'); + }); + + it('accepts a target that resolves to a directory', async () => { + const target = '/tmp/real-dir'; + const sftp = makeFakeSftp(target, /*isDir=*/ true); + const kaos = makeFakeInstance(sftp, '/tmp'); + + await kaos.chdir(target); + expect(kaos.getcwd()).toBe(target); + }); +}); + +// These tests pin the SFTPError → KaosError mapping contract. They use a +// fake SFTPWrapper that invokes callbacks with errors carrying the standard +// SFTP status codes (NO_SUCH_FILE=2, PERMISSION_DENIED=3), so they run in +// CI without needing a live SSH connection. +// +// The mapping lives in the promisified SFTP helpers in ssh.ts, so every +// SSHKaos method that touches SFTP automatically throws a KaosSSHError +// subclass (KaosFileNotFoundError / KaosPermissionError / …) instead of +// the raw ssh2 error. +describe('SSHKaos SFTP error mapping', () => { + const NO_SUCH_FILE = 2; + const PERMISSION_DENIED = 3; + + interface FailingMethods { + stat?: boolean; + lstat?: boolean; + readFile?: boolean; + writeFile?: boolean; + appendFile?: boolean; + mkdir?: boolean; + readdir?: boolean; + } + + function makeSftpError(errorCode: number): Error { + const err = new Error('simulated SFTP error'); + (err as unknown as { code: number }).code = errorCode; + return err; + } + + // Minimal SFTPWrapper stub. For each I/O method, when `failing[method]` is + // true the callback is invoked with an error carrying `code`; otherwise + // a harmless default is returned. Only the methods that SSHKaos actually + // calls need to be stubbed. + function makeFakeSftp(errorCode: number, failing: FailingMethods): unknown { + const dirStats = { + mode: 0o040755, + size: 0, + uid: 0, + gid: 0, + atime: 0, + mtime: 0, + isDirectory: (): boolean => true, + isFile: (): boolean => false, + isSymbolicLink: (): boolean => false, + isSocket: (): boolean => false, + isCharacterDevice: (): boolean => false, + isBlockDevice: (): boolean => false, + isFIFO: (): boolean => false, + }; + + return { + realpath(path: string, cb: (err: Error | null, abs: string) => void): void { + cb(null, path); + }, + stat(_path: string, cb: (err: Error | null, stats?: unknown) => void): void { + if (failing.stat === true) { + cb(makeSftpError(errorCode)); + return; + } + cb(null, dirStats); + }, + lstat(_path: string, cb: (err: Error | null, stats?: unknown) => void): void { + if (failing.lstat === true) { + cb(makeSftpError(errorCode)); + return; + } + cb(null, dirStats); + }, + readFile(_path: string, cb: (err: Error | null, data?: Buffer) => void): void { + if (failing.readFile === true) { + cb(makeSftpError(errorCode)); + return; + } + cb(null, Buffer.alloc(0)); + }, + writeFile(_path: string, _data: unknown, cb: (err: Error | null) => void): void { + if (failing.writeFile === true) { + cb(makeSftpError(errorCode)); + return; + } + cb(null); + }, + appendFile(_path: string, _data: unknown, cb: (err: Error | null) => void): void { + if (failing.appendFile === true) { + cb(makeSftpError(errorCode)); + return; + } + cb(null); + }, + mkdir(_path: string, cb: (err: Error | null) => void): void { + if (failing.mkdir === true) { + cb(makeSftpError(errorCode)); + return; + } + cb(null); + }, + readdir(_path: string, cb: (err: Error | null, list?: unknown[]) => void): void { + if (failing.readdir === true) { + cb(makeSftpError(errorCode)); + return; + } + cb(null, []); + }, + // exists() always reports the file is absent so plain mkdir takes the + // create path (where `failing.mkdir` decides the outcome). + exists(_path: string, cb: (exists: boolean) => void): void { + cb(false); + }, + }; + } + + function makeFakeKaos(sftp: unknown): SSHKaos { + const instance = Object.create(SSHKaos.prototype) as SSHKaos; + const internal = instance as unknown as { _sftp: unknown; _cwd: string; _home: string }; + internal._sftp = sftp; + internal._cwd = '/'; + internal._home = '/'; + return instance; + } + + // ── stat(): the one method that already wraps errors. ──────────────── + + it('stat() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => { + const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { stat: true })); + await expect(kaos.stat('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError); + }); + + it('stat() maps PERMISSION_DENIED → KaosPermissionError', async () => { + const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { stat: true })); + await expect(kaos.stat('/forbidden')).rejects.toBeInstanceOf(KaosPermissionError); + }); + + it('stat({ followSymlinks: false }) wraps lstat errors the same way', async () => { + const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { lstat: true })); + await expect(kaos.stat('/missing', { followSymlinks: false })).rejects.toBeInstanceOf( + KaosFileNotFoundError, + ); + }); + + it('stat() wraps unmapped failures as the base KaosSSHError', async () => { + // FAILURE=4 is not specifically mapped → generic KaosSSHError. + const kaos = makeFakeKaos(makeFakeSftp(4, { stat: true })); + await expect(kaos.stat('/x')).rejects.toBeInstanceOf(KaosSSHError); + }); + + it('stat() maps NO_CONNECTION → KaosConnectionError', async () => { + // SFTP STATUS_CODE.NO_CONNECTION = 6 + const kaos = makeFakeKaos(makeFakeSftp(6, { stat: true })); + await expect(kaos.stat('/x')).rejects.toBeInstanceOf(KaosConnectionError); + }); + + it('stat() maps CONNECTION_LOST → KaosConnectionError', async () => { + // SFTP STATUS_CODE.CONNECTION_LOST = 7 + const kaos = makeFakeKaos(makeFakeSftp(7, { stat: true })); + await expect(kaos.stat('/x')).rejects.toBeInstanceOf(KaosConnectionError); + }); + + it('stat() wraps errors without a numeric code as generic KaosSSHError', async () => { + // An error object whose `.code` is not a number (or absent entirely) + // must still be wrapped — the mapSftpError fallback should kick in. + const sftp = { + realpath(p: string, cb: (err: Error | null, abs: string) => void): void { + cb(null, p); + }, + stat(_p: string, cb: (err: Error | null) => void): void { + // Error with no .code field + cb(new Error('no code here')); + }, + }; + const kaos = makeFakeKaos(sftp); + const err = await kaos.stat('/x').catch((error: unknown) => error); + expect(err).toBeInstanceOf(KaosSSHError); + expect((err as KaosSSHError).message).toContain('no code here'); + }); + + it('stat() wraps non-Error rejections by stringifying them', async () => { + // The helper's getErrorMessage fallback handles non-Error values by + // calling String(error). Reject with a plain string and verify the + // wrap still produces a KaosSSHError with the string in the message. + const sftp = { + realpath(p: string, cb: (err: Error | null, abs: string) => void): void { + cb(null, p); + }, + stat(_p: string, cb: (err: unknown) => void): void { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + cb('raw-string-rejection'); + }, + }; + const kaos = makeFakeKaos(sftp); + const err = await kaos.stat('/x').catch((error: unknown) => error); + expect(err).toBeInstanceOf(KaosSSHError); + expect((err as KaosSSHError).message).toContain('raw-string-rejection'); + }); + + it('chdir() propagates a realpath failure as a KaosSSHError', async () => { + // sftpRealpath now runs through mapSftpError too — pin that contract. + const sftp = { + realpath(_p: string, cb: (err: Error) => void): void { + const err = new Error('realpath broke'); + (err as unknown as { code: number }).code = 2; + cb(err); + }, + stat(_p: string, _cb: unknown): void { + throw new Error('should not be called'); + }, + }; + const instance = Object.create(SSHKaos.prototype) as SSHKaos; + const internal = instance as unknown as { _sftp: unknown; _cwd: string }; + internal._sftp = sftp; + internal._cwd = '/'; + await expect(instance.chdir('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError); + }); + + // ── Other I/O methods: mapping is pushed into the promisified helpers + // in ssh.ts so every method gets the same wrapping for free. ───────── + + it('readText() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => { + const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { readFile: true })); + await expect(kaos.readText('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError); + }); + + it('readBytes() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => { + const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { readFile: true })); + await expect(kaos.readBytes('/missing')).rejects.toBeInstanceOf(KaosFileNotFoundError); + }); + + it('writeText() maps PERMISSION_DENIED → KaosPermissionError', async () => { + const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { writeFile: true })); + await expect(kaos.writeText('/forbidden', 'data')).rejects.toBeInstanceOf(KaosPermissionError); + }); + + it('writeText(append) maps PERMISSION_DENIED → KaosPermissionError', async () => { + const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { appendFile: true })); + await expect(kaos.writeText('/forbidden', 'data', { mode: 'a' })).rejects.toBeInstanceOf( + KaosPermissionError, + ); + }); + + it('writeBytes() maps PERMISSION_DENIED → KaosPermissionError', async () => { + const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { writeFile: true })); + await expect(kaos.writeBytes('/forbidden', Buffer.from('x'))).rejects.toBeInstanceOf( + KaosPermissionError, + ); + }); + + it('mkdir() maps PERMISSION_DENIED → KaosPermissionError', async () => { + const kaos = makeFakeKaos(makeFakeSftp(PERMISSION_DENIED, { mkdir: true })); + await expect(kaos.mkdir('/forbidden')).rejects.toBeInstanceOf(KaosPermissionError); + }); + + it('iterdir() maps NO_SUCH_FILE → KaosFileNotFoundError', async () => { + const kaos = makeFakeKaos(makeFakeSftp(NO_SUCH_FILE, { readdir: true })); + const gen = kaos.iterdir('/missing'); + await expect(gen.next()).rejects.toBeInstanceOf(KaosFileNotFoundError); + }); +}); + +// These tests exercise the pure command-building logic behind execWithEnv +// without needing a live SSH connection. The actual end-to-end delivery of +// env vars is validated by the smoke tests above when KAOS_SSH_SMOKE=1. +describe('SSHKaos._buildExecCommand', () => { + // Bracket access so we can reach the private static helper from tests + // without changing its visibility in the public API. + const build = ( + SSHKaos as unknown as { + _buildExecCommand: (args: string[], cwd: string, env?: Record) => string; + } + )._buildExecCommand; + + it('cd prefix + bare command when no env is supplied', () => { + expect(build(['ls', '-la'], '/home/user')).toBe('cd /home/user && ls -la'); + }); + + it('injects inline assignments before the command', () => { + expect(build(['echo', 'x'], '/home/user', { FOO: 'bar' })).toBe( + 'cd /home/user && FOO=bar echo x', + ); + }); + + it('injects multiple env vars in declaration order', () => { + const out = build(['sh', '-c', 'echo $A $B'], '/home/user', { A: '1', B: '2' }); + expect(out).toBe("cd /home/user && A=1 B=2 sh -c 'echo $A $B'"); + }); + + it('quotes values containing shell metacharacters', () => { + // Single quote in value → shellQuote escapes via the '"'"' trick. + expect(build(['cmd'], '/home/user', { V: "it's" })).toBe(`cd /home/user && V='it'"'"'s' cmd`); + // Dollar sign, backticks, pipe, ampersand → single-quoted wholesale. + expect(build(['cmd'], '/home/user', { V: '$HOME `id` | &' })).toBe( + `cd /home/user && V='$HOME \`id\` | &' cmd`, + ); + }); + + it('quotes an empty value as empty single quotes', () => { + expect(build(['cmd'], '/home/user', { V: '' })).toBe("cd /home/user && V='' cmd"); + }); + + it('rejects env var names that are not valid POSIX identifiers', () => { + expect(() => build(['cmd'], '/home/user', { '1BAD': 'x' })).toThrow(KaosValueError); + expect(() => build(['cmd'], '/home/user', { 'WITH SPACE': 'x' })).toThrow(KaosValueError); + expect(() => build(['cmd'], '/home/user', { 'WITH=EQUALS': 'x' })).toThrow(KaosValueError); + expect(() => build(['cmd'], '/home/user', { '': 'x' })).toThrow(KaosValueError); + }); + + it('accepts underscored and mixed-case identifiers', () => { + expect(build(['cmd'], '/home/user', { _UNDER: '1', camelCase: '2' })).toBe( + 'cd /home/user && _UNDER=1 camelCase=2 cmd', + ); + }); + + it('skips the cd prefix when cwd is the empty string', () => { + expect(build(['cmd', 'arg'], '', { FOO: 'bar' })).toBe('FOO=bar cmd arg'); + }); + + it('quotes cwd paths with spaces and special characters', () => { + expect(build(['cmd'], "/home/u ser's dir")).toBe(`cd '/home/u ser'"'"'s dir' && cmd`); + }); + + it('omits the assignment section entirely when env is an empty object', () => { + // Matches the behavior of plain exec() — no leading space, no KEY=... + expect(build(['cmd', 'arg'], '/home/user', {})).toBe('cd /home/user && cmd arg'); + }); +}); + +// These tests drive the SSHKaos read/stat/glob/iterdir happy paths via +// fake SFTP wrappers, so they run in CI without a live SSH server. Without +// them the smoke block is the only route to those code paths, which means +// CI only sees the error branches. +describe('SSHKaos mock success paths', () => { + interface TreeNode { + type: 'dir' | 'file'; + children?: Record; + content?: Buffer; + // Permission bits only — forces buildStMode to derive the type bits + // from the is* helpers (the "mode without type bits" branch) when true. + stripTypeBits?: boolean; + } + + function makeStats(node: TreeNode): unknown { + const isDir = node.type === 'dir'; + // Either include type bits (0o040000/0o100000) or strip them to force + // the buildStMode helper to derive them via isDirectory()/isFile(). + const baseMode = isDir ? 0o040755 : 0o100644; + const mode = node.stripTypeBits === true ? baseMode & 0o7777 : baseMode; + return { + mode, + size: node.content ? node.content.length : 0, + uid: 1000, + gid: 1000, + atime: 100, + mtime: 200, + isDirectory: () => isDir, + isFile: () => !isDir, + isSymbolicLink: () => false, + isSocket: () => false, + isCharacterDevice: () => false, + isBlockDevice: () => false, + isFIFO: () => false, + }; + } + + function lookup(root: TreeNode, path: string): TreeNode | undefined { + if (path === '/') return root; + const parts = path.split('/').filter(Boolean); + let current: TreeNode | undefined = root; + for (const part of parts) { + if (!current?.children?.[part]) return undefined; + current = current.children[part]; + } + return current; + } + + // Fake SFTP that exposes a tree and implements the handful of callbacks + // SSHKaos actually calls. Anything not needed is left unimplemented. + function makeTreeSftp(root: TreeNode): unknown { + return { + realpath(path: string, cb: (err: Error | null, abs: string) => void): void { + cb(null, path); + }, + stat(path: string, cb: (err: Error | null, stats?: unknown) => void): void { + const node = lookup(root, path); + if (!node) { + const err = new Error(`no such file: ${path}`); + (err as unknown as { code: number }).code = 2; + cb(err); + return; + } + cb(null, makeStats(node)); + }, + lstat(path: string, cb: (err: Error | null, stats?: unknown) => void): void { + const node = lookup(root, path); + if (!node) { + const err = new Error(`no such file: ${path}`); + (err as unknown as { code: number }).code = 2; + cb(err); + return; + } + cb(null, makeStats(node)); + }, + readdir(path: string, cb: (err: Error | null, list?: unknown[]) => void): void { + const node = lookup(root, path); + if (!node || node.type !== 'dir' || !node.children) { + const err = new Error(`not a directory: ${path}`); + (err as unknown as { code: number }).code = 2; + cb(err); + return; + } + cb( + null, + Object.entries(node.children).map(([filename, child]) => ({ + filename, + attrs: makeStats(child), + })), + ); + }, + readFile(path: string, cb: (err: Error | null, data?: Buffer) => void): void { + const node = lookup(root, path); + if (!node || node.type !== 'file') { + const err = new Error(`no such file: ${path}`); + (err as unknown as { code: number }).code = 2; + cb(err); + return; + } + cb(null, node.content ?? Buffer.alloc(0)); + }, + }; + } + + function makeFakeKaos(sftp: unknown, cwd = '/'): SSHKaos { + const instance = Object.create(SSHKaos.prototype) as SSHKaos; + const internal = instance as unknown as { _sftp: unknown; _cwd: string; _home: string }; + internal._sftp = sftp; + internal._cwd = cwd; + internal._home = '/home/tester'; + return instance; + } + + // ── path helpers ──────────────────────────────────────────────────── + + it('normpath delegates to posix.normalize', () => { + // No I/O — just the pure path function. Pins that normpath collapses + // `..` segments. + const kaos = makeFakeKaos(makeTreeSftp({ type: 'dir', children: {} })); + expect(kaos.normpath('/a/b/../c')).toBe('/a/c'); + }); + + // ── stat + buildStMode variants ───────────────────────────────────── + + it('stat() returns a StatResult with file-type bits preserved', async () => { + const root: TreeNode = { + type: 'dir', + children: { + 'file.txt': { type: 'file', content: Buffer.from('hi') }, + }, + }; + const kaos = makeFakeKaos(makeTreeSftp(root)); + + const fileStat: StatResult = await kaos.stat('/file.txt'); + expect((fileStat.stMode & 0o170000) === 0o100000).toBe(true); + expect(fileStat.stSize).toBe(2); + expect(fileStat.stUid).toBe(1000); + }); + + it('stat() derives type bits from is* helpers when mode lacks them', async () => { + // mode = 0o755 has zero in the S_IFMT bits, so buildStMode must fall + // back to isDirectory()/isFile() to fill them in. + const root: TreeNode = { + type: 'dir', + stripTypeBits: true, + children: { + 'bare.txt': { type: 'file', stripTypeBits: true, content: Buffer.from('x') }, + }, + }; + const kaos = makeFakeKaos(makeTreeSftp(root)); + + const dirStat = await kaos.stat('/'); + expect((dirStat.stMode & 0o170000) === 0o040000).toBe(true); + + const fileStat = await kaos.stat('/bare.txt'); + expect((fileStat.stMode & 0o170000) === 0o100000).toBe(true); + }); + + it('stat({ followSymlinks: false }) uses the lstat branch', async () => { + // No actual symlinks — just verify the alternate code path is taken + // (lstat callback is wired up, it returns stats without throwing). + const root: TreeNode = { + type: 'dir', + children: { 'a.txt': { type: 'file', content: Buffer.from('hi') } }, + }; + const kaos = makeFakeKaos(makeTreeSftp(root)); + const result = await kaos.stat('/a.txt', { followSymlinks: false }); + expect(result.stSize).toBe(2); + }); + + // ── iterdir ──────────────────────────────────────────────────────── + + it('iterdir yields directory entries', async () => { + const root: TreeNode = { + type: 'dir', + children: { + tree: { + type: 'dir', + children: { + 'a.txt': { type: 'file', content: Buffer.from('a') }, + 'b.txt': { type: 'file', content: Buffer.from('b') }, + sub: { type: 'dir', children: {} }, + }, + }, + }, + }; + const kaos = makeFakeKaos(makeTreeSftp(root)); + + const entries: string[] = []; + for await (const entry of kaos.iterdir('/tree')) { + entries.push(entry); + } + expect(new Set(entries)).toEqual(new Set(['/tree/a.txt', '/tree/b.txt', '/tree/sub'])); + }); + + // ── glob ──────────────────────────────────────────────────────────── + + it('glob matches flat patterns against the directory', async () => { + const root: TreeNode = { + type: 'dir', + children: { + tree: { + type: 'dir', + children: { + 'root.txt': { type: 'file', content: Buffer.from('r') }, + 'root.log': { type: 'file', content: Buffer.from('l') }, + }, + }, + }, + }; + const kaos = makeFakeKaos(makeTreeSftp(root)); + + const matches: string[] = []; + for await (const m of kaos.glob('/tree', '*.txt')) { + matches.push(m); + } + expect(matches).toEqual(['/tree/root.txt']); + }); + + it('glob recurses with **/pattern to match nested files', async () => { + const root: TreeNode = { + type: 'dir', + children: { + tree: { + type: 'dir', + children: { + 'root.txt': { type: 'file', content: Buffer.from('r') }, + sub: { + type: 'dir', + children: { + 'nested.txt': { type: 'file', content: Buffer.from('n') }, + deep: { + type: 'dir', + children: { + 'deeper.txt': { type: 'file', content: Buffer.from('d') }, + }, + }, + }, + }, + }, + }, + }, + }; + const kaos = makeFakeKaos(makeTreeSftp(root)); + + const matches: string[] = []; + for await (const m of kaos.glob('/tree', '**/*.txt')) { + matches.push(m); + } + const names = new Set(matches); + expect(names).toEqual( + new Set(['/tree/root.txt', '/tree/sub/nested.txt', '/tree/sub/deep/deeper.txt']), + ); + // Pin the de-dup invariant — no single file should appear twice. + expect(matches.length).toBe(new Set(matches).size); + }); + + it('glob with bare ** yields basePath and every nested entry', async () => { + const root: TreeNode = { + type: 'dir', + children: { + tree: { + type: 'dir', + children: { + 'root.txt': { type: 'file', content: Buffer.from('r') }, + sub: { + type: 'dir', + children: { + 'nested.txt': { type: 'file', content: Buffer.from('n') }, + }, + }, + }, + }, + }, + }; + const kaos = makeFakeKaos(makeTreeSftp(root)); + + const matches: string[] = []; + for await (const m of kaos.glob('/tree', '**')) { + matches.push(m); + } + const set = new Set(matches); + expect(set.has('/tree')).toBe(true); + expect(set.has('/tree/root.txt')).toBe(true); + expect(set.has('/tree/sub')).toBe(true); + expect(set.has('/tree/sub/nested.txt')).toBe(true); + }); + + it('glob with a nested literal path recurses only into the named dir', async () => { + // Pattern `sub/*.txt` → literal `sub` segment, then `*.txt` inside it. + // Exercises the non-`**` recursive branch. + const root: TreeNode = { + type: 'dir', + children: { + tree: { + type: 'dir', + children: { + 'root.txt': { type: 'file', content: Buffer.from('r') }, + sub: { + type: 'dir', + children: { + 'a.txt': { type: 'file', content: Buffer.from('a') }, + 'b.log': { type: 'file', content: Buffer.from('b') }, + }, + }, + }, + }, + }, + }; + const kaos = makeFakeKaos(makeTreeSftp(root)); + + const matches: string[] = []; + for await (const m of kaos.glob('/tree', 'sub/*.txt')) { + matches.push(m); + } + expect(matches).toEqual(['/tree/sub/a.txt']); + }); + + it('glob silently skips unreadable directories', async () => { + // If readdir() fails the generator should return without throwing — + // makes glob tolerant of permission-limited subtrees. + const sftp = { + realpath(p: string, cb: (err: Error | null, abs: string) => void): void { + cb(null, p); + }, + readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void { + const err = new Error('permission denied'); + (err as unknown as { code: number }).code = 3; + cb(err); + }, + }; + const kaos = makeFakeKaos(sftp); + + const matches: string[] = []; + for await (const m of kaos.glob('/locked', '*.txt')) { + matches.push(m); + } + expect(matches).toEqual([]); + }); + + // ── readLines empty-file early return ───────────────────────────── + + it('readLines yields nothing for an empty file', async () => { + // `readText` returns an empty string, so the generator must take the + // early-return branch instead of walking `splitlines()`. + const root: TreeNode = { + type: 'dir', + children: { 'empty.txt': { type: 'file', content: Buffer.alloc(0) } }, + }; + const kaos = makeFakeKaos(makeTreeSftp(root)); + + const lines: string[] = []; + for await (const line of kaos.readLines('/empty.txt')) { + lines.push(line); + } + expect(lines).toEqual([]); + }); + + it('readText preserves valid U+FFFD while ignoring invalid utf-8 bytes', async () => { + const data = Buffer.concat([ + Buffer.from('A\uFFFDB', 'utf-8'), + Buffer.from([0xff]), + Buffer.from('C', 'utf-8'), + ]); + const root: TreeNode = { + type: 'dir', + children: { 'mixed.txt': { type: 'file', content: data } }, + }; + const kaos = makeFakeKaos(makeTreeSftp(root)); + + await expect(kaos.readText('/mixed.txt', { errors: 'ignore' })).resolves.toBe('A\uFFFDBC'); + }); + + // ── . / .. filter coverage ──────────────────────────────────────── + + it('iterdir at root "/" does not produce double-slash paths', async () => { + // Regression: `basePath + '/' + entry.filename` produced `//foo` when + // basePath was the filesystem root. Now uses `posix.join` to collapse. + const root: TreeNode = { + type: 'dir', + children: { + 'a.txt': { type: 'file', content: Buffer.from('a') }, + sub: { type: 'dir', children: {} }, + }, + }; + const kaos = makeFakeKaos(makeTreeSftp(root), '/'); + + const entries: string[] = []; + for await (const entry of kaos.iterdir('/')) { + entries.push(entry); + } + expect(new Set(entries)).toEqual(new Set(['/a.txt', '/sub'])); + expect(entries.every((e) => !e.includes('//'))).toBe(true); + }); + + it('glob at root "/" does not produce double-slash paths', async () => { + const root: TreeNode = { + type: 'dir', + children: { + 'file.txt': { type: 'file', content: Buffer.from('x') }, + }, + }; + const kaos = makeFakeKaos(makeTreeSftp(root), '/'); + + const matches: string[] = []; + for await (const m of kaos.glob('/', '*.txt')) { + matches.push(m); + } + expect(matches).toEqual(['/file.txt']); + expect(matches.every((p) => !p.includes('//'))).toBe(true); + }); + + it('iterdir filters out "." and ".." entries from readdir output', async () => { + // Some SFTP servers include `.` / `..` in readdir results, so the + // filter in SSHKaos.iterdir must skip them unconditionally. Inject + // those entries into the fake to exercise the filter branch. + const sftp = { + realpath(p: string, cb: (err: Error | null, abs: string) => void): void { + cb(null, p); + }, + readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void { + cb(null, [ + { filename: '.', attrs: { isDirectory: (): boolean => true } }, + { filename: '..', attrs: { isDirectory: (): boolean => true } }, + { filename: 'real.txt', attrs: { isDirectory: (): boolean => false } }, + ]); + }, + }; + const kaos = makeFakeKaos(sftp, '/tree'); + + const entries: string[] = []; + for await (const e of kaos.iterdir('/tree')) { + entries.push(e); + } + // Only the real entry survives — the `.` / `..` are silently dropped. + expect(entries).toEqual(['/tree/real.txt']); + }); + + it('glob filters "." and ".." entries in both the ** and literal branches', async () => { + // Same fake readdir in two walks: one with `**` (covers the `**` + // branch filter) and one with a literal pattern (covers the non-`**` + // branch filter). + const sftp = { + realpath(p: string, cb: (err: Error | null, abs: string) => void): void { + cb(null, p); + }, + readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void { + cb(null, [ + { + filename: '.', + attrs: { + isDirectory: (): boolean => true, + isFile: (): boolean => false, + }, + }, + { + filename: '..', + attrs: { + isDirectory: (): boolean => true, + isFile: (): boolean => false, + }, + }, + { + filename: 'keeper.txt', + attrs: { + isDirectory: (): boolean => false, + isFile: (): boolean => true, + }, + }, + ]); + }, + }; + const kaos = makeFakeKaos(sftp, '/tree'); + + const viaStar: string[] = []; + for await (const m of kaos.glob('/tree', '*.txt')) viaStar.push(m); + expect(viaStar).toEqual(['/tree/keeper.txt']); + + const viaStarStar: string[] = []; + for await (const m of kaos.glob('/tree', '**')) viaStarStar.push(m); + // The recursion into `.` / `..` must be skipped — we should see only + // the base dir and the keeper file, never an infinite loop. + expect(new Set(viaStarStar)).toEqual(new Set(['/tree', '/tree/keeper.txt'])); + }); + + // ── glob error handling in the ** branch ────────────────────────── + + it('glob ** silently aborts when readdir fails', async () => { + // Similar to the non-`**` readdir-error test, but forcing the code + // through the `**` case's own try/catch. Passing a bare `**` pattern + // takes the `currentPattern === '**'` branch, and the failing + // readdir exercises the swallow-and-return path inside it. + const sftp = { + realpath(p: string, cb: (err: Error | null, abs: string) => void): void { + cb(null, p); + }, + readdir(_p: string, cb: (err: Error | null, list?: unknown[]) => void): void { + const err = new Error('denied'); + (err as unknown as { code: number }).code = 3; + cb(err); + }, + }; + const kaos = makeFakeKaos(sftp, '/tree'); + + const matches: string[] = []; + for await (const m of kaos.glob('/tree', '**')) { + matches.push(m); + } + // Pattern `**` with zero-directory match yields basePath itself + // BEFORE the readdir even runs, so we still see `/tree`. The + // important bit is that no exception propagates out. + expect(matches).toEqual(['/tree']); + }); + + // ── readBytes ────────────────────────────────────────────────────── + + it('readBytes(n) returns only the first n bytes of the file', async () => { + const content = Buffer.from('0123456789'); + const root: TreeNode = { + type: 'dir', + children: { 'data.bin': { type: 'file', content } }, + }; + const kaos = makeFakeKaos(makeTreeSftp(root)); + + const full = await kaos.readBytes('/data.bin'); + expect(Buffer.compare(full, content)).toBe(0); + + const first4 = await kaos.readBytes('/data.bin', 4); + expect(first4.toString()).toBe('0123'); + }); + + // ── execWithEnv body ─────────────────────────────────────────────── + + it('execWithEnv calls clientExec with the env-prefixed command', async () => { + // We only exercise the _execInternal path via execWithEnv here — the + // command-building detail is already covered by the _buildExecCommand + // describe block. This test just pins that execWithEnv() with a valid + // non-empty args array routes through the internal helper. + const commands: string[] = []; + const fakeClient = { + exec(cmd: string, _a: unknown, _b?: unknown): void { + commands.push(cmd); + // Mimic a channel that never emits — we just want to observe the + // command string, not wait for real I/O. + throw new Error('stop'); + }, + }; + const instance = Object.create(SSHKaos.prototype) as SSHKaos; + const internal = instance as unknown as { + _client: unknown; + _cwd: string; + _home: string; + _envLayers: readonly Record[]; + }; + internal._client = fakeClient; + internal._cwd = '/home/tester'; + internal._home = '/home/tester'; + internal._envLayers = []; + + await expect(instance.execWithEnv(['echo', 'hi'], { FOO: 'bar' })).rejects.toThrow('stop'); + + expect(commands).toHaveLength(1); + expect(commands[0]).toBe('cd /home/tester && FOO=bar echo hi'); + }); +}); + +// These tests pin the non-race mkdir error branches in SSHKaos — the +// "path already exists but is a file" and "parents=true with a final +// directory already present under existOk=false" cases. All driven via +// fake SFTP wrappers so no live server is required. +describe('SSHKaos mkdir existOk edge cases', () => { + function makeFakeKaos(sftp: unknown): SSHKaos { + const instance = Object.create(SSHKaos.prototype) as SSHKaos; + const internal = instance as unknown as { _sftp: unknown; _cwd: string }; + internal._sftp = sftp; + internal._cwd = '/'; + return instance; + } + + function makeFileStats(): unknown { + return { + mode: 0o100644, + size: 0, + uid: 0, + gid: 0, + atime: 0, + mtime: 0, + isDirectory: (): boolean => false, + isFile: (): boolean => true, + isSymbolicLink: (): boolean => false, + isSocket: (): boolean => false, + isCharacterDevice: (): boolean => false, + isBlockDevice: (): boolean => false, + isFIFO: (): boolean => false, + }; + } + + function makeDirStats(): unknown { + return { + mode: 0o040755, + size: 0, + uid: 0, + gid: 0, + atime: 0, + mtime: 0, + isDirectory: (): boolean => true, + isFile: (): boolean => false, + isSymbolicLink: (): boolean => false, + isSocket: (): boolean => false, + isCharacterDevice: (): boolean => false, + isBlockDevice: (): boolean => false, + isFIFO: (): boolean => false, + }; + } + + it('mkdir (non-parents) rejects when existOk=true but path is a file', async () => { + // simple mkdir branch: exists=true + existOk=true + not a directory + // must throw instead of silently accepting the file collision. + const sftp = { + realpath(p: string, cb: (err: Error | null, abs: string) => void): void { + cb(null, p); + }, + exists(_p: string, cb: (exists: boolean) => void): void { + cb(true); + }, + stat(_p: string, cb: (err: Error | null, stats: unknown) => void): void { + cb(null, makeFileStats()); + }, + }; + const kaos = makeFakeKaos(sftp); + await expect(kaos.mkdir('/existing-file', { existOk: true })).rejects.toBeInstanceOf( + KaosFileExistsError, + ); + }); + + it('mkdir(parents=true) rejects when the final path exists and existOk=false', async () => { + // Recursive branch: walks to the final component, finds it already + // exists (as a directory, even), and since existOk=false the call + // must surface a KaosFileExistsError instead of silently succeeding. + const sftp = { + realpath(p: string, cb: (err: Error | null, abs: string) => void): void { + cb(null, p); + }, + exists(_p: string, cb: (exists: boolean) => void): void { + cb(true); + }, + stat(_p: string, cb: (err: Error | null, stats: unknown) => void): void { + cb(null, makeDirStats()); + }, + }; + const kaos = makeFakeKaos(sftp); + await expect(kaos.mkdir('/a/b/c', { parents: true, existOk: false })).rejects.toBeInstanceOf( + KaosFileExistsError, + ); + }); + + it('mkdir(parents=true) rejects when an intermediate path is a regular file', async () => { + // Recursive branch: during the walk, an intermediate component + // already exists but is a regular file (not a directory). That must + // be a hard failure regardless of existOk, because the next sftpMkdir + // would otherwise fail with a confusing ENOTDIR. + const sftp = { + realpath(p: string, cb: (err: Error | null, abs: string) => void): void { + cb(null, p); + }, + exists(_p: string, cb: (exists: boolean) => void): void { + cb(true); + }, + stat(_p: string, cb: (err: Error | null, stats: unknown) => void): void { + cb(null, makeFileStats()); + }, + }; + const kaos = makeFakeKaos(sftp); + await expect(kaos.mkdir('/a/b/c', { parents: true, existOk: true })).rejects.toBeInstanceOf( + KaosFileExistsError, + ); + }); +}); + +describe('SSHKaos.close lifecycle', () => { + class FakeClient extends EventEmitter { + closed = false; + + end(): void { + queueMicrotask(() => { + this.closed = true; + this.emit('close'); + }); + } + + exec( + _command: string, + optionsOrCallback: + | ((err: Error | undefined, channel: never) => void) + | Record, + maybeCallback?: (err: Error | undefined, channel: never) => void, + ): void { + const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback; + if (callback === undefined) { + return; + } + if (this.closed) { + callback(new Error('channel closed'), undefined as never); + return; + } + callback(undefined, undefined as never); + } + } + + function createCloseableKaos(): SSHKaos { + const instance = Object.create(SSHKaos.prototype) as SSHKaos; + const internals = instance as unknown as { + _client: FakeClient; + _cwd: string; + _home: string; + _sftp: { end(): void }; + _envLayers: readonly Record[]; + }; + internals._client = new FakeClient(); + internals._cwd = '/tmp'; + internals._home = '/tmp'; + internals._envLayers = []; + internals._sftp = { + end(): void { + // no-op + }, + }; + return instance; + } + + it('awaits the close event before allowing follow-up execs to observe the closed state', async () => { + const kaos = createCloseableKaos(); + + await kaos.close(); + + await expect(kaos.exec('pwd')).rejects.toThrow(/channel closed/); + }); +}); diff --git a/packages/kap-server/src/contract.ts b/packages/kap-server/src/contract.ts new file mode 100644 index 0000000000000000000000000000000000000000..77af01b22db80a29d358ee68175a6cb64e93056c --- /dev/null +++ b/packages/kap-server/src/contract.ts @@ -0,0 +1,2 @@ +export type { ChannelDescriptor, ChannelMethodDescriptor } from './transport/channelRegistry'; +export type { IChannel, ScopeKind } from './transport/channel'; diff --git a/packages/kap-server/src/env.d.ts b/packages/kap-server/src/env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..88d404d0aeceefe0515e69718066bd899426d263 --- /dev/null +++ b/packages/kap-server/src/env.d.ts @@ -0,0 +1,4 @@ +declare module '*?raw' { + const content: string; + export default content; +} diff --git a/packages/kap-server/src/envelope.ts b/packages/kap-server/src/envelope.ts new file mode 100644 index 0000000000000000000000000000000000000000..d43268236d82831113c134cba33167f001034f2e --- /dev/null +++ b/packages/kap-server/src/envelope.ts @@ -0,0 +1 @@ +export { okEnvelope, errEnvelope, type Envelope } from './protocol/envelope'; diff --git a/packages/kap-server/src/error-handler.ts b/packages/kap-server/src/error-handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..6481ec50a463bdf29626d749799664a02cb4246e --- /dev/null +++ b/packages/kap-server/src/error-handler.ts @@ -0,0 +1,36 @@ +import { ErrorCodes, isError2 } from '@moonshot-ai/agent-core-v2'; + +import { errEnvelope } from './envelope'; +import { ErrorCode } from './protocol/error-codes'; +import type { FastifyError } from 'fastify'; + +interface ErrorHandlerHost { + setErrorHandler( + handler: ( + err: FastifyError, + req: { id: string; log: { error: (obj: object | string, msg?: string) => void } }, + reply: { status(code: number): { send(payload: unknown): void } }, + ) => void, + ): unknown; +} + +export function installErrorHandler(app: ErrorHandlerHost): void { + app.setErrorHandler((err, req, reply) => { + const requestId = req.id; + if (isError2(err) && err.code === ErrorCodes.CONFIG_INVALID) { + reply + .status(200) + .send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack)); + return; + } + req.log.error({ err, request_id: requestId }, 'unhandled error'); + reply.status(200).send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + err.message !== undefined && err.message !== '' ? err.message : 'internal error', + requestId, + err.stack, + ), + ); + }); +} diff --git a/packages/kap-server/src/index.ts b/packages/kap-server/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5026680cffe1bc6cce244d3a57bd6b0b86344f53 --- /dev/null +++ b/packages/kap-server/src/index.ts @@ -0,0 +1,28 @@ +export { startServer } from './start'; +export type { ServerHostIdentity, ServerStartOptions, RunningServer } from './start'; +export { okEnvelope, errEnvelope } from './envelope'; +export type { Envelope } from './envelope'; +export { classify } from './security/bindClassify'; +export type { BindClass } from './security/bindClassify'; +export { rotateServerToken, serverTokenPath } from './services/auth/persistentToken'; +export { createServerLogger } from './services/pinoLoggerService'; +export type { + CreateLoggerOptions, + ServerLogger, + ServerLogLevel, +} from './services/pinoLoggerService'; +export { + createInstanceRegistry, + listLiveServerInstances, + getLiveServerInstance, + resolveServerInstancesDir, + DEFAULT_SERVER_DIR, + DEFAULT_SERVER_INSTANCES_DIR, + HEARTBEAT_INTERVAL_MS, +} from './instanceRegistry'; +export type { + IInstanceRegistry, + InstanceRegistration, + InstanceRegistryOptions, + ServerInstanceInfo, +} from './instanceRegistry'; diff --git a/packages/kap-server/src/instanceRegistry.ts b/packages/kap-server/src/instanceRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f5cc63900c84199e0848219ab8bdb7273d1b043 --- /dev/null +++ b/packages/kap-server/src/instanceRegistry.ts @@ -0,0 +1,283 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir, open, readdir, readFile, rename, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { resolveKimiHome } from '@moonshot-ai/agent-core-v2'; +import { ulid } from 'ulid'; + +export const HEARTBEAT_INTERVAL_MS = 15_000; + +export const DEFAULT_SERVER_DIR = join(resolveKimiHome(), 'server'); +export const DEFAULT_SERVER_INSTANCES_DIR = join(DEFAULT_SERVER_DIR, 'instances'); + +export interface ServerInstanceInfo { + readonly serverId: string; + readonly pid: number; + readonly host: string; + readonly port: number; + readonly startedAt: number; + readonly heartbeatAt: number; + readonly serverVersion?: string; +} + +interface ServerInstanceDisk { + server_id: string; + pid: number; + host: string; + port: number; + started_at: number; + heartbeat_at: number; + host_version?: string; +} + +export interface InstanceRegistration { + readonly serverId: string; + update(patch: { port?: number }): Promise; + release(): Promise; +} + +export interface IInstanceRegistry { + register( + info: Omit, + ): Promise; + listLive(): Promise; +} + +export interface InstanceRegistryOptions { + readonly instancesDir?: string; + readonly now?: () => number; + readonly heartbeatIntervalMs?: number; +} + +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ESRCH') return false; + if (code === 'EPERM') return true; + return true; + } +} + +function isInstanceFile(name: string): boolean { + return name.endsWith('.json'); +} + +function encode(info: ServerInstanceInfo): string { + const disk: ServerInstanceDisk = { + server_id: info.serverId, + pid: info.pid, + host: info.host, + port: info.port, + started_at: info.startedAt, + heartbeat_at: info.heartbeatAt, + ...(info.serverVersion !== undefined ? { host_version: info.serverVersion } : {}), + }; + return JSON.stringify(disk); +} + +function decode(raw: string): ServerInstanceInfo | undefined { + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.server_id === 'string' && + typeof parsed.pid === 'number' && + typeof parsed.host === 'string' && + typeof parsed.port === 'number' && + typeof parsed.started_at === 'number' && + typeof parsed.heartbeat_at === 'number' + ) { + return { + serverId: parsed.server_id, + pid: parsed.pid, + host: parsed.host, + port: parsed.port, + startedAt: parsed.started_at, + heartbeatAt: parsed.heartbeat_at, + ...(parsed.host_version !== undefined ? { serverVersion: parsed.host_version } : {}), + }; + } + return undefined; + } catch { + return undefined; + } +} + +async function readInstanceFile(filePath: string): Promise { + try { + return decode(await readFile(filePath, 'utf8')); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + return undefined; + } +} + +async function writeFileAtomic(filePath: string, content: string): Promise { + const tmpPath = `${filePath}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`; + let renamed = false; + try { + const fh = await open(tmpPath, 'w'); + try { + await fh.writeFile(content); + } finally { + await fh.close(); + } + await rename(tmpPath, filePath); + renamed = true; + } finally { + if (!renamed) { + try { + await unlink(tmpPath); + } catch { + } + } + } +} + +async function sweepStale(instancesDir: string): Promise { + let names: string[]; + try { + names = await readdir(instancesDir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + throw err; + } + await Promise.all( + names.filter(isInstanceFile).map(async (name) => { + const filePath = join(instancesDir, name); + const info = await readInstanceFile(filePath); + if (info === undefined || pidAlive(info.pid)) return; + try { + await unlink(filePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + }), + ); +} + +async function listLiveInternal(instancesDir: string): Promise { + let names: string[]; + try { + names = await readdir(instancesDir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + const live: ServerInstanceInfo[] = []; + await Promise.all( + names.filter(isInstanceFile).map(async (name) => { + const filePath = join(instancesDir, name); + const info = await readInstanceFile(filePath); + if (info === undefined) return; + if (!pidAlive(info.pid)) { + try { + await unlink(filePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + return; + } + live.push(info); + }), + ); + live.sort((a, b) => a.startedAt - b.startedAt); + return live; +} + +export function createInstanceRegistry(options: InstanceRegistryOptions = {}): IInstanceRegistry { + const instancesDir = options.instancesDir ?? DEFAULT_SERVER_INSTANCES_DIR; + const now = options.now ?? Date.now; + const heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS; + + return { + async register(info) { + const serverId = ulid(); + const filePath = join(instancesDir, `${serverId}.json`); + await mkdir(instancesDir, { recursive: true }); + await sweepStale(instancesDir); + + const state: { port: number; released: boolean } = { port: info.port, released: false }; + + let inflightWrites = 0; + let onWritesDrained: (() => void) | null = null; + + const write = async (): Promise => { + if (state.released) return; + inflightWrites += 1; + try { + const full: ServerInstanceInfo = { + serverId, + pid: info.pid, + host: info.host, + port: state.port, + startedAt: info.startedAt, + heartbeatAt: now(), + ...(info.serverVersion !== undefined ? { serverVersion: info.serverVersion } : {}), + }; + await writeFileAtomic(filePath, encode(full)); + } finally { + inflightWrites -= 1; + if (inflightWrites === 0) onWritesDrained?.(); + } + }; + + await write(); + + const timer = setInterval(() => { + void write().catch(() => { + }); + }, heartbeatIntervalMs); + timer.unref(); + + return { + serverId, + async update(patch) { + if (state.released) return; + if (patch.port !== undefined) state.port = patch.port; + await write(); + }, + async release() { + if (state.released) return; + state.released = true; + clearInterval(timer); + if (inflightWrites > 0) { + await new Promise((resolve) => { + onWritesDrained = resolve; + }); + } + try { + await unlink(filePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + }, + }; + }, + + listLive() { + return listLiveInternal(instancesDir); + }, + }; +} + +export function resolveServerInstancesDir(homeDir?: string): string { + return homeDir === undefined + ? DEFAULT_SERVER_INSTANCES_DIR + : join(homeDir, 'server', 'instances'); +} + +export async function listLiveServerInstances( + homeDir?: string, +): Promise { + return createInstanceRegistry({ instancesDir: resolveServerInstancesDir(homeDir) }).listLive(); +} + +export async function getLiveServerInstance( + homeDir?: string, +): Promise { + const live = await listLiveServerInstances(homeDir); + return live[0]; +} diff --git a/packages/kap-server/src/lib/contentDisposition.ts b/packages/kap-server/src/lib/contentDisposition.ts new file mode 100644 index 0000000000000000000000000000000000000000..09cbd1e1366b24aa8418c100d3036b3cb4c0badd --- /dev/null +++ b/packages/kap-server/src/lib/contentDisposition.ts @@ -0,0 +1,7 @@ +export function buildContentDisposition(name: string, mediaType?: string): string { + const disposition = /^(image|video|audio)\//.test(mediaType ?? '') ? 'inline' : 'attachment'; + if (/^[\w. ()+[\]-]+$/.test(name)) { + return `${disposition}; filename="${name}"`; + } + return disposition; +} diff --git a/packages/kap-server/src/lib/fileLaunch.ts b/packages/kap-server/src/lib/fileLaunch.ts new file mode 100644 index 0000000000000000000000000000000000000000..0116e465b74890d6ad37c17ac76fdd8e746a9f27 --- /dev/null +++ b/packages/kap-server/src/lib/fileLaunch.ts @@ -0,0 +1,239 @@ +import { existsSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import path from 'node:path'; + +export interface LaunchCommand { + readonly command: string; + readonly args: readonly string[]; + readonly shell?: boolean; + readonly windowsVerbatimArguments?: boolean; +} + +export function openFileCommandFor( + absolutePath: string, + line?: number, + env: Record = process.env, + platform: NodeJS.Platform = process.platform, +): LaunchCommand { + const editor = resolveEditorCommand(env); + if (editor !== undefined) { + const target = supportsLineTarget(editor) && line !== undefined + ? `${absolutePath}:${line}` + : absolutePath; + return { + command: `${editor} ${quoteShellArg(target, platform)}`, + args: [], + shell: true, + }; + } + + switch (platform) { + case 'darwin': + return { command: 'open', args: [absolutePath] }; + case 'win32': + return { command: 'cmd', args: ['/c', 'start', '""', absolutePath] }; + default: + return { command: 'xdg-open', args: [absolutePath] }; + } +} + +export function revealFileCommandFor( + absolutePath: string, + platform: NodeJS.Platform = process.platform, +): LaunchCommand { + switch (platform) { + case 'darwin': + return { command: 'open', args: ['-R', absolutePath] }; + case 'win32': + return { + command: 'explorer.exe', + args: [explorerSelectArg(absolutePath)], + windowsVerbatimArguments: true, + }; + default: + return { command: 'xdg-open', args: [path.dirname(absolutePath)] }; + } +} + +export type OpenInAppId = + | 'finder' + | 'cursor' + | 'vscode' + | 'iterm' + | 'terminal'; + +export const OPEN_IN_APP_IDS: readonly OpenInAppId[] = [ + 'finder', + 'cursor', + 'vscode', + 'iterm', + 'terminal', +]; + +export interface OpenInAppOptions { + readonly line?: number; + readonly isDirectory?: boolean; +} + +export function openInAppCommandFor( + appId: OpenInAppId, + absolutePath: string, + options: OpenInAppOptions = {}, + platform: NodeJS.Platform = process.platform, +): LaunchCommand { + switch (appId) { + case 'vscode': + return openInVsCodeLike('code', absolutePath, options.line, platform); + case 'cursor': + return openInVsCodeLike('cursor', absolutePath, options.line, platform); + case 'finder': + return openInFinder(absolutePath, options.isDirectory, platform); + case 'iterm': + return openInMacApp('iTerm', absolutePath, platform); + case 'terminal': + return openInMacApp('Terminal', absolutePath, platform); + } +} + +export function getAvailableOpenInApps( + platform: NodeJS.Platform = process.platform, +): readonly OpenInAppId[] { + return OPEN_IN_APP_IDS.filter((appId) => isOpenInAppAvailable(appId, platform)); +} + +function isOpenInAppAvailable( + appId: OpenInAppId, + platform: NodeJS.Platform, +): boolean { + switch (appId) { + case 'finder': + case 'terminal': + return platform === 'darwin'; + case 'iterm': + if (platform !== 'darwin') return false; + return ( + existsSync('/Applications/iTerm.app') || + existsSync(`${process.env['HOME'] ?? ''}/Applications/iTerm.app`) + ); + case 'vscode': + return commandExists('code', platform); + case 'cursor': + return commandExists('cursor', platform); + } +} + +function commandExists(command: string, platform: NodeJS.Platform): boolean { + try { + if (platform === 'win32') { + const result = spawnSync('cmd', ['/c', 'where', command], { + stdio: 'ignore', + }); + return result.status === 0; + } + const result = spawnSync('command', ['-v', command], { + stdio: 'ignore', + shell: true, + }); + return result.status === 0; + } catch { + return false; + } +} + +function openInVsCodeLike( + binary: string, + absolutePath: string, + line: number | undefined, + platform: NodeJS.Platform, +): LaunchCommand { + const target = line !== undefined ? `${absolutePath}:${line}` : absolutePath; + const flag = line !== undefined ? '-g ' : ''; + return { + command: `${binary} ${flag}${quoteShellArg(target, platform)}`, + args: [], + shell: true, + }; +} + +function openInFinder( + absolutePath: string, + isDirectory: boolean | undefined, + platform: NodeJS.Platform, +): LaunchCommand { + switch (platform) { + case 'darwin': + return isDirectory + ? { command: 'open', args: [absolutePath] } + : { command: 'open', args: ['-R', absolutePath] }; + case 'win32': + return isDirectory + ? { command: 'explorer.exe', args: [absolutePath] } + : { + command: 'explorer.exe', + args: [explorerSelectArg(absolutePath)], + windowsVerbatimArguments: true, + }; + default: + return { + command: 'xdg-open', + args: [isDirectory ? absolutePath : path.dirname(absolutePath)], + }; + } +} + +function openInMacApp( + appName: string, + absolutePath: string, + platform: NodeJS.Platform, +): LaunchCommand { + if (platform === 'darwin') { + return { command: 'open', args: ['-a', appName, absolutePath] }; + } + return openFileCommandFor(absolutePath, undefined, process.env, platform); +} + +export async function launchDetached(cmd: LaunchCommand): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const child = spawn(cmd.command, cmd.args, { + detached: true, + stdio: 'ignore', + shell: cmd.shell, + windowsVerbatimArguments: cmd.windowsVerbatimArguments, + }); + child.once('error', (err) => { + if (settled) return; + settled = true; + reject(err); + }); + child.once('spawn', () => { + if (settled) return; + settled = true; + child.unref(); + resolve(); + }); + }); +} + +function resolveEditorCommand(env: Record): string | undefined { + for (const key of ['KIMI_CODE_EDITOR', 'VISUAL', 'EDITOR']) { + const value = env[key]; + if (typeof value === 'string' && value.trim().length > 0) return value.trim(); + } + return undefined; +} + +function supportsLineTarget(command: string): boolean { + const first = command.trim().split(/\s+/)[0] ?? ''; + return /(?:^|\/)(code|cursor|windsurf)(?:\.cmd|\.exe)?$/i.test(first); +} + +function explorerSelectArg(absolutePath: string): string { + const trimmed = absolutePath.replace(/\\+$/, ''); + return `/select,"${trimmed}"`; +} + +function quoteShellArg(value: string, platform: NodeJS.Platform): string { + if (platform === 'win32') return `"${value.replaceAll('"', '\\"')}"`; + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/packages/kap-server/src/lib/httpRange.ts b/packages/kap-server/src/lib/httpRange.ts new file mode 100644 index 0000000000000000000000000000000000000000..68e312902689d8fc8869edad98c5ab35b12c506f --- /dev/null +++ b/packages/kap-server/src/lib/httpRange.ts @@ -0,0 +1,44 @@ +export function pickHeader( + headers: Record, + name: string, +): string | undefined { + const v = headers[name]; + if (v === undefined) return undefined; + return Array.isArray(v) ? (v[0] as string | undefined) : (v as string); +} + +export function parseRangeHeader( + raw: string | undefined, + size: number, +): { start: number; end: number; length: number } | null { + if (raw === undefined) return null; + if (!raw.startsWith('bytes=')) return null; + const spec = raw.slice('bytes='.length); + if (spec.includes(',')) return null; + const dash = spec.indexOf('-'); + if (dash < 0) return null; + const leftRaw = spec.slice(0, dash); + const rightRaw = spec.slice(dash + 1); + if (leftRaw === '' && rightRaw === '') return null; + let start: number; + let end: number; + if (leftRaw === '') { + const suffix = Number.parseInt(rightRaw, 10); + if (!Number.isFinite(suffix) || suffix <= 0) return null; + start = Math.max(0, size - suffix); + end = size - 1; + } else { + const a = Number.parseInt(leftRaw, 10); + if (!Number.isFinite(a) || a < 0) return null; + start = a; + if (rightRaw === '') { + end = size - 1; + } else { + const b = Number.parseInt(rightRaw, 10); + if (!Number.isFinite(b) || b < a) return null; + end = Math.min(b, size - 1); + } + } + if (start >= size || start > end) return null; + return { start, end, length: end - start + 1 }; +} diff --git a/packages/kap-server/src/lib/promptMedia.ts b/packages/kap-server/src/lib/promptMedia.ts new file mode 100644 index 0000000000000000000000000000000000000000..6afe408ddff3a373dd113316d9d97d68752256fb --- /dev/null +++ b/packages/kap-server/src/lib/promptMedia.ts @@ -0,0 +1,567 @@ +import { createHash } from 'node:crypto'; +import { createReadStream, createWriteStream, type Stats } from 'node:fs'; +import { mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises'; +import { basename, extname, isAbsolute, join } from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import { + buildDaemonFileUrl, + buildImageCompressionCaption, + buildUnsupportedImageNotice, + compressBase64ForModel, + compressImageForModel, + decodeBase64Prefix, + Error2, + fileNotFoundError, + isModelAcceptedImageMime, + MAX_IMAGE_DECODE_BYTES, + normalizeImageMime, + persistOriginalImage, + resolveEffectiveImageMime, + unsupportedImageMimeFromUrl, + type ContentPart, + type GetResult, + type IFileService, + type ISessionMediaStore, + type ITelemetryService, + type PromptFileAttachment, +} from '@moonshot-ai/agent-core-v2'; +import { sniffMediaFromMagic } from '@moonshot-ai/agent-core-v2/agent/media/file-type'; +import { + IMAGE_MIME_BY_SUFFIX, + VIDEO_MIME_BY_SUFFIX, +} from '@moonshot-ai/agent-core-v2/agent/media/mediaRef'; +import { isSensitiveFile } from '@moonshot-ai/agent-core-v2/tool/path-access'; + +import type { PromptSubmission } from '../protocol/rest-prompt'; + +type WireContent = PromptSubmission['content']; + +export async function assertPromptFileRefs(content: WireContent, store: IFileService): Promise { + for (const part of content) { + if (part.type === 'file') { + if (part.file_id !== undefined) await store.get(part.file_id); + } else if ((part.type === 'image' || part.type === 'video') && part.source.kind === 'file') { + const file = await store.get(part.source.file_id); + assertMediaFile(file, part.type); + } + } +} + +export async function assertPromptPathRefs(content: WireContent): Promise { + for (const part of content) { + const path = promptPartPath(part); + if (path === undefined) continue; + if (!isAbsolute(path)) { + throw new Error2('validation.failed', `attachment path must be absolute: ${path}`); + } + const { resolvedPath } = await statAttachmentFile(path); + if (isSensitiveFile(resolvedPath)) { + throw new Error2('validation.failed', `attachment path is a sensitive file: ${path}`); + } + } +} + +export function contentHasPathRefs(content: WireContent): boolean { + return content.some((part) => promptPartPath(part) !== undefined); +} + +function promptPartPath(part: WireContent[number]): string | undefined { + if (part.type === 'file') return part.path; + if ((part.type === 'image' || part.type === 'video') && part.source.kind === 'path') { + return part.source.path; + } + return undefined; +} + +async function statAttachmentFile(sourcePath: string): Promise<{ resolvedPath: string; info: Stats }> { + const resolvedPath = await realpath(sourcePath).catch(() => undefined); + if (resolvedPath === undefined) throw fileNotFoundError(sourcePath); + const info = await stat(resolvedPath).catch(() => undefined); + if (info === undefined || !info.isFile()) throw fileNotFoundError(sourcePath); + return { resolvedPath, info }; +} + +function isFsError(error: unknown): boolean { + return error instanceof Error && typeof (error as NodeJS.ErrnoException).code === 'string'; +} + +export async function resolvePromptSessionMediaRefs( + content: WireContent, + store: ISessionMediaStore, +): Promise { + const resolved: WireContent = []; + let changed = false; + for (const part of content) { + if ( + (part.type !== 'image' && part.type !== 'video') || + part.source.kind !== 'session_media' + ) { + resolved.push(part); + continue; + } + const file = await store.open(part.source.file_id); + if (file === undefined) throw fileNotFoundError(part.source.file_id); + if (part.name === undefined) { + resolved.push({ ...part, name: file.name }); + changed = true; + } else { + resolved.push(part); + } + } + return changed ? resolved : content; +} + +export function contentToCoreParts(content: WireContent): ContentPart[] { + const parts: ContentPart[] = []; + for (const part of content) { + if (part.type === 'text') parts.push({ type: 'text', text: part.text }); + else if (part.type === 'image' && part.source.kind === 'url') parts.push({ type: 'image_url', imageUrl: { url: part.source.url, id: part.source.id, name: part.name } }); + else if (part.type === 'image' && part.source.kind === 'base64') parts.push({ type: 'image_url', imageUrl: { url: `data:${part.source.media_type};base64,${part.source.data}`, name: part.name } }); + else if (part.type === 'image' && part.source.kind === 'session_media') parts.push({ type: 'image_url', imageUrl: { url: buildDaemonFileUrl(part.source.file_id), id: part.source.file_id, name: part.name } }); + else if (part.type === 'video' && part.source.kind === 'url') parts.push({ type: 'video_url', videoUrl: { url: part.source.url, id: part.source.id, name: part.name } }); + else if (part.type === 'video' && part.source.kind === 'base64') parts.push({ type: 'video_url', videoUrl: { url: `data:${part.source.media_type};base64,${part.source.data}`, name: part.name } }); + else if (part.type === 'video' && part.source.kind === 'session_media') parts.push({ type: 'video_url', videoUrl: { url: buildDaemonFileUrl(part.source.file_id), id: part.source.file_id, name: part.name } }); + } + return parts; +} + +export interface ResolvePromptMediaOptions { + readonly resolveOriginalsDir?: () => Promise; + readonly resolveAttachmentsDir?: () => Promise; + readonly telemetry?: ITelemetryService; + readonly providerType?: string; +} + +export interface PromptMediaPreparation { + readonly content: WireContent; + readonly attachments: readonly PromptFileAttachment[]; + readonly discard: () => Promise; +} + +export async function resolvePromptMediaFiles( + input: WireContent, + store: IFileService, + cacheDir: string, + options: ResolvePromptMediaOptions = {}, +): Promise { + const ownedFileIds = new Set(); + let discarded = false; + const discard = async (): Promise => { + if (discarded) return; + discarded = true; + await Promise.all( + [...ownedFileIds].map((fileId) => store.delete(fileId).catch(() => undefined)), + ); + }; + let changed = false; + let originalsDir: string | undefined; + let originalsDirResolved = false; + const resolveOriginalsDir = async (): Promise => { + if (!originalsDirResolved) { + originalsDirResolved = true; + originalsDir = await options.resolveOriginalsDir?.().catch(() => undefined); + } + return originalsDir; + }; + let attachmentsDir: string | undefined; + let attachmentsDirResolved = false; + const resolveAttachmentsDir = async (): Promise => { + if (!attachmentsDirResolved) { + attachmentsDirResolved = true; + attachmentsDir = await options.resolveAttachmentsDir?.().catch(() => undefined); + } + return attachmentsDir ?? cacheDir; + }; + const attachments: PromptFileAttachment[] = []; + const content: WireContent = []; + try { + for (const part of input) { + if (part.type === 'image' && part.source.kind === 'base64') { + const effectiveMime = resolveEffectiveImageMime( + part.source.media_type, + decodeBase64Prefix(part.source.data), + ); + if (!isModelAcceptedImageMime(effectiveMime, options.providerType)) { + const bytes = Buffer.from(part.source.data, 'base64'); + const name = part.name ?? `image.${imageExtensionForMime(effectiveMime)}`; + const persisted = await persistAttachmentBytes( + bytes, + `${createHash('sha256').update(bytes).digest('hex').slice(0, 32)}-${sanitizeAttachmentName(name)}`, + await resolveAttachmentsDir(), + ); + content.push({ + type: 'text', + text: persisted === null + ? buildUnsupportedImageNotice(effectiveMime, undefined, options.providerType) + : buildAttachedFileNotice(name, effectiveMime, bytes.length, persisted), + }); + if (persisted !== null) { + attachments.push({ name, mediaType: effectiveMime, size: bytes.length, path: persisted }); + } + changed = true; + continue; + } + const canonicalMime = normalizeImageMime(effectiveMime); + const compressed = await compressBase64ForModel(part.source.data, canonicalMime, { + telemetry: options.telemetry, + telemetrySource: 'prompt_inline', + }); + if (compressed.changed) { + const dir = await resolveOriginalsDir(); + const originalPath = await persistOriginalImage( + Buffer.from(part.source.data, 'base64'), + part.source.media_type, + { dir }, + ); + content.push({ + type: 'text', + text: buildImageCompressionCaption({ + original: { + width: compressed.originalWidth, + height: compressed.originalHeight, + byteLength: compressed.originalByteLength, + mimeType: part.source.media_type, + }, + final: { + width: compressed.width, + height: compressed.height, + byteLength: compressed.finalByteLength, + mimeType: compressed.mimeType, + }, + originalPath, + }), + }); + content.push({ + type: 'image', + source: { kind: 'base64', media_type: compressed.mimeType, data: compressed.base64 }, + name: part.name, + }); + changed = true; + } else { + content.push(part); + } + continue; + } + + if (part.type === 'image' && part.source.kind === 'url') { + const extMime = unsupportedImageMimeFromUrl(part.source.url, options.providerType); + if (extMime !== null) { + content.push({ + type: 'text', + text: buildUnsupportedImageNotice(extMime, part.source.url, options.providerType), + }); + changed = true; + continue; + } + content.push(part); + continue; + } + + if (part.type === 'file') { + if (part.path !== undefined) { + const sourcePath = part.path; + const { info } = await statAttachmentFile(sourcePath); + const name = part.name ?? basename(sourcePath); + const mediaType = part.media_type ?? 'application/octet-stream'; + content.push({ + type: 'text', + text: buildAttachedFileNotice(name, mediaType, info.size, sourcePath), + }); + attachments.push({ name, mediaType, size: info.size, path: sourcePath }); + changed = true; + continue; + } + if (part.file_id === undefined) { + throw new Error2('validation.failed', 'file part requires file_id or path'); + } + const file = await store.get(part.file_id); + const attachedPath = await materializeAttachmentToDir(file, await resolveAttachmentsDir()); + content.push({ + type: 'text', + text: buildAttachedFileNotice(file.meta.name, file.meta.media_type, file.meta.size, attachedPath), + }); + attachments.push({ + name: file.meta.name, + mediaType: file.meta.media_type, + size: file.meta.size, + path: attachedPath, + }); + changed = true; + continue; + } + + if (part.type === 'image' && part.source.kind === 'path') { + const sourcePath = part.source.path; + const { resolvedPath, info } = await statAttachmentFile(sourcePath); + if (info.size > MAX_IMAGE_DECODE_BYTES) { + throw new Error2( + 'validation.failed', + `${sourcePath} is ${info.size} bytes, over the ${MAX_IMAGE_DECODE_BYTES}-byte image decode limit — attach it as a file instead`, + ); + } + const data = await readFile(resolvedPath).catch((error: unknown) => { + if (isFsError(error)) throw fileNotFoundError(sourcePath); + throw error; + }); + const name = part.name ?? basename(sourcePath); + const declared = pathMediaMime(sourcePath, data, 'image'); + if (!declared.startsWith('image/')) { + throw new Error2('validation.failed', `${sourcePath} is ${declared}, not an image`); + } + let mediaType = resolveEffectiveImageMime(declared, data); + if (!isModelAcceptedImageMime(mediaType, options.providerType)) { + content.push({ + type: 'text', + text: buildAttachedFileNotice(name, mediaType, data.length, sourcePath), + }); + attachments.push({ name, mediaType, size: data.length, path: sourcePath }); + changed = true; + continue; + } + mediaType = normalizeImageMime(mediaType); + const compressed = await compressImageForModel(data, mediaType, { + telemetry: options.telemetry, + telemetrySource: 'prompt_file', + }); + if (compressed.changed) { + content.push({ + type: 'text', + text: buildImageCompressionCaption({ + original: { + width: compressed.originalWidth, + height: compressed.originalHeight, + byteLength: compressed.originalByteLength, + mimeType: mediaType, + }, + final: { + width: compressed.width, + height: compressed.height, + byteLength: compressed.finalByteLength, + mimeType: compressed.mimeType, + }, + originalPath: sourcePath, + }), + }); + } + const saved = await store.save( + Readable.from(compressed.changed ? Buffer.from(compressed.data) : data), + compressed.changed ? compressedUploadName(name, compressed.mimeType) : name, + { mimeType: compressed.changed ? compressed.mimeType : mediaType }, + ); + ownedFileIds.add(saved.id); + content.push({ + type: 'image', + source: { kind: 'url', url: buildDaemonFileUrl(saved.id) }, + name: part.name ?? name, + }); + changed = true; + continue; + } + + if (part.type === 'video' && part.source.kind === 'path') { + const sourcePath = part.source.path; + const { resolvedPath } = await statAttachmentFile(sourcePath); + const mediaType = pathMediaMime(sourcePath, undefined, 'video'); + if (!mediaType.startsWith('video/')) { + throw new Error2('validation.failed', `${sourcePath} is ${mediaType}, not a video`); + } + const saved = await store + .save(createReadStream(resolvedPath), basename(sourcePath), { mimeType: mediaType }) + .catch((error: unknown) => { + if (isFsError(error)) throw fileNotFoundError(sourcePath); + throw error; + }); + ownedFileIds.add(saved.id); + content.push({ + type: 'video', + source: { kind: 'url', url: buildDaemonFileUrl(saved.id) }, + name: part.name ?? basename(sourcePath), + }); + changed = true; + continue; + } + + if ((part.type !== 'image' && part.type !== 'video') || part.source.kind !== 'file') { + content.push(part); + continue; + } + + const file = await store.get(part.source.file_id); + assertMediaFile(file, part.type); + if (part.type === 'image') { + const data = await readFileOrStream(file); + let mediaType = file.meta.media_type; + mediaType = resolveEffectiveImageMime(mediaType, data); + if (!isModelAcceptedImageMime(mediaType, options.providerType)) { + const name = part.name ?? file.meta.name; + const persisted = await persistAttachmentBytes( + data, + `${file.meta.id}-${sanitizeAttachmentName(name)}`, + await resolveAttachmentsDir(), + ); + content.push({ + type: 'text', + text: persisted === null + ? buildUnsupportedImageNotice(mediaType, name, options.providerType) + : buildAttachedFileNotice(name, mediaType, file.meta.size, persisted), + }); + if (persisted !== null) { + attachments.push({ + name, + mediaType, + size: file.meta.size, + path: persisted, + }); + } + changed = true; + continue; + } + mediaType = normalizeImageMime(mediaType); + const compressed = await compressImageForModel(data, mediaType, { + telemetry: options.telemetry, + telemetrySource: 'prompt_file', + }); + if (compressed.changed) { + const dir = await resolveOriginalsDir(); + const originalPath = await persistOriginalImage(data, mediaType, { dir }); + content.push({ + type: 'text', + text: buildImageCompressionCaption({ + original: { + width: compressed.originalWidth, + height: compressed.originalHeight, + byteLength: compressed.originalByteLength, + mimeType: mediaType, + }, + final: { + width: compressed.width, + height: compressed.height, + byteLength: compressed.finalByteLength, + mimeType: compressed.mimeType, + }, + originalPath, + }), + }); + } + let finalFile = file; + if (compressed.changed) { + const saved = await store.save( + Readable.from(Buffer.from(compressed.data)), + compressedUploadName(file.meta.name, compressed.mimeType), + { mimeType: compressed.mimeType }, + ); + ownedFileIds.add(saved.id); + finalFile = await store.get(saved.id); + } + content.push({ + type: 'image', + source: { kind: 'url', url: buildDaemonFileUrl(finalFile.meta.id) }, + name: part.name ?? file.meta.name, + }); + changed = true; + continue; + } + + content.push({ + type: 'video', + source: { kind: 'url', url: buildDaemonFileUrl(file.meta.id) }, + name: part.name ?? file.meta.name, + }); + changed = true; + } + return { content: changed ? content : input, attachments, discard }; + } catch (error) { + await discard(); + throw error; + } +} + +function compressedUploadName(originalName: string, mimeType: string): string { + const base = originalName.replace(/\.[^./\\]*$/, ''); + return `${base.length > 0 ? base : 'image'}.${imageExtensionForMime(mimeType)}`; +} + +const ATTACHMENT_NAME_MAX = 100; + +function sanitizeAttachmentName(name: string): string { + const cleaned = name + .replaceAll(/[\\/]/g, '_') + .replaceAll(/[\u0000-\u001F\u007F]/g, '') + .replace(/^\.+/, '') + .trim() + .slice(0, ATTACHMENT_NAME_MAX); + return cleaned.length > 0 ? cleaned : 'attachment'; +} + +async function materializeAttachmentToDir(file: GetResult, dir: string): Promise { + await mkdir(dir, { recursive: true }); + const target = join(dir, `${file.meta.id}-${sanitizeAttachmentName(file.meta.name)}`); + const info = await stat(target).catch(() => undefined); + if (info?.size === file.meta.size) return target; + + await pipeline(file.stream(), createWriteStream(target)); + return target; +} + +async function persistAttachmentBytes( + bytes: Uint8Array, + name: string, + dir: string, +): Promise { + try { + await mkdir(dir, { recursive: true }); + const target = join(dir, name); + const info = await stat(target).catch(() => undefined); + if (info?.size !== bytes.length) await writeFile(target, bytes); + return target; + } catch { + return null; + } +} + +function imageExtensionForMime(mediaType: string): string { + const subtype = mediaType.split('/')[1]?.toLowerCase().split('+')[0] ?? ''; + const ext = subtype.replaceAll(/[^a-z0-9-]/g, ''); + return ext.length > 0 ? ext : 'img'; +} + +function pathMediaMime( + sourcePath: string, + data: Uint8Array | undefined, + kind: 'image' | 'video', +): string { + const suffix = extname(sourcePath).toLowerCase(); + if (kind === 'image') { + if (suffix === '.svg') return 'image/svg+xml'; + const declared = IMAGE_MIME_BY_SUFFIX[suffix]; + if (declared !== undefined) return declared; + } else { + const declared = VIDEO_MIME_BY_SUFFIX[suffix]; + if (declared !== undefined) return declared; + } + const sniffed = data === undefined ? null : sniffMediaFromMagic(data); + return sniffed?.mimeType ?? 'application/octet-stream'; +} + +function buildAttachedFileNotice(name: string, mediaType: string, size: number, path: string): string { + return `Attached file "${name}" (${mediaType}, ${size} bytes): ${path} — open it with the Read tool`; +} + +async function readFileOrStream(file: GetResult): Promise { + const chunks: Buffer[] = []; + for await (const chunk of file.stream()) { + chunks.push(Buffer.from(chunk as string | Uint8Array)); + } + return Buffer.concat(chunks); +} + +function assertMediaFile(file: GetResult, expected: 'image' | 'video'): void { + const prefix = expected === 'video' ? 'video/' : 'image/'; + if (file.meta.media_type.toLowerCase().startsWith(prefix)) return; + throw new Error2( + 'validation.failed', + `file ${file.meta.id} is ${file.meta.media_type}, not ${expected === 'video' ? 'a video' : 'an image'}`, + ); +} diff --git a/packages/kap-server/src/lib/requestLog.ts b/packages/kap-server/src/lib/requestLog.ts new file mode 100644 index 0000000000000000000000000000000000000000..80ace57577b006c8b70be1424948121fa9b34194 --- /dev/null +++ b/packages/kap-server/src/lib/requestLog.ts @@ -0,0 +1,7 @@ +import type { Logger } from 'pino'; + +export type RequestLogger = Pick; + +export function requestLog(req: { id: string }): RequestLogger | undefined { + return (req as { log?: RequestLogger }).log; +} diff --git a/packages/kap-server/src/middleware/auth.ts b/packages/kap-server/src/middleware/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..0bb9ea42f8437c6a45deafe8cb84529d8005525c --- /dev/null +++ b/packages/kap-server/src/middleware/auth.ts @@ -0,0 +1,90 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; + +import { errEnvelope } from '../envelope'; +import type { IAuthTokenService } from '../services/auth/authTokenService'; +import type { CredentialValidator } from '../services/auth/credentials'; +import { + AUTH_RATE_LIMIT_CODE, + AUTH_RATE_LIMIT_MSG, + type AuthFailureLimiter, +} from './rateLimit'; + +const AUTH_ERROR_CODE = 40101; +const AUTH_ERROR_MSG = 'Unauthorized'; +const REDACTED = '[redacted]'; +const BEARER_PREFIX = 'Bearer '; + +export interface AuthHookOptions { + readonly isBypassed?: (req: FastifyRequest) => boolean; + readonly limiter?: Pick; + readonly validateCredential?: CredentialValidator; +} + +function decodeRequestPath(rawUrl: string): string | null { + const path = rawUrl.split('?', 1)[0] ?? rawUrl; + try { + return decodeURIComponent(path); + } catch { + return null; + } +} + +function defaultIsBypassed(req: FastifyRequest): boolean { + if (req.method === 'OPTIONS') { + return true; + } + const path = decodeRequestPath(req.url); + if (path === null) { + return false; + } + if (req.method === 'GET' && path === '/api/v1/healthz') { + return true; + } + const isApi = path.startsWith('/api/'); + const isMeta = path === '/openapi.json' || path === '/asyncapi.json'; + return !isApi && !isMeta; +} + +function extractBearer(header: string | undefined): string | null { + if (header === undefined || !header.startsWith(BEARER_PREFIX)) { + return null; + } + const token = header.slice(BEARER_PREFIX.length); + return token.length === 0 ? null : token; +} + +export function createAuthHook( + authTokenService: IAuthTokenService, + opts?: AuthHookOptions, +): (req: FastifyRequest, reply: FastifyReply) => Promise { + const isBypassed = opts?.isBypassed ?? defaultIsBypassed; + const validateCredential: CredentialValidator = + opts?.validateCredential ?? ((candidate) => authTokenService.isValid(candidate)); + + return async (req, reply) => { + if (opts?.limiter?.isBanned(req.ip) === true) { + return reply.code(429).send(errEnvelope(AUTH_RATE_LIMIT_CODE, AUTH_RATE_LIMIT_MSG, req.id)); + } + + const header = req.headers.authorization; + const token = extractBearer(header); + + if (isBypassed(req)) { + return; + } + + if (header !== undefined) { + req.headers.authorization = REDACTED; + } + + if (token === null) { + opts?.limiter?.recordFailure(req.ip); + return reply.code(401).send(errEnvelope(AUTH_ERROR_CODE, AUTH_ERROR_MSG, req.id)); + } + + if (!(await validateCredential(token))) { + opts?.limiter?.recordFailure(req.ip); + return reply.code(401).send(errEnvelope(AUTH_ERROR_CODE, AUTH_ERROR_MSG, req.id)); + } + }; +} diff --git a/packages/kap-server/src/middleware/defineRoute.ts b/packages/kap-server/src/middleware/defineRoute.ts new file mode 100644 index 0000000000000000000000000000000000000000..a47f67ad3b2b710e80d67660516e9726e68fc407 --- /dev/null +++ b/packages/kap-server/src/middleware/defineRoute.ts @@ -0,0 +1,205 @@ +import { z } from 'zod'; + +import { jsonSchema, openApiDocumentJsonSchema } from './schema'; +import { validateBody, validateParams, validateQuery } from './validate'; + +function toFastifyPath(openApiPath: string): string { + return openApiPath.replace(/\{([^}]+)\}/g, ':$1'); +} + +function buildErrorEnvelopeSchema( + code: number, + dataSchema: z.ZodTypeAny = z.null(), + detailsSchema?: z.ZodTypeAny, +): z.ZodTypeAny { + const base = z.object({ + code: z.literal(code), + msg: z.string(), + data: dataSchema, + request_id: z.string(), + }); + + if (detailsSchema) { + return base.extend({ + details: detailsSchema.nullable().optional(), + }); + } + + return base.extend({ + details: z.unknown().optional(), + }); +} + +function buildSuccessEnvelopeSchema(successDataSchema: z.ZodTypeAny): z.ZodTypeAny { + return z.object({ + code: z.literal(0), + msg: z.string(), + data: successDataSchema, + request_id: z.string(), + details: z.unknown().optional(), + }); +} + +function buildUnifiedResponseSchema( + successDataSchema: z.ZodTypeAny, + errors: Record, +): Record { + const errorEntries = Object.entries(errors) + .map(([code, cfg]) => [Number(code), cfg] as const) + .sort((a, b) => a[0] - b[0]); + + if (errorEntries.length === 0) { + return openApiDocumentJsonSchema( + buildSuccessEnvelopeSchema(successDataSchema), + 'output', + ); + } + + const variants: Record[] = []; + + variants.push( + openApiDocumentJsonSchema( + buildSuccessEnvelopeSchema(successDataSchema), + 'output', + ), + ); + + for (const [code, cfg] of errorEntries) { + variants.push( + openApiDocumentJsonSchema( + buildErrorEnvelopeSchema(code, cfg.dataSchema, cfg.detailsSchema), + 'output', + ), + ); + } + + return { oneOf: variants }; +} + +type InferZod = T extends z.ZodTypeAny + ? z.infer + : unknown; + +export interface DefineRouteOptions< + TBody extends z.ZodTypeAny | undefined, + TParams extends z.ZodTypeAny | undefined, + TQuery extends z.ZodTypeAny | undefined, + TSuccessData extends z.ZodTypeAny | undefined, +> { + method: string; + path: string; + body?: TBody; + params?: TParams; + querystring?: TQuery; + success?: { data: TSuccessData }; + errors?: Record; + rawResponse?: Record>; + description?: string; + summary?: string; + tags?: string[]; + operationId?: string; + consumes?: string[]; +} + +export interface RouteDefinition< + TBody extends z.ZodTypeAny | undefined, + TParams extends z.ZodTypeAny | undefined, + TQuery extends z.ZodTypeAny | undefined, +> { + method: string; + path: string; + options: { + preHandler: unknown[]; + schema: Record; + }; + handler: ( + req: { + id: string; + body: InferZod; + params: InferZod; + headers: Record; + } & (TQuery extends z.ZodTypeAny ? { query: InferZod } : {}), + reply: { send(payload: unknown): unknown }, + ) => Promise | void; +} + +export function defineRoute< + TBody extends z.ZodTypeAny | undefined, + TParams extends z.ZodTypeAny | undefined, + TQuery extends z.ZodTypeAny | undefined, + TSuccessData extends z.ZodTypeAny | undefined, +>( + options: DefineRouteOptions, + handler: RouteDefinition['handler'], +): RouteDefinition { + const preHandler: unknown[] = []; + + if (options.params) { + preHandler.push(validateParams(options.params)); + } + if (options.body) { + preHandler.push(validateBody(options.body)); + } + if (options.querystring) { + preHandler.push(validateQuery(options.querystring)); + } + + const schema: Record = {}; + + if (options.body) { + schema['body'] = jsonSchema(options.body); + } + if (options.params) { + schema['params'] = jsonSchema(options.params); + } + if (options.querystring) { + schema['querystring'] = jsonSchema(options.querystring); + } + + const hasResponse = + options.success !== undefined || + (options.errors !== undefined && Object.keys(options.errors).length > 0) || + options.rawResponse !== undefined; + + if (hasResponse) { + const responses: Record = {}; + + if (options.success || options.errors) { + responses['200'] = buildUnifiedResponseSchema( + options.success?.data ?? z.null(), + options.errors ?? {}, + ); + } + + if (options.rawResponse) { + for (const [code, rawSchema] of Object.entries(options.rawResponse)) { + responses[String(code)] = rawSchema; + } + } + + schema['response'] = responses; + } + + if (options.description) { + schema['description'] = options.description; + } + if (options.summary) { + schema['summary'] = options.summary; + } + if (options.tags) { + schema['tags'] = options.tags; + } + if (options.operationId) { + schema['operationId'] = options.operationId; + } + if (options.consumes) { + schema['consumes'] = options.consumes; + } + + return { + method: options.method, + path: toFastifyPath(options.path), + options: { preHandler, schema }, + handler, + }; +} diff --git a/packages/kap-server/src/middleware/hostnames.ts b/packages/kap-server/src/middleware/hostnames.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5fa8839ade6d0d1af84f0273c0cda17bc1fd657 --- /dev/null +++ b/packages/kap-server/src/middleware/hostnames.ts @@ -0,0 +1,108 @@ +import net from 'node:net'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +import { errEnvelope } from '../envelope'; + +const HOST_ERROR_CODE = 40301; + +export interface HostCheckOptions { + readonly boundHost?: string; + readonly extra?: readonly string[]; + readonly disable?: boolean; +} + +export interface HostCheck { + readonly onRequest: (req: FastifyRequest, reply: FastifyReply) => Promise; + readonly isAllowed: (host: string | undefined) => boolean; +} + +export function parseAllowedHosts(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env['KIMI_CODE_ALLOWED_HOSTS']; + if (raw === undefined) { + return []; + } + return raw + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +export function isHostCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env['KIMI_CODE_DISABLE_HOST_CHECK'] === '1'; +} + +export function stripPort(host: string): string { + if (host.startsWith('[')) { + const end = host.indexOf(']'); + return (end === -1 ? host : host.slice(0, end + 1)).toLowerCase(); + } + const firstColon = host.indexOf(':'); + if (firstColon === -1) { + return host.toLowerCase(); + } + const lastColon = host.lastIndexOf(':'); + if (firstColon === lastColon) { + const after = host.slice(lastColon + 1); + if (after.length > 0 && /^\d+$/.test(after)) { + return host.slice(0, lastColon).toLowerCase(); + } + } + return host.toLowerCase(); +} + +export function formatHostErrorMessage(host: string | undefined): string { + const normalizedHost = host === undefined || host.length === 0 ? undefined : stripPort(host); + const hostLabel = normalizedHost ?? ''; + const hostArg = normalizedHost ?? ''; + return `Invalid Host header: ${hostLabel}; allow this host with KIMI_CODE_ALLOWED_HOSTS=${hostArg} or 'kimi web --allowed-host ${hostArg}'.`; +} + +export function isAllowedHost(host: string | undefined, opts: HostCheckOptions): boolean { + if (opts.disable === true) { + return true; + } + if (host === undefined || host.length === 0) { + return false; + } + const h = stripPort(host); + + if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]') { + return true; + } + if (h.endsWith('.localhost')) { + return true; + } + if (net.isIP(h) !== 0) { + return true; + } + if (opts.boundHost !== undefined && h === stripPort(opts.boundHost)) { + return true; + } + if (opts.extra !== undefined) { + for (const entry of opts.extra) { + if (entry.startsWith('.')) { + const base = entry.slice(1); + if (h === base || h.endsWith(entry)) { + return true; + } + } else if (h === entry) { + return true; + } + } + } + return false; +} + +export function createHostCheck(opts: HostCheckOptions): HostCheck { + const isAllowed = (host: string | undefined): boolean => isAllowedHost(host, opts); + const onRequest = async ( + req: FastifyRequest, + reply: FastifyReply, + ): Promise => { + if (!isAllowed(req.headers.host)) { + return reply.code(403).send(errEnvelope(HOST_ERROR_CODE, formatHostErrorMessage(req.headers.host), req.id)); + } + }; + return { onRequest, isAllowed }; +} diff --git a/packages/kap-server/src/middleware/origin.ts b/packages/kap-server/src/middleware/origin.ts new file mode 100644 index 0000000000000000000000000000000000000000..245e495bcd3f92534dc0430e9c78dfd42ba1c847 --- /dev/null +++ b/packages/kap-server/src/middleware/origin.ts @@ -0,0 +1,92 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; + +import { stripPort } from './hostnames'; + +const CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; +const CORS_ALLOW_HEADERS = 'Content-Type, Authorization, X-Kimi-Client-Id, X-Kimi-Client-Name, X-Kimi-Client-Version, X-Kimi-Client-Ui-Mode'; + +export interface OriginHookOptions { + readonly allowedOrigins?: readonly string[]; +} + +export function parseCorsOrigins(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env['KIMI_CODE_CORS_ORIGINS']; + if (raw === undefined) { + return []; + } + return raw + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +export function originHost(origin: string | undefined): string | undefined { + if (origin === undefined) { + return undefined; + } + try { + return new URL(origin).host; + } catch { + return undefined; + } +} + +export function isOriginAllowed( + origin: string | undefined, + host: string | undefined, + allowed: readonly string[], +): boolean { + const oh = originHost(origin); + if (oh === undefined) { + return true; + } + const ohStripped = stripPort(oh); + if (host !== undefined) { + const hostStripped = stripPort(host); + if (ohStripped === hostStripped) { + return true; + } + if (isLoopbackHost(ohStripped) && isLoopbackHost(hostStripped)) { + return true; + } + } + return allowed.includes(origin as string); +} + +function isLoopbackHost(h: string): boolean { + return ( + h === 'localhost' || + h === '::1' || + h === '[::1]' || + h.startsWith('127.') || + h.endsWith('.localhost') + ); +} + +export function createOriginHook( + opts: OriginHookOptions, +): (req: FastifyRequest, reply: FastifyReply) => Promise { + const allowed = opts.allowedOrigins ?? []; + return async (req, reply) => { + const origin = req.headers.origin; + if (origin === undefined) { + return; + } + if (isOriginAllowed(origin, req.headers.host, allowed)) { + reply.header('Access-Control-Allow-Origin', origin); + reply.header('Access-Control-Allow-Methods', CORS_ALLOW_METHODS); + reply.header( + 'Access-Control-Allow-Headers', + req.headers['access-control-request-headers'] ?? CORS_ALLOW_HEADERS, + ); + reply.header('Vary', 'Origin'); + if (req.method === 'OPTIONS') { + return reply.code(204).send(); + } + return; + } + if (req.method === 'OPTIONS') { + return reply.code(204).send(); + } + }; +} diff --git a/packages/kap-server/src/middleware/rateLimit.ts b/packages/kap-server/src/middleware/rateLimit.ts new file mode 100644 index 0000000000000000000000000000000000000000..1bc5a83040605f5fa515a2c219782591598a2617 --- /dev/null +++ b/packages/kap-server/src/middleware/rateLimit.ts @@ -0,0 +1,79 @@ +export const AUTH_RATE_LIMIT_CODE = 42901; +export const AUTH_RATE_LIMIT_MSG = 'Too many failed auth attempts'; + +export interface AuthFailureLimiterOptions { + readonly maxFailures?: number; + readonly windowMs?: number; + readonly banMs?: number; + readonly logger?: { warn(obj: unknown, msg: string): void }; +} + +export interface AuthFailureLimiter { + recordFailure(ip: string): void; + isBanned(ip: string): boolean; + dispose(): void; +} + +interface Entry { + count: number; + windowStart: number; + bannedUntil: number; +} + +const DEFAULT_MAX_FAILURES = 10; +const DEFAULT_WINDOW_MS = 60_000; +const DEFAULT_BAN_MS = 60_000; + +export function createAuthFailureLimiter( + opts?: AuthFailureLimiterOptions, +): AuthFailureLimiter { + const maxFailures = opts?.maxFailures ?? DEFAULT_MAX_FAILURES; + const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS; + const banMs = opts?.banMs ?? DEFAULT_BAN_MS; + const entries = new Map(); + + const sweep = setInterval(() => { + const now = Date.now(); + for (const [ip, entry] of entries) { + const banned = entry.bannedUntil > now; + const windowLive = now - entry.windowStart <= windowMs; + if (!banned && !windowLive) { + entries.delete(ip); + } + } + }, windowMs); + if (typeof sweep.unref === 'function') { + sweep.unref(); + } + + return { + recordFailure(ip: string): void { + const now = Date.now(); + let entry = entries.get(ip); + if (entry === undefined || now - entry.windowStart > windowMs) { + entry = { count: 0, windowStart: now, bannedUntil: 0 }; + entries.set(ip, entry); + } + entry.count += 1; + if (entry.count >= maxFailures) { + const wasBanned = entry.bannedUntil > now; + entry.bannedUntil = now + banMs; + if (!wasBanned) { + opts?.logger?.warn( + { ip, bannedUntil: entry.bannedUntil }, + 'too many failed auth attempts; source temporarily banned', + ); + } + } + }, + isBanned(ip: string): boolean { + const entry = entries.get(ip); + if (entry === undefined) return false; + return entry.bannedUntil > Date.now(); + }, + dispose(): void { + clearInterval(sweep); + entries.clear(); + }, + }; +} diff --git a/packages/kap-server/src/middleware/schema.ts b/packages/kap-server/src/middleware/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c2789476d0dd8d70eae2b304fb874151a057f61 --- /dev/null +++ b/packages/kap-server/src/middleware/schema.ts @@ -0,0 +1,107 @@ +import { envelopeSchema } from '../protocol/envelope'; +import { z } from 'zod'; + +export function jsonSchema(schema: z.ZodTypeAny): Record { + return jsonSchemaForTarget(schema, 'input', 'draft-7'); +} + +export function outputJsonSchema(schema: z.ZodTypeAny): Record { + return jsonSchemaForTarget(schema, 'output', 'draft-7'); +} + +export function openApiDocumentJsonSchema( + schema: z.ZodTypeAny, + io: 'input' | 'output' = 'input', +): Record { + return jsonSchemaForTarget(schema, io, 'openapi-3.0'); +} + +function jsonSchemaForTarget( + schema: z.ZodTypeAny, + io: 'input' | 'output', + target: 'draft-7' | 'openapi-3.0', +): Record { + const converted = z.toJSONSchema(schema, { + target, + io, + unrepresentable: 'any', + }) as Record; + if (converted['$schema'] !== undefined) { + delete converted['$schema']; + } + return converted; +} + +export function envelopeJsonSchema( + dataSchema: z.ZodTypeAny, +): Record { + return outputJsonSchema(envelopeSchema(dataSchema)); +} + +export function openApiDocumentEnvelopeJsonSchema( + dataSchema: z.ZodTypeAny, +): Record { + return openApiDocumentJsonSchema(envelopeSchema(dataSchema), 'output'); +} + +export interface RouteSchemaOptions { + body?: z.ZodTypeAny; + querystring?: z.ZodTypeAny; + params?: z.ZodTypeAny; + response?: Record; + rawResponse?: Record>; + description?: string; + summary?: string; + tags?: string[]; + operationId?: string; + consumes?: string[]; + produces?: string[]; +} + +export function buildRouteSchema(options: RouteSchemaOptions): Record { + const schema: Record = {}; + + if (options.body) { + schema['body'] = jsonSchema(options.body); + } + if (options.querystring) { + schema['querystring'] = jsonSchema(options.querystring); + } + if (options.params) { + schema['params'] = jsonSchema(options.params); + } + if (options.response || options.rawResponse) { + const responses: Record = {}; + if (options.response) { + for (const [code, zodSchema] of Object.entries(options.response)) { + responses[String(code)] = envelopeJsonSchema(zodSchema); + } + } + if (options.rawResponse) { + for (const [code, rawSchema] of Object.entries(options.rawResponse)) { + responses[String(code)] = rawSchema; + } + } + schema['response'] = responses; + } + if (options.description) { + schema['description'] = options.description; + } + if (options.summary) { + schema['summary'] = options.summary; + } + if (options.tags) { + schema['tags'] = options.tags; + } + if (options.operationId) { + schema['operationId'] = options.operationId; + } + if (options.consumes) { + schema['consumes'] = options.consumes; + } + if (options.produces) { + schema['produces'] = options.produces; + } + + return schema; +} diff --git a/packages/kap-server/src/middleware/securityHeaders.ts b/packages/kap-server/src/middleware/securityHeaders.ts new file mode 100644 index 0000000000000000000000000000000000000000..02a93442e825705bcee73934a1f545f398cbf509 --- /dev/null +++ b/packages/kap-server/src/middleware/securityHeaders.ts @@ -0,0 +1,23 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; + +export interface SecurityHeadersOptions { + readonly tls: boolean; +} + +const HSTS_VALUE = 'max-age=31536000'; +const CONTENT_SECURITY_POLICY = + "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'self'"; + +export function createSecurityHeadersHook( + opts: SecurityHeadersOptions, +): (req: FastifyRequest, reply: FastifyReply, payload: unknown) => Promise { + return async (_req, reply, payload) => { + reply.header('X-Content-Type-Options', 'nosniff'); + reply.header('Referrer-Policy', 'no-referrer'); + reply.header('Content-Security-Policy', CONTENT_SECURITY_POLICY); + if (opts.tls === true) { + reply.header('Strict-Transport-Security', HSTS_VALUE); + } + return payload; + }; +} diff --git a/packages/kap-server/src/middleware/validate.ts b/packages/kap-server/src/middleware/validate.ts new file mode 100644 index 0000000000000000000000000000000000000000..65aa3298426be90efefa0f1c6dbc7b11c275bef5 --- /dev/null +++ b/packages/kap-server/src/middleware/validate.ts @@ -0,0 +1,92 @@ +import { ErrorCode } from '../protocol/error-codes'; +import type { z } from 'zod'; + +interface ValidationRequest { + id: string; + body?: unknown; + query?: unknown; + params?: unknown; +} + +interface ValidationReply { + send(payload: unknown): unknown; +} + +type PreHandlerHook = ( + req: ValidationRequest, + reply: ValidationReply, + done: (err?: Error) => void, +) => void; + +interface ValidationDetailItem { + path: string; + message: string; +} + +function zodIssuesToDetails(error: z.ZodError): ValidationDetailItem[] { + return error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })); +} + +function buildValidationEnvelope( + details: ValidationDetailItem[], + requestId: string, +): { + code: number; + msg: string; + data: null; + request_id: string; + details: ValidationDetailItem[]; +} { + const first = details[0]; + const msg = first === undefined + ? 'validation failed' + : first.path === '' + ? first.message + : `${first.path}: ${first.message}`; + return { + code: ErrorCode.VALIDATION_FAILED, + msg, + data: null, + request_id: requestId, + details, + }; +} + +export function validateBody(schema: z.ZodType): PreHandlerHook { + return (req, reply, done) => { + const result = schema.safeParse(req.body); + if (!result.success) { + reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id)); + return; + } + req.body = result.data; + done(); + }; +} + +export function validateQuery(schema: z.ZodType): PreHandlerHook { + return (req, reply, done) => { + const result = schema.safeParse(req.query); + if (!result.success) { + reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id)); + return; + } + req.query = result.data; + done(); + }; +} + +export function validateParams(schema: z.ZodType): PreHandlerHook { + return (req, reply, done) => { + const result = schema.safeParse(req.params); + if (!result.success) { + reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id)); + return; + } + req.params = result.data; + done(); + }; +} diff --git a/packages/kap-server/src/openapi/transforms.ts b/packages/kap-server/src/openapi/transforms.ts new file mode 100644 index 0000000000000000000000000000000000000000..2713faa6dcb2d3bbeb3e97c8c496f63a29b60241 --- /dev/null +++ b/packages/kap-server/src/openapi/transforms.ts @@ -0,0 +1,373 @@ +import { + fsDiffRequestSchema, + fsDiffResponseSchema, + fsGitStatusRequestSchema, + fsGitStatusResponseSchema, +} from '@moonshot-ai/agent-core-v2/app/git/git'; +import { + fsGrepRequestSchema, + fsGrepResponseSchema, + fsListManyRequestSchema, + fsListManyResponseSchema, + fsListRequestSchema, + fsListResponseSchema, + fsMkdirRequestSchema, + fsMkdirResponseSchema, + fsReadRequestSchema, + fsReadResponseSchema, + fsSearchRequestSchema, + fsSearchResponseSchema, + fsStatManyRequestSchema, + fsStatManyResponseSchema, + fsStatRequestSchema, + fsStatResponseSchema, +} from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fs'; +import { z } from 'zod'; + +import { + openApiDocumentEnvelopeJsonSchema, + openApiDocumentJsonSchema, +} from '../middleware/schema'; +import { + fsOpenInRequestSchema, + fsOpenInResponseSchema, + fsOpenRequestSchema, + fsOpenResponseSchema, + fsRevealRequestSchema, + fsRevealResponseSchema, +} from '../protocol/rest-fs'; +import { + questionDismissResultSchema, + questionResolveRequestSchema, + questionResolveResultSchema, +} from '../protocol/rest-question'; +import { + archiveSessionResponseSchema, + deleteSessionResponseSchema, +} from '../protocol/rest-session'; + +const binarySchema = { + type: 'string', + format: 'binary', +} as const; + +const fileUploadMultipartSchema = { + type: 'object', + properties: { + file: binarySchema, + name: { type: 'string' }, + expires_in_sec: { type: 'number', minimum: 0 }, + }, + required: ['file'], +} as const; + +const errorEnvelopeSchema = openApiDocumentEnvelopeJsonSchema(z.null()); + +const fsActionRequestSchema = { + oneOf: [ + openApiDocumentJsonSchema(fsListRequestSchema), + openApiDocumentJsonSchema(fsReadRequestSchema), + openApiDocumentJsonSchema(fsListManyRequestSchema), + openApiDocumentJsonSchema(fsStatRequestSchema), + openApiDocumentJsonSchema(fsStatManyRequestSchema), + openApiDocumentJsonSchema(fsMkdirRequestSchema), + openApiDocumentJsonSchema(fsSearchRequestSchema), + openApiDocumentJsonSchema(fsGrepRequestSchema), + openApiDocumentJsonSchema(fsGitStatusRequestSchema), + openApiDocumentJsonSchema(fsDiffRequestSchema), + openApiDocumentJsonSchema(fsOpenRequestSchema), + openApiDocumentJsonSchema(fsOpenInRequestSchema), + openApiDocumentJsonSchema(fsRevealRequestSchema), + ], +} as const; + +const fsActionResponseSchema = { + oneOf: [ + openApiDocumentEnvelopeJsonSchema(fsListResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsReadResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsListManyResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsStatResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsStatManyResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsMkdirResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsSearchResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsGrepResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsGitStatusResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsDiffResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsOpenResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsOpenInResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsRevealResponseSchema), + ], +} as const; + +const questionResponseSchema = { + oneOf: [ + openApiDocumentEnvelopeJsonSchema(questionResolveResultSchema), + openApiDocumentEnvelopeJsonSchema(questionDismissResultSchema), + ], +} as const; + +export function transformOpenApiDocument( + document: Record, +): Record { + const paths = asRecord(document['paths']); + if (paths === undefined) return document; + + patchFileUpload(paths); + patchFileDownload(paths); + patchSessionExport(paths); + patchSessionAction(paths); + patchFsAction(paths); + patchFsDownload(paths); + patchQuestionResolveOrDismiss(paths); + + return document; +} + +function patchSessionExport(paths: Record): void { + const operation = getOperation(paths, '/api/v1/sessions/{session_id}/export', 'post'); + if (operation === undefined) return; + + setResponse(operation, '200', { + description: 'Session export archive or JSON error envelope', + headers: { + 'content-disposition': headerString(), + 'content-length': headerInteger(), + 'cache-control': headerString(), + }, + content: { + 'application/zip': { + schema: binarySchema, + }, + ...jsonContent(errorEnvelopeSchema), + }, + }); +} + +function patchFileUpload(paths: Record): void { + const operation = getOperation(paths, '/api/v1/files', 'post'); + if (operation === undefined) return; + + operation['requestBody'] = { + required: true, + content: { + 'multipart/form-data': { + schema: fileUploadMultipartSchema, + }, + }, + }; +} + +function patchFileDownload(paths: Record): void { + const operation = getOperation(paths, '/api/v1/files/{file_id}', 'get'); + if (operation === undefined) return; + + setResponse(operation, '200', { + description: 'Binary file download', + headers: { + 'content-disposition': headerString(), + 'content-length': headerInteger(), + etag: headerString(), + }, + content: { + 'application/octet-stream': { + schema: binarySchema, + }, + }, + }); + setResponse(operation, '404', { + description: 'File not found', + content: jsonContent(errorEnvelopeSchema), + }); +} + +function patchSessionAction(paths: Record): void { + const internalPath = '/api/v1/sessions/{tail}'; + const pathItem = asRecord(paths[internalPath]); + const operation = asRecord(pathItem?.['post']); + if (pathItem === undefined || operation === undefined) return; + + projectSessionAction(paths, pathItem, 'archive', 'runSessionArchiveAction', { + description: 'Session archive response', + content: jsonContent(openApiDocumentEnvelopeJsonSchema(archiveSessionResponseSchema)), + }); + projectSessionAction(paths, pathItem, 'delete', 'runSessionDeleteAction', { + description: 'Session delete response', + content: jsonContent(openApiDocumentEnvelopeJsonSchema(deleteSessionResponseSchema)), + }); + delete paths[internalPath]; +} + +function projectSessionAction( + paths: Record, + pathItem: Record, + action: string, + operationId: string, + okResponse: Record, +): void { + const cloned = cloneRecord(pathItem); + replacePathParamName(cloned, 'tail', 'session_id'); + const clonedOperation = asRecord(cloned['post']); + if (clonedOperation !== undefined) { + clonedOperation['operationId'] = operationId; + setResponse(clonedOperation, '200', okResponse); + } + paths[`/api/v1/sessions/{session_id}:${action}`] = cloned; +} + +function patchFsAction(paths: Record): void { + const operation = getOperation(paths, '/api/v1/sessions/{session_id}/{tail}', 'post'); + if (operation === undefined) return; + + operation['description'] = appendDescription( + operation['description'], + 'The request and response schemas depend on the `fs:` path tail and are represented as OpenAPI `oneOf` unions.', + ); + operation['requestBody'] = { + required: true, + content: jsonContent(fsActionRequestSchema), + }; + setResponse(operation, '200', { + description: 'Filesystem action response', + content: jsonContent(fsActionResponseSchema), + }); +} + +function patchFsDownload(paths: Record): void { + const operation = getOperation(paths, '/api/v1/sessions/{session_id}/fs/{*}', 'get'); + if (operation === undefined) return; + + setResponse(operation, '200', { + description: 'Binary workspace file download', + headers: { + 'content-disposition': headerString(), + 'content-length': headerInteger(), + etag: headerString(), + 'last-modified': headerString(), + }, + content: { + 'application/octet-stream': { + schema: binarySchema, + }, + }, + }); + setResponse(operation, '206', { + description: 'Partial binary workspace file download', + headers: { + 'content-disposition': headerString(), + 'content-length': headerInteger(), + 'content-range': headerString(), + etag: headerString(), + 'last-modified': headerString(), + }, + content: { + 'application/octet-stream': { + schema: binarySchema, + }, + }, + }); + setResponse(operation, '304', { + description: 'Not modified', + headers: { + etag: headerString(), + }, + }); +} + +function patchQuestionResolveOrDismiss(paths: Record): void { + const operation = getOperation(paths, '/api/v1/sessions/{session_id}/questions/{tail}', 'post'); + if (operation === undefined) return; + + operation['description'] = appendDescription( + operation['description'], + 'Resolve uses the question response body; `:dismiss` sends an empty body.', + ); + operation['requestBody'] = { + required: false, + content: jsonContent(openApiDocumentJsonSchema(questionResolveRequestSchema)), + }; + setResponse(operation, '200', { + description: 'Question resolved or dismissed', + content: jsonContent(questionResponseSchema), + }); +} + +function getOperation( + paths: Record, + path: string, + method: string, +): Record | undefined { + const pathItem = asRecord(paths[path]); + if (pathItem === undefined) return undefined; + return asRecord(pathItem[method]); +} + +function setResponse( + operation: Record, + statusCode: string, + response: Record, +): void { + const responses = asRecord(operation['responses']) ?? {}; + responses[statusCode] = response; + operation['responses'] = responses; +} + +function jsonContent(schema: Record): Record { + return { + 'application/json': { + schema, + }, + }; +} + +function headerString(): Record { + return { + schema: { + type: 'string', + }, + }; +} + +function headerInteger(): Record { + return { + schema: { + type: 'integer', + }, + }; +} + +function appendDescription(existing: unknown, extra: string): string { + if (typeof existing !== 'string' || existing.length === 0) return extra; + return `${existing} ${extra}`; +} + +function replacePathParamName( + container: Record, + from: string, + to: string, +): void { + const params = container['parameters']; + if (Array.isArray(params)) { + for (const param of params) { + const record = asRecord(param); + if (record?.['in'] === 'path' && record['name'] === from) { + record['name'] = to; + } + } + } + + for (const method of ['get', 'post', 'put', 'patch', 'delete']) { + const operation = asRecord(container[method]); + if (operation !== undefined) { + replacePathParamName(operation, from, to); + } + } +} + +function cloneRecord(value: Record): Record { + return structuredClone(value); +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null) return undefined; + return value as Record; +} diff --git a/packages/kap-server/src/protocol/approval.ts b/packages/kap-server/src/protocol/approval.ts new file mode 100644 index 0000000000000000000000000000000000000000..0790a59f9898717e344f03bbff76e6618ec4c49c --- /dev/null +++ b/packages/kap-server/src/protocol/approval.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; + +export const approvalDecisionSchema = z.enum(['approved', 'rejected', 'cancelled']); +export type ApprovalDecision = z.infer; + +export const approvalScopeSchema = z.enum(['session']); +export type ApprovalScope = z.infer; + +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; + +export const approvalResponseSchema = z.object({ + decision: approvalDecisionSchema, + scope: approvalScopeSchema.optional(), + feedback: z.string().optional(), + selected_label: z.string().optional(), +}); +export type ApprovalResponse = z.infer; diff --git a/packages/kap-server/src/protocol/asyncapi.ts b/packages/kap-server/src/protocol/asyncapi.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b06b82df2198b32982f777118ce84c0cebfc62d --- /dev/null +++ b/packages/kap-server/src/protocol/asyncapi.ts @@ -0,0 +1,144 @@ +import { z } from 'zod'; + +import { wsOperations, type WsOperationDefinition } from './ws-control'; + +const ASYNCAPI_VERSION = '3.1.0'; +const DEFAULT_TITLE = 'Kimi Code WebSocket API'; +const DEFAULT_VERSION = '0.1.0'; +const DEFAULT_SERVER_HOST = 'localhost'; +const DEFAULT_WS_PATH = '/api/v1/ws'; +const CHANNEL_ID = 'kimiCodeWebSocket'; +const ASYNCAPI_OPERATIONS: readonly WsOperationDefinition[] = wsOperations; + +export interface AsyncApiDocumentOptions { + readonly title?: string; + readonly version?: string; + readonly serverHost?: string; + readonly serverProtocol?: 'ws' | 'wss'; + readonly wsPath?: string; +} + +export function createAsyncApiDocument( + options: AsyncApiDocumentOptions = {}, +): Record { + const title = options.title ?? DEFAULT_TITLE; + const version = options.version ?? DEFAULT_VERSION; + const serverHost = options.serverHost ?? DEFAULT_SERVER_HOST; + const serverProtocol = options.serverProtocol ?? 'ws'; + const wsPath = options.wsPath ?? DEFAULT_WS_PATH; + const messages = buildMessages(); + const channelMessages = Object.fromEntries( + Object.keys(messages).map((id) => [id, { $ref: `#/components/messages/${id}` }]), + ); + + return { + asyncapi: ASYNCAPI_VERSION, + info: { + title, + version, + description: + 'WebSocket protocol for Kimi Code daemon control frames, acknowledgements, system frames, and session event streaming.', + }, + defaultContentType: 'application/json', + servers: { + local: { + host: serverHost, + protocol: serverProtocol, + pathname: wsPath, + description: 'Kimi Code daemon WebSocket endpoint.', + }, + }, + channels: { + [CHANNEL_ID]: { + address: wsPath, + servers: [{ $ref: '#/servers/local' }], + messages: channelMessages, + }, + }, + operations: { + receiveClientMessages: { + action: 'receive', + channel: { $ref: `#/channels/${CHANNEL_ID}` }, + messages: operationMessageRefs('client_to_server'), + }, + sendServerMessages: { + action: 'send', + channel: { $ref: `#/channels/${CHANNEL_ID}` }, + messages: [ + ...operationMessageRefs('server_to_client'), + ...ackMessageRefs(), + ], + }, + }, + components: { + messages, + }, + }; +} + +function buildMessages(): Record { + const messages: Record = {}; + for (const operation of ASYNCAPI_OPERATIONS) { + const id = messageId(operation.type); + messages[id] = asyncApiMessage(operation.type, operation.description, operation.messageSchema); + if (operation.ackSchema !== undefined) { + const ackId = `${id}_ack`; + messages[ackId] = asyncApiMessage( + `${operation.type}.ack`, + `Acknowledgement for ${operation.type}.`, + operation.ackSchema, + ); + } + } + return messages; +} + +function operationMessageRefs( + direction: WsOperationDefinition['direction'], +): Array<{ $ref: string }> { + return ASYNCAPI_OPERATIONS + .filter((operation) => operation.direction === direction) + .map((operation) => ({ $ref: `#/components/messages/${messageId(operation.type)}` })); +} + +function ackMessageRefs(): Array<{ $ref: string }> { + return ASYNCAPI_OPERATIONS + .filter((operation) => operation.ackSchema !== undefined) + .map((operation) => ({ $ref: `#/components/messages/${messageId(operation.type)}_ack` })); +} + +function asyncApiMessage( + name: string, + summary: string, + schema: z.ZodTypeAny, +): Record { + return { + name, + title: titleFromName(name), + summary, + contentType: 'application/json', + payload: jsonSchema(schema), + }; +} + +function jsonSchema(schema: z.ZodTypeAny): Record { + const converted = z.toJSONSchema(schema, { + target: 'draft-7', + io: 'input', + unrepresentable: 'any', + }) as Record; + delete converted['$schema']; + return converted; +} + +function messageId(type: string): string { + return type.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); +} + +function titleFromName(name: string): string { + return name + .split(/[^A-Za-z0-9]+/) + .filter((part) => part.length > 0) + .map((part) => `${part[0]!.toUpperCase()}${part.slice(1)}`) + .join(' '); +} diff --git a/packages/kap-server/src/protocol/display.ts b/packages/kap-server/src/protocol/display.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d5ef7edc7d9cb01587b8fb198a4fe898059c2b7 --- /dev/null +++ b/packages/kap-server/src/protocol/display.ts @@ -0,0 +1,159 @@ +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('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('goal_start'), + objective: z.string(), + completionCriterion: z.string().optional(), + mode: z.enum(['manual', 'yolo']), + }), + 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('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; +export type ToolResultDisplay = z.infer; diff --git a/packages/kap-server/src/protocol/envelope.ts b/packages/kap-server/src/protocol/envelope.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7eb3e8e1563632547b156afecd3773a0fd76f27 --- /dev/null +++ b/packages/kap-server/src/protocol/envelope.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +export const envelopeSchema = (data: T) => + z.object({ + code: z.number().int(), + msg: z.string(), + data: data.nullable(), + request_id: z.string(), + details: z.unknown().optional(), + stack: z.string().optional(), + }); + +export interface Envelope { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + stack?: string; +} + +export function okEnvelope(data: T, requestId: string): Envelope { + return { code: 0, msg: 'success', data, request_id: requestId }; +} + +export function errEnvelope( + code: number, + msg: string, + requestId: string, + stack?: string, +): Envelope { + return { code, msg, data: null, request_id: requestId, stack }; +} diff --git a/packages/kap-server/src/protocol/error-codes.ts b/packages/kap-server/src/protocol/error-codes.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb0d67e3ca4d0391a6e9ae1e01cc8a0348de2cd4 --- /dev/null +++ b/packages/kap-server/src/protocol/error-codes.ts @@ -0,0 +1,87 @@ +export const ErrorCode = { + SUCCESS: 0, + + VALIDATION_FAILED: 40001, + REQUEST_MALFORMED: 40002, + PROVIDER_OAUTH_MANAGED: 40003, + CATALOG_IMPORT_INVALID: 40004, + REGISTRY_IMPORT_INVALID: 40005, + + AUTH_PROVISIONING_REQUIRED: 40110, + AUTH_TOKEN_MISSING: 40111, + AUTH_TOKEN_UNAUTHORIZED: 40112, + AUTH_MODEL_NOT_RESOLVED: 40113, + + SESSION_NOT_FOUND: 40401, + PROMPT_NOT_FOUND: 40402, + MESSAGE_NOT_FOUND: 40403, + APPROVAL_NOT_FOUND: 40404, + QUESTION_NOT_FOUND: 40405, + TASK_NOT_FOUND: 40406, + FILE_NOT_FOUND: 40407, + MCP_SERVER_NOT_FOUND: 40408, + FS_PATH_NOT_FOUND: 40409, + WORKSPACE_NOT_FOUND: 40410, + FS_PERMISSION_DENIED: 40411, + PROVIDER_NOT_FOUND: 40412, + MODEL_NOT_FOUND: 40413, + TERMINAL_NOT_FOUND: 40414, + SKILL_NOT_FOUND: 40415, + TOOL_CALL_NOT_FOUND: 40416, + CATALOG_ENTRY_NOT_FOUND: 40417, + CAPABILITY_NOT_FOUND: 40418, + PLUGIN_NOT_FOUND: 40419, + RUNTIME_NOT_FOUND: 40420, + + SESSION_BUSY: 40901, + APPROVAL_ALREADY_RESOLVED: 40902, + TASK_ALREADY_FINISHED: 40904, + MCP_ALREADY_CONNECTED: 40905, + FS_IS_DIRECTORY: 40906, + FS_IS_BINARY: 40907, + FS_GIT_UNAVAILABLE: 40908, + QUESTION_DISMISSED: 40909, + COMPACTION_UNABLE: 40910, + SESSION_UNDO_UNAVAILABLE: 40911, + SKILL_NOT_ACTIVATABLE: 40912, + + GOAL_ALREADY_EXISTS: 40913, + GOAL_NOT_FOUND: 40914, + GOAL_STATUS_INVALID: 40915, + GOAL_NOT_RESUMABLE: 40916, + GOAL_OBJECTIVE_EMPTY: 40917, + GOAL_OBJECTIVE_TOO_LONG: 40918, + FS_ALREADY_EXISTS: 40919, + GOAL_UNSUPPORTED_AGENT: 40920, + PROVIDER_ALREADY_EXISTS: 40921, + PAGE_TOKEN_MISMATCH: 40922, + SESSION_TITLE_UNAVAILABLE: 40923, + CAPABILITY_INSTALL_IN_PROGRESS: 40924, + CAPABILITY_UNSUPPORTED: 40925, + RUNTIME_UNAVAILABLE: 40926, + PROMPT_ID_CONFLICT: 40927, + REMOTE_CONTROL_ALREADY_RUNNING: 40928, + MCP_OAUTH_FAILED: 40929, + + APPROVAL_EXPIRED: 41001, + QUESTION_EXPIRED: 41002, + FILE_EXPIRED: 41003, + + FILE_TOO_LARGE: 41301, + FS_TOO_LARGE: 41302, + FS_TOO_MANY_RESULTS: 41303, + FS_PATH_ESCAPES_SESSION: 41304, + FS_GREP_TIMEOUT: 41305, + + WS_SLOW_CONSUMER: 42903, + + INTERNAL_ERROR: 50001, + PERSISTENCE_FAILURE: 50003, + CATALOG_UNAVAILABLE: 50004, + + TOOL_EXECUTION_FAILED: 60001, + TOOL_NOT_AVAILABLE: 60002, + +} as const; + +export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts new file mode 100644 index 0000000000000000000000000000000000000000..582be3c6e7d6a8ea319483733b629dc70720fed4 --- /dev/null +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -0,0 +1,1118 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; +import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; +import type { + BundledSkillActivation, + CompactionSummaryOrigin, + CronJobOrigin, + CronMissedOrigin, + HookResultOrigin, + InjectionOrigin, + PluginCommandOrigin, + RetryOrigin, + ShellCommandOrigin, + SkillActivationOrigin, + SkillSource, + SystemTriggerOrigin, + TaskOrigin, + UserPromptOrigin, +} from '@moonshot-ai/agent-core-v2/agent/contextMemory/types'; +import { messageContentSchema } from './message'; +import type { HookResultPayload } from '@moonshot-ai/agent-core-v2/features/externalHooks/agent/agentExternalHooksService'; +import type { + CompactionBlockedPayload, + CompactionCompletedPayload, + CompactionStartedPayload, +} from '@moonshot-ai/agent-core-v2/agent/fullCompaction/compactionOps'; +import type { CompactionResult } from '@moonshot-ai/agent-core-v2/agent/fullCompaction/types'; +import type { + GoalActor, + GoalBudgetLimits, + GoalBudgetReport, + GoalChange, + GoalChangeKind, + GoalChangeStats, + GoalSnapshot, + GoalStatus, + GoalToolResult, +} from '@moonshot-ai/agent-core-v2'; +import type { + AssistantDeltaPayload, + ThinkingDeltaPayload, + ToolCallDeltaPayload, + TurnStepCompletedPayload, + TurnStepInterruptedPayload, + TurnStepStartedPayload, +} from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; +import type { + McpServerStatusEventPayload, + McpServerStatusPayload, + ToolListUpdatedPayload, + ToolListUpdatedReason, +} from '@moonshot-ai/agent-core-v2/agent/mcp/mcpEvents'; +import type { McpOAuthAuthorizationUrlUpdateData } from '@moonshot-ai/agent-core-v2/agent/mcp/tools/auth'; +import type { PermissionMode } from '@moonshot-ai/agent-core-v2/agent/permissionPolicy/types'; +import type { WarningEvent } from '@moonshot-ai/agent-core-v2/agent/profile/profileService'; +import type { PluginCommandActivatedPayload } from '@moonshot-ai/agent-core-v2/agent/pluginCommand/pluginCommand'; +import type { + ShellCompletedPayload, + ShellOutputPayload, + ShellStartedPayload, +} from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommandService'; + +import type { TurnStepRetryingPayload } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; +import type { AgentTaskStatus } from '@moonshot-ai/agent-core-v2/agent/task/types'; +import type { + ToolCallStartedPayload, + ToolProgressPayload, + ToolResultEventPayload, +} from '@moonshot-ai/agent-core-v2/agent/toolExecutor/toolExecutorEvents'; +import type { UsageStatus } from '@moonshot-ai/agent-core-v2/agent/usage/usage'; +import type { FinishReason } from '@moonshot-ai/agent-core-v2/human/llm/finish-reason'; +import type { TokenUsage } from '@moonshot-ai/agent-core-v2/human/llm/usage'; +import type { + SubagentCancelledPayload, + SubagentCompletedPayload, + SubagentFailedPayload, + SubagentSpawnedPayload, + SubagentStartedPayload, +} from '@moonshot-ai/agent-core-v2/session/subagent/mirrorAgentRun'; +import type { SubagentSuspendedPayload } from '@moonshot-ai/agent-core-v2/features/swarm/session/sessionSwarmService'; +import type { ToolUpdate } from '@moonshot-ai/agent-core-v2/tool/toolContract'; + +import { ToolInputDisplaySchema } from './display'; +import { configResponseSchema } from './rest-config'; +import { sessionPendingInteractionSchema, sessionSchema } from './session'; +import { workspaceSchema } from './workspace'; + +export const tokenUsageSchema = z.object({ + inputOther: z.number(), + output: z.number(), + inputCacheRead: z.number(), + inputCacheCreation: z.number(), +}) satisfies z.ZodType; + +export const finishReasonSchema = z.enum([ + 'completed', + 'tool_calls', + 'truncated', + 'filtered', + 'paused', + 'other', +]) satisfies z.ZodType; + +export const usageStatusSchema = z.object({ + byModel: z.record(z.string(), tokenUsageSchema).optional(), + currentTurn: tokenUsageSchema.optional(), + total: tokenUsageSchema.optional(), +}) satisfies z.ZodType; + +export const permissionModeSchema = z.enum(['manual', 'yolo', 'auto']) satisfies z.ZodType; + +export const skillSourceSchema = z.enum(['project', 'user', 'extra', 'builtin']) satisfies z.ZodType; + +export const bundledSkillActivationSchema = z.object({ + activationId: z.string(), + skillName: z.string(), + skillArgs: z.string().optional(), + skillType: z.string().optional(), + skillPath: z.string().optional(), + skillSource: skillSourceSchema.optional(), +}) satisfies z.ZodType; + +export const userPromptOriginSchema = z.object({ + kind: z.literal('user'), + skillActivations: z.array(bundledSkillActivationSchema).optional(), +}) satisfies z.ZodType; + +export const skillActivationOriginSchema = z.object({ + kind: z.literal('skill_activation'), + activationId: z.string(), + skillName: z.string(), + skillArgs: z.string().optional(), + trigger: z.enum(['user-slash', 'model-tool', 'nested-skill']), + skillType: z.string().optional(), + skillPath: z.string().optional(), + skillSource: skillSourceSchema.optional(), +}) satisfies z.ZodType; + +export const pluginCommandOriginSchema = z.object({ + kind: z.literal('plugin_command'), + activationId: z.string(), + pluginId: z.string(), + commandName: z.string(), + commandArgs: z.string().optional(), + trigger: z.literal('user-slash'), +}) satisfies z.ZodType; + +export const injectionOriginSchema = z.object({ + kind: z.literal('injection'), + variant: z.string(), +}) satisfies z.ZodType; + +export const shellCommandOriginSchema = z.object({ + kind: z.literal('shell_command'), + phase: z.enum(['input', 'output']), + isError: z.boolean().optional(), +}) satisfies z.ZodType; + +export const compactionSummaryOriginSchema = z.object({ + kind: z.literal('compaction_summary'), +}) satisfies z.ZodType; + +export const systemTriggerOriginSchema = z.object({ + kind: z.literal('system_trigger'), + name: z.string(), +}) satisfies z.ZodType; + +export const taskLifecycleStatusSchema = z.enum([ + 'running', + 'completed', + 'failed', + 'timed_out', + 'killed', + 'lost', +]) satisfies z.ZodType; + +export const taskOriginSchema = z.object({ + kind: z.literal('task'), + taskId: z.string(), + status: taskLifecycleStatusSchema, + notificationId: z.string(), +}) satisfies z.ZodType; + +export const backgroundTaskOriginSchema = z.object({ + kind: z.literal('background_task'), + taskId: z.string(), + status: taskLifecycleStatusSchema, + notificationId: z.string(), +}); + +export const cronJobOriginSchema = z.object({ + kind: z.literal('cron_job'), + jobId: z.string(), + cron: z.string(), + recurring: z.boolean(), + coalescedCount: z.number(), + stale: z.boolean(), +}) satisfies z.ZodType; + +export const cronMissedOriginSchema = z.object({ + kind: z.literal('cron_missed'), + count: z.number(), +}) satisfies z.ZodType; + +export const hookResultOriginSchema = z.object({ + kind: z.literal('hook_result'), + event: z.string(), + blocked: z.boolean().optional(), +}) satisfies z.ZodType; + +export const retryOriginSchema = z.object({ + kind: z.literal('retry'), + trigger: z.string().optional(), +}) satisfies z.ZodType; + +export const promptOriginSchema = z.discriminatedUnion('kind', [ + userPromptOriginSchema, + skillActivationOriginSchema, + pluginCommandOriginSchema, + injectionOriginSchema, + shellCommandOriginSchema, + compactionSummaryOriginSchema, + systemTriggerOriginSchema, + taskOriginSchema, + backgroundTaskOriginSchema, + cronJobOriginSchema, + cronMissedOriginSchema, + hookResultOriginSchema, + retryOriginSchema, +]); + +export const goalStatusSchema = z.enum(['active', 'paused', 'blocked', 'complete']) satisfies z.ZodType; + +export const goalActorSchema = z.enum(['user', 'model', 'runtime', 'system']) satisfies z.ZodType; + +export const goalBudgetLimitsSchema = z.object({ + tokenBudget: z.number().optional(), + turnBudget: z.number().optional(), + wallClockBudgetMs: z.number().optional(), +}) satisfies z.ZodType; + +export const goalBudgetReportSchema = z.object({ + tokenBudget: z.number().nullable(), + turnBudget: z.number().nullable(), + wallClockBudgetMs: z.number().nullable(), + remainingTokens: z.number().nullable(), + remainingTurns: z.number().nullable(), + remainingWallClockMs: z.number().nullable(), + tokenBudgetReached: z.boolean(), + turnBudgetReached: z.boolean(), + wallClockBudgetReached: z.boolean(), + overBudget: z.boolean(), +}) satisfies z.ZodType; + +export const goalSnapshotSchema = z.object({ + goalId: z.string(), + objective: z.string(), + completionCriterion: z.string().optional(), + status: goalStatusSchema, + turnsUsed: z.number(), + tokensUsed: z.number(), + wallClockMs: z.number(), + budget: goalBudgetReportSchema, + terminalReason: z.string().optional(), +}) satisfies z.ZodType; + +export const goalToolResultSchema = z.object({ + goal: goalSnapshotSchema.nullable(), +}) satisfies z.ZodType; + +export const goalChangeStatsSchema = z.object({ + turnsUsed: z.number(), + tokensUsed: z.number(), + wallClockMs: z.number(), +}) satisfies z.ZodType; + +export const goalChangeKindSchema = z.enum(['lifecycle', 'completion']) satisfies z.ZodType; + +export const goalChangeSchema = z.object({ + kind: goalChangeKindSchema, + status: goalStatusSchema.optional(), + reason: z.string().optional(), + stats: goalChangeStatsSchema.optional(), + actor: goalActorSchema.optional(), +}) satisfies z.ZodType; + +export const kimiErrorCodeSchema = z.enum([ + '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.undo_unavailable', + 'session.export_not_found', + 'session.export_missing_version', + 'session.export_output_conflict', + 'session.export_too_large', + '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', + 'agent.already_exists', + 'agent.already_running', + 'agent.not_a_subagent', + 'agent.not_owned', + 'agent.type_not_allowed', + 'agent.max_tokens_exceeded', + 'activity.agent_busy', + 'activity.cancelling', + 'activity.disposing', + 'activity.disposed', + 'activity.initializing', + 'activity.session_rejected', + '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', + 'goal.unsupported_agent', + 'model.not_configured', + 'model.config_invalid', + 'profile.thinking_alias_conflict', + 'model.not_found', + 'auth.login_required', + 'auth.provisioning_required', + 'auth.token_missing', + 'auth.token_unauthorized', + 'auth.model_not_resolved', + 'context.overflow', + 'loop.max_steps_exceeded', + 'provider.api_error', + 'provider.filtered', + 'provider.rate_limit', + 'provider.auth_error', + 'provider.connection_error', + 'provider.overloaded', + 'provider.not_found', + 'skill.not_found', + 'skill.type_unsupported', + 'skill.name_empty', + 'skill.parse_failed', + 'skill.nested_too_deep', + 'records.write_failed', + 'compaction.failed', + 'compaction.unable', + 'task.task_id_empty', + 'task.limit_exceeded', + 'usage.turn_id_conflict', + 'mcp.server_not_found', + 'mcp.server_disabled', + 'mcp.startup_failed', + 'mcp.tool_name_collision', + 'mcp.oauth_failed', + 'message.not_found', + 'plugin.not_found', + 'plugin.load_failed', + 'request.invalid', + 'request.work_dir_required', + 'request.prompt_input_empty', + 'prompt.id_conflict', + 'prompt.not_found', + 'session.busy', + 'shell.git_bash_not_found', + 'workspace.not_found', + 'terminal.not_found', + 'file.not_found', + 'file.too_large', + 'fs.path_not_found', + 'fs.permission_denied', + 'fs.path_escapes', + 'fs.is_directory', + 'fs.is_binary', + 'fs.too_large', + 'fs.already_exists', + 'fs.too_many_results', + 'fs.grep_timeout', + 'fs.git_unavailable', + 'wire.migration_missing', + 'storage.permission_denied', + 'storage.disk_full', + 'cron.expression_invalid', + 'web.invalid_url', + 'web.private_address', + 'web.fetch_failed', + 'validation.failed', + 'not_implemented', + 'internal', +]); + +export const kimiErrorPayloadSchema: z.ZodType = z.lazy( + () => kimiErrorPayloadObjectSchema, +); + +const kimiErrorPayloadObjectSchema = z.object({ + code: kimiErrorCodeSchema, + message: z.string(), + name: z.string().optional(), + details: z.record(z.string(), z.unknown()).optional(), + retryable: z.boolean(), + cause: kimiErrorPayloadSchema.optional(), +}); + +export const taskInfoBaseSchema = z.object({ + taskId: z.string(), + description: z.string(), + status: taskLifecycleStatusSchema, + detached: z.boolean().optional(), + startedAt: z.number(), + endedAt: z.number().nullable(), + stopReason: z.string().optional(), + terminalNotificationSuppressed: z.boolean().optional(), + timeoutMs: z.number().optional(), +}); + +export const processTaskInfoSchema = taskInfoBaseSchema.extend({ + kind: z.literal('process'), + command: z.string(), + pid: z.number(), + exitCode: z.number().nullable(), +}); + +export const agentTaskInfoSchema = taskInfoBaseSchema.extend({ + kind: z.literal('agent'), + agentId: z.string().optional(), + subagentType: z.string().optional(), + model: z.string().optional(), + thinkingEffort: z.string().optional(), +}); + +export const questionTaskInfoSchema = taskInfoBaseSchema.extend({ + kind: z.literal('question'), + questionCount: z.number(), + toolCallId: z.string().optional(), +}); + +export const taskInfoSchema = z.discriminatedUnion('kind', [ + processTaskInfoSchema, + agentTaskInfoSchema, + questionTaskInfoSchema, +]); + +export const compactionResultSchema = z.object({ + summary: z.string(), + compactedCount: z.number(), + tokensBefore: z.number(), + tokensAfter: z.number(), + keptUserMessageCount: z.number().optional(), + keptHeadUserMessageCount: z.number().optional(), + droppedCount: z.number().optional(), +}) satisfies z.ZodType; + +export const toolUpdateSchema = z.object({ + kind: z.enum(['stdout', 'stderr', 'progress', 'status', 'custom']), + text: z.string().optional(), + percent: z.number().optional(), + customKind: z.string().optional(), + customData: z.unknown().optional(), + replace: z.boolean().optional(), +}) satisfies z.ZodType; + +export const mcpOAuthAuthorizationUrlUpdateDataSchema = z.object({ + serverName: z.string(), + authorizationUrl: z.string(), + expiresAt: z.number().optional(), +}) satisfies z.ZodType; + +export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'blocked']) satisfies z.ZodType; + +export const agentPhaseSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('idle') }), + z.object({ + kind: z.literal('running'), + turnId: z.number(), + step: z.number(), + stepId: z.string(), + since: z.number(), + }), + z.object({ + kind: z.literal('tool_call'), + turnId: z.number(), + step: z.number(), + toolCallId: z.string(), + name: z.string(), + since: z.number(), + }), + z.object({ + kind: z.literal('retrying'), + turnId: z.number(), + step: z.number(), + stepId: z.string(), + failedAttempt: z.number(), + nextAttempt: z.number(), + maxAttempts: z.number(), + delayMs: z.number(), + errorName: z.string().optional(), + statusCode: z.number().optional(), + since: z.number(), + }), + z.object({ + kind: z.literal('awaiting_approval'), + turnId: z.number(), + step: z.number().optional(), + approval: z.unknown().optional(), + since: z.number(), + }), + z.object({ + kind: z.literal('interrupted'), + turnId: z.number(), + step: z.number().optional(), + reason: z.enum(['aborted', 'max_steps', 'error']), + message: z.string().optional(), + at: z.number(), + }), + z.object({ + kind: z.literal('ended'), + turnId: z.number(), + reason: turnEndReasonSchema, + durationMs: z.number().optional(), + at: z.number(), + }), +]); + +export const agentStatusUpdatedEventSchema = z.object({ + type: z.literal('agent.status.updated'), + agentId: z.string(), + model: z.string().optional(), + thinkingEffort: z.string().optional(), + contextTokens: z.number().optional(), + maxContextTokens: z.number().optional(), + contextUsage: z.number().optional(), + planMode: z.boolean().optional(), + swarmMode: z.boolean().optional(), + towerMode: z.boolean().optional(), + permission: permissionModeSchema.optional(), + usage: usageStatusSchema.optional(), + phase: agentPhaseSchema.optional(), +}); + +export const sessionMetaUpdatedEventSchema = z.object({ + type: z.literal('session.meta.updated'), + title: z.string().optional(), + patch: z.record(z.string(), z.unknown()).optional(), +}); + +export const agentCreatedEventSchema = z.object({ + type: z.literal('agent.created'), +}); + +export const agentDisposedEventSchema = z.object({ + type: z.literal('agent.disposed'), +}); + +export const sessionCreatedEventSchema = z.object({ + type: z.literal('event.session.created'), + session: sessionSchema, +}); + +export const sessionArchivedEventSchema = z.object({ + type: z.literal('event.session.archived'), + workspace_id: z.string().min(1), +}); + +export const sessionDeletedEventSchema = z.object({ + type: z.literal('event.session.deleted'), + workspace_id: z.string().min(1), +}); + +export const workspaceCreatedEventSchema = z.object({ + type: z.literal('event.workspace.created'), + workspace: workspaceSchema, +}); + +export const workspaceUpdatedEventSchema = z.object({ + type: z.literal('event.workspace.updated'), + workspace: workspaceSchema, +}); + +export const workspaceDeletedEventSchema = z.object({ + type: z.literal('event.workspace.deleted'), + workspace_id: z.string().min(1), + root: z.string().min(1), +}); + +export const sessionWorkChangedEventSchema = z.object({ + type: z.literal('event.session.work_changed'), + busy: z.boolean(), + main_turn_active: z.boolean().optional(), + pending_interaction: sessionPendingInteractionSchema.optional(), + last_turn_reason: z.enum(['completed', 'cancelled', 'failed']).optional(), +}); + +const legacySessionStatusSchema = z.enum([ + 'idle', + 'running', + 'awaiting_approval', + 'awaiting_question', + 'aborted', +]); + +export const sessionStatusChangedEventSchema = z.object({ + type: z.literal('event.session.status_changed'), + status: legacySessionStatusSchema, + previous_status: legacySessionStatusSchema, + current_prompt_id: z.string().min(1).optional(), +}); + +export const configChangedEventSchema = z.object({ + type: z.literal('event.config.changed'), + changedFields: z.array(z.string().min(1)), + config: configResponseSchema, +}); + +export const configWarningEventSchema = z.object({ + type: z.literal('event.config.warning'), + warnings: z.array( + z.object({ + domain: z.string().optional(), + message: z.string(), + }), + ), +}); + +export const modelCatalogChangedEventSchema = z.object({ + type: z.literal('event.model_catalog.changed'), + changed: z.array( + z.object({ + provider_id: z.string().min(1), + provider_name: z.string().min(1), + added: z.number().int().min(0), + removed: z.number().int().min(0), + }), + ), + unchanged: z.array(z.string().min(1)), + failed: z.array( + z.object({ + provider: z.string().min(1), + reason: z.string().min(1), + }), + ), +}); + +export const pluginChangedEventSchema = z.object({ + type: z.literal('event.plugin.changed'), +}); + +export const capabilityChangedEventSchema = z.object({ + type: z.literal('event.capability.changed'), + capability_id: z.string(), + install: z.object({ + running: z.boolean(), + step: z.string().optional(), + percent: z.number().optional(), + error: z.string().optional(), + note: z.string().optional(), + }), +}); + +export const diUnitChangedEventSchema = z.object({ + type: z.literal('event.di.unit_changed'), + scope: z.string().min(1), + token: z.string().min(1), + state: z.enum(['Pending', 'Activating', 'Active', 'Unloading', 'Failed']), + error: z.string().optional(), +}); + +export const goalUpdatedEventSchema = z.object({ + type: z.literal('goal.updated'), + agentId: z.string(), + snapshot: goalSnapshotSchema.nullable(), + change: goalChangeSchema.optional(), +}); + +export const skillActivatedEventSchema = z.object({ + type: z.literal('skill.activated'), + agentId: z.string(), + activationId: z.string(), + skillName: z.string(), + skillArgs: z.string().optional(), + trigger: z.enum(['user-slash', 'model-tool', 'nested-skill']), + skillPath: z.string().optional(), + skillSource: skillSourceSchema.optional(), +}); + +export const pluginCommandActivatedEventSchema = z.object({ + type: z.literal('plugin_command.activated'), + agentId: z.string(), + activationId: z.string(), + pluginId: z.string(), + commandName: z.string(), + commandArgs: z.string().optional(), + trigger: z.literal('user-slash'), +}) satisfies z.ZodType; + +export const errorEventSchema = kimiErrorPayloadObjectSchema.extend({ + type: z.literal('error'), + agentId: z.string(), +}); + +export const warningEventSchema = z.object({ + type: z.literal('warning'), + agentId: z.string(), + message: z.string(), + code: z.string().optional(), +}) satisfies z.ZodType; + +export const turnStartedEventSchema = z.object({ + type: z.literal('turn.started'), + agentId: z.string(), + turnId: z.number(), + origin: promptOriginSchema, + prompt: z.string().optional(), + promptId: z.string().optional(), + promptAttachments: z + .array( + z.union([ + z.object({ kind: z.enum(['image', 'video', 'audio']), fileId: z.string() }), + z.object({ + kind: z.literal('file'), + name: z.string(), + mediaType: z.string(), + size: z.number(), + path: z.string(), + }), + ]), + ) + .optional(), +}); + +export const turnEndedEventSchema = z.object({ + type: z.literal('turn.ended'), + agentId: z.string(), + time: z.number().optional(), + turnId: z.number(), + reason: turnEndReasonSchema, + error: kimiErrorPayloadSchema.optional(), + durationMs: z.number().optional(), + interruptReason: z + .enum(['user_cancelled', 'aborted', 'max_steps', 'error', 'filtered', 'blocked']) + .optional(), +}); + +export const turnStepStartedEventSchema = z.object({ + type: z.literal('turn.step.started'), + agentId: z.string(), + turnId: z.number(), + step: z.number(), + stepId: z.string().optional(), +}) satisfies z.ZodType; + +export const turnStepCompletedEventSchema = z.object({ + type: z.literal('turn.step.completed'), + agentId: z.string(), + turnId: z.number(), + step: z.number(), + stepId: z.string().optional(), + usage: tokenUsageSchema.optional(), + finishReason: z.string().optional(), + llmFirstTokenLatencyMs: z.number().optional(), + llmStreamDurationMs: z.number().optional(), + llmRequestBuildMs: z.number().optional(), + llmServerFirstTokenMs: z.number().optional(), + llmServerDecodeMs: z.number().optional(), + llmClientConsumeMs: z.number().optional(), + llmClientBlockedMs: z.number().optional(), + providerFinishReason: finishReasonSchema.optional(), + rawFinishReason: z.string().optional(), +}) satisfies z.ZodType; + +export const turnStepRetryingEventSchema = z.object({ + type: z.literal('turn.step.retrying'), + agentId: z.string(), + turnId: z.number(), + step: z.number(), + stepId: z.string().optional(), + failedAttempt: z.number(), + nextAttempt: z.number(), + maxAttempts: z.number(), + delayMs: z.number(), + errorName: z.string(), + errorMessage: z.string(), + statusCode: z.number().optional(), +}) satisfies z.ZodType; + +export const turnStepInterruptedEventSchema = z.object({ + type: z.literal('turn.step.interrupted'), + agentId: z.string(), + turnId: z.number(), + step: z.number(), + stepId: z.string().optional(), + reason: z.string(), + message: z.string().optional(), +}) satisfies z.ZodType; + +export const assistantDeltaEventSchema = z.object({ + type: z.literal('assistant.delta'), + agentId: z.string(), + turnId: z.number(), + delta: z.string(), +}) satisfies z.ZodType; + +export const hookResultEventSchema = z.object({ + type: z.literal('hook.result'), + agentId: z.string(), + turnId: z.number().optional(), + hookEvent: z.string(), + content: z.string(), + blocked: z.boolean().optional(), +}) satisfies z.ZodType; + +export const thinkingDeltaEventSchema = z.object({ + type: z.literal('thinking.delta'), + agentId: z.string(), + turnId: z.number(), + delta: z.string(), +}) satisfies z.ZodType; + +export const toolCallDeltaEventSchema = z.object({ + type: z.literal('tool.call.delta'), + agentId: z.string(), + turnId: z.number(), + toolCallId: z.string(), + name: z.string().optional(), + argumentsPart: z.string().optional(), +}) satisfies z.ZodType; + +export const toolCallStartedEventSchema = z.object({ + type: z.literal('tool.call.started'), + agentId: z.string(), + turnId: z.number(), + toolCallId: z.string(), + name: z.string(), + args: z.unknown(), + description: z.string().optional(), + display: ToolInputDisplaySchema.optional(), +}) satisfies z.ZodType; + +export const toolProgressEventSchema = z.object({ + type: z.literal('tool.progress'), + agentId: z.string(), + turnId: z.number(), + toolCallId: z.string(), + update: toolUpdateSchema, +}) satisfies z.ZodType; + +export const shellOutputEventSchema = z.object({ + type: z.literal('shell.output'), + agentId: z.string(), + commandId: z.string(), + update: toolUpdateSchema, + taskId: z.string().optional(), +}) satisfies z.ZodType; + +export const shellStartedEventSchema = z.object({ + type: z.literal('shell.started'), + agentId: z.string(), + commandId: z.string(), + taskId: z.string(), +}) satisfies z.ZodType; + +export const shellCompletedEventSchema = z.object({ + type: z.literal('shell.completed'), + agentId: z.string(), + commandId: z.string(), + isError: z.boolean(), + taskId: z.string().optional(), +}) satisfies z.ZodType; + +export const toolResultEventSchema = z.object({ + type: z.literal('tool.result'), + agentId: z.string(), + turnId: z.number(), + toolCallId: z.string(), + output: z.unknown(), + isError: z.boolean().optional(), + synthetic: z.boolean().optional(), +}) satisfies z.ZodType; + +export const subagentSpawnedEventSchema = z.object({ + type: z.literal('subagent.spawned'), + subagentId: z.string(), + subagentName: z.string(), + parentToolCallId: z.string(), + parentToolCallUuid: z.string().optional(), + parentAgentId: z.string().optional(), + callerAgentId: z.string().optional(), + description: z.string().optional(), + swarmIndex: z.number().optional(), + runInBackground: z.boolean(), + model: z.string().optional(), + thinkingEffort: z.string().optional(), + taskId: z.string().optional(), +}) satisfies z.ZodType; + +export const subagentStartedEventSchema = z.object({ + type: z.literal('subagent.started'), + subagentId: z.string(), +}) satisfies z.ZodType; + +export const subagentSuspendedEventSchema = z.object({ + type: z.literal('subagent.suspended'), + subagentId: z.string(), + reason: z.string(), +}) satisfies z.ZodType; + +export const subagentCompletedEventSchema = z.object({ + type: z.literal('subagent.completed'), + subagentId: z.string(), + resultSummary: z.string(), + usage: tokenUsageSchema.optional(), + contextTokens: z.number().optional(), +}) satisfies z.ZodType; + +export const subagentFailedEventSchema = z.object({ + type: z.literal('subagent.failed'), + subagentId: z.string(), + error: z.string(), +}) satisfies z.ZodType; + +export const subagentCancelledEventSchema = z.object({ + type: z.literal('subagent.cancelled'), + subagentId: z.string(), +}) satisfies z.ZodType; + +export const compactionStartedEventSchema = z.object({ + type: z.literal('compaction.started'), + agentId: z.string(), + trigger: z.enum(['manual', 'auto']), + instruction: z.string().optional(), +}) satisfies z.ZodType; + +export const compactionBlockedEventSchema = z.object({ + type: z.literal('compaction.blocked'), + agentId: z.string(), + turnId: z.number().optional(), +}) satisfies z.ZodType; + +export const compactionCancelledEventSchema = z.object({ + type: z.literal('compaction.cancelled'), + agentId: z.string(), +}); + +export const compactionCompletedEventSchema = z.object({ + type: z.literal('compaction.completed'), + agentId: z.string(), + result: compactionResultSchema, +}) satisfies z.ZodType; + +export const taskStartedEventSchema = z.object({ + type: z.literal('task.started'), + agentId: z.string(), + info: taskInfoSchema, +}); + +export const taskTerminatedEventSchema = z.object({ + type: z.literal('task.terminated'), + agentId: z.string(), + info: taskInfoSchema, +}); + +export const backgroundTaskStartedEventSchema = z.object({ + type: z.literal('background.task.started'), + info: taskInfoSchema, +}); + +export const backgroundTaskTerminatedEventSchema = z.object({ + type: z.literal('background.task.terminated'), + info: taskInfoSchema, +}); + +export const cronFiredEventSchema = z.object({ + type: z.literal('cron.fired'), + origin: cronJobOriginSchema, + prompt: z.string(), +}); + +export const promptSubmittedEventSchema = z.object({ + type: z.literal('prompt.submitted'), + promptId: z.string(), + userMessageId: z.string(), + status: z.enum(['running', 'queued', 'blocked']), + content: z.array(messageContentSchema), + createdAt: isoDateTimeSchema, +}); + +export const promptCompletedEventSchema = z.object({ + type: z.literal('prompt.completed'), + agentId: z.string(), + promptId: z.string(), + finishedAt: isoDateTimeSchema, + reason: z.enum(['completed', 'failed', 'blocked']).optional(), +}); + +export const promptAbortedEventSchema = z.object({ + type: z.literal('prompt.aborted'), + agentId: z.string(), + promptId: z.string(), + abortedAt: isoDateTimeSchema, +}); + +export const promptSteeredEventSchema = z.object({ + type: z.literal('prompt.steered'), + agentId: z.string(), + activePromptId: z.string(), + promptIds: z.array(z.string()), + content: z.array(messageContentSchema), + steeredAt: isoDateTimeSchema, +}); + +export const toolListUpdatedReasonSchema = z.enum([ + 'mcp.connected', + 'mcp.disconnected', + 'mcp.failed', +]) satisfies z.ZodType; + +export const toolListUpdatedEventSchema = z.object({ + type: z.literal('tool.list.updated'), + agentId: z.string(), + reason: toolListUpdatedReasonSchema, + serverName: z.string(), +}) satisfies z.ZodType; + +export const mcpServerStatusPayloadSchema = z.object({ + name: z.string(), + transport: z.enum(['stdio', 'http']), + status: z.enum(['pending', 'connected', 'failed', 'disabled', 'needs-auth', 'removed']), + toolCount: z.number(), + error: z.string().optional(), +}) satisfies z.ZodType; + +export const mcpServerStatusEventSchema = z.object({ + type: z.literal('mcp.server.status'), + agentId: z.string(), + server: mcpServerStatusPayloadSchema, +}) satisfies z.ZodType; + +export const agentEventSchema = z.discriminatedUnion('type', [ + errorEventSchema, + warningEventSchema, + agentStatusUpdatedEventSchema, + agentCreatedEventSchema, + agentDisposedEventSchema, + sessionMetaUpdatedEventSchema, + sessionCreatedEventSchema, + sessionArchivedEventSchema, + sessionDeletedEventSchema, + workspaceCreatedEventSchema, + workspaceUpdatedEventSchema, + workspaceDeletedEventSchema, + sessionWorkChangedEventSchema, + sessionStatusChangedEventSchema, + configChangedEventSchema, + configWarningEventSchema, + modelCatalogChangedEventSchema, + diUnitChangedEventSchema, + pluginChangedEventSchema, + capabilityChangedEventSchema, + goalUpdatedEventSchema, + skillActivatedEventSchema, + pluginCommandActivatedEventSchema, + turnStartedEventSchema, + turnEndedEventSchema, + turnStepStartedEventSchema, + turnStepCompletedEventSchema, + turnStepRetryingEventSchema, + turnStepInterruptedEventSchema, + assistantDeltaEventSchema, + hookResultEventSchema, + thinkingDeltaEventSchema, + toolCallDeltaEventSchema, + toolCallStartedEventSchema, + toolProgressEventSchema, + shellOutputEventSchema, + shellStartedEventSchema, + shellCompletedEventSchema, + toolResultEventSchema, + toolListUpdatedEventSchema, + mcpServerStatusEventSchema, + subagentSpawnedEventSchema, + subagentStartedEventSchema, + subagentSuspendedEventSchema, + subagentCompletedEventSchema, + subagentFailedEventSchema, + subagentCancelledEventSchema, + compactionStartedEventSchema, + compactionBlockedEventSchema, + compactionCancelledEventSchema, + compactionCompletedEventSchema, + taskStartedEventSchema, + taskTerminatedEventSchema, + backgroundTaskStartedEventSchema, + backgroundTaskTerminatedEventSchema, + cronFiredEventSchema, + promptSubmittedEventSchema, + promptCompletedEventSchema, + promptAbortedEventSchema, + promptSteeredEventSchema, +]); + +export const eventSchema = agentEventSchema.and( + z.object({ + agentId: z.string(), + sessionId: z.string(), + }), +); diff --git a/packages/kap-server/src/protocol/goal.ts b/packages/kap-server/src/protocol/goal.ts new file mode 100644 index 0000000000000000000000000000000000000000..91f28d535ef7f75eab69c1093c2449b0551760e2 --- /dev/null +++ b/packages/kap-server/src/protocol/goal.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +export const goalStatusSchema = z.enum(['active', 'paused', 'blocked', 'complete']); +export type GoalStatus = z.infer; + +export const goalBudgetReportSchema = z.object({ + tokenBudget: z.number().nullable(), + turnBudget: z.number().nullable(), + wallClockBudgetMs: z.number().nullable(), + remainingTokens: z.number().nullable(), + remainingTurns: z.number().nullable(), + remainingWallClockMs: z.number().nullable(), + tokenBudgetReached: z.boolean(), + turnBudgetReached: z.boolean(), + wallClockBudgetReached: z.boolean(), + overBudget: z.boolean(), +}); +export type GoalBudgetReport = z.infer; + +export const goalSnapshotSchema = z.object({ + goalId: z.string(), + objective: z.string(), + completionCriterion: z.string().optional(), + status: goalStatusSchema, + turnsUsed: z.number(), + tokensUsed: z.number(), + wallClockMs: z.number(), + budget: goalBudgetReportSchema, + terminalReason: z.string().optional(), +}); +export type GoalSnapshotWire = z.infer; diff --git a/packages/kap-server/src/protocol/message.ts b/packages/kap-server/src/protocol/message.ts new file mode 100644 index 0000000000000000000000000000000000000000..7bd4b36116fd5c2202032171a40f2f99068cb62c --- /dev/null +++ b/packages/kap-server/src/protocol/message.ts @@ -0,0 +1,119 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; + +export const messageRoleSchema = z.enum(['user', 'assistant', 'tool', 'system']); +export type MessageRole = z.infer; + +export const textContentSchema = z.object({ + type: z.literal('text'), + text: z.string(), +}); +export type TextContent = z.infer; + +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; + +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; + +export const imageSourceSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('url'), + url: z.string().min(1), + id: z.string().min(1).optional(), + }), + 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) }), + z.object({ kind: z.literal('session_media'), file_id: z.string().min(1) }), + z.object({ kind: z.literal('path'), path: z.string().min(1) }), +]); +export type ImageSource = z.infer; + +export const imageContentSchema = z.object({ + type: z.literal('image'), + source: imageSourceSchema, + name: z.string().min(1).optional(), +}); +export type ImageContent = z.infer; + +export const videoContentSchema = z.object({ + type: z.literal('video'), + source: imageSourceSchema, + name: z.string().min(1).optional(), +}); +export type VideoContent = z.infer; + +export const fileContentSchema = z + .object({ + type: z.literal('file'), + file_id: z.string().min(1).optional(), + path: z.string().min(1).optional(), + name: z.string().optional(), + media_type: z.string().min(1).optional(), + size: z.number().int().nonnegative().optional(), + }) + .superRefine((part, ctx) => { + const hasFileId = part.file_id !== undefined; + const hasPath = part.path !== undefined; + if (hasFileId === hasPath) { + ctx.addIssue({ + code: 'custom', + message: 'exactly one of file_id or path is required', + path: hasFileId ? ['path'] : ['file_id'], + }); + return; + } + if (hasPath) return; + for (const key of ['name', 'media_type', 'size'] as const) { + if (part[key] === undefined) { + ctx.addIssue({ code: 'custom', message: `${key} is required with file_id`, path: [key] }); + } + } + }); +export type FileContent = z.infer; + +export const thinkingContentSchema = z.object({ + type: z.literal('thinking'), + thinking: z.string(), + signature: z.string().optional(), +}); +export type ThinkingContent = z.infer; + +export const messageContentSchema = z.discriminatedUnion('type', [ + textContentSchema, + toolUseContentSchema, + toolResultContentSchema, + imageContentSchema, + videoContentSchema, + fileContentSchema, + thinkingContentSchema, +]); +export type MessageContent = z.infer; + +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; diff --git a/packages/kap-server/src/protocol/messages/ack.ts b/packages/kap-server/src/protocol/messages/ack.ts new file mode 100644 index 0000000000000000000000000000000000000000..4313dc38b582f506632a81ff294c7165b3f8695c --- /dev/null +++ b/packages/kap-server/src/protocol/messages/ack.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const ackMessageSchema = z.object({ + type: z.literal('ack'), + id: z.number().int().nonnegative(), + code: z.number().int(), + msg: z.string().optional(), +}); + +export type AckMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/agent-state.ts b/packages/kap-server/src/protocol/messages/agent-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..c835001b4a0fd659bd8cf0ba8336fbb33483d3d1 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/agent-state.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +import { epochMsSchema, isoDateTimeSchema } from './base'; + +export const agentStatusSchema = z.enum(['idle', 'running', 'interrupted', 'completed', 'failed']); + +export type AgentStatus = z.infer; + +export const agentStateOriginSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('btw') }), + z.object({ kind: z.literal('main') }), + z.object({ + kind: z.literal('tool-swarm'), + tool_call_id: z.string().min(1), + swarm_index: z.number().int().nonnegative(), + parent_agent_id: z.string().min(1), + }), + z.object({ + kind: z.literal('tool-agent'), + tool_call_id: z.string().min(1), + parent_agent_id: z.string().min(1), + }), +]); + +export type AgentStateOrigin = z.infer; + +export const agentStateTurnSchema = z.object({ + status: z.enum(['thinking', 'retrying', 'acting', 'aborting']), +}); + +export type AgentStateTurn = z.infer; + +export const agentStateMessageSchema = z.object({ + type: z.literal('agent.state'), + session_id: z.string().min(1), + agent_id: z.string().min(1), + profile: z.object({ kind: z.string() }), + timestamp: epochMsSchema, + origin: agentStateOriginSchema, + created_at: isoDateTimeSchema, + ended_at: isoDateTimeSchema.optional(), + status: agentStatusSchema, + turn: agentStateTurnSchema.optional(), +}); + +export type AgentStateMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/assistant-delta.ts b/packages/kap-server/src/protocol/messages/assistant-delta.ts new file mode 100644 index 0000000000000000000000000000000000000000..c22e4bc4e01e3197f5e989ba589d84ff7e121cc7 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/assistant-delta.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; + +export const assistantDeltaMessageSchema = z.object({ + type: z.literal('assistant.delta'), + ...timelineMessageBase, + message_id: z.string().min(1), + text: z.string(), +}); + +export type AssistantDelta = z.infer; diff --git a/packages/kap-server/src/protocol/messages/assistant.ts b/packages/kap-server/src/protocol/messages/assistant.ts new file mode 100644 index 0000000000000000000000000000000000000000..86e491d7d73feb08e135b6038968c231f211765f --- /dev/null +++ b/packages/kap-server/src/protocol/messages/assistant.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; + +export const assistantMessageSchema = z.object({ + type: z.literal('assistant'), + ...timelineMessageBase, + message_id: z.string().min(1), + turn_id: z.string().min(1), + step_id: z.string().min(1), + status: z.enum(['streaming', 'completed']), + text: z.string(), +}); + +export type AssistantMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/base.ts b/packages/kap-server/src/protocol/messages/base.ts new file mode 100644 index 0000000000000000000000000000000000000000..62ecd68bbe2787a247c7471815ae913d75add55d --- /dev/null +++ b/packages/kap-server/src/protocol/messages/base.ts @@ -0,0 +1,38 @@ +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 epochMsSchema = z.number(); + +export const timelineMessageBase = { + session_id: z.string().min(1), + agent_id: z.string().min(1), + timestamp: epochMsSchema, +}; + +export const sessionMessageBase = { + session_id: z.string().min(1), + timestamp: epochMsSchema, +}; + +export const globalMessageBase = { + timestamp: epochMsSchema, +}; diff --git a/packages/kap-server/src/protocol/messages/capability.ts b/packages/kap-server/src/protocol/messages/capability.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e8c05b21b915e96e0ca942104f7abbfadad9bd7 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/capability.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { globalMessageBase } from './base'; + +export const capabilityMessageSchema = z.object({ + type: z.literal('capability'), + ...globalMessageBase, + capability_id: z.string().min(1).optional(), +}); + +export type CapabilityChanged = z.infer; + +export type CapabilityChangedMessage = CapabilityChanged; diff --git a/packages/kap-server/src/protocol/messages/config-warning.ts b/packages/kap-server/src/protocol/messages/config-warning.ts new file mode 100644 index 0000000000000000000000000000000000000000..04637c572f5007869e2afdb1390353a59dfef79b --- /dev/null +++ b/packages/kap-server/src/protocol/messages/config-warning.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { globalMessageBase } from './base'; + +export const configWarningMessageSchema = z.object({ + type: z.literal('config.warning'), + ...globalMessageBase, + warnings: z.array(z.string()), +}); + +export type ConfigWarning = z.infer; + +export type ConfigWarningMessage = ConfigWarning; diff --git a/packages/kap-server/src/protocol/messages/config.ts b/packages/kap-server/src/protocol/messages/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8b46be5799cc13d0a046a8b1ad5b3c7534eff21 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/config.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { globalMessageBase } from './base'; + +export const configMessageSchema = z.object({ + type: z.literal('config'), + ...globalMessageBase, + config: z.unknown().nonoptional(), + changed_fields: z.array(z.string().min(1)).optional(), +}); + +export type ConfigMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/entity-id.ts b/packages/kap-server/src/protocol/messages/entity-id.ts new file mode 100644 index 0000000000000000000000000000000000000000..1cbed227a3955210d250ca3685c2a152f396438a --- /dev/null +++ b/packages/kap-server/src/protocol/messages/entity-id.ts @@ -0,0 +1,32 @@ +import type { ServerMessage } from './union'; + +export function entityId(msg: ServerMessage): string { + const m = msg as { + message_id?: string; + tool_call_id?: string; + interaction_id?: string; + task_id?: string; + todo_id?: string; + system_id?: string; + step_id?: string; + turn_id?: string; + agent_id?: string; + }; + return ( + m.message_id ?? + m.tool_call_id ?? + m.interaction_id ?? + m.task_id ?? + m.todo_id ?? + m.system_id ?? + m.step_id ?? + m.turn_id ?? + m.agent_id ?? + '' + ); +} + +export function entityKey(msg: ServerMessage): string { + const agent = (msg as { agent_id?: string }).agent_id ?? ''; + return `${agent}:${msg.type}:${entityId(msg)}`; +} diff --git a/packages/kap-server/src/protocol/messages/error.ts b/packages/kap-server/src/protocol/messages/error.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e900b47ca8a31f1d56e7729aed42b9280682afc --- /dev/null +++ b/packages/kap-server/src/protocol/messages/error.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const errorMessageSchema = z.object({ + type: z.literal('error'), + code: z.number().int(), + msg: z.string(), +}); + +export type ErrorMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/hello.ts b/packages/kap-server/src/protocol/messages/hello.ts new file mode 100644 index 0000000000000000000000000000000000000000..7083d7512cffd89f0e4624b25c149237d8aefd6e --- /dev/null +++ b/packages/kap-server/src/protocol/messages/hello.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const helloMessageSchema = z.object({ + type: z.literal('hello'), + protocol_version: z.string().min(1), + server_id: z.string().min(1), + capabilities: z.array(z.string().min(1)), +}); + +export type HelloMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/history.ts b/packages/kap-server/src/protocol/messages/history.ts new file mode 100644 index 0000000000000000000000000000000000000000..66caf59801c1ec65bd6dc7bfe0252685822ff56b --- /dev/null +++ b/packages/kap-server/src/protocol/messages/history.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +import { assistantMessageSchema } from './assistant'; +import { interactionMessageSchema } from './interaction'; +import { stepMessageSchema } from './step'; +import { systemMessageSchema } from './system'; +import { taskMessageSchema } from './task'; +import { thinkingMessageSchema } from './thinking'; +import { todoMessageSchema } from './todo'; +import { toolCallMessageSchema } from './tool-call'; +import { turnMessageSchema } from './turn'; +import { userMessageSchema } from './user'; + +export const historyMessageSchema = z.discriminatedUnion('type', [ + turnMessageSchema, + stepMessageSchema, + userMessageSchema, + assistantMessageSchema, + thinkingMessageSchema, + toolCallMessageSchema, + systemMessageSchema, + interactionMessageSchema, + taskMessageSchema, + todoMessageSchema, +]); + +export type HistoryMessage = z.infer; + +export const historyQuerySchema = z.object({ + before_turn: z.string().min(1).optional(), + after_step: z.string().min(1).optional(), + page_size: z.number().int().positive().optional(), + agent_id: z.string().min(1).optional(), +}); + +export type HistoryQuery = z.infer; + +export const historyInFlightSchema = z.object({ + turn_id: z.string().min(1), + step_id: z.string().min(1), +}); + +export type HistoryInFlight = z.infer; + +export const historyResponseSchema = z.object({ + messages: z.array(historyMessageSchema), + has_more: z.boolean(), + in_flight: historyInFlightSchema.optional(), +}); + +export type HistoryResponse = z.infer; diff --git a/packages/kap-server/src/protocol/messages/index.ts b/packages/kap-server/src/protocol/messages/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b3d58e095324c0bc9ab538891295de5d833ef96 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/index.ts @@ -0,0 +1,35 @@ +export * from './ack'; +export * from './agent-state'; +export * from './assistant'; +export * from './assistant-delta'; +export * from './base'; +export * from './capability'; +export * from './config'; +export * from './config-warning'; +export * from './entity-id'; +export * from './error'; +export * from './hello'; +export * from './history'; +export * from './interaction'; +export * from './model-catalog'; +export * from './plugin'; +export * from './session'; +export * from './session-state'; +export * from './step'; +export * from './step-usage'; +export * from './subscribe'; +export * from './system'; +export * from './task'; +export * from './thinking'; +export * from './thinking-delta'; +export * from './todo'; +export * from './tool-call'; +export * from './tool-call-delta'; +export * from './tool-progress'; +export * from './turn'; +export * from './turn-origin'; +export * from './union'; +export * from './unsubscribe'; +export * from './user'; +export * from './user-message-origin'; +export * from './workspace'; diff --git a/packages/kap-server/src/protocol/messages/interaction.ts b/packages/kap-server/src/protocol/messages/interaction.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b16d83774441029562286bb0084c9408a8beb49 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/interaction.ts @@ -0,0 +1,107 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema, timelineMessageBase } from './base'; + +export const interactionApprovalRequestSchema = z.object({ + tool_name: z.string().min(1), + action: z.string(), + tool_input_display: z.unknown().optional(), + expires_at: isoDateTimeSchema.optional(), +}); + +export type InteractionApprovalRequest = z.infer; + +export const interactionApprovalResponseSchema = z.object({ + decision: z.enum(['approved', 'rejected', 'cancelled']), + scope: z.literal('session').optional(), + feedback: z.string().optional(), + selected_label: z.string().optional(), +}); + +export type InteractionApprovalResponse = z.infer; + +export const interactionQuestionOptionSchema = z.object({ + id: z.string().min(1), + label: z.string(), + description: z.string().optional(), +}); + +export type InteractionQuestionOption = z.infer; + +export const interactionQuestionItemSchema = z.object({ + id: z.string().min(1), + question: z.string(), + header: z.string().optional(), + body: z.string().optional(), + options: z.array(interactionQuestionOptionSchema), + multi_select: z.boolean().optional(), + allow_other: z.boolean().optional(), + other_label: z.string().optional(), + other_description: z.string().optional(), +}); + +export type InteractionQuestionItem = z.infer; + +export const interactionQuestionRequestSchema = z.object({ + questions: z.array(interactionQuestionItemSchema), +}); + +export type InteractionQuestionRequest = z.infer; + +export const interactionQuestionAnswerSchema = 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 InteractionQuestionAnswer = z.infer; + +export const interactionQuestionResponseSchema = z.object({ + answers: z.record(z.string().min(1), interactionQuestionAnswerSchema), + method: z.enum(['enter', 'space', 'number_key', 'click']).optional(), + note: z.string().optional(), +}); + +export type InteractionQuestionResponse = z.infer; + +export const interactionStatusSchema = z.enum([ + 'pending', + 'approved', + 'rejected', + 'cancelled', + 'answered', + 'dismissed', +]); + +export type InteractionStatus = z.infer; + +const interactionMessageBase = { + type: z.literal('interaction'), + ...timelineMessageBase, + interaction_id: z.string().min(1), + status: interactionStatusSchema, + tool_call_id: z.string().min(1).optional(), +}; + +export const interactionMessageSchema = z.discriminatedUnion('kind', [ + z.object({ + ...interactionMessageBase, + kind: z.literal('approval'), + request: interactionApprovalRequestSchema.optional(), + response: interactionApprovalResponseSchema.optional(), + }), + z.object({ + ...interactionMessageBase, + kind: z.literal('question'), + request: interactionQuestionRequestSchema.optional(), + response: interactionQuestionResponseSchema.optional(), + }), +]); + +export type InteractionMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/model-catalog.ts b/packages/kap-server/src/protocol/messages/model-catalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4787aee13259880f3783b62a0c27e05cccc9252 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/model-catalog.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { globalMessageBase } from './base'; + +export const modelCatalogMessageSchema = z.object({ + type: z.literal('model_catalog'), + ...globalMessageBase, +}); + +export type CatalogChanged = z.infer; + +export type ModelCatalogChangedMessage = CatalogChanged; diff --git a/packages/kap-server/src/protocol/messages/plugin.ts b/packages/kap-server/src/protocol/messages/plugin.ts new file mode 100644 index 0000000000000000000000000000000000000000..45dd1ff37405f9cbfc0fb97322dfebdf0350715f --- /dev/null +++ b/packages/kap-server/src/protocol/messages/plugin.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { globalMessageBase } from './base'; + +export const pluginMessageSchema = z.object({ + type: z.literal('plugin'), + ...globalMessageBase, +}); + +export type PluginChanged = z.infer; + +export type PluginChangedMessage = PluginChanged; diff --git a/packages/kap-server/src/protocol/messages/session-state.ts b/packages/kap-server/src/protocol/messages/session-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..426b4d4fa3082dc6f48decea7ba934768f92574d --- /dev/null +++ b/packages/kap-server/src/protocol/messages/session-state.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +import { sessionMessageBase } from './base'; +import { stepUsageSchema } from './step-usage'; + +export const sessionStateUsageSchema = z.object({ + by_model: z.record(z.string(), stepUsageSchema).optional(), + current_turn: stepUsageSchema.optional(), + total: stepUsageSchema.optional(), +}); + +export type SessionStateUsage = z.infer; + +export const sessionStateGoalSchema = z.object({ + objective: z.string(), + status: z.enum(['active', 'paused', 'blocked', 'complete']), + completion_criterion: z.string().optional(), + budget_used: z.number().optional(), + budget_limit: z.number().optional(), +}); + +export type SessionStateGoal = z.infer; + +export const sessionStateModesSchema = z.object({ + plan: z + .object({ + review_path: z.string().optional(), + version: z.number().int().optional(), + }) + .optional(), + swarm: z + .object({ + trigger: z.string().optional(), + }) + .optional(), +}); + +export type SessionStateModes = z.infer; + +export const sessionStateMessageSchema = z.object({ + type: z.literal('session.state'), + ...sessionMessageBase, + status: z.enum(['idle', 'running', 'compacting']), + pending_interaction: z.enum(['none', 'approval', 'question']).optional(), + model: z.string().optional(), + thinking_effort: z.string().optional(), + permission: z.enum(['manual', 'yolo', 'auto']).optional(), + usage: sessionStateUsageSchema.optional(), + context_tokens: z.number().int().nonnegative().optional(), + max_context_tokens: z.number().int().nonnegative().optional(), + goal: sessionStateGoalSchema.optional(), + modes: sessionStateModesSchema.optional(), +}); + +export type SessionStateMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/session.ts b/packages/kap-server/src/protocol/messages/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..4666280b1f2739055fc37dcbdf97109b90b959c3 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/session.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +import { globalMessageBase, isoDateTimeSchema } from './base'; +import { workspaceIdSchema } from './workspace'; + +const sessionInfoMetadataSchema = z + .object({ + cwd: z.string().min(1), + }) + .catchall(z.unknown()); + +const sessionInfoAgentConfigSchema = z.object({ + model: z.string(), + system_prompt: z.string().optional(), + tools: z.array(z.string()).optional(), + mcp_servers: z.array(z.string()).optional(), + thinking: z.string().min(1).optional(), + permission_mode: z.enum(['manual', 'yolo', 'auto']).optional(), + plan_mode: z.boolean().optional(), + swarm_mode: z.boolean().optional(), + tower_mode: z.boolean().optional(), + tower_base: z.string().min(1).optional(), + goal_objective: z.string().optional(), + goal_control: z.enum(['pause', 'resume', 'cancel']).optional(), +}); + +const sessionInfoUsageSchema = 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().optional(), + context_tokens: z.number().int().nonnegative(), + context_limit: z.number().int().nonnegative().optional(), + turn_count: z.number().int().nonnegative().optional(), +}); + +const sessionInfoPermissionRuleSchema = z.object({ + id: z.string().min(1), + tool_name: z.string().min(1), + matcher: z + .object({ + kind: z.enum(['command_prefix', 'path_glob', 'exact_input', 'always']), + value: z.string().optional(), + }) + .optional(), + decision: z.literal('approved'), + created_at: isoDateTimeSchema, + created_by: z.enum(['user', 'agent']), +}); + +export const sessionInfoSchema = z.object({ + id: z.string().min(1), + workspace_id: workspaceIdSchema, + title: z.string(), + created_at: isoDateTimeSchema, + updated_at: isoDateTimeSchema, + busy: z.boolean(), + main_turn_active: z.boolean().optional(), + pending_interaction: z.enum(['none', 'approval', 'question']).optional(), + last_turn_reason: z.enum(['completed', 'cancelled', 'failed']).optional(), + archived: z.boolean().optional(), + archived_at: isoDateTimeSchema.optional(), + current_prompt_id: z.string().min(1).optional(), + last_prompt: z.string().optional(), + metadata: sessionInfoMetadataSchema, + agent_config: sessionInfoAgentConfigSchema, + usage: sessionInfoUsageSchema, + permission_rules: z.array(sessionInfoPermissionRuleSchema), + message_count: z.number().int().nonnegative(), + last_seq: z.number().int().nonnegative(), +}); + +export type SessionInfo = z.infer; + +export const sessionMessageSchema = z.object({ + type: z.literal('session'), + ...globalMessageBase, + subtype: z.enum(['created', 'updated', 'archived', 'deleted']), + session: sessionInfoSchema, + changed_fields: z.array(z.string().min(1)).optional(), +}); + +export type SessionMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/step-usage.ts b/packages/kap-server/src/protocol/messages/step-usage.ts new file mode 100644 index 0000000000000000000000000000000000000000..3355e5254c8b74ce420f3ece21bf9c16f970e433 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/step-usage.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const stepUsageSchema = z.object({ + input_other: z.number().int().nonnegative(), + output: z.number().int().nonnegative(), + input_cache_read: z.number().int().nonnegative(), + input_cache_creation: z.number().int().nonnegative(), +}); + +export type StepUsage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/step.ts b/packages/kap-server/src/protocol/messages/step.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a9a6939c0f45efc00b2f583fb9e6faeb9019394 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/step.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema, timelineMessageBase } from './base'; +import { stepUsageSchema } from './step-usage'; + +export const stepTimingSchema = z.object({ + llm_first_token_ms: z.number().nonnegative().optional(), + llm_stream_duration_ms: z.number().nonnegative().optional(), +}); + +export type StepTiming = z.infer; + +export const stepRetrySchema = z.object({ + failed_attempt: z.number().int().positive(), + next_attempt: z.number().int().positive(), + max_attempts: z.number().int().positive(), + delay_ms: z.number().nonnegative(), + error_name: z.string(), + error_message: z.string(), + status_code: z.number().int().optional(), +}); + +export type StepRetry = z.infer; + +export const stepMessageSchema = z.object({ + type: z.literal('step'), + ...timelineMessageBase, + step_id: z.string().min(1), + turn_id: z.string().min(1), + ordinal: z.number().int().nonnegative(), + status: z.enum(['running', 'completed', 'interrupted', 'failed']), + started_at: isoDateTimeSchema.optional(), + ended_at: isoDateTimeSchema.optional(), + usage: stepUsageSchema.optional(), + finish_reason: z.string().optional(), + timing: stepTimingSchema.optional(), + retry: stepRetrySchema.optional(), + end_reason: z.string().optional(), + end_message: z.string().optional(), +}); + +export type StepMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/subscribe.ts b/packages/kap-server/src/protocol/messages/subscribe.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0e2018c3fb8fe97f59c9d1f8af03aa23572d8f0 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/subscribe.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const subscribeMessageSchema = z.object({ + type: z.literal('subscribe'), + id: z.number().int().nonnegative(), + session_id: z.string().min(1), + agent_ids: z.array(z.string().min(1)).optional(), + omit: z.array(z.string().min(1)).optional(), +}); + +export type SubscribeMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/system.ts b/packages/kap-server/src/protocol/messages/system.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6f4f93c9ac60e9c8b21cfb323f48989b3f66ea8 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/system.ts @@ -0,0 +1,81 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema, timelineMessageBase } from './base'; + +export const systemRemovedIdsPayloadSchema = z.object({ + removed_ids: z.array(z.string().min(1)), +}); + +export type SystemRemovedIdsPayload = z.infer; + +const systemMessageBase = { + type: z.literal('system'), + ...timelineMessageBase, + system_id: z.string().min(1), + at: isoDateTimeSchema.optional(), +}; + +export const systemMessageSchema = z.discriminatedUnion('subtype', [ + z.object({ + ...systemMessageBase, + subtype: z.literal('compaction'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('undo'), + payload: systemRemovedIdsPayloadSchema, + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('clear'), + payload: systemRemovedIdsPayloadSchema, + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('goal'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('plan.enter'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('plan.exit'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('plan.revision'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('swarm.enter'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('swarm.exit'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('notice'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('hook'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('interruption'), + payload: z.unknown().optional(), + }), +]); + +export type SystemMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/task.ts b/packages/kap-server/src/protocol/messages/task.ts new file mode 100644 index 0000000000000000000000000000000000000000..5201be33b4963e7e46274c918578934c33bd786e --- /dev/null +++ b/packages/kap-server/src/protocol/messages/task.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema, timelineMessageBase } from './base'; +import { stepUsageSchema } from './step-usage'; + +export const taskMessageSchema = z.object({ + type: z.literal('task'), + ...timelineMessageBase, + task_id: z.string().min(1), + kind: z.enum(['shell', 'subagent', 'tool', 'other']), + status: z.enum(['running', 'completed', 'failed', 'timed_out', 'killed', 'lost']), + detached: z.boolean(), + description: z.string().optional(), + child_agent_id: z.string().min(1).optional(), + output_tail: z.string(), + started_at: isoDateTimeSchema.optional(), + ended_at: isoDateTimeSchema.optional(), + result_summary: z.string().optional(), + error: z.string().optional(), + state_reason: z.string().optional(), + usage: stepUsageSchema.optional(), + model: z.string().optional(), + thinking_effort: z.string().optional(), +}); + +export type TaskMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/thinking-delta.ts b/packages/kap-server/src/protocol/messages/thinking-delta.ts new file mode 100644 index 0000000000000000000000000000000000000000..62675a812d6dce6f64425133e2bd8844ce5d57d2 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/thinking-delta.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; + +export const thinkingDeltaMessageSchema = z.object({ + type: z.literal('thinking.delta'), + ...timelineMessageBase, + message_id: z.string().min(1), + text: z.string(), +}); + +export type ThinkingDelta = z.infer; diff --git a/packages/kap-server/src/protocol/messages/thinking.ts b/packages/kap-server/src/protocol/messages/thinking.ts new file mode 100644 index 0000000000000000000000000000000000000000..278f8c0ce05313d82e051e63d1959815a37015c7 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/thinking.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; + +export const thinkingMessageSchema = z.object({ + type: z.literal('thinking'), + ...timelineMessageBase, + message_id: z.string().min(1), + turn_id: z.string().min(1), + step_id: z.string().min(1), + status: z.enum(['streaming', 'completed']), + text: z.string(), +}); + +export type ThinkingMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/todo.ts b/packages/kap-server/src/protocol/messages/todo.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee338b16b05f61e37fb32a7fe5f2d46d0ffeb967 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/todo.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema, timelineMessageBase } from './base'; + +export const todoItemSchema = z.object({ + title: z.string(), + status: z.enum(['pending', 'in_progress', 'done']), +}); + +export type TodoItem = z.infer; + +export const todoMessageSchema = z.object({ + type: z.literal('todo'), + ...timelineMessageBase, + todo_id: z.string().min(1), + items: z.array(todoItemSchema), + updated_at: isoDateTimeSchema.optional(), +}); + +export type TodoMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/tool-call-delta.ts b/packages/kap-server/src/protocol/messages/tool-call-delta.ts new file mode 100644 index 0000000000000000000000000000000000000000..37c1548b013a7a1a22e859ff6e6eadb7ddde90c7 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/tool-call-delta.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; + +export const toolCallDeltaMessageSchema = z.object({ + type: z.literal('tool_call.delta'), + ...timelineMessageBase, + tool_call_id: z.string().min(1), + input_text: z.string(), +}); + +export type ToolCallDelta = z.infer; diff --git a/packages/kap-server/src/protocol/messages/tool-call.ts b/packages/kap-server/src/protocol/messages/tool-call.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d074fc3684f9b70688f42a4294369bc78b891eb --- /dev/null +++ b/packages/kap-server/src/protocol/messages/tool-call.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; +import { toolProgressPayloadSchema } from './tool-progress'; + +export const toolCallAgentRefSchema = z.object({ + agent_id: z.string().min(1), + role: z.enum(['child', 'member']).optional(), +}); + +export type ToolCallAgentRef = z.infer; + +export const toolCallMessageSchema = z.object({ + type: z.literal('tool_call'), + ...timelineMessageBase, + tool_call_id: z.string().min(1), + turn_id: z.string().min(1), + step_id: z.string().min(1), + name: z.string().min(1), + view: z.string().optional(), + status: z.enum(['running', 'done', 'error']), + input: z.unknown().optional(), + input_text: z.string().optional(), + output: z.unknown().optional(), + display: z.unknown().optional(), + error: z.string().optional(), + progress: toolProgressPayloadSchema.optional(), + task_id: z.string().min(1).optional(), + approval_id: z.string().min(1).optional(), + todo_id: z.string().min(1).optional(), + agent_refs: z.array(toolCallAgentRefSchema).optional(), +}); + +export type ToolCallMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/tool-progress.ts b/packages/kap-server/src/protocol/messages/tool-progress.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfefac939143dcab98c5c98255d68b2fd3bcd880 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/tool-progress.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; + +export const toolProgressPayloadSchema = z.object({ + kind: z.enum(['stdout', 'stderr', 'progress', 'status', 'custom']), + text: z.string().optional(), + percent: z.number().optional(), + custom_kind: z.string().optional(), + custom_data: z.unknown().optional(), +}); + +export type ToolProgressPayload = z.infer; + +export const toolProgressMessageSchema = z.object({ + type: z.literal('tool.progress'), + ...timelineMessageBase, + tool_call_id: z.string().min(1), + progress: toolProgressPayloadSchema, +}); + +export type ToolProgress = z.infer; diff --git a/packages/kap-server/src/protocol/messages/turn-origin.ts b/packages/kap-server/src/protocol/messages/turn-origin.ts new file mode 100644 index 0000000000000000000000000000000000000000..64bac92d490d5c08b7edd6c113a6e2261827aaad --- /dev/null +++ b/packages/kap-server/src/protocol/messages/turn-origin.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const turnOriginSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('user') }), + z.object({ kind: z.literal('cron') }), + z.object({ kind: z.literal('task'), task_id: z.string().min(1) }), + z.object({ kind: z.literal('hook') }), + z.object({ kind: z.literal('compaction') }), + z.object({ kind: z.literal('side') }), + z.object({ kind: z.literal('goal') }), + z.object({ kind: z.literal('other') }), +]); + +export type TurnOrigin = z.infer; diff --git a/packages/kap-server/src/protocol/messages/turn.ts b/packages/kap-server/src/protocol/messages/turn.ts new file mode 100644 index 0000000000000000000000000000000000000000..4472510949319f5089f187180a37a24a41d2bc46 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/turn.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema, timelineMessageBase } from './base'; +import { turnOriginSchema } from './turn-origin'; + +export const turnUsageSchema = z.object({ + input_tokens: z.number().int().nonnegative().optional(), + output_tokens: z.number().int().nonnegative().optional(), + cached_tokens: z.number().int().nonnegative().optional(), + cost: z.number().nonnegative().optional(), +}); + +export type TurnUsage = z.infer; + +export const turnMessageSchema = z.object({ + type: z.literal('turn'), + ...timelineMessageBase, + turn_id: z.string().min(1), + ordinal: z.number().int().nonnegative(), + status: z.enum(['running', 'completed']), + origin: turnOriginSchema, + user_message_id: z.string().min(1).optional(), + attachment_ids: z.array(z.string().min(1)).optional(), + started_at: isoDateTimeSchema.optional(), + ended_at: isoDateTimeSchema.optional(), + usage: turnUsageSchema.optional(), + duration_ms: z.number().nonnegative().optional(), +}); + +export type TurnMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/union.ts b/packages/kap-server/src/protocol/messages/union.ts new file mode 100644 index 0000000000000000000000000000000000000000..29bba0b80e12b3fbb7401f0e18394ed4381cccee --- /dev/null +++ b/packages/kap-server/src/protocol/messages/union.ts @@ -0,0 +1,90 @@ +import { z } from 'zod'; + +import { ackMessageSchema } from './ack'; +import { agentStateMessageSchema } from './agent-state'; +import { assistantMessageSchema } from './assistant'; +import { assistantDeltaMessageSchema, type AssistantDelta } from './assistant-delta'; +import { capabilityMessageSchema } from './capability'; +import { configMessageSchema } from './config'; +import { configWarningMessageSchema } from './config-warning'; +import { errorMessageSchema } from './error'; +import { helloMessageSchema } from './hello'; +import { interactionMessageSchema } from './interaction'; +import { modelCatalogMessageSchema } from './model-catalog'; +import { pluginMessageSchema } from './plugin'; +import { sessionMessageSchema } from './session'; +import { sessionStateMessageSchema } from './session-state'; +import { stepMessageSchema } from './step'; +import { subscribeMessageSchema } from './subscribe'; +import { systemMessageSchema } from './system'; +import { taskMessageSchema } from './task'; +import { thinkingMessageSchema } from './thinking'; +import { thinkingDeltaMessageSchema, type ThinkingDelta } from './thinking-delta'; +import { todoMessageSchema } from './todo'; +import { toolCallMessageSchema } from './tool-call'; +import { toolCallDeltaMessageSchema, type ToolCallDelta } from './tool-call-delta'; +import { toolProgressMessageSchema, type ToolProgress } from './tool-progress'; +import { turnMessageSchema } from './turn'; +import { unsubscribeMessageSchema } from './unsubscribe'; +import { userMessageSchema } from './user'; +import { workspaceMessageSchema } from './workspace'; + +export const serverMessageSchema = z.discriminatedUnion('type', [ + turnMessageSchema, + stepMessageSchema, + userMessageSchema, + assistantMessageSchema, + assistantDeltaMessageSchema, + thinkingMessageSchema, + thinkingDeltaMessageSchema, + toolCallMessageSchema, + toolCallDeltaMessageSchema, + toolProgressMessageSchema, + systemMessageSchema, + interactionMessageSchema, + taskMessageSchema, + todoMessageSchema, + agentStateMessageSchema, + sessionStateMessageSchema, + sessionMessageSchema, + workspaceMessageSchema, + configMessageSchema, + configWarningMessageSchema, + modelCatalogMessageSchema, + pluginMessageSchema, + capabilityMessageSchema, + helloMessageSchema, + ackMessageSchema, + errorMessageSchema, +]); + +export type ServerMessage = z.infer; + +export type DeltaMessage = AssistantDelta | ThinkingDelta | ToolCallDelta | ToolProgress; + +export const clientMessageSchema = z.discriminatedUnion('type', [ + subscribeMessageSchema, + unsubscribeMessageSchema, +]); + +export type ClientMessage = z.infer; + +export class ContractViolation extends Error { + readonly issues: z.core.$ZodIssue[]; + readonly raw: unknown; + + constructor(issues: z.core.$ZodIssue[], raw: unknown) { + super( + `server message contract violation: ${issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`, + ); + this.name = 'ContractViolation'; + this.issues = issues; + this.raw = raw; + } +} + +export function parseServerMessage(raw: unknown): ServerMessage { + const result = serverMessageSchema.safeParse(raw); + if (!result.success) throw new ContractViolation(result.error.issues, raw); + return result.data; +} diff --git a/packages/kap-server/src/protocol/messages/unsubscribe.ts b/packages/kap-server/src/protocol/messages/unsubscribe.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ea626e0220e03e6c42112e98ece95c8c9b1b869 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/unsubscribe.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const unsubscribeMessageSchema = z.object({ + type: z.literal('unsubscribe'), + id: z.number().int().nonnegative(), + session_id: z.string().min(1), +}); + +export type UnsubscribeMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/user-message-origin.ts b/packages/kap-server/src/protocol/messages/user-message-origin.ts new file mode 100644 index 0000000000000000000000000000000000000000..3adf0668e9c7775918212b47a85cb721bc5b4976 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/user-message-origin.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +export const userMessageOriginSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('user'), + cron_id: z.string().min(1).optional(), + schedule: z.string().min(1).optional(), + }), + z.object({ + kind: z.literal('cron'), + cron_id: z.string().min(1).optional(), + schedule: z.string().min(1).optional(), + }), + z.object({ + kind: z.literal('task'), + task_id: z.string().min(1), + title: z.string(), + body: z.string(), + severity: z.string().optional(), + type: z.string().optional(), + source_kind: z.string().optional(), + source_id: z.string().optional(), + agent_id: z.string().optional(), + raw: z.unknown().optional(), + }), + z.object({ + kind: z.literal('skill'), + skill_name: z.string().min(1), + args: z.string().optional(), + trigger: z.string().optional(), + }), +]); + +export type UserMessageOrigin = z.infer; + +export type TaskNotificationPayload = Omit, 'kind' | 'task_id'>; diff --git a/packages/kap-server/src/protocol/messages/user.ts b/packages/kap-server/src/protocol/messages/user.ts new file mode 100644 index 0000000000000000000000000000000000000000..769d28d5aad0e88a82bd92cd192322c88292b9ba --- /dev/null +++ b/packages/kap-server/src/protocol/messages/user.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +import { epochMsSchema } from './base'; +import { userMessageOriginSchema } from './user-message-origin'; + +export const contentPartSchema = z.object({ + type: z.enum(['text', 'think', 'image', 'audio', 'video']), + text: z.string(), + meta: z.record(z.string(), z.any()), +}); + +export type ContentPart = z.infer; + +export const skillActivationSchema = z.object({ + skill_name: z.string().min(1), + skill_args: z.string().optional(), +}); + +export type SkillActivation = z.infer; + +export const userMessageSchema = z.object({ + type: z.literal('user'), + session_id: z.string().min(1), + agent_id: z.string().min(1), + message_id: z.string().min(1), + turn_id: z.string().min(1).optional(), + status: z.enum(['unread', 'read']), + timestamp: epochMsSchema.optional(), + text: z.array(contentPartSchema), + attachment_ids: z.array(z.string().min(1)).optional(), + skill_activations: z.array(skillActivationSchema).optional(), + origin: userMessageOriginSchema.optional(), +}); + +export type UserMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/workspace.ts b/packages/kap-server/src/protocol/messages/workspace.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a23b568019e010df8e6e66ad85dc588e3c73d55 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/workspace.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { globalMessageBase, isoDateTimeSchema } from './base'; + +export const workspaceIdSchema = z + .string() + .regex(/^wd_[a-z0-9._-]+_[0-9a-f]{12}$/, { + message: 'workspace_id must be a wd__ string', + }); + +export type WorkspaceId = z.infer; + +export const workspaceInfoSchema = z.object({ + id: workspaceIdSchema, + root: z.string().min(1), + name: z.string().min(1).max(100), + created_at: isoDateTimeSchema, + last_opened_at: isoDateTimeSchema, + session_count: z.number().int().nonnegative(), +}); + +export type WorkspaceInfo = z.infer; + +export const workspaceMessageSchema = z.object({ + type: z.literal('workspace'), + ...globalMessageBase, + subtype: z.enum(['created', 'updated', 'deleted']), + workspace: workspaceInfoSchema, +}); + +export type WorkspaceMessage = z.infer; diff --git a/packages/kap-server/src/protocol/pagination.ts b/packages/kap-server/src/protocol/pagination.ts new file mode 100644 index 0000000000000000000000000000000000000000..ccad723e24a0183135637e830fda9afdc3ffecdc --- /dev/null +++ b/packages/kap-server/src/protocol/pagination.ts @@ -0,0 +1,33 @@ +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; + +export const pageResponseSchema = (item: T) => + z.object({ + items: z.array(item), + has_more: z.boolean(), + }); + +export interface PageResponse { + items: T[]; + has_more: boolean; +} diff --git a/packages/kap-server/src/protocol/question-wire.ts b/packages/kap-server/src/protocol/question-wire.ts new file mode 100644 index 0000000000000000000000000000000000000000..dce68021a720b56408bd3b4632fb2e0275dacf8b --- /dev/null +++ b/packages/kap-server/src/protocol/question-wire.ts @@ -0,0 +1,54 @@ +import type { + QuestionItem, + QuestionOption, + QuestionRequest, +} from '@moonshot-ai/agent-core-v2'; + +import type { + QuestionItem as ProtocolQuestionItem, + QuestionOption as ProtocolQuestionOption, + QuestionRequest as ProtocolQuestionRequest, +} from './question'; + +export interface WireQuestionSource { + readonly id: string; + readonly createdAt: number; + readonly payload: unknown; +} + +function buildOption(opt: QuestionOption, itemIdx: number, optIdx: number): ProtocolQuestionOption { + const base: ProtocolQuestionOption = { id: `opt_${itemIdx}_${optIdx}`, label: opt.label }; + return opt.description === undefined ? base : { ...base, description: opt.description }; +} + +function buildItem(item: QuestionItem, itemIdx: number): ProtocolQuestionItem { + const out: ProtocolQuestionItem = { + id: `q_${itemIdx}`, + question: item.question, + options: item.options.map((o, oi) => buildOption(o, itemIdx, oi)), + }; + if (item.header !== undefined) out.header = item.header; + if (item.body !== undefined) out.body = item.body; + if (item.multiSelect !== undefined) out.multi_select = item.multiSelect; + out.allow_other = true; + if (item.otherLabel !== undefined) out.other_label = item.otherLabel; + if (item.otherDescription !== undefined) out.other_description = item.otherDescription; + return out; +} + +export function toWireQuestion( + interaction: WireQuestionSource, + sessionId: string, +): ProtocolQuestionRequest { + const req = interaction.payload as QuestionRequest; + const createdAt = new Date(interaction.createdAt).toISOString(); + const out: ProtocolQuestionRequest = { + question_id: interaction.id, + session_id: sessionId, + questions: req.questions.map((q, i) => buildItem(q, i)), + created_at: createdAt, + }; + if (req.turnId !== undefined) out.turn_id = req.turnId; + if (req.toolCallId !== undefined) out.tool_call_id = req.toolCallId; + return out; +} diff --git a/packages/kap-server/src/protocol/question.ts b/packages/kap-server/src/protocol/question.ts new file mode 100644 index 0000000000000000000000000000000000000000..d00baed04c8dc915745429ade3d257332825d361 --- /dev/null +++ b/packages/kap-server/src/protocol/question.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; + +export const questionOptionSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + description: z.string().optional(), +}); +export type QuestionOption = z.infer; + +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; + +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, +}); +export type QuestionRequest = z.infer; + +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; + +export const questionAnswerMethodSchema = z.enum(['enter', 'space', 'number_key', 'click']); +export type QuestionAnswerMethod = z.infer; + +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; diff --git a/packages/kap-server/src/protocol/request-id.ts b/packages/kap-server/src/protocol/request-id.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ed5c9df20c122f7145964fa56a9eacc3cf2a76c --- /dev/null +++ b/packages/kap-server/src/protocol/request-id.ts @@ -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); +} diff --git a/packages/kap-server/src/protocol/rest-approval.ts b/packages/kap-server/src/protocol/rest-approval.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb7e354bce8eb15bf4634f0785abf4c2a63d7d01 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-approval.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; + +import { approvalRequestSchema, approvalResponseSchema } from './approval'; + +export const listPendingApprovalsQuerySchema = z.object({ + status: z.literal('pending'), +}); +export type ListPendingApprovalsQuery = z.infer; + +export const listPendingApprovalsResponseSchema = z.object({ + items: z.array(approvalRequestSchema), +}); +export type ListPendingApprovalsResponse = z.infer; + +export const approvalResolveRequestSchema = approvalResponseSchema; +export type ApprovalResolveRequest = z.infer; + +export const approvalResolveResultSchema = z.object({ + resolved: z.literal(true), + resolved_at: isoDateTimeSchema, +}); +export type ApprovalResolveResult = z.infer; + +export const approvalAlreadyResolvedDataSchema = z.object({ + resolved: z.literal(false), +}); +export type ApprovalAlreadyResolvedData = z.infer; diff --git a/packages/kap-server/src/protocol/rest-capability.ts b/packages/kap-server/src/protocol/rest-capability.ts new file mode 100644 index 0000000000000000000000000000000000000000..68e8fb1497808c402940717774ffb8e165f9eec1 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-capability.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +export const capabilityStepSchema = z.object({ + id: z.string(), + state: z.enum(['ok', 'missing', 'failed']), + detail: z.string().optional(), + optional: z.boolean().optional(), +}); +export type CapabilityStepWire = z.infer; + +export const capabilityInstallProgressSchema = z.object({ + running: z.boolean(), + step: z.string().optional(), + percent: z.number().min(0).max(100).optional(), + error: z.string().optional(), + note: z.string().optional(), +}); +export type CapabilityInstallProgressWire = z.infer; + +export const capabilityStatusSchema = z.object({ + id: z.string(), + pluginId: z.string().optional(), + displayName: z.string(), + description: z.string(), + supported: z.boolean(), + state: z.enum(['not_installed', 'partial', 'ready', 'unsupported']), + version: z.string().optional(), + steps: z.array(capabilityStepSchema), + install: capabilityInstallProgressSchema, +}); +export type CapabilityStatusWire = z.infer; + +export const listCapabilitiesResponseSchema = z.object({ + capabilities: z.array(capabilityStatusSchema), +}); +export type ListCapabilitiesResponse = z.infer; + +export const capabilityIdParamSchema = z.object({ + capability_id: z.string().min(1), +}); +export type CapabilityIdParam = z.infer; diff --git a/packages/kap-server/src/protocol/rest-config.ts b/packages/kap-server/src/protocol/rest-config.ts new file mode 100644 index 0000000000000000000000000000000000000000..aca15275a6fee71b0dfc274a444c7110e268a814 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-config.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; + +export const providerConfigResponseSchema = z.object({ + type: z.string(), + base_url: z.string().optional(), + default_model: z.string().optional(), + has_api_key: z.boolean(), +}); +export type ProviderConfigResponse = z.infer; + +export const configResponseSchema = z + .object({ + providers: z.record(z.string(), providerConfigResponseSchema).default({}), + default_provider: z.string().optional(), + default_model: z.string().optional(), + models: z.record(z.string(), z.unknown()).optional(), + thinking: z.unknown().optional(), + plan_mode: z.boolean().optional(), + yolo: z.boolean().optional(), + default_permission_mode: z.string().optional(), + default_plan_mode: z.boolean().optional(), + permission: z.unknown().optional(), + hooks: z.array(z.unknown()).optional(), + services: z.unknown().optional(), + merge_all_available_skills: z.boolean().optional(), + extra_skill_dirs: z.array(z.string()).optional(), + loop_control: z.unknown().optional(), + background: z.unknown().optional(), + subagent: z.unknown().optional(), + secondary_model: z.unknown().optional(), + experimental: z.record(z.string(), z.boolean()).optional(), + telemetry: z.boolean().optional(), + raw: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(); +export type ConfigResponse = z.infer; + +export const patchConfigRequestSchema = z.object({ + providers: z.record(z.string(), z.unknown()).optional(), + default_provider: z.string().optional(), + default_model: z.string().optional(), + models: z.record(z.string(), z.unknown()).optional(), + thinking: z.unknown().optional(), + plan_mode: z.boolean().optional(), + yolo: z.boolean().optional(), + default_permission_mode: z.string().optional(), + default_plan_mode: z.boolean().optional(), + permission: z.unknown().optional(), + hooks: z.array(z.unknown()).optional(), + services: z.unknown().optional(), + merge_all_available_skills: z.boolean().optional(), + extra_skill_dirs: z.array(z.string()).optional(), + loop_control: z.unknown().optional(), + background: z.unknown().optional(), + subagent: z.unknown().optional(), + secondary_model: z.unknown().optional(), + experimental: z.record(z.string(), z.boolean()).optional(), + telemetry: z.boolean().optional(), +}); +export type PatchConfigRequest = z.infer; diff --git a/packages/kap-server/src/protocol/rest-connection.ts b/packages/kap-server/src/protocol/rest-connection.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cdd64416ce139946349be25ef70013560efb2e8 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-connection.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; + +export const connectionSchema = z.object({ + id: z.string().min(1), + connected_at: isoDateTimeSchema, + remote_address: z.string().nullable(), + user_agent: z.string().nullable(), + has_client_hello: z.boolean(), + subscriptions: z.array(z.string()), +}); + +export type Connection = z.infer; + +export const connectionsListResponseSchema = z.object({ + connections: z.array(connectionSchema), +}); + +export type ConnectionsListResponse = z.infer; diff --git a/packages/kap-server/src/protocol/rest-file-history.ts b/packages/kap-server/src/protocol/rest-file-history.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9bd91509e34eb9aeecae09bb477dfe16635dc75 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-file-history.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +export const fileHistoryChangesQuerySchema = z.object({ + turn_id: z.coerce.number().int().nonnegative(), +}); +export type FileHistoryChangesQuery = z.infer; + +export const fileHistoryContentQuerySchema = z.object({ + turn_id: z.coerce.number().int().nonnegative(), + path: z.string().min(1), + phase: z.enum(['start', 'end']).optional(), +}); +export type FileHistoryContentQuery = z.infer; + +export const fileHistoryChangeSchema = z.object({ + path: z.string(), + status: z.enum(['added', 'modified', 'deleted']), + additions: z.number(), + deletions: z.number(), + binary: z.boolean().optional(), + oversize: z.boolean().optional(), +}); +export type WireFileHistoryChange = z.infer; + +export const fileHistoryChangesResponseSchema = z.object({ + changes: z.array(fileHistoryChangeSchema), + recorded: z.boolean(), +}); +export type FileHistoryChangesResponse = z.infer; + +export const fileHistoryContentEntrySchema = z.object({ + version: z.number(), + content: z.string().optional(), + binary: z.boolean().optional(), +}); + +export const fileHistoryContentResponseSchema = z.object({ + content: fileHistoryContentEntrySchema.nullable(), +}); +export type FileHistoryContentResponse = z.infer; diff --git a/packages/kap-server/src/protocol/rest-file.ts b/packages/kap-server/src/protocol/rest-file.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecd1fe9e9df5a4039b3a3336e6e1e8bd6a8e521b --- /dev/null +++ b/packages/kap-server/src/protocol/rest-file.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { fileMetaSchema } from '@moonshot-ai/agent-core-v2/app/file/fileService'; + +export const uploadFileResponseSchema = fileMetaSchema; +export type UploadFileResponse = z.infer; + +export const getFileParamSchema = z.object({ + file_id: z.string().min(1), +}); +export type GetFileParam = z.infer; + +export const deleteFileParamSchema = z.object({ + file_id: z.string().min(1), +}); +export type DeleteFileParam = z.infer; + +export const deleteFileResponseSchema = z.object({ + deleted: z.literal(true), +}); +export type DeleteFileResponse = z.infer; diff --git a/packages/kap-server/src/protocol/rest-fs.ts b/packages/kap-server/src/protocol/rest-fs.ts new file mode 100644 index 0000000000000000000000000000000000000000..73716b6735a5febf59ce7601e5ec928f09f00ee5 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-fs.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +export const fsOpenRequestSchema = z.object({ + path: z.string().min(1), + line: z.number().int().positive().optional(), +}); +export type FsOpenRequest = z.infer; + +export const fsOpenResponseSchema = z.object({ + opened: z.literal(true), +}); +export type FsOpenResponse = z.infer; + +export const fsRevealRequestSchema = z.object({ + path: z.string().min(1), +}); +export type FsRevealRequest = z.infer; + +export const fsRevealResponseSchema = z.object({ + revealed: z.literal(true), +}); +export type FsRevealResponse = z.infer; + +export const fsOpenInAppIdSchema = z.enum([ + 'finder', + 'cursor', + 'vscode', + 'iterm', + 'terminal', +]); +export type FsOpenInAppId = z.infer; + +export const fsOpenInRequestSchema = z.object({ + app_id: fsOpenInAppIdSchema, + path: z.string().min(1), + line: z.number().int().positive().optional(), +}); +export type FsOpenInRequest = z.infer; + +export const fsOpenInResponseSchema = z.object({ + opened: z.literal(true), +}); +export type FsOpenInResponse = z.infer; + +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; diff --git a/packages/kap-server/src/protocol/rest-guiStore.ts b/packages/kap-server/src/protocol/rest-guiStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7daf191007bb357f4d96ae266b3b7fe07b4590b --- /dev/null +++ b/packages/kap-server/src/protocol/rest-guiStore.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +const keySchema = z.string().min(1).max(256); + +export const guiStoreGetItemQuerySchema = z.object({ key: keySchema }); + +export const guiStoreSetItemBodySchema = z.object({ + key: keySchema, + value: z.string(), +}); + +export const guiStoreRemoveItemBodySchema = z.object({ key: keySchema }); + +export const guiStoreGetItemResponseSchema = z.object({ + value: z.string().nullable(), +}); +export type GuiStoreGetItemResponse = z.infer; + +export const guiStoreLengthResponseSchema = z.object({ + length: z.number(), +}); +export type GuiStoreLengthResponse = z.infer; diff --git a/packages/kap-server/src/protocol/rest-message.ts b/packages/kap-server/src/protocol/rest-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..362a456adcaacb9bce472fcf2a0194d6f59b610f --- /dev/null +++ b/packages/kap-server/src/protocol/rest-message.ts @@ -0,0 +1,21 @@ +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; + +export const listMessagesResponseSchema = z.object({ + items: z.array(messageSchema), + has_more: z.boolean(), +}); +export type ListMessagesResponse = z.infer; + +export const getMessageResponseSchema = messageSchema; +export type GetMessageResponse = z.infer; diff --git a/packages/kap-server/src/protocol/rest-meta.ts b/packages/kap-server/src/protocol/rest-meta.ts new file mode 100644 index 0000000000000000000000000000000000000000..5da00b1549c7c8bfcdcc1dc333a37b2971a42161 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-meta.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; + +import { fsOpenInAppIdSchema } from './rest-fs'; + +export const metaCapabilitiesSchema = z.object({ + websocket: z.literal(true), + file_upload: z.literal(true), + fs_query: z.literal(true), + mcp: z.literal(true), + tasks: z.literal(true), + terminal: z.literal(true), +}); + +export type MetaCapabilities = z.infer; + +export const metaFeatureStateSchema = z.enum([ + 'Pending', + 'Activating', + 'Active', + 'Unloading', + 'Failed', +]); + +export const metaFeatureSchema = z.object({ + name: z.string().min(1), + state: metaFeatureStateSchema, + meta: z.record(z.string(), z.unknown()), +}); + +export type MetaFeature = z.infer; + +export const metaResponseSchema = z.object({ + server_version: z.string().min(1), + capabilities: metaCapabilitiesSchema, + server_id: z.string().min(1), + started_at: isoDateTimeSchema, + open_in_apps: z.array(fsOpenInAppIdSchema), + dangerous_bypass_auth: z.boolean(), + experimental_flags: z.record(z.string(), z.boolean()).optional(), + backend: z.enum(['v1', 'v2']).optional(), + web_title: z.string().optional(), + features: z.array(metaFeatureSchema).optional(), +}); + +export type MetaResponse = z.infer; diff --git a/packages/kap-server/src/protocol/rest-modelCatalog.ts b/packages/kap-server/src/protocol/rest-modelCatalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..1931153d18587ffda30fa6799e21e2ece56f8ccc --- /dev/null +++ b/packages/kap-server/src/protocol/rest-modelCatalog.ts @@ -0,0 +1,182 @@ +import { z } from 'zod'; + +import { PROVIDER_ID_PATTERN } from '@moonshot-ai/agent-core-v2'; +import { + modelCatalogItemSchema, + providerCatalogItemSchema, +} from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; + +export const listModelsResponseSchema = z.object({ + items: z.array(modelCatalogItemSchema), +}); +export type ListModelsResponse = z.infer; + +export const listProvidersResponseSchema = z.object({ + items: z.array(providerCatalogItemSchema), +}); +export type ListProvidersResponse = z.infer; + +export const getProviderResponseSchema = providerCatalogItemSchema.extend({ + api_key: z.string().optional(), +}); +export type GetProviderResponse = z.infer; + +export const providerWireTypeSchema = z.enum([ + 'kimi', + 'openai', + 'openai_responses', + 'anthropic', + 'google-genai', + 'vertexai', +]); +export type ProviderWireType = z.infer; + +export const createProviderModelSchema = z.object({ + model: z.string().min(1), + max_context_size: z.number().int().min(1), + display_name: z.string().min(1).optional(), + capabilities: z.array(z.string()).optional(), + max_output_size: z.number().int().min(1).optional(), + support_efforts: z.array(z.string().min(1)).optional(), + adaptive_thinking: z.boolean().optional(), +}); +export type CreateProviderModel = z.infer; + +function refineProviderForm( + value: { base_url?: string | undefined; models: Array<{ model: string }> }, + ctx: z.RefinementCtx, +): void { + if (value.base_url !== undefined && value.base_url.includes('${')) { + ctx.addIssue({ + code: 'custom', + message: 'base_url must not contain an environment variable placeholder', + path: ['base_url'], + }); + } + const seen = new Set(); + for (const entry of value.models) { + if (seen.has(entry.model)) { + ctx.addIssue({ + code: 'custom', + message: `duplicate model: ${entry.model}`, + path: ['models'], + }); + return; + } + seen.add(entry.model); + } +} + +export const providerIdSchema = z + .string() + .regex( + PROVIDER_ID_PATTERN, + 'id must start with a letter or digit and may only contain letters, digits, "-", "_" and spaces', + ); + +export const createProviderRequestSchema = z + .object({ + id: providerIdSchema, + type: providerWireTypeSchema, + api_key: z.string().optional(), + base_url: z.string().trim().optional(), + default_model: z.string().min(1).optional(), + models: z.array(createProviderModelSchema).min(1), + }) + .superRefine((value, ctx) => { + refineProviderForm(value, ctx); + if ( + value.default_model !== undefined && + !value.models.some((entry) => entry.model === value.default_model) + ) { + ctx.addIssue({ + code: 'custom', + message: 'default_model must be one of models[].model', + path: ['default_model'], + }); + } + }); +export type CreateProviderRequest = z.infer; + +export const createProviderResponseSchema = providerCatalogItemSchema; +export type CreateProviderResponse = z.infer; + +export const replaceProviderRequestSchema = z + .object({ + new_id: providerIdSchema.optional(), + type: providerWireTypeSchema, + api_key: z.string().optional(), + base_url: z.string().trim().optional(), + default_model: z.string().min(1).optional(), + models: z.array(createProviderModelSchema).min(1), + }) + .superRefine((value, ctx) => { + refineProviderForm(value, ctx); + if ( + value.default_model !== undefined && + !value.models.some((entry) => entry.model === value.default_model) + ) { + ctx.addIssue({ + code: 'custom', + message: 'default_model must be one of models[].model', + path: ['default_model'], + }); + } + }); +export type ReplaceProviderRequest = z.infer; + +export const replaceProviderResponseSchema = z.object({ + provider: providerCatalogItemSchema, +}); +export type ReplaceProviderResponse = z.infer; + +export const catalogModelItemSchema = z.object({ + id: z.string().min(1), + name: z.string().optional(), + max_context_size: z.number().int().min(1), + capabilities: z.array(z.string()).optional(), + reasoning: z.boolean(), +}); +export type CatalogModelItem = z.infer; + +export const catalogProviderItemSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + wire_type: providerWireTypeSchema.nullable(), + guessed: z.boolean(), + needs_base_url: z.boolean(), + rejected: z.boolean(), + reject_reason: z.string().nullable(), + env_key: z.string().nullable(), + models: z.array(catalogModelItemSchema), +}); +export type CatalogProviderItem = z.infer; + +export const listCatalogProvidersResponseSchema = z.object({ + items: z.array(catalogProviderItemSchema), +}); +export type ListCatalogProvidersResponse = z.infer; + +export const getCatalogProviderResponseSchema = catalogProviderItemSchema; +export type GetCatalogProviderResponse = z.infer; + +export const providerCollectionActionBodySchema = z.object({ + catalog_id: z.string().min(1).optional(), + api_key: z.string().optional(), + base_url: z.string().optional(), + id: providerIdSchema.optional(), + url: z.string().min(1).optional(), +}); +export type ProviderCollectionActionBody = z.infer; + +export const importCatalogProviderResponseSchema = z.object({ + provider: providerCatalogItemSchema, + models_imported: z.number().int().min(0), +}); +export type ImportCatalogProviderResponse = z.infer; + +export const importCustomRegistryResponseSchema = z.object({ + providers: z.array(providerCatalogItemSchema), + models_imported: z.number().int().min(0), +}); +export type ImportCustomRegistryResponse = z.infer; diff --git a/packages/kap-server/src/protocol/rest-oauth.ts b/packages/kap-server/src/protocol/rest-oauth.ts new file mode 100644 index 0000000000000000000000000000000000000000..70620ff84cf9fc1aed7a121568a0eda0fff262b5 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-oauth.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const oauthLoginStartRequestSchema = z.object({ + provider: z.string().min(1).optional(), + region: z.enum(['mainland-cn', 'global']).optional(), +}); +export type OAuthLoginStartRequest = z.infer; + +export const oauthLoginQuerySchema = z.object({ + provider: z.string().min(1).optional(), +}); +export type OAuthLoginQuery = z.infer; + +export const oauthLogoutRequestSchema = z.object({ + provider: z.string().min(1).optional(), +}); +export type OAuthLogoutRequest = z.infer; diff --git a/packages/kap-server/src/protocol/rest-plugin.ts b/packages/kap-server/src/protocol/rest-plugin.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee00138f4a92a6bfffbd88b925ae8be1b96aa0ef --- /dev/null +++ b/packages/kap-server/src/protocol/rest-plugin.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +export const pluginGithubMetadataSchema = z.object({ + owner: z.string(), + repo: z.string(), + ref: z.object({ + kind: z.enum(['branch', 'tag', 'sha']), + value: z.string(), + }), + installedSha: z.string().optional(), +}); + +export const pluginSummarySchema = z.object({ + id: z.string(), + displayName: z.string(), + version: z.string().optional(), + enabled: z.boolean(), + state: z.enum(['ok', 'error']), + skillCount: z.number(), + mcpServerCount: z.number(), + enabledMcpServerCount: z.number(), + hookCount: z.number(), + commandCount: z.number(), + hasErrors: z.boolean(), + source: z.enum(['local-path', 'zip-url', 'github']), + originalSource: z.string().optional(), + github: pluginGithubMetadataSchema.optional(), +}); +export type PluginSummaryWire = z.infer; + +export const listPluginsResponseSchema = z.object({ + plugins: z.array(pluginSummarySchema), +}); +export type ListPluginsResponse = z.infer; + +export const installPluginRequestSchema = z.object({ + source: z.string().min(1), +}); +export type InstallPluginRequest = z.infer; + +export const pluginMarketplaceEntrySchema = z.object({ + id: z.string(), + tier: z.enum(['official', 'curated', 'third-party']), + displayName: z.string(), + description: z.string().optional(), + homepage: z.string().optional(), + keywords: z.array(z.string()).optional(), + version: z.string().optional(), + source: z.string(), + installed: z + .object({ + version: z.string().optional(), + enabled: z.boolean(), + }) + .optional(), + updateAvailable: z.boolean().optional(), + capabilityId: z.string().optional(), +}); +export type PluginMarketplaceEntryWire = z.infer; + +export const pluginMarketplaceResponseSchema = z.object({ + entries: z.array(pluginMarketplaceEntrySchema), +}); +export type PluginMarketplaceResponse = z.infer; + +export const pluginIdParamSchema = z.object({ + tail: z.string().min(1), +}); +export type PluginIdParam = z.infer; diff --git a/packages/kap-server/src/protocol/rest-prompt.ts b/packages/kap-server/src/protocol/rest-prompt.ts new file mode 100644 index 0000000000000000000000000000000000000000..20cc84b5da623069a3934cc26601cadf2e1b6f5f --- /dev/null +++ b/packages/kap-server/src/protocol/rest-prompt.ts @@ -0,0 +1,74 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; +import { messageContentSchema } from './message'; +import { + promptPermissionModeSchema, + promptThinkingSchema, +} from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; + +export { promptPermissionModeSchema, promptThinkingSchema }; +export type { PromptPermissionMode, PromptThinking } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; + +export const promptSkillActivationSchema = z.object({ + name: z.string().min(1), + args: z.string().optional(), +}); +export type PromptSkillActivation = z.infer; + +export const promptSubmissionSchema = z.object({ + content: z.array(messageContentSchema).min(1), + metadata: z.record(z.string(), z.unknown()).optional(), + agent_id: z.string().min(1).optional(), + profile: z.string().min(1).optional(), + model: z.string().min(1).optional(), + thinking: promptThinkingSchema.optional(), + permission_mode: promptPermissionModeSchema.optional(), + plan_mode: z.boolean().optional(), + swarm_mode: z.boolean().optional(), + goal_objective: z.string().optional(), + goal_control: z.enum(['pause', 'resume', 'cancel']).optional(), + disabled_tools: z.array(z.string()).optional(), + prompt_id: z.string().min(1).optional(), + skills: z.array(promptSkillActivationSchema).min(1).optional(), +}); +export type PromptSubmission = z.infer; + +export const promptStatusSchema = z.enum(['running', 'queued', 'blocked']); +export type PromptStatus = z.infer; + +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, + metadata: z.record(z.string(), z.unknown()).optional(), +}); +export type PromptItem = z.infer; + +export const promptListResponseSchema = z.object({ + active: promptItemSchema.nullable(), + queued: z.array(promptItemSchema), +}); +export type PromptListResponse = z.infer; + +export const promptSubmitResultSchema = promptItemSchema; +export type PromptSubmitResult = z.infer; + +export const promptSteerRequestSchema = z.object({ + prompt_ids: z.array(z.string().min(1)).min(1), +}); +export type PromptSteerRequest = z.infer; + +export const promptSteerResultSchema = z.object({ + steered: z.literal(true), + prompt_ids: z.array(z.string().min(1)).min(1), +}); +export type PromptSteerResult = z.infer; + +export const promptAbortResponseSchema = z.object({ + aborted: z.boolean(), + at_seq: z.number().int().nonnegative().optional(), +}); +export type PromptAbortResponse = z.infer; diff --git a/packages/kap-server/src/protocol/rest-question.ts b/packages/kap-server/src/protocol/rest-question.ts new file mode 100644 index 0000000000000000000000000000000000000000..3584025ac68ccf3478e0fa216fee62a626368330 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-question.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; + +import { questionRequestSchema, questionResponseSchema } from './question'; + +export const listPendingQuestionsQuerySchema = z.object({ + status: z.literal('pending'), +}); +export type ListPendingQuestionsQuery = z.infer; + +export const listPendingQuestionsResponseSchema = z.object({ + items: z.array(questionRequestSchema), +}); +export type ListPendingQuestionsResponse = z.infer; + +export const questionResolveRequestSchema = questionResponseSchema; +export type QuestionResolveRequest = z.infer; + +export const questionResolveResultSchema = z.object({ + resolved: z.literal(true), + resolved_at: isoDateTimeSchema, +}); +export type QuestionResolveResult = z.infer; + +export const questionAlreadyResolvedDataSchema = z.object({ + resolved: z.literal(false), +}); +export type QuestionAlreadyResolvedData = z.infer; + +export const questionDismissResultSchema = z.object({ + dismissed: z.literal(true), + dismissed_at: isoDateTimeSchema, +}); +export type QuestionDismissResult = z.infer; diff --git a/packages/kap-server/src/protocol/rest-remote-control.ts b/packages/kap-server/src/protocol/rest-remote-control.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5d483e5abd6c703dc54349e50555bfc285d1f44 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-remote-control.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const remoteControlStatusSchema = z.object({ + enabled: z.boolean(), + state: z.enum(['off', 'starting', 'on', 'stopping']), + url: z.string().optional(), + device_id: z.string().optional(), + device_name: z.string().optional(), + error: z.string().optional(), +}); +export type RemoteControlStatusResponse = z.infer; + +export const setRemoteControlRequestSchema = z.object({ + enabled: z.boolean(), +}); +export type SetRemoteControlRequest = z.infer; diff --git a/packages/kap-server/src/protocol/rest-runtime.ts b/packages/kap-server/src/protocol/rest-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..97c414ff415455034dbfd4b4570982dcc12dd52e --- /dev/null +++ b/packages/kap-server/src/protocol/rest-runtime.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const runtimeBindingResponseSchema = z.object({ + workspace_id: z.string(), + runtime_id: z.string(), +}); + +export const switchRuntimeRequestSchema = z.object({ + runtime_id: z.string().min(1), +}); + +export const sessionRuntimeParamsSchema = z.object({ + session_id: z.string().min(1), +}); + +export type RuntimeBindingResponse = z.infer; diff --git a/packages/kap-server/src/protocol/rest-search.ts b/packages/kap-server/src/protocol/rest-search.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ffdaba23fb60832b06018e27aad7cb80dd20738 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-search.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +export const searchMessagesBodySchema = z.object({ + query: z.string().min(1), + mode: z.enum(['terms', 'literal']).optional(), + op: z.enum(['AND', 'OR']).optional(), + container: z + .object({ + session_id: z.string().min(1).optional(), + agent_id: z.string().min(1).optional(), + }) + .optional(), + role: z.enum(['user', 'assistant', 'title']).optional(), + start_time: z.number().int().nonnegative().optional(), + end_time: z.number().int().nonnegative().optional(), + sort: z.enum(['score', 'time_desc', 'time_asc']).optional(), + page_size: z.number().int().min(1).max(50).optional(), + page_token: z.string().min(1).optional(), +}); +export type SearchMessagesBody = z.infer; + +export const searchMessageHitSchema = z.object({ + session_id: z.string(), + workspace_id: z.string(), + session_title: z.string(), + agent_id: z.string(), + role: z.enum(['user', 'assistant', 'title']), + snippet: z.string(), + time: z.number(), + turn: z.number().int().nonnegative().optional(), + step_id: z.string().optional(), + score: z.number(), +}); + +export const searchMessagesResponseSchema = z.object({ + items: z.array(searchMessageHitSchema), + has_more: z.boolean(), + page_token: z.string().optional(), + incomplete: z.enum(['candidate_cap', 'postings_budget', 'deadline']).optional(), + index_state: z.object({ + state: z.enum(['building', 'ready', 'readonly']), + indexed_sessions: z.number(), + total_sessions: z.number(), + documents: z.number(), + stale: z.boolean().optional(), + degraded: z.string().optional(), + }), + source: z.enum(['live', 'index']), +}); +export type SearchMessagesResponse = z.infer; diff --git a/packages/kap-server/src/protocol/rest-session.ts b/packages/kap-server/src/protocol/rest-session.ts new file mode 100644 index 0000000000000000000000000000000000000000..41d037b82aa949eed0ca97e35c3a29c8281b9531 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-session.ts @@ -0,0 +1,192 @@ +import { z } from 'zod'; + +import { messageSchema } from './message'; +import { + sessionStatusResponseSchema, + sessionWarningSchema, + sessionWarningsResponseSchema, + updateSessionProfileRequestSchema, + type UpdateSessionProfileRequest, +} from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; + +import { goalSnapshotSchema } from './goal'; +import { cursorQuerySchema, pageResponseSchema } from './pagination'; +import { + sessionChildCreateSchema, + sessionCreateSchema, + sessionForkSchema, + sessionSchema, +} from './session'; + +export { + sessionStatusResponseSchema, + sessionWarningSchema, + sessionWarningsResponseSchema, + updateSessionProfileRequestSchema, +}; +export type { + SessionStatusResponse, + SessionWarning, + SessionWarningsResponse, + UpdateSessionProfileRequest, +} from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; + +export const createSessionRequestSchema = sessionCreateSchema; +export type CreateSessionRequest = z.infer; + +export const createSessionResponseSchema = sessionSchema; +export type CreateSessionResponse = z.infer; + +const booleanQueryParam = z.preprocess( + (value) => { + if (value === 'true' || value === '1' || value === 1 || value === true) return true; + if (value === 'false' || value === '0' || value === 0 || value === false) return false; + return value; + }, + z.boolean().optional(), +); + +export const listSessionsQuerySchema = cursorQuerySchema.and( + z.object({ + busy: booleanQueryParam, + include_archive: booleanQueryParam, + archived_only: booleanQueryParam, + exclude_empty: booleanQueryParam, + }), +); +export type ListSessionsQuery = z.infer; + +export const getSessionResponseSchema = sessionSchema; +export type GetSessionResponse = z.infer; + +export const getSessionProfileResponseSchema = sessionSchema; +export type GetSessionProfileResponse = z.infer; + +export const MAX_SESSION_EXPORT_WEB_LOG_BYTES = 256 * 1024; + +export const exportSessionParamsSchema = z.object({ + session_id: z.string().min(1), +}); +export type ExportSessionParams = z.infer; + +export const exportSessionRequestSchema = z + .object({ + web_log: z + .string() + .refine((value) => fitsUtf8ByteLimit(value, MAX_SESSION_EXPORT_WEB_LOG_BYTES), { + message: `web_log must not exceed ${MAX_SESSION_EXPORT_WEB_LOG_BYTES} UTF-8 bytes`, + }) + .optional(), + desktop: z.boolean().optional(), + }) + .strict(); +export type ExportSessionRequest = z.infer; + +export const updateSessionProfileResponseSchema = sessionSchema; +export type UpdateSessionProfileResponse = z.infer; + +export const updateSessionMetaRequestSchema = updateSessionProfileRequestSchema; +export type UpdateSessionMetaRequest = UpdateSessionProfileRequest; + +export const updateSessionMetaResponseSchema = updateSessionProfileResponseSchema; +export type UpdateSessionMetaResponse = UpdateSessionProfileResponse; + +export const updateSessionRequestSchema = updateSessionProfileRequestSchema; +export type UpdateSessionRequest = z.infer; + +export const updateSessionResponseSchema = sessionSchema; +export type UpdateSessionResponse = z.infer; + +export const forkSessionRequestSchema = sessionForkSchema; +export type ForkSessionRequest = z.infer; + +export const forkSessionResponseSchema = sessionSchema; +export type ForkSessionResponse = z.infer; + +export const startBtwSessionResponseSchema = z.object({ + agent_id: z.string().min(1), +}); +export type StartBtwSessionResponse = z.infer; + +export const listSessionChildrenQuerySchema = cursorQuerySchema.and( + z.object({ + busy: booleanQueryParam, + include_archive: booleanQueryParam, + }), +); +export type ListSessionChildrenQuery = z.infer; + +export const listSessionChildrenResponseSchema = pageResponseSchema(sessionSchema); +export type ListSessionChildrenResponse = z.infer; + +export const createSessionChildRequestSchema = sessionChildCreateSchema; +export type CreateSessionChildRequest = z.infer; + +export const createSessionChildResponseSchema = sessionSchema; +export type CreateSessionChildResponse = z.infer; + +export const getSessionGoalResponseSchema = goalSnapshotSchema.nullable(); +export type GetSessionGoalResponse = z.infer; + +export const compactSessionRequestSchema = z.preprocess( + (value) => value === undefined ? {} : value, + z.object({ + instruction: z.string().optional(), + }), +); +export type CompactSessionRequest = z.infer; + +export const compactSessionResponseSchema = z.object({}); +export type CompactSessionResponse = z.infer; + +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; + +export const undoSessionResponseSchema = z.object({ + messages: pageResponseSchema(messageSchema), + status: sessionStatusResponseSchema, +}); +export type UndoSessionResponse = z.infer; + +export const archiveSessionResponseSchema = z.object({ + archived: z.literal(true), +}); +export type ArchiveSessionResponse = z.infer; + +export const restoreSessionResponseSchema = sessionSchema; +export type RestoreSessionResponse = z.infer; + +export const deleteSessionResponseSchema = z.object({ + deleted: z.literal(true), +}); +export type DeleteSessionResponse = z.infer; + +export const sessionAbortResponseSchema = z.object({ + aborted: z.boolean(), +}); +export type SessionAbortResponse = z.infer; + +function fitsUtf8ByteLimit(value: string, limit: number): boolean { + let bytes = 0; + for (let index = 0; index < value.length; index += 1) { + const codePoint = value.codePointAt(index)!; + if (codePoint < 0x80) { + bytes += 1; + } else if (codePoint < 0x800) { + bytes += 2; + } else if (codePoint > 0xffff) { + bytes += 4; + index += 1; + } else { + bytes += 3; + } + if (bytes > limit) return false; + } + return true; +} diff --git a/packages/kap-server/src/protocol/rest-skill.ts b/packages/kap-server/src/protocol/rest-skill.ts new file mode 100644 index 0000000000000000000000000000000000000000..7021c949b337cf3161bbc87ea998d4bfd44bf00f --- /dev/null +++ b/packages/kap-server/src/protocol/rest-skill.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import { fileContentSchema, imageContentSchema, textContentSchema, videoContentSchema } from './message'; +import { skillDescriptorSchema } from './skill'; + +export const listSkillsResponseSchema = z.object({ + skills: z.array(skillDescriptorSchema), +}); +export type ListSkillsResponse = z.infer; + +export const activateSkillAttachmentSchema = z.discriminatedUnion('type', [ + textContentSchema, + imageContentSchema, + videoContentSchema, + fileContentSchema, +]); +export type ActivateSkillAttachment = z.infer; + +export const activateSkillRequestSchema = z.object({ + args: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + attachments: z.array(activateSkillAttachmentSchema).optional(), +}); +export type ActivateSkillRequest = z.infer; + +export const activateSkillResultSchema = z.object({ + activated: z.literal(true), + skill_name: z.string().min(1), +}); +export type ActivateSkillResult = z.infer; diff --git a/packages/kap-server/src/protocol/rest-snapshot.ts b/packages/kap-server/src/protocol/rest-snapshot.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8d5df7561f0462aabef890f0dd17b3be6042134 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-snapshot.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; + +import { messageSchema } from './message'; + +import { approvalRequestSchema } from './approval'; +import { questionRequestSchema } from './question'; +import { sessionSchema } from './session'; +import { taskSchema } from './task'; + +export const inFlightToolCallSchema = z.object({ + tool_call_id: z.string().min(1), + name: z.string().min(1), + args: z.unknown().optional(), + description: z.string().optional(), + display: z.unknown().optional(), + last_progress: z + .object({ + kind: z.enum(['stdout', 'stderr', 'progress', 'status', 'custom']), + text: z.string().optional(), + percent: z.number().optional(), + }) + .optional(), +}); +export type InFlightToolCall = z.infer; + +export const inFlightTurnSchema = z.object({ + turn_id: z.number().int().nonnegative(), + assistant_text: z.string(), + thinking_text: z.string(), + running_tools: z.array(inFlightToolCallSchema), + current_prompt_id: z.string().optional(), +}); +export type InFlightTurn = z.infer; + +export const snapshotSubagentSchema = taskSchema.extend({ + subagent_phase: z.enum(['queued', 'working', 'suspended', 'completed', 'failed', 'cancelled']).optional(), + subagent_type: z.string().optional(), + parent_tool_call_id: z.string().optional(), + suspended_reason: z.string().optional(), + swarm_index: z.number().int().nonnegative().optional(), + run_in_background: z.boolean().optional(), +}); +export type SnapshotSubagent = z.infer; + +export const sessionSnapshotResponseSchema = z.object({ + as_of_seq: z.number().int().nonnegative(), + epoch: z.string().min(1), + session: sessionSchema, + messages: z.object({ + items: z.array(messageSchema), + has_more: z.boolean(), + }), + in_flight_turn: inFlightTurnSchema.nullable(), + subagents: z.array(snapshotSubagentSchema).optional(), + pending_approvals: z.array(approvalRequestSchema), + pending_questions: z.array(questionRequestSchema), +}); +export type SessionSnapshotResponse = z.infer; diff --git a/packages/kap-server/src/protocol/rest-task.ts b/packages/kap-server/src/protocol/rest-task.ts new file mode 100644 index 0000000000000000000000000000000000000000..459475383023e557e65321f9950413dd0fb2a8e5 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-task.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +import { taskSchema, taskStatusSchema } from './task'; + +export const listTasksQuerySchema = z.object({ + status: taskStatusSchema.optional(), +}); +export type ListTasksQuery = z.infer; + +export const listTasksResponseSchema = z.object({ + items: z.array(taskSchema), +}); +export type ListTasksResponse = z.infer; + +export const getTaskQuerySchema = z.object({ + with_output: z.coerce.boolean().optional(), + output_bytes: z.coerce.number().int().nonnegative().optional(), +}); +export type GetTaskQuery = z.infer; + +export const getTaskResponseSchema = taskSchema; +export type GetTaskResponse = z.infer; + +export const cancelTaskResultSchema = z.object({ + cancelled: z.literal(true), +}); +export type CancelTaskResult = z.infer; + +export const detachTaskResultSchema = z.object({ + detached: z.boolean(), + status: taskStatusSchema, +}); +export type DetachTaskResult = z.infer; + +export const taskAlreadyFinishedDataSchema = z.object({ + cancelled: z.literal(false), +}); +export type TaskAlreadyFinishedData = z.infer; diff --git a/packages/kap-server/src/protocol/rest-terminal.ts b/packages/kap-server/src/protocol/rest-terminal.ts new file mode 100644 index 0000000000000000000000000000000000000000..27917aa12a4e82450c7add36f7d5cf3428eed4df --- /dev/null +++ b/packages/kap-server/src/protocol/rest-terminal.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +import { + createTerminalRequestSchema as engineCreateTerminalRequestSchema, + terminalSchema, +} from '@moonshot-ai/agent-core-v2/os/interface/terminal'; + +export const createTerminalRequestSchema = engineCreateTerminalRequestSchema.extend({ + runtime_id: z.string().min(1).optional(), +}); +export type CreateTerminalRequest = z.infer; + +export const getTerminalResponseSchema = terminalSchema; +export type GetTerminalResponse = z.infer; + +export const listTerminalsResponseSchema = z.object({ + items: z.array(terminalSchema), +}); +export type ListTerminalsResponse = z.infer; + +export const closeTerminalResponseSchema = z.object({ + closed: z.literal(true), +}); +export type CloseTerminalResponse = z.infer; diff --git a/packages/kap-server/src/protocol/rest-tool.ts b/packages/kap-server/src/protocol/rest-tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..8aed2c78657959a867a2feed959b4ac8b7ad5fdd --- /dev/null +++ b/packages/kap-server/src/protocol/rest-tool.ts @@ -0,0 +1,23 @@ +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; + +export const listToolsResponseSchema = z.object({ + tools: z.array(toolDescriptorSchema), +}); +export type ListToolsResponse = z.infer; + +export const listMcpServersResponseSchema = z.object({ + servers: z.array(mcpServerSchema), +}); +export type ListMcpServersResponse = z.infer; + +export const restartMcpServerResultSchema = z.object({ + restarting: z.literal(true), +}); +export type RestartMcpServerResult = z.infer; diff --git a/packages/kap-server/src/protocol/rest-workspace.ts b/packages/kap-server/src/protocol/rest-workspace.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f88ff71c8521906518b1e866995ed16627aca8b --- /dev/null +++ b/packages/kap-server/src/protocol/rest-workspace.ts @@ -0,0 +1,54 @@ +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; + +export const createWorkspaceRequestSchema = workspaceCreateSchema; +export type CreateWorkspaceRequest = z.infer; + +export const createWorkspaceResponseSchema = workspaceSchema; +export type CreateWorkspaceResponse = z.infer; + +export const workspaceIdParamSchema = z.object({ + workspace_id: workspaceIdSchema, +}); +export type WorkspaceIdParam = z.infer; + +export const updateWorkspaceRequestSchema = workspaceUpdateSchema; +export type UpdateWorkspaceRequest = z.infer; + +export const updateWorkspaceResponseSchema = workspaceSchema; +export type UpdateWorkspaceResponse = z.infer; + +export const deleteWorkspaceResponseSchema = z.object({ + deleted: z.literal(true), +}); +export type DeleteWorkspaceResponse = z.infer; + +export const workspaceTrustResponseSchema = z.object({ + trusted: z.boolean(), +}); +export type WorkspaceTrustResponse = z.infer; + +export const addDirRequestSchema = z.object({ + path: z.string().min(1), + persist: z.boolean().optional(), +}); +export type AddDirRequest = z.infer; + +export const addDirResponseSchema = z.object({ + project_root: z.string(), + config_path: z.string(), + additional_dirs: z.array(z.string()), + persisted: z.boolean(), +}); +export type AddDirResponse = z.infer; diff --git a/packages/kap-server/src/protocol/session.ts b/packages/kap-server/src/protocol/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..f408383451b5a5e4c3df563853ff89e4352cb278 --- /dev/null +++ b/packages/kap-server/src/protocol/session.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; +import { + permissionRuleSchema, + sessionAgentConfigPartialSchema, + sessionAgentConfigSchema, + sessionMetadataSchema, +} from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; + +import { workspaceIdSchema } from './workspace'; + +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().optional(), + context_tokens: z.number().int().nonnegative(), + context_limit: z.number().int().nonnegative().optional(), + turn_count: z.number().int().nonnegative().optional(), +}); + +export type SessionUsage = z.infer; + +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 sessionPendingInteractionSchema = z.enum(['none', 'approval', 'question']); +export type SessionPendingInteraction = z.infer; + +export const sessionSchema = z.object({ + id: z.string().min(1), + workspace_id: workspaceIdSchema, + title: z.string(), + created_at: isoDateTimeSchema, + updated_at: isoDateTimeSchema, + busy: z.boolean(), + main_turn_active: z.boolean().optional(), + pending_interaction: sessionPendingInteractionSchema.optional(), + last_turn_reason: z.enum(['completed', 'cancelled', 'failed']).optional(), + archived: z.boolean().optional(), + archived_at: isoDateTimeSchema.optional(), + current_prompt_id: z.string().min(1).optional(), + last_prompt: z.string().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; + +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; + +export const sessionForkSchema = z.object({ + title: z.string().min(1).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +export type SessionFork = z.infer; + +export const sessionChildCreateSchema = z.object({ + title: z.string().min(1).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +export type SessionChildCreate = z.infer; diff --git a/packages/kap-server/src/protocol/skill.ts b/packages/kap-server/src/protocol/skill.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcdd4c761a1c7ef94162013d2fe2751e6fb5daca --- /dev/null +++ b/packages/kap-server/src/protocol/skill.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const skillSourceSchema = z.enum(['project', 'user', 'extra', 'builtin']); +export type SkillSource = z.infer; + +export const skillDescriptorSchema = z.object({ + name: z.string().min(1), + description: z.string(), + path: z.string(), + source: skillSourceSchema, + type: z.string().optional(), + disable_model_invocation: z.boolean().optional(), +}); +export type SkillDescriptor = z.infer; diff --git a/packages/kap-server/src/protocol/task.ts b/packages/kap-server/src/protocol/task.ts new file mode 100644 index 0000000000000000000000000000000000000000..0290439094095ebeecd5b1db8dd46eff350b701c --- /dev/null +++ b/packages/kap-server/src/protocol/task.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; + +export const taskKindSchema = z.enum(['subagent', 'bash', 'tool']); +export type TaskKind = z.infer; + +export const taskStatusSchema = z.enum([ + 'running', + 'completed', + 'failed', + 'cancelled', +]); +export type TaskStatus = z.infer; + +export const taskSchema = z.object({ + id: z.string().min(1), + session_id: z.string().min(1), + kind: taskKindSchema, + description: z.string(), + status: taskStatusSchema, + command: z.string().optional(), + created_at: isoDateTimeSchema, + started_at: isoDateTimeSchema.optional(), + completed_at: isoDateTimeSchema.optional(), + output_preview: z.string().optional(), + output_bytes: z.number().int().nonnegative().optional(), + model: z.string().optional(), + thinking_effort: z.string().optional(), + agent_id: z.string().optional(), + subagent_type: z.string().optional(), + parent_tool_call_id: z.string().optional(), + run_in_background: z.boolean(), +}); +export type Task = z.infer; diff --git a/packages/kap-server/src/protocol/tool.ts b/packages/kap-server/src/protocol/tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..eab776faceac0509001c6a9445648255bc53bc5d --- /dev/null +++ b/packages/kap-server/src/protocol/tool.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +export const toolSourceSchema = z.enum(['builtin', 'skill', 'mcp']); +export type ToolSource = z.infer; + +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(), + active: z.boolean().optional(), +}); +export type ToolDescriptor = z.infer; + +export const mcpServerStatusSchema = z.enum([ + 'connected', + 'connecting', + 'disconnected', + 'error', +]); +export type McpServerStatus = z.infer; + +export const mcpServerTransportSchema = z.enum(['stdio', 'http', 'sse']); +export type McpServerTransport = z.infer; + +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; diff --git a/packages/kap-server/src/protocol/workspace.ts b/packages/kap-server/src/protocol/workspace.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3c48c76e10864ed86862a71f48eac50cfa0f2d0 --- /dev/null +++ b/packages/kap-server/src/protocol/workspace.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; + +export const workspaceIdSchema = z + .string() + .regex(/^wd_[a-z0-9._-]+_[0-9a-f]{12}$/, { + message: 'workspace_id must be a wd__ string', + }); + +export type WorkspaceId = z.infer; + +export const workspaceSchema = z.object({ + id: workspaceIdSchema, + root: z.string().min(1), + name: z.string().min(1).max(100), + created_at: isoDateTimeSchema, + last_opened_at: isoDateTimeSchema, + session_count: z.number().int().nonnegative(), +}); + +export type Workspace = z.infer; + +export const workspaceCreateSchema = z.object({ + root: z.string().min(1), + name: z.string().min(1).max(100).optional(), +}); + +export type WorkspaceCreate = z.infer; + +export const workspaceUpdateSchema = z.object({ + name: z.string().min(1).max(100), +}); + +export type WorkspaceUpdate = z.infer; diff --git a/packages/kap-server/src/protocol/ws-control.ts b/packages/kap-server/src/protocol/ws-control.ts new file mode 100644 index 0000000000000000000000000000000000000000..f71b3700496a2377951c6025d339406c494c06e6 --- /dev/null +++ b/packages/kap-server/src/protocol/ws-control.ts @@ -0,0 +1,570 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; +import { transcriptGradeSpecSchema, transcriptSeqSchema } from '@moonshot-ai/transcript'; + +import { eventSchema } from './events-zod'; + +export const WS_PROTOCOL_VERSION = 2; + +export const sessionCursorSchema = z.object({ + seq: z.number().int().nonnegative(), + epoch: z.string().min(1).optional(), +}); + +export type SessionCursor = z.infer; + +export const cursorsBySessionSchema = z.record(z.string(), sessionCursorSchema); + +export type CursorsBySession = z.infer; + +export const wsEventEnvelopeSchema = (payload: T) => + z.object({ + type: z.string(), + seq: z.number().int().nonnegative(), + epoch: z.string().optional(), + volatile: z.boolean().optional(), + offset: z.number().int().nonnegative().optional(), + session_id: z.string().optional(), + timestamp: isoDateTimeSchema, + payload, + }); + +export const wsControlEnvelopeSchema = (payload: T) => + z.object({ + type: z.string(), + id: z.string().optional(), + payload, + }); + +export const wsAckEnvelopeSchema = (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(), + protocol_version: z.number().int().positive(), + heartbeat_ms: z.number().int().positive().optional(), + 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; + +export const agentFilterSchema = z.record(z.string(), z.array(z.string()).min(1)); + +export type AgentFilter = z.infer; + +export const clientHelloPayloadSchema = z.object({ + client_id: z.string(), + subscriptions: z.array(z.string()).optional(), + cursors: cursorsBySessionSchema.optional(), + agent_filter: agentFilterSchema.optional(), +}); + +export const clientHelloMessageSchema = z.object({ + type: z.literal('client_hello'), + id: z.string(), + payload: clientHelloPayloadSchema, +}); + +export type ClientHelloMessage = z.infer; + +export const clientHelloAckPayloadSchema = z.object({ + accepted_subscriptions: z.array(z.string()), + resync_required: z.array(z.string()), + cursors: cursorsBySessionSchema.optional(), +}); + +export const helloAckPayloadSchema = clientHelloAckPayloadSchema; + +export const clientHelloAckMessageSchema = wsAckEnvelopeSchema(clientHelloAckPayloadSchema); + +export const subscribePayloadSchema = z.object({ + session_ids: z.array(z.string()), + cursors: cursorsBySessionSchema.optional(), + agent_filter: agentFilterSchema.optional(), +}); + +export const subscribeMessageSchema = z.object({ + type: z.literal('subscribe'), + id: z.string(), + payload: subscribePayloadSchema, +}); + +export type SubscribeMessage = z.infer; + +export const subscribeV2PayloadSchema = z.object({ + session_id: z.string().min(1), + transcript: transcriptGradeSpecSchema, + transcript_since: z.record(z.string(), transcriptSeqSchema).optional(), +}); + +export const subscribeV2MessageSchema = z.object({ + type: z.literal('subscribe_v2'), + id: z.string(), + payload: subscribeV2PayloadSchema, +}); + +export type SubscribeV2Message = z.infer; + +export const unsubscribeV2PayloadSchema = z.object({ + session_id: z.string().min(1), + agent_ids: z.array(z.string().min(1)).min(1).optional(), +}); + +export const unsubscribeV2MessageSchema = z.object({ + type: z.literal('unsubscribe_v2'), + id: z.string(), + payload: unsubscribeV2PayloadSchema, +}); + +export type UnsubscribeV2Message = z.infer; + +export const subscribeAckPayloadSchema = z.object({ + accepted: z.array(z.string()), + not_found: z.array(z.string()), + resync_required: z.array(z.string()), + cursors: cursorsBySessionSchema.optional(), +}); + +export const subscribeAckMessageSchema = wsAckEnvelopeSchema(subscribeAckPayloadSchema); + +export const subscribeV2AckMessageSchema = wsAckEnvelopeSchema(subscribeAckPayloadSchema); + +export const unsubscribeV2AckMessageSchema = wsAckEnvelopeSchema(subscribeAckPayloadSchema); + +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; + +export const unsubscribeAckPayloadSchema = subscribeAckPayloadSchema; + +export const unsubscribeAckMessageSchema = wsAckEnvelopeSchema(unsubscribeAckPayloadSchema); + +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; + +export const abortAckPayloadSchema = z.object({ + aborted: z.boolean().optional(), + at_seq: z.number().int().nonnegative().optional(), +}); + +export const abortAckMessageSchema = wsAckEnvelopeSchema(abortAckPayloadSchema); + +export const terminalAttachPayloadSchema = z.object({ + session_id: z.string().min(1), + terminal_id: z.string().min(1), + since_seq: z.number().int().nonnegative().optional(), +}); + +export const terminalAttachMessageSchema = z.object({ + type: z.literal('terminal_attach'), + id: z.string(), + payload: terminalAttachPayloadSchema, +}); + +export type TerminalAttachMessage = z.infer; + +export const terminalAttachAckPayloadSchema = z.object({ + attached: z.literal(true), + replayed: z.number().int().nonnegative(), +}); + +export const terminalAttachAckMessageSchema = wsAckEnvelopeSchema( + terminalAttachAckPayloadSchema, +); + +export const terminalDetachPayloadSchema = z.object({ + session_id: z.string().min(1), + terminal_id: z.string().min(1), +}); + +export const terminalDetachMessageSchema = z.object({ + type: z.literal('terminal_detach'), + id: z.string(), + payload: terminalDetachPayloadSchema, +}); + +export type TerminalDetachMessage = z.infer; + +export const terminalDetachAckPayloadSchema = z.object({ + detached: z.literal(true), +}); + +export const terminalDetachAckMessageSchema = wsAckEnvelopeSchema( + terminalDetachAckPayloadSchema, +); + +export const terminalInputPayloadSchema = z.object({ + session_id: z.string().min(1), + terminal_id: z.string().min(1), + data: z.string(), +}); + +export const terminalInputMessageSchema = z.object({ + type: z.literal('terminal_input'), + id: z.string(), + payload: terminalInputPayloadSchema, +}); + +export type TerminalInputMessage = z.infer; + +export const terminalInputAckPayloadSchema = z.object({ + accepted: z.literal(true), +}); + +export const terminalInputAckMessageSchema = wsAckEnvelopeSchema( + terminalInputAckPayloadSchema, +); + +export const terminalResizePayloadSchema = z.object({ + session_id: z.string().min(1), + terminal_id: z.string().min(1), + cols: z.number().int().positive(), + rows: z.number().int().positive(), +}); + +export const terminalResizeMessageSchema = z.object({ + type: z.literal('terminal_resize'), + id: z.string(), + payload: terminalResizePayloadSchema, +}); + +export type TerminalResizeMessage = z.infer; + +export const terminalResizeAckPayloadSchema = z.object({ + resized: z.literal(true), +}); + +export const terminalResizeAckMessageSchema = wsAckEnvelopeSchema( + terminalResizeAckPayloadSchema, +); + +export const terminalClosePayloadSchema = z.object({ + session_id: z.string().min(1), + terminal_id: z.string().min(1), +}); + +export const terminalCloseMessageSchema = z.object({ + type: z.literal('terminal_close'), + id: z.string(), + payload: terminalClosePayloadSchema, +}); + +export type TerminalCloseMessage = z.infer; + +export const terminalCloseAckPayloadSchema = z.object({ + closed: z.literal(true), +}); + +export const terminalCloseAckMessageSchema = wsAckEnvelopeSchema( + terminalCloseAckPayloadSchema, +); + +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; + +export const pongPayloadSchema = z.object({ + nonce: z.string(), +}); + +export const pongMessageSchema = z.object({ + type: z.literal('pong'), + payload: pongPayloadSchema, +}); + +export type PongMessage = z.infer; + +export const resyncRequiredPayloadSchema = z.object({ + session_id: z.string(), + reason: z.enum(['buffer_overflow', 'session_recreated', 'epoch_changed']), + current_seq: z.number().int().nonnegative(), + epoch: z.string().min(1).optional(), +}); + +export const resyncRequiredMessageSchema = z.object({ + type: z.literal('resync_required'), + timestamp: isoDateTimeSchema, + payload: resyncRequiredPayloadSchema, +}); + +export type ResyncRequiredMessage = z.infer; + +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; + +export const sessionEventMessageSchema = wsEventEnvelopeSchema(eventSchema); + +export const terminalOutputPayloadSchema = z.object({ + data: z.string(), +}); + +export const terminalOutputMessageSchema = z.object({ + type: z.literal('terminal_output'), + seq: z.number().int().positive(), + session_id: z.string().min(1), + terminal_id: z.string().min(1), + timestamp: isoDateTimeSchema, + payload: terminalOutputPayloadSchema, +}); + +export type TerminalOutputMessage = z.infer; + +export const terminalExitPayloadSchema = z.object({ + exit_code: z.number().int().nullable().optional(), +}); + +export const terminalExitMessageSchema = z.object({ + type: z.literal('terminal_exit'), + session_id: z.string().min(1), + terminal_id: z.string().min(1), + timestamp: isoDateTimeSchema, + payload: terminalExitPayloadSchema, +}); + +export type TerminalExitMessage = z.infer; + +export const clientControlMessageSchema = z.discriminatedUnion('type', [ + clientHelloMessageSchema, + subscribeMessageSchema, + subscribeV2MessageSchema, + unsubscribeMessageSchema, + unsubscribeV2MessageSchema, + abortMessageSchema, + terminalAttachMessageSchema, + terminalDetachMessageSchema, + terminalInputMessageSchema, + terminalResizeMessageSchema, + terminalCloseMessageSchema, + pongMessageSchema, +]); + +export type ClientControlMessage = z.infer; + +export const serverSystemMessageSchema = z.discriminatedUnion('type', [ + serverHelloMessageSchema, + pingMessageSchema, + resyncRequiredMessageSchema, + wsErrorMessageSchema, +]); + +export type ServerSystemMessage = z.infer; + +export type WsOperationDirection = 'client_to_server' | 'server_to_client'; + +export type WsOperationKind = 'control' | 'system' | 'event'; + +export interface WsOperationDefinition { + readonly type: string; + readonly direction: WsOperationDirection; + readonly kind: WsOperationKind; + readonly messageSchema: z.ZodTypeAny; + readonly ackSchema?: z.ZodTypeAny; + readonly description: string; +} + +export const clientControlOperations = [ + { + type: 'client_hello', + direction: 'client_to_server', + kind: 'control', + messageSchema: clientHelloMessageSchema, + ackSchema: clientHelloAckMessageSchema, + description: 'Start a client session and optionally subscribe to existing daemon sessions.', + }, + { + type: 'subscribe', + direction: 'client_to_server', + kind: 'control', + messageSchema: subscribeMessageSchema, + ackSchema: subscribeAckMessageSchema, + description: 'Subscribe the connection to one or more session event streams.', + }, + { + type: 'subscribe_v2', + direction: 'client_to_server', + kind: 'control', + messageSchema: subscribeV2MessageSchema, + ackSchema: subscribeV2AckMessageSchema, + description: + "Attach or update this connection's per-agent transcript grade stream for one session.", + }, + { + type: 'unsubscribe_v2', + direction: 'client_to_server', + kind: 'control', + messageSchema: unsubscribeV2MessageSchema, + ackSchema: unsubscribeV2AckMessageSchema, + description: + "Detach this connection's transcript grade stream for one session, optionally per agent.", + }, + { + type: 'unsubscribe', + direction: 'client_to_server', + kind: 'control', + messageSchema: unsubscribeMessageSchema, + ackSchema: unsubscribeAckMessageSchema, + description: 'Remove one or more session event stream subscriptions.', + }, + { + type: 'abort', + direction: 'client_to_server', + kind: 'control', + messageSchema: abortMessageSchema, + ackSchema: abortAckMessageSchema, + description: 'Abort a running prompt in a session.', + }, + { + type: 'terminal_attach', + direction: 'client_to_server', + kind: 'control', + messageSchema: terminalAttachMessageSchema, + ackSchema: terminalAttachAckMessageSchema, + description: 'Attach this connection to a terminal stream.', + }, + { + type: 'terminal_detach', + direction: 'client_to_server', + kind: 'control', + messageSchema: terminalDetachMessageSchema, + ackSchema: terminalDetachAckMessageSchema, + description: 'Detach this connection from a terminal stream.', + }, + { + type: 'terminal_input', + direction: 'client_to_server', + kind: 'control', + messageSchema: terminalInputMessageSchema, + ackSchema: terminalInputAckMessageSchema, + description: 'Write raw input bytes to a terminal.', + }, + { + type: 'terminal_resize', + direction: 'client_to_server', + kind: 'control', + messageSchema: terminalResizeMessageSchema, + ackSchema: terminalResizeAckMessageSchema, + description: 'Resize a terminal.', + }, + { + type: 'terminal_close', + direction: 'client_to_server', + kind: 'control', + messageSchema: terminalCloseMessageSchema, + ackSchema: terminalCloseAckMessageSchema, + description: 'Close a terminal.', + }, + { + type: 'pong', + direction: 'client_to_server', + kind: 'control', + messageSchema: pongMessageSchema, + description: 'Reply to a server ping with the same nonce.', + }, +] as const satisfies readonly WsOperationDefinition[]; + +export const serverSystemOperations = [ + { + type: 'server_hello', + direction: 'server_to_client', + kind: 'system', + messageSchema: serverHelloMessageSchema, + description: 'Initial server greeting sent immediately after the socket opens.', + }, + { + type: 'ping', + direction: 'server_to_client', + kind: 'system', + messageSchema: pingMessageSchema, + description: 'Heartbeat ping sent by the server; clients must answer with pong.', + }, + { + type: 'resync_required', + direction: 'server_to_client', + kind: 'system', + messageSchema: resyncRequiredMessageSchema, + description: 'Signals that a client must rebuild local session state from REST history.', + }, + { + type: 'error', + direction: 'server_to_client', + kind: 'system', + messageSchema: wsErrorMessageSchema, + description: 'Server-side WebSocket protocol or runtime error.', + }, +] as const satisfies readonly WsOperationDefinition[]; + +export const sessionEventOperation = { + type: 'session_event', + direction: 'server_to_client', + kind: 'event', + messageSchema: sessionEventMessageSchema, + description: 'Session-scoped agent event envelope; frame type is the payload event type.', +} as const satisfies WsOperationDefinition; + +export const wsOperations = [ + ...clientControlOperations, + ...serverSystemOperations, + sessionEventOperation, +] as const satisfies readonly WsOperationDefinition[]; + +export function getClientControlOperation( + type: string, +): (typeof clientControlOperations)[number] | undefined { + return clientControlOperations.find((operation) => operation.type === type); +} diff --git a/packages/kap-server/src/request-id.ts b/packages/kap-server/src/request-id.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd122acedf2c34f4c30afe1d310c48ce493059ab --- /dev/null +++ b/packages/kap-server/src/request-id.ts @@ -0,0 +1,11 @@ +import { parseOrGenerateRequestId } from './protocol/request-id'; + +const REQUEST_ID_HEADER = 'x-request-id'; + +export function resolveRequestId( + headers: Record, +): string { + const raw = headers[REQUEST_ID_HEADER]; + const supplied = Array.isArray(raw) ? raw[0] : raw; + return parseOrGenerateRequestId(supplied); +} diff --git a/packages/kap-server/src/requestLogging.ts b/packages/kap-server/src/requestLogging.ts new file mode 100644 index 0000000000000000000000000000000000000000..00aa856179a4aab9cecff30cb1b3dced08a27184 --- /dev/null +++ b/packages/kap-server/src/requestLogging.ts @@ -0,0 +1,44 @@ +import type { FastifyInstance, FastifyReply } from 'fastify'; + +export function extractEnvelopeCode(payload: unknown): number | undefined { + if (typeof payload !== 'string') { + return undefined; + } + const match = /^\s*\{\s*"code"\s*:\s*(-?\d+)/.exec(payload); + if (match === null) { + return undefined; + } + const code = Number(match[1]); + return Number.isSafeInteger(code) ? code : undefined; +} + +export function registerRequestLogging(app: FastifyInstance): void { + const codes = new WeakMap(); + + app.addHook('onSend', (req, reply, payload, done) => { + const code = extractEnvelopeCode(payload); + if (code !== undefined) { + codes.set(reply, code); + } + done(null, payload); + }); + + app.addHook('onResponse', (req, reply, done) => { + req.log.info( + { + req: { + method: req.method, + url: req.url, + version: req.headers['accept-version'], + host: req.host, + remoteAddress: req.ip, + remotePort: req.socket === undefined ? undefined : req.socket.remotePort, + }, + code: codes.get(reply), + responseTime: reply.elapsedTime, + }, + 'request completed', + ); + done(); + }); +} diff --git a/packages/kap-server/src/routes/action-dispatch.ts b/packages/kap-server/src/routes/action-dispatch.ts new file mode 100644 index 0000000000000000000000000000000000000000..f267340cf234bdbd07e790c34209340c325ef146 --- /dev/null +++ b/packages/kap-server/src/routes/action-dispatch.ts @@ -0,0 +1,74 @@ +import { z } from 'zod'; + +import { parseActionSuffix } from './action-suffix'; + +export interface ActionHandler { + readonly body?: z.ZodTypeAny; + handle(ctx: TExtra & { readonly id: string; readonly body: unknown }): Promise | void; +} + +export type ActionTable = Readonly< + Record> +>; + +export function actionNames( + actions: ActionTable, +): readonly TAction[] { + return Object.keys(actions) as unknown as readonly TAction[]; +} + +export function resolveActionTarget(opts: { + readonly tail: string; + readonly actions: ActionTable; + readonly resourceLabel: string; +}): { readonly id: string; readonly action: TAction } | { readonly message: string } { + const parsed = parseActionSuffix({ + tail: opts.tail, + allowedActions: actionNames(opts.actions), + resourceLabel: opts.resourceLabel, + }); + if (parsed.kind !== 'action') { + return { + message: parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${opts.tail}`, + }; + } + return { id: parsed.id, action: parsed.action }; +} + +export async function runAction(opts: { + readonly action: string; + readonly id: string; + readonly actions: ActionTable; + readonly extra: TExtra; + readonly body?: unknown; +}): Promise { + const entry = opts.actions[opts.action as TAction]; + if (entry === undefined) { + return false; + } + const body = entry.body === undefined ? opts.body : entry.body.parse(opts.body); + await entry.handle({ ...opts.extra, id: opts.id, body }); + return true; +} + +export async function dispatchAction(opts: { + readonly tail: string; + readonly actions: ActionTable; + readonly resourceLabel: string; + readonly extra: TExtra; + readonly body?: unknown; + readonly onUnsupported: (message: string) => void; +}): Promise { + const target = resolveActionTarget(opts); + if ('message' in target) { + opts.onUnsupported(target.message); + return false; + } + return runAction({ + action: target.action, + id: target.id, + actions: opts.actions, + extra: opts.extra, + body: opts.body, + }); +} diff --git a/packages/kap-server/src/routes/action-suffix.ts b/packages/kap-server/src/routes/action-suffix.ts new file mode 100644 index 0000000000000000000000000000000000000000..19558644ae139e40dcb7d94ea87a6dbcf59ae83c --- /dev/null +++ b/packages/kap-server/src/routes/action-suffix.ts @@ -0,0 +1,43 @@ +export type ActionSuffixParse = + | { readonly kind: 'bare'; readonly id: string } + | { readonly kind: 'action'; readonly id: string; readonly action: TAction } + | { readonly kind: 'invalid'; readonly reason: string }; + +export interface ParseActionSuffixOptions { + readonly tail: string; + readonly allowedActions: readonly TAction[]; + readonly defaultAction?: TAction; + readonly resourceLabel?: string; +} + +export function parseActionSuffix( + opts: ParseActionSuffixOptions, +): ActionSuffixParse { + const { tail, allowedActions, defaultAction, resourceLabel = 'resource' } = opts; + const idx = tail.lastIndexOf(':'); + if (idx <= 0) { + if (tail.length === 0) { + return { kind: 'invalid', reason: `invalid ${resourceLabel}_id in path` }; + } + if (defaultAction !== undefined) { + return { kind: 'bare', id: tail }; + } + return { kind: 'invalid', reason: `unsupported action: ${tail}` }; + } + const id = tail.slice(0, idx); + const suffix = tail.slice(idx + 1); + if (suffix === '') { + if (defaultAction !== undefined) { + return { kind: 'bare', id: tail }; + } + return { kind: 'invalid', reason: `unsupported action: ${tail}` }; + } + if (id.length === 0) { + return { kind: 'invalid', reason: `invalid ${resourceLabel}_id in path` }; + } + const matched = (allowedActions as readonly string[]).find((a) => a === suffix); + if (matched === undefined) { + return { kind: 'invalid', reason: `unsupported action: ${tail}` }; + } + return { kind: 'action', id, action: matched as TAction }; +} diff --git a/packages/kap-server/src/routes/approvals.ts b/packages/kap-server/src/routes/approvals.ts new file mode 100644 index 0000000000000000000000000000000000000000..748fdf1b2a3db6c491fc4d85aebcb846c26b2b44 --- /dev/null +++ b/packages/kap-server/src/routes/approvals.ts @@ -0,0 +1,198 @@ +import { + INTERACTION_TAG_SESSION_ID, + INTERACTION_TAG_TURN_ID, + interactions, + resumeSessionById, + type ApprovalRequest, + type ApprovalResponse, + type Interaction, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { ErrorCode } from '../protocol/error-codes'; +import { + approvalAlreadyResolvedDataSchema, + approvalResolveRequestSchema, + approvalResolveResultSchema, + listPendingApprovalsQuerySchema, + listPendingApprovalsResponseSchema, +} from '../protocol/rest-approval'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; + +interface ApprovalRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const approvalParamsSchema = z.object({ + session_id: z.string().min(1), + approval_id: z.string().min(1), +}); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +const APPROVAL_EXPIRY_MS = 24 * 60 * 60 * 1000; + +export function registerApprovalsRoutes(app: ApprovalRouteHost, core: Scope): void { + const listRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/approvals', + params: sessionIdParamSchema, + querystring: listPendingApprovalsQuerySchema, + success: { data: listPendingApprovalsResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'List pending approval requests for a session', + tags: ['approvals'], + }, + async (req, reply) => { + const { session_id } = req.params; + const handle = await resumeSessionById(core.accessor, session_id); + if (handle === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + const pending = interactions.findAll({ + kind: 'approval', + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: session_id }, + }); + const items = pending.map((i) => toWireApproval(i, session_id)); + reply.send(okEnvelope({ items }, req.id)); + }, + ); + app.get(listRoute.path, listRoute.options, listRoute.handler as Parameters[2]); + + const resolveRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/approvals/{approval_id}', + params: approvalParamsSchema, + body: approvalResolveRequestSchema, + success: { data: approvalResolveResultSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.APPROVAL_NOT_FOUND]: {}, + [ErrorCode.APPROVAL_ALREADY_RESOLVED]: { + dataSchema: approvalAlreadyResolvedDataSchema, + }, + }, + description: 'Resolve an approval request', + tags: ['approvals'], + }, + async (req, reply) => { + const { session_id, approval_id } = req.params; + const handle = await resumeSessionById(core.accessor, session_id); + if (handle === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + const isPending = + interactions.findOne({ + id: approval_id, + kind: 'approval', + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: session_id }, + }) !== undefined; + + if (!isPending) { + if ( + interactions.findOne({ + id: approval_id, + kind: 'approval', + resolved: true, + tags: { [INTERACTION_TAG_SESSION_ID]: session_id }, + }) !== undefined + ) { + reply.send({ + code: ErrorCode.APPROVAL_ALREADY_RESOLVED, + msg: `approval ${approval_id} already resolved`, + data: { resolved: false as const }, + request_id: req.id, + }); + return; + } + reply.send( + errEnvelope(ErrorCode.APPROVAL_NOT_FOUND, `approval ${approval_id} not found`, req.id), + ); + return; + } + + const body = req.body; + const response: ApprovalResponse = { + decision: body.decision, + scope: body.scope, + feedback: body.feedback, + selectedLabel: body.selected_label, + }; + interactions.respond(approval_id, response); + requestLog(req)?.info( + { session_id, approval_id, decision: response.decision, scope: response.scope }, + 'approval decided', + ); + reply.send( + okEnvelope({ resolved: true as const, resolved_at: new Date().toISOString() }, req.id), + ); + }, + ); + app.post( + resolveRoute.path, + resolveRoute.options, + resolveRoute.handler as Parameters[2], + ); +} + +export function toWireApproval(interaction: Interaction, sessionId: string): { + approval_id: string; + session_id: string; + turn_id?: number; + tool_call_id: string; + tool_name: string; + action: string; + tool_input_display: unknown; + created_at: string; + expires_at: string; +} { + const p = interaction.payload as ApprovalRequest; + const turnId = interaction.tags[INTERACTION_TAG_TURN_ID]; + return { + approval_id: interaction.id, + session_id: sessionId, + turn_id: typeof turnId === 'number' ? turnId : undefined, + tool_call_id: p.toolCallId ?? interaction.id, + tool_name: p.toolName, + action: p.action, + tool_input_display: p.display, + created_at: new Date(interaction.createdAt).toISOString(), + expires_at: new Date(interaction.createdAt + APPROVAL_EXPIRY_MS).toISOString(), + }; +} diff --git a/packages/kap-server/src/routes/auth.ts b/packages/kap-server/src/routes/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0d58611aabc17f83b3ee6a046e510a142794584 --- /dev/null +++ b/packages/kap-server/src/routes/auth.ts @@ -0,0 +1,33 @@ +import { IAuthLegacyService, type Scope } from '@moonshot-ai/agent-core-v2'; +import { authSummarySchema } from '@moonshot-ai/agent-core-v2/app/authLegacy/authLegacy'; + +import { okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; + +interface RouteHost { + get( + path: string, + options: { schema?: Record }, + handler: ( + req: { id: string }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; +} + +export function registerAuthRoute(app: RouteHost, core: Scope): void { + const route = defineRoute( + { + method: 'GET', + path: '/auth', + success: { data: authSummarySchema }, + description: 'Get server auth readiness snapshot', + tags: ['auth'], + }, + async (req, reply) => { + const summary = await core.accessor.get(IAuthLegacyService).get(); + reply.send(okEnvelope(summary, req.id)); + }, + ); + app.get(route.path, route.options, route.handler as Parameters[2]); +} diff --git a/packages/kap-server/src/routes/capabilities.ts b/packages/kap-server/src/routes/capabilities.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb50485c6d23f02bdd154e722cc21e6f73b66db4 --- /dev/null +++ b/packages/kap-server/src/routes/capabilities.ts @@ -0,0 +1,150 @@ +import { CapabilityErrors, ICapabilityService, isError2, type Scope } from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + capabilityIdParamSchema, + capabilityStatusSchema, + listCapabilitiesResponseSchema, +} from '../protocol/rest-capability'; +import { parseActionSuffix } from './action-suffix'; + +interface CapabilitiesRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const capabilityTailParamsSchema = z.object({ + tail: z.string().min(1), +}); + +export function registerCapabilitiesRoutes(app: CapabilitiesRouteHost, core: Scope): void { + const listRoute = defineRoute( + { + method: 'GET', + path: '/capabilities', + success: { data: listCapabilitiesResponseSchema }, + errors: {}, + description: 'List built-in capabilities with layered readiness status', + tags: ['capabilities'], + operationId: 'listCapabilities', + }, + async (req, reply) => { + const capabilities = await core.accessor.get(ICapabilityService).listCapabilities(); + reply.send(okEnvelope({ capabilities }, req.id)); + }, + ); + app.get( + listRoute.path, + listRoute.options, + listRoute.handler as Parameters[2], + ); + + const getRoute = defineRoute( + { + method: 'GET', + path: '/capabilities/{capability_id}', + params: capabilityIdParamSchema, + success: { data: capabilityStatusSchema }, + errors: { + [ErrorCode.CAPABILITY_NOT_FOUND]: {}, + }, + description: 'Get one capability readiness status', + tags: ['capabilities'], + operationId: 'getCapability', + }, + async (req, reply) => { + try { + const capability = await core.accessor + .get(ICapabilityService) + .getCapability(req.params.capability_id); + reply.send(okEnvelope(capability, req.id)); + } catch (error) { + reply.send(mapCapabilityError(error, req.id)); + } + }, + ); + app.get( + getRoute.path, + getRoute.options, + getRoute.handler as Parameters[2], + ); + + const installRoute = defineRoute( + { + method: 'POST', + path: '/capabilities/{tail}', + params: capabilityTailParamsSchema, + success: { data: capabilityStatusSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.CAPABILITY_NOT_FOUND]: {}, + [ErrorCode.CAPABILITY_UNSUPPORTED]: {}, + [ErrorCode.CAPABILITY_INSTALL_IN_PROGRESS]: {}, + }, + description: 'Start an idempotent capability install (poll GET for progress)', + tags: ['capabilities'], + operationId: 'installCapability', + }, + async (req, reply) => { + const parsed = parseActionSuffix({ + tail: req.params.tail, + allowedActions: ['install'], + resourceLabel: 'capability', + }); + if (parsed.kind !== 'action') { + const message = parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${req.params.tail}`; + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); + return; + } + try { + const capability = await core.accessor + .get(ICapabilityService) + .installCapability(parsed.id); + reply.send(okEnvelope(capability, req.id)); + } catch (error) { + reply.send(mapCapabilityError(error, req.id)); + } + }, + ); + app.post( + installRoute.path, + installRoute.options, + installRoute.handler as Parameters[2], + ); +} + +const CAPABILITY_ERROR_MAP: Readonly> = { + [CapabilityErrors.codes.CAPABILITY_NOT_FOUND]: ErrorCode.CAPABILITY_NOT_FOUND, + [CapabilityErrors.codes.CAPABILITY_UNSUPPORTED]: ErrorCode.CAPABILITY_UNSUPPORTED, + [CapabilityErrors.codes.CAPABILITY_INSTALL_IN_PROGRESS]: ErrorCode.CAPABILITY_INSTALL_IN_PROGRESS, +}; + +function mapCapabilityError(error: unknown, requestId: string) { + const mapped = isError2(error) ? CAPABILITY_ERROR_MAP[error.code] : undefined; + if (mapped !== undefined && isError2(error)) { + return errEnvelope(mapped, error.message, requestId, error.stack); + } + return errEnvelope( + ErrorCode.INTERNAL_ERROR, + error instanceof Error ? error.message : String(error), + requestId, + error instanceof Error ? error.stack : undefined, + ); +} diff --git a/packages/kap-server/src/routes/config.ts b/packages/kap-server/src/routes/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ebc4e0b7e9f938b0c239a89b5c2cc821d241d6a --- /dev/null +++ b/packages/kap-server/src/routes/config.ts @@ -0,0 +1,217 @@ +import { IConfigService, type Scope } from '@moonshot-ai/agent-core-v2'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { configResponseSchema, patchConfigRequestSchema } from '../protocol/rest-config'; +import type { ConfigResponse } from '../protocol/rest-config'; + +type ProviderResponse = ConfigResponse['providers'][string]; + +interface ConfigRouteHost { + get( + path: string, + options: { schema?: Record }, + handler: ( + req: { id: string }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { schema?: Record }, + handler: ( + req: { id: string; body: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; +} + +export function registerConfigRoutes(app: ConfigRouteHost, core: Scope): void { + const getRoute = defineRoute( + { + method: 'GET', + path: '/config', + success: { data: configResponseSchema }, + description: 'Get the global Kimi configuration (secrets redacted)', + tags: ['config'], + }, + async (req, reply) => { + const config = core.accessor.get(IConfigService); + await config.ready; + reply.send(okEnvelope(toConfigResponse(config.getAll()), req.id)); + }, + ); + app.get(getRoute.path, getRoute.options, getRoute.handler as Parameters[2]); + + const setRoute = defineRoute( + { + method: 'POST', + path: '/config', + body: patchConfigRequestSchema, + success: { data: configResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + }, + description: 'Update the global Kimi configuration (merge semantics)', + tags: ['config'], + }, + async (req, reply) => { + try { + const config = core.accessor.get(IConfigService); + await config.ready; + const camelPatch = convertKeysSnakeToCamel(req.body) as Record; + if (camelPatch['yolo'] === true) { + camelPatch['defaultPermissionMode'] = 'yolo'; + } + delete camelPatch['yolo']; + for (const domain of Object.keys(camelPatch)) { + await config.set(domain, camelPatch[domain]); + } + const response = toConfigResponse(config.getAll()); + const changedFields = Object.keys(req.body as Record); + requestLog(req)?.info({ changedFields }, 'config updated'); + reply.send(okEnvelope(response, req.id)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + requestLog(req)?.error({ err: error }, 'config update failed'); + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); + } + }, + ); + app.post(setRoute.path, setRoute.options, setRoute.handler as Parameters[2]); +} + +export function toConfigResponse(resolved: Record): ConfigResponse { + const wire: Record = {}; + for (const [domain, value] of Object.entries(resolved)) { + wire[camelToSnake(domain)] = + domain === 'providers' + ? toProviderResponses(value) + : domain === 'models' + ? toModelResponses(value) + : domain === 'services' + ? toServiceResponses(value) + : value; + } + const defaultPermissionMode = resolved['defaultPermissionMode']; + if (typeof defaultPermissionMode === 'string') { + wire['yolo'] = defaultPermissionMode === 'yolo'; + } + if (wire['providers'] === undefined) { + wire['providers'] = {}; + } + return wire as ConfigResponse; +} + +interface ProviderLike { + readonly type?: unknown; + readonly baseUrl?: unknown; + readonly defaultModel?: unknown; + readonly apiKey?: unknown; + readonly oauth?: unknown; +} + +function toProviderResponses(value: unknown): Record { + const result: Record = {}; + if (!isPlainObject(value)) return result; + for (const [id, raw] of Object.entries(value)) { + const provider = raw as ProviderLike; + result[id] = { + type: typeof provider.type === 'string' ? provider.type : '', + base_url: nonEmpty(provider.baseUrl), + default_model: nonEmpty(provider.defaultModel), + has_api_key: hasProviderCredential(provider), + }; + } + return result; +} + +function hasProviderCredential(provider: ProviderLike): boolean { + if (nonEmpty(provider.apiKey) !== undefined) return true; + if (provider.oauth !== undefined) return true; + return false; +} + +interface ModelLike { + readonly apiKey?: unknown; + readonly oauth?: unknown; +} + +function toModelResponses(value: unknown): Record { + const result: Record = {}; + if (!isPlainObject(value)) return result; + for (const [id, raw] of Object.entries(value)) { + if (!isPlainObject(raw)) { + result[id] = raw; + continue; + } + const { apiKey: _apiKey, oauth: _oauth, ...rest } = raw as ModelLike & Record; + result[id] = { ...rest, has_api_key: hasModelCredential(raw as ModelLike) }; + } + return result; +} + +function hasModelCredential(model: ModelLike): boolean { + if (nonEmpty(model.apiKey) !== undefined) return true; + if (model.oauth !== undefined) return true; + return false; +} + +function toServiceResponses(value: unknown): Record { + const result: Record = {}; + if (!isPlainObject(value)) return result; + for (const [id, raw] of Object.entries(value)) { + if (!isPlainObject(raw)) { + result[id] = raw; + continue; + } + const { apiKey: _apiKey, oauth: _oauth, customHeaders, ...rest } = raw as ModelLike & { + customHeaders?: unknown; + } & Record; + result[id] = { + ...rest, + has_api_key: hasModelCredential(raw as ModelLike), + custom_header_keys: isPlainObject(customHeaders) ? Object.keys(customHeaders) : undefined, + }; + } + return result; +} + +function nonEmpty(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const MAP_VALUED_CONFIG_KEYS = new Set(['providers', 'models', 'experimental', 'raw']); + +function convertKeysSnakeToCamel(obj: unknown, preserveKeys = false): unknown { + if (Array.isArray(obj)) { + return obj.map((item) => convertKeysSnakeToCamel(item)); + } + if (isPlainObject(obj)) { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[preserveKeys ? key : snakeToCamel(key)] = convertKeysSnakeToCamel( + value, + !preserveKeys && MAP_VALUED_CONFIG_KEYS.has(key), + ); + } + return result; + } + return obj; +} + +function snakeToCamel(str: string): string { + return str.replaceAll(/_([a-z])/g, (_, ch: string) => ch.toUpperCase()); +} + +function camelToSnake(str: string): string { + return str.replaceAll(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`); +} diff --git a/packages/kap-server/src/routes/connections.ts b/packages/kap-server/src/routes/connections.ts new file mode 100644 index 0000000000000000000000000000000000000000..d00706ca4987a5e660fede9ec6c2c18e0be98624 --- /dev/null +++ b/packages/kap-server/src/routes/connections.ts @@ -0,0 +1,48 @@ +import { okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { type IConnectionRegistry } from '../transport/ws/connectionRegistry'; +import { connectionsListResponseSchema } from '../protocol/rest-connection'; + +interface ConnectionsRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +export function registerConnectionsRoutes( + app: ConnectionsRouteHost, + registry: IConnectionRegistry, +): void { + const listRoute = defineRoute( + { + method: 'GET', + path: '/connections', + success: { data: connectionsListResponseSchema }, + description: 'List active WebSocket clients connected to the server', + tags: ['connections'], + }, + (req, reply) => { + const connections = Array.from(registry.values()) + .map((conn) => ({ + id: conn.id, + connected_at: conn.connectedAt, + remote_address: conn.remoteAddress, + user_agent: conn.userAgent, + has_client_hello: conn.hasClientHello, + subscriptions: [...conn.subscriptionSessionIds], + })) + .sort((a, b) => a.connected_at.localeCompare(b.connected_at)); + reply.send(okEnvelope({ connections }, req.id)); + }, + ); + app.get( + listRoute.path, + listRoute.options, + listRoute.handler as Parameters[2], + ); +} diff --git a/packages/kap-server/src/routes/fileHistory.ts b/packages/kap-server/src/routes/fileHistory.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7f81b1f7cbd576b83aaa5f5a16a8dd5f365523c --- /dev/null +++ b/packages/kap-server/src/routes/fileHistory.ts @@ -0,0 +1,109 @@ +import { + IAgentFileHistoryService, + resumeSessionById, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + fileHistoryChangesQuerySchema, + fileHistoryChangesResponseSchema, + fileHistoryContentQuerySchema, + fileHistoryContentResponseSchema, +} from '../protocol/rest-file-history'; +import { ensureMainAgent } from '../transport/mainAgent'; + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +interface FileHistoryRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +export function registerFileHistoryRoutes(app: FileHistoryRouteHost, core: Scope): void { + const changesRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/file-history/changes', + params: sessionIdParamSchema, + querystring: fileHistoryChangesQuerySchema, + success: { data: fileHistoryChangesResponseSchema }, + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: "List one turn's file changes from the turn-level file history", + tags: ['sessions'], + }, + async (req, reply) => { + const { session_id } = req.params; + const session = await resumeSessionById(core.accessor, session_id); + if (session === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + const agent = await ensureMainAgent(session); + const history = agent.accessor.get(IAgentFileHistoryService); + reply.send( + okEnvelope( + { + changes: await history.changes(req.query.turn_id), + recorded: await history.turnRecorded(req.query.turn_id), + }, + req.id, + ), + ); + }, + ); + app.get( + changesRoute.path, + changesRoute.options, + changesRoute.handler as Parameters[2], + ); + + const contentRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/file-history/content', + params: sessionIdParamSchema, + querystring: fileHistoryContentQuerySchema, + success: { data: fileHistoryContentResponseSchema }, + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: "A file's content as captured at a turn's file-history checkpoint", + tags: ['sessions'], + }, + async (req, reply) => { + const { session_id } = req.params; + const session = await resumeSessionById(core.accessor, session_id); + if (session === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + const agent = await ensureMainAgent(session); + const history = agent.accessor.get(IAgentFileHistoryService); + const content = await history.contentAt(req.query.turn_id, req.query.path, req.query.phase); + reply.send(okEnvelope({ content: content ?? null }, req.id)); + }, + ); + app.get( + contentRoute.path, + contentRoute.options, + contentRoute.handler as Parameters[2], + ); +} diff --git a/packages/kap-server/src/routes/files.ts b/packages/kap-server/src/routes/files.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e65dd2a50a2b11622c2c2ab21f93d65aa3e49d3 --- /dev/null +++ b/packages/kap-server/src/routes/files.ts @@ -0,0 +1,274 @@ +import multipart from '@fastify/multipart'; + +import { + ErrorCodes, + IFileService, + Error2, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { requestLog } from '../lib/requestLog'; +import { buildContentDisposition } from '../lib/contentDisposition'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { errEnvelope, okEnvelope } from '../protocol/envelope'; +import { + deleteFileParamSchema, + deleteFileResponseSchema, + getFileParamSchema, + uploadFileResponseSchema, +} from '../protocol/rest-file'; + +interface FilesRouteHost { + register(plugin: unknown, opts?: unknown): unknown; + post( + path: string, + options: { schema?: Record }, + handler: (req: FastifyRequestLike, reply: FilesReply) => unknown, + ): unknown; + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: (req: FastifyRequestLike, reply: FilesReply) => unknown, + ): unknown; + delete( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: (req: FastifyRequestLike, reply: FilesReply) => unknown, + ): unknown; +} + +interface FastifyRequestLike { + id: string; + params: unknown; + headers: Record; + file?: (opts?: unknown) => Promise; +} + +interface MultipartFileLike { + file: NodeJS.ReadableStream; + filename: string; + mimetype: string; + fields: Record; +} + +interface FilesReply { + type(mime: string): FilesReply; + header(name: string, value: string | number): FilesReply; + code(status: number): FilesReply; + send(payload: unknown): unknown; +} + +export function registerFilesRoutes(app: FilesRouteHost, core: Scope): void { + app.register(multipart, { + limits: { + fileSize: Number.MAX_SAFE_INTEGER, + files: 1, + }, + }); + + const uploadRoute = defineRoute( + { + method: 'POST', + path: '/files', + success: { data: uploadFileResponseSchema }, + consumes: ['multipart/form-data'], + description: 'Upload a file', + tags: ['files'], + }, + async (req, reply) => { + try { + const fastifyReq = req as unknown as FastifyRequestLike; + if (!fastifyReq.file) { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, 'multipart not initialized', req.id)); + return; + } + const part = await fastifyReq.file(); + if (!part) { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, 'missing `file` field', req.id)); + return; + } + + const nameOverride = readFieldString(part.fields['name']); + const expiresInSec = readFieldNumber(part.fields['expires_in_sec']); + + const store = core.accessor.get(IFileService); + + try { + const meta = await store.save( + part.file as unknown as import('node:stream').Readable, + part.filename, + { + name: nameOverride ?? part.filename, + mimeType: part.mimetype, + expiresInSec, + }, + ); + reply.send(okEnvelope(meta, req.id)); + } catch (error) { + sendMappedError(reply as unknown as FilesReply, req, error); + } + } catch (error) { + sendMappedError(reply as unknown as FilesReply, req, error); + } + }, + ); + app.post( + uploadRoute.path, + uploadRoute.options, + uploadRoute.handler as unknown as Parameters[2], + ); + + const downloadRoute = defineRoute( + { + method: 'GET', + path: '/files/{file_id}', + params: getFileParamSchema, + rawResponse: { + 200: { type: 'string', format: 'binary' }, + }, + errors: { + [ErrorCode.FILE_NOT_FOUND]: {}, + }, + description: 'Download a file by ID', + tags: ['files'], + }, + async (req, reply) => { + try { + const { file_id } = req.params; + const store = core.accessor.get(IFileService); + const file = await store.get(file_id); + const r = reply as unknown as FilesReply; + const { meta } = file; + const size = meta.size; + r.type(meta.media_type) + .header('content-disposition', buildContentDisposition(meta.name, meta.media_type)) + .header('accept-ranges', 'bytes') + .header('etag', `"${meta.id}-${size}"`); + + const range = parseRange( + readRangeHeader((req as unknown as FastifyRequestLike).headers['range']), + size, + ); + if (range) { + r.header('content-range', `bytes ${range.start}-${range.end}/${size}`) + .header('content-length', range.end - range.start + 1) + .code(206); + return r.send(file.stream(range)) as unknown as void; + } + + r.header('content-length', size).code(200); + return r.send(file.stream()) as unknown as void; + } catch (error) { + sendMappedError(reply as unknown as FilesReply, req, error); + return; + } + }, + ); + app.get( + downloadRoute.path, + downloadRoute.options, + downloadRoute.handler as unknown as Parameters[2], + ); + + const deleteRoute = defineRoute( + { + method: 'DELETE', + path: '/files/{file_id}', + params: deleteFileParamSchema, + success: { data: deleteFileResponseSchema }, + description: 'Delete a file by ID', + tags: ['files'], + }, + async (req, reply) => { + try { + const { file_id } = req.params; + const store = core.accessor.get(IFileService); + await store.delete(file_id); + requestLog(req)?.info({ file_id }, 'file deleted'); + reply.send(okEnvelope({ deleted: true as const }, req.id)); + } catch (error) { + sendMappedError(reply as unknown as FilesReply, req, error); + } + }, + ); + app.delete( + deleteRoute.path, + deleteRoute.options, + deleteRoute.handler as unknown as Parameters[2], + ); +} + +function sendMappedError(reply: FilesReply, req: { id: string }, err: unknown): void { + const requestId = req.id; + if (err instanceof Error2 && err.code === ErrorCodes.FILE_NOT_FOUND) { + reply.code(404).send(errEnvelope(ErrorCode.FILE_NOT_FOUND, 'file not found', requestId)); + return; + } + requestLog(req)?.error({ err }, 'file request failed'); + reply + .code(500) + .send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + err instanceof Error ? err.message : 'internal error', + requestId, + ), + ); +} + +const fieldValueSchema = z.object({ value: z.unknown() }); + +function readFieldString(field: unknown): string | undefined { + const parsed = fieldValueSchema.safeParse(field); + if (!parsed.success) return undefined; + const v = parsed.data.value; + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +function readFieldNumber(field: unknown): number | undefined { + const parsed = fieldValueSchema.safeParse(field); + if (!parsed.success) return undefined; + const v = parsed.data.value; + if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return Math.floor(v); + if (typeof v === 'string') { + const n = Number(v); + if (Number.isFinite(n) && n >= 0) return Math.floor(n); + } + return undefined; +} + +function readRangeHeader(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +interface ByteRange { + start: number; + end: number; +} + +function parseRange(header: string | undefined, size: number): ByteRange | undefined { + if (!header || size <= 0) return undefined; + const m = /^bytes=(\d*)-(\d*)$/i.exec(header.trim()); + if (!m) return undefined; + const startStr = m[1]!; + const endStr = m[2]!; + if (startStr === '' && endStr === '') return undefined; + + let start: number; + let end: number; + if (startStr === '') { + const suffix = Number(endStr); + if (!Number.isFinite(suffix) || suffix <= 0) return undefined; + start = Math.max(size - suffix, 0); + end = size - 1; + } else { + start = Number(startStr); + if (!Number.isFinite(start) || start < 0 || start >= size) return undefined; + end = endStr === '' ? size - 1 : Number(endStr); + if (!Number.isFinite(end) || end < 0) return undefined; + } + if (start > end) return undefined; + return { start, end: Math.min(end, size - 1) }; +} diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts new file mode 100644 index 0000000000000000000000000000000000000000..598da6d85ac5b2a464ead8321f2919ca4af147ed --- /dev/null +++ b/packages/kap-server/src/routes/fs.ts @@ -0,0 +1,970 @@ +import { isAbsolute } from 'node:path'; +import { Readable } from 'node:stream'; + +import { + ErrorCodes, + IRuntimeResolver, + ISessionContext, + ISessionWorkspaceContext, + IStandaloneRuntimeFactory, + ITelemetryService, + IWorkspaceFsService, + IWorkspaceInstanceManager, + IWorkspaceService, + getLiveSessionById, + resumeSessionById, + isError2, + Error2, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; +import { RuntimeError } from '@moonshot-ai/agent-core-v2/runtime/runtimeRegistry'; +import { + fsDiffRequestSchema, + fsGitStatusRequestSchema, + fsGrepRequestSchema, + fsListManyRequestSchema, + fsListRequestSchema, + fsMkdirRequestSchema, + fsReadRequestSchema, + fsSearchRequestSchema, + fsSearchResponseSchema, + fsStatManyRequestSchema, + fsStatRequestSchema, + fsSuggestRequestSchema, + fsSuggestResponseSchema, +} from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fs'; +import { GitService } from '@moonshot-ai/agent-core-v2/app/git/gitService'; +import type { IHostFileSystem } from '@moonshot-ai/agent-core-v2/os/interface/hostFileSystem'; +import type { RuntimeCapability, RuntimeLease } from '@moonshot-ai/agent-core-v2/runtime/runtime'; +import { WorkspaceFsService } from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fsService'; +import { WorkspaceGitService } from '@moonshot-ai/agent-core-v2/workspace/workspaceGit/workspaceGitService'; +import type { IWorkspaceContext } from '@moonshot-ai/agent-core-v2/workspace/workspaceContext/workspaceContext'; +import type { IWorkspaceDirs } from '@moonshot-ai/agent-core-v2/workspace/workspaceDirs/workspaceDirs'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { + launchDetached, + openFileCommandFor, + openInAppCommandFor, + revealFileCommandFor, +} from '../lib/fileLaunch'; +import { parseRangeHeader, pickHeader } from '../lib/httpRange'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + fsOpenInRequestSchema, + fsOpenRequestSchema, + fsRevealRequestSchema, +} from '../protocol/rest-fs'; + +interface FsRouteHost { + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown; query: unknown; headers: Record }, + reply: FsDownloadReply, + ) => unknown, + ): unknown; +} + +interface FsDownloadReply { + type(mime: string): FsDownloadReply; + header(name: string, value: string | number): FsDownloadReply; + code(status: number): FsDownloadReply; + send(payload: unknown): unknown; +} + +const sessionIdAndTailParamSchema = z.object({ + session_id: z.string().min(1), + tail: z.string().min(1), +}); + +const fsDownloadQuerySchema = z.object({ + runtime_id: z.string().min(1).optional(), +}); + +const workspaceFsSearchBodySchema = fsSearchRequestSchema.extend({ + workspace: z.string().min(1), + runtime_id: z.string().min(1).optional(), +}); + +const workspaceFsSuggestBodySchema = fsSuggestRequestSchema.extend({ + workspace: z.string().min(1), + runtime_id: z.string().min(1).optional(), +}); + +const rootFsSuggestBodySchema = fsSuggestRequestSchema.extend({ + roots: z.array(z.string().min(1)).min(1).max(32), + runtime_id: z.string().min(1).optional(), +}); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +const FS_ACTIONS = [ + 'list', + 'read', + 'list_many', + 'stat', + 'stat_many', + 'mkdir', + 'search', + 'grep', + 'git_status', + 'diff', + 'open', + 'open-in', + 'reveal', +] as const; +type FsAction = (typeof FS_ACTIONS)[number]; +const FS_TAIL_PREFIX = 'fs:'; + +interface RuntimeFsScope { + readonly fs: IWorkspaceFsService; + readonly hostFs: IHostFileSystem; + readonly lease: RuntimeLease; + readonly roots: { readonly workDir: string; readonly additionalDirs: readonly string[] }; +} + +function createRuntimeFs( + core: Scope, + workspaceId: string, + roots: { readonly workDir: string; readonly additionalDirs?: readonly string[] }, + runtimeId: string, + required: readonly RuntimeCapability[], +): RuntimeFsScope { + const lease = core.accessor.get(IRuntimeResolver).acquire( + { workspaceId, runtimeId }, + required, + ); + try { + return buildRuntimeFsScope(core, workspaceId, roots, runtimeId, lease); + } catch (error) { + lease.dispose(); + throw error; + } +} + +function createLocalRuntimeFs( + core: Scope, + roots: { readonly workDir: string; readonly additionalDirs?: readonly string[] }, +): RuntimeFsScope { + const workspaceId = encodeWorkDirKey(roots.workDir); + const runtime = core.accessor.get(IStandaloneRuntimeFactory).createLocalRuntime(workspaceId); + const lease: RuntimeLease = { + runtime, + track: (resource) => resource, + dispose: () => { + void runtime.dispose(); + }, + }; + try { + return buildRuntimeFsScope(core, workspaceId, roots, 'local', lease); + } catch (error) { + lease.dispose(); + throw error; + } +} + +function buildRuntimeFsScope( + core: Scope, + workspaceId: string, + roots: { readonly workDir: string; readonly additionalDirs?: readonly string[] }, + runtimeId: string, + lease: RuntimeLease, +): RuntimeFsScope { + const mapped = lease.runtime.workspace.mapRoots(roots); + const workspace = { + _serviceBrand: undefined, + workspaceId, + cwd: mapped.workDir, + source: 'local', + meta: { + id: workspaceId, + root: mapped.workDir, + name: workspaceId, + createdAt: 0, + lastOpenedAt: 0, + }, + persistenceScope: `sessions/${workspaceId}`, + } satisfies IWorkspaceContext; + const dirs = { + _serviceBrand: undefined, + ready: Promise.resolve(), + additionalDirs: mapped.additionalDirs ?? [], + onDidChange: () => ({ dispose: () => {} }), + addDir: async () => { throw new Error('runtime fs directories are immutable'); }, + mergeAdditionalDirs: async () => { throw new Error('runtime fs directories are immutable'); }, + sessionInfo: () => ({ workDir: mapped.workDir, additionalDirs: mapped.additionalDirs ?? [] }), + } as unknown as IWorkspaceDirs; + const resolver: IRuntimeResolver = { + _serviceBrand: undefined, + inspect: () => lease.runtime, + acquire: (_binding, capabilities = []) => { + const missing = capabilities.filter((capability) => !lease.runtime.capabilities.has(capability)); + if (missing.length > 0) throw new Error(`runtime ${runtimeId} missing capabilities: ${missing.join(', ')}`); + return { + runtime: lease.runtime, + track: (resource) => lease.track(resource), + dispose: () => {}, + }; + }, + }; + const instances = { + findByRoot: (root: string) => root === mapped.workDir ? { id: workspaceId } : undefined, + } as unknown as IWorkspaceInstanceManager; + const git = new WorkspaceGitService( + workspace, + { + current: new GitService(resolver, instances, lease.runtime.fs!), + onDidChange: () => ({ dispose: () => {} }), + }, + ); + return { + fs: new WorkspaceFsService( + workspace, + dirs, + lease.runtime.fs!, + resolver, + core.accessor.get(ITelemetryService), + git, + runtimeId, + ), + hostFs: lease.runtime.fs!, + lease, + roots: { workDir: mapped.workDir, additionalDirs: mapped.additionalDirs ?? [] }, + }; +} + +function acquireSessionFs( + core: Scope, + sessionId: string, + runtimeId: string, + required: readonly RuntimeCapability[], +): RuntimeFsScope { + const session = getLiveSessionById(core.accessor, sessionId); + if (session === undefined) throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + const context = session.accessor.get(ISessionContext); + const workspace = session.accessor.get(ISessionWorkspaceContext); + return createRuntimeFs(core, context.workspaceId, workspace, runtimeId, required); +} + +async function resolveWorkspaceFs( + core: Scope, + ref: string, + runtimeId: string, + required: readonly RuntimeCapability[], +): Promise { + const workspaces = core.accessor.get(IWorkspaceService); + let ws = await workspaces.get(ref); + if (ws === undefined) { + if (!isAbsolute(ref)) return undefined; + try { + ws = await workspaces.createOrTouch(ref); + } catch { + return undefined; + } + } + await core.accessor + .get(IWorkspaceInstanceManager) + .getOrCreate({ workspaceId: ws.id, root: ws.root }); + return createRuntimeFs(core, ws.id, { workDir: ws.root }, runtimeId, required); +} + +export function registerFsRoutes(app: FsRouteHost, core: Scope): void { + const fsActionRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/{tail}', + params: sessionIdAndTailParamSchema, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + [ErrorCode.FS_IS_DIRECTORY]: {}, + [ErrorCode.FS_IS_BINARY]: {}, + [ErrorCode.FS_TOO_LARGE]: {}, + [ErrorCode.FS_TOO_MANY_RESULTS]: {}, + [ErrorCode.FS_PATH_ESCAPES_SESSION]: {}, + [ErrorCode.FS_GREP_TIMEOUT]: {}, + [ErrorCode.FS_GIT_UNAVAILABLE]: {}, + [ErrorCode.FS_ALREADY_EXISTS]: {}, + }, + description: + 'Filesystem action dispatcher. Supported actions: list, read, list_many, stat, stat_many, mkdir, search, grep, git_status, diff, open, open-in, reveal.', + tags: ['fs'], + operationId: 'fsAction', + }, + async (req, reply) => { + const { session_id, tail } = req.params as { session_id: string; tail: string }; + + if (!tail.startsWith(FS_TAIL_PREFIX)) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `unsupported action: ${tail}`, req.id), + ); + return; + } + + const action = tail.slice(FS_TAIL_PREFIX.length); + if (!(FS_ACTIONS as readonly string[]).includes(action)) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `unsupported action: ${tail}`, req.id), + ); + return; + } + const fsAction = action as FsAction; + + const session = await resumeSessionById(core.accessor, session_id); + let runtimeFs: RuntimeFsScope | undefined; + try { + const result = z.object({ runtime_id: z.string().min(1).optional() }).passthrough().safeParse(req.body ?? {}); + if (!result.success) { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, 'request body must be an object', req.id)); + return; + } + const { runtime_id, ...request } = result.data; + const runtimeId = runtime_id ?? 'local'; + req.body = request; + const required: RuntimeCapability[] = ['fs']; + if (fsAction === 'search' || fsAction === 'grep' || fsAction === 'git_status' || fsAction === 'diff') { + required.push('process'); + } + runtimeFs = session === undefined && fsAction === 'search' + ? await resolveWorkspaceFs(core, session_id, runtimeId, required) + : session === undefined + ? undefined + : acquireSessionFs(core, session_id, runtimeId, required); + if (runtimeFs === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + if ((fsAction === 'open' || fsAction === 'open-in' || fsAction === 'reveal') && runtimeFs.lease.runtime.identity.runtimeId !== 'local') { + throw new Error(`filesystem action ${fsAction} is unavailable on runtime ${runtimeId}`); + } + switch (fsAction) { + case 'list': + await handleList(runtimeFs.fs, req, reply); + return; + case 'read': + await handleRead(runtimeFs.fs, req, reply); + return; + case 'list_many': + await handleListMany(runtimeFs.fs, req, reply); + return; + case 'stat': + await handleStat(runtimeFs.fs, req, reply); + return; + case 'stat_many': + await handleStatMany(runtimeFs.fs, req, reply); + return; + case 'mkdir': + await handleMkdir(runtimeFs.fs, req, reply); + return; + case 'search': + await handleSearch(runtimeFs.fs, req, reply); + return; + case 'grep': + await handleGrep(runtimeFs.fs, req, reply); + return; + case 'git_status': + await handleGitStatus(runtimeFs.fs, req, reply); + return; + case 'diff': + await handleDiff(runtimeFs.fs, req, reply); + return; + case 'open': + await handleOpen(runtimeFs.fs, req, reply); + return; + case 'open-in': + await handleOpenIn(runtimeFs.fs, session_id, req, reply); + return; + case 'reveal': + await handleReveal(runtimeFs.fs, req, reply); + return; + } + } catch (err) { + sendMappedError(reply, req, err); + } finally { + runtimeFs?.lease.dispose(); + } + }, + ); + app.post( + fsActionRoute.path, + fsActionRoute.options, + fsActionRoute.handler as unknown as Parameters[2], + ); + + const workspaceSearchRoute = defineRoute( + { + method: 'POST', + path: '/workspace/fs::search', + body: workspaceFsSearchBodySchema, + success: { data: fsSearchResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + [ErrorCode.FS_TOO_MANY_RESULTS]: {}, + }, + description: + 'Search files in a workspace without a session. `workspace` accepts a registered workspace id or an absolute root (registered on the spot).', + tags: ['fs'], + operationId: 'workspaceFsSearch', + }, + async (req, reply) => { + const { workspace, runtime_id, ...searchRequest } = req.body; + let runtimeFs: RuntimeFsScope | undefined; + try { + runtimeFs = await resolveWorkspaceFs(core, workspace, runtime_id ?? 'local', ['fs', 'process']); + if (runtimeFs === undefined) { + reply.send( + errEnvelope( + ErrorCode.WORKSPACE_NOT_FOUND, + `workspace ${workspace} does not exist`, + req.id, + ), + ); + return; + } + const data = await runtimeFs.fs.search(searchRequest); + reply.send(okEnvelope(data, req.id)); + } catch (err) { + sendMappedError(reply, req, err); + } finally { + runtimeFs?.lease.dispose(); + } + }, + ); + app.post( + workspaceSearchRoute.path, + workspaceSearchRoute.options, + workspaceSearchRoute.handler as unknown as Parameters[2], + ); + + const workspaceSuggestRoute = defineRoute( + { + method: 'POST', + path: '/workspace/fs::suggest', + body: workspaceFsSuggestBodySchema, + success: { data: fsSuggestResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: + 'Suggest file and directory completion candidates in a workspace without a session. `workspace` accepts a registered workspace id or an absolute root (registered on the spot).', + tags: ['fs'], + operationId: 'workspaceFsSuggest', + }, + async (req, reply) => { + const { workspace, runtime_id, ...suggestRequest } = req.body; + let runtimeFs: RuntimeFsScope | undefined; + try { + runtimeFs = await resolveWorkspaceFs(core, workspace, runtime_id ?? 'local', ['fs']); + if (runtimeFs === undefined) { + reply.send( + errEnvelope( + ErrorCode.WORKSPACE_NOT_FOUND, + `workspace ${workspace} does not exist`, + req.id, + ), + ); + return; + } + const data = await runtimeFs.fs.suggest(suggestRequest); + reply.send(okEnvelope(data, req.id)); + } catch (err) { + sendMappedError(reply, req, err); + } finally { + runtimeFs?.lease.dispose(); + } + }, + ); + app.post( + workspaceSuggestRoute.path, + workspaceSuggestRoute.options, + workspaceSuggestRoute.handler as unknown as Parameters[2], + ); + + const rootSuggestRoute = defineRoute( + { + method: 'POST', + path: '/fs::suggest', + body: rootFsSuggestBodySchema, + success: { data: fsSuggestResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + [ErrorCode.RUNTIME_NOT_FOUND]: {}, + [ErrorCode.RUNTIME_UNAVAILABLE]: {}, + }, + description: + 'Suggest file and directory completion candidates across one or more absolute root directories without a session or workspace. The first root is the primary root: its candidates are returned as relative paths, candidates under additional roots as absolute paths. Overlapping roots are deduplicated. No workspace registration or other side effects.', + tags: ['fs'], + operationId: 'fsSuggest', + }, + async (req, reply) => { + const { roots, runtime_id, ...suggestRequest } = req.body; + for (const root of roots) { + if (!isAbsolute(root)) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `root must be an absolute path: ${root}`, req.id), + ); + return; + } + } + const runtimeId = runtime_id ?? 'local'; + const fsRoots = { workDir: roots[0]!, additionalDirs: roots.slice(1) }; + let runtimeFs: RuntimeFsScope | undefined; + try { + runtimeFs = runtimeId === 'local' + ? createLocalRuntimeFs(core, fsRoots) + : createRuntimeFs(core, encodeWorkDirKey(roots[0]!), fsRoots, runtimeId, ['fs']); + for (const root of [runtimeFs.roots.workDir, ...runtimeFs.roots.additionalDirs]) { + let stat; + try { + stat = await runtimeFs.hostFs.stat(root); + } catch { + throw new Error2(ErrorCodes.FS_PATH_NOT_FOUND, `root not found: ${root}`, { + details: { path: root }, + }); + } + if (!stat.isDirectory) { + throw new Error2(ErrorCodes.FS_PATH_NOT_FOUND, `root is not a directory: ${root}`, { + details: { path: root }, + }); + } + } + const data = await runtimeFs.fs.suggest(suggestRequest); + reply.send(okEnvelope(data, req.id)); + } catch (err) { + if (err instanceof RuntimeError) { + const code = err.code === 'runtime.not_found' + ? ErrorCode.RUNTIME_NOT_FOUND + : ErrorCode.RUNTIME_UNAVAILABLE; + reply.send(errEnvelope(code, err.message, req.id)); + return; + } + sendMappedError(reply, req, err); + } finally { + runtimeFs?.lease.dispose(); + } + }, + ); + app.post( + rootSuggestRoute.path, + rootSuggestRoute.options, + rootSuggestRoute.handler as unknown as Parameters[2], + ); + + const downloadRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/fs/*', + querystring: fsDownloadQuerySchema, + rawResponse: { + 200: { type: 'string', format: 'binary' }, + }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + [ErrorCode.FS_PATH_ESCAPES_SESSION]: {}, + }, + description: 'Download a file from the session workspace', + tags: ['fs'], + operationId: 'downloadFile', + }, + async (req, reply) => { + const { session_id } = req.params as { session_id: string }; + const wildcard = (req.params as Record)['*'] as string; + + const DOWNLOAD_SUFFIX = ':download'; + if (!wildcard.endsWith(DOWNLOAD_SUFFIX)) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `unsupported action: ${wildcard}`, req.id), + ); + return; + } + const relPath = wildcard.slice(0, -DOWNLOAD_SUFFIX.length); + if (relPath.length === 0) { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, 'path is empty', req.id)); + return; + } + + const session = await resumeSessionById(core.accessor, session_id); + if (session === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + + let resolved: Awaited>; + let runtimeFs: RuntimeFsScope | undefined; + try { + runtimeFs = acquireSessionFs(core, session_id, req.query.runtime_id ?? 'local', ['fs']); + resolved = await runtimeFs.fs.resolveDownload(relPath); + } catch (err) { + runtimeFs?.lease.dispose(); + sendMappedError(reply, req, err); + return; + } + + const r = reply as unknown as FsDownloadReply; + const headers = req.headers; + + const ifNoneMatch = pickHeader(headers, 'if-none-match'); + if (ifNoneMatch !== undefined && ifNoneMatch === resolved.etag) { + runtimeFs.lease.dispose(); + r.code(304).header('etag', resolved.etag).send(''); + return; + } + + r.header('etag', resolved.etag); + r.header('last-modified', resolved.modifiedAt.toUTCString()); + r.header( + 'content-disposition', + `attachment; filename="${sanitizeFilename(resolved.relative)}"`, + ); + r.type(resolved.mime); + + const rangeHeader = pickHeader(headers, 'range'); + const range = parseRangeHeader(rangeHeader, resolved.size); + if (range !== null) { + r.code(206) + .header('content-length', String(range.length)) + .header('content-range', `bytes ${range.start}-${range.end}/${resolved.size}`); + const stream = createRuntimeReadStream(runtimeFs, resolved.absolute, range.start, range.length); + stream.on('error', (error: unknown) => { + requestLog(req)?.warn( + { session_id, path: relPath, err: error }, + 'fs download stream error', + ); + try { + stream.destroy(); + } catch { + } + }); + return r.send(stream) as unknown as void; + } + + r.code(200).header('content-length', String(resolved.size)); + const stream = createRuntimeReadStream(runtimeFs, resolved.absolute, 0, resolved.size); + stream.on('error', (error: unknown) => { + requestLog(req)?.warn( + { session_id, path: relPath, err: error }, + 'fs download stream error', + ); + try { + stream.destroy(); + } catch { + } + }); + return r.send(stream) as unknown as void; + }, + ); + app.get( + downloadRoute.path, + downloadRoute.options, + downloadRoute.handler as unknown as Parameters[2], + ); +} + +function createRuntimeReadStream( + runtimeFs: RuntimeFsScope, + path: string, + start: number, + length: number, +): Readable { + async function* chunks(): AsyncGenerator { + let offset = start; + let remaining = length; + while (remaining > 0) { + const chunk = await runtimeFs.hostFs.readBytes(path, Math.min(64 * 1024, remaining), offset); + if (chunk.byteLength === 0) break; + offset += chunk.byteLength; + remaining -= chunk.byteLength; + yield chunk; + } + } + const stream = Readable.from(chunks()); + const tracked = runtimeFs.lease.track({ dispose: () => { stream.destroy(); } }); + let released = false; + const release = (): void => { + if (released) return; + released = true; + tracked.dispose(); + runtimeFs.lease.dispose(); + }; + stream.once('end', release); + stream.once('close', release); + return stream; +} + +type Req = { id: string; body: unknown }; +type Reply = { send(payload: unknown): unknown }; + +async function handleList(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsListRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await fs.list(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleRead(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsReadRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await fs.read(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleListMany(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsListManyRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await fs.listMany(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleStat(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsStatRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await fs.stat(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleStatMany(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsStatManyRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await fs.statMany(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleMkdir(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsMkdirRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await fs.mkdir(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleSearch(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsSearchRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await fs.search(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleGrep(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsGrepRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await fs.grep(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleGitStatus(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsGitStatusRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await fs.gitStatus(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleDiff(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsDiffRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const data = await fs.diff(parsed.data); + reply.send(okEnvelope(data, req.id)); +} + +async function handleOpen(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsOpenRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const resolved = await fs.resolvePath(parsed.data.path); + await launchDetached(openFileCommandFor(resolved.absolute, parsed.data.line)); + reply.send(okEnvelope({ opened: true as const }, req.id)); +} + +async function handleReveal(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { + const parsed = fsRevealRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const resolved = await fs.resolvePath(parsed.data.path); + await launchDetached(revealFileCommandFor(resolved.absolute)); + reply.send(okEnvelope({ revealed: true as const }, req.id)); +} + +async function handleOpenIn(fs: IWorkspaceFsService, sessionId: string, req: Req, reply: Reply): Promise { + const parsed = fsOpenInRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const body = parsed.data; + const resolved = await fs.resolvePath(body.path); + try { + await launchDetached( + openInAppCommandFor(body.app_id, resolved.absolute, { + line: body.line, + isDirectory: resolved.isDirectory, + }), + ); + } catch (err) { + requestLog(req)?.warn( + { session_id: sessionId, app_id: body.app_id, err }, + 'fs open-in launch failed', + ); + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + `failed to open in ${body.app_id}: ${err instanceof Error ? err.message : String(err)}`, + req.id, + ), + ); + return; + } + reply.send(okEnvelope({ opened: true as const }, req.id)); +} + +function sendMappedError(reply: Reply, req: { id: string }, err: unknown): void { + const requestId = req.id; + const log = requestLog(req); + if (isError2(err)) { + switch (err.code) { + case ErrorCodes.FS_PATH_ESCAPES: + reply.send(errEnvelope(ErrorCode.FS_PATH_ESCAPES_SESSION, err.message, requestId, err.stack)); + return; + case ErrorCodes.FS_PATH_NOT_FOUND: + reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, err.message, requestId, err.stack)); + return; + case ErrorCodes.FS_IS_DIRECTORY: + reply.send(errEnvelope(ErrorCode.FS_IS_DIRECTORY, err.message, requestId, err.stack)); + return; + case ErrorCodes.FS_ALREADY_EXISTS: + reply.send(errEnvelope(ErrorCode.FS_ALREADY_EXISTS, err.message, requestId, err.stack)); + return; + case ErrorCodes.FS_IS_BINARY: + reply.send(errEnvelope(ErrorCode.FS_IS_BINARY, err.message, requestId, err.stack)); + return; + case ErrorCodes.FS_TOO_LARGE: + reply.send(errEnvelope(ErrorCode.FS_TOO_LARGE, err.message, requestId, err.stack)); + return; + case ErrorCodes.FS_TOO_MANY_RESULTS: + reply.send(errEnvelope(ErrorCode.FS_TOO_MANY_RESULTS, err.message, requestId, err.stack)); + return; + case ErrorCodes.FS_GREP_TIMEOUT: + reply.send(errEnvelope(ErrorCode.FS_GREP_TIMEOUT, err.message, requestId, err.stack)); + return; + case ErrorCodes.FS_GIT_UNAVAILABLE: + reply.send(errEnvelope(ErrorCode.FS_GIT_UNAVAILABLE, err.message, requestId, err.stack)); + return; + case ErrorCodes.SESSION_NOT_FOUND: + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack)); + return; + case ErrorCodes.OS_FS_NOT_FOUND: + case ErrorCodes.OS_FS_NOT_DIRECTORY: + reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, err.message, requestId, err.stack)); + return; + case ErrorCodes.OS_FS_IS_DIRECTORY: + reply.send(errEnvelope(ErrorCode.FS_IS_DIRECTORY, err.message, requestId, err.stack)); + return; + case ErrorCodes.OS_FS_ALREADY_EXISTS: + reply.send(errEnvelope(ErrorCode.FS_ALREADY_EXISTS, err.message, requestId, err.stack)); + return; + case ErrorCodes.OS_FS_PERMISSION_DENIED: + reply.send(errEnvelope(ErrorCode.FS_PERMISSION_DENIED, err.message, requestId, err.stack)); + return; + } + } + log?.error({ err }, 'fs request failed'); + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + err instanceof Error ? err.message : String(err), + requestId, + err instanceof Error ? err.stack : undefined, + ), + ); +} + +function buildValidationEnvelope( + issues: readonly { path: readonly PropertyKey[]; message: string }[], + requestId: string, +): { + code: number; + msg: string; + data: null; + request_id: string; + details: { path: string; message: string }[]; +} { + const details = issues.map((i) => ({ + path: i.path.map((p) => String(p)).join('.'), + message: i.message, + })); + const first = details[0]; + const msg = + first === undefined + ? 'validation failed' + : first.path === '' + ? first.message + : `${first.path}: ${first.message}`; + return { + code: ErrorCode.VALIDATION_FAILED, + msg, + data: null, + request_id: requestId, + details, + }; +} + +function sanitizeFilename(rel: string): string { + const segs = rel.split('/'); + const base = segs[segs.length - 1] ?? rel; + return base.replace(/"/g, '\\"'); +} diff --git a/packages/kap-server/src/routes/guiStore.ts b/packages/kap-server/src/routes/guiStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..002868748223704cc98124dc91fc3651fa3d9019 --- /dev/null +++ b/packages/kap-server/src/routes/guiStore.ts @@ -0,0 +1,136 @@ +import { z } from 'zod'; + +import { okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { IGuiStoreService } from '../services/guiStore/guiStore'; +import { ErrorCode } from '../protocol/error-codes'; +import { + guiStoreGetItemQuerySchema, + guiStoreGetItemResponseSchema, + guiStoreLengthResponseSchema, + guiStoreRemoveItemBodySchema, + guiStoreSetItemBodySchema, +} from '../protocol/rest-guiStore'; + +interface GuiStoreRouteHost { + get( + path: string, + options: { schema?: Record }, + handler: ( + req: { id: string; query?: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { schema?: Record }, + handler: ( + req: { id: string; body?: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; +} + +export function registerGuiStoreRoutes(app: GuiStoreRouteHost, store: IGuiStoreService): void { + const getItemRoute = defineRoute( + { + method: 'GET', + path: '/gui/store/getItem', + querystring: guiStoreGetItemQuerySchema, + success: { data: guiStoreGetItemResponseSchema }, + errors: { [ErrorCode.VALIDATION_FAILED]: {} }, + description: 'Read a value by key (mirrors localStorage.getItem).', + tags: ['gui-store'], + }, + async (req, reply) => { + const value = await store.getItem((req.query as { key: string }).key); + reply.send(okEnvelope({ value }, req.id)); + }, + ); + app.get( + getItemRoute.path, + getItemRoute.options, + getItemRoute.handler as Parameters[2], + ); + + const setItemRoute = defineRoute( + { + method: 'POST', + path: '/gui/store/setItem', + body: guiStoreSetItemBodySchema, + success: { data: z.null() }, + errors: { [ErrorCode.VALIDATION_FAILED]: {} }, + description: 'Write a value by key (mirrors localStorage.setItem).', + tags: ['gui-store'], + }, + async (req, reply) => { + const body = req.body as { key: string; value: string }; + await store.setItem(body.key, body.value); + reply.send(okEnvelope(null, req.id)); + }, + ); + app.post( + setItemRoute.path, + setItemRoute.options, + setItemRoute.handler as Parameters[2], + ); + + const removeItemRoute = defineRoute( + { + method: 'POST', + path: '/gui/store/removeItem', + body: guiStoreRemoveItemBodySchema, + success: { data: z.null() }, + errors: { [ErrorCode.VALIDATION_FAILED]: {} }, + description: 'Delete a value by key (mirrors localStorage.removeItem).', + tags: ['gui-store'], + }, + async (req, reply) => { + await store.removeItem((req.body as { key: string }).key); + reply.send(okEnvelope(null, req.id)); + }, + ); + app.post( + removeItemRoute.path, + removeItemRoute.options, + removeItemRoute.handler as Parameters[2], + ); + + const clearRoute = defineRoute( + { + method: 'POST', + path: '/gui/store/clear', + success: { data: z.null() }, + description: 'Delete all values (mirrors localStorage.clear).', + tags: ['gui-store'], + }, + async (_req, reply) => { + await store.clear(); + reply.send(okEnvelope(null, _req.id)); + }, + ); + app.post( + clearRoute.path, + clearRoute.options, + clearRoute.handler as Parameters[2], + ); + + const lengthRoute = defineRoute( + { + method: 'GET', + path: '/gui/store/length', + success: { data: guiStoreLengthResponseSchema }, + description: 'Number of stored keys (mirrors localStorage.length).', + tags: ['gui-store'], + }, + async (req, reply) => { + const length = await store.length(); + reply.send(okEnvelope({ length }, req.id)); + }, + ); + app.get( + lengthRoute.path, + lengthRoute.options, + lengthRoute.handler as Parameters[2], + ); +} diff --git a/packages/kap-server/src/routes/history.ts b/packages/kap-server/src/routes/history.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffdc423da513938176ea0713bde5359752cbad0e --- /dev/null +++ b/packages/kap-server/src/routes/history.ts @@ -0,0 +1,111 @@ +import { type Scope } from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { ErrorCode } from '../protocol/error-codes'; +import { historyResponseSchema } from '../protocol/messages'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { + HistorySessionNotFoundError, + readSessionHistory, + type HistoryServiceDeps, +} from '../services/history/historyService'; + +interface HistoryRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const historyQueryCoercion = z + .object({ + before_turn: z.string().min(1).optional(), + after_step: z.string().min(1).optional(), + page_size: z.coerce.number().int().min(1).max(500).optional(), + agent_id: z.string().min(1).optional(), + }) + .superRefine((value, ctx) => { + if (value.before_turn !== undefined && value.after_step !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'before_turn and after_step are mutually exclusive', + path: ['before_turn'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + if (value.agent_id !== undefined && !isPlainAgentId(value.agent_id)) { + ctx.addIssue({ + code: 'custom', + message: 'agent_id must be a plain agent id (no path separators)', + path: ['agent_id'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + }); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +const AGENT_ID_PATTERN = /^[^/\\]+$/; + +function isPlainAgentId(agentId: string): boolean { + return AGENT_ID_PATTERN.test(agentId) && agentId !== '.' && agentId !== '..'; +} + +export interface HistoryRouteDeps { + readonly core: Scope; + readonly homeDir: string; + readonly projection: HistoryServiceDeps['projection']; +} + +export function registerHistoryRoutes(app: HistoryRouteHost, deps: HistoryRouteDeps): void { + const route = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/history', + params: sessionIdParamSchema, + querystring: historyQueryCoercion, + success: { data: historyResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: + 'Flat entity-message history of one agent timeline, cold-rebuilt from the persisted wire records (live sessions flush first). Messages are time-ordered and share the WS entity schemas. before_turn pages to older turns, after_step catches up newer than a step, page_size bounds the page (default 200, max 500). agent_id defaults to the main agent. Live sessions carry in_flight with the current streaming position', + tags: ['history'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const page = await readSessionHistory(deps, session_id, req.query); + reply.send(okEnvelope(page, req.id)); + } catch (error) { + if (error instanceof HistorySessionNotFoundError) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, error.message, req.id, error.stack), + ); + return; + } + requestLog(req)?.error({ err: error }, 'history request failed'); + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + error instanceof Error ? error.message : String(error), + req.id, + error instanceof Error ? error.stack : undefined, + ), + ); + } + }, + ); + app.get(route.path, route.options, route.handler as Parameters[2]); +} diff --git a/packages/kap-server/src/routes/messages.ts b/packages/kap-server/src/routes/messages.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3e3b80202d6d62a794ac0306019d71dde7eb4ff --- /dev/null +++ b/packages/kap-server/src/routes/messages.ts @@ -0,0 +1,138 @@ +import { type Scope } from '@moonshot-ai/agent-core-v2'; +import { ErrorCode } from '../protocol/error-codes'; +import { messageRoleSchema } from '../protocol/message'; +import { getMessageResponseSchema, listMessagesResponseSchema } from '../protocol/rest-message'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { + getMessage, + listMessages, + MessageNotFoundError, + SessionNotFoundError, +} from '../services/messages/messageHistory'; + +interface MessageRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const messagesListQueryCoercion = z + .object({ + before_id: z.string().min(1).optional(), + after_id: z.string().min(1).optional(), + page_size: z.coerce.number().int().min(1).max(100).optional(), + role: messageRoleSchema.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 }, + }); + } + }); + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const messageIdParamSchema = z.object({ + session_id: z.string().min(1), + message_id: z.string().min(1), +}); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +export function registerMessagesRoutes(app: MessageRouteHost, core: Scope): void { + const listRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/messages', + params: sessionIdParamSchema, + querystring: messagesListQueryCoercion, + success: { data: listMessagesResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'List messages for a session', + tags: ['messages'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const page = await listMessages(core, session_id, req.query); + reply.send(okEnvelope(page, req.id)); + } catch (err) { + sendMappedError(reply, req, err); + } + }, + ); + app.get( + listRoute.path, + listRoute.options, + listRoute.handler as Parameters[2], + ); + + const getRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/messages/{message_id}', + params: messageIdParamSchema, + success: { data: getMessageResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.MESSAGE_NOT_FOUND]: {}, + }, + description: 'Get a message by ID', + tags: ['messages'], + }, + async (req, reply) => { + try { + const { session_id, message_id } = req.params; + const message = await getMessage(core, session_id, message_id); + reply.send(okEnvelope(message, req.id)); + } catch (err) { + sendMappedError(reply, req, err); + } + }, + ); + app.get( + getRoute.path, + getRoute.options, + getRoute.handler as Parameters[2], + ); +} + +function sendMappedError( + reply: { send(payload: unknown): unknown }, + req: { id: string }, + err: unknown, +): void { + const requestId = req.id; + const log = requestLog(req); + if (err instanceof SessionNotFoundError) { + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack)); + return; + } + if (err instanceof MessageNotFoundError) { + reply.send(errEnvelope(ErrorCode.MESSAGE_NOT_FOUND, err.message, requestId, err.stack)); + return; + } + log?.error({ err }, 'message request failed'); + reply.send( + errEnvelope(ErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : String(err), requestId, err instanceof Error ? err.stack : undefined), + ); +} diff --git a/packages/kap-server/src/routes/meta.ts b/packages/kap-server/src/routes/meta.ts new file mode 100644 index 0000000000000000000000000000000000000000..539209baa00338a92cd9fec05e9132e4b38eae36 --- /dev/null +++ b/packages/kap-server/src/routes/meta.ts @@ -0,0 +1,64 @@ +import { okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { metaResponseSchema } from '../protocol/rest-meta'; +import type { MetaFeature, MetaResponse } from '../protocol/rest-meta'; + +interface RouteHost { + get( + path: string, + options: { schema?: Record }, + handler: ( + req: { id: string }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; +} + +export interface MetaRouteOptions { + readonly serverVersion: string; + readonly serverId: string; + readonly startedAt: string; + readonly dangerousBypassAuth: boolean; + readonly webTitle?: string; + readonly getExperimentalFlags: () => Record | Promise>; + readonly getFeatures: () => MetaFeature[] | Promise; +} + +export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void { + const staticData = Object.freeze({ + server_version: opts.serverVersion, + capabilities: Object.freeze({ + websocket: true as const, + file_upload: true as const, + fs_query: true as const, + mcp: true as const, + tasks: true as const, + terminal: true as const, + }), + server_id: opts.serverId, + started_at: opts.startedAt, + open_in_apps: [], + dangerous_bypass_auth: opts.dangerousBypassAuth, + backend: 'v2' as const, + web_title: opts.webTitle, + }); + + const route = defineRoute( + { + method: 'GET', + path: '/meta', + success: { data: metaResponseSchema }, + description: 'Get server metadata', + tags: ['meta'], + }, + async (req, reply) => { + const data: MetaResponse = { + ...staticData, + experimental_flags: await opts.getExperimentalFlags(), + features: await opts.getFeatures(), + }; + reply.send(okEnvelope(data, req.id)); + }, + ); + app.get(route.path, route.options, route.handler as Parameters[2]); +} diff --git a/packages/kap-server/src/routes/modelCatalog.ts b/packages/kap-server/src/routes/modelCatalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..531b02c51f0448c599e3615b1fe88c8e082c6c05 --- /dev/null +++ b/packages/kap-server/src/routes/modelCatalog.ts @@ -0,0 +1,853 @@ +import { + IConfigService, + IKosongConfigService, + IModelCatalog, + IOAuthService, + IProviderDiscoveryService, + IModelsDevImportService, + isError2, + ModelsDevImportErrors, + type ModelRecord, + type ModelsSection, + type ProviderConfig, + type ProvidersSection, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { setDefaultModelResponseSchema } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; +import { refreshProviderModelsResponseSchema } from '@moonshot-ai/agent-core-v2/app/kosongConfig/discovery'; +import { + DEFAULT_MODEL_SECTION, + DEFAULT_PROVIDER_SECTION, + MODELS_SECTION, + PROVIDERS_SECTION, +} from '@moonshot-ai/agent-core-v2/app/kosongConfig/configSection'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + createProviderRequestSchema, + createProviderResponseSchema, + getCatalogProviderResponseSchema, + getProviderResponseSchema, + importCatalogProviderResponseSchema, + importCustomRegistryResponseSchema, + listCatalogProvidersResponseSchema, + listModelsResponseSchema, + listProvidersResponseSchema, + providerCollectionActionBodySchema, + replaceProviderRequestSchema, + replaceProviderResponseSchema, + type ProviderCollectionActionBody, +} from '../protocol/rest-modelCatalog'; +import { type ActionTable, runAction } from './action-dispatch'; +import { parseActionSuffix } from './action-suffix'; + +interface ModelCatalogRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + put( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + delete( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +interface StatusReply { + code(status: number): StatusReply; + send(payload?: unknown): unknown; +} + +const providerIdParamSchema = z.object({ + provider_id: z.string().min(1), +}); + +const modelActionTailParamSchema = z.object({ + tail: z.string().min(1), +}); + +const providerActionTailParamSchema = z.object({ + tail: z.string().min(1), +}); + +const providerCollectionActionParamSchema = z.object({ + action: z.string().min(1), +}); + +const catalogIdParamSchema = z.object({ + catalog_id: z.string().min(1), +}); + +async function loadCatalog(core: Scope): Promise { + await core.accessor.get(IConfigService).ready; + return core.accessor.get(IModelCatalog); +} + +async function loadConfig(core: Scope): Promise { + const config = core.accessor.get(IConfigService); + await config.ready; + await core.accessor.get(IKosongConfigService).ready; + return config; +} + +async function loadDiscovery(core: Scope): Promise { + await core.accessor.get(IConfigService).ready; + return core.accessor.get(IProviderDiscoveryService); +} + +async function loadOAuth(core: Scope): Promise { + await core.accessor.get(IConfigService).ready; + return core.accessor.get(IOAuthService); +} + +let providerWriteChain: Promise = Promise.resolve(); + +function enqueueProviderWrite(task: () => Promise): Promise { + const run = providerWriteChain.then(task, task); + providerWriteChain = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +async function seedDefaultModelWhenUnset(config: IConfigService, alias: string): Promise { + const current = config.inspect(DEFAULT_MODEL_SECTION).userValue; + if (current !== undefined && current.trim() !== '') return; + await config.replace(DEFAULT_MODEL_SECTION, alias); +} + +export function registerModelCatalogRoutes(app: ModelCatalogRouteHost, core: Scope): void { + const listModelsRoute = defineRoute( + { + method: 'GET', + path: '/models', + success: { data: listModelsResponseSchema }, + description: 'List configured model aliases', + tags: ['models'], + }, + async (req, reply) => { + const items = await (await loadCatalog(core)).listModels(); + reply.send(okEnvelope({ items }, req.id)); + }, + ); + app.get( + listModelsRoute.path, + listModelsRoute.options, + listModelsRoute.handler as Parameters[2], + ); + + const setDefaultModelRoute = defineRoute( + { + method: 'POST', + path: '/models/{tail}', + params: modelActionTailParamSchema, + success: { data: setDefaultModelResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.MODEL_NOT_FOUND]: {}, + }, + description: 'Set the global default model alias', + tags: ['models'], + operationId: 'setDefaultModel', + }, + async (req, reply) => { + try { + const { tail } = req.params; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['set_default'] as const, + resourceLabel: 'model', + }); + if (parsed.kind !== 'action') { + const message = + parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${tail}`; + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); + return; + } + const result = await (await loadCatalog(core)).setDefaultModel(parsed.id); + reply.send(okEnvelope(result, req.id)); + } catch (err) { + if (sendMappedError(reply, req.id, err)) return; + throw err; + } + }, + ); + app.post( + setDefaultModelRoute.path, + setDefaultModelRoute.options, + setDefaultModelRoute.handler as Parameters[2], + ); + + const listProvidersRoute = defineRoute( + { + method: 'GET', + path: '/providers', + success: { data: listProvidersResponseSchema }, + description: 'List configured providers', + tags: ['providers'], + }, + async (req, reply) => { + const items = await (await loadCatalog(core)).listProviders(); + reply.send(okEnvelope({ items }, req.id)); + }, + ); + app.get( + listProvidersRoute.path, + listProvidersRoute.options, + listProvidersRoute.handler as Parameters[2], + ); + + const createProviderRoute = defineRoute( + { + method: 'POST', + path: '/providers', + body: createProviderRequestSchema, + success: { data: createProviderResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.PROVIDER_ALREADY_EXISTS]: {}, + }, + description: + 'Create a provider manually (type + credentials + model list). When no global default_model is configured (fresh setup), it is seeded with the new provider default (or first) model; an existing default is never modified.', + tags: ['providers'], + operationId: 'createProvider', + }, + async (req, reply) => { + await enqueueProviderWrite(async () => { + const config = await loadConfig(core); + const { id } = req.body; + const providers = config.inspect(PROVIDERS_SECTION).userValue ?? {}; + if (providers[id] !== undefined) { + reply.send( + errEnvelope( + ErrorCode.PROVIDER_ALREADY_EXISTS, + `provider ${id} already exists`, + req.id, + ), + ); + return; + } + + const provider: ProviderConfig = { type: req.body.type }; + if (req.body.api_key !== undefined) provider.apiKey = req.body.api_key; + if (req.body.base_url !== undefined) provider.baseUrl = req.body.base_url; + if (req.body.default_model !== undefined) { + provider.defaultModel = `${id}/${req.body.default_model}`; + } + await config.set(PROVIDERS_SECTION, { [id]: provider }); + + const aliases: Record = {}; + for (const entry of req.body.models) { + const alias: ModelRecord = { + provider: id, + model: entry.model, + maxContextSize: entry.max_context_size, + }; + if (entry.display_name !== undefined) alias.displayName = entry.display_name; + if (entry.capabilities !== undefined) alias.capabilities = [...entry.capabilities]; + if (entry.max_output_size !== undefined) alias.maxOutputSize = entry.max_output_size; + if (entry.support_efforts !== undefined) + alias.supportEfforts = [...entry.support_efforts]; + if (entry.adaptive_thinking !== undefined) + alias.adaptiveThinking = entry.adaptive_thinking; + aliases[`${id}/${entry.model}`] = alias; + } + await config.set(MODELS_SECTION, aliases); + + const firstModel = req.body.models[0]; + if (firstModel !== undefined) { + await seedDefaultModelWhenUnset( + config, + provider.defaultModel ?? `${id}/${firstModel.model}`, + ); + } + + const created = await core.accessor.get(IModelCatalog).getProvider(id); + (reply as unknown as StatusReply).code(201).send(okEnvelope(created, req.id)); + }); + }, + ); + app.post( + createProviderRoute.path, + createProviderRoute.options, + createProviderRoute.handler as Parameters[2], + ); + + const replaceProviderRoute = defineRoute( + { + method: 'PUT', + path: '/providers/{provider_id}', + params: providerIdParamSchema, + body: replaceProviderRequestSchema, + success: { data: replaceProviderResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.PROVIDER_OAUTH_MANAGED]: {}, + [ErrorCode.PROVIDER_NOT_FOUND]: {}, + [ErrorCode.PROVIDER_ALREADY_EXISTS]: {}, + }, + description: + 'Replace a provider in one save (type + base_url + model list), optionally renaming it via `new_id` (the providers key, model aliases, default_provider and a default_model pointing at an old alias all migrate). `api_key` is tri-state: omitted keeps the stored key, "" clears it, any other value replaces it. The provider\'s model aliases are rebuilt from `models` — aliases no longer listed disappear from config.toml, other providers\' aliases are untouched. Beyond the rename migration, the global default pointers are never modified. Answers 200 with `{provider}`. OAuth-managed providers are rejected: log out via /oauth/logout instead.', + tags: ['providers'], + operationId: 'replaceProvider', + }, + async (req, reply) => { + await enqueueProviderWrite(async () => { + const config = await loadConfig(core); + const { provider_id } = req.params; + const providers = config.inspect(PROVIDERS_SECTION).userValue ?? {}; + const target = providers[provider_id]; + if (target === undefined) { + reply.send( + errEnvelope( + ErrorCode.PROVIDER_NOT_FOUND, + `provider ${provider_id} does not exist`, + req.id, + ), + ); + return; + } + if (target.oauth !== undefined) { + reply.send( + errEnvelope( + ErrorCode.PROVIDER_OAUTH_MANAGED, + `provider ${provider_id} is managed by OAuth login; use POST /oauth/logout instead`, + req.id, + ), + ); + return; + } + + const newId = req.body.new_id ?? provider_id; + if (newId !== provider_id && providers[newId] !== undefined) { + reply.send( + errEnvelope( + ErrorCode.PROVIDER_ALREADY_EXISTS, + `provider ${newId} already exists`, + req.id, + ), + ); + return; + } + + const provider: ProviderConfig = { ...target, type: req.body.type }; + provider.apiKey = req.body.api_key ?? target.apiKey; + provider.baseUrl = req.body.base_url; + provider.defaultModel = + req.body.default_model !== undefined + ? + `${newId}/${req.body.default_model}` + : undefined; + const nextProviders = Object.fromEntries( + Object.entries(providers).map(([key, value]) => [ + key === provider_id ? newId : key, + value, + ]), + ); + nextProviders[newId] = provider; + + const models = config.inspect(MODELS_SECTION).userValue ?? {}; + const newAliasKeys = new Set(req.body.models.map((entry) => `${newId}/${entry.model}`)); + const colliding = Object.entries(models) + .filter(([, record]) => record.provider !== provider_id) + .map(([aliasId]) => aliasId) + .filter((aliasId) => newAliasKeys.has(aliasId)); + if (colliding.length > 0) { + reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + `model alias key already owned by another provider: ${colliding.join(', ')}`, + req.id, + ), + ); + return; + } + + await config.replace(PROVIDERS_SECTION, nextProviders); + + const previousAliasIds = new Set( + Object.entries(models) + .filter(([, record]) => record.provider === provider_id) + .map(([aliasId]) => aliasId), + ); + const nextModels = Object.fromEntries( + Object.entries(models).filter(([, record]) => record.provider !== provider_id), + ); + const previousByModel = new Map( + Object.values(models) + .filter((record) => record.provider === provider_id && record.model !== undefined) + .map((record) => [record.model as string, record] as const), + ); + for (const entry of req.body.models) { + const alias: ModelRecord = { + ...previousByModel.get(entry.model), + provider: newId, + model: entry.model, + maxContextSize: entry.max_context_size, + }; + alias.displayName = entry.display_name !== undefined ? entry.display_name : undefined; + alias.capabilities = + entry.capabilities !== undefined ? [...entry.capabilities] : undefined; + alias.maxOutputSize = entry.max_output_size !== undefined ? entry.max_output_size : undefined; + alias.supportEfforts = + entry.support_efforts !== undefined ? [...entry.support_efforts] : undefined; + alias.adaptiveThinking = + entry.adaptive_thinking !== undefined ? entry.adaptive_thinking : undefined; + nextModels[`${newId}/${entry.model}`] = alias; + } + await config.replace(MODELS_SECTION, nextModels); + + if (newId !== provider_id) { + const defaultProvider = config.inspect(DEFAULT_PROVIDER_SECTION).userValue; + if (defaultProvider === provider_id) { + await config.replace(DEFAULT_PROVIDER_SECTION, newId); + } + const defaultModel = config.inspect(DEFAULT_MODEL_SECTION).userValue; + if (defaultModel !== undefined && previousAliasIds.has(defaultModel)) { + const renamedModel = models[defaultModel]?.model; + const renamedAlias = renamedModel !== undefined ? `${newId}/${renamedModel}` : undefined; + if (renamedAlias !== undefined && nextModels[renamedAlias] !== undefined) { + await config.replace(DEFAULT_MODEL_SECTION, renamedAlias); + } + } + } + + const saved = await core.accessor.get(IModelCatalog).getProvider(newId); + reply.send(okEnvelope({ provider: saved }, req.id)); + }); + }, + ); + app.put( + replaceProviderRoute.path, + replaceProviderRoute.options, + replaceProviderRoute.handler as Parameters[2], + ); + + const refreshProvidersRoute = defineRoute( + { + method: 'POST', + path: '/providers:action', + params: providerCollectionActionParamSchema, + body: providerCollectionActionBodySchema.optional(), + success: { + data: z.union([ + refreshProviderModelsResponseSchema, + importCatalogProviderResponseSchema, + importCustomRegistryResponseSchema, + ]), + }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.CATALOG_IMPORT_INVALID]: {}, + [ErrorCode.REGISTRY_IMPORT_INVALID]: {}, + [ErrorCode.PROVIDER_OAUTH_MANAGED]: {}, + [ErrorCode.CATALOG_ENTRY_NOT_FOUND]: {}, + [ErrorCode.CATALOG_UNAVAILABLE]: {}, + }, + description: + 'Provider collection actions. Use `:refresh` for all providers or `:refresh_oauth` for OAuth-backed providers only. Use `:import_catalog` to import a models.dev directory entry as a configured provider (201): the wire protocol and endpoint come from the catalog resolution (`base_url` overrides it; required when the entry resolves to needs-base-url), all catalogued models are written as aliases, and importing an id that already exists is a refresh — the provider entry and its aliases are rewritten from the catalog (OAuth-managed providers are rejected instead). `id` overrides the catalog id as the local provider id. Use `:import_registry` to import a models.dev-shaped private registry (api.json `url` + optional Bearer `api_key`, 201): every listed provider is written with a `source` blob so scheduled refreshes rediscover it, and re-importing the same URL removes providers that disappeared upstream (the URL is the stable registry identity). For both imports the global default_provider/default_model pointers are never modified — except that a default_model is seeded from the first imported model when none is configured at all (fresh setup).', + tags: ['providers'], + operationId: 'providerCollectionAction', + }, + async (req, reply) => { + const raw = req.params.action; + const action = raw.startsWith(':') ? raw.slice(1) : raw; + const handled = await runAction({ + action, + id: '', + actions: providerCollectionActions, + extra: { core, req, reply }, + }); + if (!handled) { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, `unsupported action: ${raw}`, req.id)); + } + }, + ); + app.post( + refreshProvidersRoute.path, + refreshProvidersRoute.options, + refreshProvidersRoute.handler as Parameters[2], + ); + + const refreshProviderRoute = defineRoute( + { + method: 'POST', + path: '/providers/{tail}', + params: providerActionTailParamSchema, + success: { data: refreshProviderModelsResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.PROVIDER_NOT_FOUND]: {}, + }, + description: 'Refresh model metadata for a single provider', + tags: ['providers'], + operationId: 'refreshProvider', + }, + async (req, reply) => { + try { + const { tail } = req.params; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['refresh'] as const, + resourceLabel: 'provider', + }); + if (parsed.kind !== 'action') { + const message = + parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${tail}`; + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); + return; + } + const result = await (await loadDiscovery(core)).refreshProviderModels({ + providerId: parsed.id, + }); + reply.send(okEnvelope(result, req.id)); + } catch (err) { + if (sendMappedError(reply, req.id, err)) return; + throw err; + } + }, + ); + app.post( + refreshProviderRoute.path, + refreshProviderRoute.options, + refreshProviderRoute.handler as Parameters[2], + ); + + const getProviderRoute = defineRoute( + { + method: 'GET', + path: '/providers/{provider_id}', + params: providerIdParamSchema, + success: { data: getProviderResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.PROVIDER_NOT_FOUND]: {}, + }, + description: + 'Get a configured provider by ID. Unlike the list route, the response reveals the stored `api_key` when one is set, so local clients can prefill an edit form.', + tags: ['providers'], + }, + async (req, reply) => { + try { + const { provider_id } = req.params; + const provider = await (await loadCatalog(core)).getProvider(provider_id); + const config = await loadConfig(core); + const stored = config.inspect(PROVIDERS_SECTION).userValue?.[provider_id]; + const apiKey = stored?.apiKey; + reply.send( + okEnvelope( + apiKey !== undefined && apiKey !== '' ? { ...provider, api_key: apiKey } : provider, + req.id, + ), + ); + } catch (err) { + if (sendMappedError(reply, req.id, err)) return; + throw err; + } + }, + ); + app.get( + getProviderRoute.path, + getProviderRoute.options, + getProviderRoute.handler as Parameters[2], + ); + + const deleteProviderRoute = defineRoute( + { + method: 'DELETE', + path: '/providers/{provider_id}', + params: providerIdParamSchema, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.PROVIDER_OAUTH_MANAGED]: {}, + [ErrorCode.PROVIDER_NOT_FOUND]: {}, + }, + rawResponse: { + 204: { description: 'Provider deleted.' }, + }, + description: + 'Delete a provider and all of its model aliases (204, no body). The global default_provider/default_model pointers are left untouched — they are the user\'s settings, not this endpoint\'s to garbage-collect. OAuth-managed providers are rejected: log out via /oauth/logout instead.', + tags: ['providers'], + operationId: 'deleteProvider', + }, + async (req, reply) => { + await enqueueProviderWrite(async () => { + const config = await loadConfig(core); + const { provider_id } = req.params; + const providers = config.inspect(PROVIDERS_SECTION).userValue ?? {}; + const target = providers[provider_id]; + if (target === undefined) { + reply.send( + errEnvelope( + ErrorCode.PROVIDER_NOT_FOUND, + `provider ${provider_id} does not exist`, + req.id, + ), + ); + return; + } + if (target.oauth !== undefined) { + reply.send( + errEnvelope( + ErrorCode.PROVIDER_OAUTH_MANAGED, + `provider ${provider_id} is managed by OAuth login; use POST /oauth/logout instead`, + req.id, + ), + ); + return; + } + + const models = config.inspect(MODELS_SECTION).userValue ?? {}; + const restProviders = { ...providers }; + delete restProviders[provider_id]; + await config.replace(PROVIDERS_SECTION, restProviders); + const restModels = Object.fromEntries( + Object.entries(models).filter(([, record]) => record.provider !== provider_id), + ); + if (Object.keys(restModels).length !== Object.keys(models).length) { + await config.replace(MODELS_SECTION, restModels); + } + (reply as unknown as StatusReply).code(204).send(); + }); + }, + ); + app.delete( + deleteProviderRoute.path, + deleteProviderRoute.options, + deleteProviderRoute.handler as Parameters[2], + ); + + const listCatalogProvidersRoute = defineRoute( + { + method: 'GET', + path: '/catalog/providers', + success: { data: listCatalogProvidersResponseSchema }, + errors: { [ErrorCode.CATALOG_UNAVAILABLE]: {} }, + description: + 'Browse the models.dev directory (server-proxied, 10-minute in-memory cache, built-in snapshot fallback). Entries the server cannot import carry `rejected: true` with a machine-readable `reject_reason`; entries with `needs_base_url: true` require a base URL at import time. Items keep the upstream directory order.', + tags: ['providers'], + operationId: 'listCatalogProviders', + }, + async (req, reply) => { + try { + const items = await core.accessor.get(IModelsDevImportService).listModelsDevProviders(); + reply.send(okEnvelope({ items }, req.id)); + } catch (err) { + if (sendModelsDevImportError(reply, req.id, err)) return; + throw err; + } + }, + ); + app.get( + listCatalogProvidersRoute.path, + listCatalogProvidersRoute.options, + listCatalogProvidersRoute.handler as Parameters[2], + ); + + const getCatalogProviderRoute = defineRoute( + { + method: 'GET', + path: '/catalog/providers/{catalog_id}', + params: catalogIdParamSchema, + success: { data: getCatalogProviderResponseSchema }, + errors: { + [ErrorCode.CATALOG_ENTRY_NOT_FOUND]: {}, + [ErrorCode.CATALOG_UNAVAILABLE]: {}, + }, + description: 'Get one models.dev directory entry by catalog id.', + tags: ['providers'], + operationId: 'getCatalogProvider', + }, + async (req, reply) => { + try { + const { catalog_id } = req.params; + const item = await core.accessor.get(IModelsDevImportService).getModelsDevProvider(catalog_id); + reply.send(okEnvelope(item, req.id)); + } catch (err) { + if (sendModelsDevImportError(reply, req.id, err)) return; + throw err; + } + }, + ); + app.get( + getCatalogProviderRoute.path, + getCatalogProviderRoute.options, + getCatalogProviderRoute.handler as Parameters[2], + ); +} + +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): boolean { + if (!isError2(err)) return false; + if (err.code === 'provider.not_found') { + reply.send(errEnvelope(ErrorCode.PROVIDER_NOT_FOUND, err.message, requestId, err.stack)); + return true; + } + if (err.code === 'model.not_found') { + reply.send(errEnvelope(ErrorCode.MODEL_NOT_FOUND, err.message, requestId, err.stack)); + return true; + } + return false; +} + +const MODELS_DEV_IMPORT_ERROR_CODES: Record = { + [ModelsDevImportErrors.codes.CATALOG_UNAVAILABLE]: ErrorCode.CATALOG_UNAVAILABLE, + [ModelsDevImportErrors.codes.CATALOG_ENTRY_NOT_FOUND]: ErrorCode.CATALOG_ENTRY_NOT_FOUND, + [ModelsDevImportErrors.codes.CATALOG_IMPORT_INVALID]: ErrorCode.CATALOG_IMPORT_INVALID, + [ModelsDevImportErrors.codes.REGISTRY_IMPORT_INVALID]: ErrorCode.REGISTRY_IMPORT_INVALID, + [ModelsDevImportErrors.codes.PROVIDER_OAUTH_MANAGED]: ErrorCode.PROVIDER_OAUTH_MANAGED, +}; + +function sendModelsDevImportError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): boolean { + if (!isError2(err)) return false; + const numeric = MODELS_DEV_IMPORT_ERROR_CODES[err.code]; + if (numeric === undefined) return false; + reply.send(errEnvelope(numeric, err.message, requestId, err.stack)); + return true; +} + +async function handleImportCatalog( + req: { id: string; body: ProviderCollectionActionBody | undefined }, + reply: { send(payload: unknown): unknown }, + core: Scope, +): Promise { + try { + const body = req.body; + if (body?.catalog_id === undefined) { + reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + 'catalog_id is required for :import_catalog', + req.id, + ), + ); + return; + } + + const result = await core.accessor.get(IModelsDevImportService).importModelsDevProvider({ + catalogId: body.catalog_id, + id: body.id, + apiKey: body.api_key, + baseUrl: body.base_url, + }); + (reply as unknown as StatusReply) + .code(201) + .send( + okEnvelope( + { provider: result.provider, models_imported: result.modelsImported }, + req.id, + ), + ); + } catch (err) { + if (sendModelsDevImportError(reply, req.id, err)) return; + throw err; + } +} + +async function handleImportRegistry( + req: { id: string; body: ProviderCollectionActionBody | undefined }, + reply: { send(payload: unknown): unknown }, + core: Scope, +): Promise { + try { + const body = req.body; + if (body?.url === undefined) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, 'url is required for :import_registry', req.id), + ); + return; + } + const result = await core.accessor.get(IModelsDevImportService).importCustomRegistry({ + url: body.url, + apiKey: body.api_key, + }); + (reply as unknown as StatusReply) + .code(201) + .send( + okEnvelope( + { providers: result.providers, models_imported: result.modelsImported }, + req.id, + ), + ); + } catch (err) { + if (sendModelsDevImportError(reply, req.id, err)) return; + throw err; + } +} + +type ProviderCollectionActionExtra = { + readonly core: Scope; + readonly req: { + readonly id: string; + readonly body: ProviderCollectionActionBody | undefined; + }; + readonly reply: { readonly send: (payload: unknown) => unknown }; +}; + +type ProviderCollectionActionCtx = ProviderCollectionActionExtra & { + readonly id: string; + readonly body: unknown; +}; + +const providerCollectionActions: ActionTable< + 'refresh_oauth' | 'refresh' | 'import_catalog' | 'import_registry', + ProviderCollectionActionExtra +> = { + refresh_oauth: { handle: refreshOAuthProvidersAction }, + refresh: { handle: refreshProvidersAction }, + import_catalog: { handle: importCatalogProviderAction }, + import_registry: { handle: importRegistryProviderAction }, +}; + +async function refreshOAuthProvidersAction(ctx: ProviderCollectionActionCtx): Promise { + const result = await (await loadOAuth(ctx.core)).refreshOAuthProviderModels(); + ctx.reply.send(okEnvelope(result, ctx.req.id)); +} + +async function refreshProvidersAction(ctx: ProviderCollectionActionCtx): Promise { + const result = await (await loadDiscovery(ctx.core)).refreshProviderModels({ scope: 'all' }); + ctx.reply.send(okEnvelope(result, ctx.req.id)); +} + +async function importCatalogProviderAction(ctx: ProviderCollectionActionCtx): Promise { + await enqueueProviderWrite(() => handleImportCatalog(ctx.req, ctx.reply, ctx.core)); +} + +async function importRegistryProviderAction(ctx: ProviderCollectionActionCtx): Promise { + await enqueueProviderWrite(() => handleImportRegistry(ctx.req, ctx.reply, ctx.core)); +} + diff --git a/packages/kap-server/src/routes/oauth.ts b/packages/kap-server/src/routes/oauth.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9d053ca32bee7aff66d509f26fceabd9af1b30e --- /dev/null +++ b/packages/kap-server/src/routes/oauth.ts @@ -0,0 +1,201 @@ +import { IOAuthService, type Scope } from '@moonshot-ai/agent-core-v2'; +import { + managedUsageResultSchema, + managedUserInfoResultSchema, + oauthFlowSnapshotSchema, + oauthFlowStartSchema, + oauthLoginCancelResponseSchema, + oauthLogoutResponseSchema, + oauthRegionResultSchema, +} from '@moonshot-ai/agent-core-v2/app/auth/oauthProtocol'; +import { z } from 'zod'; + +import { okEnvelope } from '../envelope'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { + oauthLoginQuerySchema, + oauthLoginStartRequestSchema, + oauthLogoutRequestSchema, +} from '../protocol/rest-oauth'; + +interface RouteHost { + get( + path: string, + options: { preHandler?: unknown[]; schema?: Record }, + handler: ( + req: { id: string; query: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler?: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; + delete( + path: string, + options: { preHandler?: unknown[]; schema?: Record }, + handler: ( + req: { id: string; query: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; +} + +const oauthFlowSnapshotOrNullSchema = z.union([ + oauthFlowSnapshotSchema, + z.null(), +]); + +export function registerOAuthRoutes(app: RouteHost, core: Scope): void { + const loginStartRoute = defineRoute( + { + method: 'POST', + path: '/oauth/login', + body: oauthLoginStartRequestSchema, + success: { data: oauthFlowStartSchema }, + description: 'Start an OAuth device-code flow', + tags: ['auth'], + }, + async (req, reply) => { + const result = await core.accessor + .get(IOAuthService) + .startLogin(req.body.provider, { region: req.body.region }); + requestLog(req)?.info({ provider: req.body.provider, action: 'login' }, 'oauth login started'); + reply.send(okEnvelope(result, req.id)); + }, + ); + app.post( + loginStartRoute.path, + loginStartRoute.options, + loginStartRoute.handler as Parameters[2], + ); + + const loginPollRoute = defineRoute( + { + method: 'GET', + path: '/oauth/login', + querystring: oauthLoginQuerySchema, + success: { data: oauthFlowSnapshotOrNullSchema }, + description: 'Poll the current OAuth device-code flow', + tags: ['auth'], + }, + async (req, reply) => { + const snapshot = core.accessor.get(IOAuthService).getFlow(req.query.provider); + reply.send(okEnvelope(snapshot ?? null, req.id)); + }, + ); + app.get( + loginPollRoute.path, + loginPollRoute.options, + loginPollRoute.handler as Parameters[2], + ); + + const loginCancelRoute = defineRoute( + { + method: 'DELETE', + path: '/oauth/login', + querystring: oauthLoginQuerySchema, + success: { data: oauthLoginCancelResponseSchema }, + description: 'Cancel the current OAuth device-code flow', + tags: ['auth'], + }, + async (req, reply) => { + const result = await core.accessor.get(IOAuthService).cancelLogin(req.query.provider); + requestLog(req)?.info( + { provider: req.query.provider, action: 'cancel_login' }, + 'oauth login cancelled', + ); + reply.send(okEnvelope(result, req.id)); + }, + ); + app.delete( + loginCancelRoute.path, + loginCancelRoute.options, + loginCancelRoute.handler as Parameters[2], + ); + + const logoutRoute = defineRoute( + { + method: 'POST', + path: '/oauth/logout', + body: oauthLogoutRequestSchema, + success: { data: oauthLogoutResponseSchema }, + description: 'Logout the managed OAuth provider', + tags: ['auth'], + }, + async (req, reply) => { + const result = await core.accessor.get(IOAuthService).logout(req.body.provider); + requestLog(req)?.info({ provider: req.body.provider, action: 'logout' }, 'oauth logout'); + reply.send(okEnvelope(result, req.id)); + }, + ); + app.post( + logoutRoute.path, + logoutRoute.options, + logoutRoute.handler as Parameters[2], + ); + + const usageRoute = defineRoute( + { + method: 'GET', + path: '/oauth/usage', + querystring: oauthLoginQuerySchema, + success: { data: managedUsageResultSchema }, + description: 'Get the managed account usage summary', + tags: ['auth'], + }, + async (req, reply) => { + const result = await core.accessor.get(IOAuthService).getManagedUsage(req.query.provider); + reply.send(okEnvelope(result, req.id)); + }, + ); + app.get( + usageRoute.path, + usageRoute.options, + usageRoute.handler as Parameters[2], + ); + + const userInfoRoute = defineRoute( + { + method: 'GET', + path: '/oauth/userinfo', + querystring: oauthLoginQuerySchema, + success: { data: managedUserInfoResultSchema }, + description: 'Get the managed account profile', + tags: ['auth'], + }, + async (req, reply) => { + const result = await core.accessor.get(IOAuthService).getManagedUserInfo(req.query.provider); + reply.send(okEnvelope(result, req.id)); + }, + ); + app.get( + userInfoRoute.path, + userInfoRoute.options, + userInfoRoute.handler as Parameters[2], + ); + + const regionRoute = defineRoute( + { + method: 'GET', + path: '/oauth/region', + success: { data: oauthRegionResultSchema }, + description: 'Resolve the client region (mainland-cn/global)', + tags: ['auth'], + }, + async (req, reply) => { + const region = core.accessor.get(IOAuthService).getRegion(); + reply.send(okEnvelope({ region }, req.id)); + }, + ); + app.get( + regionRoute.path, + regionRoute.options, + regionRoute.handler as Parameters[2], + ); +} diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts new file mode 100644 index 0000000000000000000000000000000000000000..1345004ffa857c562fd8a0270617cfefbbf21187 --- /dev/null +++ b/packages/kap-server/src/routes/plugins.ts @@ -0,0 +1,343 @@ +import { stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { + computeUpdateStatus, + ErrorCodes as DomainErrorCodes, + ICapabilityService, + IPluginService, + PluginErrors, + isError2, + parsePluginMarketplace, + readPluginMarketplace, + withLatestVersions, + type MarketplaceLocation, + type PluginMarketplace, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + installPluginRequestSchema, + listPluginsResponseSchema, + pluginMarketplaceResponseSchema, + pluginIdParamSchema, + pluginSummarySchema, + type PluginMarketplaceEntryWire, +} from '../protocol/rest-plugin'; +import { type ActionTable, dispatchAction } from './action-dispatch'; + +interface PluginsRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const pluginActions: ActionTable<'enable' | 'disable' | 'remove', PluginActionExtra> = { + enable: { handle: enablePluginAction }, + disable: { handle: disablePluginAction }, + remove: { handle: removePluginAction }, +}; + +type PluginActionExtra = { + readonly plugins: IPluginService; +}; + +type PluginActionCtx = PluginActionExtra & { readonly id: string; readonly body: unknown }; + +async function enablePluginAction(ctx: PluginActionCtx): Promise { + await ctx.plugins.setPluginEnabled({ id: ctx.id, enabled: true }); +} + +async function disablePluginAction(ctx: PluginActionCtx): Promise { + await ctx.plugins.setPluginEnabled({ id: ctx.id, enabled: false }); +} + +async function removePluginAction(ctx: PluginActionCtx): Promise { + await ctx.plugins.removePlugin({ id: ctx.id }); +} + +const CAPABILITY_ROW_IDS: Readonly< + Record +> = { + 'kimi-cu': { capabilityId: 'kimi-cu', wiringPluginIds: ['kimi-cu', 'kimi-cu-win'] }, + 'kimi-cu-win': { capabilityId: 'kimi-cu', wiringPluginIds: ['kimi-cu', 'kimi-cu-win'] }, + 'kimi-webbridge': { capabilityId: 'kimi-webbridge', wiringPluginIds: ['kimi-webbridge'] }, +}; + +function orderedWiringPluginIds(ids: readonly string[]): readonly string[] { + if (process.platform === 'win32' && process.arch === 'x64' && ids.includes('kimi-cu-win')) { + return ['kimi-cu-win', ...ids.filter((id) => id !== 'kimi-cu-win')]; + } + return ids; +} + +const MARKETPLACE_FETCH_TIMEOUT_MS = 10_000; + +function fetchWithTimeout(...args: Parameters): Promise { + const [input, init] = args; + return fetch(input, { ...init, signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS) }); +} + +async function getSourceCheckoutLocation(): Promise { + const candidate = resolve(import.meta.dirname, '../../../../plugins/marketplace.json'); + const info = await stat(candidate).catch(() => undefined); + if (info?.isFile() !== true) return undefined; + return { raw: candidate, kind: 'local', resolved: candidate }; +} + +export interface PluginsRouteOptions { + readonly marketplaceUrl: () => string; + readonly marketplaceIsDefault?: boolean; + readonly fetchImpl?: typeof fetch; +} + +export function registerPluginsRoutes( + app: PluginsRouteHost, + core: Scope, + opts: PluginsRouteOptions, +): void { + const marketplaceRoute = defineRoute( + { + method: 'GET', + path: '/plugins/marketplace', + success: { data: pluginMarketplaceResponseSchema }, + errors: {}, + description: 'List the plugin marketplace catalog merged with live install state', + tags: ['plugins'], + operationId: 'listPluginMarketplace', + }, + async (req, reply) => { + const fetchImpl = opts.fetchImpl ?? fetchWithTimeout; + let read: { raw: string; location: MarketplaceLocation }; + try { + read = await readPluginMarketplace({ + source: opts.marketplaceUrl(), + workDir: process.cwd(), + fetchImpl, + sourceCheckoutLocation: + opts.marketplaceIsDefault === true ? getSourceCheckoutLocation : undefined, + }); + } catch (error) { + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + `Plugin marketplace is unreachable: ${error instanceof Error ? error.message : String(error)}`, + req.id, + ), + ); + return; + } + let marketplace: PluginMarketplace; + try { + marketplace = parsePluginMarketplace(read.raw, read.location); + } catch (error) { + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + `Plugin marketplace returned an invalid catalog: ${error instanceof Error ? error.message : String(error)}`, + req.id, + ), + ); + return; + } + if (opts.marketplaceIsDefault === true) { + const presentIds = new Set(marketplace.plugins.map((entry) => entry.id)); + const missing = core.accessor + .get(ICapabilityService) + .describeCapabilities() + .filter((descriptor) => descriptor.supported && !presentIds.has(descriptor.id)) + .map((descriptor) => ({ + id: descriptor.id, + tier: 'official' as const, + displayName: descriptor.displayName, + description: descriptor.description, + source: `capability:${descriptor.id}`, + })); + if (missing.length > 0) { + marketplace = { ...marketplace, plugins: [...marketplace.plugins, ...missing] }; + } + } + marketplace = await withLatestVersions(marketplace, fetchImpl); + const installed = await core.accessor.get(IPluginService).listPlugins(); + const byId = new Map(installed.map((p) => [p.id, p])); + const supportedCapabilityIds = new Set( + core.accessor + .get(ICapabilityService) + .describeCapabilities() + .filter((descriptor) => descriptor.supported) + .map((descriptor) => descriptor.id), + ); + const entries: PluginMarketplaceEntryWire[] = []; + for (const entry of marketplace.plugins) { + const capabilityRow = + opts.marketplaceIsDefault === true ? CAPABILITY_ROW_IDS[entry.id] : undefined; + if ( + capabilityRow !== undefined && + !supportedCapabilityIds.has(capabilityRow.capabilityId) + ) { + continue; + } + + const record = + capabilityRow !== undefined + ? (orderedWiringPluginIds(capabilityRow.wiringPluginIds) + .map((id) => byId.get(id)) + .find((candidate) => candidate !== undefined) ?? byId.get(entry.id)) + : byId.get(entry.id); + const installedInfo = + record === undefined + ? undefined + : { enabled: record.enabled, version: record.version }; + const updateAvailable = + computeUpdateStatus(entry.version, record?.version, record !== undefined).kind === + 'update'; + entries.push({ + id: entry.id, + tier: entry.tier ?? 'third-party', + displayName: entry.displayName, + description: entry.description, + homepage: entry.homepage, + keywords: entry.keywords === undefined ? undefined : [...entry.keywords], + version: entry.version, + source: entry.source, + installed: installedInfo, + updateAvailable: updateAvailable ? true : undefined, + capabilityId: capabilityRow?.capabilityId, + }); + } + reply.send(okEnvelope({ entries }, req.id)); + }, + ); + app.get( + marketplaceRoute.path, + marketplaceRoute.options, + marketplaceRoute.handler as Parameters[2], + ); + + const listRoute = defineRoute( + { + method: 'GET', + path: '/plugins', + success: { data: listPluginsResponseSchema }, + errors: {}, + description: 'List installed plugins', + tags: ['plugins'], + operationId: 'listPlugins', + }, + async (req, reply) => { + const plugins = await core.accessor.get(IPluginService).listPlugins(); + reply.send(okEnvelope({ plugins }, req.id)); + }, + ); + app.get( + listRoute.path, + listRoute.options, + listRoute.handler as Parameters[2], + ); + + const installRoute = defineRoute( + { + method: 'POST', + path: '/plugins', + body: installPluginRequestSchema, + success: { data: pluginSummarySchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + }, + description: 'Install a plugin from a local path, zip URL, or GitHub repo', + tags: ['plugins'], + operationId: 'installPlugin', + }, + async (req, reply) => { + try { + const plugin = await core.accessor.get(IPluginService).installPlugin(req.body); + reply.send(okEnvelope(plugin, req.id)); + } catch (error) { + reply.send(mapPluginError(error, req.id)); + } + }, + ); + app.post( + installRoute.path, + installRoute.options, + installRoute.handler as Parameters[2], + ); + + const actionRoute = defineRoute( + { + method: 'POST', + path: '/plugins/{tail}', + params: pluginIdParamSchema, + success: { data: z.object({ ok: z.literal(true) }) }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.PLUGIN_NOT_FOUND]: {}, + }, + description: 'Enable, disable, or remove an installed plugin', + tags: ['plugins'], + operationId: 'pluginAction', + }, + async (req, reply) => { + const plugins = core.accessor.get(IPluginService); + try { + const handled = await dispatchAction({ + tail: req.params.tail, + actions: pluginActions, + resourceLabel: 'plugin', + extra: { plugins }, + onUnsupported: (message) => { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); + }, + }); + if (handled) { + reply.send(okEnvelope({ ok: true as const }, req.id)); + } + } catch (error) { + reply.send(mapPluginError(error, req.id)); + } + }, + ); + app.post( + actionRoute.path, + actionRoute.options, + actionRoute.handler as Parameters[2], + ); +} + +const PLUGIN_ERROR_MAP: Readonly> = { + [PluginErrors.codes.PLUGIN_NOT_FOUND]: ErrorCode.PLUGIN_NOT_FOUND, + [PluginErrors.codes.PLUGIN_LOAD_FAILED]: ErrorCode.VALIDATION_FAILED, + [DomainErrorCodes.VALIDATION_FAILED]: ErrorCode.VALIDATION_FAILED, + [DomainErrorCodes.FS_PATH_NOT_FOUND]: ErrorCode.FS_PATH_NOT_FOUND, +}; + +function mapPluginError(error: unknown, requestId: string) { + const mapped = isError2(error) ? PLUGIN_ERROR_MAP[error.code] : undefined; + if (mapped !== undefined && isError2(error)) { + return errEnvelope(mapped, error.message, requestId, error.stack); + } + return errEnvelope( + ErrorCode.INTERNAL_ERROR, + error instanceof Error ? error.message : String(error), + requestId, + error instanceof Error ? error.stack : undefined, + ); +} diff --git a/packages/kap-server/src/routes/prompts.ts b/packages/kap-server/src/routes/prompts.ts new file mode 100644 index 0000000000000000000000000000000000000000..086a827e9bffe85617a5ba190d36a7ebf2119220 --- /dev/null +++ b/packages/kap-server/src/routes/prompts.ts @@ -0,0 +1,645 @@ +import { join } from 'node:path'; + +import { + IBootstrapService, + IAgentLifecycleService, + IAgentLoopService, + IAgentPermissionModeService, + IAgentProfileService, + IAgentRuntimeBindingService, + IAgentToolPolicyService, + IAgentSkillService, + IEventBus, + IEventService, + IFileService, + ISessionMediaStore, + ISessionMetadata, + ISessionSkillCatalog, + isUserActivatableSkillType, + promptMetadataTextFromContentParts, + ProfileError, + type ContextMessage, + type PromptHandle, + type PromptOrigin, + type PromptState, + type PromptWithSkillsResult, + newMessageId, + ISessionContext, + resumeSessionById, + ITelemetryService, + applyPromptMetadataUpdate, + isError2, + Error2, + ErrorCodes, + sessionMediaOriginalsDir, + type ISessionScopeHandle, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { ErrorCode } from '../protocol/error-codes'; +import { projectPromptContentParts } from '../services/messages/messageProjection'; +import { + promptAbortResponseSchema, + promptListResponseSchema, + promptSteerRequestSchema, + promptSteerResultSchema, + promptSubmissionSchema, + promptSubmitResultSchema, + type PromptSkillActivation, +} from '../protocol/rest-prompt'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { + assertPromptFileRefs, + assertPromptPathRefs, + contentHasPathRefs, + contentToCoreParts, + resolvePromptMediaFiles, + resolvePromptSessionMediaRefs, + type PromptMediaPreparation, +} from '../lib/promptMedia'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { ensureMainAgent, MAIN_AGENT_ID } from '../transport/mainAgent'; +import { type ActionTable, resolveActionTarget, runAction } from './action-dispatch'; + +interface PromptRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const validationDetailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +async function resolveSession(core: Scope, sessionId: string): Promise { + const session = await resumeSessionById(core.accessor, sessionId); + if (session === undefined) { + throw new Error2('session.not_found', `session ${sessionId} does not exist`); + } + return session; +} + +async function resolvePrompt(core: Scope, sessionId: string, agentId?: string) { + return resolvePromptFromSession(await resolveSession(core, sessionId), agentId); +} + +async function resolvePromptFromSession(session: ISessionScopeHandle, agentId?: string) { + const agent = + agentId === undefined || agentId === MAIN_AGENT_ID + ? await ensureMainAgent(session) + : session.accessor.get(IAgentLifecycleService).handleOf(agentId); + if (agent === undefined) { + throw new Error2('agent.not_found', `agent ${agentId} does not exist`); + } + return { + prompt: agent.accessor.get(IAgentLoopService), + skill: agent.accessor.get(IAgentSkillService), + events: agent.accessor.get(IEventBus), + profile: agent.accessor.get(IAgentProfileService), + toolPolicy: agent.accessor.get(IAgentToolPolicyService), + permissionMode: agent.accessor.get(IAgentPermissionModeService), + binding: agent.accessor.get(IAgentRuntimeBindingService), + }; +} + +async function assertActivatableSkills( + catalog: ISessionSkillCatalog, + skills: readonly PromptSkillActivation[], +): Promise { + await catalog.ready; + for (const skill of skills) { + const definition = catalog.catalog.getSkill(skill.name); + if (definition === undefined) { + throw new Error2(ErrorCodes.SKILL_NOT_FOUND, `Skill "${skill.name}" was not found`); + } + if (!isUserActivatableSkillType(definition.metadata.type)) { + throw new Error2( + ErrorCodes.SKILL_TYPE_UNSUPPORTED, + `Skill "${definition.name}" cannot be activated by the user`, + ); + } + } +} + +async function applyProfileSelection( + profile: IAgentProfileService, + profileName: string, + model: string | undefined, + thinking: string | undefined, +): Promise { + if (profile.data().profileName === profileName) return false; + try { + await profile.bind({ + profile: profileName, + model, + thinking, + strictThinking: thinking !== undefined, + }); + } catch (error) { + if (error instanceof ProfileError) { + throw new Error2(ErrorCodes.REQUEST_INVALID, error.message); + } + throw error; + } + return true; +} + +export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { + const listRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/prompts', + params: sessionIdParamSchema, + success: { data: promptListResponseSchema }, + errors: { [ErrorCode.SESSION_NOT_FOUND]: {} }, + description: 'List the active prompt and queued prompts for a session', + tags: ['prompts'], + operationId: 'listPrompts', + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const result = projectPromptList((await resolvePrompt(core, session_id)).prompt); + reply.send(okEnvelope(result, req.id)); + } catch (error) { + sendMappedError(reply, req, error); + } + }, + ); + app.get(listRoute.path, listRoute.options, listRoute.handler as Parameters[2]); + + const submitRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/prompts', + body: promptSubmissionSchema, + params: sessionIdParamSchema, + success: { data: promptSubmitResultSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema: validationDetailsSchema }, + [ErrorCode.SKILL_NOT_FOUND]: {}, + [ErrorCode.SKILL_NOT_ACTIVATABLE]: {}, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.FILE_NOT_FOUND]: {}, + [ErrorCode.PROMPT_ID_CONFLICT]: {}, + }, + description: 'Submit a prompt to a session', + tags: ['prompts'], + operationId: 'submitPrompt', + }, + async (req, reply) => { + const { session_id } = req.params; + let preparedMedia: PromptMediaPreparation | undefined; + let reservation: PromptIdReservation | undefined; + let enqueued = false; + try { + const session = await resolveSession(core, session_id); + let resolved: Awaited> | undefined; + if (contentHasPathRefs(req.body.content)) { + resolved = await resolvePromptFromSession(session, req.body.agent_id); + if (resolved.binding.get().runtimeId !== 'local') { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + 'file attachments by server-local path require the local runtime', + ); + } + } + await assertPromptFileRefs(req.body.content, core.accessor.get(IFileService)); + await assertPromptPathRefs(req.body.content); + if (req.body.skills !== undefined) { + if (req.body.prompt_id !== undefined) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + 'prompt_id cannot be combined with a bundled skill submission', + ); + } + await assertActivatableSkills( + session.accessor.get(ISessionSkillCatalog), + req.body.skills, + ); + } + const resolvedSessionMedia = await resolvePromptSessionMediaRefs( + req.body.content, + session.accessor.get(ISessionMediaStore), + ); + resolved ??= await resolvePromptFromSession(session, req.body.agent_id); + reservation = reservePromptId(session_id, req.body.prompt_id); + + const telemetry = core.accessor.get(ITelemetryService).withContext({ session_id }); + preparedMedia = await resolvePromptMediaFiles( + resolvedSessionMedia, + core.accessor.get(IFileService), + core.accessor.get(IBootstrapService).cacheDir, + { + telemetry, + providerType: resolved.profile.getModelProviderType(req.body.model), + resolveOriginalsDir: async () => { + const session = await resumeSessionById(core.accessor, session_id); + if (session === undefined) return undefined; + return sessionMediaOriginalsDir(session.accessor.get(ISessionContext).sessionDir); + }, + resolveAttachmentsDir: async () => { + const session = await resumeSessionById(core.accessor, session_id); + if (session === undefined) return undefined; + return join(session.accessor.get(ISessionContext).sessionDir, 'attachments'); + }, + }, + ); + const resolvedContent = preparedMedia.content; + const promptAttachments = + preparedMedia.attachments.length > 0 ? preparedMedia.attachments : undefined; + + let thinkingConsumed = false; + if (req.body.profile !== undefined) { + thinkingConsumed = + (await applyProfileSelection( + resolved.profile, + req.body.profile, + req.body.model, + req.body.thinking, + )) && req.body.thinking !== undefined; + } + if (req.body.model !== undefined) await resolved.profile.setModel(req.body.model); + if (req.body.thinking !== undefined && !thinkingConsumed) + resolved.profile.setThinking(req.body.thinking); + if (req.body.permission_mode !== undefined) resolved.permissionMode.setMode(req.body.permission_mode); + if (req.body.disabled_tools !== undefined) { + try { + await resolved.toolPolicy.setSessionDisabledTools(req.body.disabled_tools); + } catch (error) { + if (error instanceof ProfileError) { + throw new Error2(ErrorCodes.REQUEST_INVALID, error.message); + } + throw error; + } + } + const parts = contentToCoreParts(resolvedContent); + const clientMetadata = req.body.metadata === undefined ? undefined : [structuredClone(req.body.metadata)]; + if (req.body.skills !== undefined) { + if (req.body.agent_id !== undefined && req.body.agent_id !== MAIN_AGENT_ID) { + await applyPromptMetadataUpdate({ + metadata: session.accessor.get(ISessionMetadata), + eventService: core.accessor.get(IEventService), + sessionId: session_id, + }, promptMetadataTextFromContentParts(parts, clientMetadata)); + } + const settlement = watchPromptSettlements(resolved.events); + let result: PromptWithSkillsResult; + try { + result = await resolved.skill.promptWithSkills({ + input: parts, + clientMetadata, + skills: req.body.skills, + attachments: promptAttachments, + }); + } catch (error) { + settlement.dispose(); + throw error; + } + enqueued = true; + settlement.settle(result.prompt_id, () => preparedMedia?.discard()); + reply.send( + okEnvelope( + { + prompt_id: result.prompt_id, + user_message_id: result.prompt_id, + status: result.state, + content: projectPromptContentParts(parts), + created_at: result.created_at, + metadata: clientMetadata?.[0], + }, + req.id, + ), + ); + return; + } + await applyPromptMetadataUpdate({ + metadata: session.accessor.get(ISessionMetadata), + eventService: core.accessor.get(IEventService), + sessionId: session_id, + }, promptMetadataTextFromContentParts(parts, clientMetadata)); + const status = resolved.prompt.snapshot(); + const { id } = resolved.prompt.submit({ + message: { role: 'user', content: parts }, + meta: { + promptId: reservation.id, + origin: { kind: 'user', attachments: promptAttachments, clientMetadata } as PromptOrigin, + tracked: true, + }, + }); + reservation.submit(); + enqueued = true; + const handle = resolved.prompt.promptHandle(id)!; + if (status.state === 'idle' && !status.paused && status.queue.length === 0) { + await Promise.race([handle.launched, handle.completion]); + } + const staging = preparedMedia; + void Promise.race([handle.launched, handle.completion]).then( + () => staging?.discard(), + () => staging?.discard(), + ); + reply.send(okEnvelope(projectPromptHandle(handle), req.id)); + } catch (error) { + if (!enqueued) await preparedMedia?.discard(); + sendMappedError(reply, req, error); + } finally { + reservation?.dispose(); + } + }, + ); + app.post(submitRoute.path, submitRoute.options, submitRoute.handler as Parameters[2]); + + const steerManyRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/prompts::steer', + body: promptSteerRequestSchema, + params: sessionIdParamSchema, + success: { data: promptSteerResultSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.PROMPT_NOT_FOUND]: {}, + }, + description: 'Steer queued prompts into the active turn', + tags: ['prompts'], + operationId: 'steerPrompts', + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const resolved = await resolvePrompt(core, session_id); + await resolved.prompt.steer(req.body.prompt_ids); + reply.send(okEnvelope({ steered: true, prompt_ids: [...req.body.prompt_ids] }, req.id)); + } catch (error) { + sendMappedError(reply, req, error); + } + }, + ); + app.post(steerManyRoute.path, steerManyRoute.options, steerManyRoute.handler as Parameters[2]); + + const actionRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/prompts/{tail}', + success: { data: z.union([promptAbortResponseSchema, promptSteerResultSchema]) }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.PROMPT_NOT_FOUND]: {}, + }, + description: 'Abort a running prompt or steer a queued prompt', + tags: ['prompts'], + operationId: 'promptAction', + }, + async (req, reply) => { + try { + const { session_id, tail } = req.params as { session_id: string; tail: string }; + const target = resolveActionTarget({ + tail, + actions: promptActions, + resourceLabel: 'prompt', + }); + if ('message' in target) { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, target.message, req.id)); + return; + } + const resolved = await resolvePrompt(core, session_id); + await runAction({ + action: target.action, + id: target.id, + actions: promptActions, + extra: { resolved, session_id, req, reply }, + }); + } catch (error) { + sendMappedError(reply, req, error); + } + }, + ); + app.post(actionRoute.path, actionRoute.options, actionRoute.handler as Parameters[2]); +} + +type PromptActionExtra = { + readonly resolved: Awaited>; + readonly session_id: string; + readonly req: { readonly id: string }; + readonly reply: { readonly send: (payload: unknown) => unknown }; +}; + +type PromptActionCtx = PromptActionExtra & { readonly id: string; readonly body: unknown }; + +const promptActions: ActionTable<'abort' | 'steer', PromptActionExtra> = { + abort: { handle: abortPromptAction }, + steer: { handle: steerPromptAction }, +}; + +async function abortPromptAction(ctx: PromptActionCtx): Promise { + const { resolved, session_id, req, reply, id } = ctx; + resolved.prompt.cancel({ promptId: id }); + requestLog(req)?.info({ session_id, prompt_id: id }, 'prompt aborted'); + reply.send(okEnvelope({ aborted: true }, req.id)); +} + +async function steerPromptAction(ctx: PromptActionCtx): Promise { + const { resolved, req, reply, id } = ctx; + await resolved.prompt.steer([id]); + reply.send(okEnvelope({ steered: true, prompt_ids: [id] }, req.id)); +} + +function projectPromptList(loop: IAgentLoopService) { + const snapshot = loop.snapshot(); + const active = + snapshot.activePromptId === undefined + ? undefined + : loop.promptHandle(snapshot.activePromptId); + return { + active: active === undefined ? null : projectPromptSnapshot(active), + queued: snapshot.queue + .filter((item) => item.meta?.tracked === true) + .map((item) => + projectPromptSnapshot({ + id: item.meta?.promptId ?? '', + userMessageId: item.meta?.userMessageId ?? '', + createdAt: item.meta?.createdAt ?? '', + state: 'pending', + message: { ...item.message, toolCalls: [], origin: item.meta?.origin as PromptOrigin | undefined }, + }), + ), + }; +} + +function projectPromptHandle(handle: PromptHandle) { + return projectPromptSnapshot(handle); +} + +export function projectPromptSnapshot(prompt: { + readonly id: string; + readonly userMessageId: string; + readonly createdAt: string; + readonly state: PromptState; + readonly message: ContextMessage; +}) { + const status = prompt.state === 'running' || prompt.state === 'steered' + ? 'running' + : prompt.state === 'blocked' ? 'blocked' : 'queued'; + const origin = prompt.message.origin; + const bundled = origin?.kind === 'user' ? (origin.skillActivations?.length ?? 0) : 0; + const content = bundled === 0 ? prompt.message.content : prompt.message.content.slice(bundled); + return { + prompt_id: prompt.id, + user_message_id: prompt.userMessageId, + status, + content: projectPromptContentParts(content), + created_at: prompt.createdAt, + metadata: origin?.kind === 'user' || origin?.kind === 'skill_activation' ? origin.clientMetadata?.[0] : undefined, + }; +} + +export interface PromptIdReservation { + readonly id: string; + submit(): void; + dispose(): void; +} + +const reservedPromptIds = new Map>(); + +export function reservePromptId(sessionId: string, promptId?: string): PromptIdReservation { + if (promptId !== undefined && promptId.length === 0) { + throw new Error2(ErrorCodes.REQUEST_INVALID, 'prompt_id must not be empty'); + } + let reserved = reservedPromptIds.get(sessionId); + if (reserved === undefined) { + reserved = new Set(); + reservedPromptIds.set(sessionId, reserved); + } + if (promptId !== undefined && reserved.has(promptId)) { + throw new Error2(ErrorCodes.PROMPT_ID_CONFLICT, `prompt_id '${promptId}' is already in use`); + } + const id = promptId ?? newMessageId(); + reserved.add(id); + let submitted = false; + return { + id, + submit: () => { + submitted = true; + }, + dispose: () => { + if (!submitted) reserved.delete(id); + }, + }; +} + +export function watchPromptSettlements(events: IEventBus): { + settle(promptId: string, discard: () => void | Promise): void; + dispose(): void; +} { + const settledIds = new Set(); + const parentOf = new Map(); + let armed: { id: string; discard: () => void | Promise } | undefined; + const subscription = events.subscribe((event) => { + if (event.type === 'prompt.steered') { + const steered = event as { + readonly promptIds?: unknown; + readonly activePromptId?: unknown; + }; + if (Array.isArray(steered.promptIds) && typeof steered.activePromptId === 'string') { + for (const childId of steered.promptIds) { + if (typeof childId === 'string') parentOf.set(childId, steered.activePromptId); + } + if (armed !== undefined && steered.promptIds.includes(armed.id)) { + armed = { id: steered.activePromptId, discard: armed.discard }; + } + } + return; + } + if (event.type !== 'prompt.completed' && event.type !== 'prompt.aborted') return; + const id = (event as { readonly promptId?: unknown }).promptId; + if (typeof id !== 'string') return; + settledIds.add(id); + if (armed !== undefined && armed.id === id) { + const { discard } = armed; + armed = undefined; + subscription.dispose(); + void discard(); + } + }); + return { + settle(promptId: string, discard: () => void | Promise): void { + if (settledIds.has(promptId) || settledIds.has(parentOf.get(promptId) ?? '')) { + subscription.dispose(); + void discard(); + return; + } + armed = { id: promptId, discard }; + }, + dispose(): void { + armed = undefined; + subscription.dispose(); + }, + }; +} + +function sendMappedError( + reply: { send(payload: unknown): unknown }, + req: { id: string }, + err: unknown, +): void { + const requestId = req.id; + const log = requestLog(req); + if (isError2(err)) { + switch (err.code) { + case 'session.not_found': + case 'agent.not_found': + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack)); + return; + case 'file.not_found': + reply.send(errEnvelope(ErrorCode.FILE_NOT_FOUND, err.message, requestId, err.stack)); + return; + case 'prompt.not_found': + reply.send(errEnvelope(ErrorCode.PROMPT_NOT_FOUND, err.message, requestId, err.stack)); + return; + case 'prompt.id_conflict': + reply.send(errEnvelope(ErrorCode.PROMPT_ID_CONFLICT, err.message, requestId, err.stack)); + return; + case 'session.busy': + reply.send(errEnvelope(ErrorCode.SESSION_BUSY, err.message, requestId, err.stack)); + return; + case 'request.invalid': + case 'validation.failed': + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack)); + return; + case 'skill.not_found': + reply.send(errEnvelope(ErrorCode.SKILL_NOT_FOUND, err.message, requestId, err.stack)); + return; + case 'skill.type_unsupported': + reply.send(errEnvelope(ErrorCode.SKILL_NOT_ACTIVATABLE, err.message, requestId, err.stack)); + return; + } + } + log?.error({ err }, 'prompt request failed'); + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + err instanceof Error ? err.message : String(err), + requestId, + err instanceof Error ? err.stack : undefined, + ), + ); +} diff --git a/packages/kap-server/src/routes/questions.ts b/packages/kap-server/src/routes/questions.ts new file mode 100644 index 0000000000000000000000000000000000000000..2089a8c02ea877a849a3e5bfd6098a2b1ace40ee --- /dev/null +++ b/packages/kap-server/src/routes/questions.ts @@ -0,0 +1,304 @@ +import { + INTERACTION_TAG_SESSION_ID, + interactions, + resumeSessionById, + type Interaction, + type QuestionAnswers, + type QuestionResult, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { ErrorCode } from '../protocol/error-codes'; +import { + type QuestionItem as ProtocolQuestionItem, + type QuestionRequest as ProtocolQuestionRequest, + type QuestionResponse as ProtocolQuestionResponse, +} from '../protocol/question'; +import { toWireQuestion } from '../protocol/question-wire'; +import { + listPendingQuestionsQuerySchema, + listPendingQuestionsResponseSchema, + questionAlreadyResolvedDataSchema, + questionDismissResultSchema, + questionResolveRequestSchema, + questionResolveResultSchema, +} from '../protocol/rest-question'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { type ActionTable, runAction } from './action-dispatch'; +import { parseActionSuffix } from './action-suffix'; + +interface QuestionRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const tailParamsSchema = z.object({ + session_id: z.string().min(1), + tail: z.string().min(1), +}); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): void { + const listRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/questions', + params: sessionIdParamSchema, + querystring: listPendingQuestionsQuerySchema, + success: { data: listPendingQuestionsResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'List pending question requests for a session', + tags: ['questions'], + }, + async (req, reply) => { + const { session_id } = req.params; + const handle = await resumeSessionById(core.accessor, session_id); + if (handle === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + const pending = interactions.findAll({ + kind: 'question', + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: session_id }, + }); + const items = pending.map((i) => toWireQuestion(i, session_id)); + reply.send(okEnvelope({ items }, req.id)); + }, + ); + app.get(listRoute.path, listRoute.options, listRoute.handler as Parameters[2]); + + const resolveRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/questions/{tail}', + params: tailParamsSchema, + success: { data: questionResolveResultSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.QUESTION_NOT_FOUND]: {}, + [ErrorCode.APPROVAL_ALREADY_RESOLVED]: { + dataSchema: questionAlreadyResolvedDataSchema, + }, + [ErrorCode.QUESTION_DISMISSED]: { + dataSchema: questionDismissResultSchema, + }, + }, + description: 'Resolve or dismiss a question', + tags: ['questions'], + }, + async (req, reply) => { + const { session_id, tail } = req.params; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['dismiss'] as const, + defaultAction: 'resolve', + resourceLabel: 'question', + }); + + const handle = await resumeSessionById(core.accessor, session_id); + if (handle === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + + let questionId: string; + let action: 'resolve' | 'dismiss'; + if (parsed.kind === 'invalid') { + if ( + interactions.findOne({ + id: tail, + kind: 'question', + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: session_id }, + }) !== undefined || + interactions.findOne({ + id: tail, + kind: 'question', + resolved: true, + tags: { [INTERACTION_TAG_SESSION_ID]: session_id }, + }) !== undefined + ) { + questionId = tail; + action = 'resolve'; + } else { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id)); + return; + } + } else { + questionId = parsed.id; + action = parsed.kind === 'bare' ? 'resolve' : parsed.action; + } + + const pendingInteraction = interactions.findOne({ + id: questionId, + kind: 'question', + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: session_id }, + }); + + if (pendingInteraction === undefined) { + if ( + interactions.findOne({ + id: questionId, + kind: 'question', + resolved: true, + tags: { [INTERACTION_TAG_SESSION_ID]: session_id }, + }) !== undefined + ) { + reply.send({ + code: ErrorCode.APPROVAL_ALREADY_RESOLVED, + msg: `question ${questionId} already resolved`, + data: { resolved: false as const }, + request_id: req.id, + }); + return; + } + reply.send( + errEnvelope(ErrorCode.QUESTION_NOT_FOUND, `question ${questionId} not found`, req.id), + ); + return; + } + + await runAction({ + action, + id: questionId, + actions: questionActions, + extra: { pendingInteraction, session_id, req, reply }, + }); + }, + ); + app.post( + resolveRoute.path, + resolveRoute.options, + resolveRoute.handler as Parameters[2], + ); +} + +type QuestionActionExtra = { + readonly pendingInteraction: Interaction; + readonly session_id: string; + readonly req: { readonly id: string; readonly body: unknown }; + readonly reply: { readonly send: (payload: unknown) => unknown }; +}; + +type QuestionActionCtx = QuestionActionExtra & { readonly id: string; readonly body: unknown }; + +const questionActions: ActionTable<'resolve' | 'dismiss', QuestionActionExtra> = { + resolve: { handle: resolveQuestionAction }, + dismiss: { handle: dismissQuestionAction }, +}; + +async function resolveQuestionAction(ctx: QuestionActionCtx): Promise { + const { pendingInteraction, session_id, req, reply, id } = ctx; + const bodyParse = questionResolveRequestSchema.safeParse(req.body); + if (!bodyParse.success) { + const details = bodyParse.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })); + const first = details[0]; + const msg = + first === undefined + ? 'validation failed' + : first.path === '' + ? first.message + : `${first.path}: ${first.message}`; + reply.send({ + code: ErrorCode.VALIDATION_FAILED, + msg, + data: null, + request_id: req.id, + details, + }); + return; + } + + const result = toInProcessResponse(bodyParse.data, toWireQuestion(pendingInteraction, session_id)); + interactions.respond(id, result); + requestLog(req)?.info({ session_id, question_id: id, action: 'answer' }, 'question answered'); + reply.send(okEnvelope({ resolved: true as const, resolved_at: new Date().toISOString() }, req.id)); +} + +async function dismissQuestionAction(ctx: QuestionActionCtx): Promise { + const { session_id, req, reply, id } = ctx; + interactions.respond(id, null); + requestLog(req)?.info({ session_id, question_id: id, action: 'dismiss' }, 'question dismissed'); + reply.send({ + code: ErrorCode.QUESTION_DISMISSED, + msg: `question ${id} dismissed`, + data: { dismissed: true as const, dismissed_at: new Date().toISOString() }, + request_id: req.id, + }); +} + +function toInProcessResponse( + resp: ProtocolQuestionResponse, + request?: ProtocolQuestionRequest, +): QuestionResult { + const itemsById = new Map(); + for (const item of request?.questions ?? []) { + itemsById.set(item.id, item); + } + + const flattened: QuestionAnswers = {}; + for (const [qid, ans] of Object.entries(resp.answers)) { + const item = itemsById.get(qid); + const key = item?.question ?? qid; + const optionText = (id: string): string => + item?.options.find((o) => o.id === id)?.label ?? id; + switch (ans.kind) { + case 'single': + flattened[key] = optionText(ans.option_id); + break; + case 'multi': + flattened[key] = ans.option_ids.map(optionText).join(', '); + break; + case 'other': + flattened[key] = ans.text; + break; + case 'multi_with_other': + flattened[key] = [...ans.option_ids.map(optionText), ans.other_text].join(', '); + break; + case 'skipped': + break; + } + } + const out: { answers: QuestionAnswers; method?: 'enter' | 'space' | 'number_key' } = { + answers: flattened, + }; + if (resp.method !== undefined && resp.method !== 'click') { + out.method = resp.method; + } + return out; +} diff --git a/packages/kap-server/src/routes/registerApiV1Routes.ts b/packages/kap-server/src/routes/registerApiV1Routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..84087caf68b84bdfee26b7c0299bf1a5849fb95f --- /dev/null +++ b/packages/kap-server/src/routes/registerApiV1Routes.ts @@ -0,0 +1,246 @@ +import { IConfigService, type Scope } from '@moonshot-ai/agent-core-v2'; +import { FiberState } from '@moonshot-ai/agent-core-v2/_base/di/fiber'; +import { IFeatureManager } from '@moonshot-ai/agent-core-v2/app/feature/featureManager'; +import { IFlagService } from '@moonshot-ai/agent-core-v2/app/flag/flag'; +import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; +import { ulid } from 'ulid'; + +import { okEnvelope } from '../envelope'; +import type { MetaFeature } from '../protocol/rest-meta'; +import { type IConnectionRegistry } from '../transport/ws/connectionRegistry'; +import { type SessionEventBroadcaster } from '../transport/ws/v1/sessionEventBroadcaster'; +import type { ProjectionService } from '../services/projection'; +import type { TranscriptService } from '../services/transcript/transcriptService'; +import { registerApprovalsRoutes } from './approvals'; +import { registerAuthRoute } from './auth'; +import { registerCapabilitiesRoutes } from './capabilities'; +import { registerConfigRoutes } from './config'; +import { registerConnectionsRoutes } from './connections'; +import { registerFileHistoryRoutes } from './fileHistory'; +import { registerFilesRoutes } from './files'; +import { registerFsRoutes } from './fs'; +import { registerGuiStoreRoutes } from './guiStore'; +import { registerHistoryRoutes } from './history'; +import { registerMessagesRoutes } from './messages'; +import type { IGuiStoreService } from '../services/guiStore/guiStore'; +import { registerDebugRoutes } from '../transport/registerDebugRoutes'; +import { registerMetaRoute } from './meta'; +import { registerModelCatalogRoutes } from './modelCatalog'; +import { registerOAuthRoutes } from './oauth'; +import { registerPluginsRoutes } from './plugins'; +import { registerPromptsRoutes } from './prompts'; +import { registerQuestionsRoutes } from './questions'; +import { registerRemoteControlRoutes, type RemoteControlRouteOptions } from './remoteControl'; +import { registerRuntimeRoutes } from './runtime'; +import { registerSearchRoutes } from './search'; +import { registerSessionMediaRoutes } from './sessionMedia'; +import { registerSessionExportRoute } from './sessionExport'; +import { registerSessionsRoutes } from './sessions'; +import { registerShutdownRoutes } from './shutdown'; +import { registerSnapshotRoutes } from './snapshot'; +import { registerSkillsRoutes } from './skills'; +import { registerTasksRoutes } from './tasks'; +import { registerTerminalsRoutes } from './terminals'; +import { registerToolsRoutes } from './tools'; +import { registerTranscriptRoutes } from './transcript'; +import { registerWorkspaceFsRoutes } from './workspaceFs'; +import { registerWorkspacesRoutes } from './workspaces'; + +interface ApiV1AppHost { + register( + plugin: (apiV1: ApiV1RouteHost) => Promise | void, + opts: { prefix: string }, + ): unknown; +} + +interface ApiV1RouteHost { + get( + path: string, + options: { schema?: Record }, + handler: (req: { id: string }, reply: { send(payload: unknown): unknown }) => unknown, + ): unknown; +} + +export interface RegisterApiV1RoutesOptions { + readonly serverVersion: string; + readonly hostIdentity: KimiHostIdentity; + readonly debugEndpoints?: boolean; + readonly enableShutdown?: boolean; + readonly enableTerminals?: boolean; + readonly guiStore: IGuiStoreService; + readonly onShutdown: () => void; + readonly connectionRegistry: IConnectionRegistry; + readonly broadcaster: SessionEventBroadcaster; + readonly transcriptService: TranscriptService; + readonly homeDir: string; + readonly projectionService: ProjectionService; + readonly pluginMarketplaceUrl: () => string; + readonly pluginMarketplaceIsDefault: boolean; + readonly remoteControl: RemoteControlRouteOptions; + readonly dangerousBypassAuth?: boolean; + readonly webTitle?: string; +} + +export async function registerApiV1Routes( + app: ApiV1AppHost, + core: Scope, + opts: RegisterApiV1RoutesOptions, +): Promise { + await app.register( + async (apiV1) => { + registerHealthRoute(apiV1); + + if (opts.debugEndpoints === true) { + registerDebugRoutes(apiV1 as unknown as Parameters[0], core); + } + + registerMetaRoute(apiV1, { + serverVersion: opts.serverVersion, + serverId: ulid(), + startedAt: new Date().toISOString(), + dangerousBypassAuth: opts.dangerousBypassAuth === true, + webTitle: opts.webTitle, + getExperimentalFlags: async () => { + await core.accessor.get(IConfigService).ready; + return core.accessor.get(IFlagService).snapshot(); + }, + getFeatures: () => + core.accessor + .get(IFeatureManager) + .units() + .map((unit) => ({ + name: unit.name, + state: FiberState[unit.state] as MetaFeature['state'], + meta: unit.meta, + })), + }); + + registerAuthRoute(apiV1 as unknown as Parameters[0], core); + registerOAuthRoutes(apiV1 as unknown as Parameters[0], core); + registerConfigRoutes(apiV1 as unknown as Parameters[0], core); + registerModelCatalogRoutes( + apiV1 as unknown as Parameters[0], + core, + ); + registerSessionsRoutes( + apiV1 as unknown as Parameters[0], + core, + { sessionEventCursor: (sessionId) => opts.broadcaster.getCursor(sessionId) }, + ); + registerRuntimeRoutes(apiV1 as unknown as Parameters[0], core); + registerSessionExportRoute( + apiV1 as unknown as Parameters[0], + core, + { hostIdentity: opts.hostIdentity }, + ); + registerSkillsRoutes(apiV1 as unknown as Parameters[0], core); + registerCapabilitiesRoutes( + apiV1 as unknown as Parameters[0], + core, + ); + registerPluginsRoutes(apiV1 as unknown as Parameters[0], core, { + marketplaceUrl: opts.pluginMarketplaceUrl, + marketplaceIsDefault: opts.pluginMarketplaceIsDefault, + }); + registerMessagesRoutes( + apiV1 as unknown as Parameters[0], + core, + ); + registerHistoryRoutes(apiV1 as unknown as Parameters[0], { + core, + homeDir: opts.homeDir, + projection: opts.projectionService, + }); + registerSearchRoutes(apiV1 as unknown as Parameters[0], core); + registerTasksRoutes(apiV1 as unknown as Parameters[0], core); + registerApprovalsRoutes( + apiV1 as unknown as Parameters[0], + core, + ); + registerQuestionsRoutes( + apiV1 as unknown as Parameters[0], + core, + ); + registerPromptsRoutes( + apiV1 as unknown as Parameters[0], + core, + ); + registerRemoteControlRoutes( + apiV1 as unknown as Parameters[0], + opts.remoteControl, + ); + registerWorkspacesRoutes( + apiV1 as unknown as Parameters[0], + core, + ); + registerWorkspaceFsRoutes( + apiV1 as unknown as Parameters[0], + core, + ); + registerFilesRoutes(apiV1 as unknown as Parameters[0], core); + registerSessionMediaRoutes( + apiV1 as unknown as Parameters[0], + core, + ); + registerFsRoutes(apiV1 as unknown as Parameters[0], core); + registerGuiStoreRoutes(apiV1 as unknown as Parameters[0], opts.guiStore); + registerToolsRoutes(apiV1 as unknown as Parameters[0], core); + registerFileHistoryRoutes( + apiV1 as unknown as Parameters[0], + core, + ); + if (opts.enableTerminals !== false) { + registerTerminalsRoutes( + apiV1 as unknown as Parameters[0], + core, + ); + } + registerConnectionsRoutes( + apiV1 as unknown as Parameters[0], + opts.connectionRegistry, + ); + registerSnapshotRoutes(apiV1 as unknown as Parameters[0], { + core, + broadcaster: opts.broadcaster, + }); + registerTranscriptRoutes(apiV1 as unknown as Parameters[0], { + core, + transcriptService: opts.transcriptService, + }); + if (opts.enableShutdown !== false) { + registerShutdownRoutes(apiV1 as unknown as Parameters[0], { + onShutdown: opts.onShutdown, + }); + } + }, + { prefix: '/api/v1' }, + ); +} + +function registerHealthRoute(apiV1: ApiV1RouteHost): void { + apiV1.get( + '/healthz', + { + schema: { + description: 'Health check', + response: { + 200: { + type: 'object', + properties: { + code: { type: 'number' }, + msg: { type: 'string' }, + data: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + }, + request_id: { type: 'string' }, + }, + }, + }, + }, + }, + async (req, reply) => { + return reply.send(okEnvelope({ ok: true }, req.id)); + }, + ); +} diff --git a/packages/kap-server/src/routes/registerApiV2Routes.ts b/packages/kap-server/src/routes/registerApiV2Routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..149d9c3b57a8d406bcb885051baa35b287743b9c --- /dev/null +++ b/packages/kap-server/src/routes/registerApiV2Routes.ts @@ -0,0 +1,21 @@ +import type { Scope } from '@moonshot-ai/agent-core-v2'; + +import { registerV2McpRoutes } from './v2/mcp'; +import { registerV2SessionsRoutes } from './v2/sessions'; + +interface ApiV2AppHost { + register( + plugin: (apiV2: unknown) => Promise | void, + opts: { prefix: string }, + ): unknown; +} + +export async function registerApiV2Routes(app: ApiV2AppHost, core: Scope): Promise { + await app.register( + async (apiV2) => { + registerV2SessionsRoutes(apiV2 as Parameters[0], core); + registerV2McpRoutes(apiV2 as Parameters[0], core); + }, + { prefix: '/api/v2' }, + ); +} diff --git a/packages/kap-server/src/routes/remoteControl.ts b/packages/kap-server/src/routes/remoteControl.ts new file mode 100644 index 0000000000000000000000000000000000000000..efcc534e3c23bcf14e53131ddcf784f19ba5a8d5 --- /dev/null +++ b/packages/kap-server/src/routes/remoteControl.ts @@ -0,0 +1,122 @@ +import { + RemoteControlAlreadyRunningError, + type RemoteControlManager, + type RemoteControlStatusInfo, +} from '@moonshot-ai/remote-control'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + remoteControlStatusSchema, + setRemoteControlRequestSchema, + type RemoteControlStatusResponse, +} from '../protocol/rest-remote-control'; + +interface RemoteControlRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +export interface RemoteControlRouteOptions { + readonly service: RemoteControlManager; + readonly staticEnableError?: string; +} + +export function registerRemoteControlRoutes( + app: RemoteControlRouteHost, + opts: RemoteControlRouteOptions, +): void { + const getRoute = defineRoute( + { + method: 'GET', + path: '/remote-control', + success: { data: remoteControlStatusSchema }, + description: 'Get the Remote Control tunnel status', + tags: ['remote-control'], + }, + async (req, reply) => { + reply.send(okEnvelope(toRemoteControlStatusResponse(opts.service.status()), req.id)); + }, + ); + app.get( + getRoute.path, + getRoute.options, + getRoute.handler as Parameters[2], + ); + + const setRoute = defineRoute( + { + method: 'POST', + path: '/remote-control', + body: setRemoteControlRequestSchema, + success: { data: remoteControlStatusSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.REMOTE_CONTROL_ALREADY_RUNNING]: {}, + [ErrorCode.INTERNAL_ERROR]: {}, + }, + description: 'Start or stop the Remote Control tunnel', + tags: ['remote-control'], + }, + async (req, reply) => { + const { enabled } = req.body as { enabled: boolean }; + if (!enabled) { + const status = await opts.service.disable(); + reply.send(okEnvelope(toRemoteControlStatusResponse(status), req.id)); + return; + } + if (opts.staticEnableError !== undefined) { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, opts.staticEnableError, req.id)); + return; + } + try { + const status = await opts.service.enable(); + reply.send(okEnvelope(toRemoteControlStatusResponse(status), req.id)); + } catch (error) { + if (error instanceof RemoteControlAlreadyRunningError) { + reply.send( + errEnvelope(ErrorCode.REMOTE_CONTROL_ALREADY_RUNNING, error.message, req.id), + ); + return; + } + const message = error instanceof Error ? error.message : String(error); + requestLog(req)?.error({ err: error }, 'remote-control enable failed'); + reply.send(errEnvelope(ErrorCode.INTERNAL_ERROR, message, req.id)); + } + }, + ); + app.post( + setRoute.path, + setRoute.options, + setRoute.handler as Parameters[2], + ); +} + +function toRemoteControlStatusResponse( + status: RemoteControlStatusInfo, +): RemoteControlStatusResponse { + return { + enabled: status.enabled, + state: status.state, + url: status.url, + device_id: status.deviceId, + device_name: status.deviceName, + error: status.error, + }; +} diff --git a/packages/kap-server/src/routes/runtime.ts b/packages/kap-server/src/routes/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..2283c7f423b287e6eb805e83f57063ab82a5ef4d --- /dev/null +++ b/packages/kap-server/src/routes/runtime.ts @@ -0,0 +1,104 @@ +import { + Error2, + ErrorCodes, + IAgentRuntimeBindingService, + resumeSessionById, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { RuntimeError } from '@moonshot-ai/agent-core-v2/runtime/runtimeRegistry'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + runtimeBindingResponseSchema, + sessionRuntimeParamsSchema, + switchRuntimeRequestSchema, + type RuntimeBindingResponse, +} from '../protocol/rest-runtime'; +import { ensureMainAgent } from '../transport/mainAgent'; + +interface RuntimeRouteHost { + get( + path: string, + options: { schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { schema?: Record }, + handler: ( + req: { id: string; params: unknown; body: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; +} + +export function registerRuntimeRoutes(app: RuntimeRouteHost, core: Scope): void { + const getRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/runtime', + params: sessionRuntimeParamsSchema, + success: { data: runtimeBindingResponseSchema }, + errors: { [ErrorCode.SESSION_NOT_FOUND]: {} }, + description: 'Get the main agent runtime binding', + tags: ['sessions'], + }, + async (req, reply) => { + const service = await resolveRuntime(core, req.params.session_id); + reply.send(okEnvelope(toResponse(service.get()), req.id)); + }, + ); + app.get(getRoute.path, getRoute.options, getRoute.handler as Parameters[2]); + + const switchRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/runtime', + params: sessionRuntimeParamsSchema, + body: switchRuntimeRequestSchema, + success: { data: runtimeBindingResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.RUNTIME_NOT_FOUND]: {}, + [ErrorCode.RUNTIME_UNAVAILABLE]: {}, + }, + description: 'Switch the main agent runtime binding', + tags: ['sessions'], + }, + async (req, reply) => { + try { + const service = await resolveRuntime(core, req.params.session_id); + reply.send(okEnvelope(toResponse(service.switch(req.body.runtime_id)), req.id)); + } catch (error) { + if (error instanceof RuntimeError) { + const code = error.code === 'runtime.not_found' + ? ErrorCode.RUNTIME_NOT_FOUND + : ErrorCode.RUNTIME_UNAVAILABLE; + reply.send(errEnvelope(code, error.message, req.id)); + return; + } + throw error; + } + }, + ); + app.post(switchRoute.path, switchRoute.options, switchRoute.handler as Parameters[2]); +} + +async function resolveRuntime(core: Scope, sessionId: string): Promise { + const session = await resumeSessionById(core.accessor, sessionId); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + } + const agent = await ensureMainAgent(session); + return agent.accessor.get(IAgentRuntimeBindingService); +} + +function toResponse(binding: { workspaceId: string; runtimeId: string }): RuntimeBindingResponse { + return { workspace_id: binding.workspaceId, runtime_id: binding.runtimeId }; +} diff --git a/packages/kap-server/src/routes/search.ts b/packages/kap-server/src/routes/search.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a9886606e8bca903b328eea5f46cc937e4c31e3 --- /dev/null +++ b/packages/kap-server/src/routes/search.ts @@ -0,0 +1,116 @@ +import { type Scope } from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + searchMessagesBodySchema, + searchMessagesResponseSchema, + type SearchMessagesBody, + type SearchMessagesResponse, +} from '../protocol/rest-search'; +import type { GlobalSearchPage, GlobalSearchQuery } from '../search/contract'; +import { GlobalSearchError, IGlobalSearchService } from '../search/searchService'; + +interface SearchRouteHost { + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +function toServiceQuery(body: SearchMessagesBody): GlobalSearchQuery { + return { + query: body.query, + mode: body.mode, + op: body.op, + container: + body.container === undefined + ? undefined + : { sessionId: body.container.session_id, agentId: body.container.agent_id }, + role: body.role, + startTime: body.start_time, + endTime: body.end_time, + sort: body.sort, + pageSize: body.page_size, + pageToken: body.page_token, + }; +} + +function toWirePage(page: GlobalSearchPage): SearchMessagesResponse { + return { + items: page.items.map((hit) => ({ + session_id: hit.sessionId, + workspace_id: hit.workspaceId, + session_title: hit.sessionTitle, + agent_id: hit.agentId, + role: hit.role, + snippet: hit.snippet, + time: hit.time, + turn: hit.turn, + step_id: hit.stepId, + score: hit.score, + })), + has_more: page.hasMore, + page_token: page.pageToken, + incomplete: page.incomplete, + index_state: { + state: page.indexState.state, + indexed_sessions: page.indexState.indexedSessions, + total_sessions: page.indexState.totalSessions, + documents: page.indexState.documents, + stale: page.indexState.stale, + degraded: page.indexState.degraded, + }, + source: page.source, + }; +} + +export function registerSearchRoutes(app: SearchRouteHost, core: Scope): void { + const route = defineRoute( + { + method: 'POST', + path: '/search', + body: searchMessagesBodySchema, + success: { data: searchMessagesResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + }, + description: + 'Global full-text search over user messages, assistant replies and session titles across all sessions', + tags: ['search'], + }, + async (req, reply) => { + try { + const page = await core.accessor.get(IGlobalSearchService).search(toServiceQuery(req.body)); + reply.send(okEnvelope(toWirePage(page), req.id)); + } catch (error) { + if ( + error instanceof GlobalSearchError && + (error.reason === 'invalid_query' || error.reason === 'invalid_page_token') + ) { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, error.message, req.id, error.stack)); + return; + } + requestLog(req)?.error({ err: error }, 'global search request failed'); + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + error instanceof Error ? error.message : String(error), + req.id, + error instanceof Error ? error.stack : undefined, + ), + ); + } + }, + ); + app.post(route.path, route.options, route.handler as Parameters[2]); +} diff --git a/packages/kap-server/src/routes/sessionAgentConfig.ts b/packages/kap-server/src/routes/sessionAgentConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..28a56b1c8d0e51a55114459c9740f40a52151201 --- /dev/null +++ b/packages/kap-server/src/routes/sessionAgentConfig.ts @@ -0,0 +1,90 @@ +import { + ErrorCodes, + Error2, + IAgentGoalService, + IAgentLifecycleService, + IAgentPlanService, + IAgentProfileService, + IAgentSwarmService, + IAgentTowerService, + resumeSessionById, + towerEnterFailureMessage, + type PermissionMode, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import type { SessionAgentConfigPartial } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; + +import { ensureMainAgent } from '../transport/mainAgent'; + +export async function applySessionAgentConfig( + core: Scope, + sessionId: string, + agentConfig: SessionAgentConfigPartial, +): Promise { + const session = await resumeSessionById(core.accessor, sessionId); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + } + const agent = await ensureMainAgent(session); + + const profile = agent.accessor.get(IAgentProfileService); + if (agentConfig.model !== undefined && agentConfig.model !== '') { + await profile.setModel(agentConfig.model); + } + if (agentConfig.thinking !== undefined) { + profile.setThinking(agentConfig.thinking); + } + if (agentConfig.permission_mode !== undefined) { + agent.accessor + .get(IAgentLifecycleService) + .broadcastPermissionMode(agentConfig.permission_mode as PermissionMode); + } + if (agentConfig.plan_mode !== undefined) { + const plan = agent.accessor.get(IAgentPlanService); + const active = (await plan.status()) !== null; + if (active !== agentConfig.plan_mode) { + if (agentConfig.plan_mode) await plan.enter(); + else plan.exit(); + } + } + if (agentConfig.swarm_mode !== undefined) { + const swarm = agent.accessor.get(IAgentSwarmService); + if (swarm.isActive !== agentConfig.swarm_mode) { + if (agentConfig.swarm_mode) swarm.enter('manual'); + else swarm.exit(); + } + } + if (agentConfig.tower_mode !== undefined) { + const tower = agent.accessor.get(IAgentTowerService); + if (agentConfig.tower_mode) { + const result = await tower.enter(agentConfig.tower_base); + if (!result.entered) { + throw new Error2( + ErrorCodes.SESSION_TOWER_MODE_INVALID, + towerEnterFailureMessage(result), + ); + } + } else { + await tower.exit(); + } + } + if (agentConfig.goal_objective !== undefined) { + await agent.accessor + .get(IAgentGoalService) + .createGoal({ objective: agentConfig.goal_objective }); + } + if (agentConfig.goal_control !== undefined) { + const goal = agent.accessor.get(IAgentGoalService); + switch (agentConfig.goal_control) { + case 'pause': + await goal.pauseGoal({}); + break; + case 'resume': + await goal.resumeGoal({ continueIfPaused: true, continueIfBlocked: true }); + break; + case 'cancel': + await goal.cancelGoal({}); + break; + } + } +} diff --git a/packages/kap-server/src/routes/sessionExport.ts b/packages/kap-server/src/routes/sessionExport.ts new file mode 100644 index 0000000000000000000000000000000000000000..42f98093e8855c2a5f5040aa7acd88e7b0bd9dea --- /dev/null +++ b/packages/kap-server/src/routes/sessionExport.ts @@ -0,0 +1,197 @@ +import { createReadStream, type ReadStream } from 'node:fs'; +import { mkdtemp, rm, stat } from 'node:fs/promises'; +import type { ServerResponse } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + ErrorCodes, + ILogService, + ISessionExportService, + isError2, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; + +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { errEnvelope } from '../protocol/envelope'; +import { + exportSessionParamsSchema, + exportSessionRequestSchema, +} from '../protocol/rest-session'; + +interface SessionExportRouteHost { + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: (req: unknown, reply: SessionExportReply) => unknown, + ): unknown; +} + +interface SessionExportReply { + readonly raw: ServerResponse; + type(mime: string): SessionExportReply; + header(name: string, value: string | number): SessionExportReply; + send(payload: unknown): unknown; +} + +export function registerSessionExportRoute( + app: SessionExportRouteHost, + core: Scope, + options: { readonly hostIdentity: KimiHostIdentity }, +): void { + const log = core.accessor.get(ILogService); + const route = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/export', + params: exportSessionParamsSchema, + body: exportSessionRequestSchema, + rawResponse: { + 200: { type: 'string', format: 'binary' }, + }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.INTERNAL_ERROR]: {}, + }, + description: 'Export a session and diagnostic logs as a zip archive', + tags: ['sessions'], + }, + async (req, reply) => { + const response = reply as unknown as SessionExportReply; + let aborted = false; + let responseStream: ReadStream | undefined; + let streaming = false; + let tempDir: string | undefined; + let cleanupPromise: Promise | undefined; + const exportAbort = new AbortController(); + + const onResponseClose = (): void => { + if (response.raw.writableFinished) return; + aborted = true; + exportAbort.abort(); + responseStream?.destroy(); + }; + response.raw.once('close', onResponseClose); + + const cleanup = async (): Promise => { + if (tempDir === undefined) return; + cleanupPromise ??= rm(tempDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 50, + }).catch((error: unknown) => { + log.warn('session export temporary directory cleanup failed', { + error, + requestId: req.id, + tempDir, + }); + }); + await cleanupPromise; + }; + + try { + if (aborted) return; + + const safeSessionId = sanitizeSessionId(req.params.session_id); + tempDir = await mkdtemp(join(tmpdir(), `kimi-session-export-${safeSessionId}-`)); + if (aborted) { + await cleanup(); + return; + } + + const outputPath = join(tempDir, 'session.zip'); + await core.accessor.get(ISessionExportService).export( + { + sessionId: req.params.session_id, + outputPath, + includeGlobalLog: true, + includeDesktopLog: req.body.desktop === true, + version: options.hostIdentity.version, + desktopVersion: + req.body.desktop === true ? options.hostIdentity.version : undefined, + }, + { + webLog: req.body.web_log, + signal: exportAbort.signal, + }, + ); + if (aborted) { + await cleanup(); + return; + } + + const archive = await stat(outputPath); + if (aborted) { + await cleanup(); + return; + } + + responseStream = createReadStream(outputPath); + responseStream.once('close', () => { + response.raw.off('close', onResponseClose); + void cleanup(); + }); + + const sent = response + .type('application/zip') + .header( + 'content-disposition', + `attachment; filename="kimi-session-${safeSessionId}.zip"`, + ) + .header('content-length', archive.size) + .header('cache-control', 'no-store') + .send(responseStream); + streaming = true; + return sent as void; + } catch (error) { + const stream = responseStream; + if (stream !== undefined && !stream.closed) { + const closed = new Promise((resolve) => { + stream.once('close', resolve); + }); + stream.destroy(); + await closed; + } else { + stream?.destroy(); + } + await cleanup(); + if (!aborted) sendMappedError(response, req, error); + } finally { + if (!streaming) response.raw.off('close', onResponseClose); + } + }, + ); + + app.post( + route.path, + route.options, + route.handler as unknown as Parameters[2], + ); +} + +function sanitizeSessionId(sessionId: string): string { + return sessionId.replaceAll(/[^A-Za-z0-9_-]/g, '_').slice(0, 48) || 'session'; +} + +function sendMappedError(reply: SessionExportReply, req: { id: string }, error: unknown): void { + const requestId = req.id; + if (isError2(error)) { + if (error.code === ErrorCodes.SESSION_NOT_FOUND) { + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, error.message, requestId)); + return; + } + } + requestLog(req)?.error({ err: error }, 'session export failed'); + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + error instanceof Error ? error.message : 'internal error', + requestId, + ), + ); +} diff --git a/packages/kap-server/src/routes/sessionMedia.ts b/packages/kap-server/src/routes/sessionMedia.ts new file mode 100644 index 0000000000000000000000000000000000000000..89cec1fd3c9167fa111f7a231c6b8f018d094688 --- /dev/null +++ b/packages/kap-server/src/routes/sessionMedia.ts @@ -0,0 +1,128 @@ +import { Readable } from 'node:stream'; + +import { + ISessionMediaStore, + type SessionMediaFile, +} from '@moonshot-ai/agent-core-v2/agent/media/sessionMediaStore'; +import { + FileErrors, + IFileService, + isFileError, +} from '@moonshot-ai/agent-core-v2/app/file/fileService'; +import { resumeSessionById } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionLookup'; +import type { Scope } from '@moonshot-ai/agent-core-v2/_base/di/scope'; +import { z } from 'zod'; + +import { buildContentDisposition } from '../lib/contentDisposition'; +import { parseRangeHeader, pickHeader } from '../lib/httpRange'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { errEnvelope } from '../protocol/envelope'; + +interface SessionMediaRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: (req: SessionMediaRequest, reply: SessionMediaReply) => unknown, + ): unknown; +} + +interface SessionMediaRequest { + readonly id: string; + readonly params: { readonly session_id: string; readonly file_id: string }; + readonly headers: Record; +} + +interface SessionMediaReply { + type(mime: string): SessionMediaReply; + header(name: string, value: string | number): SessionMediaReply; + code(status: number): SessionMediaReply; + send(payload: unknown): unknown; +} + +const sessionMediaParamSchema = z.object({ + session_id: z.string().min(1), + file_id: z.string().min(1), +}); + +export function registerSessionMediaRoutes(app: SessionMediaRouteHost, core: Scope): void { + const route = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/media/{file_id}', + params: sessionMediaParamSchema, + rawResponse: { 200: { type: 'string', format: 'binary' } }, + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.FILE_NOT_FOUND]: {}, + }, + description: 'Download session-canonical prompt media by file ID', + tags: ['files'], + }, + async (req, reply) => { + const r = reply as unknown as SessionMediaReply; + const { session_id, file_id } = req.params; + const session = await resumeSessionById(core.accessor, session_id); + if (session === undefined) { + return r + .code(404) + .send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, 'session not found', req.id), + ) as unknown as void; + } + let file = await session.accessor.get(ISessionMediaStore).open(file_id); + file ??= await openStagedUpload(core, file_id); + if (file === undefined) { + return r + .code(404) + .send( + errEnvelope(ErrorCode.FILE_NOT_FOUND, 'file not found', req.id), + ) as unknown as void; + } + + r + .type(file.mediaType) + .header('content-disposition', buildContentDisposition(file.name, file.mediaType)) + .header('accept-ranges', 'bytes') + .header('etag', `"${session_id}-${file_id}-${file.size}"`); + + const range = parseRangeHeader(pickHeader(req.headers, 'range'), file.size); + if (range !== null) { + return r + .header('content-range', `bytes ${range.start}-${range.end}/${file.size}`) + .header('content-length', range.length) + .code(206) + .send( + Readable.from(file.stream({ start: range.start, end: range.end })), + ) as unknown as void; + } + return r + .header('content-length', file.size) + .code(200) + .send(Readable.from(file.stream())) as unknown as void; + }, + ); + app.get( + route.path, + route.options, + route.handler as unknown as Parameters[2], + ); +} + +async function openStagedUpload( + core: Scope, + fileId: string, +): Promise { + try { + const uploaded = await core.accessor.get(IFileService).get(fileId); + return { + name: uploaded.meta.name, + mediaType: uploaded.meta.media_type, + size: uploaded.meta.size, + stream: (range) => uploaded.stream(range), + }; + } catch (error) { + if (isFileError(error, FileErrors.codes.FILE_NOT_FOUND)) return undefined; + throw error; + } +} diff --git a/packages/kap-server/src/routes/sessionProfile.ts b/packages/kap-server/src/routes/sessionProfile.ts new file mode 100644 index 0000000000000000000000000000000000000000..235607cbc6741588da138e9900484387043beb7a --- /dev/null +++ b/packages/kap-server/src/routes/sessionProfile.ts @@ -0,0 +1,46 @@ +import { + ErrorCodes, + Error2, + ISessionContext, + ISessionMetadata, + resumeSessionById, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import type { SessionWireFields } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionLegacy'; +import type { UpdateSessionProfileRequest } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; + +export async function updateSessionProfile( + core: Scope, + sessionId: string, + body: Pick, +): Promise { + const session = await resumeSessionById(core.accessor, sessionId); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + } + const metadata = session.accessor.get(ISessionMetadata); + + if (typeof body.title === 'string') { + await metadata.setTitle(body.title); + } + + const metadataPatch = body.metadata; + if (metadataPatch !== undefined && Object.keys(metadataPatch).length > 0) { + await metadata.update({ custom: { ...(metadataPatch as Record) } }); + } + + const meta = await metadata.read(); + const ctx = session.accessor.get(ISessionContext); + return { + id: meta.id, + workspaceId: ctx.workspaceId, + root: ctx.cwd, + title: meta.title, + lastPrompt: meta.lastPrompt, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + archived: meta.archived, + archivedAt: meta.archivedAt, + custom: meta.custom, + }; +} diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts new file mode 100644 index 0000000000000000000000000000000000000000..386b01e92011d7619817835e32adbb21730af7fc --- /dev/null +++ b/packages/kap-server/src/routes/sessions.ts @@ -0,0 +1,1226 @@ +import { + ErrorCodes, + IAgentContextMemoryService, + IAgentProfileService, + IAgentConversationUndoService, + IAgentFullCompactionService, + IAgentLifecycleService, + IAgentLoopService, + IAuthSummaryService, + ISessionActivityView, + ISessionBtwService, + ISessionContext, + ISessionIndex, + ISessionMetadata, + ISessionLegacyService, + ISessionTitleService, + IEventService, + SessionCreated, + IWorkspaceAliases, + ISessionManager, + IWorkspaceService, + getLiveSessionById, + programForSession, + resumeSessionById, + setSessionArchived, + isError2, + Error2, + type ContextMessage, + type IAgentScopeHandle, + type ISessionScopeHandle, + type Scope, + type SessionSummary, +} from '@moonshot-ai/agent-core-v2'; +import { SessionMetaUpdated } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetaEvents'; +import { ErrorCode } from '../protocol/error-codes'; +import { pageResponseSchema } from '../protocol/pagination'; +import { toProtocolMessage } from '../services/messages/messageProjection'; +import { + archiveSessionResponseSchema, + compactSessionRequestSchema, + compactSessionResponseSchema, + createSessionChildRequestSchema, + createSessionRequestSchema, + deleteSessionResponseSchema, + forkSessionRequestSchema, + getSessionGoalResponseSchema, + listSessionChildrenResponseSchema, + sessionAbortResponseSchema, + sessionStatusResponseSchema, + sessionWarningsResponseSchema, + startBtwSessionResponseSchema, + undoSessionRequestSchema, + undoSessionResponseSchema, + updateSessionProfileRequestSchema, +} from '../protocol/rest-session'; +import { + emptySessionUsage, + sessionSchema, + type Session, + type SessionPendingInteraction, +} from '../protocol/session'; +import { workspaceIdSchema } from '../protocol/workspace'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { readLegacyStatus } from '../services/legacyStatus/legacyStatus'; +import { ensureMainAgent, MAIN_AGENT_ID } from '../transport/mainAgent'; +import { type ActionTable, dispatchAction } from './action-dispatch'; +import { applySessionAgentConfig } from './sessionAgentConfig'; +import { updateSessionProfile } from './sessionProfile'; + +interface SessionRouteHost { + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown; headers: Record }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const booleanQueryParam = z.preprocess((value) => { + if (value === 'true' || value === '1' || value === 1 || value === true) return true; + if (value === 'false' || value === '0' || value === 0 || value === false) return false; + return value; +}, z.boolean().optional()); + +const DEFAULT_SESSION_LIST_PAGE_SIZE = 20; + +const sessionsListQueryCoercion = z + .object({ + before_id: z.string().min(1).optional(), + after_id: z.string().min(1).optional(), + page_size: z.coerce.number().int().min(1).max(100).optional(), + busy: booleanQueryParam, + include_archive: booleanQueryParam, + exclude_empty: booleanQueryParam, + archived_only: booleanQueryParam, + workspace_id: workspaceIdSchema.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 }, + }); + } + if (value.archived_only === true && value.include_archive === true) { + ctx.addIssue({ + code: 'custom', + message: 'archived_only and include_archive are mutually exclusive', + path: ['archived_only'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + }); + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const sessionChildrenListQueryCoercion = z + .object({ + before_id: z.string().min(1).optional(), + after_id: z.string().min(1).optional(), + page_size: z.coerce.number().int().min(1).max(100).optional(), + busy: booleanQueryParam, + }) + .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 }, + }); + } + }); + +const sessionActionTailParamSchema = z.object({ + tail: z.string().min(1), +}); + +const sessionActionRequestSchema = z.preprocess( + (value) => (value === undefined ? {} : value), + z.object({ + title: z.string().min(1).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + instruction: z.string().optional(), + count: z.number().int().positive().optional(), + page_size: z.number().int().min(1).max(100).optional(), + }), +); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +export interface SessionsRoutesDeps { + readonly sessionEventCursor: (sessionId: string) => Promise<{ seq: number; epoch: string }>; +} + +export function registerSessionsRoutes( + app: SessionRouteHost, + core: Scope, + deps: SessionsRoutesDeps, +): void { + const createRoute = defineRoute( + { + method: 'POST', + path: '/sessions', + body: createSessionRequestSchema, + success: { data: sessionSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + }, + description: 'Create a new session', + tags: ['sessions'], + }, + async (req, reply) => { + const body = req.body; + const callerCwd = typeof body.metadata?.cwd === 'string' ? body.metadata.cwd : undefined; + const workspaceId = body.workspace_id; + if (workspaceId === undefined && callerCwd === undefined) { + reply.send( + buildValidationEnvelope( + [{ path: 'metadata.cwd', message: 'either workspace_id or metadata.cwd is required' }], + req.id, + ), + ); + return; + } + + const registry = core.accessor.get(IWorkspaceService); + let workDir: string; + if (workspaceId !== undefined) { + const workspace = await registry.get(workspaceId); + if (workspace === undefined) { + reply.send( + errEnvelope( + ErrorCode.WORKSPACE_NOT_FOUND, + `workspace ${workspaceId} does not exist`, + req.id, + ), + ); + return; + } + if (callerCwd !== undefined && callerCwd !== workspace.root) { + reply.send( + buildValidationEnvelope( + [ + { + path: 'metadata.cwd', + message: `metadata.cwd (${callerCwd}) must equal workspace root (${workspace.root})`, + }, + ], + req.id, + ), + ); + return; + } + workDir = workspace.root; + } else { + workDir = callerCwd as string; + } + + try { + const touched = await registry.createOrTouch(workDir); + const handle = await core.accessor.get(ISessionManager).create({ + workspaceId: touched.id, + workDir, + }); + if (typeof body.title === 'string') { + await handle.accessor.get(ISessionMetadata).setTitle(body.title); + } + const meta = await handle.accessor.get(ISessionMetadata).read(); + const session = toWireSession( + { ...meta, workspaceId: touched.id }, + touched.root, + { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + ); + core.accessor.get(IEventService).publish( + new SessionCreated({ payload: { agentId: 'main', sessionId: session.id, session } }), + ); + reply.send(okEnvelope(session, req.id)); + } catch (error) { + sendMappedError(reply, req, error); + } + }, + ); + app.post( + createRoute.path, + createRoute.options, + createRoute.handler as Parameters[2], + ); + + const listRoute = defineRoute( + { + method: 'GET', + path: '/sessions', + querystring: sessionsListQueryCoercion, + success: { data: pageResponseSchema(sessionSchema) }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'List sessions', + tags: ['sessions'], + }, + async (req, reply) => { + const raw = req.query; + const archivedOnly = raw.archived_only === true; + + const workspaces = await core.accessor.get(IWorkspaceService).list(); + const roots = new Map(workspaces.map((w) => [w.id, w.root])); + + if (raw.workspace_id !== undefined && !roots.has(raw.workspace_id)) { + reply.send( + errEnvelope( + ErrorCode.WORKSPACE_NOT_FOUND, + `workspace ${raw.workspace_id} does not exist`, + req.id, + ), + ); + return; + } + + const workspaceIds = + raw.workspace_id === undefined + ? undefined + : await core.accessor.get(IWorkspaceAliases).resolveAliasIds(raw.workspace_id); + const index = core.accessor.get(ISessionIndex); + const includeArchived = archivedOnly ? true : raw.include_archive; + + interface Eligible { + readonly summary: SessionSummary; + readonly cwd: string; + readonly facts?: SessionFacts; + } + + const collect = async (pageSize: number): Promise<{ visible: Eligible[]; hasMore: boolean }> => { + const wanted = pageSize + 1; + const collected: Eligible[] = []; + let before = raw.before_id; + const after = raw.after_id; + const afterCursor = after !== undefined ? await index.get(after) : undefined; + const newerThanCursor = (summary: SessionSummary): boolean => + afterCursor === undefined || + summary.updatedAt > afterCursor.updatedAt || + (summary.updatedAt === afterCursor.updatedAt && summary.id > afterCursor.id); + while (collected.length < wanted) { + const page = await index.listRecent({ + workspaceIds, + includeArchived, + limit: wanted - collected.length, + before, + after: before === undefined ? after : undefined, + }); + if (page.items.length === 0) break; + let exhausted = false; + for (const summary of page.items) { + if (!newerThanCursor(summary)) { + exhausted = true; + break; + } + const cwd = summary.cwd ?? roots.get(summary.workspaceId); + if (cwd === undefined) continue; + if (raw.exclude_empty === true && (summary.lastPrompt ?? '').length === 0) continue; + if (archivedOnly) { + if (!summary.archived) continue; + const facts = resolveSessionFacts(core, summary.id); + if (raw.busy !== undefined && facts.busy !== raw.busy) continue; + collected.push({ summary, cwd, facts }); + } else { + collected.push({ summary, cwd }); + } + } + if (exhausted || page.nextCursor === undefined) break; + before = page.nextCursor; + } + return { visible: collected.slice(0, pageSize), hasMore: collected.length > pageSize }; + }; + + if (!archivedOnly && raw.page_size === undefined) { + const page = await index.listRecent({ + workspaceIds, + includeArchived, + before: raw.before_id, + after: raw.after_id, + }); + const eligible: Eligible[] = []; + for (const summary of page.items) { + const cwd = summary.cwd ?? roots.get(summary.workspaceId); + if (cwd === undefined) continue; + if (raw.exclude_empty === true && (summary.lastPrompt ?? '').length === 0) continue; + eligible.push({ summary, cwd }); + } + const projected = eligible.map(({ summary, cwd }) => + toWireSession(summary, cwd, resolveSessionFacts(core, summary.id)), + ); + const items = + raw.busy !== undefined + ? projected.filter((session) => session.busy === raw.busy) + : projected; + reply.send(okEnvelope({ items, has_more: false }, req.id)); + return; + } + + const pageSize = raw.page_size ?? DEFAULT_SESSION_LIST_PAGE_SIZE; + const { visible, hasMore } = await collect(pageSize); + const projected = visible.map(({ summary, cwd, facts }) => + toWireSession(summary, cwd, facts ?? resolveSessionFacts(core, summary.id)), + ); + const items = + raw.busy !== undefined && !archivedOnly + ? projected.filter((session) => session.busy === raw.busy) + : projected; + reply.send(okEnvelope({ items, has_more: hasMore }, req.id)); + }, + ); + app.get( + listRoute.path, + listRoute.options, + listRoute.handler as Parameters[2], + ); + + const getRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}', + params: sessionIdParamSchema, + success: { data: sessionSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'Get a session by ID', + tags: ['sessions'], + }, + async (req, reply) => { + const { session_id } = req.params; + const cursor = await deps.sessionEventCursor(session_id); + const summary = await core.accessor.get(ISessionIndex).get(session_id); + if (summary === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + const cwd = + summary.cwd ?? (await core.accessor.get(IWorkspaceService).get(summary.workspaceId))?.root; + if (cwd === undefined) { + reply.send( + errEnvelope( + ErrorCode.SESSION_NOT_FOUND, + `session ${session_id} has no recoverable cwd`, + req.id, + ), + ); + return; + } + reply.send( + okEnvelope( + toWireSession(summary, cwd, resolveSessionFacts(core, session_id), cursor.seq), + req.id, + ), + ); + }, + ); + app.get( + getRoute.path, + getRoute.options, + getRoute.handler as Parameters[2], + ); + + const getProfileRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/profile', + params: sessionIdParamSchema, + success: { data: sessionSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'Get session profile', + tags: ['sessions'], + }, + async (req, reply) => { + const { session_id } = req.params; + const summary = await core.accessor.get(ISessionIndex).get(session_id); + if (summary === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + const cwd = + summary.cwd ?? (await core.accessor.get(IWorkspaceService).get(summary.workspaceId))?.root; + if (cwd === undefined) { + reply.send( + errEnvelope( + ErrorCode.SESSION_NOT_FOUND, + `session ${session_id} has no recoverable cwd`, + req.id, + ), + ); + return; + } + reply.send( + okEnvelope(toWireSession(summary, cwd, resolveSessionFacts(core, session_id)), req.id), + ); + }, + ); + app.get( + getProfileRoute.path, + getProfileRoute.options, + getProfileRoute.handler as Parameters[2], + ); + + const updateProfileRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/profile', + params: sessionIdParamSchema, + body: updateSessionProfileRequestSchema, + success: { data: sessionSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'Update session profile (title, metadata, agent_config)', + tags: ['sessions'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const { agent_config, ...profileBody } = req.body; + const fields = await updateSessionProfile(core, session_id, profileBody); + if (agent_config !== undefined) { + await applySessionAgentConfig(core, session_id, agent_config); + } + const session = toWireSession(fields, fields.root, resolveSessionFacts(core, fields.id)); + if (typeof req.body.title === 'string' && req.body.title.trim().length > 0) { + core.accessor.get(IEventService).publish( + new SessionMetaUpdated({ + payload: { + agentId: 'main', + sessionId: session_id, + title: session.title, + patch: { title: session.title, isCustomTitle: true }, + }, + }), + ); + } + reply.send(okEnvelope(session, req.id)); + } catch (error) { + sendMappedError(reply, req, error); + } + }, + ); + app.post( + updateProfileRoute.path, + updateProfileRoute.options, + updateProfileRoute.handler as Parameters[2], + ); + + const generateTitleRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/title/generate', + params: sessionIdParamSchema, + body: z.preprocess( + (value) => (value === undefined ? {} : value), + z.object({ + force: z.boolean().optional(), + source: z.enum(['user_prompts', 'first_turn', 'digest']).optional(), + }), + ), + success: { data: z.object({ title: z.string() }) }, + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.SESSION_TITLE_UNAVAILABLE]: {}, + }, + description: 'Generate the session title via the managed chat_title tool', + tags: ['sessions'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const handle = await resumeSessionById(core.accessor, session_id); + if (handle === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} not found`, req.id), + ); + return; + } + const title = await handle.accessor + .get(ISessionTitleService) + .generateTitle({ force: req.body.force === true, source: req.body.source }); + if (title === undefined) { + reply.send( + errEnvelope( + ErrorCode.SESSION_TITLE_UNAVAILABLE, + 'session title generation is unavailable (no managed OAuth login, no prompt yet, or the backend request failed)', + req.id, + ), + ); + return; + } + reply.send(okEnvelope({ title }, req.id)); + } catch (error) { + sendMappedError(reply, req, error); + } + }, + ); + app.post( + generateTitleRoute.path, + generateTitleRoute.options, + generateTitleRoute.handler as Parameters[2], + ); + + const sessionActionRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{tail}', + params: sessionActionTailParamSchema, + body: sessionActionRequestSchema, + success: { + data: z.union([ + sessionSchema, + compactSessionResponseSchema, + undoSessionResponseSchema, + sessionAbortResponseSchema, + startBtwSessionResponseSchema, + archiveSessionResponseSchema, + deleteSessionResponseSchema, + ]), + }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.SESSION_BUSY]: {}, + [ErrorCode.COMPACTION_UNABLE]: {}, + [ErrorCode.SESSION_UNDO_UNAVAILABLE]: {}, + }, + description: 'Run a session action', + tags: ['sessions'], + operationId: 'runSessionAction', + }, + async (req, reply) => { + try { + await dispatchAction({ + tail: req.params.tail, + actions: sessionActions, + resourceLabel: 'session', + extra: { core, req, reply }, + body: req.body, + onUnsupported: (message) => { + reply.send(buildValidationEnvelope([{ path: 'session_id', message }], req.id)); + }, + }); + } catch (error) { + sendMappedError(reply, req, error); + } + }, + ); + app.post( + sessionActionRoute.path, + sessionActionRoute.options, + sessionActionRoute.handler as Parameters[2], + ); + + const listChildrenRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/children', + params: sessionIdParamSchema, + querystring: sessionChildrenListQueryCoercion, + success: { data: listSessionChildrenResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'List child sessions', + tags: ['sessions'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const exists = + getLiveSessionById(core.accessor, session_id) !== undefined || + (await core.accessor.get(ISessionIndex).get(session_id)) !== undefined; + if (!exists) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${session_id} does not exist`); + } + + const pageSize = req.query.page_size ?? 100; + const page = await core.accessor.get(ISessionIndex).listRecent({ + childOf: session_id, + before: req.query.before_id, + after: req.query.after_id, + limit: pageSize + 1, + }); + const window = page.items.slice(0, pageSize); + + const roots = new Map( + (await core.accessor.get(IWorkspaceService).list()).map((w) => [w.id, w.root]), + ); + const projected = window.map((summary) => + toWireSession( + summary, + summary.cwd ?? roots.get(summary.workspaceId) ?? '', + resolveSessionFacts(core, summary.id), + ), + ); + const items = + req.query.busy !== undefined + ? projected.filter((session) => session.busy === req.query.busy) + : projected; + reply.send(okEnvelope({ items, has_more: page.nextCursor !== undefined }, req.id)); + } catch (error) { + sendMappedError(reply, req, error); + } + }, + ); + app.get( + listChildrenRoute.path, + listChildrenRoute.options, + listChildrenRoute.handler as Parameters[2], + ); + + const createChildRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/children', + params: sessionIdParamSchema, + body: createSessionChildRequestSchema, + success: { data: sessionSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.SESSION_BUSY]: {}, + }, + description: 'Create a child session', + tags: ['sessions'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const childHandler = await programForSession(core.accessor, session_id); + if (childHandler === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${session_id} does not exist`); + } + const meta = await core.accessor.get(ISessionManager).createChild({ + sourceSessionId: session_id, + title: req.body.title, + metadata: req.body.metadata, + }); + const session = toWireSession( + { ...meta, workspaceId: childHandler.workspaceId }, + meta.cwd ?? '', + resolveSessionFacts(core, meta.id), + ); + core.accessor.get(IEventService).publish( + new SessionCreated({ payload: { agentId: 'main', sessionId: session.id, session } }), + ); + reply.send(okEnvelope(session, req.id)); + } catch (error) { + sendMappedError(reply, req, error); + } + }, + ); + app.post( + createChildRoute.path, + createChildRoute.options, + createChildRoute.handler as Parameters[2], + ); + + const statusRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/status', + params: sessionIdParamSchema, + success: { data: sessionStatusResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'Get realtime session status (best-effort in this slice)', + tags: ['sessions'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const status = await core.accessor.get(ISessionLegacyService).status(session_id); + reply.send(okEnvelope(status, req.id)); + } catch (error) { + sendMappedError(reply, req, error); + } + }, + ); + app.get( + statusRoute.path, + statusRoute.options, + statusRoute.handler as Parameters[2], + ); + + const goalRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/goal', + params: sessionIdParamSchema, + success: { data: getSessionGoalResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'Get the current session goal (null when none is active)', + tags: ['sessions'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const goal = await core.accessor.get(ISessionLegacyService).goal(session_id); + reply.send(okEnvelope(goal, req.id)); + } catch (error) { + sendMappedError(reply, req, error); + } + }, + ); + app.get( + goalRoute.path, + goalRoute.options, + goalRoute.handler as Parameters[2], + ); + + const sessionWarningsRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/warnings', + params: sessionIdParamSchema, + success: { data: sessionWarningsResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'Get session-level warnings (e.g. oversized AGENTS.md)', + tags: ['sessions'], + }, + async (req, reply) => { + const { session_id } = req.params; + const session = await resumeSessionById(core.accessor, session_id); + if (session === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + try { + const agent = await ensureMainAgent(session); + const agentsMdWarning = agent.accessor.get(IAgentProfileService).getAgentsMdWarning(); + const warnings = + agentsMdWarning === undefined + ? [] + : [ + { + code: 'agents-md-oversized', + message: agentsMdWarning, + severity: 'warning' as const, + }, + ]; + reply.send(okEnvelope({ warnings }, req.id)); + } catch (error) { + sendMappedError(reply, req, error); + } + }, + ); + app.get( + sessionWarningsRoute.path, + sessionWarningsRoute.options, + sessionWarningsRoute.handler as Parameters[2], + ); +} + +type SessionAction = + | 'fork' + | 'compact' + | 'undo' + | 'abort' + | 'btw' + | 'restore' + | 'archive' + | 'delete'; + +interface SessionActionExtra { + readonly core: Scope; + readonly req: { readonly id: string }; + readonly reply: { readonly send: (payload: unknown) => unknown }; +} + +type SessionActionCtx = SessionActionExtra & { + readonly id: string; + readonly body: TBody; +}; + +const sessionActions: ActionTable = { + fork: { body: forkSessionRequestSchema, handle: forkSessionAction }, + compact: { body: compactSessionRequestSchema, handle: compactSessionAction }, + undo: { body: undoSessionRequestSchema, handle: undoSessionAction }, + abort: { handle: abortSessionAction }, + btw: { handle: btwSessionAction }, + restore: { handle: restoreSessionAction }, + archive: { handle: archiveSessionAction }, + delete: { handle: deleteSessionAction }, +}; + +async function forkSessionAction( + ctx: SessionActionCtx>, +): Promise { + const { core, req, reply, id, body } = ctx; + const forkHandler = await programForSession(core.accessor, id); + if (forkHandler === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${id} does not exist`); + } + const meta = await core.accessor.get(ISessionManager).fork({ + sourceSessionId: id, + title: body.title, + metadata: body.metadata, + }); + const session = toWireSession( + { ...meta, workspaceId: forkHandler.workspaceId }, + meta.cwd ?? '', + resolveSessionFacts(core, meta.id), + ); + core.accessor + .get(IEventService) + .publish(new SessionCreated({ payload: { agentId: 'main', sessionId: session.id, session } })); + requestLog(req)?.info( + { session_id: id, action: 'fork', new_session_id: session.id }, + 'session action completed', + ); + reply.send(okEnvelope(session, req.id)); +} + +async function compactSessionAction( + ctx: SessionActionCtx>, +): Promise { + const { core, req, reply, id, body } = ctx; + const agent = await resolveMainAgent(core, id); + agent.accessor + .get(IAgentFullCompactionService) + .begin({ source: 'manual', instruction: normalizeOptional(body.instruction) }); + requestLog(req)?.info({ session_id: id, action: 'compact' }, 'session action completed'); + reply.send(okEnvelope({}, req.id)); +} + +async function undoSessionAction( + ctx: SessionActionCtx>, +): Promise { + const { core, req, reply, id, body } = ctx; + const agent = await resolveMainAgent(core, id); + await agent.accessor.get(IAgentConversationUndoService).undo(body.count); + const history = agent.accessor.get(IAgentContextMemoryService).get(); + requestLog(req)?.info({ session_id: id, action: 'undo' }, 'session action completed'); + const legacy = core.accessor.get(ISessionLegacyService); + const [summary, status] = await Promise.all([ + core.accessor.get(ISessionIndex).get(id), + legacy.status(id), + ]); + reply.send( + okEnvelope( + { + messages: pageUndoMessages(id, summary?.createdAt ?? 0, history, body.page_size), + status, + }, + req.id, + ), + ); +} + +async function abortSessionAction(ctx: SessionActionCtx): Promise { + const { core, req, reply, id } = ctx; + const agent = await resolveMainAgent(core, id); + agent.accessor.get(IAgentLoopService).cancel(); + requestLog(req)?.info({ session_id: id, action: 'abort' }, 'session action completed'); + reply.send(okEnvelope({ aborted: true }, req.id)); +} + +async function btwSessionAction(ctx: SessionActionCtx): Promise { + const { core, req, reply, id } = ctx; + const session = await resumeSessionById(core.accessor, id); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${id} does not exist`); + } + const agent = await ensureMainAgent(session); + const sessionModel = agent.accessor.get(IAgentProfileService).getModel(); + await core.accessor.get(IAuthSummaryService).ensureReady(sessionModel || undefined); + const agentId = await session.accessor.get(ISessionBtwService).start(); + reply.send(okEnvelope({ agent_id: agentId }, req.id)); +} + +async function restoreSessionAction(ctx: SessionActionCtx): Promise { + const { core, req, reply, id } = ctx; + const restored = await core.accessor.get(ISessionManager).restore(id); + if (restored === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${id} does not exist`); + } + const meta = await restored.accessor.get(ISessionMetadata).read(); + const sessionCtx = restored.accessor.get(ISessionContext); + const session = toWireSession( + { ...meta, workspaceId: sessionCtx.workspaceId }, + sessionCtx.cwd, + resolveSessionFacts(core, meta.id), + ); + requestLog(req)?.info({ session_id: id, action: 'restore' }, 'session action completed'); + reply.send(okEnvelope(session, req.id)); +} + +async function archiveSessionAction(ctx: SessionActionCtx): Promise { + const { core, req, reply, id } = ctx; + const summary = await core.accessor.get(ISessionManager).status(id); + if (summary === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${id} does not exist`); + } + await setSessionArchived(core.accessor, id, true); + requestLog(req)?.info({ session_id: id, action: 'archive' }, 'session action completed'); + reply.send(okEnvelope({ archived: true }, req.id)); +} + +async function deleteSessionAction(ctx: SessionActionCtx): Promise { + const { core, req, reply, id } = ctx; + await core.accessor.get(ISessionManager).delete(id); + requestLog(req)?.info({ session_id: id, action: 'delete' }, 'session action completed'); + reply.send(okEnvelope({ deleted: true }, req.id)); +} + +export interface SessionWireFields { + readonly id: string; + readonly workspaceId: string; + readonly title?: string; + readonly lastPrompt?: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly archived: boolean; + readonly archivedAt?: number; + readonly custom?: Record; + readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; +} + +export function toWireSession( + fields: SessionWireFields, + cwd: string, + facts: SessionFacts, + lastSeq?: number, +): Session { + return { + id: fields.id, + workspace_id: fields.workspaceId, + title: fields.title ?? '', + created_at: new Date(fields.createdAt).toISOString(), + updated_at: new Date(fields.updatedAt).toISOString(), + archived_at: + fields.archivedAt === undefined ? undefined : new Date(fields.archivedAt).toISOString(), + busy: facts.busy, + main_turn_active: facts.mainTurnActive, + pending_interaction: facts.pendingInteraction, + last_turn_reason: + facts.lastTurnReason ?? (facts.live === false ? fields.lastTurnReason : undefined), + archived: fields.archived, + last_prompt: fields.lastPrompt, + metadata: buildWireMetadata(fields.custom, cwd), + agent_config: { model: facts.model ?? '' }, + usage: emptySessionUsage(), + permission_rules: [], + message_count: 0, + last_seq: lastSeq ?? 0, + }; +} + +export interface SessionFacts { + readonly busy: boolean; + readonly mainTurnActive: boolean; + readonly pendingInteraction: SessionPendingInteraction; + readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; + readonly live?: boolean; + readonly model?: string; +} + +export function resolveSessionFacts(core: Scope, sessionId: string): SessionFacts { + const handle = getLiveSessionById(core.accessor, sessionId); + if (handle === undefined) { + return { + busy: false, + mainTurnActive: false, + pendingInteraction: 'none', + live: false, + }; + } + return { + ...handle.accessor.get(ISessionActivityView).state(), + live: true, + model: readLiveSessionModel(handle), + }; +} + +function readLiveSessionModel(session: ISessionScopeHandle): string | undefined { + const main = session.accessor.get(IAgentLifecycleService).handleOf(MAIN_AGENT_ID); + if (main === undefined) return undefined; + return readLegacyStatus(main)?.model; +} + +async function resolveMainAgent(core: Scope, sessionId: string): Promise { + const session = await resumeSessionById(core.accessor, sessionId); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + } + return ensureMainAgent(session); +} + +function normalizeOptional(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +const DEFAULT_UNDO_MESSAGE_PAGE_SIZE = 50; +const MAX_UNDO_MESSAGE_PAGE_SIZE = 100; + +function pageUndoMessages( + sessionId: string, + sessionCreatedAtMs: number, + history: readonly ContextMessage[], + requestedPageSize: number | undefined, +): { items: ReturnType[]; has_more: boolean } { + const pageSize = Math.min( + Math.max(requestedPageSize ?? DEFAULT_UNDO_MESSAGE_PAGE_SIZE, 1), + MAX_UNDO_MESSAGE_PAGE_SIZE, + ); + const all = history.map((message, index) => + toProtocolMessage(sessionId, index, message, sessionCreatedAtMs), + ); + const desc = all.toReversed(); + return { + items: desc.slice(0, pageSize), + has_more: desc.length > pageSize, + }; +} + +function buildWireMetadata( + custom: Record | undefined, + cwd: string, +): { cwd: string; [key: string]: unknown } { + if (custom === undefined) return { cwd }; + const { goal: _drop, ...rest } = custom as { goal?: unknown; [key: string]: unknown }; + return { ...rest, cwd }; +} + +function buildValidationEnvelope( + details: { path: string; message: string }[], + requestId: string, +): { + code: number; + msg: string; + data: null; + request_id: string; + details: { path: string; message: string }[]; +} { + const first = details[0]; + const msg = + first === undefined + ? 'validation failed' + : first.path === '' + ? first.message + : `${first.path}: ${first.message}`; + return { + code: ErrorCode.VALIDATION_FAILED, + msg, + data: null, + request_id: requestId, + details, + }; +} + +function sendMappedError( + reply: { send(payload: unknown): unknown }, + req: { id: string }, + err: unknown, +): void { + const requestId = req.id; + const log = requestLog(req); + if (isError2(err)) { + switch (err.code) { + case 'session.not_found': + case 'agent.not_found': + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack)); + return; + case 'session.fork_active_turn': + case ErrorCodes.SESSION_BUSY: + reply.send(errEnvelope(ErrorCode.SESSION_BUSY, err.message, requestId, err.stack)); + return; + case 'compaction.unable': + reply.send(errEnvelope(ErrorCode.COMPACTION_UNABLE, err.message, requestId, err.stack)); + return; + case 'session.undo_unavailable': + reply.send({ + code: ErrorCode.SESSION_UNDO_UNAVAILABLE, + msg: err.message, + data: (err as { details?: unknown }).details ?? null, + request_id: requestId, + stack: err.stack, + }); + return; + case ErrorCodes.GOAL_ALREADY_EXISTS: + reply.send(errEnvelope(ErrorCode.GOAL_ALREADY_EXISTS, err.message, requestId, err.stack)); + return; + case ErrorCodes.GOAL_NOT_FOUND: + reply.send(errEnvelope(ErrorCode.GOAL_NOT_FOUND, err.message, requestId, err.stack)); + return; + case ErrorCodes.GOAL_STATUS_INVALID: + reply.send(errEnvelope(ErrorCode.GOAL_STATUS_INVALID, err.message, requestId, err.stack)); + return; + case ErrorCodes.GOAL_NOT_RESUMABLE: + reply.send(errEnvelope(ErrorCode.GOAL_NOT_RESUMABLE, err.message, requestId, err.stack)); + return; + case ErrorCodes.GOAL_OBJECTIVE_EMPTY: + reply.send(errEnvelope(ErrorCode.GOAL_OBJECTIVE_EMPTY, err.message, requestId, err.stack)); + return; + case ErrorCodes.GOAL_OBJECTIVE_TOO_LONG: + reply.send( + errEnvelope(ErrorCode.GOAL_OBJECTIVE_TOO_LONG, err.message, requestId, err.stack), + ); + return; + case ErrorCodes.FS_PATH_NOT_FOUND: + reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, err.message, requestId, err.stack)); + return; + case 'request.invalid': + case 'validation.failed': + case ErrorCodes.CONFIG_INVALID: + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack)); + return; + } + } + log?.error({ err }, 'session request failed'); + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + err instanceof Error ? err.message : String(err), + requestId, + err instanceof Error ? err.stack : undefined, + ), + ); +} diff --git a/packages/kap-server/src/routes/shutdown.ts b/packages/kap-server/src/routes/shutdown.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4082c2683657b5d62636fdbd4b6e3553decd562 --- /dev/null +++ b/packages/kap-server/src/routes/shutdown.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +import { okEnvelope } from '../envelope'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; + +interface ShutdownRouteHost { + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +export interface ShutdownRouteOptions { + readonly onShutdown: () => void; +} + +export function registerShutdownRoutes( + app: ShutdownRouteHost, + opts: ShutdownRouteOptions, +): void { + const route = defineRoute( + { + method: 'POST', + path: '/shutdown', + success: { data: z.object({ ok: z.literal(true) }) }, + description: 'Gracefully shut down the server', + tags: ['meta'], + }, + (req, reply) => { + requestLog(req)?.info( + { remoteAddress: (req as unknown as { ip?: string }).ip }, + 'shutdown requested', + ); + reply.send(okEnvelope({ ok: true }, req.id)); + setImmediate(() => opts.onShutdown()); + }, + ); + app.post( + route.path, + route.options, + route.handler as Parameters[2], + ); +} diff --git a/packages/kap-server/src/routes/skills.ts b/packages/kap-server/src/routes/skills.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed02308f9862ef810fffc4bc4feb784e3ef5224f --- /dev/null +++ b/packages/kap-server/src/routes/skills.ts @@ -0,0 +1,398 @@ +import { + builtinProductSkillsEnabled, + visibleBuiltinSkills, + Error2, + ErrorCodes, + EXTRA_SKILL_DIRS_SECTION, + IAgentRuntimeBindingService, + IAgentSkillService, + IBootstrapService, + IConfigService, + IFileService, + IFlagService, + IPluginService, + ISessionContext, + ISessionIndex, + ISessionMediaStore, + ISessionSkillCatalog, + ISkillDiscovery, + ITelemetryService, + IWorkspaceService, + InMemorySkillCatalog, + isError2, + isUserActivatableSkillType, + resumeSessionById, + MERGE_ALL_AVAILABLE_SKILLS_SECTION, + SKILL_SOURCE_PRIORITY, + configuredRoots, + projectRoots, + sessionMediaOriginalsDir, + userRoots, + type ContentPart, + type ISessionScopeHandle, + type Scope, + type SkillDefinition, + type ExtraSkillDirsConfig, + type MergeAllAvailableSkillsConfig, + IAgentProfileService, +} from '@moonshot-ai/agent-core-v2'; +import { join } from 'node:path'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { + assertPromptFileRefs, + assertPromptPathRefs, + contentHasPathRefs, + contentToCoreParts, + resolvePromptMediaFiles, + resolvePromptSessionMediaRefs, + type PromptMediaPreparation, +} from '../lib/promptMedia'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { ensureMainAgent as ensureMainAgentHandle } from '../transport/mainAgent'; +import { + activateSkillRequestSchema, + activateSkillResultSchema, + listSkillsResponseSchema, +} from '../protocol/rest-skill'; +import { workspaceIdParamSchema } from '../protocol/rest-workspace'; +import type { SkillDescriptor } from '../protocol/skill'; +import { parseActionSuffix } from './action-suffix'; + +interface SkillsRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const skillTailParamsSchema = z.object({ + session_id: z.string().min(1), + tail: z.string().min(1), +}); + +type ResolvedSession = + | { readonly handle: ISessionScopeHandle } + | { readonly envelope: ReturnType }; + +async function resolveActivatedSession( + core: Scope, + sessionId: string, + requestId: string, +): Promise { + const handle = await resumeSessionById(core.accessor, sessionId); + if (handle !== undefined) return { handle }; + + const summary = await core.accessor.get(ISessionIndex).get(sessionId); + const msg = + summary === undefined + ? `session ${sessionId} does not exist` + : `session ${sessionId} is not activated, you need to activate it first`; + return { envelope: errEnvelope(ErrorCode.SESSION_NOT_FOUND, msg, requestId) }; +} + +export function registerSkillsRoutes(app: SkillsRouteHost, core: Scope): void { + const listSkillsRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/skills', + params: sessionIdParamSchema, + success: { data: listSkillsResponseSchema }, + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'List the skills available to a session', + tags: ['skills'], + operationId: 'listSkills', + }, + async (req, reply) => { + const { session_id } = req.params; + const resolved = await resolveActivatedSession(core, session_id, req.id); + if ('envelope' in resolved) { + reply.send(resolved.envelope); + return; + } + const catalog = resolved.handle.accessor.get(ISessionSkillCatalog); + await catalog.ready; + const skills = catalog.catalog.listSkills().map(toProtocolSkill); + reply.send(okEnvelope({ skills }, req.id)); + }, + ); + app.get( + listSkillsRoute.path, + listSkillsRoute.options, + listSkillsRoute.handler as Parameters[2], + ); + + const listWorkspaceSkillsRoute = defineRoute( + { + method: 'GET', + path: '/workspaces/{workspace_id}/skills', + params: workspaceIdParamSchema, + success: { data: listSkillsResponseSchema }, + errors: { + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'List the skills available to a workspace (no session required)', + tags: ['skills'], + operationId: 'listWorkspaceSkills', + }, + async (req, reply) => { + const { workspace_id } = req.params; + const ws = await core.accessor.get(IWorkspaceService).get(workspace_id); + if (ws === undefined) { + reply.send( + errEnvelope( + ErrorCode.WORKSPACE_NOT_FOUND, + `workspace ${workspace_id} does not exist`, + req.id, + ), + ); + return; + } + const skills = (await listWorkspaceSkillsForRoot(core, ws.root)).map(toProtocolSkill); + reply.send(okEnvelope({ skills }, req.id)); + }, + ); + app.get( + listWorkspaceSkillsRoute.path, + listWorkspaceSkillsRoute.options, + listWorkspaceSkillsRoute.handler as Parameters[2], + ); + + const activateSkillRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/skills/{tail}', + body: activateSkillRequestSchema, + params: skillTailParamsSchema, + success: { data: activateSkillResultSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.SKILL_NOT_FOUND]: {}, + [ErrorCode.SKILL_NOT_ACTIVATABLE]: {}, + [ErrorCode.FILE_NOT_FOUND]: {}, + }, + description: 'Activate a skill in a session (REST analogue of the / slash command)', + tags: ['skills'], + operationId: 'activateSkill', + }, + async (req, reply) => { + const { session_id, tail } = req.params; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['activate'] as const, + resourceLabel: 'skill_name', + }); + if (parsed.kind === 'invalid') { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id)); + return; + } + if (parsed.kind === 'bare') { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `unsupported action: ${tail}`, req.id), + ); + return; + } + + const resolved = await resolveActivatedSession(core, session_id, req.id); + if ('envelope' in resolved) { + reply.send(resolved.envelope); + return; + } + + let preparedMedia: PromptMediaPreparation | undefined; + try { + const attachments = req.body.attachments ?? []; + const attachmentParts: ContentPart[] = []; + if (attachments.length > 0) { + if (contentHasPathRefs(attachments)) { + const mainAgent = await ensureMainAgentHandle(resolved.handle); + if (mainAgent.accessor.get(IAgentRuntimeBindingService).get().runtimeId !== 'local') { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + 'file attachments by server-local path require the local runtime', + ); + } + } + const catalog = resolved.handle.accessor.get(ISessionSkillCatalog); + await catalog.ready; + const skill = catalog.catalog.getSkill(parsed.id); + if (skill === undefined) { + throw new Error2(ErrorCodes.SKILL_NOT_FOUND, `Skill "${parsed.id}" was not found`); + } + if (!isUserActivatableSkillType(skill.metadata.type)) { + throw new Error2( + ErrorCodes.SKILL_TYPE_UNSUPPORTED, + `Skill "${skill.name}" cannot be activated by the user`, + ); + } + await assertPromptFileRefs(attachments, core.accessor.get(IFileService)); + await assertPromptPathRefs(attachments); + const resolvedSessionMedia = await resolvePromptSessionMediaRefs( + attachments, + resolved.handle.accessor.get(ISessionMediaStore), + ); + const telemetry = core.accessor.get(ITelemetryService).withContext({ session_id }); + const sessionDir = resolved.handle.accessor.get(ISessionContext).sessionDir; + preparedMedia = await resolvePromptMediaFiles( + resolvedSessionMedia, + core.accessor.get(IFileService), + core.accessor.get(IBootstrapService).cacheDir, + { + telemetry, + providerType: (await ensureMainAgentHandle(resolved.handle)).accessor + .get(IAgentProfileService) + .getModelProviderType(), + resolveOriginalsDir: async () => sessionMediaOriginalsDir(sessionDir), + resolveAttachmentsDir: async () => join(sessionDir, 'attachments'), + }, + ); + attachmentParts.push(...contentToCoreParts(preparedMedia.content)); + } + const mainAgent = await ensureMainAgentHandle(resolved.handle); + const promptAttachments = + preparedMedia !== undefined && preparedMedia.attachments.length > 0 + ? preparedMedia.attachments + : undefined; + await mainAgent.accessor.get(IAgentSkillService).activate({ + name: parsed.id, + args: req.body.args, + clientMetadata: req.body.metadata === undefined ? undefined : [structuredClone(req.body.metadata)], + content: attachmentParts, + attachments: promptAttachments, + }); + await preparedMedia?.discard(); + preparedMedia = undefined; + requestLog(req)?.info({ session_id, skill_name: parsed.id }, 'skill activated'); + reply.send(okEnvelope({ activated: true, skill_name: parsed.id }, req.id)); + } catch (error) { + await preparedMedia?.discard(); + sendMappedError(reply, req.id, error); + } + }, + ); + app.post( + activateSkillRoute.path, + activateSkillRoute.options, + activateSkillRoute.handler as Parameters[2], + ); +} + +async function listWorkspaceSkillsForRoot( + core: Scope, + workDir: string, +): Promise { + const discovery = core.accessor.get(ISkillDiscovery); + const bootstrap = core.accessor.get(IBootstrapService); + const plugins = core.accessor.get(IPluginService); + const config = core.accessor.get(IConfigService); + const flags = core.accessor.get(IFlagService); + await config.ready; + const extraSkillDirs = config.get(EXTRA_SKILL_DIRS_SECTION) ?? []; + const mergeAllAvailableSkills = + config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; + const explicitDirs = bootstrap.args.skillDirs ?? []; + const useExplicitDirs = explicitDirs.length > 0; + const rootOptions = { mergeAllAvailableSkills }; + + const [userRootList, projectRootList, explicitRootList, extraRootList, pluginRootList] = await Promise.all([ + useExplicitDirs ? Promise.resolve([]) : userRoots(bootstrap.homeDir, bootstrap.osHomeDir, rootOptions), + useExplicitDirs ? Promise.resolve([]) : projectRoots(workDir, rootOptions), + useExplicitDirs + ? configuredRoots(explicitDirs, workDir, bootstrap.osHomeDir, 'user') + : Promise.resolve([]), + configuredRoots(extraSkillDirs, workDir, bootstrap.osHomeDir, 'extra'), + plugins.pluginSkillRoots(), + ]); + const [user, project, explicit, extra, plugin] = await Promise.all([ + discovery.discover(userRootList), + discovery.discover(projectRootList), + discovery.discover(explicitRootList), + discovery.discover(extraRootList), + discovery.discover(pluginRootList), + ]); + + const catalog = new InMemorySkillCatalog(); + const ordered = [ + { + skills: visibleBuiltinSkills(builtinProductSkillsEnabled(config), flags), + priority: SKILL_SOURCE_PRIORITY.builtin, + }, + { skills: plugin.skills, priority: SKILL_SOURCE_PRIORITY.plugin }, + { skills: extra.skills, priority: SKILL_SOURCE_PRIORITY.extra }, + { skills: user.skills, priority: SKILL_SOURCE_PRIORITY.user }, + { skills: explicit.skills, priority: SKILL_SOURCE_PRIORITY.user }, + { skills: project.skills, priority: SKILL_SOURCE_PRIORITY.workspace }, + ].toSorted((a, b) => a.priority - b.priority); + for (const { skills } of ordered) { + for (const skill of skills) catalog.register(skill, { replace: true }); + } + return catalog.listSkills(); +} + +type SkillElement = ReturnType[number]; + +function toProtocolSkill(skill: SkillElement): SkillDescriptor { + const base: SkillDescriptor = { + name: skill.name, + description: skill.description, + path: skill.path, + source: skill.source, + }; + const type = skill.metadata.type; + const disableModelInvocation = skill.metadata.disableModelInvocation; + return { + ...base, + ...(type !== undefined ? { type } : {}), + ...(disableModelInvocation !== undefined + ? { disable_model_invocation: disableModelInvocation } + : {}), + }; +} + +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (isError2(err)) { + switch (err.code) { + case ErrorCodes.SKILL_NOT_FOUND: + case ErrorCodes.SKILL_NAME_EMPTY: + reply.send(errEnvelope(ErrorCode.SKILL_NOT_FOUND, err.message, requestId, err.stack)); + return; + case ErrorCodes.SKILL_TYPE_UNSUPPORTED: + reply.send(errEnvelope(ErrorCode.SKILL_NOT_ACTIVATABLE, err.message, requestId, err.stack)); + return; + case ErrorCodes.FILE_NOT_FOUND: + reply.send(errEnvelope(ErrorCode.FILE_NOT_FOUND, err.message, requestId, err.stack)); + return; + case ErrorCodes.REQUEST_INVALID: + case ErrorCodes.VALIDATION_FAILED: + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack)); + return; + } + } + throw err; +} diff --git a/packages/kap-server/src/routes/snapshot.ts b/packages/kap-server/src/routes/snapshot.ts new file mode 100644 index 0000000000000000000000000000000000000000..edc6e11e43b7fd4ffc2bc2fec45a4a4558727865 --- /dev/null +++ b/packages/kap-server/src/routes/snapshot.ts @@ -0,0 +1,188 @@ +import { + INTERACTION_TAG_SESSION_ID, + IAgentLoopService, + ISessionContext, + ISessionMetadata, + IWorkspaceService, + interactions, + resumeSessionById, + type IAgentScopeHandle, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { ensureMainAgent } from '../transport/mainAgent'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + sessionSnapshotResponseSchema, + type InFlightTurn, + type SessionSnapshotResponse, +} from '../protocol/rest-snapshot'; +import { emptySessionUsage, type SessionUsage } from '../protocol/session'; +import { + readLegacyStatus, + type LegacyStatusSnapshot, +} from '../services/legacyStatus/legacyStatus'; +import { loadMessageHistory } from '../services/messages/messageHistory'; +import { type SessionEventBroadcaster } from '../transport/ws/v1/sessionEventBroadcaster'; +import { toWireApproval } from './approvals'; +import { toWireQuestion } from '../protocol/question-wire'; +import { resolveSessionFacts, toWireSession } from './sessions'; + +const SNAPSHOT_MESSAGE_PAGE_SIZE = 100; + +class SnapshotNotFoundError extends Error { + constructor(sessionId: string) { + super(`session ${sessionId} does not exist`); + this.name = 'SnapshotNotFoundError'; + } +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +interface SnapshotRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; params: { session_id: string } }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +export interface SnapshotRouteDeps { + readonly core: Scope; + readonly broadcaster: SessionEventBroadcaster; +} + +export function registerSnapshotRoutes(app: SnapshotRouteHost, deps: SnapshotRouteDeps): void { + const { core, broadcaster } = deps; + + const route = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/snapshot', + params: sessionIdParamSchema, + success: { data: sessionSnapshotResponseSchema }, + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.INTERNAL_ERROR]: {}, + }, + description: + 'Atomic session snapshot for client rebuild: state + as_of_seq watermark + epoch', + tags: ['sessions'], + }, + async (req, reply) => { + const { session_id } = req.params; + try { + const data = await assembleSnapshot(core, broadcaster, session_id); + reply.send(okEnvelope(data, req.id)); + } catch (err) { + if (err instanceof SnapshotNotFoundError) { + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, req.id, err.stack)); + return; + } + throw err; + } + }, + ); + app.get(route.path, route.options, route.handler as Parameters[2]); +} + +async function assembleSnapshot( + core: Scope, + broadcaster: SessionEventBroadcaster, + sessionId: string, +): Promise { + const handle = await resumeSessionById(core.accessor, sessionId); + if (handle === undefined) { + throw new SnapshotNotFoundError(sessionId); + } + + const snapState = await broadcaster.getSnapshotState(sessionId); + + const workspaceId = handle.accessor.get(ISessionContext).workspaceId; + const workspace = await core.accessor.get(IWorkspaceService).get(workspaceId); + const cwd = workspace?.root ?? ''; + const meta = await handle.accessor.get(ISessionMetadata).read(); + + const main = await ensureMainAgent(handle); + const status = readLegacyStatus(main); + const session = { + ...toWireSession( + { ...meta, workspaceId }, + cwd, + resolveSessionFacts(core, sessionId), + ), + agent_config: { model: status?.model ?? '' }, + usage: toSnapshotUsage(status), + }; + + const all = await loadMessageHistory(core, main, sessionId, meta.createdAt); + const hasMore = all.length > SNAPSHOT_MESSAGE_PAGE_SIZE; + const items = all.slice(-SNAPSHOT_MESSAGE_PAGE_SIZE); + + const currentPromptId = snapState.inFlightTurn === null ? undefined : readCurrentPromptId(main); + const inFlightTurn = attachCurrentPromptIdToInFlight(snapState.inFlightTurn, currentPromptId); + + const pendingApprovals = interactions + .findAll({ + kind: 'approval', + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: sessionId }, + }) + .map((i) => toWireApproval(i, sessionId)); + const pendingQuestions = interactions + .findAll({ + kind: 'question', + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: sessionId }, + }) + .map((i) => toWireQuestion(i, sessionId)); + + return { + as_of_seq: snapState.seq, + epoch: snapState.epoch, + session, + messages: { items, has_more: hasMore }, + in_flight_turn: inFlightTurn, + subagents: snapState.subagents, + pending_approvals: pendingApprovals, + pending_questions: pendingQuestions, + }; +} + +function readCurrentPromptId(main: IAgentScopeHandle | undefined): string | undefined { + if (main === undefined) return undefined; + try { + return main.accessor.get(IAgentLoopService).snapshot().activePromptId; + } catch { + return undefined; + } +} + +function toSnapshotUsage(status: LegacyStatusSnapshot | undefined): SessionUsage { + if (status === undefined) return emptySessionUsage(); + const total = status.usage?.total; + return { + input_tokens: total?.inputOther ?? 0, + output_tokens: total?.output ?? 0, + cache_read_tokens: total?.inputCacheRead ?? 0, + cache_creation_tokens: total?.inputCacheCreation ?? 0, + context_tokens: status.contextTokens, + context_limit: status.maxContextTokens, + }; +} + +function attachCurrentPromptIdToInFlight( + inFlightTurn: InFlightTurn | null, + currentPromptId: string | undefined, +): InFlightTurn | null { + if (inFlightTurn === null || currentPromptId === undefined) return inFlightTurn; + return { ...inFlightTurn, current_prompt_id: currentPromptId }; +} diff --git a/packages/kap-server/src/routes/tasks.ts b/packages/kap-server/src/routes/tasks.ts new file mode 100644 index 0000000000000000000000000000000000000000..08b8c877c29339bad4e92afff2fe87f81ce86b18 --- /dev/null +++ b/packages/kap-server/src/routes/tasks.ts @@ -0,0 +1,345 @@ +import { + IAgentTaskService, + ISessionIndex, + getLiveSessionById, + type AgentTaskInfo, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { ErrorCode } from '../protocol/error-codes'; +import { + cancelTaskResultSchema, + detachTaskResultSchema, + getTaskQuerySchema, + getTaskResponseSchema, + listTasksQuerySchema, + listTasksResponseSchema, +} from '../protocol/rest-task'; +import type { Task, TaskKind, TaskStatus } from '../protocol/task'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { ensureMainAgent } from '../transport/mainAgent'; +import { parseActionSuffix } from './action-suffix'; + +const DEFAULT_TASK_OUTPUT_PREVIEW_BYTES = 32 * 1024; + +interface TasksRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const sessionAndTaskIdParamSchema = z.object({ + session_id: z.string().min(1), + task_id: z.string().min(1), +}); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +export function registerTasksRoutes(app: TasksRouteHost, core: Scope): void { + const listRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/tasks', + params: sessionIdParamSchema, + querystring: listTasksQuerySchema, + success: { data: listTasksResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'List tasks for a session', + tags: ['tasks'], + }, + async (req, reply) => { + const { session_id } = req.params; + const resolved = await resolveSessionTasks(core, session_id); + if (resolved.kind === 'not_found') { + reply.send(sessionNotFound(session_id, req.id)); + return; + } + + const all = (resolved.tasks?.list(false) ?? []).map((info) => + toWireTask(session_id, info), + ); + const query = req.query as { status?: TaskStatus }; + const items = + query.status !== undefined ? all.filter((t) => t.status === query.status) : all; + reply.send(okEnvelope({ items }, req.id)); + }, + ); + app.get(listRoute.path, listRoute.options, listRoute.handler as Parameters[2]); + + const getRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/tasks/{task_id}', + params: sessionAndTaskIdParamSchema, + querystring: getTaskQuerySchema, + success: { data: getTaskResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.TASK_NOT_FOUND]: {}, + }, + description: 'Get a task by ID', + tags: ['tasks'], + }, + async (req, reply) => { + const { session_id, task_id } = req.params; + const resolved = await resolveSessionTasks(core, session_id); + if (resolved.kind === 'not_found') { + reply.send(sessionNotFound(session_id, req.id)); + return; + } + + const found = resolved.tasks?.getTask(task_id); + if (found === undefined) { + reply.send(taskNotFound(session_id, task_id, req.id)); + return; + } + + const query = req.query as { with_output?: boolean; output_bytes?: number }; + let output: { preview: string; bytes: number } | undefined; + if (query.with_output === true && resolved.tasks !== undefined) { + const tailBytes = query.output_bytes ?? DEFAULT_TASK_OUTPUT_PREVIEW_BYTES; + try { + const preview = await resolved.tasks.readOutput(task_id, tailBytes); + if (preview.length > 0) { + output = { preview, bytes: Buffer.byteLength(preview, 'utf-8') }; + } + } catch { + } + } + + reply.send(okEnvelope(toWireTask(session_id, found, output), req.id)); + }, + ); + app.get(getRoute.path, getRoute.options, getRoute.handler as Parameters[2]); + + const taskActionRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/tasks/{tail}', + success: { data: z.union([cancelTaskResultSchema, detachTaskResultSchema]) }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.TASK_NOT_FOUND]: {}, + [ErrorCode.TASK_ALREADY_FINISHED]: { + dataSchema: z.object({ cancelled: z.literal(false) }), + detailsSchema: z.object({ current_status: z.string() }), + }, + }, + description: 'Run a task action', + tags: ['tasks'], + operationId: 'runTaskAction', + }, + async (req, reply) => { + const { session_id, tail } = req.params as { + session_id: string; + tail: string; + }; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['cancel', 'detach'] as const, + resourceLabel: 'task', + }); + if (parsed.kind === 'invalid') { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id)); + return; + } + if (parsed.kind === 'bare') { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `unsupported action: ${tail}`, req.id), + ); + return; + } + const task_id = parsed.id; + if (!session_id || !task_id) { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, 'invalid path params', req.id)); + return; + } + + const resolved = await resolveSessionTasks(core, session_id); + if (resolved.kind === 'not_found') { + reply.send(sessionNotFound(session_id, req.id)); + return; + } + + const found = resolved.tasks?.getTask(task_id); + if (found === undefined) { + reply.send(taskNotFound(session_id, task_id, req.id)); + return; + } + + if (parsed.action === 'cancel') { + const wireStatus = toWireTask(session_id, found).status; + if (isTerminalStatus(wireStatus)) { + reply.send(taskAlreadyFinished(session_id, task_id, wireStatus, req.id)); + return; + } + + await resolved.tasks?.stopByUser(task_id); + requestLog(req)?.info({ session_id, task_id }, 'task cancelled'); + reply.send(okEnvelope({ cancelled: true as const }, req.id)); + return; + } + + const detached = found.status === 'running' && found.detached === false; + const info = resolved.tasks?.detach(task_id) ?? found; + if (detached) { + requestLog(req)?.info({ session_id, task_id }, 'task detached'); + } + reply.send(okEnvelope({ detached, status: mapStatus(info.status) }, req.id)); + }, + ); + app.post(taskActionRoute.path, taskActionRoute.options, taskActionRoute.handler as Parameters[2]); +} + +type ResolvedTasks = + | { readonly kind: 'not_found' } + | { readonly kind: 'resolved'; readonly tasks: IAgentTaskService | undefined }; + +async function resolveSessionTasks(core: Scope, sid: string): Promise { + const summary = await core.accessor.get(ISessionIndex).get(sid); + if (summary === undefined) return { kind: 'not_found' }; + + const session = getLiveSessionById(core.accessor, sid); + if (session === undefined) return { kind: 'resolved', tasks: undefined }; + const agent = await ensureMainAgent(session); + const tasks = agent.accessor.get(IAgentTaskService); + return { kind: 'resolved', tasks }; +} + +function mapKind(k: AgentTaskInfo['kind']): TaskKind { + switch (k) { + case 'process': + return 'bash'; + case 'agent': + return 'subagent'; + case 'question': + return 'tool'; + } +} + +function mapStatus(s: AgentTaskInfo['status']): TaskStatus { + switch (s) { + case 'running': + return 'running'; + case 'completed': + return 'completed'; + case 'failed': + return 'failed'; + case 'timed_out': + return 'failed'; + case 'killed': + return 'cancelled'; + case 'lost': + return 'failed'; + } +} + +const TERMINAL_WIRE_STATUSES: ReadonlySet = new Set([ + 'completed', + 'failed', + 'cancelled', +]); + +function isTerminalStatus(status: TaskStatus): boolean { + return TERMINAL_WIRE_STATUSES.has(status); +} + +function toWireTask( + sessionId: string, + info: AgentTaskInfo, + output?: { preview: string; bytes: number }, +): Task { + const status = mapStatus(info.status); + const createdIso = new Date(info.startedAt).toISOString(); + const base: Task = { + id: info.taskId, + session_id: sessionId, + kind: mapKind(info.kind), + description: info.description, + status, + created_at: createdIso, + started_at: createdIso, + run_in_background: info.detached ?? true, + }; + if (info.endedAt !== null && info.endedAt !== undefined) { + base.completed_at = new Date(info.endedAt).toISOString(); + } + if (info.kind === 'process' && 'command' in info && typeof info.command === 'string') { + base.command = info.command; + } + if (info.kind === 'agent' && info.model !== undefined) { + base.model = info.model; + } + if (info.kind === 'agent' && info.thinkingEffort !== undefined) { + base.thinking_effort = info.thinkingEffort; + } + if (info.kind === 'agent' && info.agentId !== undefined) { + base.agent_id = info.agentId; + } + if (info.kind === 'agent' && info.subagentType !== undefined) { + base.subagent_type = info.subagentType; + } + if ( + (info.kind === 'agent' || info.kind === 'process') && + info.parentToolCallId !== undefined + ) { + base.parent_tool_call_id = info.parentToolCallId; + } + if (output !== undefined) { + base.output_preview = output.preview; + base.output_bytes = output.bytes; + } + return base; +} + +function sessionNotFound(sid: string, requestId: string): unknown { + return errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${sid} does not exist`, requestId); +} + +function taskNotFound(sid: string, tid: string, requestId: string): unknown { + return errEnvelope( + ErrorCode.TASK_NOT_FOUND, + `task ${tid} does not exist in session ${sid}`, + requestId, + ); +} + +function taskAlreadyFinished( + sid: string, + tid: string, + currentStatus: TaskStatus, + requestId: string, +): unknown { + return { + code: ErrorCode.TASK_ALREADY_FINISHED, + msg: `task ${tid} already finished (status: ${currentStatus})`, + data: { cancelled: false }, + request_id: requestId, + details: { current_status: currentStatus }, + }; +} diff --git a/packages/kap-server/src/routes/terminals.ts b/packages/kap-server/src/routes/terminals.ts new file mode 100644 index 0000000000000000000000000000000000000000..3dcd07b0ca1aa97ad295efc2b3305fa33eb85072 --- /dev/null +++ b/packages/kap-server/src/routes/terminals.ts @@ -0,0 +1,227 @@ +import { + ErrorCodes, + ISessionTerminalService, + resumeSessionById, + isError2, + Error2, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + closeTerminalResponseSchema, + createTerminalRequestSchema, + getTerminalResponseSchema, + listTerminalsResponseSchema, +} from '../protocol/rest-terminal'; +import { parseActionSuffix } from './action-suffix'; + +interface TerminalsRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const sessionAndTerminalIdParamSchema = z.object({ + session_id: z.string().min(1), + terminal_id: z.string().min(1), +}); + +const sessionAndTailParamSchema = z.object({ + session_id: z.string().min(1), + tail: z.string().min(1), +}); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +async function resolveTerminal(core: Scope, sessionId: string): Promise { + const session = await resumeSessionById(core.accessor, sessionId); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + } + return session.accessor.get(ISessionTerminalService); +} + +export function registerTerminalsRoutes(app: TerminalsRouteHost, core: Scope): void { + const listRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/terminals', + params: sessionIdParamSchema, + success: { data: listTerminalsResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'List terminals for a session', + tags: ['terminals'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const items = await (await resolveTerminal(core, session_id)).list(); + reply.send(okEnvelope({ items }, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.get( + listRoute.path, + listRoute.options, + listRoute.handler as Parameters[2], + ); + + const createRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/terminals', + params: sessionIdParamSchema, + body: createTerminalRequestSchema, + success: { data: getTerminalResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.FS_PATH_ESCAPES_SESSION]: {}, + }, + description: 'Create a terminal for a session', + tags: ['terminals'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const session = await resumeSessionById(core.accessor, session_id); + if (session === undefined) throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${session_id} does not exist`); + const terminal = await session.accessor.get(ISessionTerminalService).create({ ...req.body, runtime_id: req.body.runtime_id ?? 'local' }); + requestLog(req)?.info({ session_id, terminal_id: terminal.id }, 'terminal created'); + reply.send(okEnvelope(terminal, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.post( + createRoute.path, + createRoute.options, + createRoute.handler as Parameters[2], + ); + + const getRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/terminals/{terminal_id}', + params: sessionAndTerminalIdParamSchema, + success: { data: getTerminalResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.TERMINAL_NOT_FOUND]: {}, + }, + description: 'Get a terminal by ID', + tags: ['terminals'], + }, + async (req, reply) => { + try { + const { session_id, terminal_id } = req.params; + const terminal = await (await resolveTerminal(core, session_id)).get(terminal_id); + reply.send(okEnvelope(terminal, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.get( + getRoute.path, + getRoute.options, + getRoute.handler as Parameters[2], + ); + + const closeRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/terminals/{tail}', + params: sessionAndTailParamSchema, + success: { data: closeTerminalResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.TERMINAL_NOT_FOUND]: {}, + }, + description: 'Close a terminal', + tags: ['terminals'], + operationId: 'closeTerminal', + }, + async (req, reply) => { + try { + const { session_id, tail } = req.params; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['close'] as const, + resourceLabel: 'terminal', + }); + if (parsed.kind !== 'action') { + const message = + parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${tail}`; + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); + return; + } + const result = await (await resolveTerminal(core, session_id)).close(parsed.id); + requestLog(req)?.info({ session_id, terminal_id: parsed.id }, 'terminal closed'); + reply.send(okEnvelope(result, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.post( + closeRoute.path, + closeRoute.options, + closeRoute.handler as Parameters[2], + ); +} + +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (isError2(err)) { + switch (err.code) { + case ErrorCodes.SESSION_NOT_FOUND: + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack)); + return; + case ErrorCodes.TERMINAL_NOT_FOUND: + reply.send(errEnvelope(ErrorCode.TERMINAL_NOT_FOUND, err.message, requestId, err.stack)); + return; + case ErrorCodes.FS_PATH_ESCAPES: + reply.send(errEnvelope(ErrorCode.FS_PATH_ESCAPES_SESSION, err.message, requestId, err.stack)); + return; + } + } + if (err instanceof Error && err.message.startsWith('Path outside workspace')) { + reply.send(errEnvelope(ErrorCode.FS_PATH_ESCAPES_SESSION, err.message, requestId, err.stack)); + return; + } + throw err; +} diff --git a/packages/kap-server/src/routes/tools.ts b/packages/kap-server/src/routes/tools.ts new file mode 100644 index 0000000000000000000000000000000000000000..0cc6b1d00bbeb3c8333fd91e7045393f1842b5d5 --- /dev/null +++ b/packages/kap-server/src/routes/tools.ts @@ -0,0 +1,262 @@ +import { + ErrorCodes, + IAgentMcpService, + ISessionIndex, + IAgentToolRegistryService, + IAgentToolPolicyService, + getLiveSessionById, + Error2, + type Scope, + type ToolInfo, + type ToolSource, +} from '@moonshot-ai/agent-core-v2'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { ensureMainAgent } from '../transport/mainAgent'; +import { ErrorCode } from '../protocol/error-codes'; +import { + listMcpServersResponseSchema, + listToolsQuerySchema, + listToolsResponseSchema, + restartMcpServerResultSchema, +} from '../protocol/rest-tool'; +import type { McpServer, ToolDescriptor } from '../protocol/tool'; +import { parseActionSuffix } from './action-suffix'; + +const MCP_NAME_PREFIX = 'mcp__'; +const MCP_NAME_SEPARATOR = '__'; + +type McpEntry = ReturnType[number]; + +interface ToolsRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +export function registerToolsRoutes(app: ToolsRouteHost, core: Scope): void { + const listToolsRoute = defineRoute( + { + method: 'GET', + path: '/tools', + querystring: listToolsQuerySchema, + success: { data: listToolsResponseSchema }, + description: 'List available tools', + tags: ['tools'], + }, + async (req, reply) => { + const agent = await resolveEffectiveAgent(core, req.query.session_id); + if (agent === undefined) { + reply.send(okEnvelope({ tools: [] }, req.id)); + return; + } + const registry = agent.accessor.get(IAgentToolRegistryService); + const policy = agent.accessor.get(IAgentToolPolicyService); + const tools = registry + .list() + .map((info) => toProtocolTool(info, policy.isToolActive(info.name, info.source))); + reply.send(okEnvelope({ tools }, req.id)); + }, + ); + app.get( + listToolsRoute.path, + listToolsRoute.options, + listToolsRoute.handler as Parameters[2], + ); + + const listMcpServersRoute = defineRoute( + { + method: 'GET', + path: '/mcp/servers', + success: { data: listMcpServersResponseSchema }, + description: 'List configured MCP servers', + tags: ['tools'], + }, + async (req, reply) => { + const agent = await resolveEffectiveAgent(core, undefined); + const servers = + agent === undefined + ? [] + : agent.accessor.get(IAgentMcpService).list().map(toProtocolMcpServer); + reply.send(okEnvelope({ servers }, req.id)); + }, + ); + app.get( + listMcpServersRoute.path, + listMcpServersRoute.options, + listMcpServersRoute.handler as Parameters[2], + ); + + const restartMcpServerRoute = defineRoute( + { + method: 'POST', + path: '/mcp/servers/{tail}', + success: { data: restartMcpServerResultSchema }, + errors: { + [ErrorCode.MCP_SERVER_NOT_FOUND]: {}, + }, + description: 'Restart an MCP server by ID', + tags: ['tools'], + operationId: 'restartMcpServer', + }, + async (req, reply) => { + const { tail } = req.params as { tail: string }; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['restart'] as const, + resourceLabel: 'mcp_server', + }); + if (parsed.kind === 'invalid') { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id)); + return; + } + if (parsed.kind === 'bare') { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `unsupported action: ${tail}`, req.id), + ); + return; + } + + const agent = await resolveEffectiveAgent(core, undefined); + if (agent === undefined) { + reply.send(mcpServerNotFound(parsed.id, req.id)); + return; + } + const mcp = agent.accessor.get(IAgentMcpService); + if (!mcp.list().some((entry) => entry.name === parsed.id)) { + reply.send(mcpServerNotFound(parsed.id, req.id)); + return; + } + try { + await mcp.reconnect(parsed.id); + reply.send(okEnvelope({ restarting: true }, req.id)); + } catch (error) { + sendMappedError(reply, req.id, error); + } + }, + ); + app.post( + restartMcpServerRoute.path, + restartMcpServerRoute.options, + restartMcpServerRoute.handler as Parameters[2], + ); +} + +async function resolveEffectiveAgent(core: Scope, sessionId: string | undefined) { + const sid = sessionId ?? (await mostRecentSessionId(core)); + if (sid === undefined) return undefined; + const session = getLiveSessionById(core.accessor, sid); + if (session === undefined) return undefined; + return ensureMainAgent(session); +} + +async function mostRecentSessionId(core: Scope): Promise { + const page = await core.accessor.get(ISessionIndex).listRecent({}); + const [first, ...rest] = page.items; + if (first === undefined) return undefined; + let newest = first; + for (const item of rest) { + if (item.createdAt > newest.createdAt) newest = item; + } + return newest.id; +} + +function mapToolSource(source: ToolSource): ToolDescriptor['source'] { + switch (source) { + case 'builtin': + return 'builtin'; + case 'user': + return 'skill'; + case 'mcp': + return 'mcp'; + } +} + +function parseMcpServerId(toolName: string): string | undefined { + if (!toolName.startsWith(MCP_NAME_PREFIX)) return undefined; + const rest = toolName.slice(MCP_NAME_PREFIX.length); + const sep = rest.indexOf(MCP_NAME_SEPARATOR); + if (sep <= 0) return undefined; + return rest.slice(0, sep); +} + +function toProtocolTool(info: ToolInfo, active: boolean): ToolDescriptor { + const source = mapToolSource(info.source); + const base: ToolDescriptor = { + name: info.name, + description: info.description, + input_schema: null, + source, + active, + }; + if (source === 'mcp') { + const serverId = parseMcpServerId(info.name); + if (serverId !== undefined) return { ...base, mcp_server_id: serverId }; + } + return base; +} + +function mapMcpStatus(status: McpEntry['status']): McpServer['status'] { + switch (status) { + case 'pending': + return 'connecting'; + case 'connected': + return 'connected'; + case 'disabled': + return 'disconnected'; + case 'removed': + return 'disconnected'; + case 'failed': + return 'error'; + case 'needs-auth': + return 'error'; + } +} + +function toProtocolMcpServer(entry: McpEntry): McpServer { + const base: McpServer = { + id: entry.name, + name: entry.name, + transport: entry.transport, + status: mapMcpStatus(entry.status), + tool_count: entry.toolCount, + }; + if (entry.error !== undefined && entry.error.length > 0) { + return { ...base, last_error: entry.error }; + } + return base; +} + +function mcpServerNotFound(serverId: string, requestId: string): unknown { + return errEnvelope( + ErrorCode.MCP_SERVER_NOT_FOUND, + `MCP server ${serverId} does not exist`, + requestId, + ); +} + +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (err instanceof Error2 && err.code === ErrorCodes.MCP_SERVER_NOT_FOUND) { + reply.send(errEnvelope(ErrorCode.MCP_SERVER_NOT_FOUND, err.message, requestId, err.stack)); + return; + } + throw err; +} diff --git a/packages/kap-server/src/routes/transcript.ts b/packages/kap-server/src/routes/transcript.ts new file mode 100644 index 0000000000000000000000000000000000000000..069f197b16840a5bdc68985508b5f8d226ff4d2a --- /dev/null +++ b/packages/kap-server/src/routes/transcript.ts @@ -0,0 +1,584 @@ +import { MAIN_AGENT_ID, type Scope } from '@moonshot-ai/agent-core-v2'; +import { + isPlainAgentId, + paginateTurns, + transcriptOpsCatchupResponseSchema, + transcriptPlanResponseSchema, + transcriptResponseSchema, + transcriptUserMessagesResponseSchema, + type ToolCallFrame, + type TranscriptAttachment, + type TranscriptInteraction, + type TranscriptItem, + type TurnOrigin, + type TurnState, +} from '@moonshot-ai/transcript'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { ErrorCode } from '../protocol/error-codes'; +import { defineRoute } from '../middleware/defineRoute'; +import type { TranscriptService } from '../services/transcript/transcriptService'; + +interface TranscriptRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const transcriptQueryCoercion = z + .object({ + agent_id: z.string().min(1), + before_turn: z.string().min(1).optional(), + after_turn: z.string().min(1).optional(), + page_size: z.coerce.number().int().min(1).max(100).optional(), + }) + .superRefine((value, ctx) => { + if (value.before_turn !== undefined && value.after_turn !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'before_turn and after_turn are mutually exclusive', + path: ['before_turn'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + if (!isPlainAgentId(value.agent_id)) { + ctx.addIssue({ + code: 'custom', + message: 'agent_id must be a plain agent id (no path separators)', + path: ['agent_id'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + }); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +const transcriptOpsQueryCoercion = z + .object({ + agent_id: z.string().min(1), + since_seq: z.coerce.number().int().min(0), + }) + .superRefine((value, ctx) => { + if (!isPlainAgentId(value.agent_id)) { + ctx.addIssue({ + code: 'custom', + message: 'agent_id must be a plain agent id (no path separators)', + path: ['agent_id'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + }); + +const DEFAULT_PAGE_SIZE = 20; + +const userMessagesQueryCoercion = z + .object({ + agent_id: z.string().min(1).optional(), + }) + .superRefine((value, ctx) => { + if (value.agent_id !== undefined && !isPlainAgentId(value.agent_id)) { + ctx.addIssue({ + code: 'custom', + message: 'agent_id must be a plain agent id (no path separators)', + path: ['agent_id'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + }); + +const planQueryCoercion = z + .object({ + agent_id: z.string().min(1), + tool_call_id: z.string().min(1).optional(), + }) + .superRefine((value, ctx) => { + if (!isPlainAgentId(value.agent_id)) { + ctx.addIssue({ + code: 'custom', + message: 'agent_id must be a plain agent id (no path separators)', + path: ['agent_id'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + }); + +export interface TranscriptRouteDeps { + readonly core: Scope; + readonly transcriptService: TranscriptService; +} + +export function registerTranscriptRoutes(app: TranscriptRouteHost, deps: TranscriptRouteDeps): void { + const { transcriptService } = deps; + + const route = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/transcript', + params: sessionIdParamSchema, + querystring: transcriptQueryCoercion, + success: { data: transcriptResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: + 'Turn-granular session transcript page: live sessions read the in-memory store (wire-records backfill awaited per requested agent), cold sessions rebuild the requested agent from the persisted wire records', + tags: ['transcript'], + }, + async (req, reply) => { + const { session_id } = req.params; + const query = req.query; + const pageQuery = { + beforeTurn: query.before_turn, + afterTurn: query.after_turn, + pageSize: query.page_size ?? DEFAULT_PAGE_SIZE, + }; + + const store = transcriptService.forSessionLive(session_id); + if (store !== undefined) { + await transcriptService.whenReady(session_id); + await transcriptService.ensureAgentHistory(session_id, query.agent_id); + const transcript = store.ensureAgent(query.agent_id); + const page = paginateTurns(transcript.getItems(), pageQuery); + reply.send( + okEnvelope( + { + agent_id: query.agent_id, + items: page.items, + has_more: page.hasMore, + tasks: [...transcript.getTasks().values()], + interactions: [...transcript.getInteractions().values()], + attachments: [...transcript.getAttachments().values()], + todos: [...transcript.getTodos().values()], + prompts: [...transcript.getPrompts().values()], + meta: transcript.getMeta(), + agents: store.agents(), + pending_interactions: transcript.listPendingInteractions(), + seq: transcriptService.getSeqWatermark(session_id, query.agent_id), + }, + req.id, + ), + ); + return; + } + + const snapshot = await transcriptService.readColdSnapshot(session_id, query.agent_id); + if (snapshot === undefined) { + sendSessionNotFound(reply, req.id, session_id); + return; + } + const page = paginateTurns(snapshot.items, pageQuery); + const roster = (await transcriptService.readColdRoster(session_id)) ?? []; + if ( + !roster.some((d) => d.agentId === query.agent_id) && + (snapshot.items.length > 0 || snapshot.tasks.length > 0 || query.agent_id === MAIN_AGENT_ID) + ) { + roster.push({ + agentId: query.agent_id, + type: query.agent_id === MAIN_AGENT_ID ? ('main' as const) : ('sub' as const), + }); + } + reply.send( + okEnvelope( + { + agent_id: query.agent_id, + items: page.items, + has_more: page.hasMore, + tasks: snapshot.tasks, + interactions: snapshot.interactions, + attachments: snapshot.attachments, + todos: snapshot.todos, + prompts: snapshot.prompts, + meta: snapshot.meta, + agents: roster, + pending_interactions: [], + }, + req.id, + ), + ); + }, + ); + app.get(route.path, route.options, route.handler as Parameters[2]); + + const opsRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/transcript/ops', + params: sessionIdParamSchema, + querystring: transcriptOpsQueryCoercion, + success: { data: transcriptOpsCatchupResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: + 'Point-to-point transcript catch-up: journaled op batches with seq > since_seq for one agent, oldest first. complete:false means the session is not live or the journal no longer reaches back to since_seq — the caller must fall back to a full transcript refresh', + tags: ['transcript'], + }, + async (req, reply) => { + const { session_id } = req.params; + const query = req.query; + + const catchup = transcriptService.getOpsSince(session_id, query.agent_id, query.since_seq); + if (catchup === undefined) { + const roster = await transcriptService.readColdRoster(session_id); + if (roster === undefined) { + sendSessionNotFound(reply, req.id, session_id); + return; + } + reply.send( + okEnvelope( + { agent_id: query.agent_id, batches: [], latest_seq: 0, complete: false }, + req.id, + ), + ); + return; + } + reply.send( + okEnvelope( + { + agent_id: query.agent_id, + batches: catchup.batches, + latest_seq: catchup.latestSeq, + complete: catchup.complete, + }, + req.id, + ), + ); + }, + ); + app.get(opsRoute.path, opsRoute.options, opsRoute.handler as Parameters[2]); + + const userMessagesRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/transcript/user-messages', + params: sessionIdParamSchema, + querystring: userMessagesQueryCoercion, + success: { data: transcriptUserMessagesResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: + 'All turn-opening inputs ("user messages") of a session, grouped per agent: every turn with a defined prompt (real user text, user-slash skill/plugin commands, cron prompts — distinguish via origin), plus attachment-only prompts projected with an empty prompt string. agent_id optional: present reads one agent, absent reads every rostered agent. Live sessions answer from the in-memory store (history backfill awaited per agent), cold sessions rebuild from the persisted wire records. Unpaginated; attachment entities referenced by the messages ride along (metadata only)', + tags: ['transcript'], + }, + async (req, reply) => { + const { session_id } = req.params; + const { agent_id } = req.query; + + const store = transcriptService.forSessionLive(session_id); + if (store !== undefined) { + await transcriptService.whenReady(session_id); + const agentIds = + agent_id !== undefined ? [agent_id] : store.agents().map((d) => d.agentId); + const agents = []; + for (const agentId of agentIds) { + await transcriptService.ensureAgentHistory(session_id, agentId); + const transcript = store.ensureAgent(agentId); + const attachments = transcript.getAttachments(); + agents.push({ + agent_id: agentId, + ...projectUserMessages(transcript.getItems(), (id) => attachments.get(id)), + }); + } + reply.send(okEnvelope({ agents }, req.id)); + return; + } + + const roster = await transcriptService.readColdRoster(session_id); + if (roster === undefined) { + sendSessionNotFound(reply, req.id, session_id); + return; + } + const agentIds = agent_id !== undefined ? [agent_id] : roster.map((d) => d.agentId); + if (agent_id === undefined && !agentIds.includes(MAIN_AGENT_ID)) { + agentIds.unshift(MAIN_AGENT_ID); + } + const agents = []; + for (const agentId of agentIds) { + const snapshot = await transcriptService.readColdSnapshot(session_id, agentId); + if (snapshot === undefined) { + sendSessionNotFound(reply, req.id, session_id); + return; + } + const byId = new Map(snapshot.attachments.map((a) => [a.attachmentId, a])); + agents.push({ + agent_id: agentId, + ...projectUserMessages(snapshot.items, (id) => byId.get(id)), + }); + } + reply.send(okEnvelope({ agents }, req.id)); + }, + ); + app.get( + userMessagesRoute.path, + userMessagesRoute.options, + userMessagesRoute.handler as Parameters[2], + ); + + const planRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/transcript/plan', + params: sessionIdParamSchema, + querystring: planQueryCoercion, + success: { data: transcriptPlanResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.TOOL_CALL_NOT_FOUND]: {}, + }, + description: + 'Plan information of an agent\'s ExitPlanMode tool calls: the reviewed plan content, plan file path, offered options, and the review outcome, in timeline order. agent_id required; tool_call_id optional — present narrows the read to that one call (unknown id or non-ExitPlanMode call → 40416), absent lists every call with recoverable plan content. Content is projected from the linked approval interaction (interactive reviews, live or cold), the live tool frame display (auto mode), or the tool result output text (cold rebuilds without an interaction). Live sessions read the in-memory store (history backfill awaited), cold sessions rebuild the agent from the persisted wire records', + tags: ['transcript'], + }, + async (req, reply) => { + const { session_id } = req.params; + const { agent_id, tool_call_id } = req.query; + + const store = transcriptService.forSessionLive(session_id); + if (store !== undefined) { + await transcriptService.whenReady(session_id); + await transcriptService.ensureAgentHistory(session_id, agent_id); + const transcript = store.ensureAgent(agent_id); + const plans = projectPlans( + transcript.getItems(), + [...transcript.getInteractions().values()], + tool_call_id, + ); + if (tool_call_id !== undefined && plans.length === 0) { + sendToolCallNotFound(reply, req.id, tool_call_id); + return; + } + reply.send(okEnvelope({ agent_id, plans }, req.id)); + return; + } + + const snapshot = await transcriptService.readColdSnapshot(session_id, agent_id); + if (snapshot === undefined) { + sendSessionNotFound(reply, req.id, session_id); + return; + } + const plans = projectPlans(snapshot.items, snapshot.interactions, tool_call_id); + if (tool_call_id !== undefined && plans.length === 0) { + sendToolCallNotFound(reply, req.id, tool_call_id); + return; + } + reply.send(okEnvelope({ agent_id, plans }, req.id)); + }, + ); + app.get(planRoute.path, planRoute.options, planRoute.handler as Parameters[2]); +} + +interface UserMessageEntry { + turn_id: string; + ordinal: number; + state: TurnState; + origin: TurnOrigin; + prompt: string; + attachment_ids?: readonly string[]; + started_at?: string; +} + +function projectUserMessages( + items: readonly TranscriptItem[], + resolveAttachment: (id: string) => TranscriptAttachment | undefined, +): { messages: UserMessageEntry[]; attachments: TranscriptAttachment[] } { + const messages: UserMessageEntry[] = []; + const attachments = new Map(); + for (const item of items) { + if (item.kind !== 'turn') continue; + const hasAttachments = item.attachmentIds !== undefined && item.attachmentIds.length > 0; + if (item.prompt === undefined && !hasAttachments) continue; + messages.push({ + turn_id: item.turnId, + ordinal: item.ordinal, + state: item.state, + origin: item.origin, + prompt: item.prompt ?? '', + attachment_ids: item.attachmentIds, + started_at: item.startedAt, + }); + for (const id of item.attachmentIds ?? []) { + const attachment = resolveAttachment(id); + if (attachment !== undefined) attachments.set(id, attachment); + } + } + return { messages, attachments: [...attachments.values()] }; +} + +function sendSessionNotFound( + reply: { send(payload: unknown): unknown }, + requestId: string, + sessionId: string, +): void { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session not found: ${sessionId}`, requestId), + ); +} + +function sendToolCallNotFound( + reply: { send(payload: unknown): unknown }, + requestId: string, + toolCallId: string, +): void { + reply.send( + errEnvelope( + ErrorCode.TOOL_CALL_NOT_FOUND, + `no ExitPlanMode tool call found for tool_call_id: ${toolCallId}`, + requestId, + ), + ); +} + +interface PlanReviewInfo { + state: 'pending' | 'approved' | 'rejected' | 'cancelled'; + selected_option?: string; + feedback?: string; +} + +interface PlanInfo { + tool_call_id: string; + turn_id: string; + source: 'interaction' | 'display' | 'output'; + plan: string; + path?: string; + options?: { label: string; description?: string }[]; + review?: PlanReviewInfo; +} + +interface PlanReviewDisplayInfo { + plan: string; + path?: string; + options?: { label: string; description?: string }[]; +} + +function projectPlans( + items: readonly TranscriptItem[], + interactions: readonly TranscriptInteraction[], + toolCallId?: string, +): PlanInfo[] { + const plans: PlanInfo[] = []; + for (const item of items) { + if (item.kind !== 'turn') continue; + for (const step of item.steps) { + for (const frame of step.frames) { + if (frame.kind !== 'tool' || frame.name !== 'ExitPlanMode') continue; + if (toolCallId !== undefined && frame.toolCallId !== toolCallId) continue; + const info = projectPlanFrame(item.turnId, frame, interactions); + if (info !== undefined) plans.push(info); + } + } + } + return plans; +} + +function projectPlanFrame( + turnId: string, + frame: ToolCallFrame, + interactions: readonly TranscriptInteraction[], +): PlanInfo | undefined { + const toolCallId = frame.toolCallId; + const interaction = interactions.find( + (i) => i.interactionKind === 'approval' && i.toolCallId === toolCallId, + ); + const review = readPlanReview(interaction); + + const requestDisplay = + interaction !== undefined && interaction.request !== null && typeof interaction.request === 'object' + ? (interaction.request as { display?: unknown }).display + : undefined; + const fromInteraction = readPlanReviewDisplay(requestDisplay); + if (fromInteraction !== undefined) { + return { tool_call_id: toolCallId, turn_id: turnId, source: 'interaction', ...fromInteraction, review }; + } + const fromDisplay = readPlanReviewDisplay(frame.display); + if (fromDisplay !== undefined) { + return { tool_call_id: toolCallId, turn_id: turnId, source: 'display', ...fromDisplay, review }; + } + const fromOutput = parsePlanFromOutput(frame.output); + if (fromOutput !== undefined) { + return { tool_call_id: toolCallId, turn_id: turnId, source: 'output', ...fromOutput, review }; + } + return undefined; +} + +function readPlanReview(interaction: TranscriptInteraction | undefined): PlanReviewInfo | undefined { + if (interaction === undefined) return undefined; + const state = interaction.state; + if (state !== 'pending' && state !== 'approved' && state !== 'rejected' && state !== 'cancelled') { + return undefined; + } + const response = + interaction.response !== null && typeof interaction.response === 'object' + ? (interaction.response as { selectedLabel?: unknown; feedback?: unknown }) + : undefined; + const selected = + typeof response?.selectedLabel === 'string' && response.selectedLabel.length > 0 + ? response.selectedLabel + : undefined; + const feedback = + typeof response?.feedback === 'string' && response.feedback.length > 0 + ? response.feedback + : undefined; + return { state, selected_option: selected, feedback }; +} + +function readPlanReviewDisplay(display: unknown): PlanReviewDisplayInfo | undefined { + if (display === null || typeof display !== 'object') return undefined; + const d = display as { kind?: unknown; plan?: unknown; path?: unknown; options?: unknown }; + if (d.kind !== 'plan_review' || typeof d.plan !== 'string' || d.plan.trim().length === 0) { + return undefined; + } + const options = Array.isArray(d.options) + ? d.options + .map((option: unknown): { label: string; description?: string } | null => { + if (option === null || typeof option !== 'object') return null; + const o = option as { label?: unknown; description?: unknown }; + if (typeof o.label !== 'string' || o.label.length === 0) return null; + return { + label: o.label, + description: typeof o.description === 'string' ? o.description : undefined, + }; + }) + .filter((o): o is { label: string; description?: string } => o !== null) + : undefined; + return { + plan: d.plan, + path: typeof d.path === 'string' ? d.path : undefined, + options: options !== undefined && options.length > 0 ? options : undefined, + }; +} + +const PLAN_SAVED_TO_MARKER = 'Plan saved to: '; +const PLAN_BODY_MARKERS = ['## Approved Plan:\n', '## Plan (auto-approved, not user-reviewed):\n']; + +function parsePlanFromOutput(output: unknown): { plan: string; path?: string } | undefined { + if (typeof output !== 'string') return undefined; + let path: string | undefined; + for (const line of output.split('\n')) { + if (line.startsWith(PLAN_SAVED_TO_MARKER)) { + path = line.slice(PLAN_SAVED_TO_MARKER.length).trim() || undefined; + break; + } + } + for (const marker of PLAN_BODY_MARKERS) { + const index = output.indexOf(marker); + if (index === -1) continue; + const plan = output.slice(index + marker.length); + if (plan.trim().length > 0) return { plan, path }; + } + return undefined; +} diff --git a/packages/kap-server/src/routes/v2/mcp.ts b/packages/kap-server/src/routes/v2/mcp.ts new file mode 100644 index 0000000000000000000000000000000000000000..9976e774cb91a63a419bb1f19c922100f5e3df5d --- /dev/null +++ b/packages/kap-server/src/routes/v2/mcp.ts @@ -0,0 +1,548 @@ +import type { ServerResponse } from 'node:http'; + +import { + ErrorCodes, + IMcpManagementService, + isError2, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { + McpServerHttpConfigSchema, + McpServerSseConfigSchema, + McpServerStdioConfigSchema, +} from '@moonshot-ai/agent-core-v2/mcpCore/config-schema'; +import { z } from 'zod'; + +import { defineRoute } from '../../middleware/defineRoute'; +import { errEnvelope, okEnvelope } from '../../protocol/envelope'; +import { ErrorCode } from '../../protocol/error-codes'; + +interface V2McpRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + put( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + delete( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const serverNameSchema = z.string().min(1); + +const serverNameParamSchema = z.object({ name: serverNameSchema }); + +const serverScopedQuerySchema = z.object({ cwd: z.string().min(1).optional() }); + +const authStatusesQuerySchema = z.object({ + cwd: z.string().min(1).optional(), + verify: z.enum(['true', 'false']).optional(), +}); + +const globalMcpServerConfigSchema = z.discriminatedUnion('transport', [ + McpServerStdioConfigSchema.extend({ name: serverNameSchema }), + McpServerHttpConfigSchema.extend({ name: serverNameSchema }), + McpServerSseConfigSchema.extend({ name: serverNameSchema }), +]); + +const mcpServerConfigBodySchema = z.discriminatedUnion('transport', [ + McpServerStdioConfigSchema, + McpServerHttpConfigSchema, + McpServerSseConfigSchema, +]); + +const testServerBodySchema = z.object({ + name: serverNameSchema.optional(), + server: globalMcpServerConfigSchema.optional(), + cwd: z.string().min(1).optional(), +}); + +const mcpServerLocatorSchema = z.discriminatedUnion('source', [ + z.object({ source: z.literal('global'), name: serverNameSchema }), + z.object({ + source: z.literal('plugin'), + pluginId: z.string().min(1), + serverName: z.string().min(1), + }), +]); + +const inspectServersBodySchema = z.object({ + targets: z.array(mcpServerLocatorSchema).optional(), + cwd: z.string().min(1).optional(), +}); + +const authCompleteBodySchema = z.object({ + flowId: z.string().min(1), + timeoutMs: z + .number() + .int() + .min(1) + .max(2 ** 31 - 1) + .optional(), +}); + +const authCancelBodySchema = z.object({ flowId: z.string().min(1) }); + +const mcpServerSourceSchema = z.enum(['global', 'plugin', 'caller']); + +const mcpServerAuthStateSchema = z.enum([ + 'not-applicable', + 'bearer-token', + 'oauth-required', + 'oauth-authorized', + 'oauth-expired', + 'unavailable', +]); + +const mcpServerConfigDataSchema = z.union([ + McpServerStdioConfigSchema.extend({ envKeys: z.array(z.string()).optional() }), + McpServerHttpConfigSchema.extend({ headerKeys: z.array(z.string()).optional() }), + McpServerSseConfigSchema.extend({ headerKeys: z.array(z.string()).optional() }), +]); + +const mcpManagedServerSchema = z.object({ + name: z.string(), + config: mcpServerConfigDataSchema, + source: mcpServerSourceSchema, + origin: z.string(), + mutable: z.boolean(), + plugin: z.object({ id: z.string(), name: z.string() }).optional(), +}); + +const mcpServerTestResultSchema = z.object({ + success: z.boolean(), + output: z.string(), +}); + +const mcpServerAuthStatusSchema = z.object({ + name: z.string(), + authStatus: mcpServerAuthStateSchema, +}); + +const mcpServerInspectionSchema = z.object({ + serverId: z.string(), + locator: mcpServerLocatorSchema, + runtimeName: z.string(), + canonicalUrl: z.string().optional(), + origin: mcpServerSourceSchema, + config: mcpServerConfigDataSchema, + enabled: z.boolean(), + editable: z.boolean(), + authStatus: mcpServerAuthStateSchema, + checkedAt: z.number().optional(), + error: z.string().optional(), +}); + +const mcpServerAuthBeginResultSchema = z.union([ + z.object({ + status: z.literal('authorization-required'), + flowId: z.string(), + authorizationUrl: z.string(), + }), + z.object({ status: z.literal('already-authorized') }), +]); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +const baseErrorSchemas = { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, +}; + +const namedServerErrorSchemas = { + ...baseErrorSchemas, + [ErrorCode.MCP_SERVER_NOT_FOUND]: {}, +}; + +const oauthErrorSchemas = { + ...baseErrorSchemas, + [ErrorCode.MCP_OAUTH_FAILED]: {}, +}; + +const namedServerOAuthErrorSchemas = { + ...namedServerErrorSchemas, + [ErrorCode.MCP_OAUTH_FAILED]: {}, +}; + +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (isError2(err)) { + switch (err.code) { + case ErrorCodes.MCP_SERVER_NOT_FOUND: + reply.send(errEnvelope(ErrorCode.MCP_SERVER_NOT_FOUND, err.message, requestId, err.stack)); + return; + case ErrorCodes.REQUEST_INVALID: + case ErrorCodes.CONFIG_INVALID: + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack)); + return; + case ErrorCodes.MCP_OAUTH_FAILED: + reply.send(errEnvelope(ErrorCode.MCP_OAUTH_FAILED, err.message, requestId, err.stack)); + return; + } + } + throw err; +} + +export function registerV2McpRoutes(app: V2McpRouteHost, core: Scope): void { + const management = (): IMcpManagementService => core.accessor.get(IMcpManagementService); + + const listServersRoute = defineRoute( + { + method: 'GET', + path: '/mcp/servers', + querystring: serverScopedQuerySchema, + success: { data: z.array(mcpManagedServerSchema) }, + errors: baseErrorSchemas, + description: + 'List every MCP server the management plane knows about (user-level file, plugin manifests; project layers join when `cwd` is given). Read-only entries carry redacted configs.', + tags: ['v2-mcp'], + }, + async (req, reply) => { + try { + const servers = await management().listServers({ cwd: req.query.cwd }); + reply.send(okEnvelope(servers, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.get( + listServersRoute.path, + (listServersRoute.options), + listServersRoute.handler as Parameters[2], + ); + + const getServerRoute = defineRoute( + { + method: 'GET', + path: '/mcp/servers/{name}', + params: serverNameParamSchema, + querystring: serverScopedQuerySchema, + success: { data: mcpManagedServerSchema }, + errors: namedServerErrorSchemas, + description: 'Get one MCP server by runtime name (`40408` when unknown).', + tags: ['v2-mcp'], + }, + async (req, reply) => { + try { + const server = await management().getServer(req.params.name, { cwd: req.query.cwd }); + reply.send(okEnvelope(server, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.get( + getServerRoute.path, + (getServerRoute.options), + getServerRoute.handler as Parameters[2], + ); + + const addServerRoute = defineRoute( + { + method: 'POST', + path: '/mcp/servers', + querystring: serverScopedQuerySchema, + body: globalMcpServerConfigSchema, + success: { data: z.array(mcpManagedServerSchema) }, + errors: baseErrorSchemas, + description: + 'Add a server to the user-level `mcp.json`; a same-named read-only entry (plugin / project layer) is rejected. Returns the refreshed list.', + tags: ['v2-mcp'], + }, + async (req, reply) => { + try { + const servers = await management().addServer(req.body, { cwd: req.query.cwd }); + reply.send(okEnvelope(servers, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.post( + addServerRoute.path, + (addServerRoute.options), + addServerRoute.handler as Parameters[2], + ); + + const updateServerRoute = defineRoute( + { + method: 'PUT', + path: '/mcp/servers/{name}', + params: serverNameParamSchema, + querystring: serverScopedQuerySchema, + body: mcpServerConfigBodySchema, + success: { data: z.array(mcpManagedServerSchema) }, + errors: namedServerErrorSchemas, + description: + 'Replace the user-level entry named in the path (the body carries no `name`); read-only entries reject the write. Returns the refreshed list.', + tags: ['v2-mcp'], + }, + async (req, reply) => { + try { + const servers = await management().updateServer( + { ...req.body, name: req.params.name }, + { cwd: req.query.cwd }, + ); + reply.send(okEnvelope(servers, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.put( + updateServerRoute.path, + (updateServerRoute.options), + updateServerRoute.handler as Parameters[2], + ); + + const removeServerRoute = defineRoute( + { + method: 'DELETE', + path: '/mcp/servers/{name}', + params: serverNameParamSchema, + querystring: serverScopedQuerySchema, + success: { data: z.array(mcpManagedServerSchema) }, + errors: namedServerErrorSchemas, + description: + 'Remove a user-level entry; read-only entries reject the delete. Returns the refreshed list.', + tags: ['v2-mcp'], + }, + async (req, reply) => { + try { + const servers = await management().removeServer(req.params.name, { cwd: req.query.cwd }); + reply.send(okEnvelope(servers, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.delete( + removeServerRoute.path, + (removeServerRoute.options), + removeServerRoute.handler as Parameters[2], + ); + + const testServerRoute = defineRoute( + { + method: 'POST', + path: '/mcp/servers::test', + body: testServerBodySchema, + success: { data: mcpServerTestResultSchema }, + errors: namedServerErrorSchemas, + description: + 'Probe a real connection to one server: pass `name` to test a registry entry (plugin and project layers included) or an inline `server` config to probe it as-is. Never persists anything.', + tags: ['v2-mcp'], + }, + async (req, reply) => { + try { + const result = await management().testServer(req.body); + reply.send(okEnvelope(result, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.post( + testServerRoute.path, + (testServerRoute.options), + testServerRoute.handler as Parameters[2], + ); + + const inspectServersRoute = defineRoute( + { + method: 'POST', + path: '/mcp/servers::inspect', + body: inspectServersBodySchema, + success: { data: z.array(mcpServerInspectionSchema) }, + errors: namedServerErrorSchemas, + description: + 'The locator-addressed catalog (redacted configs) plus a batched real-connection probe of every OAuth candidate. `targets` narrows the catalog; omitted inspects all. `cwd` includes trusted project layers.', + tags: ['v2-mcp'], + }, + async (req, reply) => { + try { + const inspections = await management().inspectServers(req.body.targets, { + cwd: req.body.cwd, + }); + reply.send(okEnvelope(inspections, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.post( + inspectServersRoute.path, + (inspectServersRoute.options), + inspectServersRoute.handler as Parameters[2], + ); + + const authStatusesRoute = defineRoute( + { + method: 'GET', + path: '/mcp/auth-statuses', + querystring: authStatusesQuerySchema, + success: { data: z.array(mcpServerAuthStatusSchema) }, + errors: baseErrorSchemas, + description: + 'Per-server OAuth state over the registry catalog. Omitted `verify` preserves implicit OAuth detection; `verify=false` is fully offline; `verify=true` verifies every candidate. Probes may refresh or invalidate credentials.', + tags: ['v2-mcp'], + }, + async (req, reply) => { + try { + const statuses = await management().listAuthStatuses({ + cwd: req.query.cwd, + verify: req.query.verify === undefined ? undefined : req.query.verify === 'true', + }); + reply.send(okEnvelope(statuses, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.get( + authStatusesRoute.path, + (authStatusesRoute.options), + authStatusesRoute.handler as Parameters[2], + ); + + const authBeginRoute = defineRoute( + { + method: 'POST', + path: '/mcp/auth::begin', + body: mcpServerLocatorSchema, + querystring: serverScopedQuerySchema, + success: { data: mcpServerAuthBeginResultSchema }, + errors: namedServerOAuthErrorSchemas, + description: + 'Begin an interactive OAuth flow for a remote server. Answers `authorization-required` with the flow handle + URL, or `already-authorized` when a grant exists.', + tags: ['v2-mcp'], + }, + async (req, reply) => { + try { + const result = await management().beginServerAuth(req.body, { cwd: req.query.cwd }); + reply.send(okEnvelope(result, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.post( + authBeginRoute.path, + (authBeginRoute.options), + authBeginRoute.handler as Parameters[2], + ); + + const authCompleteRoute = defineRoute( + { + method: 'POST', + path: '/mcp/auth::complete', + body: authCompleteBodySchema, + success: { data: z.null() }, + errors: oauthErrorSchemas, + description: + 'Await the browser callback of a begun flow and finish the code exchange (`40001` for an unknown `flowId`).', + tags: ['v2-mcp'], + }, + async (req, reply) => { + const { raw } = reply as unknown as { raw: ServerResponse }; + const disconnect = new AbortController(); + const onClose = (): void => { + if (raw.writableFinished) return; + disconnect.abort(); + }; + raw.once('close', onClose); + try { + await management().completeServerAuth(req.body, { signal: disconnect.signal }); + reply.send(okEnvelope(null, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } finally { + raw.off('close', onClose); + } + }, + ); + app.post( + authCompleteRoute.path, + (authCompleteRoute.options), + authCompleteRoute.handler as Parameters[2], + ); + + const authCancelRoute = defineRoute( + { + method: 'POST', + path: '/mcp/auth::cancel', + body: authCancelBodySchema, + success: { data: z.null() }, + errors: oauthErrorSchemas, + description: 'Tear down a begun OAuth flow without finishing it; unknown flows are ignored.', + tags: ['v2-mcp'], + }, + async (req, reply) => { + try { + await management().cancelServerAuth(req.body); + reply.send(okEnvelope(null, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.post( + authCancelRoute.path, + (authCancelRoute.options), + authCancelRoute.handler as Parameters[2], + ); + + const authResetRoute = defineRoute( + { + method: 'POST', + path: '/mcp/auth::reset', + body: mcpServerLocatorSchema, + querystring: serverScopedQuerySchema, + success: { data: z.null() }, + errors: namedServerOAuthErrorSchemas, + description: + 'Clear the stored credentials of one server; the invalidation event reaches live sessions.', + tags: ['v2-mcp'], + }, + async (req, reply) => { + try { + await management().resetServerAuth(req.body, { cwd: req.query.cwd }); + reply.send(okEnvelope(null, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.post( + authResetRoute.path, + (authResetRoute.options), + authResetRoute.handler as Parameters[2], + ); +} diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5e3b979765f6f87974c4f9d4d5765e46862ebcc --- /dev/null +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -0,0 +1,776 @@ + +import { createHash } from 'node:crypto'; + +import { + ISessionIndex, + ISessionIndexMirror, + IWorkspaceAliases, + IWorkspaceService, + setSessionArchivedBatch, + type Scope, + type SessionSummary, +} from '@moonshot-ai/agent-core-v2'; +import { IGitService, type FsPullRequest } from '@moonshot-ai/agent-core-v2/app/git/git'; +import { z } from 'zod'; + +import { defineRoute } from '../../middleware/defineRoute'; +import { errEnvelope, okEnvelope } from '../../protocol/envelope'; +import { ErrorCode } from '../../protocol/error-codes'; +import { resolveSessionFacts, type SessionFacts } from '../sessions'; + +interface V2SessionsRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown; headers: Record }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +export const v2ActivityStatusSchema = z.enum([ + 'running', + 'approval', + 'question', + 'failed', + 'idle', +]); +export type V2ActivityStatus = z.infer; + +const v2SortSchema = z.enum([ + 'meta.updated_at_desc', + 'meta.updated_at_asc', + 'meta.created_at_desc', +]); +type V2Sort = z.infer; + +const v2ViewSchema = z.enum(['flat', 'by_workspace']); +type V2View = z.infer; + +const DEFAULT_PAGE_SIZE = 50; +const DEFAULT_GROUP_PAGE_SIZE = 5; + +const repeatedParam = (item: T) => + z.union([item, z.array(item).min(1)]).optional(); + +const KNOWN_INCLUDE_DOMAINS = new Set(['git']); + +function includeDomains(include: string | undefined): string[] { + return (include ?? '') + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +const KNOWN_FIELDS = new Set(['id', 'archived']); +const IDS_PROJECTION_PAGE_SIZE_MAX = 10000; +const FULL_PAGE_SIZE_MAX = 100; + +function parseFields(raw: string | undefined): string[] { + return [ + ...new Set( + (raw ?? '') + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ), + ]; +} + +function isIdsProjection(fields: readonly string[]): boolean { + return fields.length === 2 && fields.every((field) => KNOWN_FIELDS.has(field)); +} + +const v2SessionsListQuerySchema = z + .object({ + 'workspace.id': repeatedParam(z.string().min(1)), + 'activity.status': repeatedParam(v2ActivityStatusSchema), + 'meta.updated_after': z.coerce.number().int().nonnegative().optional(), + 'meta.updated_before': z.coerce.number().int().nonnegative().optional(), + 'meta.archived': z.enum(['true', 'false', 'all']).optional(), + 'meta.has_prompt': z.enum(['true', 'false']).optional(), + view: v2ViewSchema.optional(), + 'group.page_size': z.coerce.number().int().min(1).max(IDS_PROJECTION_PAGE_SIZE_MAX).optional(), + sort: v2SortSchema.optional(), + include: z.string().optional(), + fields: z.string().optional(), + page_size: z.coerce.number().int().min(1).max(IDS_PROJECTION_PAGE_SIZE_MAX).optional(), + page: z.coerce.number().int().min(1).optional(), + page_token: z.string().min(1).optional(), + }) + .superRefine((value, ctx) => { + if (value.page !== undefined && value.page_token !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'page and page_token are mutually exclusive', + path: ['page'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + for (const domain of includeDomains(value.include)) { + if (!KNOWN_INCLUDE_DOMAINS.has(domain)) { + ctx.addIssue({ + code: 'custom', + message: `unknown domain '${domain}'`, + path: ['include'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + } + const fields = parseFields(value.fields); + for (const field of fields) { + if (!KNOWN_FIELDS.has(field)) { + ctx.addIssue({ + code: 'custom', + message: `unknown field '${field}'`, + path: ['fields'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + } + const projection = fields.length > 0 && fields.every((field) => KNOWN_FIELDS.has(field)); + if (projection && !isIdsProjection(fields)) { + ctx.addIssue({ + code: 'custom', + message: "unsupported fields projection; the only supported value is 'id,archived'", + path: ['fields'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + if (projection && includeDomains(value.include).includes('git')) { + ctx.addIssue({ + code: 'custom', + message: 'include=git is not available with the ids projection', + path: ['include'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + const pageSizeMax = projection ? IDS_PROJECTION_PAGE_SIZE_MAX : FULL_PAGE_SIZE_MAX; + if (value.page_size !== undefined && value.page_size > pageSizeMax) { + ctx.addIssue({ + code: 'custom', + message: projection + ? `page_size must be at most ${IDS_PROJECTION_PAGE_SIZE_MAX}` + : `page_size must be at most ${FULL_PAGE_SIZE_MAX} without the ids projection`, + path: ['page_size'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + if (value['group.page_size'] !== undefined) { + if (value.view !== 'by_workspace') { + ctx.addIssue({ + code: 'custom', + message: "group.page_size requires view='by_workspace'", + path: ['group.page_size'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } else if (value['group.page_size'] > pageSizeMax) { + ctx.addIssue({ + code: 'custom', + message: projection + ? `group.page_size must be at most ${IDS_PROJECTION_PAGE_SIZE_MAX}` + : `group.page_size must be at most ${FULL_PAGE_SIZE_MAX} without the ids projection`, + path: ['group.page_size'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + } + }); + +function asArray(value: T | T[] | undefined): T[] | undefined { + if (value === undefined) return undefined; + return Array.isArray(value) ? value : [value]; +} + +interface NormalizedQuery { + readonly workspaceFilter?: readonly string[]; + readonly statuses?: readonly V2ActivityStatus[]; + readonly updatedAfter?: number; + readonly updatedBefore?: number; + readonly archived: 'true' | 'false' | 'all'; + readonly hasPrompt?: boolean; + readonly view: V2View; + readonly groupPageSize: number; + readonly sort: V2Sort; + readonly includeGit: boolean; + readonly pageSize: number; + readonly projection: boolean; +} + +const v2GitDomainSchema = z.object({ + branch: z.string().nullable(), + pull_request: z + .object({ + number: z.number().int(), + state: z.enum(['open', 'closed', 'merged']), + url: z.string(), + }) + .nullable(), +}); + +const v2SessionSchema = z.object({ + id: z.string(), + workspace: z.object({ id: z.string(), cwd: z.string().nullable() }), + meta: z.object({ + title: z.string().nullable(), + last_prompt: z.string().nullable(), + created_at: z.number().int(), + updated_at: z.number().int(), + archived: z.boolean(), + archived_at: z.number().int().nullable(), + }), + activity: z.object({ status: v2ActivityStatusSchema, model: z.string().nullable() }), + git: v2GitDomainSchema.optional(), +}); + +const v2SessionIdProjectionSchema = z.object({ + id: z.string(), + archived: z.boolean(), +}); + +const v2SessionPageSchema = z.object({ + items: z.array(z.union([v2SessionSchema, v2SessionIdProjectionSchema])), + total: z.number().int(), + has_more: z.boolean(), + next_page_token: z.string().nullable(), +}); + +const v2SessionGroupSchema = z.object({ + workspace: z.object({ id: z.string(), cwd: z.string().nullable() }), + sessions: z.array(z.union([v2SessionSchema, v2SessionIdProjectionSchema])), + total: z.number().int(), +}); + +const v2SessionGroupPageSchema = z.object({ + groups: z.array(v2SessionGroupSchema), + total: z.number().int(), + has_more: z.boolean(), + next_page_token: z.string().nullable(), +}); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +const BATCH_IDS_MAX = 5000; + +const v2SessionsBatchBodySchema = z + .object({ ids: z.array(z.string().min(1)).min(1) }) + .superRefine((value, ctx) => { + if (new Set(value.ids).size > BATCH_IDS_MAX) { + ctx.addIssue({ + code: 'custom', + message: `ids must contain at most ${BATCH_IDS_MAX} unique entries`, + path: ['ids'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + }); + +const v2SessionsBatchResultSchema = z.object({ + results: z.array( + z.object({ + id: z.string(), + ok: z.boolean(), + error: z.object({ code: z.number().int(), message: z.string() }).optional(), + }), + ), + succeeded: z.number().int(), + failed: z.number().int(), +}); + +type V2BatchItemResult = z.infer['results'][number]; + +type V2GitDomain = z.infer; +type V2SessionWire = z.infer; +type V2SessionIdProjection = z.infer; + +class PageTokenMismatchError extends Error {} + +export function mapActivityStatus( + facts: SessionFacts, + persistedLastTurnReason?: 'completed' | 'cancelled' | 'failed', +): V2ActivityStatus { + if (facts.pendingInteraction === 'approval') return 'approval'; + if (facts.pendingInteraction === 'question') return 'question'; + if (facts.busy || facts.mainTurnActive) return 'running'; + if (facts.lastTurnReason === 'failed') return 'failed'; + if (facts.live === false && persistedLastTurnReason === 'failed') return 'failed'; + return 'idle'; +} + +function sortKeyOf(sort: V2Sort): (summary: SessionSummary) => number { + return sort === 'meta.created_at_desc' + ? (summary) => summary.createdAt + : (summary) => summary.updatedAt; +} + +function makeComparator(sort: V2Sort): (a: SessionSummary, b: SessionSummary) => number { + const keyOf = sortKeyOf(sort); + const ascending = sort === 'meta.updated_at_asc'; + return (a, b) => { + const ka = keyOf(a); + const kb = keyOf(b); + if (ka !== kb) return ascending ? ka - kb : kb - ka; + const order = a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + return ascending ? order : -order; + }; +} + +const PAGE_TOKEN_VERSION = 1; + +function queryFingerprint(query: NormalizedQuery): string { + const canonical = [ + query.workspaceFilter === undefined ? null : [...query.workspaceFilter].toSorted(), + query.statuses === undefined ? null : [...query.statuses].toSorted(), + query.updatedAfter ?? null, + query.updatedBefore ?? null, + query.archived, + query.hasPrompt ?? null, + query.view, + query.groupPageSize, + query.sort, + query.includeGit, + query.pageSize, + query.projection, + ]; + return createHash('sha256').update(JSON.stringify(canonical)).digest('base64url').slice(0, 16); +} + +function encodePageToken(fingerprint: string, key: number, id: string): string { + return Buffer.from( + JSON.stringify({ v: PAGE_TOKEN_VERSION, f: fingerprint, k: [key, id] }), + ).toString('base64url'); +} + +function decodePageToken(raw: string, fingerprint: string): readonly [number, string] { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); + } catch { + throw new PageTokenMismatchError( + 'page_token is corrupted; discard it and restart from the first page', + ); + } + const token = parsed as { v?: unknown; f?: unknown; k?: unknown }; + const key = Array.isArray(token.k) ? token.k : undefined; + if ( + token.v !== PAGE_TOKEN_VERSION || + typeof token.f !== 'string' || + key === undefined || + key.length !== 2 || + typeof key[0] !== 'number' || + typeof key[1] !== 'string' + ) { + throw new PageTokenMismatchError( + 'page_token is malformed or from an incompatible version; discard it and restart from the first page', + ); + } + if (token.f !== fingerprint) { + throw new PageTokenMismatchError( + 'page_token does not match the query conditions; discard it and restart from the first page', + ); + } + return [key[0], key[1]]; +} + +const GIT_DOMAIN_TTL_MS = 60_000; + +const GIT_DOMAIN_UNAVAILABLE: V2GitDomain = { branch: null, pull_request: null }; + +function mapPullRequest(pr: FsPullRequest | null): V2GitDomain['pull_request'] { + if (pr === null) return null; + return { number: pr.number, state: pr.state === 'draft' ? 'open' : pr.state, url: pr.url }; +} + +class GitDomainResolver { + private readonly cache = new Map(); + + constructor(private readonly core: Scope) {} + + async resolveAll(cwds: ReadonlySet): Promise> { + const now = Date.now(); + const resolved = new Map(); + const misses: string[] = []; + for (const cwd of cwds) { + const hit = this.cache.get(cwd); + if (hit !== undefined && now - hit.fetchedAt < GIT_DOMAIN_TTL_MS) { + resolved.set(cwd, hit.value); + } else { + misses.push(cwd); + } + } + await Promise.all( + misses.map(async (cwd) => { + const value = await this.fetch(cwd); + this.cache.set(cwd, { value, fetchedAt: now }); + resolved.set(cwd, value); + }), + ); + return resolved; + } + + private async fetch(cwd: string): Promise { + try { + const status = await this.core.accessor.get(IGitService).status(cwd); + return { + branch: status.branch.length === 0 ? null : status.branch, + pull_request: mapPullRequest(status.pullRequest), + }; + } catch { + return GIT_DOMAIN_UNAVAILABLE; + } + } +} + +async function runBatchArchive( + core: Scope, + action: 'archive' | 'restore', + rawIds: readonly string[], + requestId: string, + reply: { send(payload: unknown): unknown }, +): Promise { + const archived = action === 'archive'; + const ids = [...new Set(rawIds)]; + const outcomes = await setSessionArchivedBatch(core.accessor, ids, archived); + const results: V2BatchItemResult[] = outcomes.map((outcome) => + outcome.ok + ? { id: outcome.id, ok: true } + : { + id: outcome.id, + ok: false, + error: + outcome.reason === 'not_found' + ? { code: ErrorCode.SESSION_NOT_FOUND, message: outcome.message } + : { code: ErrorCode.INTERNAL_ERROR, message: outcome.message }, + }, + ); + await core.accessor.get(ISessionIndexMirror).drain(); + const succeeded = results.filter((result) => result.ok).length; + reply.send( + okEnvelope({ results, succeeded, failed: results.length - succeeded }, requestId), + ); +} +export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): void { + const gitResolver = new GitDomainResolver(core); + + const listRoute = defineRoute( + { + method: 'GET', + path: '/sessions', + querystring: v2SessionsListQuerySchema, + success: { data: z.union([v2SessionPageSchema, v2SessionGroupPageSchema]) }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.PAGE_TOKEN_MISMATCH]: {}, + }, + description: + "List sessions with domain-grouped metadata (workspace / meta / activity; git via include=git). activity.model carries the live session's bound model alias (null while the session is cold). Paginate with the opaque page_token (binds the first page’s query conditions) or with the stateless 1-based page parameter; every page carries total. fields=id,archived trims each item to the lightweight ids projection (select-all-matching flows; page_size ceiling relaxed to 10000). meta.has_prompt=true|false filters sessions by whether they carry a prompt. view=by_workspace groups the matching set per workspace — each group carries that workspace's first group.page_size sessions (default 5) under the requested sort plus the group's full matching total; page/page_token then page over groups.", + tags: ['v2-sessions'], + }, + async (req, reply) => { + const raw = req.query; + + const query: NormalizedQuery = { + workspaceFilter: asArray(raw['workspace.id']), + statuses: asArray(raw['activity.status']), + updatedAfter: raw['meta.updated_after'], + updatedBefore: raw['meta.updated_before'], + archived: raw['meta.archived'] ?? 'false', + hasPrompt: + raw['meta.has_prompt'] === undefined ? undefined : raw['meta.has_prompt'] === 'true', + view: raw.view ?? 'flat', + groupPageSize: raw['group.page_size'] ?? DEFAULT_GROUP_PAGE_SIZE, + sort: raw.sort ?? 'meta.updated_at_desc', + includeGit: includeDomains(raw.include).includes('git'), + pageSize: raw.page_size ?? DEFAULT_PAGE_SIZE, + projection: parseFields(raw.fields).length > 0, + }; + + const fingerprint = queryFingerprint(query); + let cursor: readonly [number, string] | undefined; + if (raw.page_token !== undefined) { + try { + cursor = decodePageToken(raw.page_token, fingerprint); + } catch (error) { + if (error instanceof PageTokenMismatchError) { + reply.send(errEnvelope(ErrorCode.PAGE_TOKEN_MISMATCH, error.message, req.id)); + return; + } + throw error; + } + } + + let workspaceIds: string[] | undefined; + if (query.workspaceFilter !== undefined) { + const aliases = core.accessor.get(IWorkspaceAliases); + const sets = await Promise.all( + query.workspaceFilter.map((id) => aliases.resolveAliasIds(id)), + ); + workspaceIds = [...new Set(sets.flat())]; + } + + const page = await core.accessor.get(ISessionIndex).listRecent({ + workspaceIds, + includeArchived: query.archived !== 'false', + }); + + const factsById = new Map(); + const factsOf = (id: string): SessionFacts => { + let facts = factsById.get(id); + if (facts === undefined) { + facts = resolveSessionFacts(core, id); + factsById.set(id, facts); + } + return facts; + }; + + const filtered = page.items.filter((summary) => { + if (query.archived === 'true' && !summary.archived) return false; + if ( + query.hasPrompt !== undefined && + ((summary.lastPrompt ?? '').length > 0) !== query.hasPrompt + ) { + return false; + } + if (query.updatedAfter !== undefined && summary.updatedAt < query.updatedAfter) { + return false; + } + if (query.updatedBefore !== undefined && summary.updatedAt > query.updatedBefore) { + return false; + } + if ( + query.statuses !== undefined && + !query.statuses.includes(mapActivityStatus(factsOf(summary.id), summary.lastTurnReason)) + ) { + return false; + } + return true; + }); + + const comparator = makeComparator(query.sort); + const sorted = filtered.toSorted(comparator); + const keyOf = sortKeyOf(query.sort); + const ascending = query.sort === 'meta.updated_at_asc'; + + const loadCwdOf = async (): Promise<(summary: SessionSummary) => string | null> => { + const roots = new Map( + (await core.accessor.get(IWorkspaceService).list()).map( + (workspace) => [workspace.id, workspace.root] as const, + ), + ); + return (summary) => summary.cwd ?? roots.get(summary.workspaceId) ?? null; + }; + + const buildItems = async ( + summaries: readonly SessionSummary[], + cwdOf: (summary: SessionSummary) => string | null, + ): Promise => { + let gitByCwd: ReadonlyMap | undefined; + if (query.includeGit) { + const cwds = new Set(); + for (const summary of summaries) { + const cwd = cwdOf(summary); + if (cwd !== null) cwds.add(cwd); + } + gitByCwd = await gitResolver.resolveAll(cwds); + } + return summaries.map((summary) => { + const cwd = cwdOf(summary); + const facts = factsOf(summary.id); + return { + id: summary.id, + workspace: { id: summary.workspaceId, cwd }, + meta: { + title: summary.title ?? null, + last_prompt: summary.lastPrompt ?? null, + created_at: summary.createdAt, + updated_at: summary.updatedAt, + archived: summary.archived, + archived_at: summary.archivedAt ?? null, + }, + activity: { + status: mapActivityStatus(facts, summary.lastTurnReason), + model: facts.model ?? null, + }, + git: + gitByCwd === undefined + ? undefined + : ((cwd !== null ? gitByCwd.get(cwd) : undefined) ?? GIT_DOMAIN_UNAVAILABLE), + }; + }); + }; + + const projectIds = (summaries: readonly SessionSummary[]): V2SessionIdProjection[] => + summaries.map((summary) => ({ id: summary.id, archived: summary.archived })); + + if (query.view === 'by_workspace') { + interface SessionGroup { + readonly workspaceId: string; + readonly rep: SessionSummary; + readonly items: SessionSummary[]; + } + const aliasService = core.accessor.get(IWorkspaceAliases); + const canonicalById = new Map(); + const canonicalIdOf = async (workspaceId: string): Promise => { + let canonical = canonicalById.get(workspaceId); + if (canonical === undefined) { + const set = await aliasService.resolveAliasIds(workspaceId); + canonical = set.length === 0 ? workspaceId : set.toSorted()[0] as string; + for (const id of set) canonicalById.set(id, canonical); + } + return canonical; + }; + const byWorkspace = new Map(); + for (const summary of sorted) { + const groupId = await canonicalIdOf(summary.workspaceId); + const group = byWorkspace.get(groupId); + if (group === undefined) { + byWorkspace.set(groupId, { + workspaceId: groupId, + rep: summary, + items: [summary], + }); + } else { + group.items.push(summary); + } + } + const groupList = [...byWorkspace.values()]; + const groupComparator = (a: SessionGroup, b: SessionGroup): number => { + const ka = keyOf(a.rep); + const kb = keyOf(b.rep); + if (ka !== kb) return ascending ? ka - kb : kb - ka; + return a.workspaceId < b.workspaceId ? -1 : a.workspaceId > b.workspaceId ? 1 : 0; + }; + groupList.sort(groupComparator); + + let start = 0; + if (raw.page !== undefined) { + start = (raw.page - 1) * query.pageSize; + } else if (cursor !== undefined) { + const [cursorKey, cursorId] = cursor; + const cursorGroup: SessionGroup = { + workspaceId: cursorId, + rep: { id: cursorId, updatedAt: cursorKey, createdAt: cursorKey } as SessionSummary, + items: [], + }; + start = groupList.findIndex((group) => groupComparator(group, cursorGroup) > 0); + if (start === -1) start = groupList.length; + } + + const windowGroups = groupList.slice(start, start + query.pageSize); + const hasMore = start + query.pageSize < groupList.length; + const lastGroup = windowGroups.at(-1); + const nextPageToken = + raw.page === undefined && hasMore && lastGroup !== undefined + ? encodePageToken(fingerprint, keyOf(lastGroup.rep), lastGroup.workspaceId) + : null; + + const cwdOf = await loadCwdOf(); + const groups = await Promise.all( + windowGroups.map(async (group) => { + const served = group.items.slice(0, query.groupPageSize); + return { + workspace: { id: group.workspaceId, cwd: cwdOf(group.rep) }, + sessions: query.projection + ? projectIds(served) + : await buildItems(served, cwdOf), + total: group.items.length, + }; + }), + ); + + reply.send( + okEnvelope( + { groups, total: groupList.length, has_more: hasMore, next_page_token: nextPageToken }, + req.id, + ), + ); + return; + } + + let start = 0; + if (raw.page !== undefined) { + start = (raw.page - 1) * query.pageSize; + } else if (cursor !== undefined) { + const [cursorKey, cursorId] = cursor; + const cursorItem = { + id: cursorId, + updatedAt: cursorKey, + createdAt: cursorKey, + } as SessionSummary; + start = sorted.findIndex((item) => comparator(item, cursorItem) > 0); + if (start === -1) start = sorted.length; + } + + const window = sorted.slice(start, start + query.pageSize); + const hasMore = start + query.pageSize < sorted.length; + const lastServed = window.at(-1); + const nextPageToken = + raw.page === undefined && hasMore && lastServed !== undefined + ? encodePageToken(fingerprint, keyOf(lastServed), lastServed.id) + : null; + + if (query.projection) { + reply.send( + okEnvelope( + { + items: projectIds(window), + total: sorted.length, + has_more: hasMore, + next_page_token: nextPageToken, + }, + req.id, + ), + ); + return; + } + + const items = await buildItems(window, await loadCwdOf()); + + reply.send( + okEnvelope( + { items, total: sorted.length, has_more: hasMore, next_page_token: nextPageToken }, + req.id, + ), + ); + }, + ); + + app.get( + listRoute.path, + listRoute.options, + listRoute.handler as Parameters[2], + ); + + for (const action of ['archive', 'restore'] as const) { + const batchRoute = defineRoute( + { + method: 'POST', + path: `/sessions::${action}`, + body: v2SessionsBatchBodySchema, + success: { data: v2SessionsBatchResultSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + }, + description: `Batch-${action} sessions by id ({ ids }, ≤5000 unique). Per-item results — a missing session folds into its own item; cold sessions are patched without materialization.`, + tags: ['v2-sessions'], + }, + async (req, reply) => { + await runBatchArchive(core, action, req.body.ids, req.id, reply); + }, + ); + app.post( + batchRoute.path, + batchRoute.options, + batchRoute.handler as Parameters[2], + ); + } +} diff --git a/packages/kap-server/src/routes/webAssets.ts b/packages/kap-server/src/routes/webAssets.ts new file mode 100644 index 0000000000000000000000000000000000000000..e453593171b2b1ae3abf51f1243477d437b7b013 --- /dev/null +++ b/packages/kap-server/src/routes/webAssets.ts @@ -0,0 +1,141 @@ +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { extname, join, normalize, relative, resolve, sep } from 'node:path'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +interface WebAssetRouteHost { + get( + path: string, + handler: (req: FastifyRequest, reply: FastifyReply) => Promise, + ): unknown; +} + +export async function registerWebAssetRoutes( + app: WebAssetRouteHost, + assetsDir: string, +): Promise { + await assertWebAssets(assetsDir); + + app.get('/', async (req, reply) => serveWebAsset(req, reply, assetsDir)); + app.get('/*', async (req, reply) => serveWebAsset(req, reply, assetsDir)); +} + +async function assertWebAssets(assetsDir: string): Promise { + try { + const info = await stat(join(assetsDir, 'index.html')); + if (!info.isFile()) { + throw new Error('index.html is not a file'); + } + } catch { + throw new Error( + `Kimi web assets were not found at ${assetsDir}. Run the package build before starting the server.`, + ); + } +} + +async function serveWebAsset( + req: FastifyRequest, + reply: FastifyReply, + assetsDir: string, +): Promise { + const requestUrl = new URL(req.url, 'http://kimi-web.local'); + if (isReservedPath(requestUrl.pathname)) { + return reply.callNotFound(); + } + + const filePath = await resolveStaticFile(assetsDir, requestUrl.pathname); + if (filePath === undefined) { + return reply.code(404).type('text/plain; charset=utf-8').send('Not found'); + } + + const fileInfo = await stat(filePath).catch(() => undefined); + if (fileInfo === undefined || !fileInfo.isFile()) { + return reply.code(404).type('text/plain; charset=utf-8').send('Not found'); + } + + return reply + .type(mimeType(filePath)) + .header('Cache-Control', cacheControl(assetsDir, filePath)) + .header('Content-Length', String(fileInfo.size)) + .send(createReadStream(filePath)); +} + +function cacheControl(assetsDir: string, filePath: string): string { + const assetPath = relative(assetsDir, filePath); + const fileName = filePath.slice(filePath.lastIndexOf(sep) + 1); + if (assetPath.startsWith(`assets${sep}`) && /[-.][A-Za-z0-9_-]{8}\.[^.]+$/.test(fileName)) { + return 'public, max-age=31536000, immutable'; + } + return 'no-cache'; +} + +async function resolveStaticFile( + assetsDir: string, + pathname: string, +): Promise { + let decoded: string; + try { + decoded = decodeURIComponent(pathname); + } catch { + return undefined; + } + + const normalized = normalize(decoded).replace(/^(\.\.(?:[/\\]|$))+/, ''); + const relative = normalized === sep ? 'index.html' : normalized.replace(/^[/\\]/, ''); + const root = resolve(assetsDir); + const candidate = resolve( + root, + relative.endsWith(sep) ? join(relative, 'index.html') : relative, + ); + if (candidate !== root && !candidate.startsWith(`${root}${sep}`)) { + return undefined; + } + + const info = await stat(candidate).catch(() => undefined); + if (info?.isFile() === true) { + return candidate; + } + if (extname(pathname) !== '') { + return undefined; + } + return join(root, 'index.html'); +} + +function isReservedPath(pathname: string): boolean { + return ( + pathname === '/api' || + pathname.startsWith('/api/') || + pathname === '/documentation' || + pathname.startsWith('/documentation/') + ); +} + +function mimeType(filePath: string): string { + switch (extname(filePath)) { + case '.html': + return 'text/html; charset=utf-8'; + case '.js': + case '.mjs': + return 'text/javascript; charset=utf-8'; + case '.css': + return 'text/css; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.svg': + return 'image/svg+xml'; + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.webp': + return 'image/webp'; + case '.ico': + return 'image/x-icon'; + case '.woff2': + return 'font/woff2'; + default: + return 'application/octet-stream'; + } +} diff --git a/packages/kap-server/src/routes/workspaceFs.ts b/packages/kap-server/src/routes/workspaceFs.ts new file mode 100644 index 0000000000000000000000000000000000000000..abadd66dabf9d486e6242a19d06e0788c0967420 --- /dev/null +++ b/packages/kap-server/src/routes/workspaceFs.ts @@ -0,0 +1,368 @@ +import { createReadStream, type ReadStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; + +import { + ErrorCodes, + HostFolderNotAbsoluteError, + HostFolderNotFoundError, + HostFolderPermissionError, + IHostFileSystem, + IHostFolderBrowser, + isError2, + type HostFileStat, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { + fsBrowseQuerySchema, + fsBrowseResponseSchema, + fsHomeResponseSchema, +} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +import { + buildEtag, + FS_BINARY_SAMPLE_BYTES, + guessMime, +} from '@moonshot-ai/agent-core-v2/_base/utils/fileMeta'; +import { classifyTextSample } from '@moonshot-ai/agent-core-v2/_base/text/encoding'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { parseRangeHeader, pickHeader } from '../lib/httpRange'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; + +interface FsContentReply { + type(mime: string): FsContentReply; + header(name: string, value: string | number): FsContentReply; + code(status: number): FsContentReply; + send(payload: unknown): unknown; +} + +interface WorkspaceFsRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: { path?: string }; headers: Record }, + reply: FsContentReply, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +export function registerWorkspaceFsRoutes(app: WorkspaceFsRouteHost, core: Scope): void { + const browseRoute = defineRoute( + { + method: 'GET', + path: '/fs::browse', + querystring: fsBrowseQuerySchema, + success: { data: fsBrowseResponseSchema }, + description: 'Browse local directories (server folder picker backend)', + tags: ['workspaces'], + operationId: 'fsBrowse', + }, + async (req, reply) => { + try { + const data = await core.accessor.get(IHostFolderBrowser).browse(req.query.path); + reply.send(okEnvelope(data, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.get( + browseRoute.path, + browseRoute.options, + browseRoute.handler as unknown as Parameters[2], + ); + + const homeRoute = defineRoute( + { + method: 'GET', + path: '/fs::home', + success: { data: fsHomeResponseSchema }, + description: 'Folder picker landing payload: $HOME + recent workspace roots', + tags: ['workspaces'], + operationId: 'fsHome', + }, + async (req, reply) => { + try { + const data = await core.accessor.get(IHostFolderBrowser).home(); + reply.send(okEnvelope(data, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.get( + homeRoute.path, + homeRoute.options, + homeRoute.handler as unknown as Parameters[2], + ); + + const contentRoute = defineRoute( + { + method: 'GET', + path: '/fs::content', + querystring: fsContentQuerySchema, + rawResponse: { + 200: { type: 'string', format: 'binary' }, + 206: { type: 'string', format: 'binary' }, + }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + [ErrorCode.FS_PERMISSION_DENIED]: {}, + [ErrorCode.FS_IS_DIRECTORY]: {}, + }, + description: + 'Serve the raw content of any file on the host filesystem by absolute path. Supports ETag caching and single-range requests.', + tags: ['workspaces'], + operationId: 'fsContent', + }, + async (req, reply) => { + return handleFsContent(core, req, reply as unknown as FsContentReply); + }, + ); + app.get( + contentRoute.path, + contentRoute.options, + contentRoute.handler as unknown as Parameters[2], + ); + + const mkdirRoute = defineRoute( + { + method: 'POST', + path: '/fs::mkdir', + body: fsMkdirBodySchema, + success: { data: fsMkdirResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + [ErrorCode.FS_PERMISSION_DENIED]: {}, + [ErrorCode.FS_ALREADY_EXISTS]: {}, + }, + description: + 'Create a directory on the host filesystem by absolute path (folder-picker "new folder" backend). Non-recursive: the parent directory must already exist.', + tags: ['workspaces'], + operationId: 'fsMkdir', + }, + async (req, reply) => { + return handleFsMkdir(req, reply); + }, + ); + app.post( + mkdirRoute.path, + mkdirRoute.options, + mkdirRoute.handler as unknown as Parameters[2], + ); +} + +const fsContentQuerySchema = z.object({ + path: z.string().min(1), +}); + +interface FsContentRequest { + id: string; + query: { path: string }; + headers: Record; +} + +async function handleFsContent( + core: Scope, + req: FsContentRequest, + reply: FsContentReply, +): Promise { + const requestId = req.id; + const { path } = req.query; + if (!isAbsolute(path)) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `path must be absolute: ${path}`, requestId), + ); + return; + } + + const hostFs = core.accessor.get(IHostFileSystem); + + let abs: string; + let st: HostFileStat; + try { + abs = await hostFs.realpath(path); + st = await hostFs.stat(abs); + } catch (err) { + sendOsFsError(reply, requestId, err, path); + return; + } + + if (st.isDirectory) { + reply.send( + errEnvelope(ErrorCode.FS_IS_DIRECTORY, `path is a directory: ${path}`, requestId), + ); + return; + } + if (!st.isFile) { + reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + `path is not a regular file: ${path}`, + requestId, + ), + ); + return; + } + + let isBinary = false; + try { + const sampleSize = Math.min(FS_BINARY_SAMPLE_BYTES, st.size); + const sample = + sampleSize === 0 ? new Uint8Array() : await hostFs.readBytes(abs, sampleSize); + const classification = classifyTextSample(sample); + isBinary = classification.isBinary || classification.encoding !== 'utf-8'; + } catch (err) { + sendOsFsError(reply, requestId, err, path); + return; + } + + const etag = buildEtag(st); + const ifNoneMatch = pickHeader(req.headers, 'if-none-match'); + if (ifNoneMatch !== undefined && ifNoneMatch === etag) { + reply.code(304).header('etag', etag).send(''); + return; + } + + reply.header('etag', etag); + reply.header('last-modified', new Date(st.mtimeMs ?? 0).toUTCString()); + reply.type(guessMime(abs, isBinary)); + + const log = requestLog(req); + const onStreamError = (stream: ReadStream) => (error: unknown) => { + log?.warn({ path, err: error }, 'fs content stream error'); + try { + stream.destroy(); + } catch { + } + }; + + const range = parseRangeHeader(pickHeader(req.headers, 'range'), st.size); + if (range !== null) { + reply + .code(206) + .header('content-length', String(range.length)) + .header('content-range', `bytes ${range.start}-${range.end}/${st.size}`); + const stream = createReadStream(abs, { start: range.start, end: range.end }); + stream.on('error', onStreamError(stream)); + return reply.send(stream) as unknown as void; + } + + reply.code(200).header('content-length', String(st.size)); + const stream = createReadStream(abs); + stream.on('error', onStreamError(stream)); + return reply.send(stream) as unknown as void; +} + +const fsMkdirBodySchema = z.object({ + path: z.string().min(1), +}); + +const fsMkdirResponseSchema = z.object({ + path: z.string(), +}); + +interface FsMkdirRequest { + id: string; + body: { path: string }; +} + +async function handleFsMkdir( + req: FsMkdirRequest, + reply: { send(payload: unknown): unknown }, +): Promise { + const requestId = req.id; + const { path } = req.body; + if (!isAbsolute(path)) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `path must be absolute: ${path}`, requestId), + ); + return; + } + + try { + await mkdir(path); + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + switch (code) { + case 'EEXIST': + reply.send( + errEnvelope(ErrorCode.FS_ALREADY_EXISTS, `path already exists: ${path}`, requestId), + ); + return; + case 'ENOENT': + case 'ENOTDIR': + reply.send( + errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `parent path not found: ${path}`, requestId), + ); + return; + case 'EACCES': + case 'EPERM': + reply.send( + errEnvelope(ErrorCode.FS_PERMISSION_DENIED, `permission denied: ${path}`, requestId), + ); + return; + } + throw err; + } + + reply.send(okEnvelope({ path }, requestId)); +} + +function sendOsFsError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, + path: string, +): void { + if (isError2(err)) { + switch (err.code) { + case ErrorCodes.OS_FS_NOT_FOUND: + case ErrorCodes.OS_FS_NOT_DIRECTORY: + reply.send( + errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `path not found: ${path}`, requestId), + ); + return; + case ErrorCodes.OS_FS_PERMISSION_DENIED: + reply.send( + errEnvelope(ErrorCode.FS_PERMISSION_DENIED, `permission denied: ${path}`, requestId), + ); + return; + } + } + throw err; +} + +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (err instanceof HostFolderNotAbsoluteError) { + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId, err.stack)); + return; + } + if (err instanceof HostFolderNotFoundError) { + reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, err.message, requestId, err.stack)); + return; + } + if (err instanceof HostFolderPermissionError) { + reply.send(errEnvelope(ErrorCode.FS_PERMISSION_DENIED, err.message, requestId, err.stack)); + return; + } + throw err; +} diff --git a/packages/kap-server/src/routes/workspaces.ts b/packages/kap-server/src/routes/workspaces.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d60ef90d5cc3801268e3ed7cc15f2bfee1df901 --- /dev/null +++ b/packages/kap-server/src/routes/workspaces.ts @@ -0,0 +1,407 @@ +import { + IBootstrapService, + IHostFileSystem, + IWorkspaceInstanceManager, + IWorkspaceService, + IWorkspaceSessions, + IWorkspaceTrust, + type Scope, + type Workspace, +} from '@moonshot-ai/agent-core-v2'; +import { isAbsolute, join, normalize, resolve } from 'node:path'; + +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + addDirRequestSchema, + addDirResponseSchema, + createWorkspaceRequestSchema, + createWorkspaceResponseSchema, + deleteWorkspaceResponseSchema, + listWorkspacesResponseSchema, + updateWorkspaceRequestSchema, + updateWorkspaceResponseSchema, + workspaceIdParamSchema, + workspaceTrustResponseSchema, +} from '../protocol/rest-workspace'; +import type { Workspace as WorkspaceWire } from '../protocol/workspace'; + +interface WorkspaceRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + patch( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + delete( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope): void { + const listRoute = defineRoute( + { + method: 'GET', + path: '/workspaces', + success: { data: listWorkspacesResponseSchema }, + description: 'List registered workspaces', + tags: ['workspaces'], + }, + async (req, reply) => { + const items = await core.accessor.get(IWorkspaceService).list(); + const projected = await Promise.all(items.map((ws) => toWireWorkspace(core, ws))); + reply.send(okEnvelope({ items: projected }, req.id)); + }, + ); + app.get(listRoute.path, listRoute.options, listRoute.handler as Parameters[2]); + + const createRoute = defineRoute( + { + method: 'POST', + path: '/workspaces', + body: createWorkspaceRequestSchema, + success: { data: createWorkspaceResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + }, + description: 'Register a workspace (idempotent on root)', + tags: ['workspaces'], + }, + async (req, reply) => { + const root = req.body.root; + if (!isAbsolute(root)) { + reply.send( + buildValidationEnvelope( + [{ path: 'root', message: 'root must be an absolute path' }], + req.id, + ), + ); + return; + } + const hostFs = core.accessor.get(IHostFileSystem); + try { + const stat = await hostFs.stat(root); + if (!stat.isDirectory) { + reply.send( + errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `root ${root} is not a directory`, req.id), + ); + return; + } + } catch { + reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `root ${root} does not exist`, req.id)); + return; + } + const ws = await core.accessor.get(IWorkspaceService).createOrTouch(root, req.body.name); + reply.send(okEnvelope(await toWireWorkspace(core, ws), req.id)); + }, + ); + app.post( + createRoute.path, + createRoute.options, + createRoute.handler as Parameters[2], + ); + + const updateRoute = defineRoute( + { + method: 'PATCH', + path: '/workspaces/{workspace_id}', + params: workspaceIdParamSchema, + body: updateWorkspaceRequestSchema, + success: { data: updateWorkspaceResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Rename a workspace (display name only)', + tags: ['workspaces'], + }, + async (req, reply) => { + const { workspace_id } = req.params; + const ws = await core.accessor + .get(IWorkspaceService) + .update(workspace_id, { name: req.body.name }); + if (ws === undefined) { + reply.send( + errEnvelope(ErrorCode.WORKSPACE_NOT_FOUND, `workspace ${workspace_id} does not exist`, req.id), + ); + return; + } + reply.send(okEnvelope(await toWireWorkspace(core, ws), req.id)); + }, + ); + app.patch( + updateRoute.path, + updateRoute.options, + updateRoute.handler as Parameters[2], + ); + + const deleteRoute = defineRoute( + { + method: 'DELETE', + path: '/workspaces/{workspace_id}', + params: workspaceIdParamSchema, + success: { data: deleteWorkspaceResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Unregister a workspace (does not remove on-disk content)', + tags: ['workspaces'], + }, + async (req, reply) => { + const { workspace_id } = req.params; + const registry = core.accessor.get(IWorkspaceService); + const existing = await registry.get(workspace_id); + if (existing === undefined) { + reply.send( + errEnvelope(ErrorCode.WORKSPACE_NOT_FOUND, `workspace ${workspace_id} does not exist`, req.id), + ); + return; + } + await registry.delete(workspace_id); + requestLog(req)?.info({ workspace_id }, 'workspace deleted'); + reply.send(okEnvelope({ deleted: true as const }, req.id)); + }, + ); + app.delete( + deleteRoute.path, + deleteRoute.options, + deleteRoute.handler as Parameters[2], + ); + + const getTrustRoute = defineRoute( + { + method: 'GET', + path: '/workspaces/{workspace_id}/trust', + params: workspaceIdParamSchema, + success: { data: workspaceTrustResponseSchema }, + errors: { + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Read the workspace trust state', + tags: ['workspaces'], + }, + async (req, reply) => { + const trust = await resolveTrust(core, req.params.workspace_id, req.id, reply); + if (trust === undefined) return; + reply.send(okEnvelope({ trusted: await trust.get() }, req.id)); + }, + ); + app.get( + getTrustRoute.path, + getTrustRoute.options, + getTrustRoute.handler as Parameters[2], + ); + + const trustRoute = defineRoute( + { + method: 'POST', + path: '/workspaces/{workspace_id}/trust', + params: workspaceIdParamSchema, + success: { data: workspaceTrustResponseSchema }, + errors: { + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Mark the workspace trusted (project-level MCP config loads)', + tags: ['workspaces'], + }, + async (req, reply) => { + const trust = await resolveTrust(core, req.params.workspace_id, req.id, reply); + if (trust === undefined) return; + await trust.trust(); + reply.send(okEnvelope({ trusted: true }, req.id)); + }, + ); + app.post( + trustRoute.path, + trustRoute.options, + trustRoute.handler as Parameters[2], + ); + + const untrustRoute = defineRoute( + { + method: 'POST', + path: '/workspaces/{workspace_id}/untrust', + params: workspaceIdParamSchema, + success: { data: workspaceTrustResponseSchema }, + errors: { + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Revoke workspace trust (project-level MCP config unloads)', + tags: ['workspaces'], + }, + async (req, reply) => { + const trust = await resolveTrust(core, req.params.workspace_id, req.id, reply); + if (trust === undefined) return; + await trust.untrust(); + reply.send(okEnvelope({ trusted: false }, req.id)); + }, + ); + app.post( + untrustRoute.path, + untrustRoute.options, + untrustRoute.handler as Parameters[2], + ); + + const addDirRoute = defineRoute( + { + method: 'POST', + path: '/workspaces/{workspace_id}/add-dir', + params: workspaceIdParamSchema, + body: addDirRequestSchema, + success: { data: addDirResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Add an additional directory to the workspace', + tags: ['workspaces'], + }, + async (req, reply) => { + const { workspace_id } = req.params; + const ws = await core.accessor.get(IWorkspaceService).get(workspace_id); + if (ws === undefined) { + reply.send( + errEnvelope(ErrorCode.WORKSPACE_NOT_FOUND, `workspace ${workspace_id} does not exist`, req.id), + ); + return; + } + const resolved = resolveAdditionalDirPath(core, ws.root, req.body.path); + const hostFs = core.accessor.get(IHostFileSystem); + try { + const stat = await hostFs.stat(resolved); + if (!stat.isDirectory) { + reply.send( + errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `path ${req.body.path} is not a directory`, req.id), + ); + return; + } + } catch { + reply.send( + errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `path ${req.body.path} does not exist`, req.id), + ); + return; + } + const workspace = await core + .accessor.get(IWorkspaceInstanceManager) + .getOrCreate({ workspaceId: workspace_id, root: ws.root }); + const result = await workspace.program.dirs.addDir({ + path: req.body.path, + persist: req.body.persist, + }); + reply.send( + okEnvelope( + { + project_root: result.projectRoot, + config_path: result.configPath, + additional_dirs: [...result.additionalDirs], + persisted: result.persisted, + }, + req.id, + ), + ); + }, + ); + app.post( + addDirRoute.path, + addDirRoute.options, + addDirRoute.handler as Parameters[2], + ); +} + +function resolveAdditionalDirPath(core: Scope, root: string, input: string): string { + const trimmed = input.trim(); + const osHomeDir = core.accessor.get(IBootstrapService).osHomeDir; + const expanded = + trimmed === '~' + ? osHomeDir + : trimmed.startsWith('~/') + ? join(osHomeDir, trimmed.slice(2)) + : trimmed; + return isAbsolute(expanded) ? normalize(expanded) : resolve(root, expanded); +} + +type TrustReply = { send(payload: unknown): unknown }; + +async function resolveTrust( + core: Scope, + workspaceId: string, + requestId: string, + reply: TrustReply, +): Promise { + const ws = await core.accessor.get(IWorkspaceService).get(workspaceId); + if (ws === undefined) { + reply.send( + errEnvelope(ErrorCode.WORKSPACE_NOT_FOUND, `workspace ${workspaceId} does not exist`, requestId), + ); + return undefined; + } + const workspace = await core + .accessor.get(IWorkspaceInstanceManager) + .getOrCreate({ workspaceId, root: ws.root }); + return workspace.program.trust; +} + +export async function toWireWorkspace(core: Scope, ws: Workspace): Promise { + const sessionCount = await core.accessor.get(IWorkspaceSessions).count(ws.id); + return { + id: ws.id, + root: ws.root, + name: ws.name, + created_at: new Date(ws.createdAt).toISOString(), + last_opened_at: new Date(ws.lastOpenedAt).toISOString(), + session_count: sessionCount, + }; +} + +function buildValidationEnvelope( + details: { path: string; message: string }[], + requestId: string, +): { + code: number; + msg: string; + data: null; + request_id: string; + details: { path: string; message: string }[]; +} { + const first = details[0]; + const msg = first === undefined ? 'validation failed' : `${first.path}: ${first.message}`; + return { + code: ErrorCode.VALIDATION_FAILED, + msg, + data: null, + request_id: requestId, + details, + }; +} diff --git a/packages/kap-server/src/search/contract.ts b/packages/kap-server/src/search/contract.ts new file mode 100644 index 0000000000000000000000000000000000000000..816f689dfe170fb66db7d71e107f03bbeda8915b --- /dev/null +++ b/packages/kap-server/src/search/contract.ts @@ -0,0 +1,66 @@ +export interface GlobalSearchQuery { + readonly query: string; + readonly mode?: 'terms' | 'literal'; + readonly op?: 'AND' | 'OR'; + readonly container?: { + readonly sessionId?: string; + readonly agentId?: string; + }; + readonly role?: 'user' | 'assistant' | 'title'; + readonly startTime?: number; + readonly endTime?: number; + readonly sort?: 'score' | 'time_desc' | 'time_asc'; + readonly pageSize?: number; + readonly pageToken?: string; +} + +export type GlobalSearchErrorReason = + | 'invalid_query' + | 'invalid_page_token' + | 'readonly_index' + | 'index_unavailable'; + +export class GlobalSearchError extends Error { + constructor( + readonly reason: GlobalSearchErrorReason, + message: string, + ) { + super(message); + this.name = 'GlobalSearchError'; + } +} + +export interface GlobalSearchHit { + readonly sessionId: string; + readonly workspaceId: string; + readonly sessionTitle: string; + readonly agentId: string; + readonly role: 'user' | 'assistant' | 'title'; + readonly snippet: string; + readonly time: number; + readonly turn?: number; + readonly stepId?: string; + readonly score: number; +} + +export interface GlobalSearchIndexState { + readonly state: 'building' | 'ready' | 'readonly'; + readonly indexedSessions: number; + readonly totalSessions: number; + readonly documents: number; + readonly stale?: boolean; + readonly degraded?: string; +} + +export type GlobalSearchSource = 'live' | 'index'; + +export type GlobalSearchIncomplete = 'candidate_cap' | 'postings_budget' | 'deadline'; + +export interface GlobalSearchPage { + readonly items: GlobalSearchHit[]; + readonly hasMore: boolean; + readonly pageToken?: string; + readonly incomplete?: GlobalSearchIncomplete; + readonly indexState: GlobalSearchIndexState; + readonly source: GlobalSearchSource; +} diff --git a/packages/kap-server/src/search/docs.ts b/packages/kap-server/src/search/docs.ts new file mode 100644 index 0000000000000000000000000000000000000000..77978159ab570f268a3317f243b25bc7fa58b307 --- /dev/null +++ b/packages/kap-server/src/search/docs.ts @@ -0,0 +1,74 @@ +export const MAX_DOC_TEXT_CHARS = 20_000; + +export interface MessageDoc { + readonly kind: 'message'; + readonly sessionIdentity?: string; + readonly sessionId: string; + readonly workspaceId: string; + readonly sessionTitle: string; + readonly agentId: string; + readonly role: 'user' | 'assistant'; + readonly text: string; + readonly time: number; + readonly turn?: number; + readonly stepId?: string; +} + +export interface TitleDoc { + readonly kind: 'title'; + readonly sessionIdentity?: string; + readonly sessionId: string; + readonly workspaceId: string; + readonly sessionTitle: string; + readonly agentId: ''; + readonly role: 'title'; + readonly text: string; + readonly time: number; +} + +export interface TurnOpener { + readonly turn: number; + readonly anchor: boolean; +} + +export interface TurnCounterState { + readonly next: number; + readonly hasTurn: boolean; + readonly openers: readonly TurnOpener[]; +} + +export interface StepTrackerState { + readonly byUuid: Record; + readonly begins: number; +} + +export interface FileMetaDoc { + readonly kind: 'fileMeta'; + readonly sessionId: string; + readonly agentId: string; + readonly source: 'root' | 'agents'; + readonly path: string; + readonly offset: number; + readonly size: number; + readonly mtimeMs?: number; + readonly ino?: number; + readonly turnState?: TurnCounterState; + readonly stepState?: StepTrackerState; +} + +export interface SessionMetaDoc { + readonly kind: 'sessionMeta'; + readonly title?: string; + readonly dir?: string; + readonly identity?: string; +} + +export interface StatsDoc { + readonly kind: 'stats'; + readonly degraded?: string; + readonly sessions: number; + readonly documents: number; + readonly lastIndexedAt: number; +} + +export type SearchDoc = MessageDoc | TitleDoc | FileMetaDoc | SessionMetaDoc | StatsDoc; diff --git a/packages/kap-server/src/search/indexCore.ts b/packages/kap-server/src/search/indexCore.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a3762c355d8d69036340d1a4bac03ba94af06c5 --- /dev/null +++ b/packages/kap-server/src/search/indexCore.ts @@ -0,0 +1,1283 @@ +import { createHash } from 'node:crypto'; +import { open, readFile, readdir, stat } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +import { + classifyStorageError, + MiniDb, + OpTracker, + TextIndexBuildingError, + wipeStoreDir, + type BatchInputOp, +} from '@moonshot-ai/minidb'; + +import { GlobalSearchError, type GlobalSearchIncomplete } from './contract.ts'; +import { + MAX_DOC_TEXT_CHARS, + type FileMetaDoc, + type MessageDoc, + type SearchDoc, + type SessionMetaDoc, + type StatsDoc, + type StepTrackerState, + type TitleDoc, + type TurnCounterState, +} from './docs.ts'; +import { + decodePageToken, + matchDocs, + paginateRows, + type MatchBudget, + type MatchedRow, + type NormalizedQuery, + type SearchBudgets, +} from './match.ts'; +import { analyzeWireLine, type StepEffect, type TurnEffect } from './wireExtract.ts'; + +const TEXT_INDEX_NAME = 'body'; +const TRI_INDEX_NAME = 'tri'; +const WIRE_FILENAME = 'wire.jsonl'; + +const FILE_META_PREFIX = '\0meta\\file\\'; +const SESSION_META_PREFIX = '\0meta\\session\\'; +const STATS_KEY = '\0meta\\stats'; + +function hashPath(filePath: string): string { + return createHash('sha256').update(filePath).digest('hex').slice(0, 32); +} + +function fileMetaKey(sessionId: string, filePath: string): string { + return `${FILE_META_PREFIX}${sessionId}\\${hashPath(filePath)}`; +} + +function fileMetaPrefixFor(sessionId: string): string { + return `${FILE_META_PREFIX}${sessionId}\\`; +} + +function legacyFileMetaKey(filePath: string): string { + return FILE_META_PREFIX + hashPath(filePath); +} + +const WIRE_READ_CHUNK_BYTES = 1 << 20; +const WIRE_BATCH_OPS = 1_000; +const SYNC_ROUND_BYTE_BUDGET = 64 << 20; +const SYNC_ROUND_TIME_BUDGET_MS = 30_000; +const SYNC_FAILURE_ESCALATION_LIMIT = 5; +const SESSION_SYNC_FAILURE_SKIP_LIMIT = 5; +const SESSION_SYNC_SKIP_COOLDOWN_MS = 300_000; +const EMPTY_BUFFER = Buffer.alloc(0); + +interface SyncRoundBudget { + bytesLeft: number; + deadline: number; +} + +function syncBudgetExhausted(budget: SyncRoundBudget): boolean { + return budget.bytesLeft <= 0 || Date.now() >= budget.deadline; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function sessionDirectoryIdentity(dir: string): Promise { + try { + const info = await stat(dir, { bigint: true }); + if (!info.isDirectory() || info.ino <= 0n || info.birthtimeNs <= 0n) return undefined; + return `${info.dev}:${info.ino}:${info.birthtimeNs}`; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return undefined; + throw error; + } +} + +const querySourceChecks = { running: false, waiters: new Set<() => void>() }; + +async function queryDirectoryIdentity(dir: string, deadlineAt: number, deadline: Promise): Promise { + while (querySourceChecks.running) { + let wake!: () => void; + const available = new Promise((resolve) => { wake = () => { resolve(true); }; }); + querySourceChecks.waiters.add(wake); + try { + if (await Promise.race([available, deadline]) === null) return null; + } finally { + querySourceChecks.waiters.delete(wake); + } + } + if (Date.now() >= deadlineAt) return null; + querySourceChecks.running = true; + const identity = sessionDirectoryIdentity(dir); + const release = () => { + querySourceChecks.running = false; + for (const wake of querySourceChecks.waiters) wake(); + querySourceChecks.waiters.clear(); + }; + void identity.then(release, release); + return Promise.race([identity, deadline]); +} + +async function sessionDirectoryTitle(dir: string, log: SearchCoreLog): Promise { + for (const scope of ['', 'session-meta']) { + try { + const meta: unknown = JSON.parse(await readFile(join(dir, scope, 'state.json'), 'utf8')); + if (typeof meta === 'object' && meta !== null && 'title' in meta && typeof meta.title === 'string') { + return meta.title; + } + return ''; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + log.warn('search index: cannot read session title', { dir, error: errorMessage(error) }); + } + } + } + return ''; +} + +const INITIAL_TURN_STATE: TurnCounterState = { next: 0, hasTurn: false, openers: [] }; + +function initialTurnState(): TurnCounterState { + return INITIAL_TURN_STATE; +} + +function applyUndoToTurnState(state: TurnCounterState, count: number): TurnCounterState { + let found = 0; + for (let i = state.openers.length - 1; i >= 0; i--) { + if (state.openers[i]!.anchor) { + found++; + if (found === count) { + return { + next: state.openers[i]!.turn, + hasTurn: i > 0, + openers: state.openers.slice(0, i), + }; + } + } + } + return state; +} + +function advanceTurnCounter( + state: TurnCounterState, + effect: TurnEffect, +): { docTurn: number | undefined; state: TurnCounterState } { + switch (effect.kind) { + case 'open': + return { + docTurn: state.next, + state: { + next: state.next + 1, + hasTurn: true, + openers: [...state.openers, { turn: state.next, anchor: effect.anchor }], + }, + }; + case 'ensure': { + const next = state.hasTurn ? state : { ...state, next: state.next + 1, hasTurn: true }; + return { docTurn: next.next - 1, state: next }; + } + case 'undo': + return { docTurn: undefined, state: applyUndoToTurnState(state, effect.count) }; + case 'none': + return { docTurn: undefined, state }; + } +} + +const INITIAL_STEP_STATE: StepTrackerState = { byUuid: {}, begins: 0 }; + +function initialStepState(): StepTrackerState { + return INITIAL_STEP_STATE; +} + +function advanceStepTracker(state: StepTrackerState, effect: StepEffect): StepTrackerState { + if (effect.kind !== 'begin') return state; + const begins = state.begins + 1; + const ordinal = effect.ordinal ?? begins; + if (state.byUuid[effect.uuid] === ordinal) return state; + return { byUuid: { ...state.byUuid, [effect.uuid]: ordinal }, begins }; +} + +export interface SearchCoreLog { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; +} + +export interface SearchCoreOptions { + readonly indexDir: string; + readonly log: SearchCoreLog; + readonly bootSalt: string; + readonly onLockToken?: (token: string) => void; +} + +export interface SyncSessionInput { + readonly id: string; + readonly workspaceId: string; + readonly title?: string; + readonly updatedAt: number; + readonly dir: string; +} + +interface SessionSyncResult { + readonly truncated: boolean; + readonly failed: boolean; + readonly error?: string; +} + +export interface CoreIndexView { + readonly state: 'building' | 'ready' | 'readonly'; + readonly indexedSessions: number; + readonly documents: number; + readonly readOnly: boolean; + readonly freshnessStale: boolean; + readonly degraded?: string; + readonly lockToken?: string; +} + +export type CoreSearchResult = + | { + readonly kind: 'page'; + readonly rows: MatchedRow[]; + readonly hasMore: boolean; + readonly incomplete?: GlobalSearchIncomplete; + readonly generation: string; + readonly index: CoreIndexView; + } + | { readonly kind: 'building'; readonly index: CoreIndexView }; + +export interface CoreSearchParams { + readonly q: NormalizedQuery; + readonly pageToken?: string; + readonly budgets: SearchBudgets; +} + +export interface CoreSyncOutcome { + readonly noop: boolean; + readonly sessions: number; + readonly documents: number; + readonly truncated: boolean; + readonly failures: number; + readonly lockToken?: string; + readonly lifecycle: CoreLifecycleReport; +} + +type CoreSyncPassOutcome = Omit; + +export type CoreLifecycleState = + | 'stopped' + | 'opening' + | 'building' + | 'ready' + | 'degraded' + | 'closing'; + +export interface CoreLifecycleReport { + readonly state: CoreLifecycleState; + readonly detail?: string; +} + +export interface CoreStatus { + readonly sessions: number; + readonly documents: number; + readonly lastIndexedAt: number | null; + readonly generation: number; + readonly readOnly: boolean; + readonly lockToken?: string; + readonly degraded?: string; + readonly lifecycle: CoreLifecycleReport; +} + +export class SearchIndexCore { + private walOffset = 0; + private fingerprint = ''; + private disposed = false; + private readonly ops = new OpTracker(); + private generation = 0; + private syncReplaced = false; + private lastRefreshError: { at: number; message: string } | null = null; + private openError: string | null = null; + private fileMetaMigrated = false; + private lockToken: string | undefined; + private lastMaintenanceDetail: string | undefined; + private consecutiveSyncFailures = 0; + private readonly sessionSyncFailures = new Map(); + private readonly sessionSyncSkips = new Map(); + + db: MiniDb | null = null; + openPromise: Promise | null = null; + refreshPromise: Promise | null = null; + fullSyncDone = false; + syncRoundBytes = SYNC_ROUND_BYTE_BUDGET; + syncRoundMs = SYNC_ROUND_TIME_BUDGET_MS; + syncSkipCooldownMs = SESSION_SYNC_SKIP_COOLDOWN_MS; + + constructor(private readonly options: SearchCoreOptions) {} + + get lockTokenView(): string | undefined { + return this.lockToken; + } + + private get indexDir(): string { + return this.options.indexDir; + } + + private get log(): SearchCoreLog { + return this.options.log; + } + + ensureOpen(): Promise { + this.openPromise ??= this.openDb().then( + () => { + this.openError = null; + }, + (error: unknown) => { + this.openPromise = null; + this.openError = errorMessage(error); + throw error; + }, + ); + return this.openPromise; + } + + private async openDb(): Promise { + const db = await this.openSearchDb(); + if (this.disposed) { + await db.close().catch(() => {}); + throw new GlobalSearchError('index_unavailable', 'search service is disposed'); + } + await this.publishDb(db, null); + } + + private tokenGeneration(): string { + return `${this.options.bootSalt}:${this.generation}`; + } + + private async publishDb(next: MiniDb, prev: MiniDb | null): Promise { + let fingerprint: string; + try { + if (!next.readOnly) { + for (const [name, options] of [ + [TEXT_INDEX_NAME, { fields: ['text'] }], + [TRI_INDEX_NAME, { fields: ['text'], tokenizer: 'ngram' }], + ] as const) { + try { + await next.createTextIndex(name, options); + } catch (error) { + if (!(error instanceof Error && error.message.includes('already exists'))) throw error; + } + } + } + fingerprint = await this.computeFingerprint(); + } catch (error) { + await next.close().catch(() => {}); + throw error; + } + this.db = next; + this.walOffset = next.recoveryInfo?.walScanEnd ?? 0; + this.generation++; + this.fingerprint = fingerprint; + this.lockToken = next.readOnly ? undefined : await this.readLockToken(); + if (prev !== null) await prev.close().catch(() => {}); + const lifecycle = next.lifecycleStatus(); + this.log.info('global search: index opened', { + dir: this.indexDir, + readOnly: next.readOnly, + state: lifecycle.state, + generation: next.getIndexGeneration()?.id ?? null, + openMs: Math.round(lifecycle.phases.openMs), + fullRecoveryMs: Math.round(lifecycle.phases.fullRecoveryMs), + }); + } + + private async readLockToken(): Promise { + try { + const raw = await readFile(join(this.indexDir, 'db.lock'), 'utf8'); + const parsed = JSON.parse(raw) as { pid?: unknown; token?: unknown }; + if (parsed.pid !== process.pid || typeof parsed.token !== 'string') return undefined; + return parsed.token; + } catch { + return undefined; + } + } + + private async openSearchDb(): Promise> { + const opts = { + dir: this.indexDir, + valueCodec: 'json', + valueMode: 'disk', + fsyncPolicy: 'everysec', + onLockFail: 'readonly', + onLockAcquired: (info: { readonly token: string }) => { + this.lockToken = info.token; + this.options.onLockToken?.(info.token); + }, + } as const; + try { + return await MiniDb.open(opts); + } catch (error) { + if (classifyStorageError(error) !== 'rebuild') throw error; + const outcome = await wipeStoreDir({ dir: this.indexDir }); + if (outcome === 'locked') throw error; + this.log.warn('global search: search-index corruption detected; rebuilding from scratch', { + dir: this.indexDir, + error: errorMessage(error), + }); + return MiniDb.open(opts); + } + } + + beginClose(): void { + this.disposed = true; + } + + async close(): Promise { + this.disposed = true; + await this.ops.close(); + await this.openPromise?.catch(() => {}); + const db = this.db; + this.db = null; + if (db) await db.close().catch(() => {}); + } + + private async tracked(op: () => Promise): Promise { + if (!this.ops.enter()) return; + try { + await op(); + } finally { + this.ops.leave(); + } + } + + private async computeFingerprint(): Promise { + const parts: string[] = []; + for (const name of ['db.wal', 'db.snapshot', 'db.textindexes.json']) { + try { + const s = await stat(join(this.indexDir, name)); + parts.push(`${name}:${s.dev}:${s.ino}:${s.mtimeMs}:${s.size}`); + } catch { + parts.push(`${name}:-`); + } + } + return parts.join('|'); + } + + refresh(): Promise { + this.refreshPromise ??= this.tracked(() => this.doRefreshReadonly()) + .then( + () => { + this.lastRefreshError = null; + }, + (error: unknown) => { + this.lastRefreshError = { at: Date.now(), message: errorMessage(error) }; + this.log.warn('global search: read-only refresh failed; serving the stale view', { + error: errorMessage(error), + }); + }, + ) + .finally(() => { + this.refreshPromise = null; + }); + return this.refreshPromise; + } + + private async doRefreshReadonly(): Promise { + const db = this.db; + if (!db || !db.readOnly || this.disposed) return; + const fp = await this.computeFingerprint(); + if (fp === this.fingerprint) return; + const [, snapPrev, defsPrev] = this.fingerprint.split('|'); + const [, snapNow, defsNow] = fp.split('|'); + if (snapPrev === snapNow && defsPrev === defsNow) { + const res = await db.catchUpFromWal(this.walOffset); + if (res !== null) { + this.walOffset = res.offset; + this.fingerprint = fp; + return; + } + } + const next = await this.openSearchDb(); + if (this.disposed) { + await next.close().catch(() => {}); + return; + } + if (this.db !== db) { + await next.close().catch(() => {}); + return; + } + await this.publishDb(next, db); + } + + async sync(sessions: readonly SyncSessionInput[]): Promise { + let outcome: CoreSyncPassOutcome = { + noop: true, + sessions: 0, + documents: 0, + truncated: false, + failures: 0, + }; + await this.tracked(async () => { + try { + outcome = await this.runSync(sessions); + this.consecutiveSyncFailures = 0; + } catch (error) { + if (classifyStorageError(error) !== 'rebuild') { + this.consecutiveSyncFailures += 1; + if (this.consecutiveSyncFailures < SYNC_FAILURE_ESCALATION_LIMIT) throw error; + } + this.consecutiveSyncFailures = 0; + await this.recoverByRebuild(error); + outcome = { noop: false, sessions: 0, documents: 0, truncated: true, failures: 0 }; + } + }); + return { ...outcome, lockToken: this.lockToken, lifecycle: this.lifecycleState() }; + } + + private async recoverByRebuild(cause: unknown): Promise { + this.log.warn('global search: search-index state is unrecoverable; wiping and rebuilding from the transcripts', { + dir: this.indexDir, + error: errorMessage(cause), + }); + await this.reindex(); + } + + private async runSync(sessions: readonly SyncSessionInput[]): Promise { + const noop: CoreSyncPassOutcome = { + noop: true, + sessions: 0, + documents: 0, + truncated: false, + failures: 0, + }; + if (this.disposed) return noop; + this.syncReplaced = false; + await this.ensureOpen(); + const db = this.db; + if (!db || db.readOnly || this.disposed) return noop; + + await this.migrateFileMetaKeys(db); + + const currentIds = new Set(sessions.map((s) => s.id)); + + for (const row of db.query({ key: { prefix: SESSION_META_PREFIX }, project: [] })) { + if (this.disposed) return noop; + const sessionId = row.key.slice(SESSION_META_PREFIX.length); + if (!currentIds.has(sessionId)) await this.deleteSessionDocs(db, sessionId); + } + + const budget: SyncRoundBudget = { + bytesLeft: this.syncRoundBytes, + deadline: Date.now() + this.syncRoundMs, + }; + let indexed = 0; + let truncated = false; + let failures = 0; + for (const summary of sessions) { + if (this.disposed) return noop; + const skip = this.sessionSyncSkips.get(summary.id); + if ( + skip !== undefined && + summary.updatedAt === skip.updatedAt && + Date.now() - skip.at < this.syncSkipCooldownMs + ) { + continue; + } + this.sessionSyncSkips.delete(summary.id); + if (syncBudgetExhausted(budget)) { + truncated = true; + break; + } + const result = await this.syncSession(db, summary, budget); + if (result === undefined) continue; + if (result.truncated) truncated = true; + if (result.failed) { + const count = (this.sessionSyncFailures.get(summary.id) ?? 0) + 1; + this.sessionSyncFailures.set(summary.id, count); + if (count >= SESSION_SYNC_FAILURE_SKIP_LIMIT) { + this.sessionSyncFailures.delete(summary.id); + this.sessionSyncSkips.set(summary.id, { at: Date.now(), updatedAt: summary.updatedAt }); + this.log.warn( + 'global search: giving up on a session whose wire transcript stays unreadable', + { sessionId: summary.id, error: result.error }, + ); + } else { + failures += 1; + this.log.warn('global search: failed to index session', { + sessionId: summary.id, + error: result.error, + }); + } + } else { + this.sessionSyncFailures.delete(summary.id); + } + indexed++; + } + + if (this.disposed) return noop; + const metaCount = db.query({ key: { prefix: '\0meta\\' }, project: [] }).length; + const stats: StatsDoc = { + kind: 'stats', + degraded: indexed < sessions.length ? `Skipped ${sessions.length - indexed} session(s) during indexing` : undefined, + sessions: indexed, + documents: db.size - metaCount, + lastIndexedAt: Date.now(), + }; + await db.set(STATS_KEY, stats); + if (!truncated && failures === 0) this.fullSyncDone = true; + if (this.syncReplaced) { + this.generation++; + } + return { noop: false, sessions: indexed, documents: stats.documents, truncated, failures }; + } + + private async migrateFileMetaKeys(db: MiniDb): Promise { + if (this.fileMetaMigrated) return; + const ops: BatchInputOp[] = []; + for (const row of db.query({ key: { prefix: FILE_META_PREFIX }, project: [] })) { + const rest = row.key.slice(FILE_META_PREFIX.length); + if (rest.includes('\\')) continue; + const meta = row.value; + if (meta.kind !== 'fileMeta') continue; + ops.push({ op: 'set', key: fileMetaKey(meta.sessionId, meta.path), value: meta }); + ops.push({ op: 'del', key: row.key }); + } + if (ops.length > 0) await db.batch(ops); + this.fileMetaMigrated = true; + } + + private async deleteSessionDocs(db: MiniDb, sessionId: string): Promise { + for (const row of db.query({ key: { prefix: `${sessionId}/` }, project: [] })) { + await db.del(row.key); + } + for (const row of db.query({ key: { prefix: fileMetaPrefixFor(sessionId) }, project: [] })) { + await db.del(row.key); + } + await db.del(SESSION_META_PREFIX + sessionId); + } + + private async syncSession( + db: MiniDb, + summary: SyncSessionInput, + budget: SyncRoundBudget, + ): Promise { + let identity: string | undefined; + try { + identity = await sessionDirectoryIdentity(summary.dir); + } catch (error) { + this.log.warn('global search: failed to index session', { + sessionId: summary.id, + error: errorMessage(error), + }); + return undefined; + } + if (identity === undefined) { + await this.deleteSessionDocs(db, summary.id); + return undefined; + } + const title = await sessionDirectoryTitle(summary.dir, this.log); + const metaKey = SESSION_META_PREFIX + summary.id; + const previous = db.get(metaKey); + if (previous?.kind !== 'sessionMeta' || previous.identity !== identity || previous.dir !== summary.dir) { + await this.deleteSessionDocs(db, summary.id); + if (previous !== undefined) this.syncReplaced = true; + } + const meta: SessionMetaDoc = { kind: 'sessionMeta', dir: summary.dir, identity, title }; + if (previous?.kind !== 'sessionMeta' || previous.identity !== identity || previous.dir !== summary.dir || previous.title !== title) { + await db.set(metaKey, meta); + } + const wireFiles = await collectWireFiles(summary.dir); + const seenPaths = new Set(wireFiles.map((file) => file.path)); + + for (const row of db.query({ key: { prefix: fileMetaPrefixFor(summary.id) } })) { + const meta = row.value; + if (meta.kind !== 'fileMeta') continue; + if (seenPaths.has(meta.path)) continue; + await this.deleteFileDocs(db, meta); + await db.del(row.key); + } + + let truncated = false; + let failed = false; + let error: string | undefined; + for (const file of wireFiles) { + const result = await this.syncWireFile(db, { ...summary, title, sessionIdentity: identity }, file, budget); + if (result.truncated) truncated = true; + if (result.failed) { + failed = true; + error ??= result.error; + } + } + + const titleKey = `${summary.id}/$title`; + const existing = db.get(titleKey); + if (title.length > 0) { + if (existing?.kind !== 'title' || existing.text !== title) { + const doc: TitleDoc = { + kind: 'title', + sessionIdentity: identity, + sessionId: summary.id, + workspaceId: summary.workspaceId, + sessionTitle: title, + agentId: '', + role: 'title', + text: title, + time: summary.updatedAt, + }; + await db.set(titleKey, doc); + if (existing !== undefined) this.syncReplaced = true; + } + } else if (existing !== undefined) { + await db.del(titleKey); + } + return { truncated, failed, error }; + } + + private async deleteFileDocs(db: MiniDb, meta: FileMetaDoc): Promise { + const prefix = `${meta.sessionId}/${meta.agentId}/${meta.source}:`; + for (const row of db.query({ key: { prefix }, project: [] })) { + await db.del(row.key); + } + } + + private async syncWireFile( + db: MiniDb, + summary: SyncSessionInput & { readonly sessionIdentity: string }, + file: WireFileRef, + budget: SyncRoundBudget, + ): Promise { + let st: { size: number; mtimeMs: number; ino: number }; + try { + st = await stat(file.path); + } catch { + return { truncated: false, failed: false }; + } + const size = st.size; + const metaKey = fileMetaKey(summary.id, file.path); + let meta = db.get(metaKey); + let legacyKey: string | null = null; + if (meta?.kind !== 'fileMeta') { + const oldKey = legacyFileMetaKey(file.path); + const legacy = db.get(oldKey); + if (legacy?.kind === 'fileMeta') { + meta = legacy; + legacyKey = oldKey; + } + } + const known = meta?.kind === 'fileMeta' ? meta : undefined; + let offset = known?.offset ?? 0; + let turnState: TurnCounterState = known?.turnState ?? initialTurnState(); + let stepState: StepTrackerState = known?.stepState ?? initialStepState(); + const fileMeta = ( + nextOffset: number, + turns: TurnCounterState, + steps: StepTrackerState, + ): FileMetaDoc => ({ + kind: 'fileMeta', + sessionId: summary.id, + agentId: file.agentId, + source: file.source, + path: file.path, + offset: nextOffset, + size, + mtimeMs: st.mtimeMs, + ino: st.ino, + turnState: turns, + stepState: steps, + }); + const legacyMeta = known !== undefined && known.stepState === undefined; + const replacedFile = known?.ino !== undefined && known.ino !== st.ino; + const rewrittenInPlace = + known?.mtimeMs !== undefined && size === known.offset && st.mtimeMs > known.mtimeMs; + if (size < offset || legacyMeta || replacedFile || rewrittenInPlace) { + this.syncReplaced = true; + await this.deleteFileDocs(db, fileMeta(0, initialTurnState(), initialStepState())); + offset = 0; + turnState = initialTurnState(); + stepState = initialStepState(); + } + if (size === offset) { + if ( + legacyKey !== null || + known === undefined || + known.size !== size || + known.mtimeMs !== st.mtimeMs || + known.ino !== st.ino || + known.offset !== offset + ) { + const ops: BatchInputOp[] = [ + { op: 'set', key: metaKey, value: fileMeta(offset, turnState, stepState) }, + ]; + if (legacyKey !== null) ops.push({ op: 'del', key: legacyKey }); + await db.batch(ops); + } + return { truncated: false, failed: false }; + } + + let handle: Awaited>; + try { + handle = await open(file.path, 'r'); + } catch (error) { + return { truncated: false, failed: true, error: errorMessage(error) }; + } + const ops: BatchInputOp[] = []; + let byteCursor = offset; + let position = offset; + let wireError: unknown; + try { + let pending: Buffer = EMPTY_BUFFER; + let finishing = false; + const chunk = Buffer.allocUnsafe(WIRE_READ_CHUNK_BYTES); + while (position < size) { + if (this.disposed) return { truncated: false, failed: false }; + if (syncBudgetExhausted(budget)) { + if (pending.length === 0) break; + finishing = true; + } + let bytesRead: number; + try { + ({ bytesRead } = await handle.read( + chunk, + 0, + Math.min(chunk.length, size - position), + position, + )); + } catch (error) { + wireError = error; + break; + } + if (bytesRead === 0) break; + budget.bytesLeft -= bytesRead; + const slice = chunk.subarray(0, bytesRead); + position += bytesRead; + let start = 0; + let completedRecord = false; + for (;;) { + const nl = slice.indexOf(0x0a, start); + if (nl === -1) break; + const lineBuf = + pending.length > 0 + ? Buffer.concat([pending, slice.subarray(start, nl)]) + : slice.subarray(start, nl); + pending = EMPTY_BUFFER; + const lineOffset = byteCursor; + byteCursor += lineBuf.length + 1; + ({ turnState, stepState } = this.collectWireLine( + ops, + summary, + file, + lineBuf.toString('utf8'), + lineOffset, + { turnState, stepState }, + )); + completedRecord = true; + start = nl + 1; + } + pending = + pending.length > 0 + ? Buffer.concat([pending, slice.subarray(start)]) + : Buffer.from(slice.subarray(start)); + if (finishing && completedRecord) break; + if (ops.length >= WIRE_BATCH_OPS) { + ops.push({ op: 'set', key: metaKey, value: fileMeta(byteCursor, turnState, stepState) }); + if (legacyKey !== null) { + ops.push({ op: 'del', key: legacyKey }); + legacyKey = null; + } + await db.batch(ops); + ops.length = 0; + } + } + } finally { + await handle.close(); + } + + const truncated = position < size && syncBudgetExhausted(budget); + if (byteCursor !== offset || legacyKey !== null) { + ops.push({ op: 'set', key: metaKey, value: fileMeta(byteCursor, turnState, stepState) }); + if (legacyKey !== null) ops.push({ op: 'del', key: legacyKey }); + await db.batch(ops); + } + return { + truncated, + failed: wireError !== undefined, + error: wireError !== undefined ? errorMessage(wireError) : undefined, + }; + } + + private collectWireLine( + ops: BatchInputOp[], + summary: SyncSessionInput & { readonly sessionIdentity: string }, + file: WireFileRef, + line: string, + lineOffset: number, + counters: { turnState: TurnCounterState; stepState: StepTrackerState }, + ): { turnState: TurnCounterState; stepState: StepTrackerState } { + let { turnState, stepState } = counters; + const analysis = analyzeWireLine(line); + const advanced = advanceTurnCounter(turnState, analysis.turn); + if ( + analysis.turn.kind === 'open' || + analysis.turn.kind === 'undo' || + (analysis.turn.kind === 'ensure' && !turnState.hasTurn) + ) { + stepState = initialStepState(); + } + turnState = advanced.state; + stepState = advanceStepTracker(stepState, analysis.step); + const extracted = analysis.messages; + for (let i = 0; i < extracted.length; i++) { + const e = extracted[i]!; + const stepOrdinal = e.stepUuid !== undefined ? stepState.byUuid[e.stepUuid] : undefined; + const doc: MessageDoc = { + kind: 'message', + sessionIdentity: summary.sessionIdentity, + sessionId: summary.id, + workspaceId: summary.workspaceId, + sessionTitle: summary.title ?? '', + agentId: file.agentId, + role: e.role, + text: e.text.length > MAX_DOC_TEXT_CHARS ? e.text.slice(0, MAX_DOC_TEXT_CHARS) : e.text, + time: e.time ?? summary.updatedAt, + turn: advanced.docTurn, + stepId: + advanced.docTurn !== undefined && stepOrdinal !== undefined + ? `t${advanced.docTurn}.${stepOrdinal}` + : undefined, + }; + ops.push({ + op: 'set', + key: `${docKeyPrefix(summary.id, file)}${lineOffset}:${i}`, + value: doc, + }); + } + return { turnState, stepState }; + } + + async search(params: CoreSearchParams): Promise { + const { q, budgets } = params; + const db = this.db; + if (db === null) { + if (this.disposed) { + throw new GlobalSearchError('index_unavailable', 'search service is disposed'); + } + if (this.openError !== null) { + throw new GlobalSearchError( + 'index_unavailable', + `search index failed to open: ${this.openError}`, + ); + } + if (params.pageToken !== undefined) { + throw new GlobalSearchError( + 'invalid_page_token', + 'the search index is not ready yet; restart the search', + ); + } + return { kind: 'building', index: this.buildingView() }; + } + + let freshnessStale = false; + let serveDb = db; + if (serveDb.readOnly) { + let fp: string | null = null; + try { + fp = await this.computeFingerprint(); + } catch (error) { + this.lastRefreshError = { at: Date.now(), message: errorMessage(error) }; + } + if (this.db === null) { + throw new GlobalSearchError('index_unavailable', 'search service is disposed'); + } + serveDb = this.db; + if (serveDb.readOnly) { + freshnessStale = fp === null || fp !== this.fingerprint || this.refreshPromise !== null; + if (fp !== null && fp !== this.fingerprint) void this.refresh(); + } + } + const generation = this.tokenGeneration(); + const page = decodePageToken(q, 'index', params.pageToken, generation); + + if (serveDb.textIndexBuilding(q.mode === 'literal' ? TRI_INDEX_NAME : TEXT_INDEX_NAME)) { + return { kind: 'building', index: this.buildingView(serveDb) }; + } + + let candidates: { key: string; value: SearchDoc | undefined; score: number }[]; + let incomplete: GlobalSearchIncomplete | undefined; + const runBounded = ( + db2: MiniDb, + ): Promise<{ + hits: { key: string; value: SearchDoc; score: number }[]; + visits: number; + truncated: boolean; + }> => { + if (q.mode === 'literal') { + return db2.searchBoundedAsync(TRI_INDEX_NAME, q.query, { + op: 'AND', + limit: budgets.literalCandidateCap + 1, + maxVisits: budgets.postingsVisitBudget, + }); + } + return db2.searchBoundedAsync(TEXT_INDEX_NAME, q.query, { + op: q.op, + limit: budgets.maxTextHits + 1, + maxVisits: budgets.postingsVisitBudget, + }); + }; + try { + let res: { + hits: { key: string; value: SearchDoc; score: number }[]; + visits: number; + truncated: boolean; + }; + try { + res = await runBounded(serveDb); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + const closedRace = + msg.includes('postings file is closed') || + msg.includes('MiniDb is closed') || + msg.includes('ValueReader is not open'); + if (!closedRace || this.db === null || this.db === serveDb) throw error; + serveDb = this.db; + res = await runBounded(serveDb); + } + if (q.mode === 'literal') { + candidates = res.hits; + if (res.truncated) incomplete = 'postings_budget'; + if (candidates.length > budgets.literalCandidateCap) { + candidates.length = budgets.literalCandidateCap; + incomplete ??= 'candidate_cap'; + } + } else { + candidates = res.hits; + if (res.truncated) incomplete = 'postings_budget'; + if (candidates.length > budgets.maxTextHits) { + candidates.length = budgets.maxTextHits; + incomplete ??= 'candidate_cap'; + } + } + } catch (error) { + if (error instanceof TextIndexBuildingError) { + return { kind: 'building', index: this.buildingView(serveDb) }; + } + if (error instanceof Error && error.message.includes('no such text index')) { + return { + kind: 'page', + rows: [], + hasMore: false, + incomplete: undefined, + generation, + index: this.readIndexView(serveDb, freshnessStale), + }; + } + throw error; + } + + const budget: MatchBudget = { + deadlineAt: Date.now() + budgets.queryDeadlineMs, + textCharsLeft: budgets.queryTextBudgetChars, + }; + const boundary = page.kind === 'keyset' ? page.boundary : undefined; + const matched = matchDocs(q, candidates, boundary, budget); + incomplete ??= matched.incomplete; + const index = this.readIndexView(serveDb, freshnessStale); + const sources = new Map(); + for (const row of matched.rows) { + const id = row.value.sessionId; + if (sources.has(id)) continue; + if (Date.now() > budget.deadlineAt) { + incomplete ??= 'deadline'; + break; + } + const meta = serveDb.get(SESSION_META_PREFIX + id); + sources.set(id, meta?.kind === 'sessionMeta' ? meta : undefined); + } + const identities = new Map(); + const visible: MatchedRow[] = []; + let deadlineTimer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + deadlineTimer = setTimeout(() => resolve(null), Math.max(0, budget.deadlineAt - Date.now())); + deadlineTimer.unref?.(); + }); + try { + for (const row of matched.rows) { + if (Date.now() > budget.deadlineAt) { + incomplete ??= 'deadline'; + break; + } + const meta = sources.get(row.value.sessionId); + if (meta?.dir === undefined || row.value.sessionIdentity === undefined || meta.identity !== row.value.sessionIdentity) { + freshnessStale = true; + continue; + } + if (!identities.has(meta.dir)) { + try { + const identity = await queryDirectoryIdentity(meta.dir, budget.deadlineAt, deadline); + if (identity === null || Date.now() > budget.deadlineAt) { + incomplete ??= 'deadline'; + break; + } + identities.set(meta.dir, identity); + } catch (error) { + throw new GlobalSearchError('index_unavailable', `cannot verify search source: ${errorMessage(error)}`); + } + } + if (identities.get(meta.dir) === row.value.sessionIdentity) { + visible.push({ ...row, value: { ...row.value, sessionTitle: meta.title ?? '' } }); + } else { + freshnessStale = true; + } + } + } finally { + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + } + const { pageRows, hasMore } = paginateRows(q, page, visible); + return { + kind: 'page', + rows: pageRows, + hasMore, + incomplete, + generation, + index: { ...index, freshnessStale: freshnessStale || this.db !== serveDb }, + }; + } + + async reindex(): Promise { + await this.ensureOpen(); + if (this.db?.readOnly === true) { + throw new GlobalSearchError( + 'readonly_index', + 'another process holds the search-index write lock; reindex from that process', + ); + } + await this.refreshPromise?.catch(() => {}); + const db = this.db; + if (db) { + await db.close().catch(() => {}); + this.db = null; + } + this.openPromise = null; + this.fullSyncDone = false; + this.sessionSyncFailures.clear(); + this.sessionSyncSkips.clear(); + this.lockToken = undefined; + const outcome = await wipeStoreDir({ dir: this.indexDir }); + if (outcome === 'locked') { + throw new GlobalSearchError( + 'readonly_index', + 'another process holds the search-index write lock; reindex from that process', + ); + } + await this.ensureOpen(); + } + + lifecycleState(): CoreLifecycleReport { + if (this.disposed) return { state: this.db === null ? 'stopped' : 'closing' }; + const db = this.db; + if (db !== null) this.noteMaintenanceDetail(db); + if (db === null) { + if (this.openPromise !== null) return { state: 'opening' }; + if (this.openError !== null) return { state: 'degraded', detail: this.openError }; + return { state: 'stopped' }; + } + if (db.textIndexBuilding(TEXT_INDEX_NAME) || db.textIndexBuilding(TRI_INDEX_NAME)) { + return { state: 'building' }; + } + return { state: 'ready' }; + } + + private maintenanceDetail(db: MiniDb): string | undefined { + const failure = db.lastCompactError ?? db.lastGenBuildError; + return failure === null ? undefined : errorMessage(failure); + } + + private noteMaintenanceDetail(db: MiniDb): void { + const detail = this.maintenanceDetail(db); + if (detail === this.lastMaintenanceDetail) return; + const previous = this.lastMaintenanceDetail; + this.lastMaintenanceDetail = detail; + if (detail !== undefined) { + this.log.warn('global search: index maintenance is failing; the WAL keeps growing until it recovers', { + error: detail, + }); + } else if (previous !== undefined) { + this.log.info('global search: index maintenance recovered'); + } + } + + async status(): Promise { + await this.ensureOpen(); + if (this.db?.readOnly === true) { + await this.refresh(); + } + const stats = this.db?.get(STATS_KEY); + const maintenance = this.db !== null ? this.maintenanceDetail(this.db) : undefined; + return { + sessions: stats?.kind === 'stats' ? stats.sessions : 0, + documents: stats?.kind === 'stats' ? stats.documents : 0, + lastIndexedAt: stats?.kind === 'stats' ? stats.lastIndexedAt : null, + generation: this.generation, + readOnly: this.db?.readOnly === true, + lockToken: this.lockToken, + degraded: this.lastRefreshError?.message ?? maintenance ?? (stats?.kind === 'stats' ? stats.degraded : undefined), + lifecycle: this.lifecycleState(), + }; + } + + private buildingView(db?: MiniDb): CoreIndexView { + const handle = db ?? this.db; + const stats = handle?.get(STATS_KEY); + const indexed = stats?.kind === 'stats' ? stats.sessions : 0; + return { + state: 'building', + indexedSessions: indexed, + documents: stats?.kind === 'stats' ? stats.documents : 0, + readOnly: handle?.readOnly === true, + freshnessStale: true, + degraded: this.lastRefreshError?.message ?? (stats?.kind === 'stats' ? stats.degraded : undefined), + lockToken: this.lockToken, + }; + } + + private readIndexView(db: MiniDb, freshnessStale: boolean): CoreIndexView { + const stats = db.get(STATS_KEY); + const indexed = stats?.kind === 'stats' ? stats.sessions : 0; + const documents = stats?.kind === 'stats' ? stats.documents : 0; + const building = db.textIndexBuilding(TEXT_INDEX_NAME) || db.textIndexBuilding(TRI_INDEX_NAME); + return { + state: building ? 'building' : db.readOnly ? 'readonly' : this.fullSyncDone ? 'ready' : 'building', + indexedSessions: indexed, + documents, + readOnly: db.readOnly, + freshnessStale, + degraded: this.lastRefreshError?.message ?? (stats?.kind === 'stats' ? stats.degraded : undefined), + lockToken: this.lockToken, + }; + } +} + +interface WireFileRef { + readonly path: string; + readonly agentId: string; + readonly source: 'root' | 'agents'; +} + +async function collectWireFiles(sessionDir: string): Promise { + const files: WireFileRef[] = []; + const root = join(sessionDir, WIRE_FILENAME); + try { + if ((await stat(root)).isFile()) files.push({ path: root, agentId: 'main', source: 'root' }); + } catch { + } + const agentsDir = join(sessionDir, 'agents'); + try { + const entries = await readdir(agentsDir, { recursive: true, withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || entry.name !== WIRE_FILENAME) continue; + const path = join(entry.parentPath, entry.name); + files.push({ path, agentId: relative(agentsDir, entry.parentPath), source: 'agents' }); + } + } catch { + } + return files; +} + +function docKeyPrefix(sessionId: string, file: WireFileRef): string { + return `${sessionId}/${file.agentId}/${file.source}:`; +} diff --git a/packages/kap-server/src/search/match.ts b/packages/kap-server/src/search/match.ts new file mode 100644 index 0000000000000000000000000000000000000000..434bbbd3671ec69ebc731db79c2269b4762771a1 --- /dev/null +++ b/packages/kap-server/src/search/match.ts @@ -0,0 +1,272 @@ +import { createHash } from 'node:crypto'; + +import { normalizeLiteral } from '@moonshot-ai/minidb'; + +import { + GlobalSearchError, + type GlobalSearchIncomplete, + type GlobalSearchSource, +} from './contract.ts'; +import type { MessageDoc, SearchDoc, TitleDoc } from './docs.ts'; + +export interface NormalizedQuery { + readonly query: string; + readonly mode: 'terms' | 'literal'; + readonly literalQuery?: string; + readonly termsQuery?: readonly string[]; + readonly op: 'AND' | 'OR'; + readonly container?: { readonly sessionId?: string; readonly agentId?: string }; + readonly role?: 'user' | 'assistant' | 'title'; + readonly startTime?: number; + readonly endTime?: number; + readonly sort: 'score' | 'time_desc' | 'time_asc'; + readonly pageSize: number; +} + +export interface SearchBudgets { + readonly literalCandidateCap: number; + readonly maxTextHits: number; + readonly postingsVisitBudget: number; + readonly queryDeadlineMs: number; + readonly queryTextBudgetChars: number; +} + +export type SortBoundary = readonly (number | string)[]; + +export type DecodedPage = + | { readonly kind: 'first' } + | { readonly kind: 'keyset'; readonly boundary: SortBoundary } + | { readonly kind: 'legacy'; readonly skip: number }; + +export function boundaryWidth(q: NormalizedQuery): 2 | 3 { + return q.mode !== 'literal' && q.sort === 'score' ? 3 : 2; +} + +export interface MatchedRow { + readonly key: string; + readonly value: MessageDoc | TitleDoc; + readonly score: number; + readonly anchor?: number; +} + +export interface MatchBudget { + readonly deadlineAt: number; + textCharsLeft: number; +} + +function cmpKey(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +export function compareRows(q: NormalizedQuery, a: MatchedRow, b: MatchedRow): number { + if (q.mode !== 'literal' && q.sort === 'score') { + return b.score - a.score || b.value.time - a.value.time || cmpKey(a.key, b.key); + } + if (q.mode !== 'literal' && q.sort === 'time_asc') { + return a.value.time - b.value.time || cmpKey(a.key, b.key); + } + return b.value.time - a.value.time || cmpKey(a.key, b.key); +} + +export function boundaryOf(q: NormalizedQuery, row: MatchedRow): SortBoundary { + return boundaryWidth(q) === 3 ? [row.score, row.value.time, row.key] : [row.value.time, row.key]; +} + +function rowAfterBoundary(q: NormalizedQuery, row: MatchedRow, boundary: SortBoundary): boolean { + let cmp: number; + if (boundary.length === 3) { + const [bs, bt, bk] = boundary as readonly [number, number, string]; + cmp = bs - row.score || bt - row.value.time || cmpKey(row.key, bk); + } else { + const [bt, bk] = boundary as readonly [number, string]; + cmp = + q.mode !== 'literal' && q.sort === 'time_asc' + ? row.value.time - bt || cmpKey(row.key, bk) + : bt - row.value.time || cmpKey(row.key, bk); + } + return cmp > 0; +} + +export class RowTopK { + private readonly a: MatchedRow[] = []; + + constructor( + private readonly q: NormalizedQuery, + private readonly k: number, + ) {} + + private worse(x: MatchedRow, y: MatchedRow): boolean { + return compareRows(this.q, x, y) > 0; + } + + offer(row: MatchedRow): void { + const a = this.a; + if (a.length < this.k) { + a.push(row); + let i = a.length - 1; + while (i > 0) { + const p = (i - 1) >> 1; + if (!this.worse(a[i]!, a[p]!)) break; + [a[p], a[i]] = [a[i]!, a[p]!]; + i = p; + } + return; + } + if (this.k === 0 || !this.worse(a[0]!, row)) return; + a[0] = row; + let i = 0; + for (;;) { + let w = i; + const l = 2 * i + 1; + const r = 2 * i + 2; + if (l < a.length && this.worse(a[l]!, a[w]!)) w = l; + if (r < a.length && this.worse(a[r]!, a[w]!)) w = r; + if (w === i) break; + [a[w], a[i]] = [a[i]!, a[w]!]; + i = w; + } + } + + sorted(): MatchedRow[] { + return this.a.sort((x, y) => compareRows(this.q, x, y)); + } +} + +const DEADLINE_CHECK_STRIDE = 64; + +export function matchDocs( + q: NormalizedQuery, + docs: Iterable<{ key: string; value: SearchDoc | undefined; score: number }>, + boundary: SortBoundary | undefined, + budget: MatchBudget, +): { rows: MatchedRow[]; incomplete?: GlobalSearchIncomplete } { + const literalQuery = q.literalQuery; + const rows: MatchedRow[] = []; + let i = 0; + for (const { key, value: doc, score } of docs) { + if ((i++ & (DEADLINE_CHECK_STRIDE - 1)) === 0 && Date.now() > budget.deadlineAt) { + return { rows, incomplete: 'deadline' }; + } + if (doc === undefined || (doc.kind !== 'message' && doc.kind !== 'title')) continue; + if (q.container?.sessionId !== undefined && doc.sessionId !== q.container.sessionId) continue; + if (q.container?.agentId !== undefined && doc.agentId !== q.container.agentId) continue; + if (q.role !== undefined && doc.role !== q.role) continue; + if (q.startTime !== undefined && doc.time < q.startTime) continue; + if (q.endTime !== undefined && doc.time > q.endTime) continue; + if (boundary !== undefined && !rowAfterBoundary(q, { key, value: doc, score }, boundary)) { + continue; + } + if (literalQuery !== undefined) { + budget.textCharsLeft -= doc.text.length; + if (budget.textCharsLeft < 0) return { rows, incomplete: 'deadline' }; + const at = normalizeLiteral(doc.text).indexOf(literalQuery); + if (at === -1) continue; + rows.push({ key, value: doc, score: 0, anchor: at }); + } else { + rows.push({ key, value: doc, score }); + } + } + return { rows }; +} + +export function paginateRows( + q: NormalizedQuery, + page: DecodedPage, + rows: MatchedRow[], +): { pageRows: MatchedRow[]; hasMore: boolean } { + let pageRows: MatchedRow[]; + let hasMore: boolean; + if (page.kind === 'legacy') { + rows.sort((a, b) => compareRows(q, a, b)); + const slice = rows.slice(page.skip, page.skip + q.pageSize + 1); + hasMore = slice.length > q.pageSize; + pageRows = slice.slice(0, q.pageSize); + } else { + const top = new RowTopK(q, q.pageSize + 1); + for (const row of rows) top.offer(row); + const slice = top.sorted(); + hasMore = slice.length > q.pageSize; + pageRows = slice.slice(0, q.pageSize); + } + return { pageRows, hasMore }; +} + +export function tokenFingerprint(q: NormalizedQuery, source: GlobalSearchSource): string { + const basis = JSON.stringify([ + q.query, + q.mode, + q.op, + q.container?.sessionId, + q.container?.agentId, + q.role, + q.startTime, + q.endTime, + q.sort, + source, + ]); + return createHash('sha256').update(basis).digest('base64url').slice(0, 16); +} + +const PAGE_TOKEN_VERSION = 2; + +export function encodePageToken( + q: NormalizedQuery, + source: GlobalSearchSource, + boundary: SortBoundary, + generation: string | undefined, +): string { + return Buffer.from( + JSON.stringify({ v: PAGE_TOKEN_VERSION, f: tokenFingerprint(q, source), g: generation, b: boundary }), + ).toString('base64url'); +} + +export function decodePageToken( + q: NormalizedQuery, + source: GlobalSearchSource, + token: string | undefined, + generation: string | undefined, +): DecodedPage { + if (token === undefined) return { kind: 'first' }; + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(token, 'base64url').toString('utf8')); + } catch { + throw new GlobalSearchError('invalid_page_token', 'pageToken is malformed'); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new GlobalSearchError('invalid_page_token', 'pageToken is malformed'); + } + const p = parsed as { v?: unknown; f?: unknown; s?: unknown; g?: unknown; b?: unknown }; + if (p.f !== tokenFingerprint(q, source)) { + throw new GlobalSearchError( + 'invalid_page_token', + 'pageToken does not match the query conditions; query conditions must not change mid-pagination', + ); + } + if (p.v === undefined) { + if (typeof p.s !== 'number' || !Number.isInteger(p.s) || p.s < 0) { + throw new GlobalSearchError('invalid_page_token', 'pageToken is malformed'); + } + return { kind: 'legacy', skip: p.s }; + } + if (p.v !== PAGE_TOKEN_VERSION) { + throw new GlobalSearchError('invalid_page_token', 'pageToken has an unsupported version'); + } + if (generation !== undefined && p.g !== generation) { + throw new GlobalSearchError( + 'invalid_page_token', + 'pageToken was issued by an older index generation (the index was rebuilt, reopened or rescanned); restart the search', + ); + } + const width = boundaryWidth(q); + if ( + !Array.isArray(p.b) || + p.b.length !== width || + typeof p.b[0] !== 'number' || + typeof p.b[width - 1] !== 'string' || + (width === 3 && typeof p.b[1] !== 'number') + ) { + throw new GlobalSearchError('invalid_page_token', 'pageToken is malformed'); + } + return { kind: 'keyset', boundary: p.b as SortBoundary }; +} diff --git a/packages/kap-server/src/search/searchService.ts b/packages/kap-server/src/search/searchService.ts new file mode 100644 index 0000000000000000000000000000000000000000..46acce1dc636f81ad28061a67af1dcc59c3f0b76 --- /dev/null +++ b/packages/kap-server/src/search/searchService.ts @@ -0,0 +1,764 @@ +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { stat } from 'node:fs/promises'; + +import { + createDecorator, + databaseSearchEnabled, + IBootstrapService, + IConfigService, + ILogService, + ISessionIndex, + LifecycleScope, + ScopeActivation, + registerScopedService, + sessionDirOf, + workspacePersistenceScope, + type SessionSummary, +} from '@moonshot-ai/agent-core-v2'; +import { normalizeLiteral, tokenize } from '@moonshot-ai/minidb'; +import type { TranscriptStore } from '@moonshot-ai/transcript'; + +import { + GlobalSearchError, + type GlobalSearchHit, + type GlobalSearchIndexState, + type GlobalSearchPage, + type GlobalSearchQuery, +} from './contract'; +import { MAX_DOC_TEXT_CHARS, type MessageDoc, type TitleDoc } from './docs'; +import { + SearchIndexCore, + type CoreIndexView, + type CoreLifecycleReport, + type CoreSearchParams, + type CoreSearchResult, + type CoreStatus, + type CoreSyncOutcome, + type SyncSessionInput, +} from './indexCore'; +import { + boundaryOf, + decodePageToken, + encodePageToken, + matchDocs, + paginateRows, + type MatchedRow, + type NormalizedQuery, + type SearchBudgets, +} from './match'; +import { makeSnippet } from './snippet'; +import { SearchWorkerError, SearchWorkerHost, dropLiveLockToken, noteLiveLockToken } from './worker/host'; + +export { GlobalSearchError } from './contract'; +export type { GlobalSearchErrorReason } from './contract'; + +const INDEX_DIR_NAME = 'search-index'; +const SESSION_PAGE_SIZE = 500; + +const MAX_QUERY_TERMS = 32; +const MAX_LITERAL_QUERY_CHARS = 1_024; +const MAX_POSTINGS_VISITS = 250_000; +const QUERY_DEADLINE_MS = 500; +const QUERY_TEXT_BUDGET_CHARS = 16_000_000; +const MAX_TEXT_HITS = 100_000; +const LITERAL_CANDIDATE_CAP = 10_000; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +const pendingDisposals = new Set>(); + +export async function drainGlobalSearchDisposals(): Promise { + while (pendingDisposals.size > 0) { + await Promise.all(pendingDisposals); + } +} + +export interface IGlobalSearchService { + readonly _serviceBrand: undefined; + search(query: GlobalSearchQuery): Promise; + reindex(): Promise<{ sessions: number; documents: number }>; + status(): Promise<{ + sessions: number; + documents: number; + lastIndexedAt: number | null; + generation: number; + degraded?: string; + lifecycle: CoreLifecycleReport; + }>; + lifecycleReport(): CoreLifecycleReport; + setLiveTranscriptSource(source: LiveTranscriptSource): void; +} + +export const IGlobalSearchService = createDecorator('globalSearch'); + +export interface LiveTranscriptSource { + forSessionLive(sessionId: string): TranscriptStore | undefined; + whenReady(sessionId: string): Promise; + ensureAgentHistory(sessionId: string, agentId: string): Promise; +} + +function normalizeQuery(input: GlobalSearchQuery, maxQueryTerms: number): NormalizedQuery { + const mode = input.mode ?? 'terms'; + const query = mode === 'literal' ? input.query : input.query.trim(); + if (query.length === 0) { + throw new GlobalSearchError('invalid_query', 'query must be a non-empty string'); + } + const literalQuery = mode === 'literal' ? normalizeLiteral(query) : undefined; + const termsQuery = mode === 'terms' ? [...new Set(tokenize(query))] : undefined; + if (termsQuery !== undefined && termsQuery.length > maxQueryTerms) { + throw new GlobalSearchError( + 'invalid_query', + `query has too many terms (${termsQuery.length} > ${maxQueryTerms}); narrow it down`, + ); + } + const pageSize = input.pageSize ?? 20; + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 50) { + throw new GlobalSearchError('invalid_query', 'pageSize must be an integer between 1 and 50'); + } + return { + query, + mode, + literalQuery, + termsQuery, + op: input.op ?? 'AND', + container: input.container, + role: input.role, + startTime: input.startTime, + endTime: input.endTime, + sort: input.sort ?? 'score', + pageSize, + }; +} + +export interface SearchBackend { + beginClose(): void; + lifecycleSnapshot(): CoreLifecycleReport; + ensureOpen(): Promise; + search(params: CoreSearchParams): Promise; + sync(sessions: readonly SyncSessionInput[]): Promise; + refresh(): Promise; + reindex(): Promise; + status(): Promise; + dispose(): Promise; +} + +export class InlineSearchBackend implements SearchBackend { + readonly core: SearchIndexCore; + + constructor(options: { indexDir: string; log: ILogService }) { + this.core = new SearchIndexCore({ + ...options, + bootSalt: randomUUID(), + onLockToken: noteLiveLockToken, + }); + } + + beginClose(): void { + this.core.beginClose(); + } + + lifecycleSnapshot(): CoreLifecycleReport { + return this.core.lifecycleState(); + } + + ensureOpen(): Promise { + return this.core.ensureOpen(); + } + + search(params: CoreSearchParams): Promise { + return this.core.search(params); + } + + sync(sessions: readonly SyncSessionInput[]): Promise { + return this.core.sync(sessions); + } + + refresh(): Promise { + return this.core.refresh(); + } + + reindex(): Promise { + return this.core.reindex(); + } + + status(): Promise { + return this.core.status(); + } + + dispose(): Promise { + dropLiveLockToken(this.core.lockTokenView); + return this.core.close(); + } +} + +export class GlobalSearchService implements IGlobalSearchService { + declare readonly _serviceBrand: undefined; + + syncDebounceMs = 2_000; + + literalCandidateCap = LITERAL_CANDIDATE_CAP; + + maxTextHits = MAX_TEXT_HITS; + + postingsVisitBudget = MAX_POSTINGS_VISITS; + + queryDeadlineMs = QUERY_DEADLINE_MS; + + queryTextBudgetChars = QUERY_TEXT_BUDGET_CHARS; + + maxQueryTerms = MAX_QUERY_TERMS; + + private backend: SearchBackend | null = null; + private backendPromise: Promise | null = null; + private syncPromise: Promise | null = null; + private refreshPromise: Promise | null = null; + private lastSyncStartedAt = 0; + private summaries = new Map(); + private disposed = false; + private reindexing = false; + private liveSource: LiveTranscriptSource | null = null; + private syncQueued = false; + private syncTimer: ReturnType | null = null; + private lastRefreshError: { at: number; message: string } | null = null; + private drainSettled = false; + + constructor( + @ISessionIndex private readonly sessionIndex: ISessionIndex, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @ILogService private readonly log: ILogService, + @IConfigService private readonly config: IConfigService, + ) { + this.requestSync(); + } + + setLiveTranscriptSource(source: LiveTranscriptSource): void { + this.liveSource = source; + } + + private get indexDir(): string { + return join(this.bootstrap.homeDir, INDEX_DIR_NAME); + } + + private ensureBackend(): Promise { + if (this.backend !== null) return Promise.resolve(this.backend); + this.backendPromise ??= this.config.ready.then(() => { + if (this.backend === null) { + this.backend = databaseSearchEnabled(this.config) + ? new SearchWorkerHost({ dir: this.indexDir, log: this.log }) + : new InlineSearchBackend({ indexDir: this.indexDir, log: this.log }); + if (this.disposed) this.backend.beginClose(); + } + return this.backend; + }); + return this.backendPromise; + } + + private toSyncInput(summary: SessionSummary): SyncSessionInput { + return { + id: summary.id, + workspaceId: summary.workspaceId, + title: summary.title, + updatedAt: summary.updatedAt, + dir: sessionDirOf( + this.bootstrap.homeDir, + workspacePersistenceScope(this.bootstrap.scope('sessions'), summary.workspaceId), + summary.id, + ), + }; + } + + dispose(): void { + this.disposed = true; + if (this.syncTimer !== null) { + clearTimeout(this.syncTimer); + this.syncTimer = null; + } + this.backend?.beginClose(); + const pending = (async () => { + await this.syncPromise?.catch(() => {}); + await this.refreshPromise?.catch(() => {}); + const backend = await this.backendPromise; + await backend?.dispose(); + this.drainSettled = true; + })(); + pendingDisposals.add(pending); + void pending.finally(() => pendingDisposals.delete(pending)); + } + + private requestSync(): void { + if (this.disposed || this.reindexing) return; + if (this.syncPromise !== null) { + this.syncQueued = true; + return; + } + const wait = this.syncDebounceMs - (Date.now() - this.lastSyncStartedAt); + if (wait > 0) { + if (this.syncTimer === null) { + this.syncTimer = setTimeout(() => { + this.syncTimer = null; + this.requestSync(); + }, wait); + this.syncTimer.unref?.(); + } + return; + } + this.startSyncPass(); + } + + private startSyncPass(): void { + this.syncQueued = false; + void this.ensureSyncStarted().then( + () => { + this.lastRefreshError = null; + if (this.syncQueued) { + this.syncQueued = false; + this.requestSync(); + } + }, + (error: unknown) => { + this.lastRefreshError = { at: Date.now(), message: errorMessage(error) }; + this.log.warn('global search: background sync failed', { error: errorMessage(error) }); + }, + ); + } + + private ensureSyncStarted(): Promise { + if (this.syncPromise === null) { + const p = this.runSync().finally(() => { + if (this.syncPromise === p) this.syncPromise = null; + }); + this.syncPromise = p; + } + return this.syncPromise; + } + + private async runSync(): Promise { + if (this.disposed || this.reindexing) return; + const backend = await this.ensureBackend(); + const sessions = await this.listAllSessions(); + if (this.disposed) return; + if (sessions.length === 0 && !(await pathExists(this.indexDir))) { + this.summaries = new Map(); + this.lastSyncStartedAt = Date.now(); + return; + } + this.summaries = new Map(sessions.map((s) => [s.id, s])); + this.lastSyncStartedAt = Date.now(); + const outcome = await backend.sync(sessions.map((s) => this.toSyncInput(s))); + if (outcome.truncated || outcome.failures > 0) this.requestSync(); + } + + private async listAllSessions(): Promise { + const out: SessionSummary[] = []; + let cursor: string | undefined; + do { + const page = await this.sessionIndex.listRecent({ before: cursor, limit: SESSION_PAGE_SIZE }); + out.push(...page.items); + cursor = page.nextCursor; + } while (cursor !== undefined); + return out; + } + + private refreshReadonly(): Promise { + if (this.refreshPromise === null) { + this.refreshPromise = this.ensureBackend() + .then((backend) => backend.refresh()) + .then( + () => {}, + (error: unknown) => { + this.lastRefreshError = { at: Date.now(), message: errorMessage(error) }; + this.log.warn('global search: read-only refresh failed; serving the stale view', { + error: errorMessage(error), + }); + }, + ); + void this.refreshPromise.finally(() => { + this.refreshPromise = null; + }); + } + return this.refreshPromise; + } + + async search(input: GlobalSearchQuery): Promise { + const q = normalizeQuery(input, this.maxQueryTerms); + const sessionId = q.container?.sessionId; + const liveStore = sessionId !== undefined ? this.liveSource?.forSessionLive(sessionId) : undefined; + if (liveStore !== undefined && sessionId !== undefined) { + return this.searchLive(q, sessionId, liveStore, input.pageToken); + } + return this.searchIndex(q, input.pageToken); + } + + private async searchLive( + q: NormalizedQuery, + sessionId: string, + store: TranscriptStore, + pageToken: string | undefined, + ): Promise { + const page = decodePageToken(q, 'live', pageToken, undefined); + const source = this.liveSource; + if (source === null) { + throw new GlobalSearchError('index_unavailable', 'live transcript source is not wired'); + } + await source.whenReady(sessionId); + const agentIds = + q.container?.agentId !== undefined + ? [q.container.agentId] + : store.agents().map((agent) => agent.agentId); + for (const agentId of agentIds) { + await source.ensureAgentHistory(sessionId, agentId); + } + const docs = await this.collectLiveDocs(sessionId, store, agentIds); + const budget = { + deadlineAt: Date.now() + this.queryDeadlineMs, + textCharsLeft: this.queryTextBudgetChars, + }; + const boundary = page.kind === 'keyset' ? page.boundary : undefined; + const matched = + q.mode === 'literal' + ? matchDocs( + q, + docs.map(({ key, value }) => ({ key, value, score: 0 })), + boundary, + budget, + ) + : matchDocs(q, matchLiveTerms(q.termsQuery ?? [], docs), boundary, budget); + const { pageRows, hasMore } = paginateRows(q, page, matched.rows); + return { + items: pageRows.map((row) => this.projectHit(q, row)), + hasMore, + pageToken: hasMore + ? encodePageToken(q, 'live', boundaryOf(q, pageRows[pageRows.length - 1]!), undefined) + : undefined, + incomplete: matched.incomplete, + indexState: { + state: 'ready', + indexedSessions: 1, + totalSessions: 1, + documents: docs.length, + }, + source: 'live', + }; + } + + private async collectLiveDocs( + sessionId: string, + store: TranscriptStore, + agentIds: readonly string[], + ): Promise<{ key: string; value: MessageDoc | TitleDoc }[]> { + const summary = await this.sessionIndex.get(sessionId); + const workspaceId = summary?.workspaceId ?? ''; + const sessionTitle = summary?.title ?? ''; + const fallbackTime = summary?.updatedAt ?? 0; + const parseTime = (iso: string | undefined): number => { + if (iso === undefined) return fallbackTime; + const ms = Date.parse(iso); + return Number.isNaN(ms) ? fallbackTime : ms; + }; + const docs: { key: string; value: MessageDoc | TitleDoc }[] = []; + for (const agentId of agentIds) { + const transcript = store.getAgent(agentId); + if (transcript === undefined) continue; + for (const item of transcript.snapshot().items) { + if (item.kind !== 'turn') continue; + const turnTime = parseTime(item.startedAt); + const prompt = item.prompt?.trim() ?? ''; + if (prompt.length > 0) { + docs.push({ + key: `${sessionId}/${agentId}/live/u/t${item.ordinal}`, + value: { + kind: 'message', + sessionId, + workspaceId, + sessionTitle, + agentId, + role: 'user', + text: prompt.length > MAX_DOC_TEXT_CHARS ? prompt.slice(0, MAX_DOC_TEXT_CHARS) : prompt, + time: turnTime, + turn: item.ordinal, + stepId: undefined, + }, + }); + } + for (const step of item.steps) { + const stepTime = parseTime(step.endedAt ?? step.startedAt ?? item.startedAt); + for (const frame of step.frames) { + if (frame.kind !== 'text' || frame.role !== 'assistant') continue; + const text = frame.text.trim(); + if (text.length === 0) continue; + docs.push({ + key: `${sessionId}/${agentId}/live/a/${frame.frameId}`, + value: { + kind: 'message', + sessionId, + workspaceId, + sessionTitle, + agentId, + role: 'assistant', + text: text.length > MAX_DOC_TEXT_CHARS ? text.slice(0, MAX_DOC_TEXT_CHARS) : text, + time: stepTime, + turn: item.ordinal, + stepId: step.stepId, + }, + }); + } + } + } + } + if (sessionTitle.length > 0) { + docs.push({ + key: `${sessionId}/$title`, + value: { + kind: 'title', + sessionId, + workspaceId, + sessionTitle, + agentId: '', + role: 'title', + text: sessionTitle, + time: fallbackTime, + }, + }); + } + return docs; + } + + private budgets(): SearchBudgets { + return { + literalCandidateCap: this.literalCandidateCap, + maxTextHits: this.maxTextHits, + postingsVisitBudget: this.postingsVisitBudget, + queryDeadlineMs: this.queryDeadlineMs, + queryTextBudgetChars: this.queryTextBudgetChars, + }; + } + + private async searchIndex( + q: NormalizedQuery, + pageToken: string | undefined, + ): Promise { + if (q.mode === 'literal') { + const literalLength = Array.from(q.literalQuery ?? '').length; + if (literalLength < 2) { + throw new GlobalSearchError( + 'invalid_query', + 'literal queries need at least 2 characters (after Unicode normalization)', + ); + } + if (literalLength > MAX_LITERAL_QUERY_CHARS) { + throw new GlobalSearchError( + 'invalid_query', + `literal queries are limited to ${MAX_LITERAL_QUERY_CHARS} characters`, + ); + } + } + + let result: CoreSearchResult; + try { + const backend = await this.ensureBackend(); + result = await backend.search({ q, pageToken, budgets: this.budgets() }); + } catch (error) { + if (error instanceof GlobalSearchError) { + if (error.reason === 'index_unavailable') this.requestSync(); + throw error; + } + if (error instanceof SearchWorkerError) { + this.lastRefreshError = { at: Date.now(), message: error.message }; + this.log.warn('global search: search worker unavailable; serving a degraded page', { + error: error.message, + code: error.code, + }); + if (error.code === 'disposed') { + throw new GlobalSearchError('index_unavailable', 'search service is disposed'); + } + this.requestSync(); + if (pageToken !== undefined) { + throw new GlobalSearchError( + 'invalid_page_token', + 'the search index is not ready yet; restart the search', + ); + } + return this.buildingPage(null); + } + throw error; + } + + if (!result.index.readOnly) this.requestSync(); + + if (result.kind === 'building') return this.buildingPage(result.index); + + return { + items: result.rows.map((row) => this.projectHit(q, row)), + hasMore: result.hasMore, + pageToken: result.hasMore + ? encodePageToken( + q, + 'index', + boundaryOf(q, result.rows[result.rows.length - 1]!), + result.generation, + ) + : undefined, + incomplete: result.incomplete, + indexState: this.composeIndexState(result.index), + source: 'index', + }; + } + + private projectHit(q: NormalizedQuery, row: MatchedRow): GlobalSearchHit { + const doc = row.value; + return { + sessionId: doc.sessionId, + workspaceId: doc.workspaceId, + sessionTitle: doc.sessionTitle, + agentId: doc.agentId, + role: doc.role, + snippet: + doc.kind === 'title' + ? doc.text + : row.anchor !== undefined && q.literalQuery !== undefined + ? makeSnippet(doc.text, q.query, 80, { at: row.anchor, len: q.literalQuery.length }) + : makeSnippet(doc.text, q.query), + time: doc.time, + turn: doc.kind === 'message' ? doc.turn : undefined, + stepId: doc.kind === 'message' ? doc.stepId : undefined, + score: row.score, + }; + } + + private composeIndexState(view: CoreIndexView): GlobalSearchIndexState { + const coordinatorStale = view.readOnly + ? this.refreshPromise !== null + : this.syncPromise !== null || this.syncQueued || this.syncTimer !== null; + return { + state: view.state, + indexedSessions: view.indexedSessions, + totalSessions: view.readOnly + ? view.indexedSessions + : Math.max(view.indexedSessions, this.summaries.size), + documents: view.documents, + stale: view.freshnessStale || coordinatorStale || undefined, + degraded: this.lastRefreshError?.message ?? view.degraded, + }; + } + + private buildingPage(view: CoreIndexView | null): GlobalSearchPage { + const indexed = view?.indexedSessions ?? 0; + const readOnly = view?.readOnly === true; + return { + items: [], + hasMore: false, + pageToken: undefined, + incomplete: undefined, + indexState: { + state: 'building', + indexedSessions: indexed, + totalSessions: readOnly ? indexed : Math.max(indexed, this.summaries.size), + documents: view?.documents ?? 0, + stale: true, + degraded: this.lastRefreshError?.message ?? view?.degraded, + }, + source: 'index', + }; + } + + async reindex(): Promise<{ sessions: number; documents: number }> { + const backend = await this.ensureBackend(); + try { + this.reindexing = true; + await backend.ensureOpen(); + await this.syncPromise?.catch(() => {}); + await backend.reindex(); + this.reindexing = false; + await this.ensureSyncStarted(); + this.lastRefreshError = null; + } catch (error) { + this.reindexing = false; + this.lastRefreshError = { at: Date.now(), message: errorMessage(error) }; + throw error; + } + const stats = await backend.status(); + return { sessions: stats.sessions, documents: stats.documents }; + } + + async status(): Promise<{ + sessions: number; + documents: number; + lastIndexedAt: number | null; + generation: number; + degraded?: string; + lifecycle: CoreLifecycleReport; + }> { + const empty = { sessions: 0, documents: 0, lastIndexedAt: null, generation: 0 }; + if (this.disposed) { + return { + ...empty, + lifecycle: { state: this.drainSettled ? 'stopped' : 'closing' }, + }; + } + try { + const backend = await this.ensureBackend(); + const status = await backend.status(); + if (!status.readOnly) this.requestSync(); + return { + sessions: status.sessions, + documents: status.documents, + lastIndexedAt: status.lastIndexedAt, + generation: status.generation, + degraded: this.lastRefreshError?.message ?? status.degraded, + lifecycle: status.lifecycle, + }; + } catch (error) { + const message = errorMessage(error); + return { ...empty, degraded: message, lifecycle: { state: 'degraded', detail: message } }; + } + } + + lifecycleReport(): CoreLifecycleReport { + if (this.disposed) return { state: this.drainSettled ? 'stopped' : 'closing' }; + return this.backend?.lifecycleSnapshot() ?? { state: 'stopped' }; + } +} + +function matchLiveTerms( + terms: readonly string[], + docs: readonly { key: string; value: MessageDoc | TitleDoc }[], +): { key: string; value: MessageDoc | TitleDoc; score: number }[] { + if (terms.length === 0) return []; + const matched: { key: string; value: MessageDoc | TitleDoc; score: number }[] = []; + for (const { key, value: doc } of docs) { + const counts = new Map(); + for (const token of tokenize(doc.text)) counts.set(token, (counts.get(token) ?? 0) + 1); + let score = 0; + let hit = true; + for (const term of terms) { + const tf = counts.get(term) ?? 0; + if (tf === 0) { + hit = false; + break; + } + score += Math.log(1 + tf); + } + if (hit) matched.push({ key, value: doc, score }); + } + return matched; +} + +registerScopedService( + LifecycleScope.App, + IGlobalSearchService, + GlobalSearchService, + ScopeActivation.OnDemand, + 'globalSearch', +); diff --git a/packages/kap-server/src/search/snippet.ts b/packages/kap-server/src/search/snippet.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e24957c2e6218ff76390bc18a93a847b287d411 --- /dev/null +++ b/packages/kap-server/src/search/snippet.ts @@ -0,0 +1,47 @@ +function collapseWs(s: string): string { + return s.replaceAll(/\s+/g, ' ').trim(); +} + +export function snippetTerms(query: string): string[] { + const terms = query + .split(/\s+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); + const whole = query.trim(); + if (whole.length > 0 && !terms.includes(whole)) terms.push(whole); + return terms; +} + +export function makeSnippet( + text: string, + query: string, + radius = 80, + anchor?: { at: number; len: number }, +): string { + let hitAt = -1; + let hitLen = 0; + if (anchor !== undefined) { + hitAt = anchor.at; + hitLen = anchor.len; + } else { + const lower = text.toLowerCase(); + for (const term of snippetTerms(query)) { + const i = lower.indexOf(term.toLowerCase()); + if (i === -1) continue; + if (hitAt === -1 || i < hitAt || (i === hitAt && term.length > hitLen)) { + hitAt = i; + hitLen = term.length; + } + } + } + + if (hitAt === -1) { + const head = collapseWs(text.slice(0, radius * 2)); + return text.length > radius * 2 ? `${head}…` : head; + } + + const start = Math.max(0, hitAt - radius); + const end = Math.min(text.length, hitAt + hitLen + radius); + const window = collapseWs(text.slice(start, end)); + return `${start > 0 ? '…' : ''}${window}${end < text.length ? '…' : ''}`; +} diff --git a/packages/kap-server/src/search/wireExtract.ts b/packages/kap-server/src/search/wireExtract.ts new file mode 100644 index 0000000000000000000000000000000000000000..24d1bec8e95a2280f95710d73bf7ce3a2851f732 --- /dev/null +++ b/packages/kap-server/src/search/wireExtract.ts @@ -0,0 +1,228 @@ +import { matchSingleMediaPathTag } from '@moonshot-ai/agent-core-v2/agent/media/mediaRef'; + +export interface ExtractedWireMessage { + readonly role: 'user' | 'assistant'; + readonly text: string; + readonly time?: number; + readonly stepUuid?: string; +} + +export type TurnEffect = + | { readonly kind: 'open'; readonly anchor: boolean } + | { readonly kind: 'ensure' } + | { readonly kind: 'undo'; readonly count: number } + | { readonly kind: 'none' }; + +export type StepEffect = + | { readonly kind: 'begin'; readonly uuid: string; readonly ordinal?: number } + | { readonly kind: 'none' }; + +export interface WireLineAnalysis { + readonly messages: ExtractedWireMessage[]; + readonly turn: TurnEffect; + readonly step: StepEffect; +} + +const NONE: TurnEffect = { kind: 'none' }; +const ENSURE: TurnEffect = { kind: 'ensure' }; +const STEP_NONE: StepEffect = { kind: 'none' }; + +const NON_USER_ORIGIN_KINDS: ReadonlySet = new Set([ + 'injection', + 'system_trigger', + 'retry', + 'compaction_summary', +]); + +const HIDDEN_USER_ORIGINS: ReadonlySet = new Set(['injection', 'system_trigger', 'retry']); +const TURN_OPENING_SYSTEM_TRIGGERS: ReadonlySet = new Set([ + 'goal_continuation', + 'subagent', +]); +const MARKER_USER_ORIGINS: ReadonlySet = new Set([ + 'skill_activation', + 'plugin_command', + 'compaction_summary', +]); + +interface OriginLike { + readonly kind?: unknown; + readonly trigger?: unknown; + readonly name?: unknown; +} + +function isUserSlashPrompt(origin: OriginLike): boolean { + return ( + (origin.kind === 'skill_activation' || origin.kind === 'plugin_command') && + origin.trigger === 'user-slash' + ); +} + +function isUserTypedOrigin(origin: OriginLike): boolean { + if (origin.kind === 'skill_activation' || origin.kind === 'plugin_command') { + return origin.trigger === 'user-slash'; + } + if (typeof origin.kind === 'string' && NON_USER_ORIGIN_KINDS.has(origin.kind)) return false; + return true; +} + +function normalizeTimestampMs(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined; + return value > 1e12 ? Math.floor(value) : Math.floor(value * 1000); +} + +interface ContentPartLike { + readonly type?: unknown; + readonly text?: unknown; +} + +function textOfContent(content: unknown): string { + if (!Array.isArray(content)) return ''; + let text = ''; + for (const raw of content as readonly unknown[]) { + if (raw === null || typeof raw !== 'object') continue; + const part = raw as ContentPartLike; + if (part.type !== 'text' || typeof part.text !== 'string') continue; + if (matchSingleMediaPathTag(part.text) !== undefined) continue; + text += part.text; + } + return text; +} + +interface ParsedWireRecord { + readonly type?: unknown; + readonly time?: unknown; + readonly message?: unknown; + readonly event?: unknown; + readonly count?: unknown; +} + +function parseWireLine(line: string): ParsedWireRecord | undefined { + const trimmed = line.trim(); + if (trimmed.length === 0) return undefined; + let record: unknown; + try { + record = JSON.parse(trimmed); + } catch { + return undefined; + } + if (record === null || typeof record !== 'object' || Array.isArray(record)) return undefined; + return record as ParsedWireRecord; +} + +function turnEffectOfAppendMessage(message: unknown): TurnEffect { + if (message === null || typeof message !== 'object') return NONE; + const m = message as { role?: unknown; origin?: unknown }; + if (m.role === 'system') return NONE; + if (m.role === 'assistant') return ENSURE; + if (m.role !== 'user') return NONE; + + const origin = + m.origin !== null && typeof m.origin === 'object' ? (m.origin as OriginLike) : undefined; + const kind = origin?.kind; + if (typeof kind === 'string' && HIDDEN_USER_ORIGINS.has(kind)) { + if ( + kind === 'system_trigger' && + typeof origin?.name === 'string' && + TURN_OPENING_SYSTEM_TRIGGERS.has(origin.name) + ) { + return { kind: 'open', anchor: false }; + } + return NONE; + } + if (typeof kind === 'string' && MARKER_USER_ORIGINS.has(kind)) { + if (origin !== undefined && isUserSlashPrompt(origin)) return { kind: 'open', anchor: true }; + return NONE; + } + const anchor = kind === undefined || kind === 'user'; + return { kind: 'open', anchor }; +} + +export function analyzeWireLine(line: string): WireLineAnalysis { + const r = parseWireLine(line); + if (r === undefined) return { messages: [], turn: NONE, step: STEP_NONE }; + const time = normalizeTimestampMs(r.time); + + if (r.type === 'context.append_message') { + const turn = turnEffectOfAppendMessage(r.message); + const message = r.message; + const messages: ExtractedWireMessage[] = []; + if (message !== null && typeof message === 'object') { + const m = message as { role?: unknown; content?: unknown; origin?: unknown }; + if (m.role === 'user') { + const origin = m.origin; + const userTyped = + origin === null || + origin === undefined || + (typeof origin === 'object' && isUserTypedOrigin(origin as OriginLike)); + if (userTyped) { + const text = textOfContent(m.content).trim(); + if (text.length > 0) messages.push({ role: 'user', text, time }); + } + } + } + return { messages, turn, step: STEP_NONE }; + } + + if (r.type === 'context.append_loop_event') { + const event = r.event; + if (event === null || typeof event !== 'object') { + return { messages: [], turn: NONE, step: STEP_NONE }; + } + const e = event as { + type?: unknown; + part?: unknown; + uuid?: unknown; + step?: unknown; + stepUuid?: unknown; + }; + const messages: ExtractedWireMessage[] = []; + if (e.type === 'step.begin') { + if (typeof e.uuid !== 'string' || e.uuid.length === 0) { + return { messages: [], turn: NONE, step: STEP_NONE }; + } + const ordinal = + typeof e.step === 'number' && Number.isSafeInteger(e.step) && e.step > 0 + ? e.step + : undefined; + return { messages: [], turn: NONE, step: { kind: 'begin', uuid: e.uuid, ordinal } }; + } + const stepUuid = + typeof e.stepUuid === 'string' && e.stepUuid.length > 0 ? e.stepUuid : undefined; + let turn: TurnEffect = NONE; + if (e.type === 'content.part') { + const part = e.part; + if (part !== null && typeof part === 'object') { + const p = part as ContentPartLike & { think?: unknown; encrypted?: unknown }; + if (p.type === 'text' && typeof p.text === 'string') { + const text = p.text.trim(); + if (text.length > 0) { + messages.push({ role: 'assistant', text, time, stepUuid }); + turn = ENSURE; + } + } else if (p.type === 'think' && typeof p.think === 'string') { + if (p.think.trim().length > 0 || p.encrypted !== undefined) turn = ENSURE; + } else { + turn = ENSURE; + } + } + } else if (e.type === 'tool.call') { + turn = ENSURE; + } + return { messages, turn, step: STEP_NONE }; + } + + if (r.type === 'context.undo') { + const count = r.count; + if (typeof count === 'number' && Number.isSafeInteger(count) && count > 0) { + return { messages: [], turn: { kind: 'undo', count }, step: STEP_NONE }; + } + return { messages: [], turn: NONE, step: STEP_NONE }; + } + + return { messages: [], turn: NONE, step: STEP_NONE }; +} + +export function extractFromWireLine(line: string): ExtractedWireMessage[] { + return analyzeWireLine(line).messages; +} diff --git a/packages/kap-server/src/search/worker/dev-hooks.mjs b/packages/kap-server/src/search/worker/dev-hooks.mjs new file mode 100644 index 0000000000000000000000000000000000000000..b752597a011aabe2d5421b339bc95eb46d908bba --- /dev/null +++ b/packages/kap-server/src/search/worker/dev-hooks.mjs @@ -0,0 +1,14 @@ +export async function resolve(specifier, context, nextResolve) { + try { + return await nextResolve(specifier, context); + } catch (error) { + if ( + error?.code === 'ERR_MODULE_NOT_FOUND' && + (specifier.startsWith('./') || specifier.startsWith('../')) && + /\.m?js$/.test(specifier) + ) { + return nextResolve(specifier.replace(/\.m?js$/, '.ts'), context); + } + throw error; + } +} diff --git a/packages/kap-server/src/search/worker/entry.ts b/packages/kap-server/src/search/worker/entry.ts new file mode 100644 index 0000000000000000000000000000000000000000..8be6b16ce284b34703de3248dc472dea98c523c0 --- /dev/null +++ b/packages/kap-server/src/search/worker/entry.ts @@ -0,0 +1,163 @@ +import { parentPort, workerData } from 'node:worker_threads'; + +import { configureTextBuildWorkerRuntime } from '@moonshot-ai/minidb/worker-runtime'; + +import { GlobalSearchError, type GlobalSearchErrorReason } from '../contract.ts'; +import { + SearchIndexCore, + type CoreSearchParams, + type SyncSessionInput, +} from '../indexCore.ts'; +import { + SEARCH_WORKER_PROTOCOL_VERSION, + type SearchWorkerData, + type SearchWorkerErrorPayload, + type SearchWorkerEvent, + type SearchWorkerOpenResult, + type SearchWorkerCall, + type SearchWorkerRequest, +} from './protocol.ts'; + +const data = workerData as SearchWorkerData; + +if (typeof data.textBuildWorkerPath === 'string') { + try { + configureTextBuildWorkerRuntime(data.textBuildWorkerPath); + } catch { + } +} + +const port = parentPort; +if (port === null) { + throw new Error('search worker entry must run inside a worker thread'); +} + +const post = (event: SearchWorkerEvent): void => { + port.postMessage(event); +}; + +const core = new SearchIndexCore({ + indexDir: data.dir, + bootSalt: data.bootSalt, + log: { + info: (message, meta) => { + post({ type: 'log', level: 'info', message, meta }); + }, + warn: (message, meta) => { + post({ type: 'log', level: 'warn', message, meta }); + }, + }, + onLockToken: (token) => { + post({ type: 'lockToken', token }); + }, +}); + +function toErrorPayload(error: unknown): SearchWorkerErrorPayload { + if (error instanceof GlobalSearchError) { + return { message: error.message, reason: error.reason as GlobalSearchErrorReason }; + } + return { message: error instanceof Error ? error.message : String(error) }; +} + +async function dispatch(request: SearchWorkerCall): Promise { + switch (request.type) { + case 'open': { + await core.ensureOpen(); + const result: SearchWorkerOpenResult = { + readOnly: core.db?.readOnly === true, + lockToken: core.lockTokenView, + lifecycle: core.lifecycleState(), + }; + return result; + } + case 'search': + return core.search(request.params as CoreSearchParams); + case 'sync': + return core.sync((request.params as { sessions: readonly SyncSessionInput[] }).sessions); + case 'refresh': { + await core.refresh(); + const result: SearchWorkerOpenResult = { + readOnly: core.db?.readOnly === true, + lockToken: core.lockTokenView, + lifecycle: core.lifecycleState(), + }; + return result; + } + case 'reindex': { + await core.reindex(); + const result: SearchWorkerOpenResult = { + readOnly: core.db?.readOnly === true, + lockToken: core.lockTokenView, + lifecycle: core.lifecycleState(), + }; + return result; + } + case 'status': + return core.status(); + case 'close': + return null; + } +} + +const inFlight = new Set>(); +let closing = false; + +async function handle(request: SearchWorkerCall): Promise { + if (request.v !== SEARCH_WORKER_PROTOCOL_VERSION) { + post({ + id: request.id, + type: 'error', + error: { + message: `search worker protocol mismatch: host v${request.v}, worker v${SEARCH_WORKER_PROTOCOL_VERSION}`, + }, + }); + return; + } + if (request.type === 'close') { + closing = true; + core.beginClose(); + await Promise.all(inFlight); + let error: SearchWorkerErrorPayload | null = null; + try { + await core.close(); + } catch (closeError) { + error = toErrorPayload(closeError); + } + if (error !== null) post({ id: request.id, type: 'error', error }); + else post({ id: request.id, type: 'result', result: null }); + port!.close(); + return; + } + if (closing) { + post({ + id: request.id, + type: 'error', + error: { message: 'search service is disposed', reason: 'index_unavailable' }, + }); + return; + } + try { + const result = await dispatch(request); + post({ id: request.id, type: 'result', result }); + } catch (error) { + post({ id: request.id, type: 'error', error: toErrorPayload(error) }); + } +} + +port.on('message', (value: unknown) => { + const request = value as SearchWorkerRequest; + if (request === null || typeof request !== 'object') return; + if (request.type === 'beginClose') { + closing = true; + core.beginClose(); + return; + } + if (typeof request.id !== 'number' || typeof request.type !== 'string') return; + const tracked = handle(request); + inFlight.add(tracked); + void tracked.finally(() => { + inFlight.delete(tracked); + }); +}); + +post({ type: 'ready', v: SEARCH_WORKER_PROTOCOL_VERSION }); diff --git a/packages/kap-server/src/search/worker/host.ts b/packages/kap-server/src/search/worker/host.ts new file mode 100644 index 0000000000000000000000000000000000000000..16f68d6034e50ed101a37eb71705cbc1a7dd3922 --- /dev/null +++ b/packages/kap-server/src/search/worker/host.ts @@ -0,0 +1,610 @@ +import fsSync from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +import { getTextBuildWorkerRuntimeState } from '@moonshot-ai/minidb/worker-runtime'; + +import { GlobalSearchError } from '../contract'; +import type { + CoreIndexView, + CoreLifecycleReport, + CoreSearchParams, + CoreSearchResult, + CoreStatus, + CoreSyncOutcome, + SearchCoreLog, + SyncSessionInput, +} from '../indexCore'; +import { + SEARCH_WORKER_PROTOCOL_VERSION, + type SearchWorkerData, + type SearchWorkerEvent, + type SearchWorkerOpenResult, + type SearchWorkerCallType, + type SearchWorkerResultMap, +} from './protocol'; +import { getSearchWorkerRuntimeState } from './runtime'; + +export type SearchWorkerErrorCode = + | 'runtime-unavailable' + | 'spawn-failed' + | 'crashed' + | 'backoff' + | 'disposed'; + +export class SearchWorkerError extends Error { + constructor( + readonly code: SearchWorkerErrorCode, + message: string, + ) { + super(message); + this.name = 'SearchWorkerError'; + } +} + +export interface SearchWorkerHostOptions { + readonly dir: string; + readonly log: SearchCoreLog; + readonly readyTimeoutMs?: number; + readonly closeTimeoutMs?: number; + readonly requestTimeoutMs?: number; + readonly syncTimeoutMs?: number; + readonly maxOldSpaceMb?: number; + readonly workerFactory?: (entry: { url: URL; data: SearchWorkerData; execArgv: string[] }) => Worker; +} + +interface WorkerEntryResolution { + readonly url: URL; + readonly execArgv: string[]; +} + +type PendingRequest = { + readonly method: SearchWorkerCallType; + readonly resolve: (value: unknown) => void; + readonly reject: (error: unknown) => void; + readonly watchdog?: ReturnType; +}; + +const DEFAULT_READY_TIMEOUT_MS = 15_000; +const DEFAULT_CLOSE_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OLD_SPACE_MB = 1024; +const STABLE_SESSION_MS = 60_000; +const BACKOFF_BASE_MS = 500; +const BACKOFF_CAP_MS = 10_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; +const DEFAULT_SYNC_TIMEOUT_MS = 30 * 60_000; +const ORPHAN_LOCK_GRACE_MS = 250; + +const liveLockTokens = new Set(); + +export function noteLiveLockToken(token: string): void { + liveLockTokens.add(token); +} + +export function dropLiveLockToken(token: string | undefined): void { + if (token !== undefined) liveLockTokens.delete(token); +} + +export class SearchWorkerHost { + private worker: Worker | null = null; + private spawnPromise: Promise | null = null; + private reapPromise: Promise | null = null; + private readonly requests = new Map(); + private nextId = 1; + private lockToken: string | undefined; + private failures = 0; + private nextRetryAfter = 0; + private lastFailure: string | null = null; + private readyAt = 0; + private exiting = false; + private exitResolve: (() => void) | null = null; + private orphanCheckScheduled = false; + private lastCoreLifecycle: CoreLifecycleReport | null = null; + + constructor(private readonly options: SearchWorkerHostOptions) {} + + private get log(): SearchCoreLog { + return this.options.log; + } + + private get dir(): string { + return this.options.dir; + } + + get reportedLockToken(): string | undefined { + return this.lockToken; + } + + lifecycleSnapshot(): CoreLifecycleReport { + if (this.exiting) { + return this.worker !== null || this.spawnPromise !== null + ? { state: 'closing' } + : { state: 'stopped' }; + } + if (this.spawnPromise !== null) return { state: 'opening' }; + if (this.worker === null) { + if (this.lastFailure !== null) { + const wait = this.nextRetryAfter - Date.now(); + return { + state: 'degraded', + detail: + wait > 0 + ? `search worker restart backing off for ${wait}ms (last failure: ${this.lastFailure})` + : `search worker restart pending (last failure: ${this.lastFailure})`, + }; + } + return { state: 'stopped' }; + } + return this.lastCoreLifecycle ?? { state: 'opening' }; + } + + async ensureOpen(): Promise { + return this.call('open'); + } + + async search(params: CoreSearchParams): Promise { + return this.call('search', params); + } + + async sync(sessions: readonly SyncSessionInput[]): Promise { + return this.call('sync', { sessions }); + } + + async refresh(): Promise { + return this.call('refresh'); + } + + async reindex(): Promise { + return this.call('reindex'); + } + + async status(): Promise { + return this.call('status'); + } + + async killWorkerForTest(): Promise { + const worker = this.worker; + if (worker === null) return; + await worker.terminate(); + await this.waitForExit(); + } + + private async call( + type: T, + params?: unknown, + ): Promise { + await this.ensureWorker(); + const worker = this.worker; + if (worker === null) { + throw new SearchWorkerError('crashed', 'search worker is not running'); + } + return this.sendRequest(worker, type, params) as Promise; + } + + private sendRequest( + worker: Worker, + type: SearchWorkerCallType, + params?: unknown, + ): Promise { + const id = this.nextId++; + return new Promise((resolve, reject) => { + let watchdog: ReturnType | undefined; + if (type !== 'close') { + const timeoutMs = + type === 'sync' || type === 'reindex' || type === 'open' + ? this.options.syncTimeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS + : this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + watchdog = setTimeout(() => this.onRequestTimeout(id), timeoutMs); + watchdog.unref?.(); + } + this.requests.set(id, { method: type, resolve, reject, watchdog }); + try { + worker.postMessage({ id, v: SEARCH_WORKER_PROTOCOL_VERSION, type, params }); + } catch (error) { + this.requests.delete(id); + if (watchdog !== undefined) clearTimeout(watchdog); + reject( + new SearchWorkerError( + 'crashed', + `search worker channel broke: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + } + }); + } + + private onRequestTimeout(id: number): void { + const pending = this.requests.get(id); + if (pending === undefined) return; + this.requests.delete(id); + const message = `search worker request '${pending.method}' timed out`; + this.log.warn(`global search: ${message}; terminating the wedged worker`, { dir: this.dir }); + pending.reject(new SearchWorkerError('crashed', message)); + const worker = this.worker; + if (worker !== null) { + void worker.terminate().catch(() => {}); + } + } + + private settleRequest(id: number, error: Error | null, result: unknown): void { + const pending = this.requests.get(id); + if (pending === undefined) return; + this.requests.delete(id); + if (pending.watchdog !== undefined) clearTimeout(pending.watchdog); + if (error !== null) { + pending.reject(error); + return; + } + this.noteResult(result); + pending.resolve(result); + } + + private noteResult(result: unknown): void { + if (result === null || typeof result !== 'object') return; + const direct = (result as { lockToken?: unknown }).lockToken; + const nested = (result as { index?: { lockToken?: unknown } }).index?.lockToken; + const token = typeof direct === 'string' ? direct : typeof nested === 'string' ? nested : undefined; + if (token !== undefined) this.lockToken = token; + const readOnly = + (result as { readOnly?: unknown }).readOnly === true || + (result as { index?: { readOnly?: unknown } }).index?.readOnly === true; + if (readOnly) this.scheduleOrphanCheck(); + const lifecycle = (result as { lifecycle?: CoreLifecycleReport }).lifecycle; + if (lifecycle !== undefined) { + this.lastCoreLifecycle = lifecycle; + return; + } + const index = (result as { index?: CoreIndexView }).index; + if (index !== undefined) { + this.lastCoreLifecycle = { state: index.state === 'building' ? 'building' : 'ready' }; + } + } + + private ensureWorker(): Promise { + if (this.worker !== null) return Promise.resolve(); + if (this.exiting) { + return Promise.reject(new SearchWorkerError('disposed', 'search worker is disposed')); + } + if (this.spawnPromise !== null) return this.spawnPromise; + const wait = this.nextRetryAfter - Date.now(); + if (wait > 0) { + return Promise.reject( + new SearchWorkerError( + 'backoff', + `search worker restart backing off for ${wait}ms (last failure: ${this.lastFailure ?? 'unknown'})`, + ), + ); + } + this.spawnPromise = this.doSpawn().finally(() => { + this.spawnPromise = null; + }); + return this.spawnPromise; + } + + private resolveEntry(): WorkerEntryResolution { + const configured = getSearchWorkerRuntimeState(); + if (configured.configured) { + return { url: pathToFileURL(configured.path), execArgv: [] }; + } + const source = new URL('./entry.ts', import.meta.url); + try { + if (fsSync.statSync(source).isFile()) { + return { + url: source, + execArgv: [ + '--experimental-transform-types', + '--disable-warning=ExperimentalWarning', + '--import', + new URL('./register-dev-hooks.mjs', import.meta.url).href, + ], + }; + } + } catch { + } + const bundled = new URL('./search-worker.mjs', import.meta.url); + try { + if (fsSync.statSync(bundled).isFile()) { + return { url: bundled, execArgv: [] }; + } + } catch { + } + throw new SearchWorkerError( + 'runtime-unavailable', + 'search worker entry not found (no configured packaged asset, no sibling source, no bundled sibling)', + ); + } + + private async doSpawn(): Promise { + await this.reapPromise; + const entry = this.resolveEntry(); + const textBuild = getTextBuildWorkerRuntimeState(); + const data: SearchWorkerData = { + dir: this.dir, + bootSalt: randomUUID(), + textBuildWorkerPath: + textBuild.configured && textBuild.entry.kind === 'packaged' ? textBuild.entry.path : undefined, + }; + let worker: Worker; + try { + worker = + this.options.workerFactory?.({ + url: entry.url, + data, + execArgv: entry.execArgv, + }) ?? + new Worker(entry.url, { + workerData: data, + execArgv: entry.execArgv, + resourceLimits: { maxOldGenerationSizeMb: this.options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB }, + name: 'kimi-search-worker', + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.noteFailure(`spawn failed: ${message}`); + throw new SearchWorkerError('spawn-failed', `search worker failed to start: ${message}`); + } + worker.unref(); + this.attach(worker); + try { + await this.awaitReady(worker); + } catch (error) { + this.detach(); + await worker.terminate().catch(() => {}); + const message = error instanceof Error ? error.message : String(error); + this.noteFailure(`handshake failed: ${message}`); + throw new SearchWorkerError('spawn-failed', `search worker handshake failed: ${message}`); + } + this.readyAt = Date.now(); + this.worker = worker; + this.lastFailure = null; + this.lastCoreLifecycle = null; + if (this.exiting) { + try { + worker.postMessage({ v: SEARCH_WORKER_PROTOCOL_VERSION, type: 'beginClose' }); + } catch { + } + } + this.log.info('global search: worker started', { dir: this.dir }); + } + + private attach(worker: Worker): void { + worker.on('message', (event: SearchWorkerEvent) => this.onMessage(worker, event)); + worker.on('error', (error: Error) => this.onError(worker, error)); + worker.on('exit', (code: number) => this.onExit(worker, code)); + } + + private detach(): void { + this.worker = null; + this.exitResolve?.(); + this.exitResolve = null; + } + + private onMessage(worker: Worker, event: SearchWorkerEvent): void { + if (this.worker !== worker) return; + if (event === null || typeof event !== 'object') return; + if (event.type === 'log') { + this.log[event.level](event.message, event.meta); + return; + } + if (event.type === 'lockToken') { + this.lockToken = event.token; + liveLockTokens.add(event.token); + return; + } + if (event.type === 'ready') return; + if (event.type === 'result') { + this.settleRequest(event.id, null, event.result); + return; + } + if (event.type === 'error') { + const payload = event.error; + const error = + payload.reason !== undefined + ? new GlobalSearchError(payload.reason, payload.message) + : new Error(payload.message); + this.settleRequest(event.id, error, undefined); + } + } + + private onError(worker: Worker, error: Error): void { + if (this.worker !== worker) return; + this.lastFailure = `worker error: ${error.message}`; + } + + private onExit(worker: Worker, code: number): void { + if (this.worker !== worker) return; + const wasExiting = this.exiting; + const sessionMs = Date.now() - this.readyAt; + const deadToken = this.lockToken; + this.detach(); + this.lockToken = undefined; + if (deadToken !== undefined) liveLockTokens.delete(deadToken); + if (this.requests.size > 0) { + const error = wasExiting + ? new SearchWorkerError('disposed', 'search worker is disposed') + : new SearchWorkerError( + 'crashed', + `search worker exited with in-flight requests (code ${code}${this.lastFailure !== null ? `; ${this.lastFailure}` : ''})`, + ); + for (const pending of this.requests.values()) { + if (pending.watchdog !== undefined) clearTimeout(pending.watchdog); + pending.reject(error); + } + this.requests.clear(); + } + if (wasExiting) return; + this.lastFailure ??= `worker exited with code ${code}`; + this.reapPromise = this.reapLockFile(deadToken).finally(() => { + this.reapPromise = null; + }); + this.failures = sessionMs > STABLE_SESSION_MS ? Math.max(1, this.failures - 1) : this.failures + 1; + const backoff = Math.min(BACKOFF_BASE_MS * 2 ** (this.failures - 1), BACKOFF_CAP_MS); + this.nextRetryAfter = Date.now() + backoff; + this.log.warn('global search: worker exited unexpectedly; restart backed off', { + code, + backoffMs: backoff, + lastFailure: this.lastFailure ?? undefined, + }); + } + + private noteFailure(message: string): void { + this.failures += 1; + const backoff = Math.min(BACKOFF_BASE_MS * 2 ** (this.failures - 1), BACKOFF_CAP_MS); + this.nextRetryAfter = Date.now() + backoff; + this.lastFailure = message; + } + + private async reapLockFile(token: string | undefined): Promise { + if (token === undefined) return; + const lockPath = join(this.dir, 'db.lock'); + try { + const raw = await readFile(lockPath, 'utf8'); + const parsed = JSON.parse(raw) as { pid?: unknown; token?: unknown }; + if (parsed.pid === process.pid && parsed.token === token) { + await rm(lockPath, { force: true }); + this.log.warn('global search: reaped the lock left by the dead worker', { dir: this.dir }); + } + } catch { + } + } + + private awaitReady(worker: Worker): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`ready handshake timed out after ${this.options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms`)); + }, this.options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS); + timer.unref?.(); + const onMessage = (event: SearchWorkerEvent): void => { + if (event?.type !== 'ready') return; + cleanup(); + if (event.v !== SEARCH_WORKER_PROTOCOL_VERSION) { + reject( + new Error( + `protocol mismatch: host v${SEARCH_WORKER_PROTOCOL_VERSION}, worker v${event.v ?? 'unknown'}`, + ), + ); + return; + } + resolve(); + }; + const onError = (error: Error): void => { + cleanup(); + reject(error); + }; + const onExit = (code: number): void => { + cleanup(); + reject(new Error(`worker exited before ready (code ${code})`)); + }; + const cleanup = (): void => { + clearTimeout(timer); + worker.off('message', onMessage); + worker.off('error', onError); + worker.off('exit', onExit); + }; + worker.on('message', onMessage); + worker.on('error', onError); + worker.on('exit', onExit); + }); + } + + private waitForExit(): Promise { + if (this.worker === null) return Promise.resolve(); + return new Promise((resolve) => { + const previous = this.exitResolve; + this.exitResolve = () => { + previous?.(); + resolve(); + }; + }); + } + + beginClose(): void { + this.exiting = true; + try { + this.worker?.postMessage({ v: SEARCH_WORKER_PROTOCOL_VERSION, type: 'beginClose' }); + } catch { + } + } + + private scheduleOrphanCheck(): void { + if (this.orphanCheckScheduled || this.exiting) return; + this.orphanCheckScheduled = true; + const timer = setTimeout(() => { + this.orphanCheckScheduled = false; + void this.recoverOrphanedLock(); + }, ORPHAN_LOCK_GRACE_MS); + timer.unref?.(); + } + + private async recoverOrphanedLock(): Promise { + if (this.exiting || this.worker === null) return; + const lockPath = join(this.dir, 'db.lock'); + let token: string; + try { + const parsed = JSON.parse(await readFile(lockPath, 'utf8')) as { + pid?: unknown; + token?: unknown; + }; + if (parsed.pid !== process.pid || typeof parsed.token !== 'string') return; + token = parsed.token; + } catch { + return; + } + if (liveLockTokens.has(token)) return; + try { + const again = JSON.parse(await readFile(lockPath, 'utf8')) as { + pid?: unknown; + token?: unknown; + }; + if (again.pid !== process.pid || again.token !== token) return; + if (liveLockTokens.has(token)) return; + } catch { + return; + } + this.log.warn('global search: reaping an orphaned same-pid lock and restarting the worker', { + dir: this.dir, + }); + await rm(lockPath, { force: true }).catch(() => {}); + const worker = this.worker; + if (worker === null) return; + await worker.terminate().catch(() => {}); + } + + dispose(): Promise { + this.exiting = true; + this.disposePromise ??= this.doDispose(); + return this.disposePromise; + } + + private disposePromise: Promise | null = null; + + private async doDispose(): Promise { + await this.spawnPromise?.catch(() => {}); + const worker = this.worker; + if (worker === null) return; + const closeTimeoutMs = this.options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS; + let timeout: ReturnType | null = null; + try { + await Promise.race([ + this.sendRequest(worker, 'close'), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject(new Error(`search worker close timed out after ${closeTimeoutMs}ms`)); + }, closeTimeoutMs); + timeout.unref?.(); + }), + ]); + } catch { + } finally { + if (timeout !== null) clearTimeout(timeout); + } + if (this.worker !== null) { + await worker.terminate().catch(() => {}); + await this.waitForExit(); + } + } +} diff --git a/packages/kap-server/src/search/worker/protocol.ts b/packages/kap-server/src/search/worker/protocol.ts new file mode 100644 index 0000000000000000000000000000000000000000..5989697246df3cc0240ba22eefcb5c68b053d5e1 --- /dev/null +++ b/packages/kap-server/src/search/worker/protocol.ts @@ -0,0 +1,71 @@ +import type { GlobalSearchErrorReason } from '../contract.ts'; +import type { + CoreLifecycleReport, + CoreSearchParams, + CoreSearchResult, + CoreStatus, + CoreSyncOutcome, + SyncSessionInput, +} from '../indexCore.ts'; + +export const SEARCH_WORKER_PROTOCOL_VERSION = 1; + +export interface SearchWorkerData { + readonly dir: string; + readonly bootSalt: string; + readonly textBuildWorkerPath?: string; +} + +export type SearchWorkerCall = + | { readonly id: number; readonly v: number; readonly type: 'open' } + | { readonly id: number; readonly v: number; readonly type: 'search'; readonly params: CoreSearchParams } + | { readonly id: number; readonly v: number; readonly type: 'sync'; readonly params: { readonly sessions: readonly SyncSessionInput[] } } + | { readonly id: number; readonly v: number; readonly type: 'refresh' } + | { readonly id: number; readonly v: number; readonly type: 'reindex' } + | { readonly id: number; readonly v: number; readonly type: 'status' } + | { readonly id: number; readonly v: number; readonly type: 'close' }; + +export type SearchWorkerCallType = SearchWorkerCall['type']; + +export interface SearchWorkerControlMessage { + readonly v: number; + readonly type: 'beginClose'; +} + +export type SearchWorkerRequest = SearchWorkerCall | SearchWorkerControlMessage; + +export interface SearchWorkerOpenResult { + readonly readOnly: boolean; + readonly lockToken?: string; + readonly lifecycle: CoreLifecycleReport; +} + +export interface SearchWorkerResultMap { + readonly open: SearchWorkerOpenResult; + readonly search: CoreSearchResult; + readonly sync: CoreSyncOutcome; + readonly refresh: SearchWorkerOpenResult; + readonly reindex: SearchWorkerOpenResult; + readonly status: CoreStatus; + readonly close: null; +} + +export interface SearchWorkerErrorPayload { + readonly message: string; + readonly reason?: GlobalSearchErrorReason; +} + +export type SearchWorkerEvent = + | { readonly type: 'ready'; readonly v: number } + | { + readonly type: 'log'; + readonly level: 'info' | 'warn'; + readonly message: string; + readonly meta?: Record; + } + | { + readonly type: 'lockToken'; + readonly token: string; + } + | { readonly id: number; readonly type: 'result'; readonly result: unknown } + | { readonly id: number; readonly type: 'error'; readonly error: SearchWorkerErrorPayload }; diff --git a/packages/kap-server/src/search/worker/register-dev-hooks.mjs b/packages/kap-server/src/search/worker/register-dev-hooks.mjs new file mode 100644 index 0000000000000000000000000000000000000000..4ed69ed7bfc27134f223a478a54aa1d123b304ff --- /dev/null +++ b/packages/kap-server/src/search/worker/register-dev-hooks.mjs @@ -0,0 +1,3 @@ +import { register } from 'node:module'; + +register('./dev-hooks.mjs', import.meta.url); diff --git a/packages/kap-server/src/search/worker/runtime.ts b/packages/kap-server/src/search/worker/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c84fcdf2aa92a9f4715a5f19c54f6fe9bec3182 --- /dev/null +++ b/packages/kap-server/src/search/worker/runtime.ts @@ -0,0 +1,44 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type SearchWorkerRuntimeState = + | { readonly configured: false } + | { readonly configured: true; readonly path: string }; + +let configuredPath: string | null = null; + +export function configureSearchWorkerRuntime(entry: string): SearchWorkerRuntimeState { + if (!path.isAbsolute(entry)) { + throw new TypeError('search worker entry must be an absolute path'); + } + let stat: fs.Stats; + try { + stat = fs.statSync(entry); + } catch (error) { + throw new TypeError( + `search worker entry is not readable: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + if (!stat.isFile()) { + throw new TypeError('search worker entry must be a regular file'); + } + if (configuredPath !== null) { + if (configuredPath !== entry) { + throw new Error('search worker runtime is already configured'); + } + return { configured: true, path: configuredPath }; + } + configuredPath = entry; + return { configured: true, path: configuredPath }; +} + +export function resetSearchWorkerRuntime(): void { + configuredPath = null; +} + +export function getSearchWorkerRuntimeState(): SearchWorkerRuntimeState { + return configuredPath === null + ? { configured: false } + : { configured: true, path: configuredPath }; +} diff --git a/packages/kap-server/src/security/bindClassify.ts b/packages/kap-server/src/security/bindClassify.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2c1e37aaa38e78172d66632f2887fa41a0dc918 --- /dev/null +++ b/packages/kap-server/src/security/bindClassify.ts @@ -0,0 +1,70 @@ +import net from 'node:net'; + +export type BindClass = 'loopback' | 'lan' | 'public'; + +export interface ClassifyOptions { + readonly bindClass?: 'lan' | 'public'; +} + +function ipv4ToInt(ip: string): number { + const [a, b, c, d] = ip.split('.'); + return ( + (((Number(a) << 24) >>> 0) + + ((Number(b) << 16) >>> 0) + + ((Number(c) << 8) >>> 0) + + (Number(d) >>> 0)) >>> + 0 + ); +} + +function ipv4InCidr(ip: string, base: string, prefix: number): boolean { + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return ((ipv4ToInt(ip) & mask) >>> 0) === ((ipv4ToInt(base) & mask) >>> 0); +} + +function expandV6(host: string): readonly string[] | null { + const lower = host.toLowerCase(); + if (lower.includes('::')) { + const halves = lower.split('::'); + const leftRaw = halves[0] ?? ''; + const rightRaw = halves[1] ?? ''; + const left = leftRaw.length > 0 ? leftRaw.split(':') : []; + const right = rightRaw.length > 0 ? rightRaw.split(':') : []; + const missing = 8 - (left.length + right.length); + if (missing < 0) return null; + return [...left, ...Array(missing).fill('0'), ...right]; + } + const parts = lower.split(':'); + return parts.length === 8 ? parts : null; +} + +function isLinkLocalV6(host: string): boolean { + const groups = expandV6(host); + if (groups === null) return false; + const first = Number.parseInt(groups[0] ?? '', 16); + return first >= 0xfe80 && first <= 0xfebf; +} + +export function classify(host: string, opts?: ClassifyOptions): BindClass { + if (host === '' || host === '0.0.0.0' || host === '::') { + return opts?.bindClass ?? 'public'; + } + if (host === 'localhost') { + return 'loopback'; + } + const family = net.isIP(host); + if (family === 4) { + if (host.startsWith('127.')) return 'loopback'; + if (ipv4InCidr(host, '10.0.0.0', 8)) return 'lan'; + if (ipv4InCidr(host, '172.16.0.0', 12)) return 'lan'; + if (ipv4InCidr(host, '192.168.0.0', 16)) return 'lan'; + if (ipv4InCidr(host, '169.254.0.0', 16)) return 'lan'; + return 'public'; + } + if (family === 6) { + if (host.toLowerCase() === '::1') return 'loopback'; + if (isLinkLocalV6(host)) return 'lan'; + return 'public'; + } + return 'public'; +} diff --git a/packages/kap-server/src/services/auth/authTokenService.ts b/packages/kap-server/src/services/auth/authTokenService.ts new file mode 100644 index 0000000000000000000000000000000000000000..46d082cecb0a76376778de04d37816765f7916bc --- /dev/null +++ b/packages/kap-server/src/services/auth/authTokenService.ts @@ -0,0 +1,28 @@ +import { createDecorator } from '@moonshot-ai/agent-core-v2'; + +import { verifyPassword } from './password'; +import type { TokenStore } from './tokenStore'; + +export interface IAuthTokenService { + readonly _serviceBrand: undefined; + + getToken(): string; + + isValid(candidate: string): Promise; +} + +export const IAuthTokenService = + createDecorator('authTokenService'); + +export function createAuthTokenService(deps: { + readonly tokenStore: TokenStore; + readonly passwordHash: string | undefined; +}): IAuthTokenService { + return { + _serviceBrand: undefined, + getToken: () => deps.tokenStore.getToken(), + isValid: async (candidate) => + deps.tokenStore.isValid(candidate) || + (await verifyPassword(candidate, deps.passwordHash)), + }; +} diff --git a/packages/kap-server/src/services/auth/credentials.ts b/packages/kap-server/src/services/auth/credentials.ts new file mode 100644 index 0000000000000000000000000000000000000000..97a372c9640d3d75d7adb69477ebc22e12db71cd --- /dev/null +++ b/packages/kap-server/src/services/auth/credentials.ts @@ -0,0 +1,24 @@ +import { timingSafeEqual } from 'node:crypto'; + +import type { IAuthTokenService } from './authTokenService'; + +export type CredentialValidator = (candidate: string) => Promise; + +function timingSafeMatch(candidate: string, expected: string): boolean { + const a = Buffer.from(candidate); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +} + +export function createCredentialValidator( + authTokenService: IAuthTokenService, + rpcToken?: string, +): CredentialValidator { + return async (candidate) => { + if (await authTokenService.isValid(candidate)) return true; + if (rpcToken !== undefined && candidate.length > 0 && timingSafeMatch(candidate, rpcToken)) { + return true; + } + return false; + }; +} diff --git a/packages/kap-server/src/services/auth/password.ts b/packages/kap-server/src/services/auth/password.ts new file mode 100644 index 0000000000000000000000000000000000000000..14abc3641c7cb8647db2df1d0e2d967673732b30 --- /dev/null +++ b/packages/kap-server/src/services/auth/password.ts @@ -0,0 +1,25 @@ +import bcrypt from 'bcryptjs'; + +const { compare, hash } = bcrypt; + +const BCRYPT_COST = 12; + +export async function resolvePasswordHash( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const plaintext = env['KIMI_CODE_PASSWORD']; + if (!plaintext) { + return undefined; + } + return hash(plaintext, BCRYPT_COST); +} + +export async function verifyPassword( + candidate: string, + passwordHash: string | undefined, +): Promise { + if (passwordHash === undefined) { + return false; + } + return compare(candidate, passwordHash); +} diff --git a/packages/kap-server/src/services/auth/persistentToken.ts b/packages/kap-server/src/services/auth/persistentToken.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea0c7c869a1a315dfdaac675854b0a53aa04ecfe --- /dev/null +++ b/packages/kap-server/src/services/auth/persistentToken.ts @@ -0,0 +1,46 @@ +import { randomBytes } from 'node:crypto'; +import { join } from 'node:path'; + +import { readPrivateFile, writePrivateFile } from './privateFiles'; + +export const SERVER_TOKEN_FILE = 'server.token'; + +export function serverTokenPath(homeDir: string): string { + return join(homeDir, SERVER_TOKEN_FILE); +} + +export function generateServerToken(): string { + return randomBytes(32).toString('base64url'); +} + +export async function writeServerToken(homeDir: string, token: string): Promise { + await writePrivateFile(serverTokenPath(homeDir), token); +} + +export async function readServerToken(homeDir: string): Promise { + try { + const buf = await readPrivateFile(serverTokenPath(homeDir)); + return buf.toString('utf8').trim(); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw err; + } +} + +export async function loadOrCreateServerToken(homeDir: string): Promise { + const existing = await readServerToken(homeDir); + if (existing !== undefined && existing.length > 0) { + return existing; + } + const token = generateServerToken(); + await writeServerToken(homeDir, token); + return token; +} + +export async function rotateServerToken(homeDir: string): Promise { + const token = generateServerToken(); + await writeServerToken(homeDir, token); + return token; +} diff --git a/packages/kap-server/src/services/auth/privateFiles.ts b/packages/kap-server/src/services/auth/privateFiles.ts new file mode 100644 index 0000000000000000000000000000000000000000..701c08b3fa9ab08c83c5e8b4fca5a1417209f969 --- /dev/null +++ b/packages/kap-server/src/services/auth/privateFiles.ts @@ -0,0 +1,61 @@ +import { randomBytes } from 'node:crypto'; +import { + chmod, + mkdir, + open, + readFile, + rename, + rm, + stat, +} from 'node:fs/promises'; +import { dirname } from 'node:path'; + +export class PrivateFileTooPermissiveError extends Error { + readonly code = 'EPRIVATE_FILE_TOO_PERMISSIVE'; + + constructor( + readonly filePath: string, + readonly mode: number, + ) { + super( + `private file ${filePath} is too permissive (mode ${mode.toString(8).padStart(3, '0')}); expected 0600`, + ); + this.name = 'PrivateFileTooPermissiveError'; + } +} + +export async function writePrivateFile( + filePath: string, + data: string | Buffer, +): Promise { + const dir = dirname(filePath); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await chmod(dir, 0o700); + + const tmp = `${filePath}.tmp.${randomBytes(8).toString('hex')}`; + + let handle: Awaited> | undefined; + try { + handle = await open(tmp, 'w', 0o600); + await handle.chmod(0o600); + await handle.writeFile(data); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(tmp, filePath); + } catch (err) { + if (handle) { + await handle.close().catch(() => {}); + } + await rm(tmp, { force: true }).catch(() => {}); + throw err; + } +} + +export async function readPrivateFile(filePath: string): Promise { + const info = await stat(filePath); + if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) { + throw new PrivateFileTooPermissiveError(filePath, info.mode & 0o777); + } + return readFile(filePath); +} diff --git a/packages/kap-server/src/services/auth/tokenStore.ts b/packages/kap-server/src/services/auth/tokenStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..2967a0661557a68eef405d7609ac425ed1337ff7 --- /dev/null +++ b/packages/kap-server/src/services/auth/tokenStore.ts @@ -0,0 +1,60 @@ +import { timingSafeEqual } from 'node:crypto'; +import { readFileSync, statSync } from 'node:fs'; + +import { loadOrCreateServerToken, serverTokenPath } from './persistentToken'; + +export interface TokenStore { + readonly tokenPath: string; + getToken(): string; + isValid(candidate: string): boolean; + dispose(): Promise; +} + +export async function createTokenStore(homeDir: string): Promise { + const tokenPath = serverTokenPath(homeDir); + const initial = await loadOrCreateServerToken(homeDir); + const initialStat = statSync(tokenPath); + let cache: { token: string; mtimeMs: number; ino: number } = { + token: initial, + mtimeMs: initialStat.mtimeMs, + ino: initialStat.ino, + }; + + const currentToken = (): string => { + let st: ReturnType; + try { + st = statSync(tokenPath); + } catch { + return cache.token; + } + if (st.mtimeMs === cache.mtimeMs && st.ino === cache.ino) { + return cache.token; + } + if (process.platform !== 'win32' && (st.mode & 0o077) !== 0) { + return cache.token; + } + try { + const token = readFileSync(tokenPath, 'utf8').trim(); + if (token.length > 0) { + cache = { token, mtimeMs: st.mtimeMs, ino: st.ino }; + } + } catch { + } + return cache.token; + }; + + return { + tokenPath, + getToken: currentToken, + isValid(candidate: string): boolean { + const tokenBuf = Buffer.from(currentToken()); + const candidateBuf = Buffer.from(candidate); + if (candidateBuf.length !== tokenBuf.length) { + return false; + } + return timingSafeEqual(candidateBuf, tokenBuf); + }, + async dispose(): Promise { + }, + }; +} diff --git a/packages/kap-server/src/services/config/configChangedPublisher.ts b/packages/kap-server/src/services/config/configChangedPublisher.ts new file mode 100644 index 0000000000000000000000000000000000000000..9899c6fe786e0f137f56352ca7c11a7f8aa6a8db --- /dev/null +++ b/packages/kap-server/src/services/config/configChangedPublisher.ts @@ -0,0 +1,48 @@ +import { ConfigChanged, IConfigService, IEventService, type Scope } from '@moonshot-ai/agent-core-v2'; + +import { toConfigResponse } from '../../routes/config'; + +export interface ConfigChangedPublisher { + close(): void; +} + +const FLUSH_DELAY_MS = 10; + +export function startConfigChangedPublisher(core: Scope): ConfigChangedPublisher { + const config = core.accessor.get(IConfigService); + const events = core.accessor.get(IEventService); + let closed = false; + let timer: ReturnType | undefined; + const pending = new Set(); + + const flush = (): void => { + timer = undefined; + if (closed || pending.size === 0) return; + const changedFields = [...pending].toSorted(); + pending.clear(); + events.publish( + new ConfigChanged({ + payload: { changedFields, config: toConfigResponse(config.getAll()) }, + }), + ); + }; + + const subscription = config.onDidSectionChange((event) => { + if (closed) return; + pending.add(event.domain); + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(flush, FLUSH_DELAY_MS); + }); + + return { + close: () => { + closed = true; + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + pending.clear(); + subscription.dispose(); + }, + }; +} diff --git a/packages/kap-server/src/services/guiStore/guiStore.ts b/packages/kap-server/src/services/guiStore/guiStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a6f0a51f46b2f291f2f9e6193e83a0a4223a25b --- /dev/null +++ b/packages/kap-server/src/services/guiStore/guiStore.ts @@ -0,0 +1,12 @@ +import { createDecorator } from '@moonshot-ai/agent-core-v2'; + +export interface IGuiStoreService { + readonly _serviceBrand: undefined; + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; + clear(): Promise; + length(): Promise; +} + +export const IGuiStoreService = createDecorator('guiStoreService'); diff --git a/packages/kap-server/src/services/guiStore/guiStoreService.ts b/packages/kap-server/src/services/guiStore/guiStoreService.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0964402cf24992ea3f5ba5700aba3d10e4e74b3 --- /dev/null +++ b/packages/kap-server/src/services/guiStore/guiStoreService.ts @@ -0,0 +1,106 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; + +import { IGuiStoreService } from './guiStore'; + +export interface GuiStoreLogger { + warn(obj: unknown, msg: string): void; +} + +const noopLogger: GuiStoreLogger = { warn: () => {} }; + +function emptyStore(): Record { + return Object.create(null) as Record; +} + +export class GuiStoreService implements IGuiStoreService { + readonly _serviceBrand: undefined; + + private readonly filePath: string; + private readonly logger: GuiStoreLogger; + private queue: Promise = Promise.resolve(); + + constructor(homeDir: string, logger?: GuiStoreLogger) { + this.filePath = join(homeDir, 'gui.toml'); + this.logger = logger ?? noopLogger; + } + + async getItem(key: string): Promise { + const all = await this.readAll(); + if (!Object.prototype.hasOwnProperty.call(all, key)) return null; + return all[key] ?? null; + } + + async setItem(key: string, value: string): Promise { + await this.withLock(async () => { + const all = await this.readAll(); + all[key] = value; + await this.writeAll(all); + }); + } + + async removeItem(key: string): Promise { + await this.withLock(async () => { + const all = await this.readAll(); + if (Object.prototype.hasOwnProperty.call(all, key)) { + delete all[key]; + await this.writeAll(all); + } + }); + } + + async clear(): Promise { + await this.withLock(() => this.writeAll(emptyStore())); + } + + async length(): Promise { + const all = await this.readAll(); + return Object.keys(all).length; + } + + private withLock(fn: () => Promise): Promise { + const run = this.queue.then(fn); + this.queue = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private async readAll(): Promise> { + let text: string; + try { + text = await readFile(this.filePath, 'utf-8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyStore(); + throw error; + } + if (text.trim().length === 0) return emptyStore(); + try { + const parsed = parseToml(text) as Record; + const out = emptyStore(); + for (const [k, v] of Object.entries(parsed)) { + if (typeof v === 'string') out[k] = v; + } + return out; + } catch (error) { + this.logger.warn( + { filePath: this.filePath, err: error }, + 'gui.toml parse failed; using an empty store', + ); + return emptyStore(); + } + } + + private async writeAll(obj: Record): Promise { + await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 }); + const plain: Record = { ...obj }; + const text = Object.keys(plain).length === 0 ? '' : stringifyToml(plain); + const tmp = `${this.filePath}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`; + await writeFile(tmp, text, { encoding: 'utf-8', mode: 0o600 }); + await rename(tmp, this.filePath); + } +} diff --git a/packages/kap-server/src/services/history/coldFold.ts b/packages/kap-server/src/services/history/coldFold.ts new file mode 100644 index 0000000000000000000000000000000000000000..b069d7e85e8b6304e3d807836deb3c61df42dec9 --- /dev/null +++ b/packages/kap-server/src/services/history/coldFold.ts @@ -0,0 +1,1771 @@ +import { + daemonFileRefFromPart, + parseDaemonFileUrl, + type ContentPart, + type TokenUsage, +} from '@moonshot-ai/agent-core-v2'; + +import type { + ContentPart as WireContentPart, + HistoryMessage, + InteractionMessage, + StepTiming, + StepUsage, + SystemMessage, + TaskMessage, + TaskNotificationPayload, + TurnOrigin, + UserMessageOrigin, +} from '../../protocol/messages'; +import { + mapInteractionEndStatus, + notificationTextOf, + parseToolArgs, + promptTextOf, + skillActivationsOf, + taskNotificationOriginOf, + taskUserOriginOf, + textPartsOf, + todoWriteItems, + toTurnOrigin, + userOriginOf, + wantsUserMessage, + wireContentParts, + wireInteractionRequest, + wireInteractionResponse, +} from '../projection/agentProjector'; +import type { ContextRecord } from '../projection/heal'; +import { + SystemIdAllocator, + TODO_ENTITY_ID, + attachmentIdOf, + isUndoAnchorOrigin, + isVisibleTurnOrigin, + stepIdOf, + textMessageIdOf, + turnIdOf, + turnOrdinalOf, + turnUserMessageIdOf, + type DurableSystemSubtype, +} from '../projection/ids'; + +export interface ColdFoldOptions { + readonly sessionId: string; + readonly agentId: string; + readonly live: boolean; + readonly fallbackTimestamp: number; + readonly subagentTaskIds?: ReadonlyMap; + readonly resolvePlanRevisionKey?: (key: string) => string; +} + +interface TurnDraft { + readonly turnId: string; + readonly rawId: number; + readonly origin: TurnOrigin; + status: 'running' | 'completed'; + userMessageId?: string; + attachmentIds?: string[]; + startedAt?: string; + endedAt?: string; + durationMs?: number; + at: number; +} + +interface StepDraft { + readonly stepId: string; + readonly turnId: string; + readonly ordinal: number; + status: 'running' | 'completed' | 'interrupted' | 'failed'; + startedAt?: string; + endedAt?: string; + usage?: StepUsage; + finishReason?: string; + timing?: StepTiming; + retry?: { + failed_attempt: number; + next_attempt: number; + max_attempts: number; + delay_ms: number; + error_name: string; + error_message: string; + status_code?: number; + }; + endReason?: string; + endMessage?: string; + at: number; +} + +interface TextDraft { + readonly messageId: string; + readonly kind: 'assistant' | 'thinking'; + readonly turnId: string; + readonly stepId: string; + text: string; + at: number; +} + +interface ToolDraft { + readonly toolCallId: string; + readonly turnId: string; + readonly stepId: string; + name: string; + status: 'running' | 'done' | 'error'; + input?: unknown; + inputText?: string; + output?: unknown; + error?: string; + taskId?: string; + approvalId?: string; + todoId?: string; + agentRefs: { agent_id: string; role?: 'child' | 'member' }[]; + at: number; +} + +interface UserDraft { + readonly messageId: string; + readonly turnId?: string; + readonly text: WireContentPart[]; + readonly timestamp?: number; + origin?: UserMessageOrigin; + attachmentIds?: string[]; + skillActivations?: { skill_name: string; skill_args?: string }[]; +} + +interface SystemDraft { + readonly systemId: string; + readonly subtype: SystemMessage['subtype']; + readonly payload: unknown; + readonly at?: string; + readonly atMs: number; +} + +interface InteractionDraft { + readonly interactionId: string; + readonly kind: 'approval' | 'question'; + status: InteractionMessage['status']; + toolCallId?: string; + request?: unknown; + response?: unknown; + at: number; +} + +interface TaskDraft { + readonly taskId: string; + readonly kind: TaskMessage['kind']; + status: TaskMessage['status']; + detached: boolean; + description?: string; + childAgentId?: string; + outputTail: string; + startedAt?: string; + endedAt?: string; + resultSummary?: string; + error?: string; + stateReason?: string; + usage?: StepUsage; + model?: string; + thinkingEffort?: string; + at: number; +} + +interface SteerInput { + readonly input: readonly ContentPart[]; + readonly origin: UserMessageOrigin | undefined; + readonly skillActivations: { skill_name: string; skill_args?: string }[] | undefined; + readonly skipBlocks: number; + readonly at: number; + readonly messageId?: string; + readonly text?: string; +} + +interface TurnScratch { + currentStep?: number; + serverUserSeq: number; + attachmentSeq: number; + openingInputKey?: string; + openingSteerDeduped: boolean; +} + +interface GoalState { + objective: string; + status: 'active' | 'paused' | 'blocked' | 'complete'; + completionCriterion?: string; + budgetUsed?: number; + budgetLimit?: number; +} + +const TASK_STATUSES = new Set([ + 'running', + 'completed', + 'failed', + 'timed_out', + 'killed', + 'lost', +]); + +const GOAL_STATUSES = new Set(['active', 'paused', 'blocked', 'complete']); + +export function foldWireHistory( + records: readonly ContextRecord[], + options: ColdFoldOptions, +): HistoryMessage[] { + const turns = new Map(); + const steps = new Map(); + const texts = new Map(); + const stepTextIds = new Map(); + const stepTextSeqs = new Map(); + const tools = new Map(); + const users = new Map(); + const systems = new Map(); + const interactions = new Map(); + const tasks = new Map(); + const order: string[] = []; + const timelineIds: string[] = []; + const sysIds = new SystemIdAllocator(); + + const stepRefs = new Map(); + const scratchByTurn = new Map(); + let currentTurn: number | undefined; + + let nextTurnId = 0; + let phantomUserSeq = 0; + const cancelledTurnIds = new Set(); + const hiddenTurnIds = new Set(); + const turnPromptIds = new Map(); + const pendingAnchorTurnIds: number[] = []; + const undoAnchors: { rawId: number }[] = []; + let undoAnchorFloor = 0; + const activeCancelTurnIds = new Set(); + + const queuedPrompts = new Map(); + const mergedSteers: { text: string; promptIds: string[] }[] = []; + + const subagentTaskIds = new Map(options.subagentTaskIds ?? []); + const agentTaskLinks: { taskId: string; agentId: string; parentToolCallId?: string }[] = []; + for (const record of records) { + if (record.type !== 'task.started' && record.type !== 'task.terminated') continue; + const info = record['info'] as { kind?: unknown; agentId?: unknown; taskId?: unknown; parentToolCallId?: unknown } | undefined; + if (info?.kind !== 'agent') continue; + if (typeof info.agentId !== 'string' || typeof info.taskId !== 'string') continue; + subagentTaskIds.set(info.agentId, info.taskId); + agentTaskLinks.push({ + taskId: info.taskId, + agentId: info.agentId, + parentToolCallId: typeof info.parentToolCallId === 'string' ? info.parentToolCallId : undefined, + }); + } + + let goal: GoalState | undefined; + let lastAtMs = options.fallbackTimestamp; + + const atMs = (record: ContextRecord): number => { + const time = record.time; + if (typeof time === 'number' && Number.isFinite(time)) { + lastAtMs = time; + } + return lastAtMs; + }; + + const atIso = (record: ContextRecord): string => new Date(atMs(record)).toISOString(); + + const scratch = (rawId: number): TurnScratch => { + let entry = scratchByTurn.get(rawId); + if (entry === undefined) { + entry = { serverUserSeq: 0, attachmentSeq: 0, openingSteerDeduped: false }; + scratchByTurn.set(rawId, entry); + } + return entry; + }; + + const pushSystem = ( + subtype: DurableSystemSubtype, + payload: unknown, + record: ContextRecord, + ): void => { + const systemId = sysIds.next(subtype); + systems.set(systemId, { systemId, subtype, payload, at: atIso(record), atMs: atMs(record) }); + order.push(`sys:${systemId}`); + timelineIds.push(systemId); + }; + + const skipCancelledTurnIds = (): void => { + while (cancelledTurnIds.delete(nextTurnId)) { + hiddenTurnIds.add(nextTurnId); + nextTurnId += 1; + } + }; + + const createTextDraft = ( + stepId: string, + turnId: string, + kind: 'assistant' | 'thinking', + recordAtMs: number, + ): TextDraft => { + const seq = (stepTextSeqs.get(stepId) ?? 0) + 1; + stepTextSeqs.set(stepId, seq); + const draft: TextDraft = { + messageId: textMessageIdOf(stepId, seq), + kind, + turnId, + stepId, + text: '', + at: recordAtMs, + }; + texts.set(draft.messageId, draft); + const entry = stepTextIds.get(stepId) ?? {}; + entry[kind] = draft.messageId; + stepTextIds.set(stepId, entry); + order.push(`text:${draft.messageId}`); + return draft; + }; + + const ensureStepDraft = ( + rawId: number, + stepOrdinal: number, + recordAtMs: number, + ): StepDraft | undefined => { + if (hiddenTurnIds.has(rawId)) return undefined; + const turnId = turnIdOf(rawId); + if (!turns.has(turnId)) return undefined; + const stepId = stepIdOf(turnId, stepOrdinal); + const existing = steps.get(stepId); + if (existing !== undefined) return existing; + const draft: StepDraft = { + stepId, + turnId, + ordinal: stepOrdinal, + status: 'running', + startedAt: new Date(recordAtMs).toISOString(), + at: recordAtMs, + }; + steps.set(stepId, draft); + order.push(`step:${stepId}`); + return draft; + }; + + const emitSteer = (rawId: number, steer: SteerInput): void => { + const turnId = turnIdOf(rawId); + const entry = scratch(rawId); + let messageId = steer.messageId; + if (messageId === undefined) { + entry.serverUserSeq += 1; + messageId = `${turnId}.u${entry.serverUserSeq}`; + } + const attachmentIds: string[] = []; + for (const part of steer.input.slice(steer.skipBlocks)) { + if (part.type === 'text') continue; + if (daemonFileRefFromPart(part) === undefined) continue; + entry.attachmentSeq += 1; + attachmentIds.push(attachmentIdOf(turnId, entry.attachmentSeq)); + } + const draft: UserDraft = { + messageId, + turnId, + text: + steer.text !== undefined + ? textPartsOf(steer.text) + : wireContentParts(steer.input.slice(steer.skipBlocks)), + timestamp: steer.at, + origin: steer.origin, + attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, + skillActivations: steer.skillActivations, + }; + users.set(messageId, draft); + order.push(`user:${messageId}`); + }; + + const dropTurnDetails = (turnId: string): void => { + turns.delete(turnId); + for (const [stepId, step] of steps) { + if (step.turnId !== turnId) continue; + steps.delete(stepId); + const entry = stepTextIds.get(stepId); + if (entry?.assistant !== undefined) texts.delete(entry.assistant); + if (entry?.thinking !== undefined) texts.delete(entry.thinking); + stepTextIds.delete(stepId); + stepTextSeqs.delete(stepId); + } + for (const [toolCallId, tool] of tools) { + if (tool.turnId === turnId) tools.delete(toolCallId); + } + for (const [messageId, user] of users) { + if (user.turnId === turnId) users.delete(messageId); + } + }; + + const removedKeys = new Set(); + const truncateTimeline = (cutIndex: number): string[] => { + const removed = timelineIds.slice(cutIndex); + for (const id of removed) { + if (turnOrdinalOf(id) !== undefined) { + dropTurnDetails(id); + } else { + systems.delete(id); + } + } + timelineIds.length = cutIndex; + return removed; + }; + + const pruneOrder = (): void => { + const kept = order.filter((key) => !removedKeys.has(key)); + order.length = 0; + order.push(...kept); + removedKeys.clear(); + }; + + const markRemoved = (ids: readonly string[]): void => { + for (const id of ids) { + if (turnOrdinalOf(id) !== undefined) { + removedKeys.add(`turn:${id}`); + for (const [stepId, step] of steps) { + if (step.turnId === id) removedKeys.add(`step:${stepId}`); + } + for (const messageId of texts.keys()) { + if (texts.get(messageId)?.turnId === id) removedKeys.add(`text:${messageId}`); + } + for (const [toolCallId, tool] of tools) { + if (tool.turnId === id) removedKeys.add(`tool:${toolCallId}`); + } + for (const [messageId, user] of users) { + if (user.turnId === id) removedKeys.add(`user:${messageId}`); + } + } else { + removedKeys.add(`sys:${id}`); + } + } + }; + + const onTurnPrompt = (record: ContextRecord): void => { + skipCancelledTurnIds(); + const recordTurnId = record['turnId']; + const rawId = + typeof recordTurnId === 'number' && Number.isInteger(recordTurnId) && recordTurnId >= 0 + ? recordTurnId + : nextTurnId; + nextTurnId = Math.max(nextTurnId, rawId + 1); + const carriedUserSeq = phantomUserSeq; + phantomUserSeq = 0; + const origin = record['origin']; + const promptId = record['promptId']; + if (typeof promptId === 'string') { + turnPromptIds.set(rawId, promptId); + queuedPrompts.delete(promptId); + } else { + const rawInput = record['input']; + const inputKey = JSON.stringify(Array.isArray(rawInput) ? rawInput : []); + for (const [queuedId, queued] of queuedPrompts) { + if (JSON.stringify(queued.content) === inputKey) { + queuedPrompts.delete(queuedId); + break; + } + } + } + if (isUndoAnchorOrigin(origin)) pendingAnchorTurnIds.push(rawId); + currentTurn = rawId; + if (!isVisibleTurnOrigin(origin)) { + hiddenTurnIds.add(rawId); + return; + } + const recordAtMs = atMs(record); + const recordAtIso = new Date(recordAtMs).toISOString(); + const turnId = turnIdOf(rawId); + const input = Array.isArray(record['input']) ? (record['input'] as ContentPart[]) : []; + const skipBlocks = bundledSkillCount(origin); + const promptText = turnPromptText(input, skipBlocks); + const attachments = promptAttachmentCount(input, origin); + const attachmentIds = + attachments > 0 + ? Array.from({ length: attachments }, (_, i) => attachmentIdOf(turnId, i + 1)) + : undefined; + const wantsUser = wantsUserMessage(origin, promptText); + const taskOrigin = taskNotificationOriginOf(origin); + const openingMessageId = + wantsUser || taskOrigin !== undefined + ? ((typeof promptId === 'string' ? promptId : undefined) ?? turnUserMessageIdOf(turnId)) + : undefined; + const draft: TurnDraft = { + turnId, + rawId, + origin: toTurnOrigin(origin, options.agentId, subagentTaskIds), + status: 'running', + userMessageId: openingMessageId, + attachmentIds, + startedAt: recordAtIso, + at: recordAtMs, + }; + turns.set(turnId, draft); + order.push(`turn:${turnId}`); + timelineIds.push(turnId); + scratchByTurn.set(rawId, { + serverUserSeq: carriedUserSeq, + attachmentSeq: attachments, + openingInputKey: JSON.stringify(input), + openingSteerDeduped: false, + }); + if (openingMessageId !== undefined) { + const notification = + taskOrigin === undefined ? undefined : parseNotificationXmlText(promptText ?? ''); + const user: UserDraft = { + messageId: openingMessageId, + turnId, + text: + notification !== undefined + ? textPartsOf(notificationTextOf(notification)) + : wireContentParts(input.slice(skipBlocks)), + timestamp: recordAtMs, + origin: + notification !== undefined + ? taskUserOriginOf(taskOrigin?.task_id, notification) + : (taskOrigin ?? userOriginOf(origin)), + attachmentIds, + skillActivations: skillActivationsOf(origin), + }; + users.set(user.messageId, user); + order.push(`user:${user.messageId}`); + } + }; + + const onTurnSteer = (record: ContextRecord): void => { + const origin = record['origin'] as + | { kind?: string; skillActivations?: readonly { skillName: string; skillArgs?: string }[]; trigger?: string } + | undefined; + const kind = origin?.kind; + if (kind !== 'user' && kind !== 'skill_activation' && kind !== 'cron_job') return; + if (kind === 'skill_activation' && origin?.trigger !== 'user-slash') return; + const rawId = currentTurn; + if (rawId === undefined || hiddenTurnIds.has(rawId)) return; + const input = Array.isArray(record['input']) ? (record['input'] as ContentPart[]) : []; + const skipBlocks = kind === 'user' ? (origin?.skillActivations?.length ?? 0) : 0; + const entry = scratch(rawId); + if ( + entry.currentStep === undefined && + !entry.openingSteerDeduped && + entry.openingInputKey !== undefined && + entry.openingInputKey === JSON.stringify(input) + ) { + entry.openingSteerDeduped = true; + return; + } + if (kind === 'user') { + const recordAtMs = atMs(record); + const matchedId = matchQueuedPrompt(input, skipBlocks); + if (matchedId !== undefined) { + purgeMergedSteer(promptTextOf(input.slice(skipBlocks))); + emitSteer(rawId, { + input, + origin: userOriginOf(origin), + skillActivations: skillActivationsOf(origin), + skipBlocks, + at: recordAtMs, + messageId: matchedId, + }); + return; + } + const mergedUsers = matchMergedSteer(input, skipBlocks); + if (mergedUsers !== undefined) { + for (const { messageId, content } of mergedUsers) { + const draft: UserDraft = { + messageId, + turnId: turnIdOf(rawId), + text: wireContentParts(content), + timestamp: recordAtMs, + }; + users.set(messageId, draft); + order.push(`user:${messageId}`); + } + return; + } + } + emitSteer(rawId, { + input, + origin: userOriginOf(origin), + skillActivations: skillActivationsOf(origin), + skipBlocks, + at: atMs(record), + }); + }; + + const matchQueuedPrompt = (input: readonly ContentPart[], skipBlocks: number): string | undefined => { + const text = promptTextOf(input.slice(skipBlocks)); + let matched: string | undefined; + for (const [queuedId, queued] of queuedPrompts) { + if (promptTextOf(queued.content) !== text) continue; + if (matched !== undefined) return undefined; + matched = queuedId; + } + if (matched !== undefined) queuedPrompts.delete(matched); + return matched; + }; + + const matchMergedSteer = ( + input: readonly ContentPart[], + skipBlocks: number, + ): { messageId: string; content: readonly ContentPart[] }[] | undefined => { + const text = promptTextOf(input.slice(skipBlocks)); + const index = mergedSteers.findIndex((entry) => entry.text === text); + if (index < 0) return undefined; + const [entry] = mergedSteers.splice(index, 1); + if (entry === undefined) return undefined; + const matched: { messageId: string; content: readonly ContentPart[] }[] = []; + for (const promptId of entry.promptIds) { + const queued = queuedPrompts.get(promptId); + if (queued !== undefined) matched.push({ messageId: promptId, content: queued.content }); + queuedPrompts.delete(promptId); + } + return matched; + }; + + const purgeMergedSteer = (text: string): void => { + for (let i = mergedSteers.length - 1; i >= 0; i--) { + if (mergedSteers[i]!.text === text) mergedSteers.splice(i, 1); + } + }; + + const onLoopEvent = (record: ContextRecord): void => { + const event = record['event'] as { type?: string } | undefined; + if (event?.type === undefined) return; + switch (event.type) { + case 'step.begin': { + const e = event as { uuid: string; turnId?: string; step?: number }; + if (e.turnId === undefined || e.step === undefined) return; + const turn = Number(e.turnId); + if (!Number.isInteger(turn)) return; + stepRefs.set(e.uuid, { turn, step: e.step }); + const draft = ensureStepDraft(turn, e.step, atMs(record)); + if (draft === undefined) return; + draft.startedAt = draft.startedAt ?? atIso(record); + const entry = scratch(turn); + entry.currentStep = e.step; + currentTurn = turn; + return; + } + case 'step.end': { + const e = event as { + uuid: string; + finishReason?: string; + rawFinishReason?: string; + providerFinishReason?: string; + usage?: TokenUsage; + llmFirstTokenLatencyMs?: number; + llmStreamDurationMs?: number; + }; + const ref = stepRefs.get(e.uuid); + if (ref === undefined) return; + const draft = steps.get(stepIdOf(turnIdOf(ref.turn), ref.step)); + if (draft === undefined) return; + draft.status = 'completed'; + draft.endedAt = atIso(record); + draft.usage = e.usage === undefined ? undefined : toSnakeUsage(e.usage); + draft.finishReason = e.finishReason ?? e.rawFinishReason ?? e.providerFinishReason; + draft.timing = + e.llmFirstTokenLatencyMs === undefined && e.llmStreamDurationMs === undefined + ? undefined + : { + llm_first_token_ms: e.llmFirstTokenLatencyMs, + llm_stream_duration_ms: e.llmStreamDurationMs, + }; + draft.retry = undefined; + draft.at = atMs(record); + return; + } + case 'content.part': { + const e = event as { + stepUuid: string; + part: { type: string; text?: string; think?: string; hidden?: boolean }; + turnId?: string; + step?: number; + }; + const ref = resolveStepRef(stepRefs, e.stepUuid, e.turnId, e.step); + if (ref === undefined) return; + const draft = ensureStepDraft(ref.turn, ref.step, atMs(record)); + if (draft === undefined) return; + if (e.part.type === 'think' && e.part.hidden === true) return; + const kind = e.part.type === 'text' ? 'assistant' : e.part.type === 'think' ? 'thinking' : undefined; + const partText = e.part.type === 'think' ? e.part.think : e.part.text; + if (kind === undefined || typeof partText !== 'string') return; + if (kind === 'thinking' && partText.length === 0) return; + const stepId = draft.stepId; + const existingId = stepTextIds.get(stepId)?.[kind]; + const text = existingId === undefined ? undefined : texts.get(existingId); + const target = text ?? createTextDraft(stepId, draft.turnId, kind, atMs(record)); + target.text += partText; + target.at = atMs(record); + return; + } + case 'tool.call': { + const e = event as { + stepUuid: string; + toolCallId: string; + name: string; + args?: unknown; + turnId?: string; + step?: number; + }; + const ref = resolveStepRef(stepRefs, e.stepUuid, e.turnId, e.step); + if (ref === undefined) return; + const draft = ensureStepDraft(ref.turn, ref.step, atMs(record)); + if (draft === undefined) return; + const existing = tools.get(e.toolCallId); + const input = parseToolArgs(e.args); + const tool: ToolDraft = { + toolCallId: e.toolCallId, + turnId: draft.turnId, + stepId: draft.stepId, + name: e.name, + status: existing?.status ?? 'running', + input, + inputText: typeof e.args === 'string' ? e.args : undefined, + output: existing?.output, + error: existing?.error, + taskId: existing?.taskId ?? taskIdByToolCall(e.toolCallId), + approvalId: existing?.approvalId, + todoId: + existing?.todoId ?? + (e.name === 'TodoList' && todoWriteItems(input) !== undefined + ? TODO_ENTITY_ID + : undefined), + agentRefs: existing?.agentRefs ?? agentRefsOf(e.toolCallId), + at: atMs(record), + }; + tools.set(e.toolCallId, tool); + if (existing === undefined) order.push(`tool:${e.toolCallId}`); + return; + } + case 'tool.result': { + const e = event as { + toolCallId: string; + result: { output: unknown; isError?: boolean }; + }; + const existing = tools.get(e.toolCallId); + if (existing === undefined) return; + const isError = e.result.isError === true; + existing.status = isError ? 'error' : 'done'; + existing.output = e.result.output; + existing.error = isError && typeof e.result.output === 'string' ? e.result.output : undefined; + existing.at = atMs(record); + return; + } + default: + return; + } + }; + + const taskIdByToolCall = (toolCallId: string): string | undefined => { + for (const link of agentTaskLinks) { + if (link.parentToolCallId === toolCallId) return link.taskId; + } + return undefined; + }; + + const agentRefsOf = (toolCallId: string): { agent_id: string; role?: 'child' | 'member' }[] => { + const refs: { agent_id: string; role?: 'child' | 'member' }[] = []; + for (const link of agentTaskLinks) { + if (link.parentToolCallId === toolCallId) refs.push({ agent_id: link.agentId, role: 'child' }); + } + return refs; + }; + + const onTaskNotificationAppend = ( + message: { content?: ContentPart[] }, + taskOrigin: Extract, + record: ContextRecord, + ): void => { + const recordAtMs = atMs(record); + const input = Array.isArray(message.content) ? message.content : []; + const rawText = promptTextOf(input); + const notification = parseNotificationXmlText(rawText); + const origin = + notification === undefined ? taskOrigin : taskUserOriginOf(taskOrigin.task_id, notification); + if (origin === undefined) return; + const rawId = currentTurn; + if (rawId !== undefined && !hiddenTurnIds.has(rawId)) { + const turnId = turnIdOf(rawId); + const turn = turns.get(turnId); + const entry = scratchByTurn.get(rawId); + if ( + turn !== undefined && + turn.origin.kind === 'task' && + turn.origin.task_id === taskOrigin.task_id && + entry?.currentStep === undefined + ) { + return; + } + if (turn !== undefined && turn.status === 'running') { + emitSteer(rawId, { + input, + origin, + skillActivations: undefined, + skipBlocks: 0, + at: recordAtMs, + text: notification === undefined ? rawText : notificationTextOf(notification), + }); + return; + } + } + phantomUserSeq += 1; + const turnId = turnIdOf(nextTurnId); + const draft: UserDraft = { + messageId: `${turnId}.u${phantomUserSeq}`, + turnId, + text: textPartsOf(notification === undefined ? rawText : notificationTextOf(notification)), + timestamp: recordAtMs, + origin, + }; + users.set(draft.messageId, draft); + order.push(`user:${draft.messageId}`); + }; + + const onAppendMessage = (record: ContextRecord): void => { + const message = record['message'] as + | { + id?: string; + role?: string; + content?: ContentPart[]; + toolCalls?: readonly { id: string; name: string; arguments: string | null }[]; + toolCallId?: string; + isError?: boolean; + origin?: unknown; + } + | undefined; + if (message?.role === undefined) return; + if (message.role === 'user') { + const taskOrigin = taskNotificationOriginOf(message.origin); + if (taskOrigin !== undefined) { + onTaskNotificationAppend(message, taskOrigin, record); + return; + } + if (!isUndoAnchorOrigin(message.origin)) return; + const messageId = typeof message.id === 'string' ? message.id : undefined; + const matchingIndex = + messageId !== undefined + ? pendingAnchorTurnIds.findIndex((turnId) => turnPromptIds.get(turnId) === messageId) + : -1; + const legacyIndex = + matchingIndex < 0 && messageId !== undefined + ? pendingAnchorTurnIds.findIndex((turnId) => !turnPromptIds.has(turnId)) + : -1; + const matchedTurnId = + matchingIndex >= 0 + ? pendingAnchorTurnIds.splice(matchingIndex, 1)[0] + : legacyIndex >= 0 + ? pendingAnchorTurnIds.splice(legacyIndex, 1)[0] + : messageId === undefined + ? pendingAnchorTurnIds.shift() + : undefined; + if (matchedTurnId !== undefined && !turnPromptIds.has(matchedTurnId) && messageId !== undefined) { + turnPromptIds.set(matchedTurnId, messageId); + } + undoAnchors.push({ rawId: matchedTurnId ?? nextTurnId }); + return; + } + if (message.role === 'assistant') { + const recordAtMs = atMs(record); + const recordAtIso = new Date(recordAtMs).toISOString(); + let rawId = currentTurn; + if (rawId === undefined || hiddenTurnIds.has(rawId) || !turns.has(turnIdOf(rawId))) { + rawId = nextTurnId; + nextTurnId += 1; + const turnId = turnIdOf(rawId); + const draft: TurnDraft = { + turnId, + rawId, + origin: { kind: 'other' }, + status: 'running', + startedAt: recordAtIso, + at: recordAtMs, + }; + turns.set(turnId, draft); + order.push(`turn:${turnId}`); + timelineIds.push(turnId); + currentTurn = rawId; + scratchByTurn.set(rawId, { + serverUserSeq: 0, + attachmentSeq: 0, + openingSteerDeduped: false, + }); + } + const entry = scratch(rawId); + const ordinal = (entry.currentStep ?? 0) + 1; + const step = ensureStepDraft(rawId, ordinal, recordAtMs); + if (step === undefined) return; + entry.currentStep = ordinal; + step.status = 'completed'; + step.endedAt = recordAtIso; + step.at = recordAtMs; + for (const part of message.content ?? []) { + if (part.type === 'text' && typeof part.text === 'string' && part.text.length > 0) { + const existingId = stepTextIds.get(step.stepId)?.assistant; + const target = + (existingId === undefined ? undefined : texts.get(existingId)) ?? + createTextDraft(step.stepId, step.turnId, 'assistant', recordAtMs); + target.text += part.text; + target.at = recordAtMs; + } else if (part.type === 'think') { + const think = (part as { think?: unknown }).think; + if ((part as { hidden?: unknown }).hidden === true) continue; + if (typeof think !== 'string' || think.length === 0) continue; + const existingId = stepTextIds.get(step.stepId)?.thinking; + const target = + (existingId === undefined ? undefined : texts.get(existingId)) ?? + createTextDraft(step.stepId, step.turnId, 'thinking', recordAtMs); + target.text += think; + target.at = recordAtMs; + } + } + for (const call of message.toolCalls ?? []) { + if (tools.has(call.id)) continue; + const input = parseToolArgs(call.arguments ?? undefined); + const tool: ToolDraft = { + toolCallId: call.id, + turnId: step.turnId, + stepId: step.stepId, + name: call.name, + status: 'running', + input, + inputText: typeof call.arguments === 'string' ? call.arguments : undefined, + taskId: taskIdByToolCall(call.id), + todoId: + call.name === 'TodoList' && todoWriteItems(input) !== undefined + ? TODO_ENTITY_ID + : undefined, + agentRefs: agentRefsOf(call.id), + at: recordAtMs, + }; + tools.set(call.id, tool); + order.push(`tool:${call.id}`); + } + return; + } + if (message.role === 'tool') { + const toolCallId = message.toolCallId; + if (typeof toolCallId !== 'string') return; + const existing = tools.get(toolCallId); + if (existing === undefined) return; + const output = promptTextOf(message.content ?? []); + const isError = message.isError === true; + existing.status = isError ? 'error' : 'done'; + existing.output = output; + existing.error = isError ? output : undefined; + existing.at = atMs(record); + return; + } + }; + + const onTurnEnded = (record: ContextRecord): void => { + const rawId = record['turnId']; + if (typeof rawId !== 'number' || !Number.isInteger(rawId)) return; + const pendingIndex = pendingAnchorTurnIds.indexOf(rawId); + if (pendingIndex >= 0) pendingAnchorTurnIds.splice(pendingIndex, 1); + const draft = turns.get(turnIdOf(rawId)); + if (draft === undefined) return; + const recordAtMs = atMs(record); + const recordAtIso = new Date(recordAtMs).toISOString(); + const reason = record['reason']; + const entry = scratch(rawId); + const step = + entry.currentStep === undefined + ? undefined + : steps.get(stepIdOf(turnIdOf(rawId), entry.currentStep)); + if (step !== undefined && step.status === 'running') { + step.status = reason === 'failed' || reason === 'blocked' ? 'failed' : 'interrupted'; + step.endedAt = recordAtIso; + step.at = recordAtMs; + } + draft.status = 'completed'; + draft.endedAt = recordAtIso; + draft.durationMs = typeof record['durationMs'] === 'number' ? record['durationMs'] : undefined; + draft.at = recordAtMs; + }; + + const onUndo = (record: ContextRecord): void => { + const count = record['count']; + if (typeof count !== 'number' || !Number.isSafeInteger(count) || count <= 0) return; + let firstUndone: number | undefined; + for (let i = 0; i < count && undoAnchors.length > undoAnchorFloor; i++) { + const anchor = undoAnchors.pop(); + if (anchor !== undefined) firstUndone = anchor.rawId; + } + if (firstUndone === undefined) return; + const cut = timelineIds.findIndex((id) => { + const ordinal = turnOrdinalOf(id); + return ordinal !== undefined && ordinal >= firstUndone; + }); + if (cut < 0) return; + const removed = timelineIds.slice(cut); + markRemoved(removed); + truncateTimeline(cut); + pruneOrder(); + for (let turnId = firstUndone; turnId < nextTurnId; turnId++) hiddenTurnIds.add(turnId); + if (currentTurn !== undefined && currentTurn >= firstUndone) currentTurn = undefined; + pushSystem('undo', { removed_ids: removed }, record); + }; + + const onClear = (record: ContextRecord): void => { + const removed = [...timelineIds]; + markRemoved(removed); + for (const id of removed) { + if (turnOrdinalOf(id) !== undefined) dropTurnDetails(id); + } + systems.clear(); + timelineIds.length = 0; + pruneOrder(); + undoAnchorFloor = undoAnchors.length; + currentTurn = undefined; + scratchByTurn.clear(); + pushSystem('clear', { removed_ids: removed }, record); + }; + + const onInteractionRequest = (record: ContextRecord): void => { + const kind = record['kind']; + if (kind !== 'approval' && kind !== 'question') return; + const id = record['id']; + if (typeof id !== 'string') return; + const payload = record['request']; + const innerToolCallId = (payload as { toolCallId?: unknown } | undefined)?.toolCallId; + const toolCallId = + typeof record['toolCallId'] === 'string' + ? record['toolCallId'] + : typeof innerToolCallId === 'string' + ? innerToolCallId + : undefined; + const recordAtMs = atMs(record); + const draft: InteractionDraft = { + interactionId: id, + kind, + status: 'pending', + toolCallId, + request: wireInteractionRequest(kind, payload), + at: recordAtMs, + }; + interactions.set(id, draft); + order.push(`ix:${id}`); + if (toolCallId !== undefined) { + const tool = tools.get(toolCallId); + if (tool !== undefined && tool.approvalId !== id) { + tool.approvalId = id; + tool.at = recordAtMs; + } + } + }; + + const onInteractionResolved = (record: ContextRecord): void => { + const id = record['id']; + if (typeof id !== 'string') return; + const draft = interactions.get(id); + if (draft === undefined) return; + const response = record['response']; + draft.status = mapInteractionEndStatus(draft.kind, response); + draft.response = wireInteractionResponse(draft.kind, draft.request, response); + draft.at = atMs(record); + }; + + const onTaskRecord = (record: ContextRecord): void => { + const info = record['info'] as + | { + taskId?: unknown; + kind?: unknown; + status?: unknown; + detached?: unknown; + description?: unknown; + agentId?: unknown; + startedAt?: unknown; + endedAt?: unknown; + stopReason?: unknown; + model?: unknown; + thinkingEffort?: unknown; + } + | undefined; + if (info === undefined || typeof info.taskId !== 'string') return; + const recordAtMs = atMs(record); + const taskId = info.taskId; + const prev = tasks.get(taskId); + const status = info.status; + const draft: TaskDraft = { + taskId, + kind: mapTaskKind(info.kind), + status: + typeof status === 'string' && TASK_STATUSES.has(status as TaskMessage['status']) + ? (status as TaskMessage['status']) + : (prev?.status ?? 'running'), + detached: typeof info.detached === 'boolean' ? info.detached : (prev?.detached ?? true), + description: typeof info.description === 'string' ? info.description : prev?.description, + childAgentId: typeof info.agentId === 'string' ? info.agentId : prev?.childAgentId, + outputTail: + typeof record['outputTail'] === 'string' ? record['outputTail'] : (prev?.outputTail ?? ''), + startedAt: prev?.startedAt ?? epochMsToIso(info.startedAt), + endedAt: epochMsToIso(info.endedAt) ?? prev?.endedAt, + resultSummary: prev?.resultSummary, + error: prev?.error, + stateReason: typeof info.stopReason === 'string' ? info.stopReason : prev?.stateReason, + usage: prev?.usage, + model: typeof info.model === 'string' ? info.model : prev?.model, + thinkingEffort: + typeof info.thinkingEffort === 'string' ? info.thinkingEffort : prev?.thinkingEffort, + at: recordAtMs, + }; + tasks.set(taskId, draft); + if (prev === undefined) order.push(`task:${taskId}`); + }; + + const onGoalRecord = (record: ContextRecord): void => { + if (record.type === 'goal.create') { + goal = { + objective: typeof record['objective'] === 'string' ? record['objective'] : '', + status: 'active', + completionCriterion: + typeof record['completionCriterion'] === 'string' + ? record['completionCriterion'] + : undefined, + budgetUsed: 0, + }; + pushSystem('goal', goalPayloadOf(goal), record); + return; + } + if (record.type === 'goal.update') { + if (goal !== undefined) { + const status = record['status']; + const tokenBudget = (record['budgetLimits'] as { tokenBudget?: unknown } | undefined) + ?.tokenBudget; + goal = { + ...goal, + status: + typeof status === 'string' && GOAL_STATUSES.has(status) + ? (status as GoalState['status']) + : goal.status, + budgetUsed: + typeof record['tokensUsed'] === 'number' ? record['tokensUsed'] : goal.budgetUsed, + budgetLimit: typeof tokenBudget === 'number' ? tokenBudget : goal.budgetLimit, + }; + } + if ( + record['status'] === undefined && + record['budgetLimits'] === undefined && + record['turnsUsed'] === undefined + ) { + return; + } + pushSystem('goal', goal === undefined ? undefined : goalPayloadOf(goal), record); + return; + } + goal = undefined; + pushSystem('goal', undefined, record); + }; + + for (const record of records) { + switch (record.type) { + case 'turn.prompt': + onTurnPrompt(record); + break; + case 'turn.steer': + onTurnSteer(record); + break; + case 'context.append_loop_event': + onLoopEvent(record); + break; + case 'context.append_message': + onAppendMessage(record); + break; + case 'turn.ended': + onTurnEnded(record); + break; + case 'turn.step.interrupted': { + const rawId = record['turnId']; + const step = record['step']; + if (typeof rawId !== 'number' || typeof step !== 'number') break; + if (typeof record['reason'] !== 'string') break; + const draft = ensureStepDraft(rawId, step, atMs(record)); + if (draft === undefined) break; + draft.status = 'interrupted'; + draft.endedAt = atIso(record); + draft.endReason = record['reason']; + draft.endMessage = typeof record['message'] === 'string' ? record['message'] : undefined; + draft.at = atMs(record); + break; + } + case 'turn.step.retrying': { + const rawId = record['turnId']; + const step = record['step']; + if (typeof rawId !== 'number' || typeof step !== 'number') break; + if ( + typeof record['failedAttempt'] !== 'number' || + typeof record['nextAttempt'] !== 'number' || + typeof record['maxAttempts'] !== 'number' || + typeof record['delayMs'] !== 'number' || + typeof record['errorName'] !== 'string' || + typeof record['errorMessage'] !== 'string' + ) { + break; + } + const draft = ensureStepDraft(rawId, step, atMs(record)); + if (draft === undefined) break; + draft.retry = { + failed_attempt: record['failedAttempt'] as number, + next_attempt: record['nextAttempt'] as number, + max_attempts: record['maxAttempts'] as number, + delay_ms: record['delayMs'] as number, + error_name: record['errorName'] as string, + error_message: record['errorMessage'] as string, + status_code: typeof record['statusCode'] === 'number' ? record['statusCode'] : undefined, + }; + draft.at = atMs(record); + break; + } + case 'turn.cancel': { + const target = record['target']; + const turnId = record['turnId']; + if (target === 'queued' && typeof turnId === 'number' && turnId >= nextTurnId) { + cancelledTurnIds.add(turnId); + skipCancelledTurnIds(); + break; + } + if ( + target !== 'active' || + typeof turnId !== 'number' || + !Number.isInteger(turnId) || + turnId < 0 || + activeCancelTurnIds.has(turnId) + ) { + break; + } + activeCancelTurnIds.add(turnId); + if (record['reason'] !== 'user_cancelled') break; + pushSystem( + 'interruption', + { turn_id: turnIdOf(turnId), reason: 'user_cancelled' }, + record, + ); + break; + } + case 'context.undo': + onUndo(record); + break; + case 'context.clear': + onClear(record); + break; + case 'context.apply_compaction': { + undoAnchorFloor = undoAnchors.length; + const text = compactionSummaryText(record); + pushSystem( + 'compaction', + { phase: 'completed', text: text.length > 0 ? text : undefined }, + record, + ); + break; + } + case 'interaction.request': + onInteractionRequest(record); + break; + case 'interaction.resolved': + onInteractionResolved(record); + break; + case 'task.started': + case 'task.terminated': + onTaskRecord(record); + break; + case 'goal.create': + case 'goal.update': + case 'goal.clear': + onGoalRecord(record); + break; + case 'plan_mode.enter': + pushSystem('plan.enter', undefined, record); + break; + case 'plan_mode.exit': + pushSystem('plan.exit', undefined, record); + break; + case 'plan_mode.cancel': + break; + case 'plan.revision': { + const key = record['key']; + const path = + typeof key === 'string' + ? (options.resolvePlanRevisionKey?.(key) ?? key) + : typeof record['path'] === 'string' + ? record['path'] + : undefined; + pushSystem( + 'plan.revision', + { + id: record['id'], + version: record['version'], + path, + sha256: record['sha256'], + bytes: record['bytes'], + }, + record, + ); + break; + } + case 'swarm_mode.enter': + pushSystem('swarm.enter', undefined, record); + break; + case 'swarm_mode.exit': + pushSystem('swarm.exit', undefined, record); + break; + case 'prompt.accepted': { + const promptId = record['promptId']; + const content = record['content']; + if (typeof promptId !== 'string' || !Array.isArray(content)) break; + queuedPrompts.set(promptId, { content: content as ContentPart[], at: atMs(record) }); + break; + } + case 'prompt.aborted': + case 'prompt.completed': { + const promptId = record['promptId']; + if (typeof promptId === 'string') queuedPrompts.delete(promptId); + break; + } + case 'prompt.steered': { + const ids = record['promptIds']; + if (!Array.isArray(ids)) break; + const content = record['content']; + const promptIds: string[] = []; + for (const id of ids) { + if (typeof id !== 'string') continue; + promptIds.push(id); + const queued = queuedPrompts.get(id); + if (queued !== undefined) queued.steered = true; + } + if (Array.isArray(content)) { + mergedSteers.push({ + text: promptTextOf(content as ContentPart[]), + promptIds, + }); + } + break; + } + default: + break; + } + } + + for (const [promptId, { content }] of queuedPrompts) { + const draft: UserDraft = { + messageId: promptId, + text: wireContentParts(content), + }; + users.set(draft.messageId, draft); + order.push(`user:${draft.messageId}`); + } + + const finalTurnStatus = (draft: TurnDraft): 'running' | 'completed' => + draft.status === 'running' && options.live ? 'running' : 'completed'; + + const finalStepStatus = (draft: StepDraft): StepDraft['status'] => + draft.status === 'running' && !options.live ? 'interrupted' : draft.status; + + const turnUsageOf = (turnId: string): StepUsage | undefined => { + let total: StepUsage | undefined; + for (const step of steps.values()) { + if (step.turnId !== turnId || step.usage === undefined) continue; + total = { + input_other: (total?.input_other ?? 0) + step.usage.input_other, + output: (total?.output ?? 0) + step.usage.output, + input_cache_read: (total?.input_cache_read ?? 0) + step.usage.input_cache_read, + input_cache_creation: + (total?.input_cache_creation ?? 0) + step.usage.input_cache_creation, + }; + } + return total; + }; + + const synthesizeSubagentTasks = (): void => { + for (const tool of tools.values()) { + if (tool.name !== 'Agent' || tool.taskId !== undefined) continue; + const outputText = typeof tool.output === 'string' ? tool.output : undefined; + const childAgentId = + outputText === undefined ? undefined : /^agent_id: (\S+)$/m.exec(outputText)?.[1]; + let realTask: TaskDraft | undefined; + if (childAgentId !== undefined) { + for (const task of tasks.values()) { + if (task.childAgentId === childAgentId) { + realTask = task; + break; + } + } + } + if (childAgentId !== undefined && realTask !== undefined) { + tool.taskId = realTask.taskId; + if (!tool.agentRefs.some((ref) => ref.agent_id === childAgentId)) { + tool.agentRefs = [...tool.agentRefs, { agent_id: childAgentId, role: 'child' }]; + } + continue; + } + const agentTaskId = `agent_${tool.toolCallId}`; + if (tasks.has(agentTaskId)) continue; + const args = (tool.input ?? {}) as Record; + const summary = + outputText === undefined + ? undefined + : /\[summary\]\n([\s\S]*?)(?:\n\nresume_hint:|$)/.exec(outputText)?.[1]?.trim(); + const status = tool.status === 'done' ? 'completed' : 'failed'; + tasks.set(agentTaskId, { + taskId: agentTaskId, + kind: 'subagent', + status, + detached: args['run_in_background'] === true, + description: typeof args['description'] === 'string' ? args['description'] : undefined, + childAgentId, + outputTail: '', + startedAt: new Date(tool.at).toISOString(), + endedAt: tool.status === 'running' ? undefined : new Date(tool.at).toISOString(), + resultSummary: + tool.status === 'done' && summary !== undefined && summary.length > 0 + ? summary + : undefined, + error: tool.status === 'error' ? (tool.error ?? outputText) : undefined, + stateReason: tool.status === 'running' ? 'interrupted' : undefined, + usage: undefined, + model: typeof args['model'] === 'string' ? args['model'] : undefined, + thinkingEffort: typeof args['thinking'] === 'string' ? args['thinking'] : undefined, + at: tool.at, + }); + tool.taskId = agentTaskId; + if (childAgentId !== undefined && !tool.agentRefs.some((ref) => ref.agent_id === childAgentId)) { + tool.agentRefs = [...tool.agentRefs, { agent_id: childAgentId, role: 'child' }]; + } + const toolIndex = order.indexOf(`tool:${tool.toolCallId}`); + if (toolIndex >= 0) order.splice(toolIndex + 1, 0, `task:${agentTaskId}`); + else order.push(`task:${agentTaskId}`); + } + }; + synthesizeSubagentTasks(); + + const messages: HistoryMessage[] = []; + for (const key of order) { + const [kind, id] = splitKey(key); + switch (kind) { + case 'turn': { + const draft = turns.get(id); + if (draft === undefined) break; + const usage = turnUsageOf(id); + messages.push({ + type: 'turn', + ...baseFields(options, draft.at), + turn_id: draft.turnId, + ordinal: draft.rawId, + status: finalTurnStatus(draft), + origin: draft.origin, + user_message_id: draft.userMessageId, + attachment_ids: draft.attachmentIds, + started_at: draft.startedAt, + ended_at: draft.endedAt, + usage: usage === undefined ? undefined : turnUsageToWire(usage), + duration_ms: draft.durationMs, + }); + break; + } + case 'step': { + const draft = steps.get(id); + if (draft === undefined) break; + messages.push({ + type: 'step', + ...baseFields(options, draft.at), + step_id: draft.stepId, + turn_id: draft.turnId, + ordinal: draft.ordinal, + status: finalStepStatus(draft), + started_at: draft.startedAt, + ended_at: draft.endedAt, + usage: draft.usage, + finish_reason: draft.finishReason, + timing: draft.timing, + retry: draft.retry, + end_reason: draft.endReason, + end_message: draft.endMessage, + }); + break; + } + case 'user': { + const draft = users.get(id); + if (draft === undefined) break; + messages.push({ + type: 'user', + session_id: options.sessionId, + agent_id: options.agentId, + message_id: draft.messageId, + turn_id: draft.turnId, + status: draft.timestamp === undefined ? 'unread' : 'read', + timestamp: draft.timestamp, + text: draft.text, + attachment_ids: draft.attachmentIds, + skill_activations: draft.skillActivations, + origin: draft.origin, + }); + break; + } + case 'text': { + const draft = texts.get(id); + if (draft === undefined) break; + const step = steps.get(draft.stepId); + const streaming = + options.live && step !== undefined && finalStepStatus(step) === 'running'; + const body = { + ...baseFields(options, draft.at), + message_id: draft.messageId, + turn_id: draft.turnId, + step_id: draft.stepId, + status: (streaming ? 'streaming' : 'completed') as 'streaming' | 'completed', + text: draft.text, + }; + if (draft.kind === 'assistant') messages.push({ type: 'assistant', ...body }); + else messages.push({ type: 'thinking', ...body }); + break; + } + case 'tool': { + const draft = tools.get(id); + if (draft === undefined) break; + messages.push({ + type: 'tool_call', + ...baseFields(options, draft.at), + tool_call_id: draft.toolCallId, + turn_id: draft.turnId, + step_id: draft.stepId, + name: draft.name, + status: draft.status === 'running' && !options.live ? 'done' : draft.status, + input: draft.input, + input_text: draft.inputText, + output: draft.output, + error: draft.error, + task_id: draft.taskId, + approval_id: draft.approvalId, + todo_id: draft.todoId, + agent_refs: draft.agentRefs.length > 0 ? draft.agentRefs : undefined, + }); + break; + } + case 'sys': { + const draft = systems.get(id); + if (draft === undefined) break; + messages.push({ + type: 'system', + ...baseFields(options, draft.atMs), + system_id: draft.systemId, + subtype: draft.subtype, + payload: draft.payload, + at: draft.at, + } as HistoryMessage); + break; + } + case 'ix': { + const draft = interactions.get(id); + if (draft === undefined) break; + const status = + draft.status === 'pending' && !options.live ? ('cancelled' as const) : draft.status; + messages.push({ + type: 'interaction', + ...baseFields(options, draft.at), + interaction_id: draft.interactionId, + kind: draft.kind, + status, + tool_call_id: draft.toolCallId, + request: draft.request, + response: draft.response, + } as HistoryMessage); + break; + } + case 'task': { + const draft = tasks.get(id); + if (draft === undefined) break; + messages.push({ + type: 'task', + ...baseFields(options, draft.at), + task_id: draft.taskId, + kind: draft.kind, + status: draft.status, + detached: draft.detached, + description: draft.description, + child_agent_id: draft.childAgentId, + output_tail: draft.outputTail, + started_at: draft.startedAt, + ended_at: draft.endedAt, + result_summary: draft.resultSummary, + error: draft.error, + state_reason: draft.stateReason, + usage: draft.usage, + model: draft.model, + thinking_effort: draft.thinkingEffort, + }); + break; + } + default: + break; + } + } + let lastTodoTool: ToolDraft | undefined; + for (const tool of tools.values()) { + if (tool.todoId !== undefined && tool.status === 'done') lastTodoTool = tool; + } + if (lastTodoTool !== undefined) { + const items = todoWriteItems(lastTodoTool.input); + if (items !== undefined) { + messages.push({ + type: 'todo', + ...baseFields(options, lastAtMs), + todo_id: TODO_ENTITY_ID, + items: items.map((item) => ({ title: item.title, status: item.status })), + updated_at: new Date(lastTodoTool.at).toISOString(), + }); + } + } + return messages; +} + +function splitKey(key: string): [string, string] { + const index = key.indexOf(':'); + return [key.slice(0, index), key.slice(index + 1)]; +} + +function baseFields( + options: ColdFoldOptions, + timestamp: number, +): { session_id: string; agent_id: string; timestamp: number } { + return { session_id: options.sessionId, agent_id: options.agentId, timestamp }; +} + +function parseNotificationXmlText(text: string): TaskNotificationPayload | undefined { + const match = text.match(/^]*)>\n?/); + if (!match) return undefined; + const attrs = match[1]!; + const attr = (name: string): string | undefined => + attrs.match(new RegExp(`${name}="([^"]*)"`))?.[1]; + const rest = text.slice(match[0].length).replace(/\n?<\/notification>\s*$/, ''); + let title = ''; + let severity: string | undefined; + const bodyLines: string[] = []; + for (const line of rest.split('\n')) { + if (line.startsWith('Title: ')) title = line.slice('Title: '.length); + else if (line.startsWith('Severity: ')) severity = line.slice('Severity: '.length); + else bodyLines.push(line); + } + return { + title, + body: bodyLines.join('\n').replaceAll(/^\n+|\n+$/g, ''), + severity, + type: attr('type'), + source_kind: attr('source_kind'), + source_id: attr('source_id'), + agent_id: attr('agent_id'), + raw: text, + }; +} + +function bundledSkillCount(origin: unknown): number { + const candidate = origin as + | { kind?: unknown; skillActivations?: readonly unknown[] } + | null + | undefined; + if (candidate?.kind !== 'user') return 0; + return candidate.skillActivations?.length ?? 0; +} + +function turnPromptText(input: readonly ContentPart[], skipBlocks: number): string | undefined { + const text = input + .filter((part): part is ContentPart & { type: 'text' } => part.type === 'text') + .slice(skipBlocks) + .map((part) => part.text) + .join(''); + return text.length > 0 ? text : undefined; +} + +function promptAttachmentCount(input: readonly ContentPart[], origin: unknown): number { + let count = 0; + for (const part of input) { + if (part.type === 'image_url') { + if (mediaFileId(part.imageUrl.url, part.imageUrl.id) !== undefined) count += 1; + } else if (part.type === 'video_url') { + if (mediaFileId(part.videoUrl.url, part.videoUrl.id) !== undefined) count += 1; + } else if (part.type === 'audio_url') { + if (mediaFileId(part.audioUrl.url, part.audioUrl.id) !== undefined) count += 1; + } + } + const candidate = origin as + | { kind?: unknown; attachments?: readonly unknown[] } + | null + | undefined; + if (candidate?.kind === 'user' || candidate?.kind === 'skill_activation') { + count += candidate.attachments?.length ?? 0; + } + return count; +} + +function mediaFileId(url: string, id: string | undefined): string | undefined { + const fileId = parseDaemonFileUrl(url)?.fileId; + if (id === undefined) return fileId; + return fileId === id ? id : undefined; +} + +function resolveStepRef( + stepRefs: ReadonlyMap, + stepUuid: string, + turnId: string | undefined, + step: number | undefined, +): { turn: number; step: number } | undefined { + const direct = stepRefs.get(stepUuid); + if (direct !== undefined) return direct; + if (turnId === undefined || step === undefined) return undefined; + const turn = Number(turnId); + if (!Number.isInteger(turn)) return undefined; + return { turn, step }; +} + +function toSnakeUsage(usage: TokenUsage): StepUsage { + return { + input_other: usage.inputOther, + output: usage.output, + input_cache_read: usage.inputCacheRead, + input_cache_creation: usage.inputCacheCreation, + }; +} + +function turnUsageToWire(usage: StepUsage): { + input_tokens: number; + output_tokens: number; + cached_tokens: number; +} { + return { + input_tokens: usage.input_other + usage.input_cache_creation, + output_tokens: usage.output, + cached_tokens: usage.input_cache_read, + }; +} + +function mapTaskKind(kind: unknown): TaskMessage['kind'] { + switch (kind) { + case 'process': + return 'shell'; + case 'agent': + return 'subagent'; + default: + return 'other'; + } +} + +function epochMsToIso(value: unknown): string | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? new Date(value).toISOString() + : undefined; +} + +function goalPayloadOf(goal: GoalState): Record { + return { + objective: goal.objective, + status: goal.status, + completion_criterion: goal.completionCriterion, + budget_used: goal.budgetUsed, + budget_limit: goal.budgetLimit, + }; +} + +function compactionSummaryText(record: ContextRecord): string { + const summary = record['summary']; + if (typeof summary === 'string') return summary; + const contextSummary = record['contextSummary']; + if (typeof contextSummary === 'string') return contextSummary; + if (summary !== null && typeof summary === 'object' && !Array.isArray(summary)) { + const content = (summary as { content?: unknown }).content; + if (Array.isArray(content)) return promptTextOf(content as ContentPart[]); + } + return ''; +} diff --git a/packages/kap-server/src/services/history/historyService.ts b/packages/kap-server/src/services/history/historyService.ts new file mode 100644 index 0000000000000000000000000000000000000000..1cf72a241a7479f8e4f577461efb4899612d7dd7 --- /dev/null +++ b/packages/kap-server/src/services/history/historyService.ts @@ -0,0 +1,168 @@ +import { join } from 'node:path'; + +import { + getLiveSessionById, + IAgentLifecycleService, + ISessionIndex, + IWireService, + MAIN_AGENT_ID, + type Scope, +} from '@moonshot-ai/agent-core-v2'; + +import { + historyResponseSchema, + type HistoryMessage, + type HistoryResponse, +} from '../../protocol/messages'; +import { readWireRecords, type ContextRecord } from '../projection/heal'; +import type { ProjectionService } from '../projection/projectionService'; +import { foldWireHistory } from './coldFold'; + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 200; + +export class HistorySessionNotFoundError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super(`session ${sessionId} does not exist`); + this.name = 'HistorySessionNotFoundError'; + this.sessionId = sessionId; + } +} + +export interface HistoryQueryOptions { + readonly before_turn?: string; + readonly after_step?: string; + readonly page_size?: number; + readonly agent_id?: string; +} + +export interface HistoryServiceDeps { + readonly homeDir: string; + readonly core: Scope; + readonly projection: ProjectionService; +} + +export async function readSessionHistory( + deps: HistoryServiceDeps, + sessionId: string, + query: HistoryQueryOptions, +): Promise { + const summary = await deps.core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) throw new HistorySessionNotFoundError(sessionId); + const agentId = query.agent_id ?? MAIN_AGENT_ID; + const live = getLiveSessionById(deps.core.accessor, sessionId) !== undefined; + if (live) await flushAgentWire(deps.core, sessionId, agentId); + const records = await readAgentWire(deps.homeDir, summary.workspaceId, sessionId, agentId); + let subagentTaskIds: ReadonlyMap | undefined; + if (agentId !== MAIN_AGENT_ID) { + if (live) await flushAgentWire(deps.core, sessionId, MAIN_AGENT_ID); + const mainRecords = await readAgentWire(deps.homeDir, summary.workspaceId, sessionId, MAIN_AGENT_ID); + subagentTaskIds = scanSubagentTaskIds(mainRecords); + } + const all = foldWireHistory(records, { + sessionId, + agentId, + live, + fallbackTimestamp: new Date(summary.createdAt).getTime(), + subagentTaskIds, + resolvePlanRevisionKey: (key) => + join('sessions', summary.workspaceId, sessionId, 'agents', agentId, key), + }); + const page = paginateHistory(all, query); + const inFlight = live ? deps.projection.inFlight(sessionId, agentId) : undefined; + const response: HistoryResponse = { + messages: page.messages, + has_more: page.hasMore, + in_flight: inFlight, + }; + const parsed = historyResponseSchema.safeParse(response); + if (!parsed.success) { + throw new Error( + `history response failed schema validation: ${parsed.error.issues + .map((issue) => `${issue.path.join('.')}: ${issue.message}`) + .join('; ')}`, + ); + } + return parsed.data; +} + +export interface HistoryPage { + readonly messages: HistoryMessage[]; + readonly hasMore: boolean; +} + +export function paginateHistory( + messages: readonly HistoryMessage[], + query: HistoryQueryOptions, +): HistoryPage { + const pageSize = Math.min(Math.max(query.page_size ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE); + if (query.before_turn !== undefined) { + const cursorIndex = messages.findIndex( + (message) => message.type === 'turn' && message.turn_id === query.before_turn, + ); + if (cursorIndex < 0) return { messages: [], hasMore: false }; + const anchors = turnAnchorIndices(messages, cursorIndex); + const start = anchors.length > pageSize ? anchors[anchors.length - pageSize]! : 0; + return { messages: messages.slice(start, cursorIndex), hasMore: anchors.length > pageSize }; + } + if (query.after_step !== undefined) { + let index = -1; + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if ('step_id' in message && message.step_id === query.after_step) { + index = i; + break; + } + } + if (index < 0) return { messages: [], hasMore: false }; + const end = Math.min(messages.length, index + 1 + pageSize); + return { messages: messages.slice(index + 1, end), hasMore: end < messages.length }; + } + const anchors = turnAnchorIndices(messages, messages.length); + const start = anchors.length > pageSize ? anchors[anchors.length - pageSize]! : 0; + return { messages: messages.slice(start), hasMore: anchors.length > pageSize }; +} + +function turnAnchorIndices(messages: readonly HistoryMessage[], endExclusive: number): number[] { + const anchors: number[] = []; + for (let i = 0; i < endExclusive; i++) { + if (messages[i]!.type === 'turn') anchors.push(i); + } + return anchors; +} + +function scanSubagentTaskIds(records: readonly ContextRecord[]): Map { + const map = new Map(); + for (const record of records) { + if (record.type !== 'task.started' && record.type !== 'task.terminated') continue; + const info = record['info'] as { kind?: unknown; agentId?: unknown; taskId?: unknown } | undefined; + if (info?.kind !== 'agent') continue; + if (typeof info.agentId !== 'string' || typeof info.taskId !== 'string') continue; + map.set(info.agentId, info.taskId); + } + return map; +} + +async function flushAgentWire(core: Scope, sessionId: string, agentId: string): Promise { + const session = getLiveSessionById(core.accessor, sessionId); + const handle = session?.accessor.get(IAgentLifecycleService).handleOf(agentId); + if (handle === undefined) return; + await handle.accessor.get(IWireService).flush(); +} + +async function readAgentWire( + homeDir: string, + workspaceId: string, + sessionId: string, + agentId: string, +): Promise { + try { + return await readWireRecords( + join(homeDir, 'sessions', workspaceId, sessionId, 'agents', agentId, 'wire.jsonl'), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } +} diff --git a/packages/kap-server/src/services/history/index.ts b/packages/kap-server/src/services/history/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..07e4eee8a03ebba98fc725013a0826bf085a5ca5 --- /dev/null +++ b/packages/kap-server/src/services/history/index.ts @@ -0,0 +1,2 @@ +export * from './coldFold'; +export * from './historyService'; diff --git a/packages/kap-server/src/services/legacyStatus/legacyActivity.ts b/packages/kap-server/src/services/legacyStatus/legacyActivity.ts new file mode 100644 index 0000000000000000000000000000000000000000..4178718f7145597345105de2c4169f39d368d061 --- /dev/null +++ b/packages/kap-server/src/services/legacyStatus/legacyActivity.ts @@ -0,0 +1,146 @@ +import { + INTERACTION_TAG_AGENT_ID, + interactions, + type AgentActivitySnapshot, + type IAgentScopeHandle, +} from '@moonshot-ai/agent-core-v2'; + +import { + toLegacyPhase, + type AgentPhase, + type LegacyActivityApproval, + type LegacyActivityLastTurn, +} from './legacyStatus'; + +export function legacyApprovalsOf(handle: IAgentScopeHandle): readonly LegacyActivityApproval[] { + return interactions + .findAll({ kind: 'approval', resolved: false, tags: { [INTERACTION_TAG_AGENT_ID]: handle.id } }) + .map((interaction) => ({ + approvalId: interaction.id, + toolCallId: (interaction.payload as { toolCallId?: string }).toolCallId ?? '', + since: interaction.createdAt, + })); +} + +export class LegacyActivityTracker { + private readonly toolSince = new Map(); + private lastTurn: LegacyActivityLastTurn | undefined; + private lastPhaseKey: string | undefined; + + constructor( + private readonly readSnapshot: () => AgentActivitySnapshot, + private readonly readApprovals: () => readonly LegacyActivityApproval[], + ) {} + + toolStarted(toolCallId: string): void { + this.toolSince.set(toolCallId, Date.now()); + } + + toolResult(toolCallId: string): void { + this.toolSince.delete(toolCallId); + } + + interrupted(event: { readonly turnId: number; readonly step: number; readonly reason: string }): AgentPhase | undefined { + if (event.reason !== 'aborted' && event.reason !== 'max_steps' && event.reason !== 'error') { + return undefined; + } + return this.emit({ + kind: 'interrupted', + turnId: event.turnId, + step: event.step, + reason: event.reason, + at: Date.now(), + }); + } + + turnEnded(event: { + readonly turnId: number; + readonly reason: LegacyActivityLastTurn['reason']; + readonly durationMs?: number; + }): AgentPhase | undefined { + this.toolSince.clear(); + this.lastTurn = { + turnId: event.turnId, + reason: event.reason, + durationMs: event.durationMs, + at: Date.now(), + }; + return this.emit({ + kind: 'ended', + turnId: event.turnId, + reason: event.reason, + durationMs: event.durationMs, + at: this.lastTurn.at, + }); + } + + recompute(): AgentPhase | undefined { + const snapshot = this.readSnapshot(); + const turn = snapshot.turn; + return this.emit( + toLegacyPhase({ + turn: + turn === undefined + ? undefined + : { + turnId: turn.turnId, + phase: turn.phase, + step: turn.step, + ending: turn.ending, + endingReason: turn.endingReason, + retry: turn.retry, + pendingApprovals: this.readApprovals(), + activeToolCalls: turn.activeToolCalls.map((call) => ({ + toolCallId: call.toolCallId, + name: call.name, + since: this.toolSince.get(call.toolCallId) ?? turn.since ?? Date.now(), + })), + since: turn.since ?? Date.now(), + }, + lastTurn: this.lastTurn, + }), + ); + } + + private emit(phase: AgentPhase | undefined): AgentPhase | undefined { + if (phase === undefined) return undefined; + const key = JSON.stringify(phase); + if (key === this.lastPhaseKey) return undefined; + this.lastPhaseKey = key; + return phase; + } +} + +export function phaseFromDomainEvent( + tracker: LegacyActivityTracker, + event: { readonly type: string; readonly toolCallId?: string; readonly turnId?: number; readonly step?: number; readonly reason?: string; readonly durationMs?: number }, +): AgentPhase | undefined { + switch (event.type) { + case 'turn.started': + case 'turn.step.started': + case 'turn.step.retrying': + case 'permission.approval.requested': + case 'permission.approval.resolved': + return tracker.recompute(); + case 'tool.call.started': + if (event.toolCallId !== undefined) tracker.toolStarted(event.toolCallId); + return tracker.recompute(); + case 'tool.result': + if (event.toolCallId !== undefined) tracker.toolResult(event.toolCallId); + return tracker.recompute(); + case 'turn.step.interrupted': + if (event.turnId === undefined || event.step === undefined || event.reason === undefined) { + return undefined; + } + return tracker.interrupted({ turnId: event.turnId, step: event.step, reason: event.reason }); + case 'turn.ended': + if (event.turnId === undefined || event.reason === undefined) return undefined; + return tracker.turnEnded({ + turnId: event.turnId, + reason: event.reason as LegacyActivityLastTurn['reason'], + durationMs: event.durationMs, + }); + default: + return undefined; + } +} diff --git a/packages/kap-server/src/services/legacyStatus/legacyStatus.ts b/packages/kap-server/src/services/legacyStatus/legacyStatus.ts new file mode 100644 index 0000000000000000000000000000000000000000..60d3fce2a0d54a1989ff489510486a431f907434 --- /dev/null +++ b/packages/kap-server/src/services/legacyStatus/legacyStatus.ts @@ -0,0 +1,233 @@ +import { + agentContextOf, + IAgentProfileService, + ISessionTokenCountingService, + ISessionUsageService, + IModelCatalog, + IModelService, + type IAgentScopeHandle, + type UsageStatus, +} from '@moonshot-ai/agent-core-v2'; +import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; + +export type AgentPhase = + | { readonly kind: 'idle' } + | { + readonly kind: 'running'; + readonly turnId: number; + readonly step: number; + readonly stepId: string; + readonly since: number; + } + | { + readonly kind: 'tool_call'; + readonly turnId: number; + readonly step: number; + readonly toolCallId: string; + readonly name: string; + readonly since: number; + } + | { + readonly kind: '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 statusCode?: number; + readonly since: number; + } + | { + readonly kind: 'awaiting_approval'; + readonly turnId: number; + readonly step?: number; + readonly approval?: unknown; + readonly since: number; + } + | { + readonly kind: 'interrupted'; + readonly turnId: number; + readonly step?: number; + readonly reason: 'aborted' | 'max_steps' | 'error'; + readonly message?: string; + readonly at: number; + } + | { + readonly kind: 'ended'; + readonly turnId: number; + readonly reason: TurnEndReason; + readonly durationMs?: number; + readonly at: number; + }; + +export interface LegacyActivityApproval { + readonly approvalId: string; + readonly toolCallId: string; + readonly since: number; +} + +export interface LegacyActivityToolCall { + readonly toolCallId: string; + readonly name: string; + readonly since: number; +} + +export interface LegacyActivityRetry { + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + readonly errorName?: string; + readonly statusCode?: number; +} + +export interface LegacyActivityTurn { + readonly turnId: number; + readonly phase: 'running' | 'tool_call' | 'retrying'; + readonly step: number; + readonly ending: boolean; + readonly endingReason?: 'aborted' | 'max_steps' | 'error'; + readonly retry?: LegacyActivityRetry; + readonly pendingApprovals: readonly LegacyActivityApproval[]; + readonly activeToolCalls: readonly LegacyActivityToolCall[]; + readonly since: number; +} + +export interface LegacyActivityLastTurn { + readonly turnId: number; + readonly reason: TurnEndReason; + readonly durationMs?: number; + readonly at: number; +} + +export interface LegacyActivitySnapshot { + readonly turn?: LegacyActivityTurn; + readonly lastTurn?: LegacyActivityLastTurn; +} + +export interface LegacyStatusSnapshot { + readonly usage?: UsageStatus; + readonly contextTokens: number; + readonly maxContextTokens?: number; + readonly model: string; +} + +export function readLegacyStatus(agent: IAgentScopeHandle): LegacyStatusSnapshot | undefined { + const profile = agent.accessor.get(IAgentProfileService) as + | IAgentProfileService + | undefined; + const usageService = agent.accessor.get(ISessionUsageService) as + | ISessionUsageService + | undefined; + const tokenCounting = agent.accessor.get(ISessionTokenCountingService) as + | ISessionTokenCountingService + | undefined; + if (profile === undefined || usageService === undefined || tokenCounting === undefined) { + return undefined; + } + const context = agentContextOf(agent); + const usage = usageService.status(context); + const contextTokens = tokenCounting.statusSize(context); + const capabilities = profile.getModelCapabilities(); + let maxContextTokens = capabilities.max_input_tokens ?? capabilities.max_context_tokens; + if (maxContextTokens === 0 && profile.getModel() === '') { + maxContextTokens = defaultModelContextTokens(agent) ?? 0; + } + const model = profile.getModel(); + return { + usage, + contextTokens, + maxContextTokens: maxContextTokens > 0 ? maxContextTokens : undefined, + model, + }; +} + +function defaultModelContextTokens(agent: IAgentScopeHandle): number | undefined { + const models = agent.accessor.get(IModelService) as IModelService | undefined; + const catalog = agent.accessor.get(IModelCatalog) as IModelCatalog | undefined; + const defaultModel = models?.getDefaultModel(); + if (defaultModel === undefined || defaultModel.length === 0 || catalog === undefined) { + return undefined; + } + try { + const capabilities = catalog.get(defaultModel).capabilities; + return capabilities.max_input_tokens ?? capabilities.max_context_tokens; + } catch { + return undefined; + } +} + +export function toLegacyPhase(state: LegacyActivitySnapshot): AgentPhase | undefined { + const { turn, lastTurn } = state; + + if (turn === undefined) { + if (lastTurn !== undefined) { + return { + kind: 'ended', + turnId: lastTurn.turnId, + reason: lastTurn.reason, + durationMs: lastTurn.durationMs, + at: lastTurn.at, + }; + } + return { kind: 'idle' }; + } + + if (turn.pendingApprovals.length > 0) { + const latest = turn.pendingApprovals[turn.pendingApprovals.length - 1]!; + return { + kind: 'awaiting_approval', + turnId: turn.turnId, + step: turn.step || undefined, + approval: { approvalId: latest.approvalId, toolCallId: latest.toolCallId }, + since: latest.since, + }; + } + if (turn.ending && turn.endingReason !== undefined) { + return { + kind: 'interrupted', + turnId: turn.turnId, + step: turn.step, + reason: turn.endingReason, + at: turn.since, + }; + } + switch (turn.phase) { + case 'running': + return { + kind: 'running', + turnId: turn.turnId, + step: turn.step, + stepId: '', + since: turn.since, + }; + case 'retrying': + return { + kind: 'retrying', + turnId: turn.turnId, + step: turn.step, + stepId: '', + failedAttempt: turn.retry?.failedAttempt ?? 0, + nextAttempt: turn.retry?.nextAttempt ?? 0, + maxAttempts: turn.retry?.maxAttempts ?? 0, + delayMs: turn.retry?.delayMs ?? 0, + errorName: turn.retry?.errorName, + statusCode: turn.retry?.statusCode, + since: turn.since, + }; + case 'tool_call': { + const latest = turn.activeToolCalls[turn.activeToolCalls.length - 1]; + return { + kind: 'tool_call', + turnId: turn.turnId, + step: turn.step, + toolCallId: latest?.toolCallId ?? '', + name: latest?.name ?? '', + since: latest?.since ?? turn.since, + }; + } + } +} diff --git a/packages/kap-server/src/services/messages/messageHistory.ts b/packages/kap-server/src/services/messages/messageHistory.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e99d0ffe5f000abfe352f07bd01173243468a22 --- /dev/null +++ b/packages/kap-server/src/services/messages/messageHistory.ts @@ -0,0 +1,183 @@ +import { + AGENT_WIRE_RECORD_KEY, + IAgentBlobService, + IAgentContextMemoryService, + IAgentScopeContext, + IAppendLogStore, + ISessionIndex, + IWireService, + createContextTranscriptReducer, + resumeSessionById, + type ContextMessage, + type ContextTranscript, + type IAgentScopeHandle, + type Scope, + type WireRecord, +} from '@moonshot-ai/agent-core-v2'; + +import { ensureMainAgent } from '../../transport/mainAgent'; +import type { Message, MessageRole } from '../../protocol/message'; +import { toProtocolMessage } from './messageProjection'; + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 100; + +export class SessionNotFoundError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super(`session ${sessionId} does not exist`); + this.name = 'SessionNotFoundError'; + this.sessionId = sessionId; + } +} + +export class MessageNotFoundError extends Error { + readonly sessionId: string; + readonly messageId: string; + constructor(sessionId: string, messageId: string) { + super(`message ${messageId} does not exist in session ${sessionId}`); + this.name = 'MessageNotFoundError'; + this.sessionId = sessionId; + this.messageId = messageId; + } +} + +export interface MessageListQuery { + readonly before_id?: string | undefined; + readonly after_id?: string | undefined; + readonly page_size?: number | undefined; + readonly role?: MessageRole | undefined; +} + +export interface PageResponse { + items: T[]; + has_more: boolean; +} + +export async function listMessages( + core: Scope, + sessionId: string, + query: MessageListQuery, +): Promise> { + const all = await loadMessages(core, sessionId); + const desc = [...all].reverse(); + + let pivotIndex = -1; + if (query.before_id !== undefined) { + pivotIndex = desc.findIndex((m) => m.id === query.before_id); + } else if (query.after_id !== undefined) { + pivotIndex = desc.findIndex((m) => m.id === query.after_id); + } + + let slice: Message[]; + if (query.before_id !== undefined && pivotIndex >= 0) { + slice = desc.slice(pivotIndex + 1); + } else if (query.after_id !== undefined && pivotIndex >= 0) { + slice = desc.slice(0, pivotIndex); + } else { + slice = desc; + } + + const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE; + const pageSize = Math.min(Math.max(requestedSize, 1), MAX_PAGE_SIZE); + const page = slice.slice(0, pageSize); + const hasMore = slice.length > pageSize; + + const filtered = query.role !== undefined ? page.filter((m) => m.role === query.role) : page; + + return { items: filtered, has_more: hasMore }; +} + +export async function getMessage( + core: Scope, + sessionId: string, + messageId: string, +): Promise { + const all = await loadMessages(core, sessionId); + const entry = all.find((m) => m.id === messageId); + if (entry === undefined) { + throw new MessageNotFoundError(sessionId, messageId); + } + return entry; +} + +async function loadMessages(core: Scope, sessionId: string): Promise { + const summary = await core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) { + throw new SessionNotFoundError(sessionId); + } + + const session = await resumeSessionById(core.accessor, sessionId); + if (session === undefined) return []; + const agent = await ensureMainAgent(session); + + return loadMessageHistory(core, agent, sessionId, summary.createdAt); +} + +export async function loadMessageHistory( + core: Scope, + agent: IAgentScopeHandle, + sessionId: string, + sessionCreatedAtMs: number, +): Promise { + const transcript = await readTranscript(core, agent); + const contextMessages = agent.accessor.get(IAgentContextMemoryService).get(); + const merged = mergeLiveTail(transcript, contextMessages); + const entries = await rehydrate(agent, merged.messages); + + let previousMs = Number.NEGATIVE_INFINITY; + return entries.map((msg, index) => { + const baseMs = merged.times[index] ?? sessionCreatedAtMs + index; + const createdAtMs = Math.max(previousMs + 1, baseMs); + previousMs = createdAtMs; + return toProtocolMessage(sessionId, index, msg, sessionCreatedAtMs, createdAtMs); + }); +} + +async function rehydrate( + agent: IAgentScopeHandle, + messages: readonly ContextMessage[], +): Promise { + const blobs = agent.accessor.get(IAgentBlobService); + let changed = false; + const out: ContextMessage[] = []; + for (const msg of messages) { + const content = await blobs.loadParts(msg.content); + if (content === msg.content) { + out.push(msg); + continue; + } + changed = true; + out.push({ ...msg, content: [...content] }); + } + return changed ? out : messages; +} + +async function readTranscript(core: Scope, agent: IAgentScopeHandle): Promise { + await agent.accessor.get(IWireService).flush(); + const scope = agent.accessor.get(IAgentScopeContext).scope(); + const reducer = createContextTranscriptReducer(); + for await (const record of core.accessor + .get(IAppendLogStore) + .read(scope, AGENT_WIRE_RECORD_KEY)) { + reducer.add(record); + } + return reducer.result(); +} + +function mergeLiveTail( + transcript: ContextTranscript, + contextMessages: readonly ContextMessage[], +): { + readonly messages: readonly ContextMessage[]; + readonly times: readonly (number | undefined)[]; +} { + if (contextMessages.length <= transcript.foldedLength) { + return { messages: transcript.entries, times: transcript.times }; + } + const tail = contextMessages.slice(transcript.foldedLength); + return { + messages: [...transcript.entries, ...tail], + times: [...transcript.times, ...tail.map(() => undefined)], + }; +} diff --git a/packages/kap-server/src/services/messages/messageProjection.ts b/packages/kap-server/src/services/messages/messageProjection.ts new file mode 100644 index 0000000000000000000000000000000000000000..86e1f20a027194dd9d652c589373f9dd19625240 --- /dev/null +++ b/packages/kap-server/src/services/messages/messageProjection.ts @@ -0,0 +1,147 @@ +import { daemonFileRefFromPart, parseDaemonFileUrl, type ContentPart, type ContextMessage } from '@moonshot-ai/agent-core-v2'; + +import type { Message, MessageContent, MessageRole, ToolUseContent } from '../../protocol/message'; + +function deriveMessageId(sessionId: string, index: number): string { + const padded = String(index).padStart(6, '0'); + return `msg_${sessionId}_${padded}`; +} + +function toProtocolRole(role: ContextMessage['role']): MessageRole { + return role as MessageRole; +} + +function mapContentPart(part: ContextMessage['content'][number]): MessageContent { + switch (part.type) { + case 'text': + return { type: 'text', text: part.text }; + case 'think': { + const sig = part.encrypted; + return sig !== undefined + ? { type: 'thinking', thinking: part.think, signature: sig } + : { type: 'thinking', thinking: part.think }; + } + case 'image_url': { + const ref = parseDaemonFileUrl(part.imageUrl.url); + return ref !== undefined + ? { type: 'image', source: { kind: 'session_media', file_id: ref.fileId }, name: part.imageUrl.name } + : { type: 'image', source: { kind: 'url', url: part.imageUrl.url, id: part.imageUrl.id }, name: part.imageUrl.name }; + } + case 'audio_url': + return { type: 'text', text: `[audio:${part.audioUrl.url}]` }; + case 'video_url': { + const ref = parseDaemonFileUrl(part.videoUrl.url); + return ref !== undefined + ? { type: 'video', source: { kind: 'session_media', file_id: ref.fileId }, name: part.videoUrl.name } + : { type: 'video', source: { kind: 'url', url: part.videoUrl.url, id: part.videoUrl.id }, name: part.videoUrl.name }; + } + } +} + +function buildProtocolContent(msg: ContextMessage): MessageContent[] { + const visibleContent = msg.content.filter((p) => p.type !== 'think' || p.hidden !== true); + if (msg.role === 'tool') { + if (msg.toolCallId === undefined) { + return visibleContent.map((p) => mapContentPart(p)); + } + const hasMediaPart = visibleContent.some( + (p) => p.type === 'image_url' || p.type === 'video_url' || p.type === 'audio_url', + ); + const output: unknown = hasMediaPart + ? visibleContent + : visibleContent.map((p) => (p.type === 'text' ? p.text : '')).join(''); + const part: MessageContent = + msg.isError === true + ? { + type: 'tool_result', + tool_call_id: msg.toolCallId, + output, + is_error: true, + } + : { + type: 'tool_result', + tool_call_id: msg.toolCallId, + output, + }; + return [part]; + } + + const base = visibleContent.map((p) => mapContentPart(p)); + + if (msg.role === 'assistant' && msg.toolCalls.length > 0) { + for (const call of msg.toolCalls) { + let parsedInput: unknown = call.arguments; + if (typeof call.arguments === 'string') { + try { + parsedInput = JSON.parse(call.arguments); + } catch { + parsedInput = call.arguments; + } + } + const part: ToolUseContent = { + type: 'tool_use', + tool_call_id: call.id, + tool_name: call.name, + input: parsedInput, + }; + base.push(part); + } + } + + return base; +} + +export function projectPromptContentParts(content: readonly ContentPart[]): MessageContent[] { + const parts: MessageContent[] = []; + for (const part of content) { + const daemonRef = daemonFileRefFromPart(part); + if (daemonRef !== undefined) { + parts.push({ + type: daemonRef.kind, + source: { kind: 'session_media', file_id: daemonRef.ref.fileId }, + name: + part.type === 'image_url' + ? part.imageUrl.name + : part.type === 'video_url' + ? part.videoUrl.name + : undefined, + }); + continue; + } + if (part.type === 'text') parts.push({ type: 'text', text: part.text }); + else if (part.type === 'image_url') { + const match = /^data:([^;]+);base64,(.*)$/.exec(part.imageUrl.url); + parts.push(match === null + ? { type: 'image', source: { kind: 'url', url: part.imageUrl.url, id: part.imageUrl.id }, name: part.imageUrl.name } + : { type: 'image', source: { kind: 'base64', media_type: match[1]!, data: match[2]! }, name: part.imageUrl.name }); + } else if (part.type === 'video_url') { + const match = /^data:([^;]+);base64,(.*)$/.exec(part.videoUrl.url); + parts.push(match === null + ? { type: 'video', source: { kind: 'url', url: part.videoUrl.url, id: part.videoUrl.id }, name: part.videoUrl.name } + : { type: 'video', source: { kind: 'base64', media_type: match[1]!, data: match[2]! }, name: part.videoUrl.name }); + } + } + return parts; +} + +export function toProtocolMessage( + sessionId: string, + index: number, + msg: ContextMessage, + sessionCreatedAtMs: number, + createdAtMsOverride?: number, +): Message { + const id = msg.id ?? deriveMessageId(sessionId, index); + const role = toProtocolRole(msg.role); + const content = buildProtocolContent(msg); + const createdAtMs = createdAtMsOverride ?? sessionCreatedAtMs + index; + const metadata = msg.origin !== undefined ? { origin: msg.origin } : undefined; + return { + id, + session_id: sessionId, + role, + content, + created_at: new Date(createdAtMs).toISOString(), + ...(metadata !== undefined ? { metadata } : {}), + }; +} diff --git a/packages/kap-server/src/services/modelCatalog/modelCatalogRefreshScheduler.ts b/packages/kap-server/src/services/modelCatalog/modelCatalogRefreshScheduler.ts new file mode 100644 index 0000000000000000000000000000000000000000..69892b96943e40b0b9bc880759dfb0f7729f553a --- /dev/null +++ b/packages/kap-server/src/services/modelCatalog/modelCatalogRefreshScheduler.ts @@ -0,0 +1,89 @@ +import { + type IConfigService, + type IProviderDiscoveryService, + type ModelCatalogConfig, + MODEL_CATALOG_SECTION, +} from '@moonshot-ai/agent-core-v2'; + +import type { ServerLogger } from '../pinoLoggerService'; + +const DEFAULT_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; +const INTERVAL_ENV = 'KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS'; +const REFRESH_ON_START_ENV = 'KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START'; + +export class ModelCatalogRefreshScheduler { + private timer: ReturnType | undefined; + private started = false; + private disposed = false; + + constructor( + private readonly discovery: IProviderDiscoveryService, + private readonly config: IConfigService, + private readonly logger: Pick, + private readonly env: NodeJS.ProcessEnv = process.env, + ) {} + + async start(): Promise { + if (this.started) return; + this.started = true; + + await this.config.ready; + if (this.disposed) return; + const catalogConfig = this.config.get(MODEL_CATALOG_SECTION); + const intervalMs = resolveIntervalMs(this.env, catalogConfig?.refreshIntervalMs); + const refreshOnStart = resolveRefreshOnStart(this.env, catalogConfig?.refreshOnStart); + + if (refreshOnStart) { + void this.refresh('startup'); + } + + if (intervalMs > 0) { + this.timer = setInterval(() => void this.refresh('interval'), intervalMs); + this.timer.unref?.(); + this.logger.info({ intervalMs }, 'provider-model catalog auto-refresh enabled'); + } + } + + dispose(): void { + this.disposed = true; + if (this.timer !== undefined) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + private async refresh(trigger: 'startup' | 'interval'): Promise { + try { + const result = await this.discovery.refreshProviderModels({ scope: 'all' }); + if (result.failed.length > 0) { + this.logger.warn( + { trigger, failed: result.failed }, + 'provider-model catalog refresh completed with failures', + ); + } + } catch (error) { + this.logger.warn( + { trigger, err: error instanceof Error ? error.message : String(error) }, + 'provider-model catalog refresh failed', + ); + } + } +} + +function resolveIntervalMs(env: NodeJS.ProcessEnv, configValue: number | undefined): number { + const raw = env[INTERVAL_ENV]; + if (raw !== undefined && raw.trim().length > 0) { + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed >= 0) return parsed; + } + return configValue ?? DEFAULT_REFRESH_INTERVAL_MS; +} + +function resolveRefreshOnStart(env: NodeJS.ProcessEnv, configValue: boolean | undefined): boolean { + const raw = env[REFRESH_ON_START_ENV]; + if (raw !== undefined && raw.trim().length > 0) { + const normalized = raw.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes'; + } + return configValue ?? true; +} diff --git a/packages/kap-server/src/services/pinoLoggerService.ts b/packages/kap-server/src/services/pinoLoggerService.ts new file mode 100644 index 0000000000000000000000000000000000000000..39d9a28ae6d88310b0b94a23b947093fa6508a04 --- /dev/null +++ b/packages/kap-server/src/services/pinoLoggerService.ts @@ -0,0 +1,19 @@ +import { pino, type DestinationStream, type Logger, type LoggerOptions } from 'pino'; + +export type ServerLogger = Logger; + +export type ServerLogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'; + +export interface CreateLoggerOptions { + level: ServerLogLevel; + stream?: DestinationStream; +} + +export function createServerLogger(opts: CreateLoggerOptions): ServerLogger { + const base: LoggerOptions = { + level: opts.level, + base: { name: 'kimi-server-v2' }, + timestamp: pino.stdTimeFunctions.isoTime, + }; + return opts.stream === undefined ? pino(base) : pino(base, opts.stream); +} diff --git a/packages/kap-server/src/services/projection/agentProjector.ts b/packages/kap-server/src/services/projection/agentProjector.ts new file mode 100644 index 0000000000000000000000000000000000000000..9587a950696842ca1bef8148b92d10f09ca0f9cc --- /dev/null +++ b/packages/kap-server/src/services/projection/agentProjector.ts @@ -0,0 +1,2505 @@ +import { + daemonFileRefFromPart, + readTodoItems, + type AgentTaskInfo, + type ContentPart, + type TokenUsage, +} from '@moonshot-ai/agent-core-v2'; + +import type { + AssistantMessage, + ContentPart as WireContentPart, + InteractionMessage, + ServerMessage, + StepMessage, + StepRetry, + StepTiming, + StepUsage, + SystemMessage, + TaskMessage, + TaskNotificationPayload, + ThinkingMessage, + TodoMessage, + ToolCallAgentRef, + ToolCallMessage, + ToolProgressPayload, + TurnMessage, + TurnOrigin, + UserMessage, + UserMessageOrigin, +} from '../../protocol/messages'; +import { PROJECTION_IGNORED_EVENT_TYPES, type ProjectionBusEvent } from './events'; +import type { WireTurnFold } from './heal'; +import { + SystemIdAllocator, + TODO_ENTITY_ID, + attachmentIdOf, + isCompactionSystemId, + isUndoAnchorOrigin, + stepIdOf, + textMessageIdOf, + turnIdOf, + turnOrdinalOf, + turnUserMessageIdOf, +} from './ids'; + +const TASK_OUTPUT_TAIL_MAX = 8192; +const PENDING_CLEAR_SETTLE_MS = 100; + +export interface ProjectorInteraction { + readonly id: string; + readonly kind: 'approval' | 'question'; + readonly payload: unknown; + readonly createdAt: number; +} + +export interface ProjectorLookups { + readonly stepOrdinal?: (turnId: string) => number | undefined; + readonly resolvePlanRevisionKey?: (key: string) => string; +} + +export interface ProjectorHooks { + readonly onUnknownEvent?: (type: string) => void; + readonly onDeferred?: (messages: ServerMessage[]) => void; +} + +interface TurnRecord { + turnId: string; + ordinal: number; + status: 'running' | 'completed'; + origin: TurnOrigin; + anchor: boolean; + promptId?: string; + userMessageId?: string; + attachmentIds?: string[]; + openingKey?: { text: string; attachments: number }; + openingSteerDeduped: boolean; + startedAt?: string; + endedAt?: string; + durationMs?: number; + usage?: StepUsage; +} + +interface StepRecord { + stepId: string; + turnId: string; + ordinal: number; + status: 'running' | 'completed' | 'interrupted' | 'failed'; + startedAt?: string; + endedAt?: string; + usage?: StepUsage; + finishReason?: string; + timing?: StepTiming; + retry?: StepRetry; + endReason?: string; + endMessage?: string; +} + +interface TextRecord { + messageId: string; + kind: 'assistant' | 'thinking'; + turnId: string; + stepId: string; + status: 'streaming' | 'completed'; + text: string; +} + +interface ToolRecord { + toolCallId: string; + turnId: string; + stepId: string; + name: string; + status: 'running' | 'done' | 'error'; + input?: unknown; + inputText?: string; + output?: unknown; + display?: unknown; + error?: string; + progress?: ToolProgressPayload; + taskId?: string; + approvalId?: string; + todoId?: string; + agentRefs: ToolCallAgentRef[]; + startedAt?: string; +} + +interface TaskRecord { + taskId: string; + kind: TaskMessage['kind']; + status: TaskMessage['status']; + detached: boolean; + description?: string; + childAgentId?: string; + outputTail: string; + startedAt?: string; + endedAt?: string; + resultSummary?: string; + error?: string; + stateReason?: string; + usage?: StepUsage; + model?: string; + thinkingEffort?: string; +} + +interface InteractionRecord { + interactionId: string; + kind: 'approval' | 'question'; + status: InteractionMessage['status']; + toolCallId?: string; + request?: unknown; + response?: unknown; +} + +interface UserRecord { + messageId: string; + turnId?: string; + text: WireContentPart[]; + status: 'unread' | 'read'; + timestamp?: number; + origin?: UserMessageOrigin; + attachmentIds?: string[]; + skillActivations?: { skill_name: string; skill_args?: string }[]; +} + +interface PromptRecord { + promptId: string; + userMessageId: string; + content: readonly ContentPart[]; + status: 'running' | 'queued' | 'steered' | 'completed' | 'aborted'; + createdAt: string; +} + +export class AgentMessageProjector { + private currentTurn: TurnRecord | undefined; + private currentStep: StepRecord | undefined; + private openText: TextRecord | undefined; + private openThinking: TextRecord | undefined; + private serverUserSeq = 0; + private attachmentSeq = 0; + private phantomUserSeq = 0; + private readonly turns = new Map(); + private readonly steps = new Map(); + private readonly texts = new Map(); + private readonly stepTextIds = new Map(); + private readonly stepTextSeqs = new Map(); + private readonly tools = new Map(); + private readonly tasks = new Map(); + private readonly shellTasks = new Map(); + private readonly interactions = new Map(); + private readonly users = new Map(); + private readonly prompts = new Map(); + private readonly stepOrdinals = new Map(); + private readonly stepUsageByTurn = new Map(); + private mergedSteers: { text: string; promptIds: string[] }[] = []; + private pendingFullCut = false; + private pendingClearTimer: NodeJS.Timeout | undefined; + private todoItems: { title: string; status: 'pending' | 'in_progress' | 'done' }[] | undefined; + private todoUpdatedAt: string | undefined; + private planMode = false; + private swarmMode = false; + private readonly timelineIds: string[] = []; + private readonly sysIds = new SystemIdAllocator(); + private readonly endedTurnOrdinals: number[] = []; + private readonly anchorTurnOrdinals = new Set(); + private timelineRewriteCount = 0; + private nextTurnIdHint = 0; + + constructor( + readonly agentId: string, + private readonly sessionId: string, + private readonly subagentTaskIds: Map, + private readonly lookups?: ProjectorLookups, + private readonly hooks?: ProjectorHooks, + ) {} + + map(event: ProjectionBusEvent): ServerMessage[] { + switch (event.type) { + case 'plan.revision': + return this.onPlanRevision(event); + case 'turn.started': + return this.onTurnStarted(event); + case 'turn.ended': + return this.onTurnEnded(event); + case 'turn.step.started': + return this.onStepStarted(event); + case 'turn.step.completed': + return this.onStepCompleted(event); + case 'turn.step.interrupted': + return this.onStepInterrupted(event); + case 'turn.step.retrying': + return this.onStepRetrying(event); + case 'assistant.delta': + return this.onTextDelta(event, 'assistant'); + case 'thinking.delta': + return this.onTextDelta(event, 'thinking'); + case 'tool.call.delta': + return this.onToolCallDelta(event); + case 'tool.progress': + return this.onToolProgress(event); + case 'tool.call.started': + return this.onToolCallStarted(event); + case 'tool.result': + return this.onToolResult(event); + case 'task.started': + case 'task.terminated': + return this.onTaskLifecycle(event); + case 'shell.started': + return this.onShellStarted(event); + case 'shell.output': + return this.onShellOutput(event); + case 'shell.completed': + return this.onShellCompleted(event); + case 'subagent.spawned': + return this.onSubagentSpawned(event); + case 'subagent.completed': + case 'subagent.failed': + case 'subagent.cancelled': + case 'subagent.suspended': + return this.onSubagentRun(event); + case 'goal.updated': + return this.onGoalUpdated(event); + case 'agent.status.updated': + return this.onAgentStatusUpdated(event); + case 'prompt.submitted': + return this.onPromptSubmitted(event); + case 'prompt.queued': + return this.onPromptQueued(event); + case 'prompt.started': + return this.onPromptStarted(event); + case 'prompt.completed': + return this.onPromptCompleted(event); + case 'prompt.aborted': + return this.onPromptAborted(event); + case 'prompt.steered': + return this.onPromptSteered(event); + case 'turn.steer': + return this.onTurnSteered(event); + case 'hook.result': + return [this.systemOp('hook', hookPayload(event), event.time)]; + case 'skill.activated': + case 'plugin_command.activated': + return []; + case 'compaction.started': + case 'compaction.blocked': + case 'compaction.cancelled': + return []; + case 'compaction.completed': { + const result = event.result; + const text = + result.summary.length > 0 ? result.summary : result.contextSummary; + return [ + this.systemOp( + 'compaction', + { phase: 'completed', text: text !== undefined && text.length > 0 ? text : undefined }, + event.time, + ), + ]; + } + case 'context.spliced': + return this.onContextSpliced(event); + case 'context.undone': + return this.onContextUndone(event); + case 'error': + return [ + this.systemOp( + 'notice', + { level: 'error', message: event.message, ...restOf(event) }, + event.time, + ), + ]; + case 'warning': + return [ + this.systemOp( + 'notice', + { level: 'warning', message: event.message, code: event.code }, + event.time, + ), + ]; + case 'cron.fired': + case 'permission.approval.requested': + case 'permission.approval.resolved': + case 'subagent.started': + return []; + case 'task.notified': + return this.onTaskNotified(event); + default: { + const type = (event as { type: string }).type; + if (PROJECTION_IGNORED_EVENT_TYPES.has(type)) return []; + this.hooks?.onUnknownEvent?.(type); + return []; + } + } + } + + seedActiveTurn(info: { + turnId: number; + promptId?: string; + userMessageId?: string; + origin?: TurnOrigin; + anchor?: boolean; + }): void { + const turnId = turnIdOf(info.turnId); + this.noteTurnId(info.turnId); + if (info.anchor === true) this.anchorTurnOrdinals.add(info.turnId); + this.currentTurn = { + turnId, + ordinal: info.turnId, + status: 'running', + origin: info.origin ?? { kind: 'other' }, + anchor: info.anchor === true, + promptId: info.promptId, + userMessageId: + info.promptId === undefined + ? undefined + : (info.userMessageId ?? turnUserMessageIdOf(turnId)), + openingSteerDeduped: false, + }; + this.turns.set(turnId, this.currentTurn); + this.timelineIds.push(turnId); + } + + seedTask(info: AgentTaskInfo): ServerMessage[] { + if (info.status !== 'running') return []; + const agentInfo = agentInfoOf(info); + const kind = mapTaskKind(info.kind); + const task = this.upsertTask(info.taskId, (prev) => ({ + taskId: info.taskId, + kind, + status: 'running', + detached: info.detached ?? prev?.detached ?? kind !== 'shell', + description: info.description, + childAgentId: agentInfo?.agentId ?? prev?.childAgentId, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? epochMsToIso(info.startedAt), + model: agentInfo?.model ?? prev?.model, + thinkingEffort: agentInfo?.thinkingEffort ?? prev?.thinkingEffort, + })); + const childAgentId = agentInfo?.agentId; + if (info.kind === 'agent' && typeof childAgentId === 'string' && childAgentId.length > 0) { + this.subagentTaskIds.set(childAgentId, info.taskId); + } + return [this.taskOp(task)]; + } + + seedTodo( + items: readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[], + ): ServerMessage[] { + if (items.length === 0) return []; + this.todoItems = items.map((item) => ({ title: item.title, status: item.status })); + this.todoUpdatedAt = undefined; + return [this.todoOp()]; + } + + taskOutputUpdated(taskId: string, outputTail: string): ServerMessage[] { + const task = this.tasks.get(taskId); + if (task === undefined || task.outputTail === outputTail) return []; + task.outputTail = outputTail; + return [this.taskOp(task)]; + } + + seedModes(modes: { planMode?: boolean; swarmMode?: boolean }): void { + if (modes.planMode !== undefined) this.planMode = modes.planMode; + if (modes.swarmMode !== undefined) this.swarmMode = modes.swarmMode; + } + + todoChanged( + items: readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[], + ): ServerMessage[] { + this.todoItems = items.map((item) => ({ title: item.title, status: item.status })); + this.todoUpdatedAt = nowIso(); + return [this.todoOp()]; + } + + interactionRequested(interaction: ProjectorInteraction): ServerMessage[] { + const payload = interaction.payload as Record | null; + const toolCallId = + typeof payload?.['toolCallId'] === 'string' ? payload['toolCallId'] : undefined; + const record: InteractionRecord = { + interactionId: interaction.id, + kind: interaction.kind, + status: 'pending', + toolCallId, + request: this.wireInteractionRequest(interaction), + }; + this.interactions.set(interaction.id, record); + const ops: ServerMessage[] = [this.interactionOp(record)]; + if (toolCallId !== undefined) { + const tool = this.tools.get(toolCallId); + if (tool !== undefined && tool.approvalId !== interaction.id) { + tool.approvalId = interaction.id; + ops.push(this.toolOp(tool)); + } + } + return ops; + } + + interactionResolved(id: string, response: unknown): ServerMessage[] { + const record = this.interactions.get(id); + if (record === undefined) return []; + record.status = mapInteractionEndStatus(record.kind, response); + record.response = this.wireInteractionResponse(record, response); + return [this.interactionOp(record)]; + } + + recoveryMessages(): ServerMessage[] { + const ops: ServerMessage[] = []; + const turn = this.currentTurn; + if (turn !== undefined && turn.status === 'running') { + ops.push(this.turnOp(turn)); + const step = this.currentStep; + const replayStepId = + step !== undefined && step.turnId === turn.turnId ? step.stepId : undefined; + if (step !== undefined && replayStepId !== undefined) { + ops.push(this.stepOp(step)); + for (const record of this.texts.values()) { + if (record.stepId === replayStepId) ops.push(this.textOp(record)); + } + } + for (const tool of this.tools.values()) { + if (tool.turnId !== turn.turnId) continue; + if (tool.status === 'running' || tool.stepId === replayStepId) ops.push(this.toolOp(tool)); + } + } + for (const record of this.interactions.values()) { + if (record.status === 'pending') ops.push(this.interactionOp(record)); + } + for (const task of this.tasks.values()) { + if (task.status === 'running') ops.push(this.taskOp(task)); + } + for (const user of this.users.values()) { + if (user.status === 'unread') ops.push(this.userOp(user)); + } + if (this.todoItems !== undefined) ops.push(this.todoOp()); + return ops; + } + + notifyContextCleared(): ServerMessage[] { + this.cancelPendingClearTimer(); + this.pendingFullCut = false; + return this.applyClear(); + } + + applyTimelineSeed(seed: { + timelineIds: readonly string[]; + systemCounts: ReadonlyMap; + anchorTurnOrdinals: readonly number[]; + nextTurnId: number; + }): void { + if (this.timelineRewriteCount > 0) return; + const existing = new Set(this.timelineIds); + this.timelineIds.unshift(...seed.timelineIds.filter((id) => !existing.has(id))); + for (const [subtype, count] of seed.systemCounts) this.sysIds.seed(subtype, count); + for (const ordinal of seed.anchorTurnOrdinals) this.anchorTurnOrdinals.add(ordinal); + this.nextTurnIdHint = Math.max(this.nextTurnIdHint, seed.nextTurnId); + } + + dispose(): void { + this.cancelPendingClearTimer(); + } + + private noteTurnId(turnId: number): void { + this.nextTurnIdHint = Math.max(this.nextTurnIdHint, turnId + 1); + } + + takeEndedTurnOrdinals(): number[] { + return this.endedTurnOrdinals.splice(0); + } + + healTurn(ordinal: number, fold: WireTurnFold): ServerMessage[] { + const turnId = turnIdOf(ordinal); + const held = this.turns.get(turnId); + if (held?.status !== 'completed') return []; + const ops: ServerMessage[] = []; + const stepOrdinals = new Set([...fold.steps.keys(), ...fold.texts.keys()]); + for (const wireTool of fold.tools.values()) stepOrdinals.add(wireTool.step); + for (const stepOrdinal of [...stepOrdinals].toSorted((a, b) => a - b)) { + const wireStep = fold.steps.get(stepOrdinal); + const stepId = stepIdOf(turnId, stepOrdinal); + const live = this.steps.get(stepId); + if (live === undefined) { + const step: StepRecord = { + stepId, + turnId, + ordinal: stepOrdinal, + status: wireStep?.status ?? 'interrupted', + endedAt: wireStep?.endedAt, + usage: wireStep?.usage, + finishReason: wireStep?.finishReason, + timing: wireStep?.timing, + endReason: wireStep?.endReason, + endMessage: wireStep?.endMessage, + }; + this.steps.set(stepId, step); + this.stepOrdinals.set(turnId, Math.max(this.stepOrdinals.get(turnId) ?? 0, stepOrdinal)); + ops.push(this.stepOp(step)); + } else if (live.status === 'running' && wireStep !== undefined) { + live.status = wireStep.status; + live.endedAt = wireStep.endedAt; + live.usage = live.usage ?? wireStep.usage; + live.finishReason = live.finishReason ?? wireStep.finishReason; + live.timing = live.timing ?? wireStep.timing; + live.endReason = live.endReason ?? wireStep.endReason; + live.endMessage = live.endMessage ?? wireStep.endMessage; + ops.push(this.stepOp(live)); + } + const wireTexts = fold.texts.get(stepOrdinal); + if (wireTexts !== undefined) { + ops.push(...this.healStepTexts(stepId, turnId, wireTexts)); + } + } + for (const [toolCallId, wireTool] of fold.tools) { + const live = this.tools.get(toolCallId); + const stepId = stepIdOf(turnId, wireTool.step); + if (live === undefined) { + const tool: ToolRecord = { + toolCallId, + turnId, + stepId, + name: wireTool.name, + status: wireTool.isError === true ? 'error' : 'done', + input: parseToolArgs(wireTool.args), + inputText: typeof wireTool.args === 'string' ? wireTool.args : undefined, + output: wireTool.output, + error: + wireTool.isError === true && typeof wireTool.output === 'string' + ? wireTool.output + : undefined, + agentRefs: [], + }; + this.tools.set(toolCallId, tool); + ops.push(this.toolOp(tool)); + continue; + } + const liveHasOutcome = + live.output !== undefined || live.error !== undefined || live.status !== 'running'; + const wireHasOutcome = wireTool.output !== undefined || wireTool.isError === true; + if (liveHasOutcome || !wireHasOutcome) continue; + live.status = wireTool.isError === true ? 'error' : 'done'; + live.output = wireTool.output; + live.error = + wireTool.isError === true && typeof wireTool.output === 'string' + ? wireTool.output + : undefined; + ops.push(this.toolOp(live)); + } + this.dropTurnDetails(turnId); + return ops; + } + + inFlight(): { turn_id: string; step_id: string } | undefined { + const turn = this.currentTurn; + const step = this.currentStep; + if (turn === undefined || step === undefined) return undefined; + if (turn.status !== 'running' || step.turnId !== turn.turnId) return undefined; + return { turn_id: turn.turnId, step_id: step.stepId }; + } + + private healStepTexts( + stepId: string, + turnId: string, + wireTexts: { assistant: string; thinking: string; first: 'assistant' | 'thinking' }, + ): ServerMessage[] { + const ops: ServerMessage[] = []; + const kinds: readonly ('assistant' | 'thinking')[] = + wireTexts.first === 'thinking' ? ['thinking', 'assistant'] : ['assistant', 'thinking']; + for (const kind of kinds) { + const wireText = kind === 'assistant' ? wireTexts.assistant : wireTexts.thinking; + const liveId = this.stepTextIds.get(stepId)?.[kind]; + const live = liveId === undefined ? undefined : this.texts.get(liveId); + if (live === undefined) { + if (wireText.length === 0) continue; + const record = this.createTextRecord(stepId, turnId, kind); + record.text = wireText; + record.status = 'completed'; + ops.push(this.textOp(record)); + continue; + } + if (wireText.length > live.text.length) { + live.text = wireText; + live.status = 'completed'; + ops.push(this.textOp(live)); + } + } + return ops; + } + + private onTurnStarted(event: { + time: number; + turnId: number; + promptId?: string; + origin: unknown; + prompt?: string; + promptAttachments?: readonly unknown[]; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + if (this.currentTurn !== undefined && this.currentTurn.status === 'running') { + ops.push(...this.finalizeTurn(this.currentTurn, event.time)); + } + const turnId = turnIdOf(event.turnId); + this.noteTurnId(event.turnId); + this.serverUserSeq = this.phantomUserSeq; + this.phantomUserSeq = 0; + const origin = this.mapTurnOrigin(event.origin); + const attachments = event.promptAttachments ?? []; + const attachmentIds = attachments.map((_, index) => attachmentIdOf(turnId, index + 1)); + this.attachmentSeq = attachmentIds.length; + const promptRecord = event.promptId === undefined ? undefined : this.prompts.get(event.promptId); + const promptText = + event.prompt ?? + (promptRecord === undefined ? undefined : promptTextOf(promptRecord.content)); + const wantsUser = wantsUserMessage(event.origin, promptText); + const anchor = isUndoAnchorOrigin(event.origin); + if (anchor) this.anchorTurnOrdinals.add(event.turnId); + const turn: TurnRecord = { + turnId, + ordinal: event.turnId, + status: 'running', + origin, + anchor, + promptId: event.promptId, + userMessageId: wantsUser + ? (promptRecord?.userMessageId ?? turnUserMessageIdOf(turnId)) + : undefined, + attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, + openingKey: { text: promptText ?? '', attachments: attachmentIds.length }, + openingSteerDeduped: false, + startedAt: epochMsToIso(event.time), + }; + this.currentTurn = turn; + this.turns.set(turnId, turn); + this.timelineIds.push(turnId); + this.currentStep = undefined; + this.openText = undefined; + this.openThinking = undefined; + ops.push(this.turnOp(turn)); + if (wantsUser && turn.userMessageId !== undefined) { + const user: UserRecord = { + messageId: turn.userMessageId, + turnId, + text: + promptRecord === undefined + ? textPartsOf(promptText ?? '') + : wireContentParts(promptRecord.content), + status: 'read', + timestamp: event.time, + origin: userOriginOf(event.origin), + attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, + skillActivations: skillActivationsOf(event.origin), + }; + this.users.set(user.messageId, user); + ops.push(this.userOp(user)); + } + return ops; + } + + private onTurnEnded(event: { + time: number; + turnId: number; + reason: 'completed' | 'cancelled' | 'failed' | 'blocked'; + durationMs?: number; + interruptReason?: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const turnId = turnIdOf(event.turnId); + const turn = this.currentTurn?.turnId === turnId ? this.currentTurn : this.turns.get(turnId); + if (turn === undefined) return ops; + ops.push(...this.finalizeTurn(turn, event.time, event.reason, event.durationMs)); + this.currentStep = undefined; + if (this.currentTurn?.turnId === turnId) this.currentTurn = undefined; + this.endedTurnOrdinals.push(event.turnId); + if (event.reason === 'cancelled' && event.interruptReason === 'user_cancelled') { + ops.push( + this.systemOp( + 'interruption', + { turn_id: turnId, reason: event.interruptReason }, + event.time, + ), + ); + } + return ops; + } + + private finalizeTurn( + turn: TurnRecord, + time: number, + reason?: 'completed' | 'cancelled' | 'failed' | 'blocked', + durationMs?: number, + ): ServerMessage[] { + const ops = this.flushOpenTexts(); + const turnId = turn.turnId; + if (this.currentStep !== undefined && this.currentStep.turnId === turnId) { + const step = this.currentStep; + if (step.status === 'running') { + step.status = reason === 'failed' || reason === 'blocked' ? 'failed' : 'interrupted'; + step.endedAt = epochMsToIso(time); + ops.push(this.stepOp(step)); + } + } + turn.status = 'completed'; + turn.endedAt = epochMsToIso(time); + turn.durationMs = durationMs; + turn.usage = this.takeTurnUsage(turnId); + ops.push(this.turnOp(turn)); + return ops; + } + + private takeTurnUsage(turnId: string): StepUsage | undefined { + const usages = this.stepUsageByTurn.get(turnId); + this.stepUsageByTurn.delete(turnId); + if (usages === undefined || usages.length === 0) return undefined; + let inputOther = 0; + let output = 0; + let inputCacheRead = 0; + let inputCacheCreation = 0; + for (const usage of usages) { + inputOther += usage.input_other; + output += usage.output; + inputCacheRead += usage.input_cache_read; + inputCacheCreation += usage.input_cache_creation; + } + return { + input_other: inputOther, + output, + input_cache_read: inputCacheRead, + input_cache_creation: inputCacheCreation, + }; + } + + private onStepStarted(event: { time: number; turnId: number; step: number }): ServerMessage[] { + const ops = this.settlePendingClear(); + const turnId = turnIdOf(event.turnId); + if (this.currentStep !== undefined && this.currentStep.status === 'running') { + ops.push(...this.flushOpenTexts()); + this.currentStep.status = 'completed'; + this.currentStep.endedAt = epochMsToIso(event.time); + ops.push(this.stepOp(this.currentStep)); + } + const stepId = stepIdOf(turnId, event.step); + this.stepOrdinals.set(turnId, event.step); + const step: StepRecord = { + stepId, + turnId, + ordinal: event.step, + status: 'running', + startedAt: epochMsToIso(event.time), + }; + this.currentStep = step; + this.steps.set(stepId, step); + this.openText = undefined; + this.openThinking = undefined; + ops.push(this.stepOp(step)); + return ops; + } + + private onStepCompleted(event: { + time: number; + turnId: number; + step: number; + usage?: TokenUsage; + finishReason?: string; + rawFinishReason?: string; + providerFinishReason?: string; + llmFirstTokenLatencyMs?: number; + llmStreamDurationMs?: number; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + ops.push(...this.flushOpenTexts()); + const turnId = turnIdOf(event.turnId); + const stepId = stepIdOf(turnId, event.step); + const prev = this.currentStep?.stepId === stepId ? this.currentStep : this.steps.get(stepId); + const usage = event.usage === undefined ? undefined : toSnakeUsage(event.usage); + if (usage !== undefined) { + const usages = this.stepUsageByTurn.get(turnId) ?? []; + usages.push(usage); + this.stepUsageByTurn.set(turnId, usages); + } + const step: StepRecord = { + stepId, + turnId, + ordinal: event.step, + status: 'completed', + startedAt: prev?.startedAt, + endedAt: epochMsToIso(event.time), + usage, + finishReason: event.finishReason ?? event.rawFinishReason ?? event.providerFinishReason, + timing: timingOf(event), + }; + this.currentStep = step; + this.steps.set(stepId, step); + ops.push(this.stepOp(step)); + return ops; + } + + private onStepInterrupted(event: { + time: number; + turnId: number; + step: number; + reason: string; + message?: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + ops.push(...this.flushOpenTexts()); + const turnId = turnIdOf(event.turnId); + const stepId = stepIdOf(turnId, event.step); + const prev = this.currentStep?.stepId === stepId ? this.currentStep : this.steps.get(stepId); + const step: StepRecord = { + stepId, + turnId, + ordinal: event.step, + status: 'interrupted', + startedAt: prev?.startedAt, + endedAt: epochMsToIso(event.time), + endReason: event.reason, + endMessage: event.message, + }; + this.currentStep = step; + this.steps.set(stepId, step); + ops.push(this.stepOp(step)); + return ops; + } + + private onStepRetrying(event: { + turnId: number; + step: number; + failedAttempt: number; + nextAttempt: number; + maxAttempts: number; + delayMs: number; + errorName: string; + errorMessage: string; + statusCode?: number; + }): ServerMessage[] { + const turnId = turnIdOf(event.turnId); + const stepId = stepIdOf(turnId, event.step); + const prev = this.currentStep?.stepId === stepId ? this.currentStep : this.steps.get(stepId); + const step: StepRecord = { + stepId, + turnId, + ordinal: event.step, + status: 'running', + startedAt: prev?.startedAt, + retry: { + failed_attempt: event.failedAttempt, + next_attempt: event.nextAttempt, + max_attempts: event.maxAttempts, + delay_ms: event.delayMs, + error_name: event.errorName, + error_message: event.errorMessage, + status_code: event.statusCode, + }, + }; + this.currentStep = step; + this.steps.set(stepId, step); + return [this.stepOp(step)]; + } + + private onTextDelta( + event: { time: number; turnId: number; delta: string }, + kind: 'assistant' | 'thinking', + ): ServerMessage[] { + if (kind === 'thinking' && event.delta.length === 0) return []; + const ops = this.settlePendingClear(); + const turnId = turnIdOf(event.turnId); + this.ensureTurn(turnId, event.time, ops); + const step = this.ensureStep(turnId, event.time, ops); + let open = kind === 'assistant' ? this.openText : this.openThinking; + if (open === undefined || open.stepId !== step.stepId) { + open = this.createTextRecord(step.stepId, turnId, kind); + if (kind === 'assistant') this.openText = open; + else this.openThinking = open; + ops.push(this.textOp(open)); + } + open.text += event.delta; + ops.push(this.textDeltaOp(open, event.delta)); + return ops; + } + + private flushOpenTexts(): ServerMessage[] { + const ops: ServerMessage[] = []; + for (const open of [this.openText, this.openThinking]) { + if (open === undefined) continue; + open.status = 'completed'; + ops.push(this.textOp(open)); + } + this.openText = undefined; + this.openThinking = undefined; + return ops; + } + + private ensureTurn(turnId: string, time: number, ops: ServerMessage[]): TurnRecord { + if (this.currentTurn !== undefined && this.currentTurn.turnId === turnId) { + return this.currentTurn; + } + const ordinal = turnOrdinalOf(turnId) ?? 0; + this.noteTurnId(ordinal); + const turn: TurnRecord = { + turnId, + ordinal, + status: 'running', + origin: { kind: 'other' }, + anchor: false, + openingSteerDeduped: false, + startedAt: epochMsToIso(time), + }; + this.currentTurn = turn; + this.turns.set(turnId, turn); + this.timelineIds.push(turnId); + ops.push(this.turnOp(turn)); + return turn; + } + + private ensureStep(turnId: string, time: number, ops: ServerMessage[]): StepRecord { + if (this.currentStep !== undefined && this.currentStep.turnId === turnId) { + return this.currentStep; + } + const ordinal = this.lookups?.stepOrdinal?.(turnId) ?? this.stepOrdinals.get(turnId) ?? 1; + const step: StepRecord = { + stepId: stepIdOf(turnId, ordinal), + turnId, + ordinal, + status: 'running', + startedAt: epochMsToIso(time), + }; + this.stepOrdinals.set(turnId, ordinal); + this.currentStep = step; + this.steps.set(step.stepId, step); + ops.push(this.stepOp(step)); + return step; + } + + private onToolCallDelta(event: { + time: number; + turnId: number; + toolCallId: string; + name?: string; + argumentsPart?: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const prev = this.tools.get(event.toolCallId); + if (prev !== undefined) { + prev.inputText = (prev.inputText ?? '') + (event.argumentsPart ?? ''); + ops.push(this.toolDeltaOp(event.toolCallId, event.argumentsPart ?? '')); + return ops; + } + const turnId = turnIdOf(event.turnId); + this.ensureTurn(turnId, event.time, ops); + const step = this.ensureStep(turnId, event.time, ops); + const tool: ToolRecord = { + toolCallId: event.toolCallId, + turnId, + stepId: step.stepId, + name: event.name ?? '', + status: 'running', + inputText: event.argumentsPart ?? '', + agentRefs: [], + startedAt: epochMsToIso(event.time), + }; + this.tools.set(event.toolCallId, tool); + ops.push(this.toolOp(tool)); + if ((event.argumentsPart ?? '').length > 0) { + ops.push(this.toolDeltaOp(event.toolCallId, event.argumentsPart ?? '')); + } + return ops; + } + + private onToolProgress(event: { + toolCallId: string; + update: { + kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom'; + text?: string; + percent?: number; + customKind?: string; + customData?: unknown; + }; + }): ServerMessage[] { + const tool = this.tools.get(event.toolCallId); + if (tool === undefined) return []; + tool.progress = { + kind: event.update.kind, + text: event.update.text, + percent: event.update.percent, + custom_kind: event.update.customKind, + custom_data: event.update.customData, + }; + return [ + { + type: 'tool.progress', + ...this.base(), + tool_call_id: event.toolCallId, + progress: tool.progress, + }, + ]; + } + + private onToolCallStarted(event: { + time: number; + turnId: number; + toolCallId: string; + name: string; + args: unknown; + display?: unknown; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const turnId = turnIdOf(event.turnId); + this.ensureTurn(turnId, event.time, ops); + const step = this.ensureStep(turnId, event.time, ops); + const prev = this.tools.get(event.toolCallId); + const input = parseToolArgs(event.args); + const todoItems = event.name === 'TodoList' ? todoWriteItems(input) : undefined; + const tool: ToolRecord = { + toolCallId: event.toolCallId, + turnId, + stepId: step.stepId, + name: event.name, + status: 'running', + input, + inputText: prev?.inputText ?? (typeof event.args === 'string' ? event.args : undefined), + display: event.display, + todoId: todoItems !== undefined ? TODO_ENTITY_ID : undefined, + progress: prev?.progress, + agentRefs: prev?.agentRefs ?? [], + startedAt: prev?.startedAt ?? epochMsToIso(event.time), + }; + this.tools.set(event.toolCallId, tool); + ops.push(this.toolOp(tool)); + return ops; + } + + private onToolResult(event: { + time: number; + turnId: number; + toolCallId: string; + output: unknown; + isError?: boolean; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + let tool = this.tools.get(event.toolCallId); + if (tool === undefined) { + const turnId = turnIdOf(event.turnId); + this.ensureTurn(turnId, event.time, ops); + const step = this.ensureStep(turnId, event.time, ops); + tool = { + toolCallId: event.toolCallId, + turnId, + stepId: step.stepId, + name: '', + status: 'running', + agentRefs: [], + }; + this.tools.set(event.toolCallId, tool); + } + const isError = event.isError === true; + tool.status = isError ? 'error' : 'done'; + tool.output = event.output; + tool.error = isError && typeof event.output === 'string' ? event.output : undefined; + ops.push(this.toolOp(tool)); + return ops; + } + + private onTaskLifecycle(event: { + type: 'task.started' | 'task.terminated'; + time: number; + info: AgentTaskInfo; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const { info } = event; + const agentInfo = agentInfoOf(info); + const parentTool = + agentInfo?.parentToolCallId === undefined + ? undefined + : this.tools.get(agentInfo.parentToolCallId); + const task = this.upsertTask(info.taskId, (prev) => ({ + taskId: info.taskId, + kind: mapTaskKind(info.kind), + status: info.status, + detached: info.detached ?? prev?.detached ?? true, + description: info.description, + childAgentId: agentInfo?.agentId ?? prev?.childAgentId, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? parentTool?.startedAt ?? epochMsToIso(info.startedAt), + endedAt: info.endedAt === null ? prev?.endedAt : epochMsToIso(info.endedAt), + resultSummary: prev?.resultSummary, + usage: prev?.usage, + error: prev?.error, + stateReason: info.stopReason ?? prev?.stateReason, + model: agentInfo?.model ?? prev?.model, + thinkingEffort: agentInfo?.thinkingEffort ?? prev?.thinkingEffort, + })); + if (event.type === 'task.started') { + const childAgentId = agentInfo?.agentId; + if (info.kind === 'agent' && typeof childAgentId === 'string' && childAgentId.length > 0) { + this.subagentTaskIds.set(childAgentId, info.taskId); + if (parentTool !== undefined && parentTool.taskId !== info.taskId) { + parentTool.taskId = info.taskId; + ops.push(this.toolOp(parentTool)); + } + } + } + ops.push(this.taskOp(task)); + return ops; + } + + private onShellStarted(event: { + time: number; + commandId: string; + taskId: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + this.shellTasks.set(event.commandId, event.taskId); + const task = this.upsertTask(event.taskId, (prev) => ({ + taskId: event.taskId, + kind: 'shell', + status: 'running', + detached: prev?.detached ?? false, + description: prev?.description, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? epochMsToIso(event.time), + })); + ops.push(this.taskOp(task)); + return ops; + } + + private shellTaskId(event: { commandId: string; taskId?: string }): string { + const taskId = + this.shellTasks.get(event.commandId) ?? event.taskId ?? `shell-${event.commandId}`; + this.shellTasks.set(event.commandId, taskId); + return taskId; + } + + private onShellOutput(event: { + time: number; + commandId: string; + taskId?: string; + update: { kind: string; text?: string }; + }): ServerMessage[] { + const text = event.update.text; + if (typeof text !== 'string' || text.length === 0) return []; + const ops = this.settlePendingClear(); + const taskId = this.shellTaskId(event); + const task = this.upsertTask(taskId, (prev) => ({ + taskId, + kind: prev?.kind ?? 'shell', + status: 'running', + detached: prev?.detached ?? false, + description: prev?.description, + outputTail: tailWindow((prev?.outputTail ?? '') + text), + startedAt: prev?.startedAt ?? epochMsToIso(event.time), + })); + ops.push(this.taskOp(task)); + return ops; + } + + private onShellCompleted(event: { + time: number; + commandId: string; + taskId?: string; + isError: boolean; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const taskId = this.shellTaskId(event); + const task = this.upsertTask(taskId, (prev) => ({ + taskId, + kind: prev?.kind ?? 'shell', + status: event.isError ? 'failed' : 'completed', + detached: prev?.detached ?? false, + description: prev?.description, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? epochMsToIso(event.time), + endedAt: epochMsToIso(event.time), + })); + ops.push(this.taskOp(task)); + return ops; + } + + private upsertTask( + taskId: string, + build: (prev: TaskRecord | undefined) => TaskRecord, + ): TaskRecord { + const task = build(this.tasks.get(taskId)); + this.tasks.set(taskId, task); + return task; + } + + private onSubagentSpawned(event: { + time: number; + subagentId: string; + parentToolCallId: string; + description?: string; + swarmIndex?: number; + runInBackground: boolean; + taskId?: string; + model?: string; + thinkingEffort?: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const tool = this.tools.get(event.parentToolCallId); + if (tool !== undefined) { + const ref: ToolCallAgentRef = { + agent_id: event.subagentId, + role: event.swarmIndex !== undefined ? 'member' : 'child', + }; + tool.agentRefs = [...tool.agentRefs, ref]; + ops.push(this.toolOp(tool)); + } + const taskId = event.taskId; + if (taskId === undefined) return ops; + this.subagentTaskIds.set(event.subagentId, taskId); + if (tool !== undefined && tool.taskId !== taskId) { + tool.taskId = taskId; + ops.push(this.toolOp(tool)); + } + const task = this.upsertTask(taskId, (prev) => ({ + taskId, + kind: 'subagent', + status: 'running', + detached: event.runInBackground, + description: event.description ?? prev?.description, + childAgentId: event.subagentId, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? tool?.startedAt ?? epochMsToIso(event.time), + model: event.model ?? prev?.model, + thinkingEffort: event.thinkingEffort ?? prev?.thinkingEffort, + })); + ops.push(this.taskOp(task)); + return ops; + } + + private onSubagentRun(event: { + type: 'subagent.completed' | 'subagent.failed' | 'subagent.cancelled' | 'subagent.suspended'; + time: number; + subagentId: string; + resultSummary?: string; + usage?: TokenUsage; + error?: string; + reason?: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const taskKey = this.subagentTaskIds.get(event.subagentId) ?? event.subagentId; + const existing = this.tasks.get(taskKey); + if (existing === undefined) return ops; + const terminal = event.type !== 'subagent.suspended'; + existing.status = + event.type === 'subagent.completed' + ? 'completed' + : event.type === 'subagent.failed' + ? 'failed' + : event.type === 'subagent.cancelled' + ? 'killed' + : 'running'; + if (terminal) existing.endedAt = epochMsToIso(event.time); + existing.resultSummary = event.resultSummary ?? existing.resultSummary; + existing.usage = event.usage === undefined ? existing.usage : toSnakeUsage(event.usage); + existing.error = event.error ?? existing.error; + existing.stateReason = event.reason ?? existing.stateReason; + ops.push(this.taskOp(existing)); + return ops; + } + + private onGoalUpdated(event: { + time: number; + snapshot: { + objective: string; + status: 'active' | 'paused' | 'blocked' | 'complete'; + completionCriterion?: string; + tokensUsed: number; + budget: { tokenBudget: number | null }; + } | null; + }): ServerMessage[] { + const snapshot = event.snapshot; + const payload = + snapshot === null + ? undefined + : { + objective: snapshot.objective, + status: snapshot.status, + completion_criterion: snapshot.completionCriterion, + budget_used: snapshot.tokensUsed, + budget_limit: snapshot.budget.tokenBudget ?? undefined, + }; + return [this.systemOp('goal', payload, event.time)]; + } + + private onAgentStatusUpdated(event: { + time: number; + planMode?: boolean; + swarmMode?: boolean; + }): ServerMessage[] { + const ops: ServerMessage[] = []; + if (event.planMode !== undefined && event.planMode !== this.planMode) { + this.planMode = event.planMode; + if (event.planMode) { + ops.push(this.systemOp('plan.enter', undefined, event.time)); + } else if (this.planExitApproved()) { + ops.push(this.systemOp('plan.exit', undefined, event.time)); + } + } + if (event.swarmMode !== undefined && event.swarmMode !== this.swarmMode) { + this.swarmMode = event.swarmMode; + ops.push( + this.systemOp(event.swarmMode ? 'swarm.enter' : 'swarm.exit', undefined, event.time), + ); + } + return ops; + } + + private planExitApproved(): boolean { + let latest: ToolRecord | undefined; + for (const tool of this.tools.values()) { + if (tool.name === 'ExitPlanMode') latest = tool; + } + if (latest?.approvalId === undefined) return false; + return this.interactions.get(latest.approvalId)?.status === 'approved'; + } + + private onPlanRevision(event: { + time: number; + id: string; + version: number; + key: string; + sha256: string; + bytes: number; + }): ServerMessage[] { + const path = this.lookups?.resolvePlanRevisionKey?.(event.key) ?? event.key; + return [ + this.systemOp( + 'plan.revision', + { id: event.id, version: event.version, path, sha256: event.sha256, bytes: event.bytes }, + event.time, + ), + ]; + } + + private onPromptSubmitted(event: { + promptId: string; + userMessageId: string; + status: 'running' | 'queued'; + content: readonly ContentPart[]; + createdAt: string; + }): ServerMessage[] { + const prev = this.prompts.get(event.promptId); + this.prompts.set(event.promptId, { + promptId: event.promptId, + userMessageId: event.userMessageId, + content: event.content, + status: event.status, + createdAt: prev?.createdAt ?? event.createdAt, + }); + return []; + } + + private onPromptQueued(event: { + promptId: string; + content: readonly ContentPart[]; + }): ServerMessage[] { + let prev = this.prompts.get(event.promptId); + if (prev === undefined) { + prev = { + promptId: event.promptId, + userMessageId: event.promptId, + content: event.content, + status: 'queued', + createdAt: nowIso(), + }; + this.prompts.set(event.promptId, prev); + } + if (this.users.has(prev.userMessageId)) return []; + const user: UserRecord = { + messageId: prev.userMessageId, + text: wireContentParts(prev.content), + status: 'unread', + }; + this.users.set(user.messageId, user); + return [this.userOp(user)]; + } + + private onPromptStarted(event: { promptId: string }): ServerMessage[] { + const prev = this.prompts.get(event.promptId); + if (prev === undefined) return []; + prev.status = 'running'; + return []; + } + + private onPromptCompleted(event: { promptId: string }): ServerMessage[] { + const prev = this.prompts.get(event.promptId); + if (prev === undefined) return []; + prev.status = 'completed'; + return []; + } + + private onPromptAborted(event: { promptId: string }): ServerMessage[] { + const prev = this.prompts.get(event.promptId); + if (prev === undefined) return []; + prev.status = 'aborted'; + return []; + } + + private onPromptSteered(event: { + activePromptId: string; + promptIds: string[]; + content: readonly ContentPart[]; + steeredAt: string; + }): ServerMessage[] { + for (const promptId of event.promptIds) { + const prev = this.prompts.get(promptId); + if (prev === undefined) continue; + prev.status = 'steered'; + } + this.mergedSteers.push({ text: promptTextOf(event.content), promptIds: event.promptIds }); + return []; + } + + private onTurnSteered(event: { + time: number; + input: readonly ContentPart[]; + origin: unknown; + }): ServerMessage[] { + const origin = event.origin as { + kind?: string; + skillActivations?: readonly { skillName: string; skillArgs?: string }[]; + jobId?: string; + cron?: string; + trigger?: string; + }; + const kind = origin.kind; + if (kind !== 'user' && kind !== 'skill_activation' && kind !== 'cron_job') return []; + if (kind === 'skill_activation' && origin.trigger !== 'user-slash') return []; + const ops = this.settlePendingClear(); + const turn = this.currentTurn; + if (turn === undefined || turn.status !== 'running') return ops; + const skipBlocks = kind === 'user' ? (origin.skillActivations?.length ?? 0) : 0; + const step = this.currentStep; + const stepStarted = step !== undefined && step.turnId === turn.turnId; + if (!stepStarted && !turn.openingSteerDeduped && turn.openingKey !== undefined) { + const key = steerKeyOf(event.input, skipBlocks); + if (key.text === turn.openingKey.text && key.attachments === turn.openingKey.attachments) { + turn.openingSteerDeduped = true; + return ops; + } + } + const matched = + kind === 'user' ? this.matchQueuedPrompt(event.input, skipBlocks) : undefined; + if (matched !== undefined) { + const text = promptTextOf(event.input.slice(skipBlocks)); + this.mergedSteers = this.mergedSteers.filter((entry) => entry.text !== text); + const existing = this.users.get(matched); + if (existing !== undefined) { + if (existing.status === 'unread') { + existing.status = 'read'; + existing.turnId = turn.turnId; + existing.timestamp = event.time; + ops.push(this.userOp(existing)); + } + return ops; + } + ops.push( + this.steerUserMessage(turn, event.input, { + origin: userOriginOf(event.origin), + skillActivations: skillActivationsOf(event.origin), + skipBlocks, + at: event.time, + messageId: matched, + }), + ); + return ops; + } + if (kind === 'user') { + const merged = this.matchMergedSteer(event.input, skipBlocks); + if (merged !== undefined) { + ops.push(...this.readSteeredUsers(merged, turn, event.time)); + return ops; + } + } + ops.push( + this.steerUserMessage(turn, event.input, { + origin: userOriginOf(event.origin), + skillActivations: skillActivationsOf(event.origin), + skipBlocks, + at: event.time, + }), + ); + return ops; + } + + private matchQueuedPrompt( + input: readonly ContentPart[], + skipBlocks: number, + ): string | undefined { + const text = promptTextOf(input.slice(skipBlocks)); + let matched: string | undefined; + for (const prompt of this.prompts.values()) { + if (prompt.status !== 'queued' && prompt.status !== 'steered') continue; + if (promptTextOf(prompt.content) !== text) continue; + if (matched !== undefined) return undefined; + matched = prompt.userMessageId; + } + return matched; + } + + private matchMergedSteer( + input: readonly ContentPart[], + skipBlocks: number, + ): { promptIds: string[] } | undefined { + const text = promptTextOf(input.slice(skipBlocks)); + const index = this.mergedSteers.findIndex((entry) => entry.text === text); + if (index < 0) return undefined; + const [entry] = this.mergedSteers.splice(index, 1); + return entry; + } + + private readSteeredUsers( + merged: { promptIds: string[] }, + turn: TurnRecord, + timestamp: number, + ): ServerMessage[] { + const ops: ServerMessage[] = []; + for (const promptId of merged.promptIds) { + const prompt = this.prompts.get(promptId); + if (prompt === undefined) continue; + prompt.status = 'completed'; + const user = this.users.get(prompt.userMessageId); + if (user === undefined || user.status !== 'unread') continue; + user.status = 'read'; + user.turnId = turn.turnId; + user.timestamp = timestamp; + ops.push(this.userOp(user)); + } + return ops; + } + + private steerUserMessage( + turn: TurnRecord, + input: readonly ContentPart[], + opts: { + origin: UserMessageOrigin | undefined; + skillActivations: { skill_name: string; skill_args?: string }[] | undefined; + skipBlocks: number; + at: number; + text?: string; + messageId?: string; + }, + ): ServerMessage { + const messageId = opts.messageId ?? `${turn.turnId}.u${(this.serverUserSeq += 1)}`; + const attachmentIds: string[] = []; + for (const part of input.slice(opts.skipBlocks)) { + if (part.type === 'text') continue; + if (daemonFileRefFromPart(part) === undefined) continue; + this.attachmentSeq += 1; + attachmentIds.push(attachmentIdOf(turn.turnId, this.attachmentSeq)); + } + const user: UserRecord = { + messageId, + turnId: turn.turnId, + text: + opts.text !== undefined + ? textPartsOf(opts.text) + : wireContentParts(input.slice(opts.skipBlocks)), + status: 'read', + timestamp: opts.at, + origin: opts.origin, + attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, + skillActivations: opts.skillActivations, + }; + this.users.set(messageId, user); + return this.userOp(user); + } + + private onTaskNotified(event: { + time: number; + notificationType: string; + title: string; + body: string; + severity: string; + sourceKind: string; + sourceId: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const notification: TaskNotificationPayload = { + title: event.title, + body: event.body, + severity: event.severity, + type: event.notificationType, + source_kind: event.sourceKind, + source_id: event.sourceId, + }; + const origin = taskUserOriginOf(event.sourceId, notification); + if (origin === undefined) return ops; + const text = notificationTextOf(notification); + const turn = this.currentTurn; + if ( + turn !== undefined && + turn.status === 'running' && + turn.origin.kind === 'task' && + turn.origin.task_id === origin.task_id + ) { + const messageId = turn.userMessageId ?? turnUserMessageIdOf(turn.turnId); + if (turn.userMessageId === undefined) { + turn.userMessageId = messageId; + ops.push(this.turnOp(turn)); + } + const existing = this.users.get(messageId); + if (existing !== undefined) { + if (existing.origin?.kind !== 'task' || existing.origin.title === '') { + existing.text = textPartsOf(text); + existing.origin = origin; + ops.push(this.userOp(existing)); + } + return ops; + } + const user: UserRecord = { + messageId, + turnId: turn.turnId, + text: textPartsOf(text), + status: 'read', + timestamp: event.time, + origin, + }; + this.users.set(messageId, user); + ops.push(this.userOp(user)); + return ops; + } + if (turn !== undefined && turn.status === 'running') { + ops.push( + this.steerUserMessage(turn, [], { + origin, + skillActivations: undefined, + skipBlocks: 0, + at: event.time, + text, + }), + ); + return ops; + } + this.phantomUserSeq += 1; + const turnId = turnIdOf(this.nextTurnIdHint); + const user: UserRecord = { + messageId: `${turnId}.u${this.phantomUserSeq}`, + turnId, + text: textPartsOf(text), + status: 'read', + timestamp: event.time, + origin, + }; + this.users.set(user.messageId, user); + ops.push(this.userOp(user)); + return ops; + } + + private onContextSpliced(event: { + start: number; + deleteCount: number; + messages: readonly unknown[]; + }): ServerMessage[] { + if (event.start === 0 && event.deleteCount > 0 && event.messages.length === 0) { + this.pendingFullCut = true; + this.armPendingClearTimer(); + } + return []; + } + + private armPendingClearTimer(): void { + if (this.pendingClearTimer !== undefined) return; + this.pendingClearTimer = setTimeout(() => { + this.pendingClearTimer = undefined; + if (!this.pendingFullCut) return; + this.pendingFullCut = false; + this.hooks?.onDeferred?.(this.applyClear()); + }, PENDING_CLEAR_SETTLE_MS); + this.pendingClearTimer.unref(); + } + + private cancelPendingClearTimer(): void { + if (this.pendingClearTimer === undefined) return; + clearTimeout(this.pendingClearTimer); + this.pendingClearTimer = undefined; + } + + private settlePendingClear(): ServerMessage[] { + if (!this.pendingFullCut) return []; + this.cancelPendingClearTimer(); + this.pendingFullCut = false; + return this.applyClear(); + } + + private applyClear(): ServerMessage[] { + this.timelineRewriteCount += 1; + const removed = [...this.timelineIds]; + const op = this.systemOp('clear', { removed_ids: removed }, undefined); + this.turns.clear(); + this.steps.clear(); + this.texts.clear(); + this.stepTextIds.clear(); + this.stepTextSeqs.clear(); + this.tools.clear(); + this.users.clear(); + this.stepOrdinals.clear(); + this.stepUsageByTurn.clear(); + this.timelineIds.length = 0; + this.currentTurn = undefined; + this.currentStep = undefined; + this.openText = undefined; + this.openThinking = undefined; + return [op]; + } + + private onContextUndone(event: { + time: number; + turns: number; + fromTurnId?: number; + }): ServerMessage[] { + this.cancelPendingClearTimer(); + this.pendingFullCut = false; + const removed = this.removedIdsForUndo(event.turns, event.fromTurnId); + if (removed.length === 0) return []; + this.timelineRewriteCount += 1; + const op = this.systemOp('undo', { removed_ids: removed }, event.time); + for (const id of removed) { + if (turnOrdinalOf(id) === undefined) continue; + this.dropTurnDetails(id); + this.stepOrdinals.delete(id); + this.stepUsageByTurn.delete(id); + } + const firstRemoved = this.timelineIds.indexOf(removed[0]!); + if (firstRemoved >= 0) this.timelineIds.splice(firstRemoved); + return [op]; + } + + private removedIdsForUndo(turns: number, fromTurnId: number | undefined): string[] { + const cut = this.findUndoCutIndex(turns, fromTurnId); + if (cut === undefined) return []; + return this.timelineIds.slice(cut); + } + + private findUndoCutIndex(turns: number, fromTurnId: number | undefined): number | undefined { + if (fromTurnId !== undefined) { + for (let i = 0; i < this.timelineIds.length; i++) { + const ordinal = turnOrdinalOf(this.timelineIds[i]!); + if (ordinal !== undefined && ordinal >= fromTurnId) return i; + } + return undefined; + } + let remaining = turns; + for (let i = this.timelineIds.length - 1; i >= 0; i--) { + const id = this.timelineIds[i]!; + if (isCompactionSystemId(id)) return undefined; + const ordinal = turnOrdinalOf(id); + if (ordinal === undefined) continue; + if (!this.anchorTurnOrdinals.has(ordinal)) continue; + remaining -= 1; + if (remaining === 0) return i; + } + return undefined; + } + + private dropTurnDetails(turnId: string): void { + this.turns.delete(turnId); + for (const [stepId, step] of this.steps) { + if (step.turnId === turnId) this.steps.delete(stepId); + } + for (const [stepId, entry] of this.stepTextIds) { + if (!stepId.startsWith(`${turnId}.`)) continue; + if (entry.assistant !== undefined) this.texts.delete(entry.assistant); + if (entry.thinking !== undefined) this.texts.delete(entry.thinking); + this.stepTextIds.delete(stepId); + this.stepTextSeqs.delete(stepId); + } + for (const [toolCallId, tool] of this.tools) { + if (tool.turnId === turnId) this.tools.delete(toolCallId); + } + for (const [messageId, user] of this.users) { + if (user.turnId === turnId) this.users.delete(messageId); + } + } + + private mapTurnOrigin(origin: unknown): TurnOrigin { + return toTurnOrigin(origin, this.agentId, this.subagentTaskIds); + } + + private wireInteractionRequest(interaction: ProjectorInteraction): unknown { + return wireInteractionRequest(interaction.kind, interaction.payload); + } + + private wireInteractionResponse(record: InteractionRecord, response: unknown): unknown { + return wireInteractionResponse(record.kind, record.request, response); + } + + private createTextRecord( + stepId: string, + turnId: string, + kind: 'assistant' | 'thinking', + ): TextRecord { + const seq = (this.stepTextSeqs.get(stepId) ?? 0) + 1; + this.stepTextSeqs.set(stepId, seq); + const record: TextRecord = { + messageId: textMessageIdOf(stepId, seq), + kind, + turnId, + stepId, + status: 'streaming', + text: '', + }; + this.texts.set(record.messageId, record); + const entry = this.stepTextIds.get(stepId) ?? {}; + entry[kind] = record.messageId; + this.stepTextIds.set(stepId, entry); + return record; + } + + private base(): { session_id: string; agent_id: string; timestamp: number } { + return { session_id: this.sessionId, agent_id: this.agentId, timestamp: Date.now() }; + } + + private turnOp(turn: TurnRecord): TurnMessage { + return { + type: 'turn', + ...this.base(), + turn_id: turn.turnId, + ordinal: turn.ordinal, + status: turn.status, + origin: turn.origin, + user_message_id: turn.userMessageId, + attachment_ids: turn.attachmentIds, + started_at: turn.startedAt, + ended_at: turn.endedAt, + usage: turn.usage === undefined ? undefined : turnUsageToWire(turn.usage), + duration_ms: turn.durationMs, + }; + } + + private stepOp(step: StepRecord): StepMessage { + return { + type: 'step', + ...this.base(), + step_id: step.stepId, + turn_id: step.turnId, + ordinal: step.ordinal, + status: step.status, + started_at: step.startedAt, + ended_at: step.endedAt, + usage: step.usage, + finish_reason: step.finishReason, + timing: step.timing, + retry: step.retry, + end_reason: step.endReason, + end_message: step.endMessage, + }; + } + + private textOp(record: TextRecord): AssistantMessage | ThinkingMessage { + const base = { + ...this.base(), + message_id: record.messageId, + turn_id: record.turnId, + step_id: record.stepId, + status: record.status, + text: record.text, + }; + if (record.kind === 'assistant') return { type: 'assistant', ...base }; + return { type: 'thinking', ...base }; + } + + private textDeltaOp(record: TextRecord, delta: string): ServerMessage { + if (record.kind === 'assistant') { + return { + type: 'assistant.delta', + ...this.base(), + message_id: record.messageId, + text: delta, + }; + } + return { + type: 'thinking.delta', + ...this.base(), + message_id: record.messageId, + text: delta, + }; + } + + private toolOp(tool: ToolRecord): ToolCallMessage { + return { + type: 'tool_call', + ...this.base(), + tool_call_id: tool.toolCallId, + turn_id: tool.turnId, + step_id: tool.stepId, + name: tool.name, + status: tool.status, + input: tool.input, + input_text: tool.inputText, + output: tool.output, + display: tool.display, + error: tool.error, + progress: tool.progress, + task_id: tool.taskId, + approval_id: tool.approvalId, + todo_id: tool.todoId, + agent_refs: tool.agentRefs.length > 0 ? tool.agentRefs : undefined, + }; + } + + private toolDeltaOp(toolCallId: string, inputText: string): ServerMessage { + return { + type: 'tool_call.delta', + ...this.base(), + tool_call_id: toolCallId, + input_text: inputText, + }; + } + + private userOp(user: UserRecord): UserMessage { + return { + type: 'user', + session_id: this.sessionId, + agent_id: this.agentId, + message_id: user.messageId, + turn_id: user.turnId, + status: user.status, + timestamp: user.timestamp, + text: user.text, + attachment_ids: user.attachmentIds, + skill_activations: user.skillActivations, + origin: user.origin, + }; + } + + private taskOp(task: TaskRecord): TaskMessage { + return { + type: 'task', + ...this.base(), + task_id: task.taskId, + kind: task.kind, + status: task.status, + detached: task.detached, + description: task.description, + child_agent_id: task.childAgentId, + output_tail: task.outputTail, + started_at: task.startedAt, + ended_at: task.endedAt, + result_summary: task.resultSummary, + error: task.error, + state_reason: task.stateReason, + usage: task.usage, + model: task.model, + thinking_effort: task.thinkingEffort, + }; + } + + private interactionOp(record: InteractionRecord): InteractionMessage { + return { + type: 'interaction', + ...this.base(), + interaction_id: record.interactionId, + kind: record.kind, + status: record.status, + tool_call_id: record.toolCallId, + request: record.request, + response: record.response, + } as InteractionMessage; + } + + private todoOp(): TodoMessage { + return { + type: 'todo', + ...this.base(), + todo_id: TODO_ENTITY_ID, + items: this.todoItems ?? [], + updated_at: this.todoUpdatedAt, + }; + } + + private systemOp( + subtype: SystemMessage['subtype'], + payload: unknown, + time?: number, + ): SystemMessage { + const systemId = this.sysIds.next(subtype); + this.timelineIds.push(systemId); + return { + type: 'system', + ...this.base(), + system_id: systemId, + subtype, + payload, + at: time === undefined ? undefined : epochMsToIso(time), + } as SystemMessage; + } +} + +export function toTurnOrigin( + origin: unknown, + agentId: string, + subagentTaskIds: ReadonlyMap, +): TurnOrigin { + const candidate = origin as + | { kind?: unknown; taskId?: unknown; name?: unknown } + | null + | undefined; + const kind = typeof candidate?.kind === 'string' ? candidate.kind : undefined; + if (kind === undefined) return { kind: 'other' }; + switch (kind) { + case 'user': + case 'skill_activation': + case 'plugin_command': + case 'shell_command': + return { kind: 'user' }; + case 'cron_job': + case 'cron_missed': + return { kind: 'cron' }; + case 'task': + case 'background_task': { + const taskId = candidate?.taskId; + return typeof taskId === 'string' ? { kind: 'task', task_id: taskId } : { kind: 'other' }; + } + case 'hook_result': + return { kind: 'hook' }; + case 'compaction_summary': + return { kind: 'compaction' }; + case 'system_trigger': { + if (candidate?.name === 'goal_continuation') return { kind: 'goal' }; + const taskId = subagentTaskIds.get(agentId); + return taskId === undefined ? { kind: 'other' } : { kind: 'task', task_id: taskId }; + } + default: + return { kind: 'other' }; + } +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function epochMsToIso(value: number): string { + return new Date(value).toISOString(); +} + +function restOf(event: { + readonly type: string; + readonly time?: number; + readonly agentId?: string; +}): Record { + const { type: _type, time: _time, agentId: _agentId, ...rest } = event; + return rest; +} + +function toSnakeUsage(usage: TokenUsage): StepUsage { + return { + input_other: usage.inputOther, + output: usage.output, + input_cache_read: usage.inputCacheRead, + input_cache_creation: usage.inputCacheCreation, + }; +} + +function turnUsageToWire(usage: StepUsage): { + input_tokens: number; + output_tokens: number; + cached_tokens: number; +} { + return { + input_tokens: usage.input_other + usage.input_cache_creation, + output_tokens: usage.output, + cached_tokens: usage.input_cache_read, + }; +} + +function timingOf(event: { + llmFirstTokenLatencyMs?: number; + llmStreamDurationMs?: number; +}): StepTiming | undefined { + if (event.llmFirstTokenLatencyMs === undefined && event.llmStreamDurationMs === undefined) { + return undefined; + } + return { + llm_first_token_ms: event.llmFirstTokenLatencyMs, + llm_stream_duration_ms: event.llmStreamDurationMs, + }; +} + +function mapTaskKind(kind: string): TaskMessage['kind'] { + switch (kind) { + case 'process': + return 'shell'; + case 'agent': + return 'subagent'; + default: + return 'other'; + } +} + +function agentInfoOf(info: AgentTaskInfo): + | { + agentId?: string; + parentToolCallId?: string; + model?: string; + thinkingEffort?: string; + } + | undefined { + if (info.kind !== 'agent') return undefined; + return info as { + agentId?: string; + parentToolCallId?: string; + model?: string; + thinkingEffort?: string; + }; +} + +function tailWindow(text: string): string { + return text.length <= TASK_OUTPUT_TAIL_MAX ? text : text.slice(text.length - TASK_OUTPUT_TAIL_MAX); +} + +export function parseToolArgs(args: unknown): unknown { + if (typeof args !== 'string' || args.length === 0) return args; + try { + return JSON.parse(args) as unknown; + } catch { + return args; + } +} + +export function todoWriteItems(input: unknown): readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[] | undefined { + const todos = (input as { todos?: unknown } | undefined)?.todos; + if (!Array.isArray(todos)) return undefined; + const items = readTodoItems(todos); + return items.length === 0 && todos.length > 0 ? undefined : items; +} + +export function mapInteractionEndStatus( + kind: 'approval' | 'question', + response: unknown, +): InteractionMessage['status'] { + if (isCancellation(response)) return 'cancelled'; + if (kind === 'question') return response === null ? 'dismissed' : 'answered'; + const decision = (response as { decision?: unknown } | null | undefined)?.decision; + if (decision === 'approved' || decision === 'rejected' || decision === 'cancelled') { + return decision; + } + return 'cancelled'; +} + +export function isCancellation(response: unknown): boolean { + return (response as { cancelled?: unknown } | null | undefined)?.cancelled === true; +} + +export function wantsUserMessage(origin: unknown, promptText: string | undefined): boolean { + const candidate = origin as { kind?: unknown; name?: unknown } | null | undefined; + switch (candidate?.kind) { + case 'user': + case 'skill_activation': + case 'plugin_command': + case 'shell_command': + case 'cron_job': + case 'cron_missed': + return true; + case 'system_trigger': + return ( + candidate.name === 'subagent' && + typeof promptText === 'string' && + promptText.length > 0 + ); + default: + return false; + } +} + +export function userOriginOf(origin: unknown): UserMessageOrigin | undefined { + const candidate = origin as + | { + kind?: unknown; + jobId?: unknown; + cron?: unknown; + skillName?: unknown; + skillArgs?: unknown; + trigger?: unknown; + pluginId?: unknown; + commandName?: unknown; + commandArgs?: unknown; + } + | null + | undefined; + if (candidate?.kind === 'cron_job') return cronUserOrigin(candidate); + if (candidate?.kind === 'cron_missed') return { kind: 'cron' }; + if (candidate?.kind === 'skill_activation' && typeof candidate.skillName === 'string') { + return { + kind: 'skill', + skill_name: candidate.skillName, + args: typeof candidate.skillArgs === 'string' ? candidate.skillArgs : undefined, + trigger: typeof candidate.trigger === 'string' ? candidate.trigger : undefined, + }; + } + if (candidate?.kind === 'plugin_command') { + const name = + typeof candidate.commandName === 'string' + ? candidate.commandName + : typeof candidate.pluginId === 'string' + ? candidate.pluginId + : undefined; + if (name === undefined) return undefined; + return { + kind: 'skill', + skill_name: name, + args: typeof candidate.commandArgs === 'string' ? candidate.commandArgs : undefined, + trigger: typeof candidate.trigger === 'string' ? candidate.trigger : undefined, + }; + } + return undefined; +} + +export function taskUserOriginOf( + taskId: unknown, + notification?: TaskNotificationPayload, +): Extract | undefined { + if (typeof taskId !== 'string' || taskId.length === 0) return undefined; + if (notification === undefined) return { kind: 'task', task_id: taskId, title: '', body: '' }; + return { kind: 'task', task_id: taskId, ...notification }; +} + +export function taskNotificationOriginOf( + origin: unknown, +): Extract | undefined { + const candidate = origin as { kind?: unknown; taskId?: unknown } | null | undefined; + if (candidate?.kind !== 'task' && candidate?.kind !== 'background_task') return undefined; + return taskUserOriginOf(candidate.taskId); +} + +export function notificationTextOf(notification: { + title: string; + body: string; +}): string { + return `${notification.title}\n${notification.body}`.trim(); +} + +function cronUserOrigin(candidate: { + jobId?: unknown; + cron?: unknown; +}): UserMessageOrigin | undefined { + if (typeof candidate.jobId !== 'string' || typeof candidate.cron !== 'string') return undefined; + return { kind: 'cron', cron_id: candidate.jobId, schedule: candidate.cron }; +} + +export function skillActivationsOf( + origin: unknown, +): { skill_name: string; skill_args?: string }[] | undefined { + const candidate = origin as { + kind?: unknown; + skillActivations?: readonly { skillName: string; skillArgs?: string }[]; + skillName?: unknown; + skillArgs?: unknown; + } | null | undefined; + if (candidate?.kind === 'user') { + const activations = candidate.skillActivations ?? []; + if (activations.length === 0) return undefined; + return activations.map((a) => ({ skill_name: a.skillName, skill_args: a.skillArgs })); + } + if (candidate?.kind === 'skill_activation' && typeof candidate.skillName === 'string') { + return [ + { + skill_name: candidate.skillName, + skill_args: typeof candidate.skillArgs === 'string' ? candidate.skillArgs : undefined, + }, + ]; + } + return undefined; +} + +export function promptTextOf(content: readonly ContentPart[]): string { + return content + .filter((part): part is ContentPart & { type: 'text' } => part.type === 'text') + .map((part) => part.text) + .join(''); +} + +export function steerKeyOf( + input: readonly ContentPart[], + skipBlocks: number, +): { text: string; attachments: number } { + let text = ''; + let attachments = 0; + for (const part of input.slice(skipBlocks)) { + if (part.type === 'text') { + text += part.text; + continue; + } + if (daemonFileRefFromPart(part) !== undefined) attachments += 1; + } + return { text, attachments }; +} + +export function wireContentParts(content: readonly ContentPart[]): WireContentPart[] { + const out: WireContentPart[] = []; + for (const part of content) { + switch (part.type) { + case 'text': + out.push({ type: 'text', text: part.text, meta: {} }); + break; + case 'think': + out.push({ type: 'think', text: part.think, meta: {} }); + break; + case 'image_url': + out.push({ + type: 'image', + text: part.imageUrl.url, + meta: { id: part.imageUrl.id, name: part.imageUrl.name }, + }); + break; + case 'audio_url': + out.push({ type: 'audio', text: part.audioUrl.url, meta: { id: part.audioUrl.id } }); + break; + case 'video_url': + out.push({ + type: 'video', + text: part.videoUrl.url, + meta: { id: part.videoUrl.id, name: part.videoUrl.name }, + }); + break; + default: + break; + } + } + return out; +} + +export function textPartsOf(text: string): WireContentPart[] { + return [{ type: 'text', text, meta: {} }]; +} + +function hookPayload(event: { + turnId?: number; + hookEvent: string; + content: string; + blocked?: boolean; +}): Record { + return { + turn_id: event.turnId, + hook_event: event.hookEvent, + content: event.content, + blocked: event.blocked, + }; +} + +export function wireInteractionRequest(kind: 'approval' | 'question', payload: unknown): unknown { + if (kind === 'approval') { + const record = payload as Record | null; + const toolName = typeof record?.['toolName'] === 'string' ? record['toolName'] : undefined; + if (toolName === undefined || toolName.length === 0) return undefined; + return { + tool_name: toolName, + action: typeof record?.['action'] === 'string' ? record['action'] : '', + tool_input_display: record?.['display'], + }; + } + return toV3QuestionRequest(payload); +} + +export function wireInteractionResponse( + kind: 'approval' | 'question', + request: unknown, + response: unknown, +): unknown { + if (isCancellation(response)) { + return kind === 'approval' ? { decision: 'cancelled' } : undefined; + } + if (kind === 'approval') { + const r = response as { + decision?: unknown; + scope?: unknown; + feedback?: unknown; + selectedLabel?: unknown; + } | null; + if (r === null || typeof r !== 'object') return undefined; + const decision = r.decision; + if (decision !== 'approved' && decision !== 'rejected' && decision !== 'cancelled') { + return undefined; + } + return { + decision, + scope: r.scope === 'session' ? 'session' : undefined, + feedback: typeof r.feedback === 'string' ? r.feedback : undefined, + selected_label: typeof r.selectedLabel === 'string' ? r.selectedLabel : undefined, + }; + } + return mapQuestionResponse(request, response); +} + +export function toV3QuestionRequest(payload: unknown): unknown { + const request = payload as { + questions?: readonly { + question: string; + header?: string; + body?: string; + options: readonly { label: string; description?: string }[]; + multiSelect?: boolean; + otherLabel?: string; + otherDescription?: string; + }[]; + }; + if (request.questions === undefined) return undefined; + return { + questions: request.questions.map((item, i) => ({ + id: `q_${i}`, + question: item.question, + header: item.header, + body: item.body, + options: item.options.map((option, j) => ({ + id: `opt_${i}_${j}`, + label: option.label, + description: option.description, + })), + multi_select: item.multiSelect, + allow_other: true, + other_label: item.otherLabel, + other_description: item.otherDescription, + })), + }; +} + +function mapQuestionResponse(request: unknown, response: unknown): unknown { + const r = response as { answers?: unknown; method?: unknown } | null; + if (r === null || typeof r !== 'object' || r.answers === null || typeof r.answers !== 'object') { + return undefined; + } + const items = + ( + request as + | { + questions?: readonly { + id: string; + question: string; + options: readonly { id: string; label: string }[]; + }[]; + } + | undefined + )?.questions ?? []; + const answers: Record = {}; + for (const [key, value] of Object.entries(r.answers as Record)) { + const item = items.find((q) => q.id === key || q.question === key); + if (item === undefined) continue; + if (value === true) { + answers[item.id] = { kind: 'skipped' }; + continue; + } + if (typeof value !== 'string') continue; + const single = item.options.find((o) => o.label === value); + if (single !== undefined) { + answers[item.id] = { kind: 'single', option_id: single.id }; + continue; + } + const parts = value.split(', '); + const optionIds = parts.flatMap((part) => { + const found = item.options.find((o) => o.label === part); + return found === undefined ? [] : [found.id]; + }); + if (parts.length > 1 && optionIds.length === parts.length) { + answers[item.id] = { kind: 'multi', option_ids: optionIds }; + continue; + } + answers[item.id] = { kind: 'other', text: value }; + } + if (Object.keys(answers).length === 0) return undefined; + const method = r.method; + return { + answers, + method: + method === 'enter' || method === 'space' || method === 'number_key' || method === 'click' + ? method + : undefined, + }; +} diff --git a/packages/kap-server/src/services/projection/agentState.ts b/packages/kap-server/src/services/projection/agentState.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f2d6b25a2ddca4cbd847e3ffd7346db9276e758 --- /dev/null +++ b/packages/kap-server/src/services/projection/agentState.ts @@ -0,0 +1,165 @@ +import type { AgentActivitySnapshot, AgentTaskInfo } from '@moonshot-ai/agent-core-v2'; + +import type { + AgentStateMessage, + AgentStateOrigin, + AgentStateTurn, + AgentStatus, +} from '../../protocol/messages'; + +const TERMINAL_STATUSES: ReadonlySet = new Set(['completed', 'failed', 'interrupted']); + +export class AgentStateTracker { + private origin: AgentStateOrigin | undefined; + private profileKind = ''; + private createdAt: string; + private endedAt: string | undefined; + private status: AgentStatus = 'idle'; + private turn: AgentStateTurn | undefined; + + constructor( + readonly agentId: string, + createdAt?: string, + ) { + this.createdAt = createdAt ?? new Date().toISOString(); + } + + get hasOrigin(): boolean { + return this.origin !== undefined; + } + + seedMain(profileKind: string, createdAt: string, running: boolean): void { + this.origin = { kind: 'main' }; + this.profileKind = profileKind; + this.createdAt = createdAt; + if (running) this.status = 'running'; + } + + seedBtw(profileKind: string, createdAt: string, running: boolean): void { + this.origin = { kind: 'btw' }; + this.profileKind = profileKind; + this.createdAt = createdAt; + if (running) this.status = 'running'; + } + + seedToolSpawned(event: { + subagentId: string; + subagentName: string; + parentToolCallId: string; + parentAgentId?: string; + swarmIndex?: number; + }): boolean { + if (event.subagentId !== this.agentId || event.parentToolCallId.length === 0) return false; + const origin: AgentStateOrigin = + event.swarmIndex !== undefined + ? { + kind: 'tool-swarm', + tool_call_id: event.parentToolCallId, + swarm_index: event.swarmIndex, + parent_agent_id: event.parentAgentId ?? 'main', + } + : { + kind: 'tool-agent', + tool_call_id: event.parentToolCallId, + parent_agent_id: event.parentAgentId ?? 'main', + }; + if (JSON.stringify(this.origin) === JSON.stringify(origin)) return false; + this.origin = origin; + this.profileKind = event.subagentName; + return true; + } + + seedToolFromTask(profileKind: string, createdAt: string, info: AgentTaskInfo | undefined): boolean { + const agentInfo = info === undefined ? undefined : agentInfoOfTask(info); + const toolCallId = agentInfo?.parentToolCallId; + if (toolCallId === undefined || toolCallId.length === 0) return false; + this.origin = { + kind: 'tool-agent', + tool_call_id: toolCallId, + parent_agent_id: 'main', + }; + this.profileKind = profileKind; + this.createdAt = createdAt; + return true; + } + + turnStarted(): boolean { + if (this.status === 'running') return false; + this.status = 'running'; + this.endedAt = undefined; + return true; + } + + turnEnded(): boolean { + if (this.status !== 'running') return false; + this.status = 'idle'; + this.turn = undefined; + return true; + } + + runStarted(): boolean { + return this.turnStarted(); + } + + runFinished(status: 'completed' | 'failed' | 'interrupted', endedAt: string): boolean { + if (this.status === status) return false; + this.status = status; + this.turn = undefined; + this.endedAt = endedAt; + return true; + } + + close(endedAt: string): boolean { + if (this.endedAt !== undefined) return false; + this.endedAt = endedAt; + this.turn = undefined; + if (TERMINAL_STATUSES.has(this.status)) return true; + this.status = 'interrupted'; + return true; + } + + recompute(snapshot: AgentActivitySnapshot): boolean { + if (TERMINAL_STATUSES.has(this.status) && snapshot.turn === undefined) return false; + const turn = snapshot.turn; + if (turn === undefined) { + const changed = this.status === 'running' || this.turn !== undefined; + if (this.status === 'running') this.status = 'idle'; + this.turn = undefined; + return changed; + } + if (this.status === 'idle') this.status = 'running'; + const next: AgentStateTurn = { + status: turn.ending + ? 'aborting' + : turn.phase === 'retrying' + ? 'retrying' + : turn.phase === 'tool_call' + ? 'acting' + : 'thinking', + }; + if (this.turn?.status === next.status) return false; + this.turn = next; + return true; + } + + snapshot(sessionId: string): AgentStateMessage | undefined { + if (this.origin === undefined) return undefined; + return { + type: 'agent.state', + session_id: sessionId, + agent_id: this.agentId, + profile: { kind: this.profileKind }, + timestamp: Date.now(), + origin: this.origin, + created_at: this.createdAt, + ended_at: this.endedAt, + status: this.status, + turn: this.turn, + }; + } +} + +function agentInfoOfTask(info: AgentTaskInfo): { parentToolCallId?: string } | undefined { + if (info.kind !== 'agent') return undefined; + return info as { parentToolCallId?: string }; +} diff --git a/packages/kap-server/src/services/projection/events.ts b/packages/kap-server/src/services/projection/events.ts new file mode 100644 index 0000000000000000000000000000000000000000..33dbbeee8daaa82129de9c7653c61dca50b6b0cb --- /dev/null +++ b/packages/kap-server/src/services/projection/events.ts @@ -0,0 +1,116 @@ +import type { ContextSpliced } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextEvents'; +import type { HookResult } from '@moonshot-ai/agent-core-v2/features/externalHooks/agent/agentExternalHooksService'; +import type { + CompactionBlocked, + CompactionCancelled, + CompactionCompleted, + CompactionStarted, +} from '@moonshot-ai/agent-core-v2/agent/fullCompaction/compactionOps'; +import type { ContextUndone, CronFired, GoalUpdated } from '@moonshot-ai/agent-core-v2'; +import type { + AssistantDelta, + ThinkingDelta, + ToolCallDelta, + TurnStarted, + TurnStepCompleted, + TurnStepInterrupted, + TurnStepRetrying, + TurnStepStarted, +} from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; +import type { TurnEnded, TurnSteer } from '@moonshot-ai/agent-core-v2/agent/loop/turnOps'; +import type { AgentErrorEvent } from '@moonshot-ai/agent-core-v2/agent/mcp/mcpEvents'; +import type { PluginCommandActivated } from '@moonshot-ai/agent-core-v2/agent/pluginCommand/pluginCommand'; +import type { WarningIssued } from '@moonshot-ai/agent-core-v2/agent/profile/profileOps'; +import type { + PromptAborted, + PromptCompleted, + PromptQueued, + PromptStarted, + PromptSteered, + PromptSubmitted, +} from '@moonshot-ai/agent-core-v2/agent/prompt/promptEvents'; +import type { + ShellCompleted, + ShellOutput, + ShellStarted, +} from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommandService'; +import type { SkillActivated } from '@moonshot-ai/agent-core-v2/features/skill/skillOps'; +import type { + TaskNotified, + TaskStarted, + TaskTerminatedNotice, +} from '@moonshot-ai/agent-core-v2/agent/task/taskOps'; +import type { + PermissionApprovalRequested, + PermissionApprovalResolved, +} from '@moonshot-ai/agent-core-v2/agent/toolApproval/toolApprovalService'; +import type { + ToolCallStarted, + ToolProgress, + ToolResultEvent, +} from '@moonshot-ai/agent-core-v2/agent/toolExecutor/toolExecutorEvents'; +import type { AgentStatusUpdated } from '@moonshot-ai/agent-core-v2/agent/usage/usageEvents'; +import type { PlanRevision } from '@moonshot-ai/agent-core-v2/features/plan/planOps'; +import type { SubagentSuspended } from '@moonshot-ai/agent-core-v2/features/swarm/session/sessionSwarmService'; +import type { + SubagentCancelled, + SubagentCompleted, + SubagentFailed, + SubagentSpawned, + SubagentStarted, +} from '@moonshot-ai/agent-core-v2/session/subagent/mirrorAgentRun'; + +export type ProjectionBusEvent = + | ({ readonly type: 'plan.revision' } & PlanRevision) + | ({ readonly type: 'turn.started' } & TurnStarted) + | ({ readonly type: 'turn.ended' } & TurnEnded) + | ({ readonly type: 'turn.step.started' } & TurnStepStarted) + | ({ readonly type: 'turn.step.completed' } & TurnStepCompleted) + | ({ readonly type: 'turn.step.interrupted' } & TurnStepInterrupted) + | ({ readonly type: 'turn.step.retrying' } & TurnStepRetrying) + | ({ readonly type: 'assistant.delta' } & AssistantDelta) + | ({ readonly type: 'thinking.delta' } & ThinkingDelta) + | ({ readonly type: 'tool.call.delta' } & ToolCallDelta) + | ({ readonly type: 'tool.progress' } & ToolProgress) + | ({ readonly type: 'tool.call.started' } & ToolCallStarted) + | ({ readonly type: 'tool.result' } & ToolResultEvent) + | ({ readonly type: 'task.started' } & TaskStarted) + | ({ readonly type: 'task.terminated' } & TaskTerminatedNotice) + | ({ readonly type: 'task.notified' } & TaskNotified) + | ({ readonly type: 'shell.started' } & ShellStarted) + | ({ readonly type: 'shell.output' } & ShellOutput) + | ({ readonly type: 'shell.completed' } & ShellCompleted) + | ({ readonly type: 'subagent.spawned' } & SubagentSpawned) + | ({ readonly type: 'subagent.started' } & SubagentStarted) + | ({ readonly type: 'subagent.completed' } & SubagentCompleted) + | ({ readonly type: 'subagent.failed' } & SubagentFailed) + | ({ readonly type: 'subagent.cancelled' } & SubagentCancelled) + | ({ readonly type: 'subagent.suspended' } & SubagentSuspended) + | ({ readonly type: 'goal.updated' } & GoalUpdated) + | ({ readonly type: 'agent.status.updated' } & AgentStatusUpdated) + | ({ readonly type: 'prompt.queued' } & PromptQueued) + | ({ readonly type: 'prompt.submitted' } & PromptSubmitted) + | ({ readonly type: 'prompt.started' } & PromptStarted) + | ({ readonly type: 'prompt.completed' } & PromptCompleted) + | ({ readonly type: 'prompt.aborted' } & PromptAborted) + | ({ readonly type: 'prompt.steered' } & PromptSteered) + | ({ readonly type: 'turn.steer' } & TurnSteer) + | ({ readonly type: 'hook.result' } & HookResult) + | ({ readonly type: 'skill.activated' } & SkillActivated) + | ({ readonly type: 'plugin_command.activated' } & PluginCommandActivated) + | ({ readonly type: 'cron.fired' } & CronFired) + | ({ readonly type: 'compaction.started' } & CompactionStarted) + | ({ readonly type: 'compaction.blocked' } & CompactionBlocked) + | ({ readonly type: 'compaction.cancelled' } & CompactionCancelled) + | ({ readonly type: 'compaction.completed' } & CompactionCompleted) + | ({ readonly type: 'context.spliced' } & ContextSpliced) + | ({ readonly type: 'context.undone' } & ContextUndone) + | ({ readonly type: 'permission.approval.requested' } & PermissionApprovalRequested) + | ({ readonly type: 'permission.approval.resolved' } & PermissionApprovalResolved) + | ({ readonly type: 'error' } & AgentErrorEvent) + | ({ readonly type: 'warning' } & WarningIssued); + +export const PROJECTION_IGNORED_EVENT_TYPES: ReadonlySet = new Set([ + 'mcp.server.status', + 'tool.list.updated', +]); diff --git a/packages/kap-server/src/services/projection/heal.ts b/packages/kap-server/src/services/projection/heal.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e081bd013fd810843b5255d83b7b1524a56c932 --- /dev/null +++ b/packages/kap-server/src/services/projection/heal.ts @@ -0,0 +1,420 @@ +import { readFile } from 'node:fs/promises'; + +import type { TokenUsage } from '@moonshot-ai/agent-core-v2'; + +import type { StepTiming, StepUsage } from '../../protocol/messages'; +import { + SystemIdAllocator, + isUndoAnchorOrigin, + isVisibleTurnOrigin, + turnIdOf, + turnOrdinalOf, +} from './ids'; + +export interface ContextRecord { + readonly type: string; + readonly time?: number; + readonly [key: string]: unknown; +} + +export async function readWireRecords(wirePath: string): Promise { + const raw = await readFile(wirePath, 'utf8'); + const lines = raw.split('\n'); + const records: ContextRecord[] = []; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]!; + if (line.endsWith('\r')) line = line.slice(0, -1); + if (line.length === 0) continue; + try { + records.push(JSON.parse(line) as ContextRecord); + } catch (parseError) { + if (i === lines.length - 1) break; + throw new Error(`wire.jsonl: corrupted line ${i + 1} in ${wirePath}: ${String(parseError)}`, { + cause: parseError, + }); + } + } + return records; +} + +export interface TimelineSeed { + readonly timelineIds: string[]; + readonly systemCounts: ReadonlyMap; + readonly anchorTurnOrdinals: number[]; + readonly nextTurnId: number; +} + +export function foldTimelineSeed(records: readonly ContextRecord[]): TimelineSeed { + const timelineIds: string[] = []; + const anchorTurnOrdinals: number[] = []; + const sysIds = new SystemIdAllocator(); + let nextTurnId = 0; + let currentTurn: number | undefined; + const cancelledTurnIds = new Set(); + const hiddenTurnIds = new Set(); + const visibleTurnOrdinals = new Set(); + const turnPromptIds = new Map(); + const pendingAnchorTurnIds: number[] = []; + const undoAnchors: { rawId: number }[] = []; + let undoAnchorFloor = 0; + const activeCancelTurnIds = new Set(); + + const skipCancelledTurnIds = (): void => { + while (cancelledTurnIds.delete(nextTurnId)) { + hiddenTurnIds.add(nextTurnId); + nextTurnId += 1; + } + }; + + const pushSystem = (subtype: string): void => { + timelineIds.push(sysIds.next(subtype)); + }; + + for (const record of records) { + switch (record.type) { + case 'turn.prompt': { + skipCancelledTurnIds(); + const recordTurnId = record['turnId']; + const rawId = + typeof recordTurnId === 'number' && Number.isInteger(recordTurnId) && recordTurnId >= 0 + ? recordTurnId + : nextTurnId; + nextTurnId = Math.max(nextTurnId, rawId + 1); + const origin = record['origin']; + const promptId = record['promptId']; + if (typeof promptId === 'string') turnPromptIds.set(rawId, promptId); + if (isUndoAnchorOrigin(origin)) { + pendingAnchorTurnIds.push(rawId); + anchorTurnOrdinals.push(rawId); + } + currentTurn = rawId; + if (!isVisibleTurnOrigin(origin)) { + hiddenTurnIds.add(rawId); + break; + } + visibleTurnOrdinals.add(rawId); + timelineIds.push(turnIdOf(rawId)); + break; + } + case 'context.append_message': { + const message = record['message'] as + | { id?: string; role?: string; origin?: unknown } + | undefined; + if (message?.role === 'assistant') { + if ( + currentTurn === undefined || + hiddenTurnIds.has(currentTurn) || + !visibleTurnOrdinals.has(currentTurn) + ) { + const rawId = nextTurnId; + nextTurnId += 1; + visibleTurnOrdinals.add(rawId); + timelineIds.push(turnIdOf(rawId)); + currentTurn = rawId; + } + break; + } + if (message?.role !== 'user' || !isUndoAnchorOrigin(message.origin)) break; + const messageId = typeof message.id === 'string' ? message.id : undefined; + const matchingIndex = + messageId !== undefined + ? pendingAnchorTurnIds.findIndex((turnId) => turnPromptIds.get(turnId) === messageId) + : -1; + const legacyIndex = + matchingIndex < 0 && messageId !== undefined + ? pendingAnchorTurnIds.findIndex((turnId) => !turnPromptIds.has(turnId)) + : -1; + const matchedTurnId = + matchingIndex >= 0 + ? pendingAnchorTurnIds.splice(matchingIndex, 1)[0] + : legacyIndex >= 0 + ? pendingAnchorTurnIds.splice(legacyIndex, 1)[0] + : messageId === undefined + ? pendingAnchorTurnIds.shift() + : undefined; + if (matchedTurnId !== undefined && !turnPromptIds.has(matchedTurnId) && messageId !== undefined) { + turnPromptIds.set(matchedTurnId, messageId); + } + undoAnchors.push({ rawId: matchedTurnId ?? nextTurnId }); + break; + } + case 'turn.ended': { + const rawId = record['turnId']; + if (typeof rawId !== 'number' || !Number.isInteger(rawId)) break; + const pendingIndex = pendingAnchorTurnIds.indexOf(rawId); + if (pendingIndex >= 0) pendingAnchorTurnIds.splice(pendingIndex, 1); + break; + } + case 'turn.cancel': { + const target = record['target']; + const turnId = record['turnId']; + if (target === 'queued' && typeof turnId === 'number' && turnId >= nextTurnId) { + cancelledTurnIds.add(turnId); + skipCancelledTurnIds(); + break; + } + if ( + target !== 'active' || + typeof turnId !== 'number' || + !Number.isInteger(turnId) || + turnId < 0 || + activeCancelTurnIds.has(turnId) + ) { + break; + } + activeCancelTurnIds.add(turnId); + if (record['reason'] !== 'user_cancelled') break; + pushSystem('interruption'); + break; + } + case 'context.undo': { + const count = record['count']; + if (typeof count !== 'number' || !Number.isSafeInteger(count) || count <= 0) break; + let firstUndone: number | undefined; + for (let i = 0; i < count && undoAnchors.length > undoAnchorFloor; i++) { + const anchor = undoAnchors.pop(); + if (anchor !== undefined) firstUndone = anchor.rawId; + } + if (firstUndone === undefined) break; + const cut = timelineIds.findIndex((id) => { + const ordinal = turnOrdinalOf(id); + return ordinal !== undefined && ordinal >= firstUndone; + }); + if (cut < 0) break; + timelineIds.length = cut; + for (let turnId = firstUndone; turnId < nextTurnId; turnId++) hiddenTurnIds.add(turnId); + if (currentTurn !== undefined && currentTurn >= firstUndone) currentTurn = undefined; + pushSystem('undo'); + break; + } + case 'context.clear': { + timelineIds.length = 0; + undoAnchorFloor = undoAnchors.length; + currentTurn = undefined; + pushSystem('clear'); + break; + } + case 'context.apply_compaction': { + undoAnchorFloor = undoAnchors.length; + pushSystem('compaction'); + break; + } + case 'goal.create': + case 'goal.clear': { + pushSystem('goal'); + break; + } + case 'goal.update': { + if ( + record['status'] === undefined && + record['budgetLimits'] === undefined && + record['turnsUsed'] === undefined + ) { + break; + } + pushSystem('goal'); + break; + } + case 'plan_mode.enter': { + pushSystem('plan.enter'); + break; + } + case 'plan_mode.exit': { + pushSystem('plan.exit'); + break; + } + case 'plan.revision': { + pushSystem('plan.revision'); + break; + } + case 'swarm_mode.enter': { + pushSystem('swarm.enter'); + break; + } + case 'swarm_mode.exit': { + pushSystem('swarm.exit'); + break; + } + default: + break; + } + } + return { + timelineIds, + systemCounts: sysIds.counts(), + anchorTurnOrdinals, + nextTurnId, + }; +} + +export interface WireStepFold { + readonly status: 'completed' | 'interrupted'; + readonly endedAt?: string; + readonly usage?: StepUsage; + readonly finishReason?: string; + readonly timing?: StepTiming; + readonly endReason?: string; + readonly endMessage?: string; +} + +export interface WireToolFold { + readonly step: number; + readonly name: string; + readonly args: unknown; + readonly output?: unknown; + readonly isError?: boolean; +} + +export interface WireTurnFold { + readonly steps: Map; + readonly texts: Map; + readonly tools: Map; +} + +interface StepRef { + readonly turn: number; + readonly step: number; +} + +export function foldWireTurn(records: readonly ContextRecord[], turnOrdinal: number): WireTurnFold { + const steps = new Map(); + const texts = new Map< + number, + { assistant: string; thinking: string; first: 'assistant' | 'thinking' } + >(); + const tools = new Map(); + const stepRefs = new Map(); + const stepOf = (uuid: string | undefined): StepRef | undefined => + uuid === undefined ? undefined : stepRefs.get(uuid); + for (const record of records) { + if (record.type === 'context.append_loop_event') { + const event = record['event'] as { type?: string } | undefined; + if (event?.type === undefined) continue; + switch (event.type) { + case 'step.begin': { + const e = event as { uuid: string; turnId?: string; step?: number }; + if (e.turnId === undefined || e.step === undefined) continue; + const turn = Number(e.turnId); + if (!Number.isInteger(turn)) continue; + stepRefs.set(e.uuid, { turn, step: e.step }); + continue; + } + case 'step.end': { + const e = event as { + uuid: string; + finishReason?: string; + rawFinishReason?: string; + providerFinishReason?: string; + usage?: TokenUsage; + llmFirstTokenLatencyMs?: number; + llmStreamDurationMs?: number; + }; + const ref = stepOf(e.uuid); + if (ref === undefined || ref.turn !== turnOrdinal) continue; + steps.set(ref.step, { + status: 'completed', + endedAt: record.time === undefined ? undefined : new Date(record.time).toISOString(), + usage: e.usage === undefined ? undefined : toSnakeUsage(e.usage), + finishReason: e.finishReason ?? e.rawFinishReason ?? e.providerFinishReason, + timing: + e.llmFirstTokenLatencyMs === undefined && e.llmStreamDurationMs === undefined + ? undefined + : { + llm_first_token_ms: e.llmFirstTokenLatencyMs, + llm_stream_duration_ms: e.llmStreamDurationMs, + }, + }); + continue; + } + case 'content.part': { + const e = event as { + stepUuid: string; + part: { type: string; text?: string; think?: string }; + turnId?: string; + step?: number; + }; + let ref = stepOf(e.stepUuid); + if (ref === undefined && e.turnId !== undefined && e.step !== undefined) { + const turn = Number(e.turnId); + if (Number.isInteger(turn)) ref = { turn, step: e.step }; + } + if (ref === undefined || ref.turn !== turnOrdinal) continue; + const entry = texts.get(ref.step) ?? { assistant: '', thinking: '', first: 'assistant' as const }; + if (e.part.type === 'text' && typeof e.part.text === 'string') { + if (entry.assistant.length === 0 && entry.thinking.length === 0) entry.first = 'assistant'; + entry.assistant += e.part.text; + } else if (e.part.type === 'think' && typeof e.part.think === 'string') { + if (entry.assistant.length === 0 && entry.thinking.length === 0) entry.first = 'thinking'; + entry.thinking += e.part.think; + } else { + continue; + } + texts.set(ref.step, entry); + continue; + } + case 'tool.call': { + const e = event as { + stepUuid: string; + toolCallId: string; + name: string; + args?: unknown; + turnId?: string; + step?: number; + }; + let ref = stepOf(e.stepUuid); + if (ref === undefined && e.turnId !== undefined && e.step !== undefined) { + const turn = Number(e.turnId); + if (Number.isInteger(turn)) ref = { turn, step: e.step }; + } + if (ref === undefined || ref.turn !== turnOrdinal) continue; + tools.set(e.toolCallId, { + step: ref.step, + name: e.name, + args: e.args, + output: tools.get(e.toolCallId)?.output, + isError: tools.get(e.toolCallId)?.isError, + }); + continue; + } + case 'tool.result': { + const e = event as { + toolCallId: string; + result: { output: unknown; isError?: boolean }; + }; + const existing = tools.get(e.toolCallId); + if (existing === undefined) continue; + tools.set(e.toolCallId, { + ...existing, + output: e.result.output, + isError: e.result.isError, + }); + continue; + } + default: + continue; + } + } + if (record.type === 'turn.step.interrupted') { + if (record['turnId'] !== turnOrdinal) continue; + const step = record['step']; + if (typeof step !== 'number') continue; + steps.set(step, { + status: 'interrupted', + endedAt: record.time === undefined ? undefined : new Date(record.time).toISOString(), + endReason: typeof record['reason'] === 'string' ? record['reason'] : undefined, + endMessage: typeof record['message'] === 'string' ? record['message'] : undefined, + }); + continue; + } + } + return { steps, texts, tools }; +} + +function toSnakeUsage(usage: TokenUsage): StepUsage { + return { + input_other: usage.inputOther, + output: usage.output, + input_cache_read: usage.inputCacheRead, + input_cache_creation: usage.inputCacheCreation, + }; +} diff --git a/packages/kap-server/src/services/projection/ids.ts b/packages/kap-server/src/services/projection/ids.ts new file mode 100644 index 0000000000000000000000000000000000000000..124e46ffce5b43a41de573b6898eb2ccce2d91fb --- /dev/null +++ b/packages/kap-server/src/services/projection/ids.ts @@ -0,0 +1,97 @@ +export const TODO_ENTITY_ID = 'todo'; + +export const DURABLE_SYSTEM_SUBTYPES = [ + 'compaction', + 'undo', + 'clear', + 'goal', + 'plan.enter', + 'plan.exit', + 'plan.revision', + 'swarm.enter', + 'swarm.exit', + 'interruption', +] as const; + +export type DurableSystemSubtype = (typeof DURABLE_SYSTEM_SUBTYPES)[number]; + +export const LIVE_ONLY_SYSTEM_SUBTYPES = ['hook', 'notice'] as const; + +export type LiveOnlySystemSubtype = (typeof LIVE_ONLY_SYSTEM_SUBTYPES)[number]; + +export function turnIdOf(ordinal: number): string { + return `t${ordinal}`; +} + +export function stepIdOf(turnId: string, ordinal: number): string { + return `${turnId}.${ordinal}`; +} + +export function textMessageIdOf(stepId: string, ordinal: number): string { + return `${stepId}.a${ordinal}`; +} + +export function turnUserMessageIdOf(turnId: string): string { + return `${turnId}.u0`; +} + +export function attachmentIdOf(baseId: string, ordinal: number): string { + return `${baseId}.att${ordinal}`; +} + +export function turnOrdinalOf(turnId: string): number | undefined { + if (!/^t\d+$/.test(turnId)) return undefined; + return Number(turnId.slice(1)); +} + +export function stepRefOf(stepId: string): { turnId: string; ordinal: number } | undefined { + const match = /^(t\d+)\.(\d+)$/.exec(stepId); + if (match === null) return undefined; + return { turnId: match[1]!, ordinal: Number(match[2]) }; +} + +export function systemIdOf(subtype: string, ordinal: number): string { + return `sys_${subtype}_${ordinal}`; +} + +export function isCompactionSystemId(id: string): boolean { + return id.startsWith(`sys_compaction_`); +} + +export function isUndoAnchorOrigin(origin: unknown): boolean { + const kind = (origin as { kind?: unknown } | null | undefined)?.kind; + if (kind === undefined || kind === 'user') return true; + const trigger = (origin as { trigger?: unknown } | null | undefined)?.trigger; + return (kind === 'skill_activation' || kind === 'plugin_command') && trigger === 'user-slash'; +} + +export function isVisibleTurnOrigin(origin: unknown): boolean { + const kind = (origin as { kind?: unknown } | null | undefined)?.kind; + if (kind === 'system_trigger') { + const name = (origin as { name?: unknown } | null | undefined)?.name; + return name === 'goal_continuation' || name === 'subagent'; + } + if (kind === 'skill_activation' || kind === 'plugin_command') { + return (origin as { trigger?: unknown } | null | undefined)?.trigger === 'user-slash'; + } + if (kind === 'injection' || kind === 'retry' || kind === 'compaction_summary') return false; + return true; +} + +export class SystemIdAllocator { + private readonly seqs = new Map(); + + next(subtype: string): string { + const ordinal = (this.seqs.get(subtype) ?? 0) + 1; + this.seqs.set(subtype, ordinal); + return systemIdOf(subtype, ordinal); + } + + seed(subtype: string, ordinal: number): void { + this.seqs.set(subtype, Math.max(this.seqs.get(subtype) ?? 0, ordinal)); + } + + counts(): ReadonlyMap { + return this.seqs; + } +} diff --git a/packages/kap-server/src/services/projection/index.ts b/packages/kap-server/src/services/projection/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a7ac88a955d6375691f5df94055cd0f30b40f1e --- /dev/null +++ b/packages/kap-server/src/services/projection/index.ts @@ -0,0 +1,7 @@ +export * from './agentProjector'; +export * from './events'; +export * from './heal'; +export * from './ids'; +export * from './projectionService'; +export * from './sessionProjection'; +export * from './sessionState'; diff --git a/packages/kap-server/src/services/projection/projectionService.ts b/packages/kap-server/src/services/projection/projectionService.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c71220251aab96202051697f89f8e8f89533261 --- /dev/null +++ b/packages/kap-server/src/services/projection/projectionService.ts @@ -0,0 +1,87 @@ +import { + followSessionLifecycles, + getLiveSessionById, + type IDisposable, + type Scope, +} from '@moonshot-ai/agent-core-v2'; + +import type { ServerMessage } from '../../protocol/messages'; +import { + SessionProjection, + type ProjectionLogger, +} from './sessionProjection'; + +export interface ProjectionServiceDeps { + readonly homeDir: string; + readonly core: Scope; + readonly logger?: ProjectionLogger; +} + +export class ProjectionService { + private readonly live = new Map(); + + constructor(private readonly deps: ProjectionServiceDeps) { + followSessionLifecycles(deps.core.accessor, (service) => { + const d1 = service.onDidCloseSession(({ sessionId }) => { + this.dropSession(sessionId); + }); + const d2 = service.onDidArchiveSession(({ sessionId }) => { + this.dropSession(sessionId); + }); + return { + dispose: () => { + d1.dispose(); + d2.dispose(); + }, + }; + }); + } + + forSessionLive(sessionId: string): SessionProjection | undefined { + const existing = this.live.get(sessionId); + if (existing !== undefined) { + if (getLiveSessionById(this.deps.core.accessor, sessionId) !== undefined) return existing; + this.dropSession(sessionId); + return undefined; + } + const session = getLiveSessionById(this.deps.core.accessor, sessionId); + if (session === undefined) return undefined; + let projection: SessionProjection; + try { + projection = new SessionProjection(sessionId, session, this.deps); + } catch (error) { + if (error instanceof Error && error.message === 'InstantiationService has been disposed') { + return undefined; + } + throw error; + } + this.live.set(sessionId, projection); + return projection; + } + + onMessage( + sessionId: string, + listener: (message: ServerMessage) => void, + ): IDisposable | undefined { + return this.forSessionLive(sessionId)?.onMessage(listener); + } + + recoveryMessages(sessionId: string): ServerMessage[] { + return this.forSessionLive(sessionId)?.recoveryMessages() ?? []; + } + + notifyContextCleared(sessionId: string, agentId: string): void { + this.live.get(sessionId)?.notifyContextCleared(agentId); + } + + inFlight(sessionId: string, agentId: string): { turn_id: string; step_id: string } | undefined { + return this.forSessionLive(sessionId)?.inFlight(agentId); + } + + dropSession(sessionId: string): void { + const entry = this.live.get(sessionId); + if (entry === undefined) return; + this.live.delete(sessionId); + entry.dispose(); + } +} diff --git a/packages/kap-server/src/services/projection/sessionProjection.ts b/packages/kap-server/src/services/projection/sessionProjection.ts new file mode 100644 index 0000000000000000000000000000000000000000..a32372227d4628bc622f5cfa1cefa1ac222732b2 --- /dev/null +++ b/packages/kap-server/src/services/projection/sessionProjection.ts @@ -0,0 +1,700 @@ +import { join } from 'node:path'; + +import { + IAgentGoalService, + IAgentLifecycleService, + IAgentLoopService, + IAgentPermissionModeService, + IAgentProfileService, + IAgentScopeContext, + IAgentStateService, + IAgentTaskService, + IAgentTodoService, + IEventBus, + INTERACTION_TAG_AGENT_ID, + INTERACTION_TAG_SESSION_ID, + ISessionActivityView, + ISessionIndex, + IWireService, + MAIN_AGENT_ID, + interactions, + toDisposable, + type AgentTaskInfo, + type IAgentScopeHandle, + type IDisposable, + type Interaction, + type ISessionScopeHandle, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { planKey } from '@moonshot-ai/agent-core-v2/features/plan/planOps'; +import { swarmKey } from '@moonshot-ai/agent-core-v2/features/swarm/swarmOps'; + +import { serverMessageSchema, type ServerMessage } from '../../protocol/messages'; +import { readLegacyStatus } from '../legacyStatus/legacyStatus'; +import { AgentStateTracker } from './agentState'; +import { AgentMessageProjector, toTurnOrigin, type ProjectorInteraction } from './agentProjector'; +import type { ProjectionBusEvent } from './events'; +import { foldTimelineSeed, foldWireTurn, readWireRecords, type ContextRecord } from './heal'; +import { isUndoAnchorOrigin } from './ids'; +import { SessionStateAggregator } from './sessionState'; + +const TURN_HEAL_DEBOUNCE_MS = 250; +const TASK_OUTPUT_TAIL_CHARS = 4096; + +export interface ProjectionLogger { + warn(obj: unknown, msg: string): void; +} + +export interface SessionProjectionDeps { + readonly homeDir: string; + readonly core: Scope; + readonly logger?: ProjectionLogger; +} + +export class SessionProjection { + private readonly projectors = new Map(); + private readonly agentDisposables = new Map(); + private readonly disposables: IDisposable[] = []; + private readonly listeners = new Set<(message: ServerMessage) => void>(); + private readonly aggregator = new SessionStateAggregator(); + private readonly agentStates = new Map(); + private readonly subagentTaskIds = new Map(); + private readonly interactionAgents = new Map(); + private readonly knownInteractions = new Set(); + private readonly unknownEventTypes = new Set(); + private readonly validationFailures = new Map(); + private readonly healTimers = new Map; timer: NodeJS.Timeout }>(); + private disposed = false; + + constructor( + readonly sessionId: string, + private readonly session: ISessionScopeHandle, + private readonly deps: SessionProjectionDeps, + ) { + const agents = session.accessor.get(IAgentLifecycleService); + for (const context of agents.list()) { + const handle = agents.handleOf(context.agentId); + if (handle !== undefined) this.subscribeAgent(handle); + } + this.disposables.push( + agents.onDidCreate( + this.guard((context) => { + const handle = agents.handleOf(context.agentId); + if (handle !== undefined) this.subscribeAgent(handle); + }), + ), + agents.onDidClose( + this.guard((context) => { + this.dropAgent(context.agentId); + }), + ), + toDisposable( + interactions.onDidChangePending( + this.guard(() => { + this.onInteractionsChanged(); + }), + ), + ), + toDisposable( + interactions.onDidResolve( + this.guard(({ id, response }) => { + this.onInteractionResolve(id, response); + }), + ), + ), + ); + for (const pending of this.pendingInteractions()) { + this.announce(pending, false); + } + const activity = session.accessor.get(ISessionActivityView) as + | ISessionActivityView + | undefined; + if (activity !== undefined) { + this.aggregator.feedSessionActivity(activity.state()); + this.disposables.push( + activity.onDidChange( + this.guard((event) => { + this.aggregator.feedSessionActivity(event.state); + this.emitState(); + }), + ), + ); + } + } + + onMessage(listener: (message: ServerMessage) => void): IDisposable { + this.listeners.add(listener); + return { + dispose: () => { + this.listeners.delete(listener); + }, + }; + } + + recoveryMessages(): ServerMessage[] { + const messages: ServerMessage[] = [this.aggregator.snapshot(this.sessionId)]; + for (const tracker of this.agentStates.values()) { + const state = tracker.snapshot(this.sessionId); + if (state !== undefined) messages.push(state); + } + for (const projector of this.projectors.values()) { + messages.push(...projector.recoveryMessages()); + } + return messages.filter((message) => this.validate(message) !== undefined); + } + + notifyContextCleared(agentId: string): void { + const projector = this.projectors.get(agentId); + if (projector === undefined) return; + this.emitAll(projector.notifyContextCleared()); + } + + inFlight(agentId: string): { turn_id: string; step_id: string } | undefined { + return this.projectors.get(agentId)?.inFlight(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const pending of this.healTimers.values()) clearTimeout(pending.timer); + this.healTimers.clear(); + for (const list of this.agentDisposables.values()) { + for (const d of list) d.dispose(); + } + this.agentDisposables.clear(); + for (const d of this.disposables) d.dispose(); + for (const projector of this.projectors.values()) projector.dispose(); + this.projectors.clear(); + this.agentStates.clear(); + this.listeners.clear(); + this.interactionAgents.clear(); + this.knownInteractions.clear(); + } + + private subscribeAgent(handle: IAgentScopeHandle): void { + const agentId = handle.id; + if (this.projectors.has(agentId)) return; + const loop = handle.accessor.get(IAgentLoopService) as IAgentLoopService | undefined; + this.trackAgentState(handle, loop); + const projector = new AgentMessageProjector( + agentId, + this.sessionId, + this.subagentTaskIds, + { + stepOrdinal: (turnId) => { + const turn = loop?.snapshot().turn; + return turn === undefined || `t${turn.turnId}` !== turnId ? undefined : turn.step; + }, + resolvePlanRevisionKey: (key) => + handle.accessor.get(IAgentScopeContext).scope(key), + }, + { + onUnknownEvent: (type) => { + if (this.unknownEventTypes.has(type)) return; + this.unknownEventTypes.add(type); + this.deps.logger?.warn( + { sessionId: this.sessionId, agentId, type }, + 'projection: unhandled engine event type, dropped', + ); + }, + onDeferred: (messages) => { + if (!this.disposed) this.emitAll(messages); + }, + }, + ); + this.projectors.set(agentId, projector); + const agentState = handle.accessor.get(IAgentStateService) as IAgentStateService | undefined; + const planMode = + agentState?.has(planKey) === true ? agentState.get(planKey).active : undefined; + const swarmMode = + agentState?.has(swarmKey) === true ? agentState.get(swarmKey) !== null : undefined; + projector.seedModes({ planMode, swarmMode }); + const disposables: IDisposable[] = []; + const bus = handle.accessor.get(IEventBus) as IEventBus | undefined; + if (bus !== undefined) { + disposables.push( + bus.subscribe( + this.guard((event) => { + this.onBusEvent(agentId, event as ProjectionBusEvent); + }), + ), + ); + } + const todo = handle.accessor.get(IAgentTodoService) as IAgentTodoService | undefined; + if (todo !== undefined) { + projector.seedTodo(todo.get()); + disposables.push( + todo.onDidChange( + this.guard((items) => { + this.emitAll(projector.todoChanged(items)); + }), + ), + ); + } + const tasks = handle.accessor.get(IAgentTaskService) as IAgentTaskService | undefined; + for (const info of tasks?.list() ?? []) projector.seedTask(info); + const status = loop?.snapshot(); + if (status?.state === 'running' && status.activeTurnId !== undefined) { + const prompts = handle.accessor.get(IAgentLoopService) as IAgentLoopService | undefined; + const active = + status.activePromptId === undefined ? undefined : prompts?.promptHandle(status.activePromptId); + const rawOrigin = active?.message.origin; + projector.seedActiveTurn({ + turnId: status.activeTurnId, + promptId: active?.id, + userMessageId: active?.userMessageId, + origin: + rawOrigin === undefined + ? undefined + : toTurnOrigin(rawOrigin, agentId, this.subagentTaskIds), + anchor: isUndoAnchorOrigin(rawOrigin), + }); + } + if (agentId === MAIN_AGENT_ID) { + this.seedMainAgent(handle, disposables, { planMode, swarmMode }); + } + this.agentDisposables.set(agentId, disposables); + void this.seedTimelineFromWire(agentId, projector); + } + + private seedMainAgent( + handle: IAgentScopeHandle, + disposables: IDisposable[], + modes: { planMode?: boolean; swarmMode?: boolean }, + ): void { + this.aggregator.feedMainStatus({ planMode: modes.planMode, swarmMode: modes.swarmMode }); + const legacy = readLegacyStatus(handle); + if (legacy !== undefined) { + this.aggregator.feedSeed({ + model: legacy.model.length > 0 ? legacy.model : undefined, + usage: legacy.usage, + contextTokens: legacy.contextTokens, + maxContextTokens: legacy.maxContextTokens, + }); + } + const profile = handle.accessor.get(IAgentProfileService) as IAgentProfileService | undefined; + if (profile !== undefined) { + this.aggregator.feedSeed({ thinkingEffort: profile.getEffectiveThinkingLevel() }); + } + const permission = handle.accessor.get(IAgentPermissionModeService) as + | IAgentPermissionModeService + | undefined; + if (permission !== undefined) { + this.aggregator.feedSeed({ permission: permission.mode }); + disposables.push( + permission.onDidChangeMode( + this.guard(({ mode }) => { + this.aggregator.feedSeed({ permission: mode }); + this.emitState(); + }), + ), + ); + } + const goal = handle.accessor.get(IAgentGoalService) as IAgentGoalService | undefined; + if (goal !== undefined) { + this.aggregator.feedGoal(goal.getGoal().goal); + } + } + + private trackAgentState(handle: IAgentScopeHandle, loop: IAgentLoopService | undefined): void { + const agentId = handle.id; + if (this.agentStates.has(agentId)) return; + const tracker = new AgentStateTracker(agentId, new Date().toISOString()); + this.agentStates.set(agentId, tracker); + const running = loop?.snapshot().state === 'running'; + const createdAt = new Date().toISOString(); + if (agentId === MAIN_AGENT_ID) { + const profile = handle.accessor.get(IAgentProfileService) as IAgentProfileService | undefined; + tracker.seedMain(profile?.data().profileName ?? '', createdAt, running); + } else { + const scopeContext = handle.accessor.get(IAgentScopeContext) as + | { forkedFrom?: string } + | undefined; + if (scopeContext?.forkedFrom !== undefined && scopeContext.forkedFrom.length > 0) { + const profile = handle.accessor.get(IAgentProfileService) as + | IAgentProfileService + | undefined; + tracker.seedBtw(profile?.data().profileName ?? '', createdAt, running); + } else { + const mainHandle = this.agentHandle(MAIN_AGENT_ID); + const tasks = mainHandle?.accessor.get(IAgentTaskService) as IAgentTaskService | undefined; + const link = tasks + ?.list(false) + .find( + (info) => + info.kind === 'agent' && (info as { agentId?: string }).agentId === agentId, + ); + const profile = handle.accessor.get(IAgentProfileService) as + | IAgentProfileService + | undefined; + tracker.seedToolFromTask(profile?.data().profileName ?? '', createdAt, link); + } + } + this.emitAgentState(agentId); + } + + private emitAgentState(agentId: string): void { + const message = this.agentStates.get(agentId)?.snapshot(this.sessionId); + if (message !== undefined) this.emit(message); + } + + private dropAgent(agentId: string): void { + for (const d of this.agentDisposables.get(agentId) ?? []) d.dispose(); + this.agentDisposables.delete(agentId); + this.projectors.get(agentId)?.dispose(); + this.projectors.delete(agentId); + const timer = this.healTimers.get(agentId); + if (timer !== undefined) { + clearTimeout(timer.timer); + this.healTimers.delete(agentId); + } + const tracker = this.agentStates.get(agentId); + if (tracker !== undefined) { + if (tracker.close(new Date().toISOString())) this.emitAgentState(agentId); + this.agentStates.delete(agentId); + } + } + + private onBusEvent(agentId: string, event: ProjectionBusEvent): void { + if (this.disposed) return; + const projector = this.projectors.get(agentId); + if (projector === undefined) return; + this.emitAll(projector.map(event)); + this.onAgentStateEvent(agentId, event); + if (event.type === 'task.terminated') { + const info = (event as { info?: AgentTaskInfo }).info; + if (info !== undefined) void this.patchTaskOutputTail(agentId, info.taskId); + } + for (const ordinal of projector.takeEndedTurnOrdinals()) { + this.scheduleHeal(agentId, ordinal); + } + if (agentId === MAIN_AGENT_ID) { + if (event.type === 'agent.status.updated') { + this.aggregator.feedMainStatus(event); + } else if (event.type === 'goal.updated') { + this.aggregator.feedGoal(event.snapshot); + } else if (event.type === 'plan.revision') { + const handle = this.agentHandle(MAIN_AGENT_ID); + const path = handle?.accessor.get(IAgentScopeContext).scope(event.key) ?? event.key; + this.aggregator.feedPlanRevision(path, event.version); + } + } + this.emitState(); + } + + private onAgentStateEvent(agentId: string, event: ProjectionBusEvent): void { + switch (event.type) { + case 'subagent.spawned': { + const spawned = event as { + subagentId: string; + subagentName: string; + parentToolCallId: string; + parentAgentId?: string; + swarmIndex?: number; + }; + const tracker = this.agentStates.get(spawned.subagentId); + if (tracker?.seedToolSpawned(spawned) === true) this.emitAgentState(spawned.subagentId); + return; + } + case 'subagent.started': { + const tracker = this.agentStates.get((event as { subagentId: string }).subagentId); + if (tracker?.runStarted() === true) this.emitAgentState(tracker.agentId); + return; + } + case 'subagent.completed': { + const tracker = this.agentStates.get((event as { subagentId: string }).subagentId); + if ( + tracker?.runFinished( + 'completed', + new Date((event as { time?: number }).time ?? Date.now()).toISOString(), + ) === true + ) { + this.emitAgentState(tracker.agentId); + } + return; + } + case 'subagent.failed': { + const tracker = this.agentStates.get((event as { subagentId: string }).subagentId); + if ( + tracker?.runFinished( + 'failed', + new Date((event as { time?: number }).time ?? Date.now()).toISOString(), + ) === true + ) { + this.emitAgentState(tracker.agentId); + } + return; + } + case 'subagent.cancelled': { + const tracker = this.agentStates.get((event as { subagentId: string }).subagentId); + if ( + tracker?.runFinished( + 'interrupted', + new Date((event as { time?: number }).time ?? Date.now()).toISOString(), + ) === true + ) { + this.emitAgentState(tracker.agentId); + } + return; + } + case 'turn.started': { + const tracker = this.agentStates.get(agentId); + if (tracker === undefined) return; + if (tracker.turnStarted()) this.emitAgentState(agentId); + this.recomputeAgentTurn(agentId); + return; + } + case 'turn.ended': { + const tracker = this.agentStates.get(agentId); + if (tracker === undefined) return; + if (tracker.turnEnded()) this.emitAgentState(agentId); + this.recomputeAgentTurn(agentId); + return; + } + case 'turn.step.started': + case 'tool.call.started': + case 'tool.result': + case 'turn.step.interrupted': + this.recomputeAgentTurn(agentId); + return; + case 'turn.step.retrying': + queueMicrotask(() => { + if (!this.disposed) this.recomputeAgentTurn(agentId); + }); + return; + default: + return; + } + } + + private recomputeAgentTurn(agentId: string): void { + const tracker = this.agentStates.get(agentId); + if (tracker === undefined) return; + const loop = this.agentHandle(agentId)?.accessor.get(IAgentLoopService) as + | IAgentLoopService + | undefined; + if (loop === undefined) return; + const snapshot = loop.snapshot(); + if (tracker.recompute(snapshot)) this.emitAgentState(agentId); + } + + private agentHandle(agentId: string): IAgentScopeHandle | undefined { + return this.session.accessor.get(IAgentLifecycleService).handleOf(agentId); + } + + private guard(fn: (...args: A) => void): (...args: A) => void { + return (...args: A) => { + try { + fn(...args); + } catch (error) { + this.deps.logger?.warn( + { + sessionId: this.sessionId, + err: error instanceof Error ? error.message : String(error), + }, + 'projection: event callback failed, continuing', + ); + } + }; + } + + private pendingInteractions(): readonly Interaction[] { + return interactions.findAll({ + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: this.sessionId }, + }); + } + + private onInteractionsChanged(): void { + for (const pending of this.pendingInteractions()) { + if (this.knownInteractions.has(pending.id)) continue; + this.announce(pending, true); + } + } + + private announce(interaction: Interaction, emit: boolean): void { + if (interaction.kind !== 'approval' && interaction.kind !== 'question') return; + this.knownInteractions.add(interaction.id); + const agentId = interactionAgentId(interaction); + this.interactionAgents.set(interaction.id, agentId); + const projector = this.projectorFor(agentId); + if (projector === undefined) return; + const request: ProjectorInteraction = { + id: interaction.id, + kind: interaction.kind, + payload: interaction.payload, + createdAt: interaction.createdAt, + }; + const ops = projector.interactionRequested(request); + if (emit) this.emitAll(ops); + } + + private onInteractionResolve(id: string, response: unknown): void { + this.knownInteractions.delete(id); + const agentId = this.interactionAgents.get(id); + if (agentId === undefined) return; + this.interactionAgents.delete(id); + const projector = this.projectors.get(agentId); + if (projector === undefined) return; + this.emitAll(projector.interactionResolved(id, response)); + this.emitState(); + } + + private projectorFor(agentId: string): AgentMessageProjector | undefined { + const existing = this.projectors.get(agentId); + if (existing !== undefined) return existing; + const handle = this.agentHandle(agentId); + if (handle === undefined) return undefined; + this.subscribeAgent(handle); + return this.projectors.get(agentId); + } + + private scheduleHeal(agentId: string, ordinal: number): void { + const existing = this.healTimers.get(agentId); + if (existing !== undefined) { + existing.ordinals.add(ordinal); + existing.timer.refresh(); + return; + } + const ordinals = new Set([ordinal]); + const timer = setTimeout(() => { + this.healTimers.delete(agentId); + void this.healTurns(agentId, ordinals); + }, TURN_HEAL_DEBOUNCE_MS); + timer.unref(); + this.healTimers.set(agentId, { ordinals, timer }); + } + + private async seedTimelineFromWire( + agentId: string, + projector: AgentMessageProjector, + ): Promise { + const records = await this.readAgentWire(agentId); + if (records === undefined) return; + if (this.disposed || this.projectors.get(agentId) !== projector) return; + projector.applyTimelineSeed(foldTimelineSeed(records)); + } + + private async healTurns(agentId: string, ordinals: ReadonlySet): Promise { + const projector = this.projectors.get(agentId); + if (projector === undefined || this.disposed) return; + const records = await this.readAgentWire(agentId); + if (records === undefined) return; + if (this.disposed || this.projectors.get(agentId) !== projector) return; + for (const ordinal of ordinals) { + this.emitAll(projector.healTurn(ordinal, foldWireTurn(records, ordinal))); + } + } + + private async patchTaskOutputTail(agentId: string, taskId: string): Promise { + const tasks = this.agentHandle(agentId)?.accessor.get(IAgentTaskService); + if (tasks === undefined) return; + let tail: string; + try { + tail = await tasks.readOutput(taskId, TASK_OUTPUT_TAIL_CHARS); + } catch { + return; + } + if (this.disposed || tail.length === 0) return; + this.emitAll(this.projectors.get(agentId)?.taskOutputUpdated(taskId, tail) ?? []); + } + + private async readAgentWire(agentId: string): Promise { + const index = this.deps.core.accessor.get(ISessionIndex) as ISessionIndex | undefined; + if (index === undefined) return undefined; + const summary = await index.get(this.sessionId); + if (summary === undefined) return undefined; + const wire = this.agentHandle(agentId)?.accessor.get(IWireService); + if (wire !== undefined) { + try { + await wire.flush(); + } catch (error) { + this.deps.logger?.warn( + { + sessionId: this.sessionId, + agentId, + err: error instanceof Error ? error.message : error, + }, + 'projection: wire flush failed, reading what is on disk', + ); + } + } + try { + return await readWireRecords( + join( + this.deps.homeDir, + 'sessions', + summary.workspaceId, + this.sessionId, + 'agents', + agentId, + 'wire.jsonl', + ), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + this.deps.logger?.warn( + { + sessionId: this.sessionId, + agentId, + err: error instanceof Error ? error.message : error, + }, + 'projection: wire read failed, continuing without it', + ); + return undefined; + } + } + + private emitAll(messages: ServerMessage[]): void { + for (const message of messages) this.emit(message); + } + + private emitState(): void { + const state = this.aggregator.changed(this.sessionId); + if (state !== undefined) this.emit(state); + } + + private emit(message: ServerMessage): void { + const parsed = this.validate(message); + if (parsed === undefined) return; + for (const listener of this.listeners) { + try { + listener(parsed); + } catch { + } + } + } + + private validate(message: ServerMessage): ServerMessage | undefined { + const parsed = serverMessageSchema.safeParse(message); + if (parsed.success) return parsed.data; + const type = String((message as { type?: unknown }).type); + const count = (this.validationFailures.get(type) ?? 0) + 1; + this.validationFailures.set(type, count); + if (count === 1 || count % 100 === 0) { + this.deps.logger?.warn( + { + sessionId: this.sessionId, + type, + count, + issues: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }, + 'projection: outbound message failed schema validation, dropped', + ); + } + return undefined; + } +} + +function interactionAgentId(interaction: Interaction): string { + const payloadAgent = (interaction.payload as { agentId?: unknown }).agentId; + const tag = interaction.tags[INTERACTION_TAG_AGENT_ID]; + return ( + (typeof tag === 'string' ? tag : undefined) ?? + (typeof payloadAgent === 'string' ? payloadAgent : undefined) ?? + MAIN_AGENT_ID + ); +} diff --git a/packages/kap-server/src/services/projection/sessionState.ts b/packages/kap-server/src/services/projection/sessionState.ts new file mode 100644 index 0000000000000000000000000000000000000000..f15d70b32343da34584d39d14de512abb38cf7fe --- /dev/null +++ b/packages/kap-server/src/services/projection/sessionState.ts @@ -0,0 +1,161 @@ +import type { + PermissionMode, + SessionActivityState, + TokenUsage, + UsageStatus, +} from '@moonshot-ai/agent-core-v2'; + +import type { + SessionStateGoal, + SessionStateMessage, + SessionStateModes, + SessionStateUsage, + StepUsage, +} from '../../protocol/messages'; + +interface GoalSnapshotLike { + readonly objective: string; + readonly status: 'active' | 'paused' | 'blocked' | 'complete'; + readonly completionCriterion?: string; + readonly tokensUsed: number; + readonly budget: { readonly tokenBudget: number | null }; +} + +export class SessionStateAggregator { + private sessionActivity: SessionActivityState | undefined; + private model: string | undefined; + private thinkingEffort: string | undefined; + private permission: 'manual' | 'yolo' | 'auto' | undefined; + private usage: SessionStateUsage | undefined; + private contextTokens: number | undefined; + private maxContextTokens: number | undefined; + private goal: SessionStateGoal | null | undefined; + private planMode = false; + private swarmMode = false; + private planRevision: { path: string; version: number } | undefined; + private lastEmittedJson: string | undefined; + + feedSessionActivity(state: SessionActivityState): void { + this.sessionActivity = state; + } + + feedMainStatus(event: { + model?: string; + thinkingEffort?: string; + usage?: UsageStatus; + contextTokens?: number; + maxContextTokens?: number; + planMode?: boolean; + swarmMode?: boolean; + permission?: 'manual' | 'yolo' | 'auto'; + }): void { + if (event.model !== undefined) this.model = event.model; + if (event.thinkingEffort !== undefined) this.thinkingEffort = event.thinkingEffort; + if (event.usage !== undefined) this.usage = usageToWire(event.usage); + if (event.contextTokens !== undefined) this.contextTokens = event.contextTokens; + if (event.maxContextTokens !== undefined) this.maxContextTokens = event.maxContextTokens; + if (event.planMode !== undefined) this.planMode = event.planMode; + if (event.swarmMode !== undefined) this.swarmMode = event.swarmMode; + if (event.permission !== undefined) this.permission = event.permission; + } + + feedSeed(seed: { + model?: string; + thinkingEffort?: string; + usage?: UsageStatus; + contextTokens?: number; + maxContextTokens?: number; + permission?: PermissionMode; + }): void { + if (seed.model !== undefined) this.model = seed.model; + if (seed.thinkingEffort !== undefined) this.thinkingEffort = seed.thinkingEffort; + if (seed.usage !== undefined) this.usage = usageToWire(seed.usage); + if (seed.contextTokens !== undefined) this.contextTokens = seed.contextTokens; + if (seed.maxContextTokens !== undefined) this.maxContextTokens = seed.maxContextTokens; + if (seed.permission !== undefined) this.permission = seed.permission; + } + + feedGoal(snapshot: GoalSnapshotLike | null): void { + this.goal = + snapshot === null + ? null + : { + objective: snapshot.objective, + status: snapshot.status, + completion_criterion: snapshot.completionCriterion, + budget_used: snapshot.tokensUsed, + budget_limit: snapshot.budget.tokenBudget ?? undefined, + }; + } + + feedPlanRevision(path: string, version: number): void { + this.planRevision = { path, version }; + } + + snapshot(sessionId: string): SessionStateMessage { + return this.build(sessionId); + } + + changed(sessionId: string): SessionStateMessage | undefined { + const next = this.build(sessionId); + const { timestamp: _timestamp, ...comparable } = next; + const json = JSON.stringify(comparable); + if (json === this.lastEmittedJson) return undefined; + this.lastEmittedJson = json; + return next; + } + + private build(sessionId: string): SessionStateMessage { + const busy = this.sessionActivity?.busy ?? false; + const modes = this.computeModes(); + return { + type: 'session.state', + session_id: sessionId, + timestamp: Date.now(), + status: busy ? 'running' : 'idle', + pending_interaction: this.sessionActivity?.pendingInteraction, + model: this.model, + thinking_effort: this.thinkingEffort, + permission: this.permission, + usage: this.usage, + context_tokens: this.contextTokens, + max_context_tokens: this.maxContextTokens, + goal: this.goal ?? undefined, + modes, + }; + } + + private computeModes(): SessionStateModes | undefined { + const modes: SessionStateModes = {}; + if (this.planMode) { + modes.plan = { + review_path: this.planRevision?.path, + version: this.planRevision?.version, + }; + } + if (this.swarmMode) modes.swarm = {}; + return modes.plan === undefined && modes.swarm === undefined ? undefined : modes; + } +} + +function usageToWire(usage: UsageStatus): SessionStateUsage { + return { + by_model: + usage.byModel === undefined + ? undefined + : Object.fromEntries( + Object.entries(usage.byModel).map(([model, u]) => [model, toSnakeUsage(u)]), + ), + current_turn: usage.currentTurn === undefined ? undefined : toSnakeUsage(usage.currentTurn), + total: usage.total === undefined ? undefined : toSnakeUsage(usage.total), + }; +} + +function toSnakeUsage(usage: TokenUsage): StepUsage { + return { + input_other: usage.inputOther, + output: usage.output, + input_cache_read: usage.inputCacheRead, + input_cache_creation: usage.inputCacheCreation, + }; +} diff --git a/packages/kap-server/src/services/telemetry.ts b/packages/kap-server/src/services/telemetry.ts new file mode 100644 index 0000000000000000000000000000000000000000..264228b60a43d8dc9d7adb5fb6c612c5d1fc1661 --- /dev/null +++ b/packages/kap-server/src/services/telemetry.ts @@ -0,0 +1,75 @@ +import { + type CloudAppender, + createCloudAppender, + IBootstrapService, + IConfigService, + type IDisposable, + IOAuthToolkit, + ITelemetryService, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { createKimiDeviceId } from '@moonshot-ai/kimi-code-oauth'; + +const SERVER_TELEMETRY_APP_NAME = 'kimi-code-cli'; +const SERVER_TELEMETRY_UI_MODE = 'web'; +const TELEMETRY_DISABLE_ENV = 'KIMI_DISABLE_TELEMETRY'; +const TELEMETRY_DISABLE_ENV_VALUES = new Set(['1', 'true', 't', 'yes', 'y']); + +const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 3_000; + +export interface ServerTelemetry { + readonly appender?: CloudAppender; + readonly registration?: IDisposable; +} + +function isTelemetryDisabledByEnv(core: Scope): boolean { + const value = core.accessor.get(IBootstrapService).getEnv(TELEMETRY_DISABLE_ENV); + return value !== undefined && TELEMETRY_DISABLE_ENV_VALUES.has(value.trim().toLowerCase()); +} + +export async function initializeServerTelemetry( + core: Scope, + homeDir: string, +): Promise { + const service = core.accessor.get(ITelemetryService); + const config = core.accessor.get(IConfigService); + await config.ready; + const enabled = config.get('telemetry') !== false; + if (!enabled || isTelemetryDisabledByEnv(core)) return {}; + + const auth = core.accessor.get(IOAuthToolkit); + const appender = createCloudAppender(core.accessor, { + deviceId: createKimiDeviceId(homeDir), + appName: SERVER_TELEMETRY_APP_NAME, + uiMode: SERVER_TELEMETRY_UI_MODE, + model: config.get('defaultModel') ?? undefined, + getAccessToken: async () => (await auth.getCachedAccessToken()) ?? null, + }); + const registration = service.addAppender(appender); + try { + appender.startPeriodicFlush(); + } catch (error) { + registration.dispose(); + throw error; + } + return { appender, registration }; +} + +export async function shutdownServerTelemetry( + telemetry: ServerTelemetry, + deadlineMs = Date.now() + TELEMETRY_SHUTDOWN_TIMEOUT_MS, +): Promise { + telemetry.registration?.dispose(); + if (telemetry.appender === undefined) return; + let timer: ReturnType | undefined; + try { + await Promise.race([ + telemetry.appender.shutdown(), + new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, deadlineMs - Date.now())); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} diff --git a/packages/kap-server/src/services/transcript/coreBinding.ts b/packages/kap-server/src/services/transcript/coreBinding.ts new file mode 100644 index 0000000000000000000000000000000000000000..934facabc9749644ef832b26258ac6a827083b55 --- /dev/null +++ b/packages/kap-server/src/services/transcript/coreBinding.ts @@ -0,0 +1,316 @@ +import { + IAgentLifecycleService, + IAgentConversationUndoParticipantRegistry, + IAgentLoopService, + IAgentScopeContext, + IAgentTaskService, + IEventBus, + INTERACTION_TAG_AGENT_ID, + INTERACTION_TAG_SESSION_ID, + ISessionMetadata, + MAIN_AGENT_ID, + interactions, + toDisposable, + type AgentMeta, + type IDisposable, + type IAgentScopeHandle, + type Interaction, + type ISessionScopeHandle, +} from '@moonshot-ai/agent-core-v2'; +import type { AgentDescriptor, TranscriptChangeEvent, TranscriptStore } from '@moonshot-ai/transcript'; + +import { legacyApprovalsOf } from '../legacyStatus/legacyActivity'; +import { + AgentTranscriptProjector, + type ProjectorBusEvent, + type ProjectorInteraction, +} from './coreEventMap'; + +export interface TranscriptBindingLogger { + warn(obj: unknown, msg: string): void; +} + +export interface TranscriptBinding extends IDisposable { + seedPendingInteractions(agentId?: string): void; +} + +export function bindSessionTranscript( + store: TranscriptStore, + session: ISessionScopeHandle, + logger?: TranscriptBindingLogger, + onOps?: (event: TranscriptChangeEvent) => void, + reconcileAfterUndo?: (agentId: string) => Promise, +): TranscriptBinding { + const agents = session.accessor.get(IAgentLifecycleService); + const pendingInteractions = (): readonly Interaction[] => + interactions.findAll({ + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: session.id }, + }); + const disposables: IDisposable[] = []; + const agentDisposables = new Map(); + const subscribedAgents = new Set(); + const projectors = new Map(); + const interactionAgents = new Map(); + const knownInteractions = new Set(); + const unseeded = new Map(); + const earlyResolves = new Map(); + const seededAgents = new Set(); + let seededAll = false; + const isSeeded = (agentId: string): boolean => seededAll || seededAgents.has(agentId); + + const applyOps = (agentId: string, ops: ReturnType): void => { + if (ops.length === 0) return; + const result = store.ensureAgent(agentId).apply(ops); + if (result.gap !== undefined) { + logger?.warn( + { sessionId: store.sessionId, agentId, gap: result.gap }, + 'transcript: append gap — producer/consumer skew', + ); + return; + } + onOps?.({ agentId, ops }); + }; + + const projectorFor = (agentId: string): AgentTranscriptProjector => { + let projector = projectors.get(agentId); + if (projector === undefined) { + projector = new AgentTranscriptProjector(agentId, store.sessionId, { + stepFrames: (turnId, stepId) => + store.getAgent(agentId)?.getTurn(turnId)?.steps.find((s) => s.stepId === stepId)?.frames, + toolFrame: (toolCallId) => { + const transcript = store.getAgent(agentId); + if (transcript === undefined) return undefined; + for (const item of transcript.getItems()) { + if (item.kind !== 'turn') continue; + for (const step of item.steps) { + for (const frame of step.frames) { + if (frame.kind === 'tool' && frame.toolCallId === toolCallId) { + return { turnId: item.turnId, stepId: step.stepId, frame }; + } + } + } + } + return undefined; + }, + stepOrdinal: (turnId) => { + const agentHandle = agents.handleOf(agentId); + if (agentHandle === undefined) return undefined; + const turn = agentHandle.accessor.get(IAgentLoopService)?.snapshot().turn; + return turn === undefined || `t${turn.turnId}` !== turnId ? undefined : turn.step; + }, + activitySnapshot: () => + agents.handleOf(agentId)?.accessor.get(IAgentLoopService)?.snapshot() ?? {}, + pendingApprovals: () => { + const agentHandle = agents.handleOf(agentId); + return agentHandle === undefined ? [] : legacyApprovalsOf(agentHandle); + }, + turn: (turnId) => store.getAgent(agentId)?.getTurn(turnId), + resolvePlanRevisionKey: (key) => + agents.handleOf(agentId)?.accessor.get(IAgentScopeContext).scope(key) ?? key, + }); + const agentHandle = agents.handleOf(agentId); + if (agentHandle !== undefined) { + const tasks = agentHandle.accessor.get(IAgentTaskService)?.list() ?? []; + for (const info of tasks) { + if (info.kind === 'agent' && typeof info.agentId === 'string' && info.agentId.length > 0) { + applyOps( + agentId, + projector.seedSubagentTask({ + taskId: info.taskId, + agentId: info.agentId, + description: info.description, + status: info.status, + detached: info.detached ?? false, + startedAt: info.startedAt, + }), + ); + } + } + } + projectors.set(agentId, projector); + } + return projector; + }; + + const subscribeAgent = (handle: IAgentScopeHandle): void => { + if (subscribedAgents.has(handle.id)) return; + subscribedAgents.add(handle.id); + const projector = projectorFor(handle.id); + store.ensureAgent(handle.id, { agentId: handle.id }); + const bus = handle.accessor.get(IEventBus); + const busD = bus.subscribe((event) => + applyOps(handle.id, projectorFor(handle.id).map(event as ProjectorBusEvent)), + ); + const loopStatus = handle.accessor.get(IAgentLoopService)?.snapshot(); + if (loopStatus?.state === 'running' && loopStatus.activeTurnId !== undefined) { + const promptId = loopStatus.activePromptId; + projector.seedActiveTurn({ turnId: loopStatus.activeTurnId, promptId }); + } + const list = agentDisposables.get(handle.id) ?? []; + list.push(busD); + if (reconcileAfterUndo !== undefined) { + list.push(handle.accessor.get(IAgentConversationUndoParticipantRegistry).register({ + id: 'transcript', + phase: 'after-flush', + reconcileAfterUndo: async () => { + await reconcileAfterUndo(handle.id); + projectors.delete(handle.id); + }, + })); + } + agentDisposables.set(handle.id, list); + }; + + const interactionAgentId = (interaction: Interaction): string => { + const payloadAgent = (interaction.payload as { agentId?: unknown }).agentId; + const tag = interaction.tags[INTERACTION_TAG_AGENT_ID]; + return ( + (typeof tag === 'string' ? tag : undefined) ?? + (typeof payloadAgent === 'string' ? payloadAgent : undefined) ?? + MAIN_AGENT_ID + ); + }; + + const announceInteraction = (interaction: Interaction): void => { + if (interaction.kind !== 'approval' && interaction.kind !== 'question') return; + const agentId = interactionAgentId(interaction); + interactionAgents.set(interaction.id, agentId); + const request: ProjectorInteraction = { + id: interaction.id, + kind: interaction.kind, + payload: interaction.payload, + createdAt: interaction.createdAt, + }; + applyOps(agentId, projectorFor(agentId).mapInteractionRequested(request)); + }; + + const refreshDescriptors = (): void => { + void session.accessor + .get(ISessionMetadata) + .read() + .then((meta) => { + for (const agentId of projectors.keys()) { + store.describeAgent(descriptorFromMeta(agentId, meta.agents?.[agentId])); + } + }) + .catch(() => { + }); + }; + + for (const agent of agents.list()) { + const handle = agents.handleOf(agent.agentId); + if (handle !== undefined) subscribeAgent(handle); + } + disposables.push( + agents.onDidCreate((context) => { + const handle = agents.handleOf(context.agentId); + if (handle !== undefined) subscribeAgent(handle); + seededAgents.add(context.agentId); + refreshDescriptors(); + }), + agents.onDidClose((context) => { + const agentId = context.agentId; + for (const d of agentDisposables.get(agentId) ?? []) d.dispose(); + agentDisposables.delete(agentId); + subscribedAgents.delete(agentId); + projectors.delete(agentId); + store.markDisposed(agentId, new Date().toISOString()); + }), + ); + + for (const pending of pendingInteractions()) { + if (pending.kind !== 'approval' && pending.kind !== 'question') continue; + if (knownInteractions.has(pending.id)) continue; + knownInteractions.add(pending.id); + interactionAgents.set(pending.id, interactionAgentId(pending)); + unseeded.set(pending.id, pending); + } + const seedPendingInteractions = (agentId?: string): void => { + if (agentId === undefined) seededAll = true; + else seededAgents.add(agentId); + for (const [id, interaction] of unseeded) { + if (agentId !== undefined && interactionAgents.get(id) !== agentId) continue; + unseeded.delete(id); + announceInteraction(interaction); + const early = earlyResolves.get(id); + if (early === undefined) continue; + interactionAgents.delete(id); + earlyResolves.delete(id); + const projector = projectors.get(early.agentId); + if (projector !== undefined) { + applyOps(early.agentId, projector.mapInteractionResolved(id, early.response)); + } + } + for (const pending of pendingInteractions()) { + if (knownInteractions.has(pending.id)) continue; + if (agentId !== undefined && interactionAgentId(pending) !== agentId) continue; + knownInteractions.add(pending.id); + announceInteraction(pending); + } + }; + disposables.push( + toDisposable( + interactions.onDidChangePending(() => { + for (const pending of pendingInteractions()) { + if (knownInteractions.has(pending.id)) continue; + const agentId = interactionAgentId(pending); + knownInteractions.add(pending.id); + if (!isSeeded(agentId)) { + interactionAgents.set(pending.id, agentId); + unseeded.set(pending.id, pending); + continue; + } + announceInteraction(pending); + } + }), + ), + toDisposable( + interactions.onDidResolve(({ id, response }) => { + knownInteractions.delete(id); + const agentId = interactionAgents.get(id); + if (agentId === undefined) return; + interactionAgents.delete(id); + if (unseeded.has(id)) { + earlyResolves.set(id, { agentId, response }); + return; + } + const projector = projectors.get(agentId); + if (projector === undefined) return; + applyOps(agentId, projector.mapInteractionResolved(id, response)); + }), + ), + ); + + refreshDescriptors(); + + return { + seedPendingInteractions, + dispose: () => { + for (const d of disposables) d.dispose(); + for (const list of agentDisposables.values()) { + for (const d of list) d.dispose(); + } + agentDisposables.clear(); + projectors.clear(); + interactionAgents.clear(); + knownInteractions.clear(); + unseeded.clear(); + earlyResolves.clear(); + }, + }; +} + +export function descriptorFromMeta(agentId: string, meta: AgentMeta | undefined): AgentDescriptor { + const parentFromLabels = meta?.labels?.['parentAgentId']; + const swarmItem = meta?.labels?.['swarmItem'] ?? meta?.swarmItem; + return { + agentId, + type: meta?.type ?? (agentId === MAIN_AGENT_ID ? 'main' : 'sub'), + parentAgentId: + parentFromLabels !== undefined && parentFromLabels.length > 0 + ? parentFromLabels + : (meta?.parentAgentId ?? undefined), + label: swarmItem !== undefined && swarmItem.length > 0 ? swarmItem : undefined, + }; +} diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e077f3147629f92c80427aac58731fedf6d38ed --- /dev/null +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -0,0 +1,1665 @@ +import type { ContextSpliced } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextEvents'; +import type { HookResult } from '@moonshot-ai/agent-core-v2/features/externalHooks/agent/agentExternalHooksService'; +import type { + CompactionBlocked, + CompactionCancelled, + CompactionCompleted, + CompactionStarted, +} from '@moonshot-ai/agent-core-v2/agent/fullCompaction/compactionOps'; +import { daemonFileRefFromPart, type ContentPart, type ContextUndone, type CronFired, type GoalUpdated } from '@moonshot-ai/agent-core-v2'; +import type { + AssistantDelta, + ThinkingDelta, + ToolCallDelta, + TurnStarted, + TurnStepCompleted, + TurnStepInterrupted, + TurnStepRetrying, + TurnStepStarted, +} from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; +import type { TurnEnded, TurnSteer } from '@moonshot-ai/agent-core-v2/agent/loop/turnOps'; +import type { AgentActivitySnapshot } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; +import type { AgentErrorEvent } from '@moonshot-ai/agent-core-v2/agent/mcp/mcpEvents'; +import type { PluginCommandActivated } from '@moonshot-ai/agent-core-v2/agent/pluginCommand/pluginCommand'; +import type { WarningIssued } from '@moonshot-ai/agent-core-v2/agent/profile/profileOps'; +import type { + PromptAborted, + PromptCompleted, + PromptQueued, + PromptStarted, + PromptSteered, + PromptSubmitted, +} from '@moonshot-ai/agent-core-v2/agent/prompt/promptEvents'; +import type { + ShellCompleted, + ShellOutput, + ShellStarted, +} from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommandService'; +import type { SkillActivated } from '@moonshot-ai/agent-core-v2/features/skill/skillOps'; +import type { + TaskNotified, + TaskStarted, + TaskTerminatedNotice, +} from '@moonshot-ai/agent-core-v2/agent/task/taskOps'; +import type { + PermissionApprovalRequested, + PermissionApprovalResolved, +} from '@moonshot-ai/agent-core-v2/agent/toolApproval/toolApprovalService'; +import type { + ToolCallStarted, + ToolProgress, + ToolResultEvent, +} from '@moonshot-ai/agent-core-v2/agent/toolExecutor/toolExecutorEvents'; +import type { AgentStatusUpdated } from '@moonshot-ai/agent-core-v2/agent/usage/usageEvents'; +import type { PlanRevision } from '@moonshot-ai/agent-core-v2/features/plan/planOps'; +import type { SubagentSuspended } from '@moonshot-ai/agent-core-v2/features/swarm/session/sessionSwarmService'; +import type { + SubagentCancelled, + SubagentCompleted, + SubagentFailed, + SubagentSpawned, + SubagentStarted, +} from '@moonshot-ai/agent-core-v2/session/subagent/mirrorAgentRun'; +import { + projectTranscriptUserOrigin, + projectTranscriptUserTurnOrigin, + type AgentRef, + type AgentUsageMeta, + type StepHeader, + type StepUsage, + type TextFrame, + type ToolCallFrame, + type ToolFrameProgress, + type TranscriptAttachment, + type TranscriptFrame, + type TranscriptInteraction, + type TranscriptMarker, + type TranscriptOperation, + type TranscriptPrompt, + type TranscriptTask, + type TranscriptTodo, + type TranscriptUsage, + type TranscriptUserOrigin, + type TurnHeader, + type TurnOrigin, + type TurnState, +} from '@moonshot-ai/transcript'; + +import { toLegacyPhase, type LegacyActivityApproval } from '../legacyStatus/legacyStatus'; +import { LegacyActivityTracker, phaseFromDomainEvent } from '../legacyStatus/legacyActivity'; +import { toWireQuestion } from '../../protocol/question-wire'; +import { projectPromptContentParts } from '../messages/messageProjection'; + +export interface ProjectorInteraction { + readonly id: string; + readonly kind: 'approval' | 'question'; + readonly payload: unknown; + readonly createdAt: number; +} + +type PlanRevisionEvent = { readonly type: 'plan.revision' } & PlanRevision; + +type PromptQueuedEvent = { readonly type: 'prompt.queued' } & PromptQueued; +type PromptSubmittedEvent = { readonly type: 'prompt.submitted' } & PromptSubmitted; +type PromptStartedEvent = { readonly type: 'prompt.started' } & PromptStarted; +type PromptCompletedEvent = { readonly type: 'prompt.completed' } & PromptCompleted; +type PromptAbortedEvent = { readonly type: 'prompt.aborted' } & PromptAborted; +type PromptSteeredEvent = { readonly type: 'prompt.steered' } & PromptSteered; +type TurnSteerEvent = { readonly type: 'turn.steer' } & TurnSteer; +type SteerFileAttachment = { readonly name: string; readonly mediaType: string; readonly size: number }; + +export type ProjectorBusEvent = + | PlanRevisionEvent + | ({ readonly type: 'turn.started' } & TurnStarted) + | ({ readonly type: 'turn.ended' } & TurnEnded) + | ({ readonly type: 'turn.step.started' } & TurnStepStarted) + | ({ readonly type: 'turn.step.completed' } & TurnStepCompleted) + | ({ readonly type: 'turn.step.interrupted' } & TurnStepInterrupted) + | ({ readonly type: 'turn.step.retrying' } & TurnStepRetrying) + | ({ readonly type: 'assistant.delta' } & AssistantDelta) + | ({ readonly type: 'thinking.delta' } & ThinkingDelta) + | ({ readonly type: 'tool.call.delta' } & ToolCallDelta) + | ({ readonly type: 'tool.progress' } & ToolProgress) + | ({ readonly type: 'tool.call.started' } & ToolCallStarted) + | ({ readonly type: 'tool.result' } & ToolResultEvent) + | ({ readonly type: 'permission.approval.requested' } & PermissionApprovalRequested) + | ({ readonly type: 'permission.approval.resolved' } & PermissionApprovalResolved) + | ({ readonly type: 'task.started' } & TaskStarted) + | ({ readonly type: 'task.terminated' } & TaskTerminatedNotice) + | ({ readonly type: 'task.notified' } & TaskNotified) + | ({ readonly type: 'shell.started' } & ShellStarted) + | ({ readonly type: 'shell.output' } & ShellOutput) + | ({ readonly type: 'shell.completed' } & ShellCompleted) + | ({ readonly type: 'subagent.spawned' } & SubagentSpawned) + | ({ readonly type: 'subagent.started' } & SubagentStarted) + | ({ readonly type: 'subagent.completed' } & SubagentCompleted) + | ({ readonly type: 'subagent.failed' } & SubagentFailed) + | ({ readonly type: 'subagent.cancelled' } & SubagentCancelled) + | ({ readonly type: 'subagent.suspended' } & SubagentSuspended) + | ({ readonly type: 'goal.updated' } & GoalUpdated) + | ({ readonly type: 'agent.status.updated' } & AgentStatusUpdated) + | PromptQueuedEvent + | PromptSubmittedEvent + | PromptStartedEvent + | PromptCompletedEvent + | PromptAbortedEvent + | PromptSteeredEvent + | TurnSteerEvent + | ({ readonly type: 'hook.result' } & HookResult) + | ({ readonly type: 'skill.activated' } & SkillActivated) + | ({ readonly type: 'plugin_command.activated' } & PluginCommandActivated) + | ({ readonly type: 'cron.fired' } & CronFired) + | ({ readonly type: 'compaction.started' } & CompactionStarted) + | ({ readonly type: 'compaction.blocked' } & CompactionBlocked) + | ({ readonly type: 'compaction.cancelled' } & CompactionCancelled) + | ({ readonly type: 'compaction.completed' } & CompactionCompleted) + | ({ readonly type: 'context.spliced' } & ContextSpliced) + | ({ readonly type: 'context.undone' } & ContextUndone) + | ({ readonly type: 'error' } & AgentErrorEvent) + | ({ readonly type: 'warning' } & WarningIssued); + +export type ProjectorFrameLookup = ( + turnId: string, + stepId: string, +) => readonly TranscriptFrame[] | undefined; + +export type ProjectorToolFrameLookup = (toolCallId: string) => ToolFrameRecord | undefined; + +export type ProjectorStepOrdinalLookup = (turnId: string) => number | undefined; + +export type ProjectorTurnLookup = (turnId: string) => TurnHeader | undefined; + +export type ProjectorPlanRevisionKey = (key: string) => string; + +export interface ProjectorLookups { + readonly stepFrames?: ProjectorFrameLookup; + readonly toolFrame?: ProjectorToolFrameLookup; + readonly stepOrdinal?: ProjectorStepOrdinalLookup; + readonly turn?: ProjectorTurnLookup; + readonly resolvePlanRevisionKey?: ProjectorPlanRevisionKey; + readonly activitySnapshot?: () => AgentActivitySnapshot; + readonly pendingApprovals?: () => readonly LegacyActivityApproval[]; +} + +interface OpenTextFrame { + readonly frameId: string; + offset: number; + text: string; +} + +export interface ToolFrameRecord { + readonly turnId: string; + readonly stepId: string; + readonly frame: ToolCallFrame; +} + +export class AgentTranscriptProjector { + private currentTurn: TurnHeader | undefined; + private currentStep: StepHeader | undefined; + private pendingTaskNotifications: { text: string; taskId: string | undefined }[] = []; + private pendingSteers: { + input: readonly ContentPart[]; + files: readonly SteerFileAttachment[]; + promptIds: readonly string[] | undefined; + origin: TranscriptUserOrigin; + }[] = []; + private unpairedSteerPromptIds: string[][] = []; + private readonly stepOrdinals = new Map(); + private frameOrdinal = 0; + private attachmentOrdinal = 0; + private openText: OpenTextFrame | undefined; + private openThinking: OpenTextFrame | undefined; + private readonly toolFrames = new Map(); + private readonly tasks = new Map(); + private readonly shellTasks = new Map(); + private readonly subagentTaskIds = new Map(); + private activityTracker: LegacyActivityTracker | undefined; + + seedSubagentTask(info: { + readonly taskId: string; + readonly agentId: string; + readonly description: string; + readonly status: string; + readonly detached: boolean; + readonly startedAt: number; + }): TranscriptOperation[] { + if (info.status !== 'running') return []; + this.subagentTaskIds.set(info.agentId, info.taskId); + const task = this.upsertTask(info.taskId, (prev) => ({ + taskId: info.taskId, + kind: 'subagent', + state: 'running', + detached: info.detached, + description: info.description, + agentId: info.agentId, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? epochMsToIso(info.startedAt), + endedAt: prev?.endedAt, + })); + return [{ op: 'task.upsert', task }]; + } + + seedActiveTurn(info: { turnId: number; promptId?: string }): void { + const turnId = `t${info.turnId}`; + const prev = this.lookups?.turn?.(turnId); + this.currentTurn = { + kind: 'turn', + turnId, + ordinal: info.turnId, + state: 'running', + triggerPromptId: info.promptId ?? prev?.triggerPromptId, + origin: prev?.origin ?? { kind: 'other' }, + prompt: prev?.prompt, + attachmentIds: prev?.attachmentIds, + startedAt: prev?.startedAt, + }; + } + private readonly interactions = new Map(); + private readonly prompts = new Map(); + private readonly stepUsageByTurn = new Map(); + private markerSeq = 0; + private planModeActive = false; + + constructor( + readonly agentId: string, + private readonly sessionId: string, + private readonly lookups?: ProjectorLookups, + ) {} + + map(event: ProjectorBusEvent): TranscriptOperation[] { + const ops = this.mapEvent(event); + const phase = this.phaseFor(event); + if (phase === undefined) return ops; + return [...ops, { op: 'meta.merge', meta: { agent: { phase } } }]; + } + + private phaseFor(event: ProjectorBusEvent): ReturnType { + if (this.lookups?.activitySnapshot === undefined || this.lookups.pendingApprovals === undefined) { + return undefined; + } + this.activityTracker ??= new LegacyActivityTracker( + this.lookups.activitySnapshot, + this.lookups.pendingApprovals, + ); + return phaseFromDomainEvent(this.activityTracker, event); + } + + private mapEvent(event: ProjectorBusEvent): TranscriptOperation[] { + switch (event.type) { + case 'plan.revision': + return this.onPlanRevision(event); + case 'turn.started': + return this.onTurnStarted(event); + case 'turn.ended': + return this.onTurnEnded(event); + case 'turn.step.started': + return this.onStepStarted(event); + case 'turn.step.completed': + return this.onStepCompleted(event); + case 'turn.step.interrupted': + return this.onStepFinished(event); + case 'turn.step.retrying': + return this.onStepRetrying(event); + case 'assistant.delta': + return this.onTextDelta(event.turnId, 'assistant', event.delta); + case 'thinking.delta': + return this.onTextDelta(event.turnId, 'thinking', event.delta); + case 'tool.call.delta': + return this.onToolCallDelta(event); + case 'tool.progress': + return this.onToolProgress(event); + case 'tool.call.started': + return this.onToolCallStarted(event); + case 'tool.result': + return this.onToolResult(event); + case 'permission.approval.requested': + case 'permission.approval.resolved': + return []; + case 'task.started': + case 'task.terminated': + return this.onTaskLifecycle(event); + case 'task.notified': + return this.onTaskNotified(event); + case 'shell.started': + return this.onShellStarted(event); + case 'shell.output': + return this.onShellOutput(event); + case 'shell.completed': + return this.onShellCompleted(event); + case 'subagent.spawned': + return this.onSubagentSpawned(event); + case 'subagent.started': + case 'subagent.completed': + case 'subagent.failed': + case 'subagent.cancelled': + case 'subagent.suspended': + return this.onSubagentRun(event); + case 'goal.updated': + return this.onGoalUpdated(event); + case 'agent.status.updated': + return this.onAgentStatusUpdated(event); + case 'prompt.queued': + return this.onPromptQueued(event); + case 'prompt.submitted': + return this.onPromptSubmitted(event); + case 'prompt.started': + return this.onPromptStarted(event); + case 'prompt.completed': + return this.onPromptCompleted(event); + case 'prompt.aborted': + return this.onPromptAborted(event); + case 'prompt.steered': + return this.onPromptSteered(event); + case 'turn.steer': + return this.onTurnSteered(event); + case 'hook.result': + return [this.markerOp('hook', restOf(event))]; + case 'skill.activated': + return [this.markerOp('skill', restOf(event))]; + case 'plugin_command.activated': + return [this.markerOp('skill', { ...restOf(event), variant: 'plugin_command' })]; + case 'cron.fired': + return [this.markerOp('cron.fired', restOf(event))]; + case 'compaction.started': + case 'compaction.blocked': + case 'compaction.cancelled': + case 'compaction.completed': + return [ + this.markerOp('compaction', { + phase: event.type.slice('compaction.'.length), + ...restOf(event), + }), + ]; + case 'context.spliced': + return [this.markerOp('undo', restOf(event))]; + case 'context.undone': + return []; + case 'error': + return [this.noticeOp('error', event.message, restOf(event))]; + case 'warning': + return [this.noticeOp('warning', event.message, restOf(event))]; + default: + return []; + } + } + + private onTurnStarted(event: { + turnId: number; + promptId?: string; + origin: unknown; + prompt?: string; + promptAttachments?: readonly ( + | { kind: 'image' | 'video' | 'audio'; fileId: string; name?: string } + | { kind: 'file'; name: string; mediaType: string; size: number; path: string } + )[]; + }): TranscriptOperation[] { + const n = event.turnId; + const turnId = `t${n}`; + const ops: TranscriptOperation[] = []; + const attachmentIds: string[] = []; + for (const input of event.promptAttachments ?? []) { + const attachment: TranscriptAttachment = + input.kind === 'file' + ? { + attachmentId: `${turnId}.att${attachmentIds.length + 1}`, + mediaType: input.mediaType, + name: input.name, + size: input.size, + } + : { + attachmentId: `${turnId}.att${attachmentIds.length + 1}`, + mediaType: `${input.kind}/*`, + name: input.name, + source: { kind: 'session_media', fileId: input.fileId }, + }; + ops.push({ op: 'attachment.upsert', attachment }); + attachmentIds.push(attachment.attachmentId); + } + this.currentTurn = { + kind: 'turn', + turnId, + triggerPromptId: event.promptId, + ordinal: n, + state: 'running', + origin: mapTurnOrigin(event.origin), + prompt: event.prompt, + attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, + startedAt: nowIso(), + }; + this.currentStep = undefined; + this.pendingTaskNotifications = []; + this.pendingSteers = []; + this.openText = undefined; + this.openThinking = undefined; + ops.push({ op: 'turn.upsert', turn: this.currentTurn }); + ops.push({ op: 'meta.merge', meta: { activity: 'turn' } }); + return ops; + } + + private onTurnEnded(event: { + time?: number; + turnId: number; + reason: 'completed' | 'cancelled' | 'failed' | 'blocked'; + error?: { message: string }; + durationMs?: number; + interruptReason?: string; + }): TranscriptOperation[] { + const ops: TranscriptOperation[] = []; + this.flushOpenFrames(ops); + const turnId = `t${event.turnId}`; + if (this.currentStep !== undefined && this.currentStep.state === 'running') { + const step: StepHeader = { ...this.currentStep, state: 'interrupted', endedAt: nowIso() }; + this.currentStep = step; + ops.push({ op: 'step.upsert', turnId: step.turnId, step }); + } + if (this.currentStep === undefined && this.pendingSteers.length > 0) { + const ordinal = (this.stepOrdinals.get(turnId) ?? this.lookups?.stepOrdinal?.(turnId) ?? 0) + 1; + const step: StepHeader = { + kind: 'step', + stepId: `${turnId}.${ordinal}`, + turnId, + ordinal, + state: 'interrupted', + endedAt: nowIso(), + }; + this.stepOrdinals.set(turnId, ordinal); + this.currentStep = step; + ops.push({ op: 'step.upsert', turnId, step }); + } + if (this.currentStep !== undefined) { + for (const pending of this.pendingSteers) { + this.steerUserFrame( + ops, + turnId, + this.currentStep.stepId, + pending.input, + pending.files, + pending.promptIds, + pending.origin, + ); + } + } + this.pendingSteers = []; + const prev = + this.currentTurn?.turnId === turnId ? this.currentTurn : this.lookups?.turn?.(turnId); + const state = mapTurnEndState(event.reason); + this.currentTurn = { + kind: 'turn', + turnId, + ordinal: event.turnId, + state, + triggerPromptId: prev?.triggerPromptId, + origin: prev?.origin ?? { kind: 'other' }, + prompt: prev?.prompt, + attachmentIds: prev?.attachmentIds, + startedAt: prev?.startedAt, + endedAt: event.time === undefined ? nowIso() : epochMsToIso(event.time), + durationMs: event.durationMs, + error: event.error?.message, + usage: this.takeTurnUsage(turnId), + }; + ops.push({ op: 'turn.upsert', turn: this.currentTurn }); + ops.push({ op: 'meta.merge', meta: { activity: 'idle' } }); + this.currentStep = undefined; + this.pendingTaskNotifications = []; + if (event.reason === 'cancelled' && event.interruptReason === 'user_cancelled') { + ops.push( + this.markerOp('interruption', { turnId: event.turnId, reason: event.interruptReason }), + ); + } + return ops; + } + + private takeTurnUsage(turnId: string): TranscriptUsage | undefined { + const usages = this.stepUsageByTurn.get(turnId); + this.stepUsageByTurn.delete(turnId); + if (usages === undefined || usages.length === 0) return undefined; + let inputOther = 0; + let output = 0; + let inputCacheRead = 0; + let inputCacheCreation = 0; + for (const usage of usages) { + inputOther += usage.inputOther; + output += usage.output; + inputCacheRead += usage.inputCacheRead; + inputCacheCreation += usage.inputCacheCreation; + } + return { + inputTokens: inputOther + inputCacheCreation, + cachedTokens: inputCacheRead, + outputTokens: output, + }; + } + + private onStepStarted(event: { turnId: number; step: number }): TranscriptOperation[] { + const turnId = `t${event.turnId}`; + const stepId = `${turnId}.${event.step}`; + this.stepOrdinals.set(turnId, event.step); + this.currentStep = { + kind: 'step', + stepId, + turnId, + ordinal: event.step, + state: 'running', + startedAt: nowIso(), + }; + this.frameOrdinal = 0; + this.attachmentOrdinal = 0; + this.openText = undefined; + this.openThinking = undefined; + const ops: TranscriptOperation[] = [{ op: 'step.upsert', turnId, step: this.currentStep }]; + for (const pending of this.pendingTaskNotifications) { + ops.push({ + op: 'frame.upsert', + turnId, + stepId, + frame: { + kind: 'text', + frameId: `${stepId}.f${++this.frameOrdinal}`, + role: 'user', + text: pending.text, + taskId: pending.taskId, + }, + }); + } + this.pendingTaskNotifications = []; + for (const pending of this.pendingSteers) { + this.steerUserFrame(ops, turnId, stepId, pending.input, pending.files, pending.promptIds, pending.origin); + } + this.pendingSteers = []; + return ops; + } + + private onStepCompleted(event: { + turnId: number; + step: number; + usage?: StepUsage; + finishReason?: string; + rawFinishReason?: string; + providerFinishReason?: string; + llmFirstTokenLatencyMs?: number; + llmStreamDurationMs?: number; + llmRequestBuildMs?: number; + llmServerFirstTokenMs?: number; + llmServerDecodeMs?: number; + llmClientConsumeMs?: number; + llmClientBlockedMs?: number; + }): TranscriptOperation[] { + const ops: TranscriptOperation[] = []; + this.flushOpenFrames(ops); + const turnId = `t${event.turnId}`; + const stepId = `${turnId}.${event.step}`; + const prev = this.currentStep?.stepId === stepId ? this.currentStep : undefined; + if (event.usage !== undefined) { + const usages = this.stepUsageByTurn.get(turnId) ?? []; + usages.push(event.usage); + this.stepUsageByTurn.set(turnId, usages); + } + this.currentStep = { + kind: 'step', + stepId, + turnId, + ordinal: event.step, + state: 'completed', + startedAt: prev?.startedAt, + endedAt: nowIso(), + usage: event.usage, + finishReason: event.finishReason ?? event.rawFinishReason ?? event.providerFinishReason, + timing: { + llmFirstTokenLatencyMs: event.llmFirstTokenLatencyMs, + llmStreamDurationMs: event.llmStreamDurationMs, + llmRequestBuildMs: event.llmRequestBuildMs, + llmServerFirstTokenMs: event.llmServerFirstTokenMs, + llmServerDecodeMs: event.llmServerDecodeMs, + llmClientConsumeMs: event.llmClientConsumeMs, + llmClientBlockedMs: event.llmClientBlockedMs, + }, + }; + ops.push({ op: 'step.upsert', turnId, step: this.currentStep }); + return ops; + } + + private onStepFinished(event: { + type: 'turn.step.interrupted'; + turnId: number; + step: number; + reason: string; + message?: string; + }): TranscriptOperation[] { + const ops: TranscriptOperation[] = []; + this.flushOpenFrames(ops); + const turnId = `t${event.turnId}`; + const stepId = `${turnId}.${event.step}`; + const prev = this.currentStep?.stepId === stepId ? this.currentStep : undefined; + this.currentStep = { + kind: 'step', + stepId, + turnId, + ordinal: event.step, + state: 'interrupted', + startedAt: prev?.startedAt, + endedAt: nowIso(), + endReason: event.reason, + endMessage: event.message, + }; + ops.push({ op: 'step.upsert', turnId, step: this.currentStep }); + return ops; + } + + private onStepRetrying(event: { + turnId: number; + step: number; + failedAttempt: number; + nextAttempt: number; + maxAttempts: number; + delayMs: number; + errorName: string; + errorMessage: string; + statusCode?: number; + }): TranscriptOperation[] { + const ops: TranscriptOperation[] = []; + const turnId = `t${event.turnId}`; + const stepId = `${turnId}.${event.step}`; + const prev = this.currentStep?.stepId === stepId ? this.currentStep : undefined; + this.currentStep = { + kind: 'step', + stepId, + turnId, + ordinal: event.step, + state: 'running', + startedAt: prev?.startedAt, + retry: { + failedAttempt: event.failedAttempt, + nextAttempt: event.nextAttempt, + maxAttempts: event.maxAttempts, + delayMs: event.delayMs, + errorName: event.errorName, + errorMessage: event.errorMessage, + statusCode: event.statusCode, + }, + }; + ops.push({ op: 'step.upsert', turnId, step: this.currentStep }); + return ops; + } + + private onTextDelta( + turnNumber: number, + kind: 'assistant' | 'thinking', + delta: string, + ): TranscriptOperation[] { + const ops: TranscriptOperation[] = []; + const turnId = `t${turnNumber}`; + const step = this.ensureStep(turnId, ops); + let open = kind === 'assistant' ? this.openText : this.openThinking; + open ??= this.adoptStreamFrame(turnId, step.stepId, kind); + if (open === undefined) { + const frameId = `${step.stepId}.f${++this.frameOrdinal}`; + open = { frameId, offset: 0, text: '' }; + ops.push({ + op: 'frame.upsert', + turnId, + stepId: step.stepId, + frame: + kind === 'assistant' + ? { kind: 'text', frameId, role: 'assistant', text: '' } + : { kind: 'thinking', frameId, text: '' }, + }); + } + ops.push({ + op: 'append', + target: { type: 'frame', turnId, stepId: step.stepId, frameId: open.frameId }, + offset: open.offset, + text: delta, + }); + open.offset += delta.length; + open.text += delta; + if (kind === 'assistant') this.openText = open; + else this.openThinking = open; + return ops; + } + + private adoptStreamFrame( + turnId: string, + stepId: string, + kind: 'assistant' | 'thinking', + ): OpenTextFrame | undefined { + const frames = this.lookups?.stepFrames?.(turnId, stepId); + if (frames === undefined || frames.length === 0) return undefined; + for (const frame of frames) { + const match = /\.f(\d+)$/.exec(frame.frameId); + if (match !== null) { + this.frameOrdinal = Math.max(this.frameOrdinal, Number(match[1])); + } + } + for (let i = frames.length - 1; i >= 0; i -= 1) { + const frame = frames[i]; + if (frame === undefined) continue; + if (kind === 'assistant' && frame.kind === 'text' && frame.role === 'assistant') { + return { frameId: frame.frameId, offset: frame.text.length, text: frame.text }; + } + if (kind === 'thinking' && frame.kind === 'thinking') { + return { frameId: frame.frameId, offset: frame.text.length, text: frame.text }; + } + } + return undefined; + } + + private flushOpenFrames(ops: TranscriptOperation[]): void { + const step = this.currentStep; + for (const open of [this.openText, this.openThinking]) { + if (open === undefined || step === undefined) continue; + const isText = open === this.openText; + ops.push({ + op: 'frame.upsert', + turnId: step.turnId, + stepId: step.stepId, + frame: isText + ? { kind: 'text', frameId: open.frameId, role: 'assistant', text: open.text } + : { kind: 'thinking', frameId: open.frameId, text: open.text }, + }); + } + this.openText = undefined; + this.openThinking = undefined; + } + + private ensureStep(turnId: string, ops: TranscriptOperation[]): StepHeader { + if (this.currentStep !== undefined && this.currentStep.turnId === turnId) { + return this.currentStep; + } + const ordinal = + this.lookups?.stepOrdinal?.(turnId) ?? this.stepOrdinals.get(turnId) ?? 1; + this.currentStep = { + kind: 'step', + stepId: `${turnId}.${ordinal}`, + turnId, + ordinal, + state: 'running', + startedAt: nowIso(), + }; + ops.push({ op: 'step.upsert', turnId, step: this.currentStep }); + return this.currentStep; + } + + private onToolCallDelta(event: { + turnId: number; + toolCallId: string; + name?: string; + argumentsPart?: string; + }): TranscriptOperation[] { + const ops: TranscriptOperation[] = []; + const prev = this.toolFrames.get(event.toolCallId); + if (prev !== undefined) { + const frame: ToolCallFrame = { + ...prev.frame, + inputText: (prev.frame.inputText ?? '') + (event.argumentsPart ?? ''), + }; + this.toolFrames.set(event.toolCallId, { ...prev, frame }); + ops.push({ op: 'frame.upsert', turnId: prev.turnId, stepId: prev.stepId, frame }); + return ops; + } + const turnId = `t${event.turnId}`; + const step = this.ensureStep(turnId, ops); + const frameId = `${step.stepId}.${event.toolCallId}`; + const frame: ToolCallFrame = { + kind: 'tool', + frameId, + toolCallId: event.toolCallId, + name: event.name ?? '', + state: 'running', + inputText: event.argumentsPart ?? '', + }; + this.toolFrames.set(event.toolCallId, { turnId, stepId: step.stepId, frame }); + ops.push({ op: 'frame.upsert', turnId, stepId: step.stepId, frame }); + return ops; + } + + private onToolProgress(event: { + toolCallId: string; + update: ToolFrameProgress; + }): TranscriptOperation[] { + const hit = this.toolFrames.get(event.toolCallId) ?? this.adoptToolFrame(event.toolCallId); + if (hit === undefined) return []; + const frame: ToolCallFrame = { + ...hit.frame, + progress: { + kind: event.update.kind, + text: event.update.text, + percent: event.update.percent, + customKind: event.update.customKind, + customData: event.update.customData, + }, + }; + this.toolFrames.set(event.toolCallId, { ...hit, frame }); + return [{ op: 'frame.upsert', turnId: hit.turnId, stepId: hit.stepId, frame }]; + } + + private onToolCallStarted(event: { + turnId: number; + toolCallId: string; + name: string; + args: unknown; + display?: unknown; + }): TranscriptOperation[] { + const ops: TranscriptOperation[] = []; + const turnId = `t${event.turnId}`; + const step = this.ensureStep(turnId, ops); + const frameId = `${step.stepId}.${event.toolCallId}`; + const input = parseToolArgs(event.args); + const frame: ToolCallFrame = { + kind: 'tool', + frameId, + toolCallId: event.toolCallId, + name: event.name, + state: 'running', + input, + inputText: this.toolFrames.get(event.toolCallId)?.frame.inputText, + display: event.display, + todoId: event.name === TODO_LIST_TOOL_NAME && todoWriteItems(input) !== undefined ? TODO_ENTITY_ID : undefined, + }; + this.toolFrames.set(event.toolCallId, { turnId, stepId: step.stepId, frame }); + ops.push({ op: 'frame.upsert', turnId, stepId: step.stepId, frame }); + return ops; + } + + private onToolResult(event: { + toolCallId: string; + output: unknown; + isError?: boolean; + }): TranscriptOperation[] { + const hit = this.toolFrames.get(event.toolCallId) ?? this.adoptToolFrame(event.toolCallId); + if (hit === undefined) return []; + const isError = event.isError === true; + const frame: ToolCallFrame = { + ...hit.frame, + state: isError ? 'error' : 'done', + output: event.output, + error: isError && typeof event.output === 'string' ? event.output : undefined, + }; + this.toolFrames.set(event.toolCallId, { ...hit, frame }); + const ops: TranscriptOperation[] = [ + { op: 'frame.upsert', turnId: hit.turnId, stepId: hit.stepId, frame }, + ]; + if (!isError && frame.name === TODO_LIST_TOOL_NAME) { + const items = todoWriteItems(frame.input); + if (items !== undefined) { + const todo: TranscriptTodo = { todoId: TODO_ENTITY_ID, items, updatedAt: nowIso() }; + ops.push({ op: 'todo.upsert', todo }); + } + } + return ops; + } + + private adoptToolFrame(toolCallId: string): ToolFrameRecord | undefined { + const hit = this.lookups?.toolFrame?.(toolCallId); + if (hit === undefined) return undefined; + this.toolFrames.set(toolCallId, hit); + return hit; + } + + private onTaskNotified(event: { + notificationType: string; + title: string; + body: string; + severity: string; + sourceKind: string; + sourceId: string; + }): TranscriptOperation[] { + const step = this.currentStep; + const turn = this.currentTurn; + if (turn === undefined || turn.state !== 'running') return []; + const text = `${event.title}\n${event.body}`.trim(); + if (step !== undefined && step.state === 'running') { + const frame: TextFrame = { + kind: 'text', + frameId: `${step.stepId}.f${++this.frameOrdinal}`, + role: 'user', + text, + taskId: event.sourceId, + }; + return [{ op: 'frame.upsert', turnId: turn.turnId, stepId: step.stepId, frame }]; + } + if (turn.origin?.kind === 'task' && (turn.origin.taskId === undefined || turn.origin.taskId === event.sourceId)) return []; + this.pendingTaskNotifications.push({ text, taskId: event.sourceId }); + return []; + } + + private onTaskLifecycle(event: { + type: 'task.started' | 'task.terminated'; + info: { + taskId: string; + kind: string; + description: string; + status: TranscriptTask['state']; + detached?: boolean; + agentId?: string; + startedAt: number; + endedAt: number | null; + }; + }): TranscriptOperation[] { + const { info } = event; + const task = this.upsertTask(info.taskId, (prev) => ({ + taskId: info.taskId, + kind: mapTaskKind(info.kind), + state: info.status, + detached: info.detached ?? prev?.detached ?? true, + description: info.description, + agentId: info.agentId ?? prev?.agentId, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? epochMsToIso(info.startedAt), + endedAt: info.endedAt === null ? prev?.endedAt : epochMsToIso(info.endedAt), + resultSummary: prev?.resultSummary, + usage: prev?.usage, + error: prev?.error, + stateReason: prev?.stateReason, + model: prev?.model, + thinkingEffort: prev?.thinkingEffort, + })); + const ops: TranscriptOperation[] = [{ op: 'task.upsert', task }]; + if (event.type === 'task.started') { + if (info.kind === 'agent' && typeof info.agentId === 'string' && info.agentId.length > 0) { + this.subagentTaskIds.set(info.agentId, info.taskId); + } + ops.push({ + op: 'taskref.upsert', + item: { kind: 'taskref', refId: `ref-${info.taskId}`, taskId: info.taskId, at: nowIso() }, + }); + } + return ops; + } + + private onShellStarted(event: { commandId: string; taskId: string }): TranscriptOperation[] { + this.shellTasks.set(event.commandId, event.taskId); + const task = this.upsertTask(event.taskId, (prev) => ({ + taskId: event.taskId, + kind: 'shell', + state: 'running', + detached: prev?.detached ?? false, + description: prev?.description, + agentId: prev?.agentId, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? nowIso(), + endedAt: prev?.endedAt, + })); + return [ + { op: 'task.upsert', task }, + { + op: 'taskref.upsert', + item: { kind: 'taskref', refId: `ref-${event.taskId}`, taskId: event.taskId, at: nowIso() }, + }, + ]; + } + + private shellTaskId(event: { commandId: string; taskId?: string }): string { + const taskId = this.shellTasks.get(event.commandId) ?? event.taskId ?? `shell-${event.commandId}`; + this.shellTasks.set(event.commandId, taskId); + return taskId; + } + + private onShellOutput(event: { + commandId: string; + taskId?: string; + update: { kind: string; text?: string }; + }): TranscriptOperation[] { + const taskId = this.shellTaskId(event); + const text = event.update.text; + if (typeof text !== 'string' || text.length === 0) return []; + const ops: TranscriptOperation[] = []; + let task = this.tasks.get(taskId); + if (task === undefined) { + task = this.upsertTask(taskId, (prev) => ({ + taskId, + kind: 'shell', + state: 'running', + detached: prev?.detached ?? false, + description: prev?.description, + agentId: prev?.agentId, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? nowIso(), + endedAt: prev?.endedAt, + })); + ops.push( + { op: 'task.upsert', task }, + { + op: 'taskref.upsert', + item: { kind: 'taskref', refId: `ref-${taskId}`, taskId, at: nowIso() }, + }, + ); + } + const offset = task.outputTail.length; + this.tasks.set(taskId, { ...task, outputTail: task.outputTail + text }); + ops.push({ op: 'append', target: { type: 'task', taskId }, offset, text }); + return ops; + } + + private onShellCompleted(event: { + commandId: string; + taskId?: string; + isError: boolean; + }): TranscriptOperation[] { + const taskId = this.shellTaskId(event); + const hadTask = this.tasks.has(taskId); + const task = this.upsertTask(taskId, (prev) => ({ + taskId, + kind: prev?.kind ?? 'shell', + state: event.isError ? 'failed' : 'completed', + detached: prev?.detached ?? false, + description: prev?.description, + agentId: prev?.agentId, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? nowIso(), + endedAt: nowIso(), + })); + const ops: TranscriptOperation[] = [{ op: 'task.upsert', task }]; + if (!hadTask) { + ops.push({ + op: 'taskref.upsert', + item: { kind: 'taskref', refId: `ref-${taskId}`, taskId, at: nowIso() }, + }); + } + return ops; + } + + private upsertTask( + taskId: string, + build: (prev: TranscriptTask | undefined) => TranscriptTask, + ): TranscriptTask { + const task = build(this.tasks.get(taskId)); + this.tasks.set(taskId, task); + return task; + } + + private onSubagentSpawned(event: { + subagentId: string; + subagentName: string; + parentToolCallId: string; + description?: string; + swarmIndex?: number; + runInBackground: boolean; + taskId?: string; + model?: string; + thinkingEffort?: string; + }): TranscriptOperation[] { + const taskKey = event.taskId ?? event.subagentId; + if (event.taskId !== undefined) { + this.subagentTaskIds.set(event.subagentId, event.taskId); + } else { + this.subagentTaskIds.delete(event.subagentId); + } + const task = this.upsertTask(taskKey, (prev) => { + const newGeneration = prev !== undefined && prev.state !== 'running'; + return { + taskId: taskKey, + kind: 'subagent', + state: 'running', + detached: event.runInBackground, + description: event.description ?? prev?.description, + agentId: event.subagentId, + outputTail: prev?.outputTail ?? '', + startedAt: newGeneration ? nowIso() : (prev?.startedAt ?? nowIso()), + endedAt: newGeneration ? undefined : prev?.endedAt, + model: event.model ?? prev?.model, + thinkingEffort: event.thinkingEffort ?? prev?.thinkingEffort, + }; + }); + const ops: TranscriptOperation[] = [{ op: 'task.upsert', task }]; + const hit = + this.toolFrames.get(event.parentToolCallId) ?? this.adoptToolFrame(event.parentToolCallId); + if (hit !== undefined) { + const ref: AgentRef = { + agentId: event.subagentId, + role: event.swarmIndex !== undefined ? 'member' : 'child', + }; + const frame: ToolCallFrame = { + ...hit.frame, + agentRefs: [...(hit.frame.agentRefs ?? []), ref], + }; + this.toolFrames.set(event.parentToolCallId, { ...hit, frame }); + ops.push({ op: 'frame.upsert', turnId: hit.turnId, stepId: hit.stepId, frame }); + } + return ops; + } + + private onSubagentRun(event: { + type: 'subagent.started' | 'subagent.completed' | 'subagent.failed' | 'subagent.cancelled' | 'subagent.suspended'; + subagentId: string; + resultSummary?: string; + usage?: StepUsage; + error?: string; + reason?: string; + }): TranscriptOperation[] { + const terminal = + event.type === 'subagent.completed' || + event.type === 'subagent.failed' || + event.type === 'subagent.cancelled'; + const state: TranscriptTask['state'] = + event.type === 'subagent.completed' + ? 'completed' + : event.type === 'subagent.failed' + ? 'failed' + : event.type === 'subagent.cancelled' + ? 'killed' + : 'running'; + const build = (taskId: string) => (prev: TranscriptTask | undefined): TranscriptTask => { + const newGeneration = !terminal && prev !== undefined && prev.state !== 'running'; + return { + taskId, + kind: 'subagent', + state, + detached: prev?.detached ?? true, + description: prev?.description, + agentId: event.subagentId, + outputTail: prev?.outputTail ?? '', + startedAt: newGeneration ? nowIso() : (prev?.startedAt ?? nowIso()), + endedAt: terminal ? nowIso() : newGeneration ? undefined : prev?.endedAt, + resultSummary: newGeneration ? undefined : (event.resultSummary ?? prev?.resultSummary), + usage: newGeneration ? undefined : (event.usage ?? prev?.usage), + error: newGeneration ? undefined : (event.error ?? prev?.error), + stateReason: event.reason ?? (newGeneration ? undefined : prev?.stateReason), + model: prev?.model, + thinkingEffort: prev?.thinkingEffort, + }; + }; + const taskKey = this.subagentTaskIds.get(event.subagentId) ?? event.subagentId; + const task = this.upsertTask(taskKey, build(taskKey)); + const ops: TranscriptOperation[] = [{ op: 'task.upsert', task }]; + if (taskKey !== event.subagentId && this.tasks.has(event.subagentId)) { + const agentTask = this.upsertTask(event.subagentId, build(event.subagentId)); + ops.push({ op: 'task.upsert', task: agentTask }); + } + return ops; + } + + private onGoalUpdated(event: { + readonly type: string; + snapshot: { + objective: string; + status: 'active' | 'paused' | 'blocked' | 'complete'; + completionCriterion?: string; + tokensUsed: number; + budget: { tokenBudget: number | null }; + } | null; + }): TranscriptOperation[] { + const ops: TranscriptOperation[] = []; + const snapshot = event.snapshot; + if (snapshot === null) { + ops.push({ op: 'meta.merge', meta: { goal: null } }); + } else { + ops.push({ + op: 'meta.merge', + meta: { + goal: { + objective: snapshot.objective, + status: snapshot.status, + completionCriterion: snapshot.completionCriterion, + budgetUsed: snapshot.tokensUsed, + budgetLimit: snapshot.budget.tokenBudget ?? undefined, + }, + }, + }); + } + ops.push(this.markerOp('goal', restOf(event))); + return ops; + } + + private onAgentStatusUpdated(event: { + planMode?: boolean; + swarmMode?: boolean; + towerMode?: boolean; + model?: string; + thinkingEffort?: string; + usage?: AgentUsageMeta; + contextTokens?: number; + maxContextTokens?: number; + contextUsage?: number; + permission?: 'manual' | 'yolo' | 'auto'; + }): TranscriptOperation[] { + const ops: TranscriptOperation[] = []; + const modes: { + plan?: Record | null; + swarm?: Record | null; + tower?: Record | null; + } = {}; + if (event.planMode === true) { + modes.plan = {}; + this.planModeActive = true; + } else if (event.planMode === false) { + modes.plan = null; + this.planModeActive = false; + } + if (event.swarmMode === true) modes.swarm = {}; + else if (event.swarmMode === false) modes.swarm = null; + if (event.towerMode === true) modes.tower = {}; + else if (event.towerMode === false) modes.tower = null; + if (modes.plan !== undefined || modes.swarm !== undefined || modes.tower !== undefined) { + ops.push({ op: 'meta.merge', meta: { modes } }); + } + const agent: { + model?: string; + thinkingEffort?: string; + usage?: AgentUsageMeta; + contextTokens?: number; + maxContextTokens?: number; + contextUsage?: number; + permission?: 'manual' | 'yolo' | 'auto'; + } = {}; + let hasStatusSlice = false; + if (event.model !== undefined) { + agent.model = event.model; + hasStatusSlice = true; + } + if (event.thinkingEffort !== undefined) { + agent.thinkingEffort = event.thinkingEffort; + hasStatusSlice = true; + } + if (event.usage !== undefined) { + agent.usage = event.usage; + hasStatusSlice = true; + } + if (event.contextTokens !== undefined) { + agent.contextTokens = event.contextTokens; + hasStatusSlice = true; + } + if (event.maxContextTokens !== undefined) { + agent.maxContextTokens = event.maxContextTokens; + hasStatusSlice = true; + } + if (event.contextUsage !== undefined) { + agent.contextUsage = event.contextUsage; + hasStatusSlice = true; + } + if (event.permission !== undefined) { + agent.permission = event.permission; + hasStatusSlice = true; + } + if (hasStatusSlice) { + ops.push({ op: 'meta.merge', meta: { agent } }); + } + return ops; + } + + private onPlanRevision(event: PlanRevisionEvent): TranscriptOperation[] { + const path = this.lookups?.resolvePlanRevisionKey?.(event.key) ?? event.key; + const { key: _key, ...rest } = restOf(event); + const payload = { ...rest, path }; + const ops: TranscriptOperation[] = [this.markerOp('plan.revision', payload)]; + if (this.planModeActive) { + ops.push({ + op: 'meta.merge', + meta: { modes: { plan: { reviewPath: path, version: event.version } } }, + }); + } + return ops; + } + + private markerOp(marker: string, payload: unknown): TranscriptOperation { + this.markerSeq += 1; + const item: TranscriptMarker = { + kind: 'marker', + markerId: `live-m${this.markerSeq}`, + marker, + payload, + at: nowIso(), + }; + return { op: 'marker.upsert', item }; + } + + private noticeOp( + level: 'error' | 'warning' | 'info', + message: string, + eventPayload: unknown, + ): TranscriptOperation { + return this.markerOp('notice', { level, message, event: eventPayload }); + } + + private onPromptQueued(event: PromptQueuedEvent): TranscriptOperation[] { + const prompt = this.upsertPrompt(event.promptId, (prev) => ({ + promptId: event.promptId, + status: 'queued', + userMessageId: prev?.userMessageId, + content: projectPromptContentParts(event.content), + clientMetadata: event.clientMetadata ?? prev?.clientMetadata, + createdAt: prev?.createdAt ?? nowIso(), + })); + return [{ op: 'prompt.upsert', prompt }]; + } + + private onPromptSubmitted(event: PromptSubmittedEvent): TranscriptOperation[] { + const prompt = this.upsertPrompt(event.promptId, (prev) => ({ + promptId: event.promptId, + status: prev !== undefined && isTerminalPromptStatus(prev.status) ? prev.status : event.status, + userMessageId: event.userMessageId, + content: projectPromptContentParts(event.content), + clientMetadata: event.clientMetadata ?? prev?.clientMetadata, + createdAt: prev?.createdAt ?? event.createdAt, + finishedAt: prev?.finishedAt, + steeredAt: prev?.steeredAt, + })); + return [{ op: 'prompt.upsert', prompt }]; + } + + private onPromptStarted(event: PromptStartedEvent): TranscriptOperation[] { + const prompt = this.upsertPrompt(event.promptId, (prev) => ({ + promptId: event.promptId, + status: 'running', + userMessageId: prev?.userMessageId, + content: prev?.content, + clientMetadata: prev?.clientMetadata, + createdAt: prev?.createdAt ?? new Date().toISOString(), + finishedAt: prev?.finishedAt, + steeredAt: prev?.steeredAt, + })); + return [{ op: 'prompt.upsert', prompt }]; + } + + private onPromptCompleted(event: PromptCompletedEvent): TranscriptOperation[] { + const prompt = this.upsertPrompt(event.promptId, (prev) => ({ + promptId: event.promptId, + status: event.reason ?? 'completed', + userMessageId: prev?.userMessageId, + content: prev?.content, + clientMetadata: prev?.clientMetadata, + createdAt: prev?.createdAt ?? event.finishedAt, + finishedAt: event.finishedAt, + steeredAt: prev?.steeredAt, + })); + return [{ op: 'prompt.upsert', prompt }]; + } + + private onPromptAborted(event: PromptAbortedEvent): TranscriptOperation[] { + const prompt = this.upsertPrompt(event.promptId, (prev) => ({ + promptId: event.promptId, + status: 'aborted', + userMessageId: prev?.userMessageId, + content: prev?.content, + clientMetadata: prev?.clientMetadata, + createdAt: prev?.createdAt ?? event.abortedAt, + finishedAt: event.abortedAt, + steeredAt: prev?.steeredAt, + })); + return [{ op: 'prompt.upsert', prompt }]; + } + + private onPromptSteered(event: PromptSteeredEvent): TranscriptOperation[] { + const ops: TranscriptOperation[] = []; + const active = this.upsertPrompt(event.activePromptId, (prev) => ({ + promptId: event.activePromptId, + status: prev?.status ?? 'running', + userMessageId: prev?.userMessageId, + content: projectPromptContentParts(event.content), + clientMetadata: prev?.clientMetadata, + createdAt: prev?.createdAt ?? event.steeredAt, + finishedAt: prev?.finishedAt, + steeredAt: event.steeredAt, + })); + ops.push({ op: 'prompt.upsert', prompt: active }); + this.unpairedSteerPromptIds.push([...event.promptIds]); + for (const promptId of event.promptIds) { + const steered = this.upsertPrompt(promptId, (prev) => ({ + promptId, + status: 'completed', + userMessageId: prev?.userMessageId, + content: prev?.content, + clientMetadata: prev?.clientMetadata, + createdAt: prev?.createdAt ?? event.steeredAt, + finishedAt: event.steeredAt, + steeredAt: event.steeredAt, + })); + ops.push({ op: 'prompt.upsert', prompt: steered }); + } + return ops; + } + + private onTurnSteered(event: TurnSteerEvent): TranscriptOperation[] { + const origin = event.origin; + if (origin.kind !== 'user' && origin.kind !== 'skill_activation') return []; + const frameOrigin = projectTranscriptUserOrigin(origin); + if (frameOrigin === undefined) return []; + const turn = this.currentTurn; + if (turn !== undefined && turn.state !== 'running') return []; + const skip = origin.kind === 'user' ? origin.skillActivations?.length ?? 0 : 0; + const input = skip > 0 ? event.input.slice(skip) : event.input; + const files = origin.attachments ?? []; + const step = this.currentStep; + if (step !== undefined && step.state === 'running') { + const ops: TranscriptOperation[] = []; + this.steerUserFrame( + ops, + step.turnId, + step.stepId, + input, + files, + origin.kind === 'user' ? this.unpairedSteerPromptIds.shift() : undefined, + frameOrigin, + ); + return ops; + } + this.pendingSteers.push({ + input, + files, + promptIds: origin.kind === 'user' ? this.unpairedSteerPromptIds.shift() : undefined, + origin: frameOrigin, + }); + return []; + } + + private steerUserFrame( + ops: TranscriptOperation[], + turnId: string, + stepId: string, + input: readonly ContentPart[], + files: readonly SteerFileAttachment[], + promptIds: readonly string[] | undefined, + origin: TranscriptUserOrigin, + ): void { + const texts: string[] = []; + const attachmentIds: string[] = []; + for (const part of input) { + if (part.type === 'text') { + texts.push(part.text); + continue; + } + const ref = daemonFileRefFromPart(part); + if (ref === undefined) continue; + const attachment: TranscriptAttachment = { + attachmentId: `${stepId}.att${++this.attachmentOrdinal}`, + mediaType: `${ref.kind}/*`, + name: + part.type === 'image_url' + ? part.imageUrl.name + : part.type === 'video_url' + ? part.videoUrl.name + : undefined, + source: { kind: 'session_media', fileId: ref.ref.fileId }, + }; + ops.push({ op: 'attachment.upsert', attachment }); + attachmentIds.push(attachment.attachmentId); + } + for (const file of files) { + const attachment: TranscriptAttachment = { + attachmentId: `${stepId}.att${++this.attachmentOrdinal}`, + mediaType: file.mediaType, + name: file.name, + size: file.size, + }; + ops.push({ op: 'attachment.upsert', attachment }); + attachmentIds.push(attachment.attachmentId); + } + ops.push({ + op: 'frame.upsert', + turnId, + stepId, + frame: { + kind: 'text', + frameId: `${stepId}.f${++this.frameOrdinal}`, + role: 'user', + text: texts.join(''), + attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, + promptIds, + origin, + }, + }); + } + + private upsertPrompt( + promptId: string, + build: (prev: TranscriptPrompt | undefined) => TranscriptPrompt, + ): TranscriptPrompt { + const prompt = build(this.prompts.get(promptId)); + this.prompts.set(promptId, prompt); + return prompt; + } + + mapInteractionRequested(interaction: ProjectorInteraction): TranscriptOperation[] { + const payload = interaction.payload as { toolCallId?: unknown }; + const toolCallId = typeof payload.toolCallId === 'string' ? payload.toolCallId : undefined; + const entity: TranscriptInteraction = { + interactionId: interaction.id, + interactionKind: interaction.kind, + toolCallId, + state: 'pending', + request: this.wireInteractionRequest(interaction), + }; + this.interactions.set(interaction.id, entity); + return [{ op: 'interaction.upsert', interaction: entity }]; + } + + private wireInteractionRequest(interaction: ProjectorInteraction): unknown { + if (interaction.kind !== 'question') return interaction.payload; + try { + return toWireQuestion(interaction, this.sessionId); + } catch { + return interaction.payload; + } + } + + mapInteractionResolved(id: string, response: unknown): TranscriptOperation[] { + const record = this.interactions.get(id); + if (record === undefined) return []; + this.interactions.delete(id); + const state = mapInteractionEndState(record.interactionKind, response); + const ops: TranscriptOperation[] = [ + { op: 'interaction.upsert', interaction: { ...record, state, response } }, + ]; + const toolCallId = record.toolCallId; + if (toolCallId !== undefined) { + const hit = this.toolFrames.get(toolCallId) ?? this.adoptToolFrame(toolCallId); + if (hit !== undefined) { + const toolFrame: ToolCallFrame = { ...hit.frame, approvalId: id }; + this.toolFrames.set(toolCallId, { ...hit, frame: toolFrame }); + ops.push({ op: 'frame.upsert', turnId: hit.turnId, stepId: hit.stepId, frame: toolFrame }); + } + } + return ops; + } +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function isTerminalPromptStatus(status: TranscriptPrompt['status']): boolean { + return status === 'completed' || status === 'failed' || status === 'aborted' || status === 'blocked'; +} + +function epochMsToIso(value: number): string { + return new Date(value).toISOString(); +} + +function restOf(event: { readonly type: string; readonly time?: number }): Record { + const { type: _type, time: _time, ...rest } = event; + return rest; +} + +function mapTurnOrigin(origin: unknown): TurnOrigin { + const candidate = origin as { kind?: unknown } | null | undefined; + const kind = typeof candidate?.kind === 'string' ? candidate.kind : undefined; + switch (kind) { + case 'user': + return projectTranscriptUserTurnOrigin(origin); + case 'cron_job': + case 'cron_missed': { + const jobId = (candidate as { jobId?: unknown }).jobId; + return { + kind: 'cron', + taskId: typeof jobId === 'string' ? jobId : undefined, + payload: origin, + }; + } + case 'task': + case 'background_task': { + const taskId = (candidate as { taskId?: unknown }).taskId; + return typeof taskId === 'string' + ? { kind: 'task', taskId, payload: origin } + : { kind: 'other', payload: origin }; + } + case 'hook_result': + return { kind: 'hook', payload: origin }; + case 'compaction_summary': + return { kind: 'compaction', payload: origin }; + case 'shell_command': + return { kind: 'user', payload: origin }; + default: + return { kind: 'other', payload: origin }; + } +} + +function mapTurnEndState(reason: 'completed' | 'cancelled' | 'failed' | 'blocked'): TurnState { + switch (reason) { + case 'completed': + return 'completed'; + case 'cancelled': + return 'cancelled'; + case 'failed': + case 'blocked': + return 'failed'; + } +} + +function mapTaskKind(kind: string): TranscriptTask['kind'] { + switch (kind) { + case 'process': + return 'shell'; + case 'agent': + return 'subagent'; + default: + return 'other'; + } +} + +function mapInteractionEndState( + kind: 'approval' | 'question', + response: unknown, +): TranscriptInteraction['state'] { + if (kind === 'question') return response === null ? 'dismissed' : 'answered'; + const decision = (response as { decision?: unknown } | null | undefined)?.decision; + if (decision === 'approved' || decision === 'rejected' || decision === 'cancelled') { + return decision; + } + return 'cancelled'; +} + +const TODO_LIST_TOOL_NAME = 'TodoList'; +const TODO_ENTITY_ID = 'todo'; + +function todoWriteItems(input: unknown): TranscriptTodo['items'] | undefined { + const todos = (input as { todos?: unknown } | undefined)?.todos; + if (!Array.isArray(todos)) return undefined; + const items: { title: string; status: 'pending' | 'in_progress' | 'done' }[] = []; + for (const entry of todos) { + const title = (entry as { title?: unknown } | undefined)?.title; + const status = (entry as { status?: unknown } | undefined)?.status; + if (typeof title !== 'string') return undefined; + if (status !== 'pending' && status !== 'in_progress' && status !== 'done') return undefined; + items.push({ title, status }); + } + return items; +} + +function parseToolArgs(args: unknown): unknown { + if (typeof args !== 'string' || args.length === 0) return args; + try { + return JSON.parse(args) as unknown; + } catch { + return args; + } +} diff --git a/packages/kap-server/src/services/transcript/index.ts b/packages/kap-server/src/services/transcript/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e2ebefd19654981ac17c6ae492dfc700c9322c3 --- /dev/null +++ b/packages/kap-server/src/services/transcript/index.ts @@ -0,0 +1,3 @@ +export * from './coreEventMap'; +export * from './coreBinding'; +export * from './transcriptService'; diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts new file mode 100644 index 0000000000000000000000000000000000000000..f75fc51130020886c8e87cfa316ad032a3b2ce98 --- /dev/null +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -0,0 +1,827 @@ +import type { UserPromptOrigin } from '@moonshot-ai/agent-core-v2/agent/contextMemory/types'; +import { join } from 'node:path'; +import { readFile } from 'node:fs/promises'; + +import { + IAgentLifecycleService, + IAgentContextMemoryService, + IFlagService, + ISessionIndex, + ISessionManager, + ISessionMetadata, + IAgentLoopService, + TOWER_FLAG_ID, + followSessionLifecycles, + getLiveSessionById, + isTowerFeatureAssembled, + isUndoAnchor, + reduceContextTranscript, + type ContextMessage, + type IDisposable, + type Scope, + type SessionMeta, +} from '@moonshot-ai/agent-core-v2'; +import { + TowerStore, + resolveTowerRepoRoot, +} from '@moonshot-ai/agent-core-v2/features/tower/protocol/index'; +import { + TranscriptStore, + foldWireRecordFacts, + groupMessagesIntoSnapshot, + isPlainAgentId, + type AgentDescriptor, + type ActivityMeta, + type AgentTranscript, + type AgentTranscriptSnapshot, + type TranscriptChangeEvent, + type TranscriptMarker, + type TranscriptOperation, + type TranscriptTaskRef, + type TranscriptTurn, +} from '@moonshot-ai/transcript'; + +import { readWireRecords, type ContextRecord } from './wireRecords'; +import { toWireQuestion } from '../../protocol/question-wire'; +import { projectPromptContentParts } from '../messages/messageProjection'; +import { + bindSessionTranscript, + descriptorFromMeta, + type TranscriptBinding, + type TranscriptBindingLogger, +} from './coreBinding'; + +const SESSIONS_ROOT = 'sessions'; +const AGENTS_DIR = 'agents'; +const MAIN_AGENT_ID = 'main'; +const WIRE_FILE = 'wire.jsonl'; +const STATE_FILE = 'state.json'; + +export interface TranscriptServiceDeps { + readonly homeDir: string; + readonly core: Scope; + readonly logger?: TranscriptBindingLogger; +} + +interface LiveEntry { + readonly store: TranscriptStore; + readonly binding: TranscriptBinding; + readonly ready: Promise; + readonly agentBackfills: Map>; + readonly opsJournals: Map; + readonly undoGenerations: Map; +} + +interface AgentOpsJournal { + nextSeq: number; + batches: { seq: number; ops: TranscriptOperation[] }[]; +} + +export const TRANSCRIPT_OPS_JOURNAL_CAPACITY = 2000; + +export interface TranscriptOpsCatchup { + readonly batches: readonly { seq: number; ops: readonly TranscriptOperation[] }[]; + readonly latestSeq: number; + readonly complete: boolean; +} + +export class TranscriptService { + private readonly live = new Map(); + private readonly opsListeners = new Map< + string, + Set<(event: TranscriptChangeEvent, seq: number) => void> + >(); + private readonly healTimers = new Map; timer: NodeJS.Timeout }>(); + + constructor(private readonly deps: TranscriptServiceDeps) { + followSessionLifecycles(deps.core.accessor, (service) => { + const d1 = service.onDidCloseSession(({ sessionId }) => this.dropSession(sessionId)); + const d2 = service.onDidArchiveSession(({ sessionId }) => this.dropSession(sessionId)); + return { + dispose: () => { + d1.dispose(); + d2.dispose(); + }, + }; + }); + } + + forSessionLive(sessionId: string): TranscriptStore | undefined { + const existing = this.live.get(sessionId); + if (existing !== undefined) { + if (getLiveSessionById(this.deps.core.accessor, sessionId) !== undefined) { + return existing.store; + } + this.dropSession(sessionId); + return undefined; + } + const session = getLiveSessionById(this.deps.core.accessor, sessionId); + if (session === undefined) return undefined; + const store = new TranscriptStore(sessionId); + let binding: TranscriptBinding; + try { + binding = bindSessionTranscript( + store, + session, + this.deps.logger, + (event) => this.handleLiveOps(sessionId, event), + (agentId) => this.rebuildAfterUndo(sessionId, agentId), + ); + } catch (error) { + if (error instanceof Error && error.message === 'InstantiationService has been disposed') { + return undefined; + } + throw error; + } + this.live.set(sessionId, { + store, + binding, + ready: (async () => { + await this.backfillMain(sessionId, store); + if (this.live.get(sessionId)?.store === store) { + binding.seedPendingInteractions(MAIN_AGENT_ID); + } + })(), + agentBackfills: new Map(), + opsJournals: new Map(), + undoGenerations: new Map(), + }); + return store; + } + + async whenReady(sessionId: string): Promise { + await this.live.get(sessionId)?.ready; + } + + async ensureAgentHistory(sessionId: string, agentId: string): Promise { + if (agentId === MAIN_AGENT_ID) return this.whenReady(sessionId); + const entry = this.live.get(sessionId); + if (entry === undefined) return; + await entry.ready; + let backfill = entry.agentBackfills.get(agentId); + if (backfill === undefined) { + backfill = this.backfillAgent(sessionId, entry.store, agentId); + entry.agentBackfills.set(agentId, backfill); + } + await backfill; + if (this.live.get(sessionId)?.store === entry.store) { + entry.binding.seedPendingInteractions(agentId); + } + } + + private async backfillMain(sessionId: string, store: TranscriptStore): Promise { + await this.backfillAgent(sessionId, store, MAIN_AGENT_ID); + if (this.live.get(sessionId)?.store !== store) return; + try { + const session = getLiveSessionById(this.deps.core.accessor, sessionId); + const meta = await session?.accessor.get(ISessionMetadata).read(); + for (const [agentId, agentMeta] of Object.entries(meta?.agents ?? {})) { + store.describeAgent(descriptorFromMeta(agentId, agentMeta)); + } + } catch { + } + } + + private async backfillAgent(sessionId: string, store: TranscriptStore, agentId: string): Promise { + let snapshot: AgentTranscriptSnapshot | undefined; + try { + snapshot = await this.readColdSnapshot(sessionId, agentId); + } catch (error) { + this.deps.logger?.warn( + { sessionId, agentId, err: error instanceof Error ? error.message : error }, + 'transcript: history backfill failed, continuing without it', + ); + } + if (this.live.get(sessionId)?.store !== store) return; + const transcript = store.ensureAgent(agentId); + if (snapshot !== undefined) { + const superseded = supersededColdAttachmentIds(snapshot, transcript); + const ops = snapshotToOps(snapshot, (turn) => + healTurnOps(turn, transcript.getTurn(turn.turnId)), + ).filter( + (op) => op.op !== 'attachment.upsert' || !superseded.has(op.attachment.attachmentId), + ); + const overlay = this.liveTurnOverlay(sessionId, agentId, transcript, snapshot); + if (overlay !== undefined) ops.push(overlay, { op: 'meta.merge', meta: { activity: 'turn' } }); + ops.push(...this.livePromptBackfill(sessionId, agentId)); + const result = transcript.apply(ops); + if (result.gap !== undefined) { + this.deps.logger?.warn({ sessionId, agentId, gap: result.gap }, 'transcript: backfill append gap'); + } + this.dispatchOps(sessionId, { agentId, ops }); + } + const existing = store.agents().find((d) => d.agentId === agentId); + const hasContent = + snapshot !== undefined && (snapshot.items.length > 0 || snapshot.tasks.length > 0); + if (existing !== undefined || hasContent) { + store.describeAgent({ + agentId, + type: existing?.type ?? (agentId === MAIN_AGENT_ID ? 'main' : 'sub'), + parentAgentId: existing?.parentAgentId, + label: existing?.label, + createdAt: existing?.createdAt, + }); + } + } + + onSessionOps( + sessionId: string, + listener: (event: TranscriptChangeEvent, seq: number) => void, + ): IDisposable | undefined { + if (this.forSessionLive(sessionId) === undefined) return undefined; + let listeners = this.opsListeners.get(sessionId); + if (listeners === undefined) { + listeners = new Set(); + this.opsListeners.set(sessionId, listeners); + } + listeners.add(listener); + return { + dispose: () => { + const entry = this.opsListeners.get(sessionId); + if (entry === undefined) return; + entry.delete(listener); + if (entry.size === 0) this.opsListeners.delete(sessionId); + }, + }; + } + + private dispatchOps(sessionId: string, event: TranscriptChangeEvent): void { + const seq = this.journalOps(sessionId, event); + const listeners = this.opsListeners.get(sessionId); + if (listeners === undefined) return; + for (const listener of listeners) { + try { + listener(event, seq); + } catch { + } + } + } + + private journalOps(sessionId: string, event: TranscriptChangeEvent): number { + const entry = this.live.get(sessionId); + if (entry === undefined) return 0; + let journal = entry.opsJournals.get(event.agentId); + if (journal === undefined) { + journal = { nextSeq: 1, batches: [] }; + entry.opsJournals.set(event.agentId, journal); + } + const seq = journal.nextSeq++; + journal.batches.push({ seq, ops: [...event.ops] }); + if (journal.batches.length > TRANSCRIPT_OPS_JOURNAL_CAPACITY) journal.batches.shift(); + return seq; + } + + getSeqWatermark(sessionId: string, agentId: string): number { + const journal = this.live.get(sessionId)?.opsJournals.get(agentId); + return journal === undefined ? 0 : journal.nextSeq - 1; + } + + getOpsSince( + sessionId: string, + agentId: string, + sinceSeq: number, + ): TranscriptOpsCatchup | undefined { + if (this.forSessionLive(sessionId) === undefined) return undefined; + const journal = this.live.get(sessionId)?.opsJournals.get(agentId); + const latestSeq = journal === undefined ? 0 : journal.nextSeq - 1; + if (sinceSeq > latestSeq) return { batches: [], latestSeq, complete: false }; + const batches = journal?.batches.filter((batch) => batch.seq > sinceSeq) ?? []; + const oldest = journal?.batches[0]?.seq; + const complete = batches.length === 0 || (oldest !== undefined && oldest <= sinceSeq + 1); + return { batches, latestSeq, complete }; + } + + private handleLiveOps(sessionId: string, event: TranscriptChangeEvent): void { + this.dispatchOps(sessionId, event); + for (const op of event.ops) { + if (op.op === 'turn.upsert' && TERMINAL_TURN_STATES.has(op.turn.state)) { + this.scheduleTurnHeal(sessionId, event.agentId, op.turn.ordinal); + } + } + } + + private scheduleTurnHeal(sessionId: string, agentId: string, ordinal: number): void { + const key = `${sessionId}:${agentId}`; + const existing = this.healTimers.get(key); + if (existing !== undefined) { + existing.ordinals.add(ordinal); + existing.timer.refresh(); + return; + } + const ordinals = new Set([ordinal]); + const timer = setTimeout(() => { + this.healTimers.delete(key); + void this.healEndedTurns(sessionId, agentId, ordinals); + }, TURN_HEAL_DEBOUNCE_MS); + timer.unref(); + this.healTimers.set(key, { ordinals, timer }); + } + + private liveTurnOverlay( + sessionId: string, + agentId: string, + transcript: AgentTranscript, + snapshot: AgentTranscriptSnapshot, + ): TranscriptOperation | undefined { + const session = getLiveSessionById(this.deps.core.accessor, sessionId); + const agent = + session === undefined + ? undefined + : session.accessor.get(IAgentLifecycleService).handleOf(agentId); + const status = agent?.accessor.get(IAgentLoopService).snapshot(); + if (status?.state !== 'running' || status.activeTurnId === undefined) return undefined; + const activePromptId = status.activePromptId; + const ordinal = status.activeTurnId; + const turnId = `t${ordinal}`; + const existing = transcript.getTurn(turnId); + const snapshotTurn = snapshot.items.find( + (item): item is TranscriptTurn => item.kind === 'turn' && item.ordinal === ordinal, + ); + return { + op: 'turn.upsert', + turn: { + kind: 'turn', + turnId, + ordinal, + state: 'running', + triggerPromptId: existing?.triggerPromptId ?? snapshotTurn?.triggerPromptId ?? activePromptId, + origin: existing?.origin ?? snapshotTurn?.origin ?? { kind: 'other' }, + prompt: existing?.prompt ?? snapshotTurn?.prompt, + attachmentIds: existing?.attachmentIds ?? snapshotTurn?.attachmentIds, + startedAt: existing?.startedAt ?? snapshotTurn?.startedAt, + }, + }; + } + + private livePromptBackfill(sessionId: string, agentId: string): TranscriptOperation[] { + const agent = getLiveSessionById(this.deps.core.accessor, sessionId) + ?.accessor.get(IAgentLifecycleService) + .handleOf(agentId); + if (agent === undefined) return []; + const loop = agent.accessor.get(IAgentLoopService); + const snapshot = loop.snapshot(); + const ops: TranscriptOperation[] = []; + const activeHandle = + snapshot.activePromptId === undefined + ? undefined + : loop.promptHandle(snapshot.activePromptId); + if (activeHandle !== undefined) { + const activeOrigin = activeHandle.message.origin; + ops.push({ + op: 'prompt.upsert', + prompt: { + promptId: activeHandle.id, + status: 'running', + userMessageId: activeHandle.userMessageId, + content: projectPromptContentParts(activeHandle.message.content), + createdAt: activeHandle.createdAt, + clientMetadata: activeOrigin?.kind === 'user' || activeOrigin?.kind === 'skill_activation' ? activeOrigin.clientMetadata : undefined, + }, + }); + } + for (const item of snapshot.queue) { + if (item.meta?.tracked !== true) continue; + ops.push({ + op: 'prompt.upsert', + prompt: { + promptId: item.meta?.promptId ?? '', + status: 'queued', + userMessageId: item.meta?.userMessageId ?? '', + content: projectPromptContentParts(item.message.content), + createdAt: item.meta?.createdAt ?? '', + clientMetadata: (item.meta?.origin as UserPromptOrigin | undefined)?.clientMetadata, + }, + }); + } + return ops; + } + + private async rebuildAfterUndo(sessionId: string, agentId: string): Promise { + const entry = this.live.get(sessionId); + if (entry === undefined) return; + entry.undoGenerations.set(agentId, (entry.undoGenerations.get(agentId) ?? 0) + 1); + const key = `${sessionId}:${agentId}`; + const pending = this.healTimers.get(key); + if (pending !== undefined) { + clearTimeout(pending.timer); + this.healTimers.delete(key); + } + await entry.ready; + await entry.agentBackfills.get(agentId); + let snapshot: AgentTranscriptSnapshot | undefined; + for (let attempt = 0; attempt < 2; attempt++) { + try { + snapshot = await this.readColdSnapshot(sessionId, agentId); + if (snapshot !== undefined) break; + } catch (error) { + this.deps.logger?.warn( + { sessionId, agentId, err: error instanceof Error ? error.message : error }, + 'transcript: undo history read failed', + ); + } + } + if (snapshot === undefined) { + const agent = getLiveSessionById(this.deps.core.accessor, sessionId) + ?.accessor.get(IAgentLifecycleService).handleOf(agentId); + if (agent !== undefined) { + const current = entry.store.ensureAgent(agentId).snapshot(); + const retained = groupMessagesIntoSnapshot(agent.accessor.get(IAgentContextMemoryService).get()); + snapshot = { ...current, items: retained.items, attachments: retained.attachments, prompts: [] }; + } + } + if (snapshot === undefined || this.live.get(sessionId) !== entry) return; + const ops: TranscriptOperation[] = [{ op: 'reset', agentId, snapshot }]; + entry.store.ensureAgent(agentId).apply(ops); + this.dispatchOps(sessionId, { agentId, ops }); + } + + private async healEndedTurns( + sessionId: string, + agentId: string, + ordinals: ReadonlySet, + ): Promise { + const entry = this.live.get(sessionId); + if (entry === undefined) return; + const generation = entry.undoGenerations.get(agentId) ?? 0; + let snapshot: AgentTranscriptSnapshot | undefined; + try { + snapshot = await this.readColdSnapshot(sessionId, agentId); + } catch (error) { + this.deps.logger?.warn( + { sessionId, agentId, err: error instanceof Error ? error.message : error }, + 'transcript: post-turn heal failed, continuing without it', + ); + return; + } + if (snapshot === undefined || this.live.get(sessionId)?.store !== entry.store) return; + if ((entry.undoGenerations.get(agentId) ?? 0) !== generation) return; + const transcript = entry.store.getAgent(agentId); + if (transcript === undefined) return; + const turnOps: TranscriptOperation[] = []; + for (const item of snapshot.items) { + if (item.kind !== 'turn' || !ordinals.has(item.ordinal)) continue; + turnOps.push(...healTurnOps(item, transcript.getTurn(item.turnId))); + } + if (turnOps.length === 0) return; + const superseded = supersededColdAttachmentIds(snapshot, transcript); + const ops: TranscriptOperation[] = [ + ...snapshot.attachments + .filter((attachment) => !superseded.has(attachment.attachmentId)) + .map((attachment) => ({ + op: 'attachment.upsert' as const, + attachment, + })), + ...turnOps, + ]; + transcript.apply(ops); + this.dispatchOps(sessionId, { agentId, ops }); + } + + async readColdRoster(sessionId: string): Promise { + const summary = await this.deps.core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) return undefined; + let meta: SessionMeta; + try { + const raw = await readFile( + join(this.deps.homeDir, SESSIONS_ROOT, summary.workspaceId, sessionId, STATE_FILE), + 'utf-8', + ); + meta = JSON.parse(raw) as SessionMeta; + } catch { + return []; + } + return Object.entries(meta.agents ?? {}).map(([agentId, agentMeta]) => + descriptorFromMeta(agentId, agentMeta), + ); + } + + async readColdSnapshot( + sessionId: string, + agentId: string = MAIN_AGENT_ID, + ): Promise { + const summary = await this.deps.core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) return undefined; + if (!isPlainAgentId(agentId)) { + return groupMessagesIntoSnapshot([]); + } + const wirePath = join( + this.deps.homeDir, + SESSIONS_ROOT, + summary.workspaceId, + sessionId, + AGENTS_DIR, + agentId, + WIRE_FILE, + ); + let records: Awaited>; + try { + records = await readWireRecords(wirePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return groupMessagesIntoSnapshot([]); + } + throw error; + } + const messages = [...reduceContextTranscript(records).entries]; + const taskOriginTurnTaskIds = new Set(); + const steeredContents = new Map>(); + const pendingSteers = new Map>(); + const matchedSteers: { key: string; kind: string }[] = []; + const anchorStack: { taskIdsSnapshot: Set; steerCount: number }[] = []; + let anchorFloor = 0; + let sawTurnPrompt = false; + for (const record of records) { + if (record.type === 'context.undo') { + const count = typeof record['count'] === 'number' ? (record['count'] as number) : 0; + for (let i = 0; i < count && anchorStack.length > anchorFloor; i++) { + const popped = anchorStack.pop()!; + matchedSteers.length = popped.steerCount; + taskOriginTurnTaskIds.clear(); + for (const id of popped.taskIdsSnapshot) taskOriginTurnTaskIds.add(id); + } + continue; + } + if (record.type === 'context.clear') { + anchorFloor = anchorStack.length; + continue; + } + if (record.type === 'context.append_message') { + const message = (record as { message?: ContextMessage }).message; + if (message !== undefined && isUndoAnchor(message)) { + anchorStack.push({ taskIdsSnapshot: new Set(taskOriginTurnTaskIds), steerCount: matchedSteers.length }); + } + if (message?.role === 'user') { + const key = JSON.stringify(message.content); + const kind = message.origin?.kind ?? 'user'; + const pendingByKind = pendingSteers.get(key); + const remaining = pendingByKind?.get(kind) ?? 0; + if (remaining > 0) { + pendingByKind!.set(kind, remaining - 1); + matchedSteers.push({ key, kind }); + } + } + continue; + } + if (record.type === 'turn.steer') { + const input = record['input']; + if (Array.isArray(input)) { + const key = JSON.stringify(input); + const steerOrigin = (record as { origin?: { kind?: unknown } }).origin?.kind; + const kind = typeof steerOrigin === 'string' ? steerOrigin : 'user'; + const byKind = pendingSteers.get(key) ?? new Map(); + byKind.set(kind, (byKind.get(kind) ?? 0) + 1); + pendingSteers.set(key, byKind); + } + continue; + } + if (record.type !== 'turn.prompt') continue; + sawTurnPrompt = true; + const origin = (record as { origin?: { kind?: unknown; taskId?: unknown } }).origin; + if (origin === undefined) continue; + if ( + (origin.kind === 'task' || origin.kind === 'background_task') && + typeof origin.taskId === 'string' + ) { + taskOriginTurnTaskIds.add(origin.taskId); + } + } + for (const steer of matchedSteers) { + const byKind = steeredContents.get(steer.key) ?? new Map(); + byKind.set(steer.kind, (byKind.get(steer.kind) ?? 0) + 1); + steeredContents.set(steer.key, byKind); + } + const base = groupMessagesIntoSnapshot( + messages, + sawTurnPrompt || steeredContents.size > 0 ? { taskOriginTurnTaskIds, steeredContents } : undefined, + ); + const folded = foldWireRecordFacts(projectQuestionInteractionRecords(records, sessionId), base, { + resolvePlanRevisionKey: (key) => + join(SESSIONS_ROOT, summary.workspaceId, sessionId, AGENTS_DIR, agentId, key), + }); + const status = getLiveSessionById(this.deps.core.accessor, sessionId) + ?.accessor.get(IAgentLifecycleService) + .handleOf(agentId) + ?.accessor.get(IAgentLoopService) + .snapshot(); + const activity: ActivityMeta = status?.state === 'running' ? 'turn' : 'idle'; + const snapshot = { ...folded, meta: { ...folded.meta, activity } }; + if (snapshot.meta.modes?.tower === undefined) return snapshot; + const flags = this.deps.core.accessor.get(IFlagService); + if ( + agentId === MAIN_AGENT_ID && + flags.enabled(TOWER_FLAG_ID) && + isTowerFeatureAssembled(flags) && + (await this.coldTowerOwnedHere(sessionId, summary.cwd)) + ) { + return snapshot; + } + const modes = { ...snapshot.meta.modes, tower: undefined }; + const cleared = modes.plan === undefined && modes.swarm === undefined && modes.tower === undefined; + return { ...snapshot, meta: { ...snapshot.meta, modes: cleared ? undefined : modes } }; + } + + private async coldTowerOwnedHere(sessionId: string, cwd: string | undefined): Promise { + if (cwd === undefined) return true; + const owner = await new TowerStore(resolveTowerRepoRoot(cwd)) + .load() + .then((state) => state.sessionId, () => undefined); + if (owner === undefined || owner === sessionId) return true; + return this.deps.core.accessor.get(ISessionManager).get(owner) === undefined; + } + + dropSession(sessionId: string): void { + this.opsListeners.delete(sessionId); + for (const [key, pending] of this.healTimers) { + if (key.startsWith(`${sessionId}:`)) { + clearTimeout(pending.timer); + this.healTimers.delete(key); + } + } + const entry = this.live.get(sessionId); + if (entry === undefined) return; + this.live.delete(sessionId); + entry.binding.dispose(); + } +} + +export function snapshotToOps( + snapshot: AgentTranscriptSnapshot, + turnOps: (turn: TranscriptTurn) => TranscriptOperation[] = snapshotTurnOps, +): TranscriptOperation[] { + const ops: TranscriptOperation[] = []; + const pending: (TranscriptMarker | TranscriptTaskRef)[] = []; + let lastTurnOrdinal: number | undefined; + const flushPending = (beforeTurn?: number): void => { + for (const item of pending) { + ops.push( + item.kind === 'marker' + ? { op: 'marker.upsert', item, beforeTurn } + : { op: 'taskref.upsert', item, beforeTurn }, + ); + } + pending.length = 0; + }; + for (const item of snapshot.items) { + if (item.kind === 'turn') { + flushPending(item.ordinal); + lastTurnOrdinal = item.ordinal; + ops.push(...turnOps(item)); + } else { + pending.push(item); + } + } + flushPending(lastTurnOrdinal === undefined ? undefined : lastTurnOrdinal + 1); + for (const attachment of snapshot.attachments) { + ops.push({ op: 'attachment.upsert', attachment }); + } + for (const task of snapshot.tasks) { + ops.push({ op: 'task.upsert', task }); + } + ops.push({ op: 'meta.merge', meta: snapshot.meta }); + return ops; +} + +export function snapshotTurnOps(turn: TranscriptTurn): TranscriptOperation[] { + const ops: TranscriptOperation[] = []; + const { steps, ...header } = turn; + ops.push({ op: 'turn.upsert', turn: header }); + for (const step of steps) { + const { frames, ...stepHeader } = step; + ops.push({ op: 'step.upsert', turnId: turn.turnId, step: stepHeader }); + for (const frame of frames) { + ops.push({ op: 'frame.upsert', turnId: turn.turnId, stepId: step.stepId, frame }); + } + } + return ops; +} + +const TURN_HEAL_DEBOUNCE_MS = 250; +const TERMINAL_TURN_STATES: ReadonlySet = new Set([ + 'completed', + 'failed', + 'cancelled', +]); + +function projectQuestionInteractionRecords( + records: readonly ContextRecord[], + sessionId: string, +): ContextRecord[] { + return records.map((record) => { + if (record.type !== 'interaction.request' || record['kind'] !== 'question') return record; + const id = record['id']; + const request = record['request']; + const time = record['time']; + if (typeof id !== 'string' || typeof time !== 'number' || !Number.isFinite(time)) { + return record; + } + if (request === null || typeof request !== 'object') return record; + try { + const innerToolCallId = (request as { toolCallId?: unknown }).toolCallId; + const toolCallId = + typeof record['toolCallId'] === 'string' + ? record['toolCallId'] + : typeof innerToolCallId === 'string' + ? innerToolCallId + : undefined; + return { + ...record, + toolCallId, + request: toWireQuestion({ id, createdAt: time, payload: request }, sessionId), + }; + } catch { + return record; + } + }); +} + +function supersededColdAttachmentIds( + snapshot: AgentTranscriptSnapshot, + transcript: AgentTranscript, +): ReadonlySet { + const superseded = new Set(); + for (const item of snapshot.items) { + if (item.kind !== 'turn' || item.attachmentIds === undefined) continue; + const live = transcript.getTurn(item.turnId); + if (live?.attachmentIds === undefined || live.attachmentIds.length === 0) continue; + for (const id of item.attachmentIds) superseded.add(id); + } + return superseded; +} + +export function healTurnOps( + snapshotTurn: TranscriptTurn, + liveTurn: TranscriptTurn | undefined, +): TranscriptOperation[] { + const { steps, ...header } = snapshotTurn; + const ops: TranscriptOperation[] = []; + if (liveTurn === undefined) { + ops.push({ op: 'turn.upsert', turn: header }); + for (const step of steps) { + const { frames, ...stepHeader } = step; + ops.push({ op: 'step.upsert', turnId: snapshotTurn.turnId, step: stepHeader }); + for (const frame of frames) { + ops.push({ op: 'frame.upsert', turnId: snapshotTurn.turnId, stepId: step.stepId, frame }); + } + } + return ops; + } + ops.push({ + op: 'turn.upsert', + turn: { + ...header, + state: liveTurn.state, + triggerPromptId: liveTurn.triggerPromptId ?? header.triggerPromptId, + prompt: liveTurn.prompt ?? header.prompt, + attachmentIds: liveTurn.attachmentIds ?? header.attachmentIds, + startedAt: liveTurn.startedAt ?? header.startedAt, + endedAt: liveTurn.endedAt ?? header.endedAt, + }, + }); + for (const step of steps) { + const liveStep = liveTurn.steps.find((entry) => entry.stepId === step.stepId); + const { frames, ...stepHeader } = step; + if (liveStep === undefined) { + ops.push({ op: 'step.upsert', turnId: snapshotTurn.turnId, step: stepHeader }); + for (const frame of frames) { + ops.push({ op: 'frame.upsert', turnId: snapshotTurn.turnId, stepId: step.stepId, frame }); + } + continue; + } + for (const frame of frames) { + const liveFrame = liveStep.frames.find((entry) => entry.frameId === frame.frameId); + if (frame.kind === 'tool') { + const liveTool = liveFrame?.kind === 'tool' ? liveFrame : undefined; + const liveHasOutcome = + liveTool !== undefined && (liveTool.output !== undefined || liveTool.error !== undefined); + const snapshotHasOutcome = frame.output !== undefined || frame.error !== undefined; + if (liveTool !== undefined && (liveHasOutcome || !snapshotHasOutcome)) continue; + ops.push({ + op: 'frame.upsert', + turnId: snapshotTurn.turnId, + stepId: step.stepId, + frame: + liveTool === undefined + ? frame + : { + ...frame, + display: liveTool.display ?? frame.display, + agentRefs: liveTool.agentRefs ?? frame.agentRefs, + approvalId: liveTool.approvalId ?? frame.approvalId, + }, + }); + continue; + } + if (frame.kind !== 'text' && frame.kind !== 'thinking') continue; + if ( + liveFrame !== undefined && + liveFrame.kind === frame.kind && + (liveFrame.kind === 'text' || liveFrame.kind === 'thinking') && + liveFrame.text.length >= frame.text.length + ) { + continue; + } + ops.push({ op: 'frame.upsert', turnId: snapshotTurn.turnId, stepId: step.stepId, frame }); + } + } + return ops; +} diff --git a/packages/kap-server/src/services/transcript/wireRecords.ts b/packages/kap-server/src/services/transcript/wireRecords.ts new file mode 100644 index 0000000000000000000000000000000000000000..53fdd245a333564614e34a5c6a5251fe5b611805 --- /dev/null +++ b/packages/kap-server/src/services/transcript/wireRecords.ts @@ -0,0 +1,27 @@ +import { readFile } from 'node:fs/promises'; + +export interface ContextRecord { + readonly type: string; + readonly [key: string]: unknown; +} + +export async function readWireRecords(wirePath: string): Promise { + const raw = await readFile(wirePath, 'utf8'); + const lines = raw.split('\n'); + const records: ContextRecord[] = []; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]!; + if (line.endsWith('\r')) line = line.slice(0, -1); + if (line.length === 0) continue; + try { + records.push(JSON.parse(line) as ContextRecord); + } catch (parseError) { + if (i === lines.length - 1) break; + throw new Error( + `wire.jsonl: corrupted line ${i + 1} in ${wirePath}: ${String(parseError)}`, + { cause: parseError }, + ); + } + } + return records; +} diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ad117df27068adb81d63c04e6fc7424e7251657 --- /dev/null +++ b/packages/kap-server/src/start.ts @@ -0,0 +1,673 @@ +import { + bootstrap, + drainQueryStoreDisposals, + drainSessionMetadataWrites, + drainSessionIndexMirror, + drainLogCloses, + ConfigWarning, + CapabilityChanged, + IAppendLogStore, + IConfigService, + IEventService, + IMcpOAuthService, + IOAuthService, + IProviderDiscoveryService, + ISessionIndex, + ISessionIndexMirror, + ICapabilityService, + IPluginService, + IWorkspaceService, + PluginChanged, + logSeed, + resolveConfigPath, + resolveKimiHome, + resolveLoggingConfig, + type ConfigDiagnostic, + type Scope, + type ScopeSeed, +} from '@moonshot-ai/agent-core-v2'; +import { + createKimiDefaultHeaders, + kimiRegionProfile, + type KimiHostIdentity, +} from '@moonshot-ai/kimi-code-oauth'; +import { createAsyncApiDocument } from './protocol/asyncapi'; +import Fastify, { type FastifyInstance } from 'fastify'; + +import { installErrorHandler } from './error-handler'; +import { createInstanceRegistry, type InstanceRegistration } from './instanceRegistry'; +import { transformOpenApiDocument } from './openapi/transforms'; +import { registerRequestLogging } from './requestLogging'; +import { resolveRequestId } from './request-id'; +import { registerApiV1Routes } from './routes/registerApiV1Routes'; +import { registerApiV2Routes } from './routes/registerApiV2Routes'; +import { registerWebAssetRoutes } from './routes/webAssets'; +import { + createServerLogger, + type ServerLogger, + type ServerLogLevel, +} from './services/pinoLoggerService'; +import { join } from 'node:path'; +import type { Socket } from 'node:net'; +import type { IncomingMessage } from 'node:http'; +import type { Duplex } from 'node:stream'; + +import { + ConnectionRegistry, + type IConnectionRegistry, +} from './transport/ws/connectionRegistry'; +import { extractWsBearerToken } from './transport/ws/bearerProtocol'; +import { SessionEventBroadcaster } from './transport/ws/v1/sessionEventBroadcaster'; +import type { ConfigWarningItem } from './transport/ws/v1/events'; +import { registerWsV1, WS_PATH as WS_PATH_V1 } from './transport/ws/v1/registerWsV1'; +import { registerWsDebug, WS_DEBUG_PATH } from './transport/ws/debug/registerWsDebug'; +import { registerWsV3, WS_PATH_V3 } from './transport/ws/v3/registerWsV3'; +import { getServerVersion } from './version'; +import { classify } from './security/bindClassify'; +import { + createHostCheck, + isHostCheckDisabled, + parseAllowedHosts, +} from './middleware/hostnames'; +import { createOriginHook, isOriginAllowed, parseCorsOrigins } from './middleware/origin'; +import { createSecurityHeadersHook } from './middleware/securityHeaders'; +import { createAuthHook } from './middleware/auth'; +import { GuiStoreService } from './services/guiStore/guiStoreService'; +import { + initializeServerTelemetry, + type ServerTelemetry, + shutdownServerTelemetry, +} from './services/telemetry'; +import { TranscriptService } from './services/transcript/transcriptService'; +import { ProjectionService } from './services/projection'; +import { ModelCatalogRefreshScheduler } from './services/modelCatalog/modelCatalogRefreshScheduler'; +import { startConfigChangedPublisher } from './services/config/configChangedPublisher'; +import { createAuthFailureLimiter } from './middleware/rateLimit'; +import { createRemoteControlManager } from '@moonshot-ai/remote-control'; + +import { createAuthTokenService, type IAuthTokenService } from './services/auth/authTokenService'; +import { createCredentialValidator } from './services/auth/credentials'; +import { resolvePasswordHash } from './services/auth/password'; +import { createTokenStore } from './services/auth/tokenStore'; + +import { drainGlobalSearchDisposals, IGlobalSearchService } from './search/searchService'; + +export interface ServerHostIdentity extends KimiHostIdentity { + readonly displayName?: string; + readonly replyStyleGuide?: string; +} + +export interface ServerStartOptions { + readonly host?: string; + readonly port?: number; + readonly homeDir?: string; + readonly env?: NodeJS.ProcessEnv; + readonly pluginMarketplaceUrl?: string; + readonly configPath?: string; + readonly instancesDir?: string; + readonly logLevel?: ServerLogLevel; + readonly logger?: ServerLogger; + readonly debugEndpoints?: boolean; + readonly bindClass?: 'lan' | 'public'; + readonly allowedHosts?: readonly string[]; + readonly corsOrigins?: readonly string[]; + readonly disableHostCheck?: boolean; + readonly insecureNoTls?: boolean; + readonly allowRemoteShutdown?: boolean; + readonly authTokenService?: IAuthTokenService; + readonly disableAuth?: boolean; + readonly webTitle?: string; + readonly rpcToken?: string; + readonly seeds?: ScopeSeed; + readonly hostIdentity: ServerHostIdentity; + readonly skillDirs?: readonly string[]; + readonly webAssetsDir?: string; + readonly serverVersion?: string; + readonly telemetry?: boolean; +} + +export interface RunningServer { + readonly app: FastifyInstance; + readonly core: Scope; + readonly connectionRegistry: IConnectionRegistry; + readonly authTokenService: IAuthTokenService; + readonly host: string; + readonly port: number; + close(): Promise; +} + +const DEFAULT_HOST = '127.0.0.1'; +const DEFAULT_PORT = 58627; + +export async function startServer(opts: ServerStartOptions): Promise { + const host = opts.host ?? DEFAULT_HOST; + const port = opts.port ?? DEFAULT_PORT; + const homeDir = resolveKimiHome(opts.homeDir); + const serverVersion = opts.serverVersion ?? getServerVersion(); + const registry = createInstanceRegistry({ + instancesDir: opts.instancesDir ?? join(homeDir, 'server', 'instances'), + }); + const registration: InstanceRegistration = await registry.register({ + pid: process.pid, + host, + port, + startedAt: Date.now(), + serverVersion, + }); + const exposureClass = classify(host, { bindClass: opts.bindClass }); + if (exposureClass !== 'loopback' && opts.insecureNoTls !== true) { + await registration.release(); + throw new Error( + `Refusing to bind ${host} (${exposureClass}) without TLS; terminate TLS at a reverse proxy or pass --insecure-no-tls.`, + ); + } + const enableShutdown = exposureClass === 'loopback' || opts.allowRemoteShutdown === true; + const enableTerminals = exposureClass === 'loopback'; + const debugEndpoints = exposureClass === 'loopback' && opts.debugEndpoints === true; + const logger = opts.logger ?? createServerLogger({ level: opts.logLevel ?? 'info' }); + const onUnhandledRejection = (reason: unknown): void => { + logger.error( + { err: reason instanceof Error ? reason : new Error(String(reason)) }, + 'unhandledRejection', + ); + }; + const onUncaughtException = (err: unknown): void => { + logger.error( + { err: err instanceof Error ? err : new Error(String(err)) }, + 'uncaughtException', + ); + }; + const authFailureLimiter = + exposureClass === 'loopback' ? undefined : createAuthFailureLimiter({ logger }); + + const configPath = resolveConfigPath({ homeDir, configPath: opts.configPath }); + const guiStore = new GuiStoreService(homeDir, logger); + let authTokenService: IAuthTokenService; + let passwordConfigured = false; + if (opts.authTokenService !== undefined) { + authTokenService = opts.authTokenService; + } else { + const tokenStore = await createTokenStore(homeDir); + const passwordHash = await resolvePasswordHash(); + passwordConfigured = passwordHash !== undefined; + authTokenService = createAuthTokenService({ tokenStore, passwordHash }); + } + const validateCredential = createCredentialValidator(authTokenService, opts.rpcToken); + const logging = resolveLoggingConfig({ homeDir, env: process.env }); + let boundPort = port; + const localOriginHost = host.includes(':') ? `[${host}]` : host; + const remoteControlManager = createRemoteControlManager({ + homeDir, + localOrigin: () => `http://${localOriginHost}:${boundPort}`, + localServerToken: () => authTokenService.getToken(), + clientVersion: `kimi-code/${serverVersion}`, + stderr: { + write: (text) => { + logger.warn(String(text).trimEnd()); + return true; + }, + }, + }); + const { app: core } = bootstrap( + { + homeDir, + configPath, + env: opts.env, + clientIdentity: opts.hostIdentity, + args: { + requestHeaders: createKimiDefaultHeaders({ homeDir, ...opts.hostIdentity }), + skillDirs: opts.skillDirs, + displayName: opts.hostIdentity.displayName, + replyStyleGuide: opts.hostIdentity.replyStyleGuide, + }, + }, + [...logSeed(logging), ...(opts.seeds ?? [])], + ); + + let telemetry: ServerTelemetry = {}; + if (opts.telemetry === true) { + try { + telemetry = await initializeServerTelemetry(core, homeDir); + } catch (error) { + logger.warn( + { err: error instanceof Error ? error.message : String(error) }, + 'telemetry initialization failed; continuing without telemetry', + ); + } + } + + if (exposureClass !== 'loopback') { + logger.warn( + { host, exposureClass }, + 'binding non-loopback host without TLS — use a reverse proxy or tunnel in production', + ); + if (!passwordConfigured) { + logger.warn( + { host, exposureClass }, + 'binding non-loopback host with token-only auth (no KIMI_CODE_PASSWORD) — the bearer token printed in the startup banner is the only credential protecting this server', + ); + } + } + const modelCatalogRefreshScheduler = new ModelCatalogRefreshScheduler( + core.accessor.get(IProviderDiscoveryService), + core.accessor.get(IConfigService), + logger, + ); + + try { + await core.accessor.get(IWorkspaceService).list(); + } catch (error) { + logger.warn( + { err: error instanceof Error ? error.message : String(error) }, + 'workspace catalog startup sync failed', + ); + } + + try { + await core.accessor.get(ISessionIndex).prepare(); + } catch (error) { + logger.warn( + { err: error instanceof Error ? error.message : String(error) }, + 'session index prepare failed; falling back to on-demand reads', + ); + } + + const app = Fastify({ + loggerInstance: logger, + disableRequestLogging: true, + genReqId: (req) => resolveRequestId(req.headers), + }) as unknown as FastifyInstance; + app.server.requestTimeout = 0; + registerRequestLogging(app); + app.setValidatorCompiler(() => () => true); + app.setSerializerCompiler(() => (data) => JSON.stringify(data)); + installErrorHandler(app); + const hostCheck = createHostCheck({ + boundHost: host, + extra: [...parseAllowedHosts(process.env), ...(opts.allowedHosts ?? [])], + disable: opts.disableHostCheck ?? isHostCheckDisabled(), + }); + const allowedOrigins = opts.corsOrigins ?? parseCorsOrigins(); + app.addHook('onRequest', hostCheck.onRequest); + app.addHook('onRequest', createOriginHook({ allowedOrigins })); + if (opts.disableAuth !== true) { + app.addHook( + 'onRequest', + createAuthHook(authTokenService, { limiter: authFailureLimiter, validateCredential }), + ); + } else { + logger.warn( + { host, exposureClass }, + 'DANGEROUS: bearer-token auth is DISABLED (--dangerous-bypass-auth) — every REST and WebSocket route accepts unauthenticated requests', + ); + } + if (exposureClass !== 'loopback') { + app.addHook('onSend', createSecurityHeadersHook({ tls: false })); + } + + const close = async (): Promise => { + if (wssDebug !== undefined) { + for (const client of wssDebug.clients) client.terminate(); + } + configChangedPublisher.close(); + await remoteControlManager.close(); + await app.close(); + configWarningSubscription.dispose(); + pluginChangeSubscription.dispose(); + capabilityInstallSubscription.dispose(); + authFailureLimiter?.dispose(); + modelCatalogRefreshScheduler.dispose(); + try { + await shutdownServerTelemetry(telemetry); + } catch (error) { + logger.warn( + { err: error instanceof Error ? error.message : String(error) }, + 'telemetry shutdown failed; continuing server cleanup', + ); + } + try { + await drainSessionMetadataWrites(); + await core.accessor.get(ISessionIndexMirror).drain(); + await core.accessor.get(IMcpOAuthService).shutdown(); + const appendLogStore = core.accessor.get(IAppendLogStore); + core.dispose(); + await appendLogStore.drainRetirements(); + await drainSessionIndexMirror(); + await drainGlobalSearchDisposals(); + await drainQueryStoreDisposals(); + await drainSessionMetadataWrites(); + await drainLogCloses(); + } finally { + try { + await registration.release(); + } finally { + process.off('unhandledRejection', onUnhandledRejection); + process.off('uncaughtException', onUncaughtException); + } + } + }; + + const connectionRegistry = new ConnectionRegistry(); + const transcriptService = new TranscriptService({ homeDir, core, logger }); + core.accessor.get(IGlobalSearchService).setLiveTranscriptSource(transcriptService); + const broadcaster = new SessionEventBroadcaster({ + eventsDir: join(homeDir, 'server', 'events'), + core, + logger, + transcriptService, + }); + const projectionService = new ProjectionService({ homeDir, core, logger }); + + const configService = core.accessor.get(IConfigService); + const publishConfigWarnings = (diagnostics: readonly ConfigDiagnostic[]): void => { + const warnings: ConfigWarningItem[] = diagnostics + .filter((diagnostic) => diagnostic.severity === 'warning') + .map((diagnostic) => + diagnostic.domain === undefined + ? { message: diagnostic.message } + : { domain: diagnostic.domain, message: diagnostic.message }, + ); + core.accessor.get(IEventService).publish(new ConfigWarning({ payload: { warnings } })); + }; + const configWarningSubscription = configService.onDidChangeDiagnostics(publishConfigWarnings); + const configChangedPublisher = startConfigChangedPublisher(core); + + const pluginService = core.accessor.get(IPluginService); + const pluginChangeSubscription = pluginService.onDidReload(() => { + core.accessor.get(IEventService).publish(new PluginChanged({ payload: {} })); + }); + const capabilityService = core.accessor.get(ICapabilityService); + const capabilityInstallSubscription = capabilityService.onDidChangeInstall((change) => { + core.accessor.get(IEventService).publish( + new CapabilityChanged({ + payload: { capability_id: change.id, install: change.install }, + }), + ); + }); + void configService.ready + .then(() => { + if (configService.diagnostics().some((diagnostic) => diagnostic.severity === 'warning')) { + publishConfigWarnings(configService.diagnostics()); + } + }) + .catch(() => { + }); + + async function registerOpenApi(): Promise { + const { default: swagger } = await import('@fastify/swagger'); + await app.register(swagger, { + openapi: { + info: { + title: 'Kimi Code Server API', + description: + 'REST API for the Kimi Code local server. All JSON responses are wrapped in a uniform envelope `{ code, msg, data, request_id }`.', + version: serverVersion, + }, + tags: [ + { name: 'meta', description: 'Server metadata' }, + { name: 'auth', description: 'Auth readiness & login state' }, + { name: 'models', description: 'Configured model aliases' }, + { name: 'providers', description: 'Configured providers' }, + { name: 'sessions', description: 'Session lifecycle' }, + { name: 'v2-sessions', description: 'Domain-grouped session list query (API v2)' }, + { name: 'workspaces', description: 'Workspace registry + folder picker' }, + { name: 'messages', description: 'Message history' }, + { name: 'search', description: 'Global message search' }, + { name: 'transcript', description: 'Turn-granular session transcript' }, + { name: 'prompts', description: 'Prompt submission & abort' }, + { name: 'approvals', description: 'Approval resolution' }, + { name: 'questions', description: 'Question resolution & dismiss' }, + { name: 'tools', description: 'Tool & MCP server management' }, + { name: 'tasks', description: 'Task management' }, + { name: 'terminals', description: 'PTY terminal sessions' }, + { name: 'fs', description: 'Filesystem operations' }, + { name: 'files', description: 'File upload & download' }, + { name: 'remote-control', description: 'Remote Control tunnel' }, + ], + }, + transformObject: (documentObject) => { + if (!('openapiObject' in documentObject)) { + return documentObject.swaggerObject; + } + return transformOpenApiDocument(documentObject.openapiObject as Record); + }, + }); + } + + await registerOpenApi(); + + await registerApiV1Routes(app, core, { + serverVersion, + hostIdentity: opts.hostIdentity, + debugEndpoints, + enableShutdown, + enableTerminals, + guiStore, + pluginMarketplaceUrl: (() => { + const configured = opts.pluginMarketplaceUrl ?? process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL']; + if (configured !== undefined) return () => configured; + return () => + `${kimiRegionProfile(core.accessor.get(IOAuthService).getRegion()).cdnBase}/plugins/marketplace.json`; + })(), + pluginMarketplaceIsDefault: + opts.pluginMarketplaceUrl === undefined && + (process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] === undefined || + process.env['KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER'] === '1'), + remoteControl: { + service: remoteControlManager, + staticEnableError: + exposureClass !== 'loopback' + ? 'Remote Control requires a loopback host.' + : opts.disableAuth === true + ? 'Remote Control cannot be combined with --dangerous-bypass-auth.' + : undefined, + }, + onShutdown: () => { + void close().catch((err: unknown) => logger.error({ err }, 'server close failed')); + }, + connectionRegistry, + broadcaster, + transcriptService, + homeDir, + projectionService, + dangerousBypassAuth: opts.disableAuth === true, + webTitle: opts.webTitle, + }); + + await registerApiV2Routes(app, core); + + const wssV1 = registerWsV1(core, { + validateCredential, + registry: connectionRegistry, + broadcaster, + logger, + }); + const wssDebug = debugEndpoints ? registerWsDebug() : undefined; + + const { wss: wssV3, hub: wsV3Hub } = registerWsV3(core, { + registry: connectionRegistry, + projection: projectionService, + serverId: registration.serverId, + logger, + }); + + const handleUpgrade = async ( + req: IncomingMessage, + socket: Duplex, + head: Buffer, + ): Promise => { + const url = req.url ?? ''; + const isV1 = url === WS_PATH_V1 || url.startsWith(`${WS_PATH_V1}?`); + const isV3 = url === WS_PATH_V3 || url.startsWith(`${WS_PATH_V3}?`); + const isDebug = url === WS_DEBUG_PATH || url.startsWith(`${WS_DEBUG_PATH}?`); + const wss = isV1 ? wssV1 : isV3 ? wssV3 : isDebug ? wssDebug : undefined; + if (wss === undefined) { + socket.destroy(); + return; + } + + if (!hostCheck.isAllowed(req.headers.host)) { + logger.warn( + { remoteAddress: req.socket.remoteAddress, path: url, reason: 'host_not_allowed' }, + 'ws upgrade rejected', + ); + (socket as Socket).write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + (socket as Socket).destroy(); + return; + } + if (!isOriginAllowed(req.headers.origin, req.headers.host, allowedOrigins)) { + logger.warn( + { remoteAddress: req.socket.remoteAddress, path: url, reason: 'origin_not_allowed' }, + 'ws upgrade rejected', + ); + (socket as Socket).write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + (socket as Socket).destroy(); + return; + } + + if (opts.disableAuth !== true) { + const authHeader = req.headers.authorization; + const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null; + const protocolToken = extractWsBearerToken(req.headers['sec-websocket-protocol']); + const candidate = bearerToken !== null && bearerToken.length > 0 ? bearerToken : protocolToken; + let ok = false; + if (candidate !== null) { + try { + ok = await validateCredential(candidate); + } catch (error) { + logger.warn( + { + err: error, + remoteAddress: req.socket.remoteAddress, + path: url, + reason: 'credential_validation_error', + }, + 'ws upgrade rejected', + ); + ok = false; + } + } + if (!ok) { + logger.warn( + { + remoteAddress: req.socket.remoteAddress, + path: url, + reason: candidate === null ? 'missing_credential' : 'invalid_credential', + }, + 'ws upgrade rejected', + ); + (socket as Socket).write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); + (socket as Socket).destroy(); + return; + } + } + + (socket as Socket).setNoDelay(true); + wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); + }; + app.server.on('upgrade', (req, socket, head) => { + void handleUpgrade(req, socket, head).catch((error: unknown) => + logger.error({ err: error }, 'ws upgrade handler failed'), + ); + }); + + app.addHook('onClose', async () => { + connectionRegistry.closeAll('server shutting down'); + wssV1.close(); + wssDebug?.close(); + wssV3.close(); + wsV3Hub.dispose(); + await broadcaster.close(); + }); + + app.get('/asyncapi.json', async (_req, reply) => { + return reply + .type('application/json') + .send(createAsyncApiDocument({ version: serverVersion, serverHost: host })); + }); + + app.get('/openapi.json', async (_req, reply) => { + const openApiDocument = (app as unknown as { swagger(): unknown }).swagger(); + return reply.type('application/json').send(openApiDocument); + }); + + if (opts.webAssetsDir !== undefined) { + await registerWebAssetRoutes(app, opts.webAssetsDir); + } + + try { + await listenWithPortRetry({ + listen: (h, p) => app.listen({ host: h, port: p }), + host, + port, + logger, + }); + } catch (error) { + try { + await close(); + } catch { + } + throw error; + } + + const address = app.server.address(); + boundPort = typeof address === 'object' && address !== null ? address.port : port; + await registration.update({ port: boundPort }); + + void modelCatalogRefreshScheduler.start().catch((error) => { + logger.warn( + { err: error instanceof Error ? error.message : String(error) }, + 'provider-model catalog auto-refresh failed to start', + ); + }); + + process.on('unhandledRejection', onUnhandledRejection); + process.on('uncaughtException', onUncaughtException); + + return { app, core, connectionRegistry, authTokenService, host, port: boundPort, close }; +} + +export const PORT_RETRY_LIMIT = 100; + +export interface ListenWithPortRetryOptions { + readonly listen: (host: string, port: number) => Promise; + readonly host: string; + readonly port: number; + readonly logger: ServerLogger; + readonly maxRetries?: number; +} + +export async function listenWithPortRetry( + opts: ListenWithPortRetryOptions, +): Promise<{ address: string; port: number }> { + if (opts.port === 0) { + const address = await opts.listen(opts.host, 0); + return { address, port: 0 }; + } + + const maxRetries = opts.maxRetries ?? PORT_RETRY_LIMIT; + let port = opts.port; + for (let attempt = 0; ; attempt++) { + try { + const address = await opts.listen(opts.host, port); + if (port !== opts.port) { + opts.logger.warn( + { requestedPort: opts.port, port, host: opts.host }, + 'requested port was busy; server bound to a higher port', + ); + } + return { address, port }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EADDRINUSE' || attempt >= maxRetries || port >= 65535) { + throw error; + } + const next = port + 1; + opts.logger.warn( + { host: opts.host, port, next }, + 'port in use by another process, trying next port', + ); + port = next; + } + } +} diff --git a/packages/kap-server/src/transport/businessSnapshotDispatcher.ts b/packages/kap-server/src/transport/businessSnapshotDispatcher.ts new file mode 100644 index 0000000000000000000000000000000000000000..8584df83314881648676af6fb100d72ac20c2622 --- /dev/null +++ b/packages/kap-server/src/transport/businessSnapshotDispatcher.ts @@ -0,0 +1,65 @@ +import { + Error2, + ErrorCodes, + IAgentLifecycleService, + IAgentRuntimeBindingService, + IAgentRuntimeService, + ISessionContext, + IWorkspaceInstanceManager, + getLiveSessionById, + snapshotAgentRuntimeBinding, + snapshotSessionWorkspaceAssociation, + type AgentRuntimeBindingSnapshot, + type Scope, + type SessionWorkspaceAssociationSnapshot, + type WorkspaceInstanceSnapshot, + type WorkspaceInstancesSnapshot, +} from '@moonshot-ai/agent-core-v2'; + +import { MAIN_AGENT_ID, ensureMainAgent } from './mainAgent'; + +export function workspaceSnapshots(core: Scope): WorkspaceInstancesSnapshot { + return core.accessor.get(IWorkspaceInstanceManager).snapshot(); +} + +export async function workspaceSnapshot( + core: Scope, + workspaceId: string, +): Promise { + return (await core.accessor.get(IWorkspaceInstanceManager).getOrCreate({ workspaceId })).snapshot(); +} + +export function sessionWorkspaceAssociation( + core: Scope, + sessionId: string, +): SessionWorkspaceAssociationSnapshot { + const session = getLiveSessionById(core.accessor, sessionId); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} not found`); + } + return snapshotSessionWorkspaceAssociation(session.accessor.get(ISessionContext)); +} + +export async function agentRuntimeBindingSnapshot( + core: Scope, + sessionId: string, + agentId: string, +): Promise { + const session = getLiveSessionById(core.accessor, sessionId); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} not found`); + } + const agent = agentId === MAIN_AGENT_ID + ? await ensureMainAgent(session) + : session.accessor.get(IAgentLifecycleService).handleOf(agentId); + if (agent === undefined) { + throw new Error2( + ErrorCodes.AGENT_NOT_FOUND, + `agent ${agentId} not found in session ${sessionId}`, + ); + } + return snapshotAgentRuntimeBinding( + agent.accessor.get(IAgentRuntimeBindingService), + agent.accessor.get(IAgentRuntimeService), + ); +} diff --git a/packages/kap-server/src/transport/businessSnapshotRoutes.ts b/packages/kap-server/src/transport/businessSnapshotRoutes.ts new file mode 100644 index 0000000000000000000000000000000000000000..a15cbe53a81ec4324899fb41f2c7bf1afda26c8a --- /dev/null +++ b/packages/kap-server/src/transport/businessSnapshotRoutes.ts @@ -0,0 +1,72 @@ +import type { Scope } from '@moonshot-ai/agent-core-v2'; + +import { okEnvelope } from '../protocol/envelope'; +import { mapError, withTimeout } from './errors'; +import type { RouteHost } from './serviceDispatcherRoutes'; +import { + agentRuntimeBindingSnapshot, + sessionWorkspaceAssociation, + workspaceSnapshot, + workspaceSnapshots, +} from './businessSnapshotDispatcher'; + +interface SnapshotRequest { + readonly id: string; + readonly params: unknown; +} + +interface SnapshotReply { + send(payload: unknown): unknown; +} + +export function registerBusinessSnapshotRoutes( + app: RouteHost, + core: Scope, + basePath: string, + callTimeoutMs = 30_000, +): void { + app.get(`${basePath}/workspaces`, async (req, reply) => + sendSnapshot(req, reply, () => workspaceSnapshots(core), callTimeoutMs)); + app.get(`${basePath}/workspace/:workspace_id/snapshot`, async (req, reply) => + sendSnapshot( + req, + reply, + () => workspaceSnapshot(core, requestParams(req)['workspace_id'] ?? ''), + callTimeoutMs, + )); + app.get(`${basePath}/session/:session_id/association`, async (req, reply) => + sendSnapshot( + req, + reply, + () => sessionWorkspaceAssociation(core, requestParams(req)['session_id'] ?? ''), + callTimeoutMs, + )); + app.get(`${basePath}/session/:session_id/agent/:agent_id/runtime-binding`, async (req, reply) => + sendSnapshot( + req, + reply, + () => agentRuntimeBindingSnapshot( + core, + requestParams(req)['session_id'] ?? '', + requestParams(req)['agent_id'] ?? '', + ), + callTimeoutMs, + )); +} + +function requestParams(req: SnapshotRequest): Record { + return req.params as Record; +} + +async function sendSnapshot( + req: SnapshotRequest, + reply: SnapshotReply, + read: () => unknown, + callTimeoutMs: number, +): Promise { + try { + return reply.send(okEnvelope(await withTimeout(Promise.resolve(read()), callTimeoutMs), req.id)); + } catch (error) { + return reply.send(mapError(error, req.id)); + } +} diff --git a/packages/kap-server/src/transport/channel.ts b/packages/kap-server/src/transport/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..b99d3a1f90a9f43ef8a22cf7519e9ab5f18e3065 --- /dev/null +++ b/packages/kap-server/src/transport/channel.ts @@ -0,0 +1,6 @@ +export type ScopeKind = 'core' | 'session' | 'agent'; + +export interface IChannel { + call(command: string, arg?: unknown): Promise; + listen(event: string, arg?: unknown): unknown; +} diff --git a/packages/kap-server/src/transport/channelRegistry.ts b/packages/kap-server/src/transport/channelRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea847c7d5ccef8aa54ab46283a20d72332c7121a --- /dev/null +++ b/packages/kap-server/src/transport/channelRegistry.ts @@ -0,0 +1,122 @@ +import { + Disposable, + getScopedServiceDescriptors, + IFeatureManager, + LifecycleScope, +} from '@moonshot-ai/agent-core-v2'; + +import type { Scope, ScopedEntry, ServiceIdentifier } from '@moonshot-ai/agent-core-v2'; + +export interface ChannelMethodDescriptor { + readonly name: string; + readonly kind: 'method' | 'property'; + readonly arity: number; + readonly params: string; +} + +export interface ChannelDescriptor { + readonly name: string; + readonly scope: 'app' | 'session' | 'agent'; + readonly domain: string; + readonly methods: readonly ChannelMethodDescriptor[]; +} + +const SCOPE_NAME: Record = { + [LifecycleScope.App]: 'app', + [LifecycleScope.Session]: 'session', + [LifecycleScope.Agent]: 'agent', +}; + +let serviceNameIndex: Map> | undefined; + +function scopedServiceNameIndex(): Map> { + serviceNameIndex ??= (() => { + const map = new Map>(); + for (const scope of [ + LifecycleScope.App, + LifecycleScope.Session, + LifecycleScope.Agent, + ]) { + for (const entry of getScopedServiceDescriptors(scope)) { + const name = entry.id.toString(); + if (!map.has(name)) map.set(name, entry.id); + } + } + return map; + })(); + return serviceNameIndex; +} + +export function resolveAnyScopedServiceId( + core: Scope, + name: string, +): ServiceIdentifier | undefined { + return ( + scopedServiceNameIndex().get(name) ?? + core.accessor + .get(IFeatureManager) + .contributedServices() + .find((entry) => entry.id.toString() === name)?.id + ); +} + +function extractParams(fn: (...args: never[]) => unknown): string { + const src = fn.toString(); + const start = src.indexOf('('); + if (start === -1) return ''; + let depth = 0; + for (let i = start; i < src.length; i++) { + const ch = src[i]; + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth === 0) return src.slice(start + 1, i).trim(); + } + } + return ''; +} + +function describeMethods( + ctor: new (...args: any[]) => unknown, +): readonly ChannelMethodDescriptor[] { + const methods = new Map(); + let proto: object | null = ctor.prototype; + while (proto !== null && proto !== Object.prototype && proto !== Disposable.prototype) { + for (const name of Object.getOwnPropertyNames(proto)) { + if (name === 'constructor' || name.startsWith('_') || methods.has(name)) continue; + const desc = Object.getOwnPropertyDescriptor(proto, name); + if (desc === undefined) continue; + if (typeof desc.get === 'function') { + methods.set(name, { name, kind: 'property', arity: 0, params: '' }); + } else if (typeof desc.value === 'function') { + const fn = desc.value as (...args: never[]) => unknown; + methods.set(name, { + name, + kind: 'method', + arity: fn.length, + params: extractParams(fn), + }); + } + } + proto = Object.getPrototypeOf(proto) as object | null; + } + return [...methods.values()].toSorted((a, b) => a.name.localeCompare(b.name)); +} + +export function describeAllChannels(): readonly ChannelDescriptor[] { + const byName = new Map(); + for (const scope of [LifecycleScope.App, LifecycleScope.Session, LifecycleScope.Agent]) { + for (const entry of getScopedServiceDescriptors(scope)) { + const name = entry.id.toString(); + if (!byName.has(name)) byName.set(name, entry); + } + } + return [...byName.entries()] + .map(([name, entry]) => ({ + name, + scope: SCOPE_NAME[entry.scope] ?? 'app', + domain: entry.domain, + methods: describeMethods(entry.descriptor.ctor), + })) + .toSorted((a, b) => a.name.localeCompare(b.name)); +} diff --git a/packages/kap-server/src/transport/dispatcher.ts b/packages/kap-server/src/transport/dispatcher.ts new file mode 100644 index 0000000000000000000000000000000000000000..7cce4b04d9c06a32ad981a4427122141cb484ade --- /dev/null +++ b/packages/kap-server/src/transport/dispatcher.ts @@ -0,0 +1,104 @@ +import { + ErrorCodes, + IAgentLifecycleService, + Error2, + getLiveSessionById, + type IScopeHandle, + type Scope, + type ServiceIdentifier, +} from '@moonshot-ai/agent-core-v2'; + +import type { ScopeKind } from './channel'; +import { resolveAnyScopedServiceId } from './channelRegistry'; +import { assertSerializable } from './errors'; +import { MAIN_AGENT_ID, ensureMainAgent } from './mainAgent'; + +export type ChannelLookup = (name: string) => ServiceIdentifier | undefined; + +export async function resolveScope( + core: Scope, + scopeKind: ScopeKind, + params: Record, +): Promise { + switch (scopeKind) { + case 'core': + return core; + case 'session': { + const sessionId = params['session_id'] ?? ''; + const session = getLiveSessionById(core.accessor, sessionId); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} not found`); + } + return session; + } + case 'agent': { + const sessionId = params['session_id'] ?? ''; + const agentId = params['agent_id'] ?? ''; + const session = getLiveSessionById(core.accessor, sessionId); + if (session === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} not found`); + } + if (agentId === MAIN_AGENT_ID) return ensureMainAgent(session); + const agent = session.accessor.get(IAgentLifecycleService).handleOf(agentId); + if (agent === undefined) { + throw new Error2( + ErrorCodes.AGENT_NOT_FOUND, + `agent ${agentId} not found in session ${sessionId}`, + ); + } + return agent; + } + } +} + +export async function resolveService( + core: Scope, + scopeKind: ScopeKind, + params: Record, + serviceName: string, + lookup: ChannelLookup = (name) => resolveAnyScopedServiceId(core, name), +): Promise { + const scope = await resolveScope(core, scopeKind, params); + if (scope === undefined) { + throw new Error2( + ErrorCodes.SESSION_NOT_FOUND, + `session ${params['session_id'] ?? ''} not found`, + ); + } + const id = lookup(serviceName); + if (id === undefined) { + throw new Error2(ErrorCodes.REQUEST_INVALID, `unknown service: ${serviceName}`); + } + try { + return scope.accessor.get(id) as object; + } catch { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + `service not available in ${scopeKind} scope: ${serviceName}`, + ); + } +} + +export async function dispatch( + core: Scope, + scopeKind: ScopeKind, + params: Record, + serviceName: string, + method: string, + arg: unknown, + lookup: ChannelLookup = (name) => resolveAnyScopedServiceId(core, name), +): Promise { + const service = await resolveService(core, scopeKind, params, serviceName, lookup); + const member = (service as Record)[method]; + if (member === undefined) { + throw new Error2(ErrorCodes.REQUEST_INVALID, `method not found: ${serviceName}.${method}`); + } + + if (typeof member !== 'function') { + return assertSerializable(member); + } + + const args = Array.isArray(arg) ? arg : arg === undefined ? [] : [arg]; + const result = await (member as (...a: unknown[]) => unknown).apply(service, args); + return assertSerializable(result); +} diff --git a/packages/kap-server/src/transport/errors.ts b/packages/kap-server/src/transport/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..298d504ae7c2a5b4053e7276e21352c116479454 --- /dev/null +++ b/packages/kap-server/src/transport/errors.ts @@ -0,0 +1,105 @@ +import { ErrorCodes, Error2 } from '@moonshot-ai/agent-core-v2'; + +import { errEnvelope } from '../protocol/envelope'; +import { ErrorCode } from '../protocol/error-codes'; + +export class TimeoutError extends Error { + constructor(readonly ms: number) { + super(`call timed out after ${ms}ms`); + this.name = 'TimeoutError'; + } +} + +export function withTimeout(promise: Promise, ms: number): Promise { + if (ms <= 0) return promise; + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new TimeoutError(ms)), ms); + timer.unref?.(); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }) as Promise; +} + +const KIMI_TO_PROTOCOL: Record = { + [ErrorCodes.SESSION_NOT_FOUND]: ErrorCode.SESSION_NOT_FOUND, + [ErrorCodes.AGENT_NOT_FOUND]: ErrorCode.SESSION_NOT_FOUND, + [ErrorCodes.SESSION_UNDO_UNAVAILABLE]: ErrorCode.SESSION_UNDO_UNAVAILABLE, + [ErrorCodes.REQUEST_INVALID]: ErrorCode.VALIDATION_FAILED, + [ErrorCodes.CONFIG_INVALID]: ErrorCode.VALIDATION_FAILED, + [ErrorCodes.NOT_IMPLEMENTED]: ErrorCode.INTERNAL_ERROR, + [ErrorCodes.PROMPT_NOT_FOUND]: ErrorCode.PROMPT_NOT_FOUND, + [ErrorCodes.FS_PATH_NOT_FOUND]: ErrorCode.FS_PATH_NOT_FOUND, + [ErrorCodes.SESSION_BUSY]: ErrorCode.SESSION_BUSY, + [ErrorCodes.PROMPT_ID_CONFLICT]: ErrorCode.PROMPT_ID_CONFLICT, + [ErrorCodes.GOAL_ALREADY_EXISTS]: ErrorCode.GOAL_ALREADY_EXISTS, + [ErrorCodes.GOAL_NOT_FOUND]: ErrorCode.GOAL_NOT_FOUND, + [ErrorCodes.GOAL_STATUS_INVALID]: ErrorCode.GOAL_STATUS_INVALID, + [ErrorCodes.GOAL_NOT_RESUMABLE]: ErrorCode.GOAL_NOT_RESUMABLE, + [ErrorCodes.GOAL_OBJECTIVE_EMPTY]: ErrorCode.GOAL_OBJECTIVE_EMPTY, + [ErrorCodes.GOAL_OBJECTIVE_TOO_LONG]: ErrorCode.GOAL_OBJECTIVE_TOO_LONG, + [ErrorCodes.GOAL_UNSUPPORTED_AGENT]: ErrorCode.GOAL_UNSUPPORTED_AGENT, + [ErrorCodes.OS_FS_NOT_FOUND]: ErrorCode.FS_PATH_NOT_FOUND, + [ErrorCodes.OS_FS_NOT_DIRECTORY]: ErrorCode.FS_PATH_NOT_FOUND, + [ErrorCodes.OS_FS_IS_DIRECTORY]: ErrorCode.FS_IS_DIRECTORY, + [ErrorCodes.OS_FS_ALREADY_EXISTS]: ErrorCode.FS_ALREADY_EXISTS, + [ErrorCodes.OS_FS_PERMISSION_DENIED]: ErrorCode.FS_PERMISSION_DENIED, + [ErrorCodes.STORAGE_IO_FAILED]: ErrorCode.PERSISTENCE_FAILURE, + [ErrorCodes.STORAGE_LOCKED]: ErrorCode.PERSISTENCE_FAILURE, +}; + +export function mapError(err: unknown, requestId: string): ReturnType { + if (err instanceof Error2) { + const code = KIMI_TO_PROTOCOL[err.code] ?? ErrorCode.INTERNAL_ERROR; + return errEnvelope(code, err.message, requestId, err.stack); + } + if (err instanceof TimeoutError) { + return errEnvelope(ErrorCode.INTERNAL_ERROR, err.message, requestId, err.stack); + } + return errEnvelope( + ErrorCode.INTERNAL_ERROR, + err instanceof Error ? err.message : String(err), + requestId, + err instanceof Error ? err.stack : undefined, + ); +} + +export function validationEnvelope( + details: { path: string; message: string }[], + requestId: string, +): { + code: number; + msg: string; + data: null; + request_id: string; + details: { path: string; message: string }[]; +} { + const first = details[0]; + const msg = + first === undefined + ? 'validation failed' + : first.path === '' + ? first.message + : `${first.path}: ${first.message}`; + return { + code: ErrorCode.VALIDATION_FAILED, + msg, + data: null, + request_id: requestId, + details, + }; +} + +export function assertSerializable(value: unknown): unknown { + if (value === undefined) return null; + try { + JSON.stringify(value); + } catch (error) { + throw new Error2( + ErrorCodes.INTERNAL, + `result not serializable: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return value; +} diff --git a/packages/kap-server/src/transport/mainAgent.ts b/packages/kap-server/src/transport/mainAgent.ts new file mode 100644 index 0000000000000000000000000000000000000000..515dac043b4045f40180e36b0a9ae129de05b984 --- /dev/null +++ b/packages/kap-server/src/transport/mainAgent.ts @@ -0,0 +1,18 @@ +import { + ensureMainAgent as ensureMainAgentContext, + IAgentLifecycleService, + MAIN_AGENT_ID, + type IAgentScopeHandle, + type ISessionScopeHandle, +} from '@moonshot-ai/agent-core-v2'; + +export { MAIN_AGENT_ID }; + +export async function ensureMainAgent(session: ISessionScopeHandle): Promise { + const context = await ensureMainAgentContext(session); + const handle = session.accessor.get(IAgentLifecycleService).handleOf(context.agentId); + if (handle === undefined) { + throw new Error('Main agent was not found'); + } + return handle; +} diff --git a/packages/kap-server/src/transport/registerDebugRoutes.ts b/packages/kap-server/src/transport/registerDebugRoutes.ts new file mode 100644 index 0000000000000000000000000000000000000000..edf0d6e0d070e4370f5d007470a04d9a58992579 --- /dev/null +++ b/packages/kap-server/src/transport/registerDebugRoutes.ts @@ -0,0 +1,13 @@ +import type { Scope } from '@moonshot-ai/agent-core-v2'; + +import { registerBusinessSnapshotRoutes } from './businessSnapshotRoutes'; +import { describeAllChannels, resolveAnyScopedServiceId } from './channelRegistry'; +import { type RouteHost, registerServiceDispatcherRoutes } from './serviceDispatcherRoutes'; + +export function registerDebugRoutes(app: RouteHost, core: Scope): void { + registerServiceDispatcherRoutes(app, core, '/debug', { + lookup: (name) => resolveAnyScopedServiceId(core, name), + describe: describeAllChannels, + }); + registerBusinessSnapshotRoutes(app, core, '/debug'); +} diff --git a/packages/kap-server/src/transport/serviceDispatcherRoutes.ts b/packages/kap-server/src/transport/serviceDispatcherRoutes.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9cf5455c69b9900412f89447a39ac141685aefc --- /dev/null +++ b/packages/kap-server/src/transport/serviceDispatcherRoutes.ts @@ -0,0 +1,131 @@ +import type { Scope } from '@moonshot-ai/agent-core-v2'; + +import { requestLog } from '../lib/requestLog'; +import { reservePromptId, type PromptIdReservation } from '../routes/prompts'; +import { okEnvelope } from '../protocol/envelope'; +import { ErrorCode } from '../protocol/error-codes'; +import type { ScopeKind } from './channel'; +import { + type ChannelDescriptor, + describeAllChannels, + resolveAnyScopedServiceId, +} from './channelRegistry'; +import { type ChannelLookup, dispatch } from './dispatcher'; +import { mapError, validationEnvelope, withTimeout } from './errors'; + +interface RpcRequest { + readonly id: string; + readonly method: string; + readonly body: unknown; + readonly query: unknown; + readonly params: unknown; + readonly headers: Record; +} + +interface RpcReply { + status(code: number): { send(payload: unknown): unknown }; + send(payload: unknown): unknown; +} + +export interface RouteHost { + get(path: string, handler: (req: RpcRequest, reply: RpcReply) => Promise): unknown; + post(path: string, handler: (req: RpcRequest, reply: RpcReply) => Promise): unknown; +} + +export interface ServiceDispatcherRouteOptions { + readonly callTimeoutMs?: number; + readonly lookup?: ChannelLookup; + readonly describe?: () => readonly ChannelDescriptor[]; +} + +export function registerServiceDispatcherRoutes( + app: RouteHost, + core: Scope, + basePath: string, + opts: ServiceDispatcherRouteOptions = {}, +): void { + const lookup = opts.lookup ?? ((name) => resolveAnyScopedServiceId(core, name)); + const scopeRoutes: { path: string; scopeKind: ScopeKind }[] = [ + { path: `${basePath}/:service/:method`, scopeKind: 'core' }, + { path: `${basePath}/session/:session_id/:service/:method`, scopeKind: 'session' }, + { + path: `${basePath}/session/:session_id/agent/:agent_id/:service/:method`, + scopeKind: 'agent', + }, + ]; + for (const { path, scopeKind } of scopeRoutes) { + const handler = makeHandler(core, scopeKind, opts, lookup); + app.get(path, handler); + app.post(path, handler); + } + + const describe = opts.describe ?? describeAllChannels; + app.get(`${basePath}/channels`, async (req, reply) => + reply.send(okEnvelope(describe(), req.id)), + ); +} + +function makeHandler( + core: Scope, + scopeKind: ScopeKind, + opts: ServiceDispatcherRouteOptions, + lookup: ChannelLookup, +): (req: RpcRequest, reply: RpcReply) => Promise { + return async (req, reply) => { + const requestId = req.id; + + const { service, method } = req.params as { service: string; method: string }; + + let arg: unknown; + try { + arg = req.method.toUpperCase() === 'GET' ? parseArgFromQuery(req.query) : req.body; + } catch { + return reply.send( + validationEnvelope([{ path: 'arg', message: 'invalid JSON in ?arg=' }], requestId), + ); + } + + let promptReservation: PromptIdReservation | undefined; + try { + promptReservation = + scopeKind === 'agent' && service === 'agentPromptService' && method === 'submit' + ? reservePromptId( + (req.params as Record)['session_id'] ?? '', + (arg as { promptId?: string } | undefined)?.promptId, + ) + : undefined; + const result = await withTimeout( + dispatch( + core, + scopeKind, + req.params as Record, + service, + method, + promptReservation === undefined ? arg : { ...(arg as object), promptId: promptReservation.id }, + lookup, + ), + opts.callTimeoutMs ?? 30_000, + ); + promptReservation?.submit(); + return reply.send(okEnvelope(result, requestId)); + } catch (error) { + promptReservation?.dispose(); + const envelope = mapError(error, requestId); + const log = requestLog(req); + if (envelope.code === ErrorCode.INTERNAL_ERROR) { + log?.error({ err: error, service, method }, 'rpc dispatch failed'); + } else { + log?.warn({ err: error, service, method }, 'rpc dispatch failed'); + } + return reply.send(envelope); + } + }; +} + +function parseArgFromQuery(query: unknown): unknown { + const q = query as Record | undefined; + const raw = q?.['arg']; + if (raw === undefined) return undefined; + if (typeof raw !== 'string') return undefined; + return JSON.parse(raw) as unknown; +} diff --git a/packages/kap-server/src/transport/ws/bearerProtocol.ts b/packages/kap-server/src/transport/ws/bearerProtocol.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad7c0b9246329aa914ba513dd3c3bf04a3a3f0ad --- /dev/null +++ b/packages/kap-server/src/transport/ws/bearerProtocol.ts @@ -0,0 +1,24 @@ +export const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; + +export function extractWsBearerToken(protocolHeader: string | undefined): string | null { + if (protocolHeader === undefined) { + return null; + } + for (const entry of protocolHeader.split(',')) { + const protocol = entry.trim(); + if (protocol.startsWith(WS_BEARER_PROTOCOL_PREFIX)) { + const token = protocol.slice(WS_BEARER_PROTOCOL_PREFIX.length); + return token.length === 0 ? null : token; + } + } + return null; +} + +export function selectWsBearerProtocol(protocols: Iterable): string | false { + for (const protocol of protocols) { + if (protocol.startsWith(WS_BEARER_PROTOCOL_PREFIX)) { + return protocol; + } + } + return false; +} diff --git a/packages/kap-server/src/transport/ws/connectionRegistry.ts b/packages/kap-server/src/transport/ws/connectionRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..6242b276d698c19632e1f9e515284bf2426e988b --- /dev/null +++ b/packages/kap-server/src/transport/ws/connectionRegistry.ts @@ -0,0 +1,53 @@ +export interface ConnectionLike { + readonly id: string; + readonly connectedAt: string; + readonly remoteAddress: string | null; + readonly userAgent: string | null; + readonly hasClientHello: boolean; + readonly subscriptionSessionIds: readonly string[]; + close(code?: number, reason?: string): void; +} + +export interface IConnectionRegistry { + add(conn: ConnectionLike): void; + remove(connId: string): void; + get(connId: string): ConnectionLike | undefined; + values(): Iterable; + closeAll(reason?: string): void; + size(): number; +} + +export class ConnectionRegistry implements IConnectionRegistry { + private readonly conns = new Map(); + + add(conn: ConnectionLike): void { + this.conns.set(conn.id, conn); + } + + remove(connId: string): void { + this.conns.delete(connId); + } + + get(connId: string): ConnectionLike | undefined { + return this.conns.get(connId); + } + + values(): Iterable { + return this.conns.values(); + } + + closeAll(reason?: string): void { + const snapshot = Array.from(this.conns.values()); + this.conns.clear(); + for (const conn of snapshot) { + try { + conn.close(1001, reason); + } catch { + } + } + } + + size(): number { + return this.conns.size; + } +} diff --git a/packages/kap-server/src/transport/ws/debug/registerWsDebug.ts b/packages/kap-server/src/transport/ws/debug/registerWsDebug.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb1258620effe7f8a77b8d570007b5df42046cb7 --- /dev/null +++ b/packages/kap-server/src/transport/ws/debug/registerWsDebug.ts @@ -0,0 +1,39 @@ +import type { XstateInspectionCollector } from '@moonshot-ai/agent-core-v2/human/xstateInspection'; +import { WebSocketServer } from 'ws'; + +import { selectWsBearerProtocol } from '../bearerProtocol'; +import { WsConnectionDebug } from './wsConnectionDebug'; + +export const WS_DEBUG_PATH = '/api/v1/debug/ws'; + +export interface RegisterWsDebugOptions { + readonly collector?: XstateInspectionCollector; + readonly heartbeatIntervalMs?: number; + readonly flushIntervalMs?: number; + readonly highWaterMarkBytes?: number; +} + +export function registerWsDebug(opts: RegisterWsDebugOptions = {}): WebSocketServer { + const wss = new WebSocketServer({ noServer: true, handleProtocols: selectWsBearerProtocol }); + const connections = new Set(); + + wss.on('connection', (socket) => { + const conn = new WsConnectionDebug({ + socket, + collector: opts.collector, + heartbeatIntervalMs: opts.heartbeatIntervalMs, + flushIntervalMs: opts.flushIntervalMs, + highWaterMarkBytes: opts.highWaterMarkBytes, + }); + connections.add(conn); + socket.on('close', () => { + connections.delete(conn); + }); + }); + + wss.on('close', () => { + for (const conn of connections) conn.close(); + }); + + return wss; +} diff --git a/packages/kap-server/src/transport/ws/debug/wsConnectionDebug.ts b/packages/kap-server/src/transport/ws/debug/wsConnectionDebug.ts new file mode 100644 index 0000000000000000000000000000000000000000..77f6e68248c33c92160e199046cfcfdfe84b87a8 --- /dev/null +++ b/packages/kap-server/src/transport/ws/debug/wsConnectionDebug.ts @@ -0,0 +1,114 @@ +import { + xstateInspectionCollector, + type XstateInspectionCollector, + type XstateInspectionEnvelope, +} from '@moonshot-ai/agent-core-v2/human/xstateInspection'; +import type { WebSocket } from 'ws'; + +const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000; +const HEARTBEAT_MISS_LIMIT = 2; +const DEFAULT_FLUSH_INTERVAL_MS = 16; +const DEFAULT_HIGH_WATER_MARK_BYTES = 1 << 20; + +export interface WsConnectionDebugOptions { + readonly socket: WebSocket; + readonly collector?: XstateInspectionCollector; + readonly heartbeatIntervalMs?: number; + readonly flushIntervalMs?: number; + readonly highWaterMarkBytes?: number; +} + +export class WsConnectionDebug { + private readonly socket: WebSocket; + private readonly heartbeatIntervalMs: number; + private readonly flushIntervalMs: number; + private readonly highWaterMarkBytes: number; + private readonly unsubscribe: () => void; + + private closed = false; + private outbound: XstateInspectionEnvelope[] = []; + private flushTimer?: ReturnType; + private heartbeatTimer?: ReturnType; + private lastPongAt = Date.now(); + + constructor(opts: WsConnectionDebugOptions) { + this.socket = opts.socket; + this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; + this.flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; + this.highWaterMarkBytes = opts.highWaterMarkBytes ?? DEFAULT_HIGH_WATER_MARK_BYTES; + + this.socket.on('close', () => this.onClose()); + this.socket.on('error', () => this.onClose()); + this.socket.on('pong', () => { + this.lastPongAt = Date.now(); + }); + + const collector = opts.collector ?? xstateInspectionCollector; + this.unsubscribe = collector.subscribe((envelope) => this.onEnvelope(envelope)); + + this.heartbeatTimer = setInterval(() => this.onHeartbeat(), this.heartbeatIntervalMs); + this.heartbeatTimer.unref?.(); + } + + private onEnvelope(envelope: XstateInspectionEnvelope): void { + if (this.closed) return; + if (this.socket.bufferedAmount > this.highWaterMarkBytes) return; + this.outbound.push(envelope); + this.scheduleFlush(); + } + + private scheduleFlush(): void { + if (this.flushTimer !== undefined) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined; + this.flush(); + }, this.flushIntervalMs); + this.flushTimer.unref?.(); + } + + private flush(): void { + if (this.outbound.length === 0) return; + if (this.closed || this.socket.readyState !== this.socket.OPEN) { + this.outbound = []; + return; + } + const envelopes = this.outbound; + this.outbound = []; + for (const envelope of envelopes) { + if (this.closed || this.socket.readyState !== this.socket.OPEN) return; + try { + this.socket.send(JSON.stringify(envelope)); + } catch { + } + } + } + + private onHeartbeat(): void { + if (Date.now() - this.lastPongAt >= this.heartbeatIntervalMs * HEARTBEAT_MISS_LIMIT) { + this.close(); + return; + } + try { + this.socket.ping(); + } catch { + } + } + + close(): void { + if (this.closed) return; + try { + this.socket.close(1000); + } catch { + } + this.onClose(); + } + + private onClose(): void { + if (this.closed) return; + this.closed = true; + if (this.flushTimer !== undefined) clearTimeout(this.flushTimer); + if (this.heartbeatTimer !== undefined) clearInterval(this.heartbeatTimer); + this.outbound = []; + this.unsubscribe(); + } +} diff --git a/packages/kap-server/src/transport/ws/v1/events.ts b/packages/kap-server/src/transport/ws/v1/events.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d03735a0b6796feb6752b96707d00a7718a515b --- /dev/null +++ b/packages/kap-server/src/transport/ws/v1/events.ts @@ -0,0 +1,263 @@ +import type { z } from 'zod'; + +import type { agentEventSchema } from '../../../protocol/events-zod'; +import type { MessageContent } from '../../../protocol/message'; +import type { PermissionMode } from '@moonshot-ai/agent-core-v2/agent/permissionPolicy/types'; +import type { UsageStatus } from '@moonshot-ai/agent-core-v2/agent/usage/usage'; +import type { AgentPhase } from '../../../services/legacyStatus/legacyStatus'; +import type { ConfigResponse } from '../../../protocol/rest-config'; +import type { Session, SessionPendingInteraction } from '../../../protocol/session'; +import type { Workspace } from '../../../protocol/workspace'; + +export interface AgentStatusUpdatedEvent { + readonly type: 'agent.status.updated'; + readonly model?: string; + readonly thinkingEffort?: string; + readonly contextTokens?: number; + readonly maxContextTokens?: number; + readonly contextUsage?: number; + readonly planMode?: boolean; + readonly swarmMode?: boolean; + readonly towerMode?: boolean; + readonly permission?: PermissionMode; + readonly usage?: UsageStatus; + readonly phase?: AgentPhase; +} + +export interface AgentCreatedEvent { + readonly type: 'agent.created'; +} + +export interface AgentDisposedEvent { + readonly type: 'agent.disposed'; +} + +export interface SessionMetaUpdatedEvent { + readonly type: 'session.meta.updated'; + readonly title?: string; + readonly patch?: Record; +} + +export interface SessionCreatedEvent { + readonly type: 'event.session.created'; + readonly session: Session; +} + +export interface SessionArchivedEvent { + readonly type: 'event.session.archived'; + readonly workspace_id: string; +} + +export interface SessionDeletedEvent { + readonly type: 'event.session.deleted'; + readonly workspace_id: string; +} + +export interface WorkspaceCreatedEvent { + readonly type: 'event.workspace.created'; + readonly workspace: Workspace; +} + +export interface WorkspaceUpdatedEvent { + readonly type: 'event.workspace.updated'; + readonly workspace: Workspace; +} + +export interface WorkspaceDeletedEvent { + readonly type: 'event.workspace.deleted'; + readonly workspace_id: string; + readonly root: string; +} + +export interface SessionWorkChangedEvent { + readonly type: 'event.session.work_changed'; + readonly busy: boolean; + readonly main_turn_active?: boolean; + readonly pending_interaction?: SessionPendingInteraction; + readonly last_turn_reason?: 'completed' | 'cancelled' | 'failed'; +} + +type LegacySessionStatus = + | 'idle' + | 'running' + | 'awaiting_approval' + | 'awaiting_question' + | 'aborted'; + +export interface SessionStatusChangedEvent { + readonly type: 'event.session.status_changed'; + readonly status: LegacySessionStatus; + readonly previous_status: LegacySessionStatus; + readonly current_prompt_id?: string; +} + +export interface ConfigChangedEvent { + readonly type: 'event.config.changed'; + readonly changedFields: string[]; + readonly config: ConfigResponse; +} + +export interface ConfigWarningItem { + readonly domain?: string; + readonly message: string; +} + +export interface ConfigWarningEvent { + readonly type: 'event.config.warning'; + readonly warnings: readonly ConfigWarningItem[]; +} + +export interface ModelCatalogRefreshChange { + readonly provider_id: string; + readonly provider_name: string; + readonly added: number; + readonly removed: number; +} + +export interface ModelCatalogRefreshFailure { + readonly provider: string; + readonly reason: string; +} + +export interface ModelCatalogChangedEvent { + readonly type: 'event.model_catalog.changed'; + readonly changed: readonly ModelCatalogRefreshChange[]; + readonly unchanged: readonly string[]; + readonly failed: readonly ModelCatalogRefreshFailure[]; +} + +export interface PluginChangedEvent { + readonly type: 'event.plugin.changed'; +} + +export interface CapabilityChangedEvent { + readonly type: 'event.capability.changed'; + readonly capability_id: string; + readonly install: { + readonly running: boolean; + readonly step?: string; + readonly percent?: number; + readonly error?: string; + readonly note?: string; + }; +} + +export interface DiUnitChangedEvent { + readonly type: 'event.di.unit_changed'; + readonly scope: string; + readonly token: string; + readonly state: 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed'; + readonly error?: string; +} + +export interface PromptSubmittedEvent { + readonly type: 'prompt.submitted'; + readonly promptId: string; + readonly userMessageId: string; + readonly status: 'running' | 'queued' | 'blocked'; + readonly content: readonly MessageContent[]; + readonly createdAt: string; +} + +export type TaskLifecycleStatus = + | 'running' + | 'completed' + | 'failed' + | 'timed_out' + | 'killed' + | 'lost'; + +export interface TaskInfoBase { + readonly taskId: string; + readonly description: string; + readonly status: TaskLifecycleStatus; + readonly detached?: boolean; + readonly startedAt: number; + readonly endedAt: number | null; + readonly stopReason?: string; + readonly terminalNotificationSuppressed?: boolean; + readonly timeoutMs?: number; +} + +export interface ProcessTaskInfo extends TaskInfoBase { + readonly kind: 'process'; + readonly command: string; + readonly pid: number; + readonly exitCode: number | null; +} + +export interface AgentTaskInfo extends TaskInfoBase { + readonly kind: 'agent'; + readonly agentId?: string; + readonly subagentType?: string; +} + +export interface QuestionTaskInfo extends TaskInfoBase { + readonly kind: 'question'; + readonly questionCount: number; + readonly toolCallId?: string; +} + +export type TaskInfo = + | ProcessTaskInfo + | AgentTaskInfo + | QuestionTaskInfo; + +export interface BackgroundTaskStartedEvent { + readonly type: 'background.task.started'; + readonly info: TaskInfo; +} + +export interface BackgroundTaskTerminatedEvent { + readonly type: 'background.task.terminated'; + readonly info: TaskInfo; +} + +type CoreStreamEvent = z.infer; + +export type AgentEvent = + | CoreStreamEvent + | AgentStatusUpdatedEvent + | AgentCreatedEvent + | AgentDisposedEvent + | SessionMetaUpdatedEvent + | SessionCreatedEvent + | SessionArchivedEvent + | SessionDeletedEvent + | WorkspaceCreatedEvent + | WorkspaceUpdatedEvent + | WorkspaceDeletedEvent + | SessionWorkChangedEvent + | SessionStatusChangedEvent + | ConfigChangedEvent + | ConfigWarningEvent + | ModelCatalogChangedEvent + | PluginChangedEvent + | CapabilityChangedEvent + | DiUnitChangedEvent + | PromptSubmittedEvent + | BackgroundTaskStartedEvent + | BackgroundTaskTerminatedEvent; + +export type Event = AgentEvent & { agentId: string; sessionId: string; readonly time?: number }; + +export const VOLATILE_EVENT_TYPES = [ + 'assistant.delta', + 'thinking.delta', + 'tool.call.delta', + 'tool.progress', + 'shell.output', + 'shell.started', + 'shell.completed', + 'agent.status.updated', + 'event.di.unit_changed', + 'event.capability.changed', +] as const; + +export type VolatileEventType = (typeof VOLATILE_EVENT_TYPES)[number]; + +const volatileEventTypeSet: ReadonlySet = new Set(VOLATILE_EVENT_TYPES); + +export function isVolatileEventType(type: string): type is VolatileEventType { + return volatileEventTypeSet.has(type); +} diff --git a/packages/kap-server/src/transport/ws/v1/inFlightTurnTracker.ts b/packages/kap-server/src/transport/ws/v1/inFlightTurnTracker.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f268ce9fd9fa5eeb6ee3c97a51e0de5351d3a11 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v1/inFlightTurnTracker.ts @@ -0,0 +1,127 @@ +import type { Event } from './events'; +import type { InFlightToolCall, InFlightTurn } from '../../../protocol/rest-snapshot'; + +const MAIN_AGENT_ID = 'main'; + +interface ToolAccum { + tool_call_id: string; + name: string; + args?: unknown; + description?: string; + display?: unknown; + last_progress?: { + kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom'; + text?: string; + percent?: number; + }; +} + +interface TurnAccum { + turnId: number; + assistantText: string; + thinkingText: string; + tools: Map; +} + +export interface VolatileAnnotation { + offset?: number; +} + +export class InFlightTurnTracker { + private readonly bySession = new Map(); + + apply(sessionId: string, event: Event): VolatileAnnotation { + if (event.agentId !== MAIN_AGENT_ID) return {}; + + switch (event.type) { + case 'turn.started': { + this.bySession.set(sessionId, { + turnId: event.turnId, + assistantText: '', + thinkingText: '', + tools: new Map(), + }); + return {}; + } + case 'turn.ended': { + this.bySession.delete(sessionId); + return {}; + } + case 'turn.step.started': { + const turn = this.bySession.get(sessionId); + if (!turn || turn.turnId !== event.turnId) return {}; + turn.assistantText = ''; + turn.thinkingText = ''; + return {}; + } + case 'assistant.delta': { + const turn = this.bySession.get(sessionId); + if (!turn || turn.turnId !== event.turnId) return {}; + const offset = turn.assistantText.length; + turn.assistantText += event.delta; + return { offset }; + } + case 'thinking.delta': { + const turn = this.bySession.get(sessionId); + if (!turn || turn.turnId !== event.turnId) return {}; + const offset = turn.thinkingText.length; + turn.thinkingText += event.delta; + return { offset }; + } + case 'tool.call.started': { + const turn = this.bySession.get(sessionId); + if (!turn || turn.turnId !== event.turnId) return {}; + turn.tools.set(event.toolCallId, { + tool_call_id: event.toolCallId, + name: event.name, + args: event.args, + ...(event.description !== undefined ? { description: event.description } : {}), + ...(event.display !== undefined ? { display: event.display } : {}), + }); + return {}; + } + case 'tool.progress': { + const turn = this.bySession.get(sessionId); + const tool = turn?.tools.get(event.toolCallId); + if (!tool) return {}; + const { kind, text, percent } = event.update; + if (kind === 'custom') return {}; + tool.last_progress = { + kind, + ...(text !== undefined ? { text } : {}), + ...(percent !== undefined ? { percent } : {}), + }; + return {}; + } + case 'tool.result': { + this.bySession.get(sessionId)?.tools.delete(event.toolCallId); + return {}; + } + default: + return {}; + } + } + + get(sessionId: string): InFlightTurn | null { + const turn = this.bySession.get(sessionId); + if (!turn) return null; + const running_tools: InFlightToolCall[] = Array.from(turn.tools.values()).map((t) => ({ + tool_call_id: t.tool_call_id, + name: t.name, + ...(t.args !== undefined ? { args: t.args } : {}), + ...(t.description !== undefined ? { description: t.description } : {}), + ...(t.display !== undefined ? { display: t.display } : {}), + ...(t.last_progress !== undefined ? { last_progress: t.last_progress } : {}), + })); + return { + turn_id: turn.turnId, + assistant_text: turn.assistantText, + thinking_text: turn.thinkingText, + running_tools, + }; + } + + clear(sessionId: string): void { + this.bySession.delete(sessionId); + } +} diff --git a/packages/kap-server/src/transport/ws/v1/protocol.ts b/packages/kap-server/src/transport/ws/v1/protocol.ts new file mode 100644 index 0000000000000000000000000000000000000000..3af8dd6fb9237a52ce39d96a1aed357194a908ac --- /dev/null +++ b/packages/kap-server/src/transport/ws/v1/protocol.ts @@ -0,0 +1,73 @@ +export interface ServerHelloPayload { + ws_connection_id: string; + protocol_version: number; + heartbeat_ms: number; + max_event_buffer_size: number; + capabilities: { + event_batching: boolean; + compression: boolean; + }; +} + +export interface ServerHelloFrame { + type: 'server_hello'; + timestamp: string; + payload: ServerHelloPayload; +} + +export function buildServerHello(payload: ServerHelloPayload): ServerHelloFrame { + return { type: 'server_hello', timestamp: new Date().toISOString(), payload }; +} + +export interface PingFrame { + type: 'ping'; + timestamp: string; + payload: { nonce: string }; +} + +export function buildPing(nonce: string): PingFrame { + return { type: 'ping', timestamp: new Date().toISOString(), payload: { nonce } }; +} + +export interface AckFrame

{ + type: 'ack'; + id: string; + code: number; + msg: string; + payload: P; +} + +export function buildAck

(id: string, code: number, msg: string, payload: P): AckFrame

{ + return { type: 'ack', id, code, msg, payload }; +} + +export type ResyncReason = 'buffer_overflow' | 'session_recreated' | 'epoch_changed'; + +export interface ResyncRequiredFrame { + type: 'resync_required'; + timestamp: string; + payload: { + session_id: string; + reason: ResyncReason; + current_seq: number; + epoch?: string; + }; +} + +export function buildResyncRequired( + sessionId: string, + reason: ResyncReason, + currentSeq: number, + epoch?: string, +): ResyncRequiredFrame { + return { + type: 'resync_required', + timestamp: new Date().toISOString(), + payload: { + session_id: sessionId, + reason, + current_seq: currentSeq, + ...(epoch !== undefined ? { epoch } : {}), + }, + }; +} diff --git a/packages/kap-server/src/transport/ws/v1/registerWsV1.ts b/packages/kap-server/src/transport/ws/v1/registerWsV1.ts new file mode 100644 index 0000000000000000000000000000000000000000..21e3729a2e79224d5a40ee5de0f08e6a14ccc0e1 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v1/registerWsV1.ts @@ -0,0 +1,49 @@ +import type { Scope } from '@moonshot-ai/agent-core-v2'; +import { WebSocketServer } from 'ws'; + +import type { CredentialValidator } from '../../../services/auth/credentials'; +import { type IConnectionRegistry } from '../connectionRegistry'; +import type { SessionEventBroadcaster } from './sessionEventBroadcaster'; +import type { JournalLogger } from './sessionEventJournal'; +import { WsConnectionV1 } from './wsConnectionV1'; +import { selectWsBearerProtocol } from '../bearerProtocol'; + +export const WS_PATH = '/api/v1/ws'; + +export interface RegisterWsV1Options { + readonly validateCredential?: CredentialValidator; + readonly registry: IConnectionRegistry; + readonly broadcaster: SessionEventBroadcaster; + readonly logger?: JournalLogger; + readonly maxBufferSize?: number; + readonly flushIntervalMs?: number; + readonly maxBatchSize?: number; + readonly highWaterMarkBytes?: number; + readonly heartbeatIntervalMs?: number; +} + +export function registerWsV1(core: Scope, opts: RegisterWsV1Options): WebSocketServer { + void core; + const wss = new WebSocketServer({ noServer: true, handleProtocols: selectWsBearerProtocol }); + const { registry, broadcaster } = opts; + + wss.on('connection', (socket, req) => { + const conn = new WsConnectionV1({ + socket, + broadcaster, + connectionRegistry: registry, + validateCredential: opts.validateCredential, + remoteAddress: req.socket.remoteAddress ?? null, + userAgent: req.headers['user-agent'] ?? null, + logger: opts.logger, + maxBufferSize: opts.maxBufferSize, + flushIntervalMs: opts.flushIntervalMs, + maxBatchSize: opts.maxBatchSize, + highWaterMarkBytes: opts.highWaterMarkBytes, + heartbeatIntervalMs: opts.heartbeatIntervalMs, + }); + socket.on('close', () => registry.remove(conn.id)); + }); + + return wss; +} diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts new file mode 100644 index 0000000000000000000000000000000000000000..324fdf5bddb73ee0a55f7dc756d5652e48cc4d6b --- /dev/null +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -0,0 +1,1560 @@ +import { rm } from 'node:fs/promises'; + +import type { + ApprovalResponse, + Event2, + IAgentScopeHandle, + IDisposable, + Interaction, + InteractionKind, + ISessionScopeHandle, + Scope, + SessionActivityState, + Workspace, +} from '@moonshot-ai/agent-core-v2'; +import { + IAgentLifecycleService, + IAgentLoopService, + IEventBus, + IEventService, + INTERACTION_TAG_AGENT_ID, + INTERACTION_TAG_SESSION_ID, + ISessionActivityView, + ISessionIndex, + ISessionManager, + MAIN_AGENT_ID, + getLiveSessionById, + interactions, + toDisposable, +} from '@moonshot-ai/agent-core-v2'; +import type { + ConfigWarningItem, + DiUnitChangedEvent, + ModelCatalogRefreshChange, + ModelCatalogRefreshFailure, + SessionCreatedEvent, + SessionMetaUpdatedEvent, + Event, +} from './events'; +import { isVolatileEventType } from './events'; +import type { SessionCursor } from '../../../protocol/ws-control'; +import { + configChangedEventSchema, + modelCatalogChangedEventSchema, +} from '../../../protocol/events-zod'; +import type { InFlightTurn, SnapshotSubagent } from '../../../protocol/rest-snapshot'; +import { + detachGrades, + filterOpsForGrade, + gradeFor, + needsResetOnTransition, + redactSnapshotForGrade, + type AgentTranscript, + type TranscriptGrade, + type TranscriptGradeSpec, + type TranscriptOperation, + type TranscriptOpsEvent, + type TranscriptResetEvent, + type TranscriptStore, +} from '@moonshot-ai/transcript'; + +import { toWireApproval } from '../../../routes/approvals'; +import { toWireQuestion } from '../../../protocol/question-wire'; +import { toWireWorkspace } from '../../../routes/workspaces'; +import { projectPromptContentParts } from '../../../services/messages/messageProjection'; +import { readLegacyStatus } from '../../../services/legacyStatus/legacyStatus'; +import { + legacyApprovalsOf, + LegacyActivityTracker, + phaseFromDomainEvent, +} from '../../../services/legacyStatus/legacyActivity'; +import type { TranscriptService } from '../../../services/transcript/transcriptService'; +import { InFlightTurnTracker } from './inFlightTurnTracker'; +import { SubagentRosterTracker } from './subagentRosterTracker'; +import { + type EventEnvelope, + type JournalLogger, + SessionEventJournal, + sessionJournalPath, +} from './sessionEventJournal'; + +export type ResyncReason = 'buffer_overflow' | 'session_recreated' | 'epoch_changed'; + +export interface BufferedSinceResult { + events: Array<{ seq: number; envelope: EventEnvelope }>; + resyncRequired: ResyncReason | false; + currentSeq: number; + epoch: string; +} + +export interface SessionSnapshotState { + seq: number; + epoch: string; + inFlightTurn: InFlightTurn | null; + subagents: SnapshotSubagent[]; +} + +export type BroadcastDelivery = 'subscription' | 'immediate'; + +export interface BroadcastTarget { + send(envelope: EventEnvelope, delivery?: BroadcastDelivery): void; +} + +export type AgentFilter = ReadonlySet | undefined; + +export interface TargetSubscription { + readonly agentFilter?: AgentFilter; + readonly transcriptGrades?: TranscriptGradeSpec; +} + +interface TranscriptStream { + readonly store: TranscriptStore; + readonly knownAgents: Set; +} + +interface SessionState { + readonly sessionId: string; + readonly journal: SessionEventJournal; + readonly tracker: InFlightTurnTracker; + readonly roster: SubagentRosterTracker; + deferredWork?: SessionActivityState; + readonly tail: Array<{ seq: number; envelope: EventEnvelope }>; + readonly targets: Map; + queue: Promise; + readonly agentDisposables: Map; + readonly lifecycleDisposables: IDisposable[]; + readonly knownInteractions: Map; + transcriptStream?: TranscriptStream; + readonly transcriptSeeded: Set; + readonly deferredTranscriptSeeds: Map< + BroadcastTarget, + { readonly spec: TranscriptGradeSpec; readonly transcriptSince?: Record } + >; +} + +export const DEFAULT_MAX_BUFFER_SIZE = 1000; +const GLOBAL_SESSION_ID = '__global__'; +const TRANSCRIPT_RESET_TAIL_TURNS = 0; + +async function disposeSessionState(state: SessionState): Promise { + for (const d of state.lifecycleDisposables) d.dispose(); + for (const d of state.agentDisposables.values()) d.dispose(); + await state.journal.close(); +} + +export class SessionEventBroadcaster { + private readonly sessions = new Map(); + private readonly globalTargets = new Set(); + private readonly diEventTargets = new Set(); + private readonly pendingStates = new Map>(); + private readonly activityTrackers = new Map(); + private readonly maxBufferSize: number; + private readonly coreEventSubscription: IDisposable; + private readonly deletionSubscription: IDisposable | undefined; + private closed = false; + + constructor( + private readonly opts: { + readonly eventsDir: string; + readonly core: Scope; + readonly logger?: JournalLogger; + readonly maxBufferSize?: number; + readonly transcriptService?: TranscriptService; + }, + ) { + this.maxBufferSize = opts.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE; + this.deletionSubscription = opts.core.accessor.get(ISessionManager).onWillDeleteSession?.( + (event) => { + event.waitUntil(this.purgeSession(event.sessionId)); + }, + ); + this.coreEventSubscription = opts.core.accessor + .get(IEventService) + .subscribe((event) => this.onCoreEvent(event)); + } + + addGlobalTarget(target: BroadcastTarget): void { + this.globalTargets.add(target); + } + + removeGlobalTarget(target: BroadcastTarget): void { + this.globalTargets.delete(target); + this.diEventTargets.delete(target); + } + + addDiEventTarget(target: BroadcastTarget): void { + this.diEventTargets.add(target); + } + + async subscribe( + sessionId: string, + target: BroadcastTarget, + filter?: AgentFilter, + transcriptGrades?: TranscriptGradeSpec, + opts?: { deferTranscriptReset?: boolean; transcriptSince?: Record }, + ): Promise { + const state = await this.ensureState(sessionId); + if (state === undefined) return false; + const prev = state.targets.get(target); + state.targets.set(target, { agentFilter: filter, transcriptGrades }); + if (transcriptGrades !== undefined) { + if (opts?.deferTranscriptReset === true) { + state.transcriptSeeded.delete(target); + state.deferredTranscriptSeeds.set(target, { + spec: transcriptGrades, + transcriptSince: opts.transcriptSince, + }); + } else { + state.deferredTranscriptSeeds.delete(target); + const gated = this.willSendTranscriptReset(state, transcriptGrades, prev); + if (gated) state.transcriptSeeded.delete(target); + await this.subscribeTranscript( + state, + target, + transcriptGrades, + prev?.transcriptGrades, + opts?.transcriptSince, + ); + if (state.targets.has(target)) state.transcriptSeeded.add(target); + } + } + return true; + } + + private willSendTranscriptReset( + state: SessionState, + spec: TranscriptGradeSpec, + prev: TargetSubscription | undefined, + ): boolean { + const service = this.opts.transcriptService; + if (service === undefined) return false; + const store = service.forSessionLive(state.sessionId); + if (store === undefined) return false; + for (const descriptor of store.agents()) { + const grade = gradeFor(spec, descriptor.agentId); + if (grade === 'off') continue; + if (needsResetOnTransition(gradeFor(prev?.transcriptGrades, descriptor.agentId), grade)) { + return true; + } + } + return false; + } + + async flushTranscriptSeed(sessionId: string, target: BroadcastTarget): Promise { + const state = this.sessions.get(sessionId); + if (state === undefined) return; + const deferred = state.deferredTranscriptSeeds.get(target); + if (deferred === undefined) return; + state.deferredTranscriptSeeds.delete(target); + await this.subscribeTranscript(state, target, deferred.spec, undefined, deferred.transcriptSince); + if (state.targets.has(target)) state.transcriptSeeded.add(target); + } + + unsubscribe(sessionId: string, target: BroadcastTarget): void { + const state = this.sessions.get(sessionId); + if (state === undefined) return; + state.targets.delete(target); + state.transcriptSeeded.delete(target); + state.deferredTranscriptSeeds.delete(target); + } + + unsubscribeTranscript( + sessionId: string, + target: BroadcastTarget, + agentIds?: readonly string[], + ): void { + const state = this.sessions.get(sessionId); + if (state === undefined) return; + const sub = state.targets.get(target); + if (sub === undefined) return; + const next = + agentIds === undefined ? undefined : detachGrades(sub.transcriptGrades, agentIds); + if (next === undefined) { + state.targets.set(target, { agentFilter: sub.agentFilter, transcriptGrades: undefined }); + state.transcriptSeeded.delete(target); + state.deferredTranscriptSeeds.delete(target); + } else { + state.targets.set(target, { agentFilter: sub.agentFilter, transcriptGrades: next }); + } + } + + private async subscribeTranscript( + state: SessionState, + target: BroadcastTarget, + spec: TranscriptGradeSpec, + prev: TranscriptGradeSpec | undefined, + transcriptSince?: Record, + ): Promise { + const service = this.opts.transcriptService; + if (service === undefined) return; + const store = service.forSessionLive(state.sessionId); + if (store === undefined) return; + await service.whenReady(state.sessionId); + const backfill = new Set( + Object.keys(spec).filter((agentId) => agentId !== '*' && gradeFor(spec, agentId) !== 'off'), + ); + for (const descriptor of store.agents()) { + if (gradeFor(spec, descriptor.agentId) !== 'off') backfill.add(descriptor.agentId); + } + await Promise.all( + [...backfill].map((agentId) => service.ensureAgentHistory(state.sessionId, agentId)), + ); + const current = state.targets.get(target); + if (current?.transcriptGrades === undefined) return; + const currentSpec = current.transcriptGrades; + this.ensureTranscriptStream(state, store); + for (const descriptor of store.agents()) { + const grade = gradeFor(currentSpec, descriptor.agentId); + if (grade === 'off') continue; + const transcript = store.getAgent(descriptor.agentId); + if (transcript === undefined) continue; + const since = transcriptSince?.[descriptor.agentId] ?? transcriptSince?.['*']; + if (since !== undefined) { + const catchup = service.getOpsSince(state.sessionId, descriptor.agentId, since); + if (catchup !== undefined && catchup.complete) { + this.replayTranscriptOps(state, target, descriptor.agentId, grade, catchup.batches); + continue; + } + } + if (!needsResetOnTransition(gradeFor(prev, descriptor.agentId), grade)) { + continue; + } + this.sendTranscriptReset(state, target, transcript, grade); + } + } + + private replayTranscriptOps( + state: SessionState, + target: BroadcastTarget, + agentId: string, + grade: TranscriptGrade, + batches: readonly { seq: number; ops: readonly TranscriptOperation[] }[], + ): void { + for (const batch of batches) { + const filtered = filterOpsForGrade(grade, batch.ops); + if (filtered.length === 0) continue; + try { + target.send( + this.buildTranscriptEnvelope(state, 'transcript.ops', { + agent_id: agentId, + ops: filtered, + seq: batch.seq, + }), + ); + } catch { + } + } + } + + private ensureTranscriptStream(state: SessionState, store: TranscriptStore): void { + if (state.transcriptStream?.store === store) return; + const service = this.opts.transcriptService; + if (service === undefined) return; + const stream: TranscriptStream = { + store, + knownAgents: new Set(store.agents().map((d) => d.agentId)), + }; + state.transcriptStream = stream; + + const opsDisposable = service.onSessionOps(state.sessionId, ({ agentId, ops }, seq) => { + for (const [target, sub] of state.targets) { + if (!state.transcriptSeeded.has(target)) continue; + const grade = gradeFor(sub.transcriptGrades, agentId); + const filtered = filterOpsForGrade(grade, ops); + if (filtered.length === 0) continue; + try { + target.send( + this.buildTranscriptEnvelope(state, 'transcript.ops', { + agent_id: agentId, + ops: filtered, + seq, + }), + ); + } catch { + } + } + }); + if (opsDisposable !== undefined) state.lifecycleDisposables.push(opsDisposable); + + state.lifecycleDisposables.push( + store.onRosterChange((agents) => { + for (const descriptor of agents) { + if (stream.knownAgents.has(descriptor.agentId)) continue; + stream.knownAgents.add(descriptor.agentId); + const transcript = store.getAgent(descriptor.agentId); + if (transcript === undefined) continue; + for (const [target, sub] of state.targets) { + if (!state.transcriptSeeded.has(target)) continue; + const grade = gradeFor(sub.transcriptGrades, descriptor.agentId); + if (grade === 'off') continue; + try { + this.sendTranscriptReset(state, target, transcript, grade); + } catch { + } + } + } + }), + ); + } + + private sendTranscriptReset( + state: SessionState, + target: BroadcastTarget, + transcript: AgentTranscript, + grade: TranscriptGrade, + ): void { + const snapshot = redactSnapshotForGrade( + grade, + transcript.snapshot({ tailTurns: TRANSCRIPT_RESET_TAIL_TURNS }), + ); + target.send( + this.buildTranscriptEnvelope(state, 'transcript.reset', { + agent_id: transcript.agentId, + snapshot, + has_more_older: snapshot.hasMoreOlder ?? false, + seq: this.opts.transcriptService?.getSeqWatermark(state.sessionId, transcript.agentId), + }), + ); + } + + private buildTranscriptEnvelope( + state: SessionState, + type: 'transcript.reset' | 'transcript.ops', + payload: Omit | Omit, + ): EventEnvelope { + return { + type, + seq: state.journal.seq, + epoch: state.journal.epoch, + volatile: true, + session_id: state.sessionId, + timestamp: new Date().toISOString(), + payload: { type, ...payload }, + }; + } + + async getBufferedSince( + sessionId: string, + cursor: SessionCursor, + filter?: AgentFilter, + transcriptGrades?: TranscriptGradeSpec, + ): Promise { + const state = await this.ensureState(sessionId); + if (state === undefined) { + return { events: [], resyncRequired: 'session_recreated', currentSeq: 0, epoch: '' }; + } + await state.queue; + const { journal, tail } = state; + const currentSeq = journal.seq; + const { epoch } = journal; + + if (cursor.epoch !== undefined && cursor.epoch !== epoch) { + return { events: [], resyncRequired: 'epoch_changed', currentSeq, epoch }; + } + if (cursor.seq > currentSeq) { + return { events: [], resyncRequired: 'epoch_changed', currentSeq, epoch }; + } + if (cursor.seq === currentSeq) { + return { events: [], resyncRequired: false, currentSeq, epoch }; + } + if (currentSeq - cursor.seq > this.maxBufferSize) { + return { events: [], resyncRequired: 'buffer_overflow', currentSeq, epoch }; + } + + const applyFilter = ( + entries: Array<{ seq: number; envelope: EventEnvelope }>, + ): Array<{ seq: number; envelope: EventEnvelope }> => + filter === undefined && transcriptGrades === undefined + ? entries + : entries.filter( + ({ envelope }) => + matchesAgentFilter(envelope, filter) && + !suppressedByTranscript(envelope, transcriptGrades), + ); + + const tailStart = tail[0]?.seq; + if (tailStart !== undefined && tailStart <= cursor.seq + 1) { + const events = applyFilter(tail.filter((e) => e.seq > cursor.seq)); + return { events, resyncRequired: false, currentSeq, epoch }; + } + const fromDisk = await journal.readSince(cursor.seq, this.maxBufferSize); + return { events: applyFilter(fromDisk), resyncRequired: false, currentSeq, epoch }; + } + + async getCursor(sessionId: string): Promise<{ seq: number; epoch: string }> { + const state = await this.ensureState(sessionId); + if (state === undefined) { + const cold = await this.readColdWatermark(sessionId); + return cold ?? { seq: 0, epoch: '' }; + } + await state.queue; + return { seq: state.journal.seq, epoch: state.journal.epoch }; + } + + async getSnapshotState(sessionId: string): Promise { + const state = await this.ensureState(sessionId); + if (state === undefined) { + const cold = await this.readColdWatermark(sessionId); + return cold !== undefined + ? { ...cold, inFlightTurn: null, subagents: [] } + : { seq: 0, epoch: '', inFlightTurn: null, subagents: [] }; + } + await state.queue; + return { + seq: state.journal.seq, + epoch: state.journal.epoch, + inFlightTurn: state.tracker.get(sessionId), + subagents: state.roster.get(sessionId), + }; + } + + private async readColdWatermark( + sessionId: string, + ): Promise<{ seq: number; epoch: string } | undefined> { + const summary = await this.opts.core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) return undefined; + const journal = await SessionEventJournal.open( + sessionJournalPath(this.opts.eventsDir, sessionId), + this.opts.logger, + ); + const watermark = { seq: journal.seq, epoch: journal.epoch }; + await journal.close(); + return watermark; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.coreEventSubscription.dispose(); + this.deletionSubscription?.dispose(); + await Promise.all( + [...this.pendingStates.values()].map((pending) => pending.catch(() => undefined)), + ); + for (const [sessionId, state] of this.sessions) { + await disposeSessionState(state); + this.dropActivityTrackers(sessionId); + this.opts.transcriptService?.dropSession(sessionId); + } + this.sessions.clear(); + } + + private dropActivityTrackers(sessionId: string): void { + for (const key of this.activityTrackers.keys()) { + if (key.startsWith(`${sessionId}:`)) this.activityTrackers.delete(key); + } + } + + private async purgeSession(sessionId: string): Promise { + await this.pendingStates.get(sessionId); + const state = this.sessions.get(sessionId); + if (state !== undefined) { + this.sessions.delete(sessionId); + state.targets.clear(); + await disposeSessionState(state); + this.dropActivityTrackers(sessionId); + } + this.opts.transcriptService?.dropSession(sessionId); + await rm(sessionJournalPath(this.opts.eventsDir, sessionId), { force: true }); + } + + private ensureState(sessionId: string): Promise { + if (this.closed) return Promise.resolve(undefined); + const existing = this.sessions.get(sessionId); + if (existing !== undefined) return Promise.resolve(existing); + let pending = this.pendingStates.get(sessionId); + if (pending === undefined) { + pending = this.createSessionState(sessionId).finally(() => { + if (this.pendingStates.get(sessionId) === pending) { + this.pendingStates.delete(sessionId); + } + }); + this.pendingStates.set(sessionId, pending); + } + return pending; + } + + private async createSessionState(sessionId: string): Promise { + if (this.closed) return undefined; + + const session = getLiveSessionById(this.opts.core.accessor, sessionId); + if (session === undefined) return undefined; + + const journal = await SessionEventJournal.open( + sessionJournalPath(this.opts.eventsDir, sessionId), + this.opts.logger, + ); + if (this.closed || getLiveSessionById(this.opts.core.accessor, sessionId) !== session) { + await journal.close(); + return undefined; + } + const state: SessionState = { + sessionId, + journal, + tracker: new InFlightTurnTracker(), + roster: new SubagentRosterTracker(), + tail: [], + targets: new Map(), + queue: Promise.resolve(), + agentDisposables: new Map(), + lifecycleDisposables: [], + knownInteractions: new Map(), + transcriptSeeded: new Set(), + deferredTranscriptSeeds: new Map(), + }; + this.sessions.set(sessionId, state); + try { + this.attachWorkView(session, state); + this.attachAgents(sessionId, session, state); + this.attachInteractions(sessionId, state); + } catch (error) { + this.sessions.delete(sessionId); + await disposeSessionState(state); + this.dropActivityTrackers(sessionId); + if (error instanceof Error && error.message === 'InstantiationService has been disposed') return undefined; + throw error; + } + return state; + } + + private ensureGlobalState(): Promise { + if (this.closed) return Promise.resolve(undefined); + const existing = this.sessions.get(GLOBAL_SESSION_ID); + if (existing !== undefined) return Promise.resolve(existing); + let pending = this.pendingStates.get(GLOBAL_SESSION_ID); + if (pending === undefined) { + pending = this.createGlobalState().finally(() => { + if (this.pendingStates.get(GLOBAL_SESSION_ID) === pending) { + this.pendingStates.delete(GLOBAL_SESSION_ID); + } + }); + this.pendingStates.set(GLOBAL_SESSION_ID, pending); + } + return pending; + } + + private async createGlobalState(): Promise { + const journal = await SessionEventJournal.open( + sessionJournalPath(this.opts.eventsDir, GLOBAL_SESSION_ID), + this.opts.logger, + ); + if (this.closed) { + await journal.close(); + return undefined; + } + const state: SessionState = { + sessionId: GLOBAL_SESSION_ID, + journal, + tracker: new InFlightTurnTracker(), + roster: new SubagentRosterTracker(), + tail: [], + targets: new Map(), + queue: Promise.resolve(), + agentDisposables: new Map(), + lifecycleDisposables: [], + knownInteractions: new Map(), + transcriptSeeded: new Set(), + deferredTranscriptSeeds: new Map(), + }; + this.sessions.set(GLOBAL_SESSION_ID, state); + return state; + } + + private onCoreEvent(event: Event2): void { + const corePayload = (event as { readonly payload?: unknown }).payload; + if (event.type === 'event.session.created') { + const payload = sessionCreatedPayload(corePayload); + if (payload === undefined) return; + void this.dispatchSessionEvent(payload.sessionId, { + type: 'event.session.created', + session: payload.session, + agentId: 'main', + sessionId: payload.sessionId, + } as Event).catch((error: unknown) => + this.logDispatchError(payload.sessionId, 'event.session.created', error), + ); + return; + } + if (event.type === 'event.session.archived') { + const payload = sessionArchivedPayload(corePayload); + if (payload === undefined) return; + void this.dispatchGlobal({ + type: 'event.session.archived', + workspace_id: payload.workspaceId, + agentId: 'main', + sessionId: payload.sessionId, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.session.archived', error), + ); + return; + } + if (event.type === 'event.session.deleted') { + const payload = sessionDeletedPayload(corePayload); + if (payload === undefined) return; + void this.dispatchGlobal({ + type: 'event.session.deleted', + workspace_id: payload.workspaceId, + agentId: 'main', + sessionId: payload.sessionId, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.session.deleted', error), + ); + return; + } + if (event.type === 'event.workspace.created' || event.type === 'event.workspace.updated') { + const workspace = workspaceLifecyclePayload(corePayload); + if (workspace === undefined) return; + const type = event.type; + void (async () => { + const wire = await toWireWorkspace(this.opts.core, workspace); + await this.dispatchGlobal({ + type, + workspace: wire, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event); + })().catch((error: unknown) => this.logDispatchError(GLOBAL_SESSION_ID, type, error)); + return; + } + if (event.type === 'event.workspace.deleted') { + const payload = workspaceDeletedPayload(corePayload); + if (payload === undefined) return; + void this.dispatchGlobal({ + type: 'event.workspace.deleted', + workspace_id: payload.workspaceId, + root: payload.root, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.workspace.deleted', error), + ); + return; + } + if (event.type === 'session.meta.updated') { + const payload = sessionMetaUpdatedPayload(corePayload); + if (payload === undefined) return; + const sessionId = sessionMetaUpdatedSessionId(corePayload); + if (sessionId === undefined) return; + void this.dispatchSessionEvent(sessionId, { + type: 'session.meta.updated', + ...payload, + agentId: 'main', + sessionId, + } as Event).catch((error: unknown) => + this.logDispatchError(sessionId, 'session.meta.updated', error), + ); + return; + } + if (event.type === 'event.plugin.changed') { + void this.dispatchGlobal({ + type: 'event.plugin.changed', + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.plugin.changed', error), + ); + return; + } + if (event.type === 'event.capability.changed') { + const payload = capabilityChangedPayload(corePayload); + if (payload === undefined) return; + void this.dispatchGlobal({ + type: 'event.capability.changed', + ...payload, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.capability.changed', error), + ); + return; + } + if (event.type === 'event.config.warning') { + const payload = configWarningPayload(corePayload); + if (payload === undefined) return; + void this.dispatchGlobal({ + type: 'event.config.warning', + warnings: payload.warnings, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.config.warning', error), + ); + return; + } + if (event.type === 'event.config.changed') { + const payload = configChangedPayload(corePayload); + if (payload === undefined) return; + void this.dispatchGlobal({ + type: 'event.config.changed', + changedFields: payload.changedFields, + config: payload.config, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.config.changed', error), + ); + return; + } + if (event.type === 'event.model_catalog.changed') { + const payload = modelCatalogChangedPayload(corePayload); + if (payload === undefined) return; + void this.dispatchGlobal({ + type: 'event.model_catalog.changed', + changed: payload.changed, + unchanged: payload.unchanged, + failed: payload.failed, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.model_catalog.changed', error), + ); + return; + } + if (event.type === 'event.di.unit_changed') { + const payload = diUnitChangedPayload(corePayload); + if (payload === undefined) return; + void this.dispatchGlobal({ + type: 'event.di.unit_changed', + ...payload, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.di.unit_changed', error), + ); + return; + } + } + + private async dispatchGlobal(event: Event): Promise { + const state = await this.ensureGlobalState(); + if (state === undefined) return; + state.queue = state.queue + .then(() => this.dispatch(state, event, isVolatileEventType(event.type))) + .catch((error: unknown) => this.logDispatchDropped(state.sessionId, event.type, error)); + } + + private async dispatchSessionEvent(sessionId: string, event: Event): Promise { + let state: SessionState | undefined; + try { + state = await this.ensureState(sessionId); + } catch (error) { + if (error instanceof Error && error.message === 'InstantiationService has been disposed') { + return; + } + throw error; + } + if (state === undefined) return; + state.queue = state.queue + .then(() => this.dispatch(state, event, isVolatileEventType(event.type))) + .catch((error: unknown) => this.logDispatchDropped(state.sessionId, event.type, error)); + } + + private attachWorkView(session: ISessionScopeHandle, state: SessionState): void { + const workView = session.accessor.get(ISessionActivityView); + workView.state(); + state.lifecycleDisposables.push( + workView.onDidChange(({ state: work, cause }) => { + if (cause === 'turn_ended') { + state.deferredWork = work; + queueMicrotask(() => { + if (this.sessions.get(state.sessionId) !== state) return; + this.flushDeferredWork(state); + }); + return; + } + this.flushDeferredWork(state); + this.enqueueWorkChanged(state, work); + }), + ); + } + + private flushDeferredWork(state: SessionState): void { + const deferred = state.deferredWork; + if (deferred === undefined) return; + state.deferredWork = undefined; + this.enqueueWorkChanged(state, deferred); + } + + private attachAgents(sessionId: string, session: ISessionScopeHandle, state: SessionState): void { + const agents = session.accessor.get(IAgentLifecycleService); + const subscribeAgent = (handle: IAgentScopeHandle): void => { + if (state.agentDisposables.has(handle.id)) return; + state.agentDisposables.set(handle.id, this.attachAgent(sessionId, handle)); + }; + for (const agent of agents.list()) { + const handle = agents.handleOf(agent.agentId); + if (handle !== undefined) subscribeAgent(handle); + } + state.lifecycleDisposables.push( + agents.onDidCreate((context) => { + const handle = agents.handleOf(context.agentId); + if (handle !== undefined) subscribeAgent(handle); + this.enqueueDurable(state, { + type: 'agent.created', + agentId: context.agentId, + sessionId, + }); + }), + agents.onDidClose((context) => { + const agentId = context.agentId; + const d = state.agentDisposables.get(agentId); + if (d !== undefined) { + d.dispose(); + state.agentDisposables.delete(agentId); + this.activityTrackers.delete(`${sessionId}:${agentId}`); + this.enqueueDurable(state, { + type: 'agent.disposed', + agentId, + sessionId, + }); + } + }), + ); + } + + private attachAgent(sessionId: string, handle: IAgentScopeHandle): IDisposable { + const eventBus = handle.accessor.get(IEventBus); + this.activityTrackers.set( + `${sessionId}:${handle.id}`, + new LegacyActivityTracker( + () => handle.accessor.get(IAgentLoopService).snapshot(), + () => legacyApprovalsOf(handle), + ), + ); + let lastLegacyStatus: string | undefined; + const emitLegacyStatus = (): void => { + const snapshot = readLegacyStatus(handle); + if (snapshot === undefined) return; + const key = JSON.stringify(snapshot); + if (key === lastLegacyStatus) return; + lastLegacyStatus = key; + this.onAgentEvent(sessionId, MAIN_AGENT_ID, { + type: 'agent.status.updated', + ...snapshot, + } as unknown as Event2); + }; + const disposables: IDisposable[] = [ + eventBus.subscribe((event) => { + let projected: Event2 = event; + if (event.type === 'agent.status.updated') { + const snapshot = readLegacyStatus(handle); + if (snapshot !== undefined) { + lastLegacyStatus = JSON.stringify(snapshot); + projected = Object.assign({}, event, snapshot) as unknown as Event2; + } + } + if (handle.id === MAIN_AGENT_ID && event.type === 'context.spliced') { + emitLegacyStatus(); + } + this.onAgentEvent(sessionId, handle.id, projected); + }), + ]; + + return { dispose: () => disposables.forEach((disposable) => disposable.dispose()) }; + } + + private onAgentEvent(sessionId: string, agentId: string, event: Event2): void { + const state = this.sessions.get(sessionId); + if (state === undefined) return; + + if ( + event.type === 'agent.status.updated' && + (event as { phase?: unknown }).phase !== undefined + ) { + return; + } + + let wireEvent: Event; + if (event.type === 'turn.started') { + const { promptAttachments: _internal, ...wireFields } = event as typeof event & { + promptAttachments?: unknown; + }; + wireEvent = Object.assign({}, wireFields, { agentId, sessionId }) as unknown as Event; + } else if (event.type === 'prompt.steered' || event.type === 'prompt.queued' || event.type === 'prompt.submitted') { + const content = (event as unknown as { content: Parameters[0] }).content; + wireEvent = Object.assign({}, event, { + content: projectPromptContentParts(content), + agentId, + sessionId, + }) as unknown as Event; + } else { + wireEvent = Object.assign({}, event, { agentId, sessionId }) as unknown as Event; + } + const volatile = isVolatileSignal(event.type); + state.queue = state.queue + .then(() => this.dispatch(state, wireEvent, volatile)) + .catch((error: unknown) => this.logDispatchDropped(state.sessionId, wireEvent.type, error)); + const legacy = legacyTaskEvent(event, agentId, sessionId); + if (legacy !== undefined) { + state.queue = state.queue + .then(() => this.dispatch(state, legacy, volatile)) + .catch((error: unknown) => this.logDispatchDropped(state.sessionId, legacy.type, error)); + } + const tracker = this.activityTrackers.get(`${sessionId}:${agentId}`); + if (tracker !== undefined) { + const phase = phaseFromDomainEvent(tracker, event); + if (phase !== undefined) { + const phaseEvent = { + type: 'agent.status.updated', + phase, + agentId, + sessionId, + } as unknown as Event; + state.queue = state.queue + .then(() => this.dispatch(state, phaseEvent, true)) + .catch((error: unknown) => this.logDispatchDropped(state.sessionId, phaseEvent.type, error)); + } + } + } + + private attachInteractions( + sessionId: string, + state: SessionState, + ): void { + const pendingOfSession = (): readonly Interaction[] => + interactions.findAll({ + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: sessionId }, + }); + for (const i of pendingOfSession()) { + state.knownInteractions.set(i.id, { kind: i.kind, agentId: interactionAgentId(i) }); + } + state.lifecycleDisposables.push( + toDisposable( + interactions.onDidChangePending(() => { + for (const i of pendingOfSession()) { + if (state.knownInteractions.has(i.id)) continue; + state.knownInteractions.set(i.id, { + kind: i.kind, + agentId: interactionAgentId(i), + }); + const event = interactionRequestedEvent(i, sessionId); + if (event !== undefined) { + this.enqueueDurable(state, event); + } + } + }), + ), + toDisposable( + interactions.onDidResolve(({ id, response }) => { + const known = state.knownInteractions.get(id); + if (known === undefined) return; + state.knownInteractions.delete(id); + const event = interactionResolvedEvent(known.kind, id, response, sessionId, known.agentId); + if (event !== undefined) { + this.enqueueDurable(state, event); + } + }), + ), + ); + } + + private enqueueDurable(state: SessionState, event: Event): void { + state.queue = state.queue + .then(() => this.dispatch(state, event, false)) + .catch((error: unknown) => this.logDispatchDropped(state.sessionId, event.type, error)); + } + + private enqueueWorkChanged(state: SessionState, work: SessionActivityState): void { + state.queue = state.queue + .then(() => + this.dispatch( + state, + { + type: 'event.session.work_changed', + busy: work.busy, + main_turn_active: work.mainTurnActive, + pending_interaction: work.pendingInteraction, + last_turn_reason: work.lastTurnReason, + agentId: 'main', + sessionId: state.sessionId, + } as Event, + false, + ), + ) + .catch((error: unknown) => + this.logDispatchDropped(state.sessionId, 'event.session.work_changed', error), + ); + } + + private logDispatchError(sessionId: string, eventType: string, error: unknown): void { + const logger = this.opts.logger; + if (logger === undefined) return; + if (logger.error !== undefined) { + logger.error({ sessionId, eventType, err: error }, 'session event dispatch failed'); + } else { + logger.warn({ sessionId, eventType, err: error }, 'session event dispatch failed'); + } + } + + private logDispatchDropped(sessionId: string, eventType: string, error: unknown): void { + this.opts.logger?.warn( + { sessionId, eventType, err: error }, + 'session event dispatch failed; event dropped', + ); + } + + private async dispatch(state: SessionState, event: Event, volatile: boolean): Promise { + const { journal, tracker, roster, tail, targets, sessionId } = state; + const annotation = tracker.apply(sessionId, event); + roster.apply(sessionId, event); + + let envelope: EventEnvelope; + if (volatile) { + envelope = this.buildEnvelope(journal.seq, sessionId, event, { + epoch: journal.epoch, + volatile: true, + ...(annotation.offset !== undefined ? { offset: annotation.offset } : {}), + }); + } else { + const seq = journal.nextSeq(); + envelope = this.buildEnvelope(seq, sessionId, event, { epoch: journal.epoch }); + journal.append(seq, envelope); + tail.push({ seq, envelope }); + while (tail.length > this.maxBufferSize) tail.shift(); + } + + if (isGlobalEvent(event.type)) { + const recipients = new Set(this.globalTargets); + for (const target of this.allTargets()) recipients.add(target); + const diGated = event.type.startsWith('event.di.'); + for (const target of recipients) { + if (diGated && !this.diEventTargets.has(target)) continue; + try { + target.send(envelope, 'immediate'); + } catch { + } + } + } else { + for (const [target, sub] of targets) { + if (!matchesAgentFilter(envelope, sub.agentFilter)) continue; + if (suppressedByTranscript(envelope, sub.transcriptGrades)) continue; + try { + target.send(envelope); + } catch { + } + } + } + } + + private buildEnvelope( + seq: number, + sessionId: string, + event: Event, + extras: { epoch?: string; volatile?: boolean; offset?: number }, + ): EventEnvelope { + return { + type: event.type, + seq, + session_id: sessionId, + timestamp: + event.time !== undefined + ? new Date(event.time).toISOString() + : new Date().toISOString(), + payload: event, + ...extras, + }; + } + + private *allTargets(): Iterable { + for (const state of this.sessions.values()) { + for (const target of state.targets.keys()) yield target; + } + } +} + +const VOLATILE_SIGNAL_TYPES = [ + 'assistant.delta', + 'thinking.delta', + 'tool.call.delta', + 'tool.progress', + 'shell.output', + 'shell.started', + 'shell.completed', + 'agent.status.updated', +] as const; + +const volatileSignalTypeSet: ReadonlySet = new Set(VOLATILE_SIGNAL_TYPES); + +function isVolatileSignal(type: string): boolean { + return volatileSignalTypeSet.has(type); +} + +function legacyTaskEvent(event: Event2, agentId: string, sessionId: string): Event | undefined { + if (event.type !== 'task.started' && event.type !== 'task.terminated') return undefined; + const legacyType = + event.type === 'task.started' ? 'background.task.started' : 'background.task.terminated'; + return Object.assign({}, event, { type: legacyType, agentId, sessionId }) as unknown as Event; +} + +function isGlobalEvent(type: string): boolean { + return ( + type === 'session.meta.updated' || + type.startsWith('event.session.') || + type.startsWith('event.workspace.') || + type.startsWith('event.config.') || + type.startsWith('event.model_catalog.') || + type.startsWith('event.plugin.') || + type.startsWith('event.capability.') || + type.startsWith('event.di.') + ); +} + +function isAgentLifecycleEvent(type: string): boolean { + return type === 'agent.created' || type === 'agent.disposed'; +} + +function matchesAgentFilter(envelope: EventEnvelope, filter: AgentFilter): boolean { + if (filter === undefined) return true; + if (isGlobalEvent(envelope.type)) return true; + if (isAgentLifecycleEvent(envelope.type)) return true; + const payload = envelope.payload; + const agentId = + typeof payload === 'object' && payload !== null + ? (payload as { agentId?: unknown }).agentId + : undefined; + if (typeof agentId !== 'string') return true; + return filter.has(agentId); +} + +const TRANSCRIPT_PROJECTED_EVENT_TYPES: ReadonlySet = new Set([ + 'turn.started', + 'turn.ended', + 'turn.step.started', + 'turn.step.completed', + 'turn.step.interrupted', + 'turn.step.retrying', + 'assistant.delta', + 'thinking.delta', + 'tool.call.delta', + 'tool.call.started', + 'tool.progress', + 'tool.result', + 'shell.started', + 'shell.output', + 'shell.completed', + 'task.started', + 'task.terminated', + 'background.task.started', + 'background.task.terminated', + 'task.notified', + 'subagent.spawned', + 'subagent.started', + 'subagent.completed', + 'subagent.failed', + 'subagent.cancelled', + 'subagent.suspended', + 'compaction.started', + 'compaction.blocked', + 'compaction.cancelled', + 'compaction.completed', + 'skill.activated', + 'plugin_command.activated', + 'cron.fired', + 'error', + 'warning', + 'goal.updated', + 'plan.revision', + 'context.spliced', + 'agent.status.updated', + 'hook.result', + 'prompt.submitted', + 'prompt.started', + 'prompt.completed', + 'prompt.aborted', + 'prompt.steered', + 'turn.steer', + 'event.question.requested', + 'event.question.dismissed', + 'event.question.answered', + 'event.approval.requested', + 'event.approval.resolved', +]); + +function suppressedByTranscript( + envelope: EventEnvelope, + spec: TranscriptGradeSpec | undefined, +): boolean { + if (spec === undefined) return false; + if (isGlobalEvent(envelope.type)) return false; + if (isAgentLifecycleEvent(envelope.type)) return false; + const payload = envelope.payload; + const agentId = + typeof payload === 'object' && payload !== null + ? (payload as { agentId?: unknown }).agentId + : undefined; + if (typeof agentId !== 'string') return false; + if (gradeFor(spec, agentId) === 'off') return false; + return TRANSCRIPT_PROJECTED_EVENT_TYPES.has(envelope.type); +} + +function interactionAgentId(interaction: Interaction): string { + const tag = interaction.tags[INTERACTION_TAG_AGENT_ID]; + return typeof tag === 'string' ? tag : MAIN_AGENT_ID; +} + +function interactionRequestedEvent(interaction: Interaction, sessionId: string): Event | undefined { + const agentId = interactionAgentId(interaction); + switch (interaction.kind) { + case 'question': + return { + type: 'event.question.requested', + agentId, + sessionId, + ...toWireQuestion(interaction, sessionId), + } as unknown as Event; + case 'approval': + return { + type: 'event.approval.requested', + agentId, + sessionId, + ...toWireApproval(interaction, sessionId), + } as unknown as Event; + default: + return undefined; + } +} + +function interactionResolvedEvent( + kind: InteractionKind, + id: string, + response: unknown, + sessionId: string, + agentId: string, +): Event | undefined { + const resolvedAt = new Date().toISOString(); + switch (kind) { + case 'question': { + if (response === null) { + return { + type: 'event.question.dismissed', + agentId, + sessionId, + question_id: id, + dismissed_at: resolvedAt, + } as unknown as Event; + } + const answers = (response as { answers?: unknown }).answers ?? response; + return { + type: 'event.question.answered', + agentId, + sessionId, + question_id: id, + answers, + resolved_at: resolvedAt, + } as unknown as Event; + } + case 'approval': { + const r = response as Partial; + return { + type: 'event.approval.resolved', + agentId, + sessionId, + approval_id: id, + decision: r.decision, + scope: r.scope, + feedback: r.feedback, + selected_label: r.selectedLabel, + resolved_at: resolvedAt, + } as unknown as Event; + } + default: + return undefined; + } +} + +function sessionMetaUpdatedPayload( + payload: unknown, +): Pick | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const candidate = payload as Partial; + const title = typeof candidate.title === 'string' ? candidate.title : undefined; + const patch = + typeof candidate.patch === 'object' && + candidate.patch !== null && + !Array.isArray(candidate.patch) + ? candidate.patch + : undefined; + if (title === undefined && patch === undefined) return undefined; + return { title, patch }; +} + +function sessionMetaUpdatedSessionId(payload: unknown): string | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const sessionId = (payload as { sessionId?: unknown }).sessionId; + return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined; +} + +const DI_UNIT_STATES: ReadonlySet = new Set([ + 'Pending', + 'Activating', + 'Active', + 'Unloading', + 'Failed', +]); + +function diUnitChangedPayload( + payload: unknown, +): Pick | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const candidate = payload as Partial; + if (typeof candidate.scope !== 'string' || candidate.scope.length === 0) return undefined; + if (typeof candidate.token !== 'string' || candidate.token.length === 0) return undefined; + if (typeof candidate.state !== 'string' || !DI_UNIT_STATES.has(candidate.state)) { + return undefined; + } + return { + scope: candidate.scope, + token: candidate.token, + state: candidate.state as DiUnitChangedEvent['state'], + error: typeof candidate.error === 'string' ? candidate.error : undefined, + }; +} + +function sessionCreatedPayload( + payload: unknown, +): { sessionId: string; session: SessionCreatedEvent['session'] } | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const candidate = payload as { sessionId?: unknown; session?: unknown }; + const sessionId = + typeof candidate.sessionId === 'string' && candidate.sessionId.length > 0 + ? candidate.sessionId + : undefined; + const session = + typeof candidate.session === 'object' && + candidate.session !== null && + !Array.isArray(candidate.session) + ? (candidate.session as SessionCreatedEvent['session']) + : undefined; + if (sessionId === undefined || session === undefined) return undefined; + return { sessionId, session }; +} + +function sessionArchivedPayload( + payload: unknown, +): { sessionId: string; workspaceId: string } | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const candidate = payload as { sessionId?: unknown; workspaceId?: unknown }; + if (typeof candidate.sessionId !== 'string' || candidate.sessionId.length === 0) { + return undefined; + } + if (typeof candidate.workspaceId !== 'string' || candidate.workspaceId.length === 0) { + return undefined; + } + return { sessionId: candidate.sessionId, workspaceId: candidate.workspaceId }; +} + +function sessionDeletedPayload( + payload: unknown, +): { sessionId: string; workspaceId: string } | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const candidate = payload as { sessionId?: unknown; workspaceId?: unknown }; + if (typeof candidate.sessionId !== 'string' || candidate.sessionId.length === 0) { + return undefined; + } + if (typeof candidate.workspaceId !== 'string' || candidate.workspaceId.length === 0) { + return undefined; + } + return { sessionId: candidate.sessionId, workspaceId: candidate.workspaceId }; +} + +function workspaceLifecyclePayload(payload: unknown): Workspace | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const candidate = (payload as { workspace?: unknown }).workspace; + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) { + return undefined; + } + const ws = candidate as Partial; + if (typeof ws.id !== 'string' || ws.id.length === 0) return undefined; + if (typeof ws.root !== 'string' || ws.root.length === 0) return undefined; + if (typeof ws.name !== 'string') return undefined; + if (typeof ws.createdAt !== 'number' || typeof ws.lastOpenedAt !== 'number') return undefined; + return { + id: ws.id, + root: ws.root, + name: ws.name, + createdAt: ws.createdAt, + lastOpenedAt: ws.lastOpenedAt, + }; +} + +function workspaceDeletedPayload( + payload: unknown, +): { workspaceId: string; root: string } | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const candidate = payload as { workspaceId?: unknown; root?: unknown }; + if (typeof candidate.workspaceId !== 'string' || candidate.workspaceId.length === 0) { + return undefined; + } + if (typeof candidate.root !== 'string' || candidate.root.length === 0) return undefined; + return { workspaceId: candidate.workspaceId, root: candidate.root }; +} + +interface CapabilityChangedPayload { + capability_id: string; + install: { + running: boolean; + step?: string; + percent?: number; + error?: string; + note?: string; + }; +} + +function capabilityChangedPayload(payload: unknown): CapabilityChangedPayload | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const id = (payload as { capability_id?: unknown }).capability_id; + if (typeof id !== 'string' || id.length === 0) return undefined; + const install = (payload as { install?: unknown }).install; + if (typeof install !== 'object' || install === null) return undefined; + const running = (install as { running?: unknown }).running; + if (typeof running !== 'boolean') return undefined; + const out: CapabilityChangedPayload['install'] = { running }; + for (const key of ['step', 'error', 'note'] as const) { + const value = (install as Record)[key]; + if (typeof value === 'string') out[key] = value; + } + const percent = (install as { percent?: unknown }).percent; + if (typeof percent === 'number') out.percent = percent; + return { capability_id: id, install: out }; +} + +function configWarningPayload(payload: unknown): { warnings: ConfigWarningItem[] } | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const warnings = (payload as { warnings?: unknown }).warnings; + if (!Array.isArray(warnings)) return undefined; + const items: ConfigWarningItem[] = []; + for (const warning of warnings) { + if (typeof warning !== 'object' || warning === null) return undefined; + const message = (warning as { message?: unknown }).message; + if (typeof message !== 'string' || message.length === 0) return undefined; + const domain = (warning as { domain?: unknown }).domain; + if (domain !== undefined && typeof domain !== 'string') return undefined; + items.push(typeof domain === 'string' ? { domain, message } : { message }); + } + return { warnings: items }; +} + +const configChangedPayloadSchema = configChangedEventSchema.omit({ type: true }); +const modelCatalogChangedPayloadSchema = modelCatalogChangedEventSchema.omit({ type: true }); + +function configChangedPayload( + payload: unknown, +): { changedFields: string[]; config: unknown } | undefined { + const parsed = configChangedPayloadSchema.safeParse(payload); + if (!parsed.success) return undefined; + return { changedFields: parsed.data.changedFields, config: parsed.data.config }; +} + +function modelCatalogChangedPayload( + payload: unknown, +): + | { + changed: ModelCatalogRefreshChange[]; + unchanged: string[]; + failed: ModelCatalogRefreshFailure[]; + } + | undefined { + const parsed = modelCatalogChangedPayloadSchema.safeParse(payload); + if (!parsed.success) return undefined; + return parsed.data; +} diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts b/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts new file mode 100644 index 0000000000000000000000000000000000000000..83d41e297aa5acb4f58784709186b84149a9afd2 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts @@ -0,0 +1,225 @@ +import { createReadStream } from 'node:fs'; +import { appendFile, mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { ulid } from 'ulid'; + +const JOURNAL_VERSION = 1; + +export interface EventEnvelope { + readonly type: string; + readonly seq: number; + readonly epoch?: string; + readonly volatile?: boolean; + readonly offset?: number; + readonly session_id?: string; + readonly timestamp: string; + readonly payload: unknown; +} + +interface JournalHeaderLine { + kind: 'journal_header'; + version: number; + epoch: string; + created_at: number; +} + +interface JournalEventLine { + kind: 'event'; + seq: number; + envelope: EventEnvelope; +} + +export interface JournalEntry { + seq: number; + envelope: EventEnvelope; +} + +export interface JournalLogger { + warn(obj: unknown, msg: string): void; + error?(obj: unknown, msg: string): void; +} + +const noopLogger: JournalLogger = { warn: () => {} }; + +export class SessionEventJournal { + private _seq: number; + private pendingLines: string[] = []; + private flushPromise: Promise | undefined; + private headerPending: boolean; + private closed = false; + + private constructor( + private readonly filePath: string, + private readonly logger: JournalLogger, + public readonly epoch: string, + lastSeq: number, + isFresh: boolean, + ) { + this._seq = lastSeq; + this.headerPending = isFresh; + } + + get seq(): number { + return this._seq; + } + + static async open(filePath: string, logger: JournalLogger = noopLogger): Promise { + let epoch: string | undefined; + let lastSeq = 0; + let sawAnyLine = false; + + try { + for await (const raw of readLines(filePath)) { + sawAnyLine = true; + const parsed = parseJournalLine(raw); + if (parsed === undefined) continue; + if (parsed.kind === 'journal_header') { + if (epoch === undefined) epoch = parsed.epoch; + continue; + } + if (parsed.seq > lastSeq) lastSeq = parsed.seq; + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + logger.warn( + { filePath, err: String(error) }, + 'event journal unreadable; starting a fresh epoch', + ); + } + } + + if (epoch === undefined) { + if (sawAnyLine) { + logger.warn({ filePath }, 'event journal missing header; rotating to a fresh epoch'); + } + return new SessionEventJournal(filePath, logger, `ep_${ulid()}`, 0, true); + } + return new SessionEventJournal(filePath, logger, epoch, lastSeq, false); + } + + nextSeq(): number { + this._seq += 1; + return this._seq; + } + + append(seq: number, envelope: EventEnvelope): void { + if (this.closed) return; + const line: JournalEventLine = { kind: 'event', seq, envelope }; + this.pendingLines.push(JSON.stringify(line)); + this.scheduleFlush(); + } + + async readSince(fromSeqExclusive: number, limit: number): Promise { + await this.flush(); + const out: JournalEntry[] = []; + try { + for await (const raw of readLines(this.filePath)) { + const parsed = parseJournalLine(raw); + if (parsed === undefined || parsed.kind !== 'event') continue; + if (parsed.seq <= fromSeqExclusive) continue; + out.push({ seq: parsed.seq, envelope: parsed.envelope }); + if (out.length >= limit) break; + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw error; + } + return out; + } + + async flush(): Promise { + while (this.flushPromise !== undefined || this.pendingLines.length > 0) { + if (this.flushPromise === undefined) { + this.flushPromise = this.flushOnce().finally(() => { + this.flushPromise = undefined; + }); + } + await this.flushPromise; + } + } + + async close(): Promise { + this.closed = true; + await this.flush(); + } + + private scheduleFlush(): void { + if (this.flushPromise !== undefined) return; + this.flushPromise = this.flushOnce().finally(() => { + this.flushPromise = undefined; + if (this.pendingLines.length > 0) this.scheduleFlush(); + }); + } + + private async flushOnce(): Promise { + const lines: string[] = []; + if (this.headerPending) { + const header: JournalHeaderLine = { + kind: 'journal_header', + version: JOURNAL_VERSION, + epoch: this.epoch, + created_at: Date.now(), + }; + lines.push(JSON.stringify(header)); + this.headerPending = false; + } + lines.push(...this.pendingLines); + this.pendingLines = []; + if (lines.length === 0) return; + try { + await mkdir(dirname(this.filePath), { recursive: true }); + await appendFile(this.filePath, lines.join('\n') + '\n', 'utf8'); + } catch (error) { + this.logger.warn( + { filePath: this.filePath, err: String(error) }, + 'event journal write failed; events remain live-only this round', + ); + } + } +} + +export function sessionJournalPath(eventsDir: string, sessionId: string): string { + return join(eventsDir, `${sessionId}.jsonl`); +} + +function parseJournalLine(raw: string): JournalHeaderLine | JournalEventLine | undefined { + const trimmed = raw.endsWith('\r') ? raw.slice(0, -1) : raw; + if (trimmed.length === 0) return undefined; + let value: unknown; + try { + value = JSON.parse(trimmed); + } catch { + return undefined; + } + if (typeof value !== 'object' || value === null) return undefined; + const kind = (value as { kind?: unknown }).kind; + if (kind === 'journal_header') { + const epoch = (value as { epoch?: unknown }).epoch; + if (typeof epoch !== 'string' || epoch.length === 0) return undefined; + return value as JournalHeaderLine; + } + if (kind === 'event') { + const seq = (value as { seq?: unknown }).seq; + const envelope = (value as { envelope?: unknown }).envelope; + if (typeof seq !== 'number' || !Number.isInteger(seq) || seq <= 0) return undefined; + if (typeof envelope !== 'object' || envelope === null) return undefined; + return value as JournalEventLine; + } + return undefined; +} + +async function* readLines(filePath: string): AsyncIterable { + let buffered = ''; + const stream = createReadStream(filePath, { encoding: 'utf8' }); + for await (const chunk of stream) { + buffered += chunk; + let newlineIndex = buffered.indexOf('\n'); + while (newlineIndex !== -1) { + yield buffered.slice(0, newlineIndex); + buffered = buffered.slice(newlineIndex + 1); + newlineIndex = buffered.indexOf('\n'); + } + } + if (buffered.length > 0) yield buffered; +} diff --git a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts new file mode 100644 index 0000000000000000000000000000000000000000..60e9ce3a126a638604c8439501f47be72bd7191d --- /dev/null +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -0,0 +1,116 @@ +import type { Event } from './events'; +import type { SnapshotSubagent } from '../../../protocol/rest-snapshot'; + +const MAIN_AGENT_ID = 'main'; + +export class SubagentRosterTracker { + private readonly bySession = new Map>(); + + apply(sessionId: string, event: Event): void { + switch (event.type) { + case 'subagent.spawned': { + if (event.runInBackground === true) return; + let roster = this.bySession.get(sessionId); + if (!roster) { + roster = new Map(); + this.bySession.set(sessionId, roster); + } + roster.set(event.subagentId, { + id: event.subagentId, + session_id: sessionId, + kind: 'subagent', + description: event.description ?? event.subagentName ?? 'Sub Agent', + status: 'running', + subagent_phase: 'queued', + subagent_type: event.subagentName, + parent_tool_call_id: event.parentToolCallId === '' ? undefined : event.parentToolCallId, + swarm_index: event.swarmIndex, + run_in_background: event.runInBackground, + model: event.model, + thinking_effort: event.thinkingEffort, + created_at: new Date().toISOString(), + }); + return; + } + case 'subagent.started': { + const entry = this.bySession.get(sessionId)?.get(event.subagentId); + if (!entry) return; + entry.subagent_phase = 'working'; + entry.suspended_reason = undefined; + entry.started_at ??= new Date().toISOString(); + return; + } + case 'subagent.suspended': { + const entry = this.bySession.get(sessionId)?.get(event.subagentId); + if (!entry) return; + entry.subagent_phase = 'suspended'; + entry.suspended_reason = event.reason; + return; + } + case 'subagent.completed': { + const entry = this.bySession.get(sessionId)?.get(event.subagentId); + if (!entry) return; + entry.subagent_phase = 'completed'; + entry.status = 'completed'; + entry.completed_at = new Date().toISOString(); + entry.output_preview = event.resultSummary; + return; + } + case 'subagent.failed': { + const entry = this.bySession.get(sessionId)?.get(event.subagentId); + if (!entry) return; + entry.subagent_phase = 'failed'; + entry.status = 'failed'; + entry.completed_at = new Date().toISOString(); + entry.output_preview = event.error; + return; + } + case 'subagent.cancelled': { + const entry = this.bySession.get(sessionId)?.get(event.subagentId); + if (!entry) return; + entry.subagent_phase = 'cancelled'; + entry.status = 'cancelled'; + entry.completed_at = new Date().toISOString(); + return; + } + case 'task.started': { + const info = event.info; + if (info.kind === 'agent' && info.detached === true && info.agentId !== undefined) { + this.bySession.get(sessionId)?.delete(info.agentId); + } + return; + } + case 'turn.ended': { + if (event.agentId !== MAIN_AGENT_ID) return; + const roster = this.bySession.get(sessionId); + if (roster === undefined || event.reason === 'completed') return; + for (const entry of roster.values()) { + if (entry.status !== 'running') continue; + entry.status = 'failed'; + entry.subagent_phase = 'failed'; + entry.completed_at = new Date().toISOString(); + entry.output_preview ??= `Main turn ${event.reason}`; + } + return; + } + case 'turn.started': { + if (event.agentId === MAIN_AGENT_ID) { + this.bySession.delete(sessionId); + } + return; + } + default: + return; + } + } + + get(sessionId: string): SnapshotSubagent[] { + const roster = this.bySession.get(sessionId); + if (!roster) return []; + return Array.from(roster.values(), (entry) => ({ ...entry })); + } + + clear(sessionId: string): void { + this.bySession.delete(sessionId); + } +} diff --git a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts new file mode 100644 index 0000000000000000000000000000000000000000..3da60b7fc4481ae9f65459089d79bcb0de0fe1df --- /dev/null +++ b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts @@ -0,0 +1,570 @@ +import { + unsubscribeV2PayloadSchema, + WS_PROTOCOL_VERSION, + type SessionCursor, +} from '../../../protocol/ws-control'; +import { + detachGrades, + transcriptSubscribeV2PayloadSchema, + type TranscriptGradeSpec, +} from '@moonshot-ai/transcript'; +import { ulid } from 'ulid'; +import type { RawData, WebSocket } from 'ws'; + +import type { CredentialValidator } from '../../../services/auth/credentials'; +import type { IConnectionRegistry } from '../connectionRegistry'; +import { + type EventEnvelope, + type JournalLogger, +} from './sessionEventJournal'; +import { + buildAck, + buildPing, + buildResyncRequired, + buildServerHello, +} from './protocol'; +import { + type AgentFilter, + type BroadcastDelivery, + type BroadcastTarget, + type ResyncReason, + type SessionEventBroadcaster, + type TargetSubscription, +} from './sessionEventBroadcaster'; + +const DEFAULT_MAX_BUFFER_SIZE = 1000; + +const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000; +const HEARTBEAT_MISS_LIMIT = 2; + +type SessionSubscription = TargetSubscription; + +const DEFAULT_FLUSH_INTERVAL_MS = 16; +const DEFAULT_MAX_BATCH_SIZE = 64; +const DEFAULT_HIGH_WATER_MARK_BYTES = 1 << 20; +const DEFAULT_BACKPRESSURE_RETRY_MS = 5; +const DEFAULT_BACKPRESSURE_MAX_DELAY_MS = 100; + +interface InboundFrame { + type: string; + id?: string; + payload?: Record; +} + +export interface WsConnectionV1Options { + readonly socket: WebSocket; + readonly broadcaster: SessionEventBroadcaster; + readonly connectionRegistry: IConnectionRegistry; + readonly validateCredential?: CredentialValidator; + readonly remoteAddress: string | null; + readonly userAgent: string | null; + readonly logger?: JournalLogger; + readonly maxBufferSize?: number; + readonly flushIntervalMs?: number; + readonly maxBatchSize?: number; + readonly highWaterMarkBytes?: number; + readonly heartbeatIntervalMs?: number; +} + +export class WsConnectionV1 implements BroadcastTarget { + readonly id: string; + readonly connectedAt: string; + readonly remoteAddress: string | null; + readonly userAgent: string | null; + + private readonly socket: WebSocket; + private readonly broadcaster: SessionEventBroadcaster; + private readonly validateCredential?: CredentialValidator; + private readonly maxBufferSize: number; + private readonly flushIntervalMs: number; + private readonly maxBatchSize: number; + private readonly highWaterMarkBytes: number; + private readonly heartbeatIntervalMs: number; + private readonly logger?: JournalLogger; + + private closed = false; + private gotClientHello = false; + readonly subscriptions = new Map(); + private controlQueue: Promise = Promise.resolve(); + + private outbound: unknown[] = []; + private flushTimer?: ReturnType; + private backpressureRetryTimer?: ReturnType; + private backpressureSince?: number; + + private heartbeatTimer?: ReturnType; + private lastInboundAt = Date.now(); + + constructor(opts: WsConnectionV1Options) { + this.id = `conn_${ulid()}`; + this.connectedAt = new Date().toISOString(); + this.remoteAddress = opts.remoteAddress; + this.userAgent = opts.userAgent; + this.socket = opts.socket; + this.broadcaster = opts.broadcaster; + this.validateCredential = opts.validateCredential; + this.logger = opts.logger; + this.maxBufferSize = opts.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE; + this.flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; + this.maxBatchSize = opts.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE; + this.highWaterMarkBytes = opts.highWaterMarkBytes ?? DEFAULT_HIGH_WATER_MARK_BYTES; + this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; + + this.socket.on('message', (data: RawData) => this.onMessage(data)); + this.socket.on('close', () => this.onClose()); + this.socket.on('error', () => this.onClose()); + + opts.connectionRegistry.add(this); + this.broadcaster.addGlobalTarget(this); + this.sendImmediateFrame( + buildServerHello({ + ws_connection_id: this.id, + protocol_version: WS_PROTOCOL_VERSION, + heartbeat_ms: this.heartbeatIntervalMs, + max_event_buffer_size: this.maxBufferSize, + capabilities: { event_batching: false, compression: false }, + }), + ); + this.heartbeatTimer = setInterval(() => { + this.onHeartbeat(); + }, this.heartbeatIntervalMs); + this.heartbeatTimer.unref?.(); + } + + get hasClientHello(): boolean { + return this.gotClientHello; + } + + get subscriptionSessionIds(): readonly string[] { + return Array.from(this.subscriptions.keys()).sort(); + } + + send(envelope: EventEnvelope, delivery: BroadcastDelivery = 'subscription'): void { + if (delivery === 'immediate') this.sendImmediateFrame(envelope); + else this.sendSubscribedFrame(envelope); + } + + private onMessage(data: RawData): void { + if (this.closed) return; + let frame: InboundFrame; + try { + frame = JSON.parse(rawDataToString(data)) as InboundFrame; + } catch { + return; + } + if (typeof frame?.type !== 'string') return; + this.lastInboundAt = Date.now(); + + switch (frame.type) { + case 'pong': + return; + case 'client_hello': + this.enqueueControl(() => this.onClientHello(frame)); + return; + case 'subscribe': + this.enqueueControl(() => this.onSubscribe(frame)); + return; + case 'subscribe_v2': + this.enqueueControl(() => this.onSubscribeV2(frame)); + return; + case 'unsubscribe_v2': + this.enqueueControl(() => this.onUnsubscribeV2(frame)); + return; + case 'unsubscribe': + this.enqueueControl(() => this.onUnsubscribe(frame)); + return; + default: + return; + } + } + + private enqueueControl(task: () => Promise): void { + this.controlQueue = this.controlQueue.then(task).catch(() => { + }); + } + + private onHeartbeat(): void { + if (Date.now() - this.lastInboundAt >= this.heartbeatIntervalMs * HEARTBEAT_MISS_LIMIT) { + this.close(1001, 'heartbeat timeout'); + return; + } + this.sendImmediateFrame(buildPing(ulid())); + } + + private async onClientHello(frame: InboundFrame): Promise { + if (!(await this.authorize(frame))) return; + this.gotClientHello = true; + + const payload = frame.payload ?? {}; + const subscriptions = asStringArray(payload['subscriptions']); + const cursors = payload['cursors'] as Record | undefined; + const agentFilter = parseAgentFilter(payload['agent_filter']); + + if (payload['client_id'] === 'kimi-inspect') this.broadcaster.addDiEventTarget(this); + + const accepted: string[] = []; + const resyncRequired: string[] = []; + const serverCursors: Record = {}; + + for (const sid of subscriptions) { + await this.attachSession( + sid, + cursors?.[sid], + agentFilter?.[sid], + this.subscriptions.get(sid)?.transcriptGrades, + undefined, + { accepted, resyncRequired, serverCursors }, + ); + } + + this.sendImmediateFrame( + buildAck(frame.id ?? '', 0, 'success', { + accepted_subscriptions: accepted, + resync_required: resyncRequired, + cursors: serverCursors, + }), + ); + } + + private async onSubscribe(frame: InboundFrame): Promise { + const payload = frame.payload ?? {}; + const sessionIds = asStringArray(payload['session_ids']); + const cursors = payload['cursors'] as Record | undefined; + const agentFilter = parseAgentFilter(payload['agent_filter']); + + const accepted: string[] = []; + const notFound: string[] = []; + const resyncRequired: string[] = []; + const serverCursors: Record = {}; + + for (const sid of sessionIds) { + await this.attachSession( + sid, + cursors?.[sid], + agentFilter?.[sid], + this.subscriptions.get(sid)?.transcriptGrades, + undefined, + { accepted, resyncRequired, serverCursors, notFound }, + ); + } + + this.sendImmediateFrame( + buildAck(frame.id ?? '', 0, 'success', { + accepted, + not_found: notFound, + resync_required: resyncRequired, + cursors: serverCursors, + }), + ); + } + + private async onSubscribeV2(frame: InboundFrame): Promise { + const parsed = transcriptSubscribeV2PayloadSchema.safeParse(frame.payload ?? {}); + if (!parsed.success) { + this.sendImmediateFrame(buildAck(frame.id ?? '', 1, 'invalid subscribe_v2 payload', {})); + return; + } + const sid = parsed.data.session_id; + + const accepted: string[] = []; + const notFound: string[] = []; + const resyncRequired: string[] = []; + const serverCursors: Record = {}; + + await this.attachSession( + sid, + undefined, + this.subscriptions.get(sid)?.agentFilter, + parsed.data.transcript, + parsed.data.transcript_since, + { accepted, resyncRequired, serverCursors, notFound }, + ); + + this.sendImmediateFrame( + buildAck(frame.id ?? '', 0, 'success', { + accepted, + not_found: notFound, + resync_required: resyncRequired, + cursors: serverCursors, + }), + ); + } + + private async onUnsubscribeV2(frame: InboundFrame): Promise { + const parsed = unsubscribeV2PayloadSchema.safeParse(frame.payload ?? {}); + if (!parsed.success) { + this.sendImmediateFrame(buildAck(frame.id ?? '', 1, 'invalid unsubscribe_v2 payload', {})); + return; + } + const sid = parsed.data.session_id; + const agentIds = parsed.data.agent_ids; + + const existing = this.subscriptions.get(sid); + if (existing !== undefined) { + this.broadcaster.unsubscribeTranscript(sid, this, agentIds); + this.subscriptions.set(sid, { + agentFilter: existing.agentFilter, + transcriptGrades: + agentIds === undefined ? undefined : detachGrades(existing.transcriptGrades, agentIds), + }); + } + + this.sendImmediateFrame( + buildAck(frame.id ?? '', 0, 'success', { + accepted: [sid], + not_found: [], + resync_required: [], + }), + ); + } + + private async onUnsubscribe(frame: InboundFrame): Promise { + const payload = frame.payload ?? {}; + const sessionIds = asStringArray(payload['session_ids']); + for (const sid of sessionIds) { + this.broadcaster.unsubscribe(sid, this); + this.subscriptions.delete(sid); + } + this.sendImmediateFrame( + buildAck(frame.id ?? '', 0, 'success', { + accepted: [], + not_found: [], + resync_required: [], + }), + ); + } + + private async attachSession( + sid: string, + cursor: SessionCursor | undefined, + filter: AgentFilter | undefined, + transcriptGrades: TranscriptGradeSpec | undefined, + transcriptSince: Record | undefined, + collectors: { + accepted: string[]; + resyncRequired: string[]; + serverCursors: Record; + notFound?: string[]; + }, + ): Promise { + const { accepted, resyncRequired, serverCursors, notFound } = collectors; + const ok = await this.broadcaster.subscribe(sid, this, filter, transcriptGrades, { + deferTranscriptReset: cursor !== undefined, + transcriptSince, + }); + if (!ok) { + if (notFound !== undefined) notFound.push(sid); + else resyncRequired.push(sid); + return; + } + this.subscriptions.set(sid, { agentFilter: filter, transcriptGrades }); + accepted.push(sid); + if (cursor !== undefined) { + await this.replay(sid, cursor, filter, transcriptGrades, resyncRequired, serverCursors); + await this.broadcaster.flushTranscriptSeed(sid, this); + } else { + const cur = await this.broadcaster.getCursor(sid); + serverCursors[sid] = cur; + } + } + + private async replay( + sid: string, + cursor: SessionCursor, + filter: AgentFilter | undefined, + transcriptGrades: TranscriptGradeSpec | undefined, + resyncRequired: string[], + serverCursors: Record, + ): Promise { + const result = await this.broadcaster.getBufferedSince(sid, cursor, filter, transcriptGrades); + if (result.resyncRequired !== false) { + this.sendImmediateFrame( + buildResyncRequired(sid, result.resyncRequired as ResyncReason, result.currentSeq, result.epoch), + ); + resyncRequired.push(sid); + } else { + for (const { envelope } of result.events) this.sendSubscribedFrame(envelope); + } + serverCursors[sid] = { seq: result.currentSeq, epoch: result.epoch }; + } + + private async authorize(frame: InboundFrame): Promise { + const payload = frame.payload ?? {}; + const token = typeof payload['token'] === 'string' ? (payload['token'] as string) : undefined; + if (token === undefined || this.validateCredential === undefined) return true; + let ok = false; + try { + ok = await this.validateCredential(token); + } catch { + ok = false; + } + if (!ok) { + this.sendImmediateFrame(buildAck(frame.id ?? '', 40112, 'unauthorized', {})); + this.close(); + return false; + } + return true; + } + + private sendSubscribedFrame(msg: unknown): void { + if (this.closed) return; + this.outbound.push(msg); + if (this.outbound.length >= this.maxBatchSize) { + this.flush(); + return; + } + this.scheduleFlush(); + } + + private sendImmediateFrame(msg: unknown): void { + if (this.closed) return; + this.outbound.push(msg); + this.flush(); + } + + private scheduleFlush(): void { + if (this.flushTimer !== undefined) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined; + this.flush(); + }, this.flushIntervalMs); + this.flushTimer.unref?.(); + } + + private flush(force = false): void { + if (this.flushTimer !== undefined) { + clearTimeout(this.flushTimer); + this.flushTimer = undefined; + } + if (this.outbound.length === 0) return; + if (this.closed || this.socket.readyState !== this.socket.OPEN) { + this.outbound = []; + return; + } + + if (!force && this.socket.bufferedAmount > this.highWaterMarkBytes) { + this.deferForBackpressure(); + return; + } + this.backpressureSince = undefined; + + const frames = coalesceFrames(this.outbound); + this.outbound = []; + for (const frame of frames) { + if (this.closed || this.socket.readyState !== this.socket.OPEN) return; + try { + this.socket.send(JSON.stringify(frame)); + } catch { + } + } + } + + private deferForBackpressure(): void { + const now = Date.now(); + if (this.backpressureSince === undefined) this.backpressureSince = now; + if (now - this.backpressureSince >= DEFAULT_BACKPRESSURE_MAX_DELAY_MS) { + this.flush(true); + return; + } + if (this.backpressureRetryTimer !== undefined) return; + this.backpressureRetryTimer = setTimeout(() => { + this.backpressureRetryTimer = undefined; + this.flush(); + }, DEFAULT_BACKPRESSURE_RETRY_MS); + this.backpressureRetryTimer.unref?.(); + } + + close(code = 1000, reason?: string): void { + if (this.closed) return; + this.flush(true); + try { + this.socket.close(code, reason); + } catch { + } + } + + private onClose(): void { + if (this.closed) return; + this.closed = true; + if (this.flushTimer !== undefined) clearTimeout(this.flushTimer); + if (this.backpressureRetryTimer !== undefined) clearTimeout(this.backpressureRetryTimer); + if (this.heartbeatTimer !== undefined) clearInterval(this.heartbeatTimer); + this.outbound = []; + this.broadcaster.removeGlobalTarget(this); + for (const sid of this.subscriptions.keys()) this.broadcaster.unsubscribe(sid, this); + } +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((v): v is string => typeof v === 'string'); +} + +function parseAgentFilter(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const out: Record = {}; + for (const [sid, ids] of Object.entries(value)) { + if (!Array.isArray(ids)) continue; + const set = new Set(ids.filter((v): v is string => typeof v === 'string')); + if (set.size === 0) continue; + out[sid] = set; + } + return out; +} + +function rawDataToString(data: RawData): string { + if (typeof data === 'string') return data; + if (Buffer.isBuffer(data)) return data.toString('utf8'); + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} + +interface CoalescableDelta { + type: 'assistant.delta' | 'thinking.delta'; + seq: number; + volatile: true; + offset?: number; + session_id?: string; + timestamp: string; + payload: { + agentId?: string; + turnId?: number; + delta: string; + [key: string]: unknown; + }; +} + +function isCoalescableDelta(frame: unknown): frame is CoalescableDelta { + if (typeof frame !== 'object' || frame === null) return false; + const f = frame as Record; + if (f['volatile'] !== true) return false; + const type = f['type']; + if (type !== 'assistant.delta' && type !== 'thinking.delta') return false; + const payload = f['payload']; + if (typeof payload !== 'object' || payload === null) return false; + return typeof (payload as Record)['delta'] === 'string'; +} + +export function coalesceFrames(frames: readonly unknown[]): unknown[] { + const out: unknown[] = []; + for (const frame of frames) { + const last = out.at(-1); + if ( + last !== undefined && + isCoalescableDelta(last) && + isCoalescableDelta(frame) && + last.type === frame.type && + last.session_id === frame.session_id && + last.payload.agentId === frame.payload.agentId && + last.payload.turnId === frame.payload.turnId + ) { + out[out.length - 1] = { + ...last, + payload: { ...last.payload, delta: last.payload.delta + frame.payload.delta }, + }; + } else { + out.push(frame); + } + } + return out; +} diff --git a/packages/kap-server/src/transport/ws/v3/globalTranslator.ts b/packages/kap-server/src/transport/ws/v3/globalTranslator.ts new file mode 100644 index 0000000000000000000000000000000000000000..e76b0c6924a43447426df962f09b2b416bcb7997 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/globalTranslator.ts @@ -0,0 +1,240 @@ +import { basename } from 'node:path'; + +import type { IDisposable, Workspace } from '@moonshot-ai/agent-core-v2'; + +import { serverMessageSchema, type ServerMessage, type WorkspaceInfo } from '../../../protocol/messages'; +import type { WsV3CoreEvent, WsV3GlobalSource, WsV3Logger } from './wsV3Deps'; + +export class GlobalMessageTranslator { + private queue: Promise = Promise.resolve(); + private readonly workspaces = new Map(); + private readonly validationFailures = new Map(); + private readonly disposable: IDisposable; + private disposed = false; + + constructor( + private readonly deps: WsV3GlobalSource, + private readonly emit: (message: ServerMessage) => void, + private readonly logger?: WsV3Logger, + ) { + this.disposable = deps.subscribe((event) => this.onEvent(event)); + this.enqueue(async () => { + for (const workspace of await deps.listWorkspaces()) { + this.workspaces.set(workspace.id, await deps.workspaceInfo(workspace)); + } + }); + } + + dispose(): void { + this.disposed = true; + this.disposable.dispose(); + this.workspaces.clear(); + } + + private onEvent(event: WsV3CoreEvent): void { + this.enqueue(async () => { + if (this.disposed) return; + for (const candidate of await this.translate(event)) this.emitValidated(candidate); + }); + } + + private enqueue(task: () => Promise): void { + this.queue = this.queue.then(task).catch(() => {}); + } + + private async translate(event: WsV3CoreEvent): Promise { + const timestamp = Date.now(); + switch (event.type) { + case 'event.workspace.created': + case 'event.workspace.updated': { + const workspace = workspaceRef(event.payload); + if (workspace === undefined) return []; + const info = await this.deps.workspaceInfo(workspace); + this.workspaces.set(info.id, info); + return [ + { + type: 'workspace', + timestamp, + subtype: event.type === 'event.workspace.created' ? 'created' : 'updated', + workspace: info, + }, + ]; + } + case 'event.workspace.deleted': { + const payload = workspaceDeletedRef(event.payload); + if (payload === undefined) return []; + const cached = this.workspaces.get(payload.workspaceId); + this.workspaces.delete(payload.workspaceId); + const fallback: WorkspaceInfo = { + id: payload.workspaceId, + root: payload.root, + name: basename(payload.root).slice(0, 100) || payload.root, + created_at: new Date(timestamp).toISOString(), + last_opened_at: new Date(timestamp).toISOString(), + session_count: 0, + }; + return [ + { type: 'workspace', timestamp, subtype: 'deleted', workspace: cached ?? fallback }, + ]; + } + case 'event.config.changed': { + const payload = asRecord(event.payload); + if (payload === undefined) return []; + return [ + { + type: 'config', + timestamp, + config: payload['config'], + changed_fields: stringArray(payload['changedFields']), + }, + ]; + } + case 'event.config.warning': { + const warnings = configWarningStrings(event.payload); + if (warnings === undefined) return []; + return [{ type: 'config.warning', timestamp, warnings }]; + } + case 'event.model_catalog.changed': + return [{ type: 'model_catalog', timestamp }]; + case 'event.plugin.changed': + return [{ type: 'plugin', timestamp }]; + case 'event.capability.changed': { + const payload = asRecord(event.payload); + const capabilityId = payload?.['capability_id']; + return [ + { + type: 'capability', + timestamp, + capability_id: + typeof capabilityId === 'string' && capabilityId.length > 0 + ? capabilityId + : undefined, + }, + ]; + } + case 'event.session.created': { + const payload = asRecord(event.payload); + const sessionId = stringField(payload, 'sessionId'); + if (payload === undefined || sessionId === undefined) return []; + const session = payload['session'] ?? (await this.deps.sessionInfo(sessionId)); + if (typeof session !== 'object' || session === null) return []; + return [{ type: 'session', timestamp, subtype: 'created', session }]; + } + case 'event.session.archived': { + const sessionId = stringField(asRecord(event.payload), 'sessionId'); + if (sessionId === undefined) return []; + const session = await this.deps.sessionInfo(sessionId); + if (session === undefined) return []; + return [{ type: 'session', timestamp, subtype: 'archived', session }]; + } + case 'session.meta.updated': { + const payload = asRecord(event.payload); + const sessionId = stringField(payload, 'sessionId'); + if (payload === undefined || sessionId === undefined) return []; + const session = await this.deps.sessionInfo(sessionId); + if (session === undefined) return []; + return [ + { + type: 'session', + timestamp, + subtype: 'updated', + session, + changed_fields: metaChangedFields(payload), + }, + ]; + } + default: + return []; + } + } + + private emitValidated(candidate: unknown): void { + const parsed = serverMessageSchema.safeParse(candidate); + if (!parsed.success) { + const type = String((candidate as { readonly type?: unknown } | null)?.type); + const count = (this.validationFailures.get(type) ?? 0) + 1; + this.validationFailures.set(type, count); + if (count === 1 || count % 100 === 0) { + this.logger?.warn( + { + type, + count, + issues: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }, + 'ws v3: global message failed schema validation, dropped', + ); + } + return; + } + this.emit(parsed.data); + } +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function stringField(payload: Record | undefined, key: string): string | undefined { + const value = payload?.[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function stringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const out = value.filter((item): item is string => typeof item === 'string' && item.length > 0); + return out.length === 0 ? undefined : out; +} + +function workspaceRef(payload: unknown): Workspace | undefined { + const candidate = asRecord(payload)?.['workspace']; + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) { + return undefined; + } + const ws = candidate as Partial; + if (typeof ws.id !== 'string' || ws.id.length === 0) return undefined; + if (typeof ws.root !== 'string' || ws.root.length === 0) return undefined; + if (typeof ws.name !== 'string') return undefined; + if (typeof ws.createdAt !== 'number' || typeof ws.lastOpenedAt !== 'number') return undefined; + return { + id: ws.id, + root: ws.root, + name: ws.name, + createdAt: ws.createdAt, + lastOpenedAt: ws.lastOpenedAt, + }; +} + +function workspaceDeletedRef(payload: unknown): { workspaceId: string; root: string } | undefined { + const record = asRecord(payload); + const workspaceId = stringField(record, 'workspaceId'); + const root = stringField(record, 'root'); + if (workspaceId === undefined || root === undefined) return undefined; + return { workspaceId, root }; +} + +function configWarningStrings(payload: unknown): string[] | undefined { + const warnings = asRecord(payload)?.['warnings']; + if (!Array.isArray(warnings)) return undefined; + const out: string[] = []; + for (const warning of warnings) { + const record = asRecord(warning); + const message = record?.['message']; + if (typeof message !== 'string' || message.length === 0) return undefined; + const domain = record?.['domain']; + out.push(typeof domain === 'string' && domain.length > 0 ? `${domain}: ${message}` : message); + } + return out; +} + +function metaChangedFields(payload: Record): string[] | undefined { + const patch = asRecord(payload['patch']); + if (patch === undefined) return undefined; + const out: string[] = []; + if (typeof patch['title'] === 'string') out.push('title'); + if (typeof patch['lastPrompt'] === 'string') out.push('last_prompt'); + return out.length === 0 ? undefined : out; +} diff --git a/packages/kap-server/src/transport/ws/v3/index.ts b/packages/kap-server/src/transport/ws/v3/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9af9a9677ab39b3f558c21fc1612e3dd63b45e2 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/index.ts @@ -0,0 +1,6 @@ +export * from './globalTranslator'; +export * from './registerWsV3'; +export * from './sessionLane'; +export * from './wsConnectionV3'; +export * from './wsV3Deps'; +export * from './wsV3Hub'; diff --git a/packages/kap-server/src/transport/ws/v3/registerWsV3.ts b/packages/kap-server/src/transport/ws/v3/registerWsV3.ts new file mode 100644 index 0000000000000000000000000000000000000000..b06321f916e16a97ad2873b621ae0a319078ee51 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/registerWsV3.ts @@ -0,0 +1,92 @@ +import { + IEventService, + ISessionIndex, + ISessionManager, + IWorkspaceService, + type Scope, + type Workspace, +} from '@moonshot-ai/agent-core-v2'; +import { WebSocketServer } from 'ws'; + +import type { WorkspaceInfo } from '../../../protocol/messages'; +import { resolveSessionFacts, toWireSession } from '../../../routes/sessions'; +import { toWireWorkspace } from '../../../routes/workspaces'; +import type { ProjectionService } from '../../../services/projection'; +import { selectWsBearerProtocol } from '../bearerProtocol'; +import type { IConnectionRegistry } from '../connectionRegistry'; +import { WsConnectionV3 } from './wsConnectionV3'; +import type { WsV3GlobalSource, WsV3Logger, WsV3SessionLifecycle } from './wsV3Deps'; +import { WsV3Hub } from './wsV3Hub'; + +export const WS_PATH_V3 = '/api/v3/ws'; + +export interface RegisterWsV3Options { + readonly registry: IConnectionRegistry; + readonly projection: ProjectionService; + readonly serverId: string; + readonly logger?: WsV3Logger; + readonly maxOutboundMessages?: number; + readonly heartbeatIntervalMs?: number; +} + +export interface WsV3Registration { + readonly wss: WebSocketServer; + readonly hub: WsV3Hub; +} + +export function registerWsV3(core: Scope, opts: RegisterWsV3Options): WsV3Registration { + const lifecycle: WsV3SessionLifecycle = { + onDidCreateSession(listener) { + const manager = core.accessor.get(ISessionManager); + const event = manager.onDidCreateSession; + if (event === undefined) return { dispose: () => {} }; + return event((created) => listener({ sessionId: created.sessionId })); + }, + async sessionExists(sessionId) { + return (await core.accessor.get(ISessionIndex).get(sessionId)) !== undefined; + }, + }; + const globalSource: WsV3GlobalSource = { + subscribe(listener) { + return core.accessor.get(IEventService).subscribe((event) => { + listener({ + type: event.type, + payload: (event as { readonly payload?: unknown }).payload, + }); + }); + }, + listWorkspaces: () => core.accessor.get(IWorkspaceService).list(), + workspaceInfo: (workspace: Workspace): Promise => toWireWorkspace(core, workspace), + async sessionInfo(sessionId) { + const summary = await core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) return undefined; + const cwd = + summary.cwd ?? + (await core.accessor.get(IWorkspaceService).get(summary.workspaceId))?.root; + if (cwd === undefined) return undefined; + return toWireSession(summary, cwd, resolveSessionFacts(core, sessionId)); + }, + }; + const hub = new WsV3Hub({ + projection: opts.projection, + lifecycle, + globalSource, + logger: opts.logger, + }); + const wss = new WebSocketServer({ noServer: true, handleProtocols: selectWsBearerProtocol }); + wss.on('connection', (socket, req) => { + const conn = new WsConnectionV3({ + socket, + hub, + connectionRegistry: opts.registry, + remoteAddress: req.socket.remoteAddress ?? null, + userAgent: req.headers['user-agent'] ?? null, + serverId: opts.serverId, + logger: opts.logger, + maxOutboundMessages: opts.maxOutboundMessages, + heartbeatIntervalMs: opts.heartbeatIntervalMs, + }); + socket.on('close', () => opts.registry.remove(conn.id)); + }); + return { wss, hub }; +} diff --git a/packages/kap-server/src/transport/ws/v3/sessionLane.ts b/packages/kap-server/src/transport/ws/v3/sessionLane.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bfd0049b7720bbd5699d94c34f3abc40eb9e2d3 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/sessionLane.ts @@ -0,0 +1,158 @@ +import type { IDisposable } from '@moonshot-ai/agent-core-v2'; + +import { ErrorCode } from '../../../protocol/error-codes'; +import type { ServerMessage } from '../../../protocol/messages'; +import { + passesSubscriptionFilter, + type SubscriptionFilter, + type WsConnectionV3, +} from './wsConnectionV3'; +import type { WsV3Logger, WsV3Projection, WsV3SessionLifecycle } from './wsV3Deps'; + +export interface LaneSubscriber { + readonly conn: WsConnectionV3; + readonly filter: SubscriptionFilter; + recoveryPending: boolean; +} + +export interface SessionLaneDeps { + readonly projection: WsV3Projection; + readonly lifecycle: WsV3SessionLifecycle; + readonly onEmpty: (lane: SessionLane) => void; + readonly logger?: WsV3Logger; +} + +export class SessionLane { + private readonly subscribers = new Set(); + private queue: Promise = Promise.resolve(); + private attachDisposable?: IDisposable; + private disposed = false; + + constructor( + readonly sessionId: string, + private readonly deps: SessionLaneDeps, + ) {} + + get subscriberCount(): number { + return this.subscribers.size; + } + + addSubscriber(sub: LaneSubscriber, requestId: number): void { + this.subscribers.add(sub); + this.enqueue(async () => { + if (this.disposed || !this.subscribers.has(sub)) return; + try { + const exists = await this.deps.lifecycle.sessionExists(this.sessionId); + if (!exists) { + this.subscribers.delete(sub); + sub.conn.untrackSubscription(this.sessionId); + sub.conn.enqueue({ + type: 'ack', + id: requestId, + code: ErrorCode.SESSION_NOT_FOUND, + msg: `session ${this.sessionId} does not exist`, + }); + this.disposeIfEmpty(); + return; + } + this.ensureAttached(); + sub.conn.enqueue({ type: 'ack', id: requestId, code: ErrorCode.SUCCESS }); + if (this.attachDisposable !== undefined) { + for (const message of this.deps.projection.recoveryMessages(this.sessionId)) { + if (passesSubscriptionFilter(sub.filter, message)) sub.conn.enqueue(message); + } + } + } catch (error) { + this.deps.logger?.warn( + { + sessionId: this.sessionId, + err: error instanceof Error ? error.message : String(error), + }, + 'ws v3: subscription recovery failed, subscriber keeps live traffic without recovery', + ); + sub.conn.enqueue({ + type: 'ack', + id: requestId, + code: ErrorCode.INTERNAL_ERROR, + msg: 'subscription recovery failed', + }); + } finally { + sub.recoveryPending = false; + } + }); + } + + removeSubscriber(sub: LaneSubscriber): void { + if (!this.subscribers.delete(sub)) return; + this.disposeIfEmpty(); + } + + notifySessionLive(): void { + this.enqueue(() => { + if (this.disposed) return; + try { + this.attachDisposable?.dispose(); + this.attachDisposable = undefined; + this.ensureAttached(); + if (this.attachDisposable === undefined) return; + const recovery = this.deps.projection.recoveryMessages(this.sessionId); + for (const sub of this.subscribers) { + for (const message of recovery) { + if (passesSubscriptionFilter(sub.filter, message)) sub.conn.enqueue(message); + } + } + } catch (error) { + this.deps.logger?.warn( + { + sessionId: this.sessionId, + err: error instanceof Error ? error.message : String(error), + }, + 'ws v3: session-live recovery failed, subscribers keep live traffic without recovery', + ); + } finally { + for (const sub of this.subscribers) sub.recoveryPending = false; + } + }); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.attachDisposable?.dispose(); + this.attachDisposable = undefined; + this.subscribers.clear(); + } + + private ensureAttached(): void { + if (this.attachDisposable !== undefined) return; + this.attachDisposable = this.deps.projection.onMessage(this.sessionId, (message) => { + this.enqueue(() => this.fanout(message)); + }); + } + + private fanout(message: ServerMessage): void { + if (this.disposed) return; + for (const sub of this.subscribers) { + if (sub.recoveryPending) continue; + if (!passesSubscriptionFilter(sub.filter, message)) continue; + sub.conn.enqueue(message); + } + } + + private disposeIfEmpty(): void { + if (this.subscribers.size > 0) return; + this.deps.onEmpty(this); + } + + private enqueue(task: () => Promise | void): void { + this.queue = this.queue.then(task).catch((error) => { + this.deps.logger?.warn( + { + sessionId: this.sessionId, + err: error instanceof Error ? error.message : String(error), + }, + 'ws v3: session lane task failed, lane continues', + ); + }); + } +} diff --git a/packages/kap-server/src/transport/ws/v3/wsConnectionV3.ts b/packages/kap-server/src/transport/ws/v3/wsConnectionV3.ts new file mode 100644 index 0000000000000000000000000000000000000000..14bb3e8fadc580e87a858cb59586bf5ef5d16d21 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/wsConnectionV3.ts @@ -0,0 +1,314 @@ +import { ulid } from 'ulid'; +import type { RawData, WebSocket } from 'ws'; + +import { ErrorCode } from '../../../protocol/error-codes'; +import { + clientMessageSchema, + serverMessageSchema, + type ServerMessage, + type SubscribeMessage, +} from '../../../protocol/messages'; +import type { IConnectionRegistry } from '../connectionRegistry'; +import type { LaneSubscriber } from './sessionLane'; +import type { WsV3Logger } from './wsV3Deps'; +import type { WsV3Hub } from './wsV3Hub'; + +export const V3_PROTOCOL_VERSION = '3'; +export const V3_CAPABILITIES: readonly string[] = ['step_replay_v1']; + +const DEFAULT_MAX_OUTBOUND_MESSAGES = 1000; +const DEFAULT_HIGH_WATER_MARK_BYTES = 1 << 20; +const DEFAULT_BACKPRESSURE_RETRY_MS = 5; +const DEFAULT_STALL_TIMEOUT_MS = 30_000; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000; +const HEARTBEAT_MISS_LIMIT = 2; + +export interface SubscriptionFilter { + readonly agentIds?: ReadonlySet; + readonly omit: ReadonlySet; +} + +export function makeSubscriptionFilter(frame: SubscribeMessage): SubscriptionFilter { + return { + agentIds: frame.agent_ids === undefined ? undefined : new Set(frame.agent_ids), + omit: new Set(frame.omit ?? []), + }; +} + +export function passesSubscriptionFilter( + filter: SubscriptionFilter, + message: ServerMessage, +): boolean { + if (filter.omit.has(message.type)) return false; + if ( + filter.agentIds !== undefined && + 'agent_id' in message && + !filter.agentIds.has(message.agent_id) + ) { + return false; + } + return true; +} + +export interface WsConnectionV3Options { + readonly socket: WebSocket; + readonly hub: WsV3Hub; + readonly connectionRegistry?: IConnectionRegistry; + readonly remoteAddress: string | null; + readonly userAgent: string | null; + readonly serverId: string; + readonly logger?: WsV3Logger; + readonly maxOutboundMessages?: number; + readonly highWaterMarkBytes?: number; + readonly backpressureRetryMs?: number; + readonly stallTimeoutMs?: number; + readonly heartbeatIntervalMs?: number; +} + +export class WsConnectionV3 { + readonly id: string; + readonly connectedAt: string; + readonly remoteAddress: string | null; + readonly userAgent: string | null; + + private readonly socket: WebSocket; + private readonly hub: WsV3Hub; + private readonly logger?: WsV3Logger; + private readonly maxOutboundMessages: number; + private readonly highWaterMarkBytes: number; + private readonly backpressureRetryMs: number; + private readonly stallTimeoutMs: number; + private readonly heartbeatIntervalMs: number; + + private readonly subscriptions = new Map(); + private outbound: string[] = []; + private drainTimer?: ReturnType; + private heartbeatTimer?: ReturnType; + private missedPongs = 0; + private stallSince?: number; + private closed = false; + + constructor(opts: WsConnectionV3Options) { + this.id = `conn_${ulid()}`; + this.connectedAt = new Date().toISOString(); + this.remoteAddress = opts.remoteAddress; + this.userAgent = opts.userAgent; + this.socket = opts.socket; + this.hub = opts.hub; + this.logger = opts.logger; + this.maxOutboundMessages = opts.maxOutboundMessages ?? DEFAULT_MAX_OUTBOUND_MESSAGES; + this.highWaterMarkBytes = opts.highWaterMarkBytes ?? DEFAULT_HIGH_WATER_MARK_BYTES; + this.backpressureRetryMs = opts.backpressureRetryMs ?? DEFAULT_BACKPRESSURE_RETRY_MS; + this.stallTimeoutMs = opts.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS; + this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; + + this.socket.on('message', (data: RawData) => this.onRaw(data)); + this.socket.on('pong', () => { + this.missedPongs = 0; + }); + this.socket.on('close', () => this.onClose()); + this.socket.on('error', () => this.onClose()); + + opts.connectionRegistry?.add(this); + this.hub.addConnection(this); + this.sendImmediate({ + type: 'hello', + protocol_version: V3_PROTOCOL_VERSION, + server_id: opts.serverId, + capabilities: [...V3_CAPABILITIES], + }); + this.heartbeatTimer = setInterval(() => this.onHeartbeat(), this.heartbeatIntervalMs); + this.heartbeatTimer.unref?.(); + } + + get hasClientHello(): boolean { + return true; + } + + get subscriptionSessionIds(): readonly string[] { + return Array.from(this.subscriptions.keys()).toSorted(); + } + + trackSubscription(sessionId: string, sub: LaneSubscriber): void { + this.subscriptions.set(sessionId, sub); + } + + untrackSubscription(sessionId: string): void { + this.subscriptions.delete(sessionId); + } + + subscriptionFor(sessionId: string): LaneSubscriber | undefined { + return this.subscriptions.get(sessionId); + } + + enqueue(message: ServerMessage): void { + if (this.closed) return; + const validated = this.validateOutbound(message); + if (validated === undefined) return; + if (this.outbound.length >= this.maxOutboundMessages) { + this.overflow(); + return; + } + this.outbound.push(JSON.stringify(validated)); + this.drain(); + } + + close(code = 1000, reason?: string): void { + if (this.closed) return; + try { + this.socket.close(code, reason); + } catch { + this.onClose(); + } + } + + private onRaw(data: RawData): void { + if (this.closed) return; + let parsed: unknown; + try { + parsed = JSON.parse(rawDataToString(data)); + } catch { + this.enqueue({ + type: 'error', + code: ErrorCode.REQUEST_MALFORMED, + msg: 'frame is not valid JSON', + }); + return; + } + const result = clientMessageSchema.safeParse(parsed); + if (!result.success) { + const type = (parsed as { readonly type?: unknown } | null)?.type; + this.enqueue({ + type: 'error', + code: ErrorCode.VALIDATION_FAILED, + msg: + typeof type === 'string' + ? `unknown or invalid frame type: ${type}` + : 'frame failed client message validation', + }); + return; + } + const frame = result.data; + if (frame.type === 'subscribe') { + this.hub.subscribeSession(this, frame); + } else { + this.hub.unsubscribeSession(this, frame.session_id, frame.id); + } + } + + private onHeartbeat(): void { + this.missedPongs += 1; + if (this.missedPongs >= HEARTBEAT_MISS_LIMIT) { + this.logger?.warn( + { connId: this.id, remoteAddress: this.remoteAddress }, + 'ws v3: heartbeat timeout, terminating connection', + ); + try { + this.socket.terminate(); + } catch { + this.onClose(); + } + return; + } + try { + this.socket.ping(); + } catch { + } + } + + private overflow(): void { + if (this.closed) return; + this.logger?.warn( + { connId: this.id, remoteAddress: this.remoteAddress, queued: this.outbound.length }, + 'ws v3: outbound queue overflow, closing slow consumer', + ); + this.sendImmediate({ + type: 'error', + code: ErrorCode.WS_SLOW_CONSUMER, + msg: 'outbound queue overflow: slow consumer', + }); + this.outbound = []; + this.close(1008, 'slow consumer'); + } + + private drain(): void { + if (this.drainTimer !== undefined) { + clearTimeout(this.drainTimer); + this.drainTimer = undefined; + } + if (this.closed || this.socket.readyState !== this.socket.OPEN) { + this.outbound = []; + return; + } + while (this.outbound.length > 0) { + if (this.socket.bufferedAmount > this.highWaterMarkBytes) { + this.deferDrain(); + return; + } + const frame = this.outbound.shift(); + if (frame === undefined) break; + try { + this.socket.send(frame); + } catch { + } + } + this.stallSince = undefined; + } + + private deferDrain(): void { + const now = Date.now(); + this.stallSince ??= now; + if (now - this.stallSince >= this.stallTimeoutMs) { + this.overflow(); + return; + } + this.drainTimer = setTimeout(() => { + this.drainTimer = undefined; + this.drain(); + }, this.backpressureRetryMs); + this.drainTimer.unref?.(); + } + + private sendImmediate(message: ServerMessage): void { + if (this.closed || this.socket.readyState !== this.socket.OPEN) return; + const validated = this.validateOutbound(message); + if (validated === undefined) return; + try { + this.socket.send(JSON.stringify(validated)); + } catch { + } + } + + private validateOutbound(message: ServerMessage): ServerMessage | undefined { + const parsed = serverMessageSchema.safeParse(message); + if (parsed.success) return parsed.data; + this.logger?.warn( + { + connId: this.id, + type: (message as { readonly type?: unknown }).type, + issues: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }, + 'ws v3: outbound message failed schema validation, dropped', + ); + return undefined; + } + + private onClose(): void { + if (this.closed) return; + this.closed = true; + if (this.drainTimer !== undefined) clearTimeout(this.drainTimer); + if (this.heartbeatTimer !== undefined) clearInterval(this.heartbeatTimer); + this.outbound = []; + this.hub.dropConnection(this); + } +} + +function rawDataToString(data: RawData): string { + if (typeof data === 'string') return data; + if (Buffer.isBuffer(data)) return data.toString('utf8'); + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} diff --git a/packages/kap-server/src/transport/ws/v3/wsV3Deps.ts b/packages/kap-server/src/transport/ws/v3/wsV3Deps.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e4097b1753107654c1b251e315702295d575868 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/wsV3Deps.ts @@ -0,0 +1,32 @@ +import type { IDisposable, Workspace } from '@moonshot-ai/agent-core-v2'; + +import type { ServerMessage, WorkspaceInfo } from '../../../protocol/messages'; + +export interface WsV3Logger { + warn(obj: unknown, msg: string): void; +} + +export interface WsV3Projection { + onMessage( + sessionId: string, + listener: (message: ServerMessage) => void, + ): IDisposable | undefined; + recoveryMessages(sessionId: string): ServerMessage[]; +} + +export interface WsV3SessionLifecycle { + onDidCreateSession(listener: (event: { readonly sessionId: string }) => void): IDisposable; + sessionExists(sessionId: string): Promise; +} + +export interface WsV3CoreEvent { + readonly type: string; + readonly payload?: unknown; +} + +export interface WsV3GlobalSource { + subscribe(listener: (event: WsV3CoreEvent) => void): IDisposable; + listWorkspaces(): Promise; + workspaceInfo(workspace: Workspace): Promise; + sessionInfo(sessionId: string): Promise; +} diff --git a/packages/kap-server/src/transport/ws/v3/wsV3Hub.ts b/packages/kap-server/src/transport/ws/v3/wsV3Hub.ts new file mode 100644 index 0000000000000000000000000000000000000000..42f648a455809d79909c28e4154fe0eeb999e3eb --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/wsV3Hub.ts @@ -0,0 +1,101 @@ +import type { IDisposable } from '@moonshot-ai/agent-core-v2'; + +import { ErrorCode } from '../../../protocol/error-codes'; +import type { ServerMessage, SubscribeMessage } from '../../../protocol/messages'; +import { GlobalMessageTranslator } from './globalTranslator'; +import { SessionLane, type LaneSubscriber } from './sessionLane'; +import { makeSubscriptionFilter, type WsConnectionV3 } from './wsConnectionV3'; +import type { + WsV3GlobalSource, + WsV3Logger, + WsV3Projection, + WsV3SessionLifecycle, +} from './wsV3Deps'; + +export interface WsV3HubDeps { + readonly projection: WsV3Projection; + readonly lifecycle: WsV3SessionLifecycle; + readonly globalSource: WsV3GlobalSource; + readonly logger?: WsV3Logger; +} + +export class WsV3Hub { + private readonly lanes = new Map(); + private readonly connections = new Set(); + private readonly translator: GlobalMessageTranslator; + private readonly lifecycleDisposable: IDisposable; + + constructor(private readonly deps: WsV3HubDeps) { + this.translator = new GlobalMessageTranslator( + deps.globalSource, + (message) => this.broadcastGlobal(message), + deps.logger, + ); + this.lifecycleDisposable = deps.lifecycle.onDidCreateSession((event) => { + this.lanes.get(event.sessionId)?.notifySessionLive(); + }); + } + + addConnection(conn: WsConnectionV3): void { + this.connections.add(conn); + } + + subscribeSession(conn: WsConnectionV3, frame: SubscribeMessage): void { + const lane = this.laneFor(frame.session_id); + const previous = conn.subscriptionFor(frame.session_id); + const sub: LaneSubscriber = { + conn, + filter: makeSubscriptionFilter(frame), + recoveryPending: true, + }; + conn.trackSubscription(frame.session_id, sub); + lane.addSubscriber(sub, frame.id); + if (previous !== undefined) lane.removeSubscriber(previous); + } + + unsubscribeSession(conn: WsConnectionV3, sessionId: string, requestId: number): void { + const sub = conn.subscriptionFor(sessionId); + if (sub !== undefined) { + conn.untrackSubscription(sessionId); + this.lanes.get(sessionId)?.removeSubscriber(sub); + } + conn.enqueue({ type: 'ack', id: requestId, code: ErrorCode.SUCCESS }); + } + + dropConnection(conn: WsConnectionV3): void { + this.connections.delete(conn); + for (const sessionId of conn.subscriptionSessionIds) { + const sub = conn.subscriptionFor(sessionId); + if (sub !== undefined) this.lanes.get(sessionId)?.removeSubscriber(sub); + conn.untrackSubscription(sessionId); + } + } + + dispose(): void { + this.lifecycleDisposable.dispose(); + this.translator.dispose(); + for (const lane of this.lanes.values()) lane.dispose(); + this.lanes.clear(); + this.connections.clear(); + } + + private broadcastGlobal(message: ServerMessage): void { + for (const conn of this.connections) conn.enqueue(message); + } + + private laneFor(sessionId: string): SessionLane { + const existing = this.lanes.get(sessionId); + if (existing !== undefined) return existing; + const lane = new SessionLane(sessionId, { + projection: this.deps.projection, + lifecycle: this.deps.lifecycle, + onEmpty: (empty) => { + if (this.lanes.get(empty.sessionId) === empty) this.lanes.delete(empty.sessionId); + empty.dispose(); + }, + logger: this.deps.logger, + }); + this.lanes.set(sessionId, lane); + return lane; + } +} diff --git a/packages/kap-server/src/version.ts b/packages/kap-server/src/version.ts new file mode 100644 index 0000000000000000000000000000000000000000..6395495be1a8155b4c6151f051c33bb6a8a547bf --- /dev/null +++ b/packages/kap-server/src/version.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +let cached: string | undefined; + +export function getServerVersion(): string { + if (cached !== undefined) return cached; + try { + const pkgUrl = new URL('../package.json', import.meta.url); + const raw = readFileSync(fileURLToPath(pkgUrl), 'utf-8'); + const pkg = JSON.parse(raw) as { version?: unknown }; + cached = typeof pkg.version === 'string' ? pkg.version : '0.0.0'; + } catch { + cached = '0.0.0'; + } + return cached; +} diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..0181975705d4499c9f2b8112a93242cebc3ed96c --- /dev/null +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -0,0 +1,569 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`API surface snapshot > matches the documented v2 route table and meta endpoints 1`] = ` +{ + "meta": [ + [ + "GET", + "/", + 404, + ], + [ + "GET", + "/asyncapi.json", + 200, + ], + [ + "GET", + "/openapi.json", + 200, + ], + ], + "routes": [ + [ + "DELETE", + "/api/v1/files/{file_id}", + ], + [ + "DELETE", + "/api/v1/oauth/login", + ], + [ + "DELETE", + "/api/v1/providers/{provider_id}", + ], + [ + "DELETE", + "/api/v1/workspaces/{workspace_id}", + ], + [ + "DELETE", + "/api/v2/mcp/servers/{name}", + ], + [ + "GET", + "/api/v1/auth", + ], + [ + "GET", + "/api/v1/capabilities", + ], + [ + "GET", + "/api/v1/capabilities/{capability_id}", + ], + [ + "GET", + "/api/v1/catalog/providers", + ], + [ + "GET", + "/api/v1/catalog/providers/{catalog_id}", + ], + [ + "GET", + "/api/v1/config", + ], + [ + "GET", + "/api/v1/connections", + ], + [ + "GET", + "/api/v1/debug/{service}/{method}", + ], + [ + "GET", + "/api/v1/debug/channels", + ], + [ + "GET", + "/api/v1/debug/session/{session_id}/{service}/{method}", + ], + [ + "GET", + "/api/v1/debug/session/{session_id}/agent/{agent_id}/{service}/{method}", + ], + [ + "GET", + "/api/v1/debug/session/{session_id}/agent/{agent_id}/runtime-binding", + ], + [ + "GET", + "/api/v1/debug/session/{session_id}/association", + ], + [ + "GET", + "/api/v1/debug/workspace/{workspace_id}/snapshot", + ], + [ + "GET", + "/api/v1/debug/workspaces", + ], + [ + "GET", + "/api/v1/files/{file_id}", + ], + [ + "GET", + "/api/v1/fs:browse", + ], + [ + "GET", + "/api/v1/fs:content", + ], + [ + "GET", + "/api/v1/fs:home", + ], + [ + "GET", + "/api/v1/gui/store/getItem", + ], + [ + "GET", + "/api/v1/gui/store/length", + ], + [ + "GET", + "/api/v1/healthz", + ], + [ + "GET", + "/api/v1/mcp/servers", + ], + [ + "GET", + "/api/v1/meta", + ], + [ + "GET", + "/api/v1/models", + ], + [ + "GET", + "/api/v1/oauth/login", + ], + [ + "GET", + "/api/v1/oauth/region", + ], + [ + "GET", + "/api/v1/oauth/usage", + ], + [ + "GET", + "/api/v1/oauth/userinfo", + ], + [ + "GET", + "/api/v1/plugins", + ], + [ + "GET", + "/api/v1/plugins/marketplace", + ], + [ + "GET", + "/api/v1/providers", + ], + [ + "GET", + "/api/v1/providers/{provider_id}", + ], + [ + "GET", + "/api/v1/remote-control", + ], + [ + "GET", + "/api/v1/sessions", + ], + [ + "GET", + "/api/v1/sessions/{session_id}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/approvals", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/children", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/file-history/changes", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/file-history/content", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/fs/{*}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/goal", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/history", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/media/{file_id}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/messages", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/messages/{message_id}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/profile", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/prompts", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/questions", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/runtime", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/skills", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/snapshot", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/status", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/tasks", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/tasks/{task_id}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/terminals", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/terminals/{terminal_id}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/transcript", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/transcript/ops", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/transcript/plan", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/transcript/user-messages", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/warnings", + ], + [ + "GET", + "/api/v1/tools", + ], + [ + "GET", + "/api/v1/workspaces", + ], + [ + "GET", + "/api/v1/workspaces/{workspace_id}/skills", + ], + [ + "GET", + "/api/v1/workspaces/{workspace_id}/trust", + ], + [ + "GET", + "/api/v2/mcp/auth-statuses", + ], + [ + "GET", + "/api/v2/mcp/servers", + ], + [ + "GET", + "/api/v2/mcp/servers/{name}", + ], + [ + "GET", + "/api/v2/sessions", + ], + [ + "GET", + "/asyncapi.json", + ], + [ + "GET", + "/openapi.json", + ], + [ + "PATCH", + "/api/v1/workspaces/{workspace_id}", + ], + [ + "POST", + "/api/v1/capabilities/{tail}", + ], + [ + "POST", + "/api/v1/config", + ], + [ + "POST", + "/api/v1/debug/{service}/{method}", + ], + [ + "POST", + "/api/v1/debug/session/{session_id}/{service}/{method}", + ], + [ + "POST", + "/api/v1/debug/session/{session_id}/agent/{agent_id}/{service}/{method}", + ], + [ + "POST", + "/api/v1/files", + ], + [ + "POST", + "/api/v1/fs:mkdir", + ], + [ + "POST", + "/api/v1/fs:suggest", + ], + [ + "POST", + "/api/v1/gui/store/clear", + ], + [ + "POST", + "/api/v1/gui/store/removeItem", + ], + [ + "POST", + "/api/v1/gui/store/setItem", + ], + [ + "POST", + "/api/v1/mcp/servers/{tail}", + ], + [ + "POST", + "/api/v1/models/{tail}", + ], + [ + "POST", + "/api/v1/oauth/login", + ], + [ + "POST", + "/api/v1/oauth/logout", + ], + [ + "POST", + "/api/v1/plugins", + ], + [ + "POST", + "/api/v1/plugins/{tail}", + ], + [ + "POST", + "/api/v1/providers", + ], + [ + "POST", + "/api/v1/providers{action}", + ], + [ + "POST", + "/api/v1/providers/{tail}", + ], + [ + "POST", + "/api/v1/remote-control", + ], + [ + "POST", + "/api/v1/search", + ], + [ + "POST", + "/api/v1/sessions", + ], + [ + "POST", + "/api/v1/sessions/{session_id}:archive", + ], + [ + "POST", + "/api/v1/sessions/{session_id}:delete", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/approvals/{approval_id}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/children", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/export", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/profile", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/prompts", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/prompts:steer", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/prompts/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/questions/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/runtime", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/skills/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/tasks/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/terminals", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/terminals/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/title/generate", + ], + [ + "POST", + "/api/v1/shutdown", + ], + [ + "POST", + "/api/v1/workspace/fs:search", + ], + [ + "POST", + "/api/v1/workspace/fs:suggest", + ], + [ + "POST", + "/api/v1/workspaces", + ], + [ + "POST", + "/api/v1/workspaces/{workspace_id}/add-dir", + ], + [ + "POST", + "/api/v1/workspaces/{workspace_id}/trust", + ], + [ + "POST", + "/api/v1/workspaces/{workspace_id}/untrust", + ], + [ + "POST", + "/api/v2/mcp/auth:begin", + ], + [ + "POST", + "/api/v2/mcp/auth:cancel", + ], + [ + "POST", + "/api/v2/mcp/auth:complete", + ], + [ + "POST", + "/api/v2/mcp/auth:reset", + ], + [ + "POST", + "/api/v2/mcp/servers", + ], + [ + "POST", + "/api/v2/mcp/servers:inspect", + ], + [ + "POST", + "/api/v2/mcp/servers:test", + ], + [ + "POST", + "/api/v2/sessions:archive", + ], + [ + "POST", + "/api/v2/sessions:restore", + ], + [ + "PUT", + "/api/v1/providers/{provider_id}", + ], + [ + "PUT", + "/api/v2/mcp/servers/{name}", + ], + ], +} +`; diff --git a/packages/kap-server/test/apiSurface.snapshot.test.ts b/packages/kap-server/test/apiSurface.snapshot.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f0688c168d97ffc30735b9970ad62c560824f15 --- /dev/null +++ b/packages/kap-server/test/apiSurface.snapshot.test.ts @@ -0,0 +1,84 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { startServer, type RunningServer } from '../src'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +const HTTP_METHODS = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +]); + +const META_ENDPOINTS = ['/openapi.json', '/asyncapi.json', '/']; + +describe('API surface snapshot', () => { + let home: string | undefined; + let server: RunningServer | undefined; + + beforeAll(async () => { + home = mkdtempSync(join(tmpdir(), 'kimi-server-v2-api-surface-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + debugEndpoints: true, + }); + }); + + afterAll(async () => { + if (server !== undefined) { + try { + await server.close(); + } catch { + } + server = undefined; + } + if (home !== undefined) { + rmSync(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it('matches the documented v2 route table and meta endpoints', async () => { + const base = `http://${server!.host}:${server!.port}`; + + const openApiRes = await fetch(`${base}/openapi.json`, { headers: authHeaders(server as RunningServer) } as never); + expect(openApiRes.status).toBe(200); + const openApi = (await openApiRes.json()) as { + paths?: Record>; + }; + const paths = openApi.paths ?? {}; + expect(Object.keys(paths).length).toBeGreaterThan(0); + + const routes: Array<[string, string]> = []; + for (const [path, item] of Object.entries(paths)) { + for (const key of Object.keys(item)) { + if (HTTP_METHODS.has(key.toLowerCase())) { + routes.push([key.toUpperCase(), path]); + } + } + } + routes.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])); + + const meta: Array<[string, string, number]> = []; + for (const endpoint of META_ENDPOINTS) { + const res = await fetch(`${base}${endpoint}`, { headers: authHeaders(server as RunningServer) } as never); + meta.push(['GET', endpoint, res.status]); + } + meta.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]) || a[2] - b[2]); + + expect({ routes, meta }).toMatchSnapshot(); + }); +}); diff --git a/packages/kap-server/test/approvals.test.ts b/packages/kap-server/test/approvals.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6937230eea0f48c93fbe4b5215556e978d24ac2 --- /dev/null +++ b/packages/kap-server/test/approvals.test.ts @@ -0,0 +1,199 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { randomUUID } from 'node:crypto'; + +import { ensureMainAgent, getLiveSessionById, interactions } from '@moonshot-ai/agent-core-v2'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: { path: string; message: string }[]; +} + +interface ApprovalWire { + approval_id: string; + session_id: string; + turn_id?: number; + tool_call_id: string; + tool_name: string; + action: string; + tool_input_display: unknown; + created_at: string; + expires_at: string; +} + +interface ListWire { + items: ApprovalWire[]; +} + +interface ResolveWire { + resolved: true; + resolved_at: string; +} + +describe('server-v2 /api/v1/sessions/{sid}/approvals', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-approvals-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const hasBody = body !== undefined; + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: authHeaders( + server as RunningServer, + hasBody ? { 'content-type': 'application/json' } : {}, + ), + body: hasBody ? JSON.stringify(body) : undefined, + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function createSession(): Promise { + const { body } = await postJson<{ id: string }>('/api/v1/sessions', { + metadata: { cwd: home as string }, + }); + expect(body.code).toBe(0); + const handle = getLiveSessionById(server!.core.accessor, body.data.id); + expect(handle).toBeDefined(); + await ensureMainAgent(handle!); + return body.data.id; + } + + function enqueueApproval(sessionId: string, toolCallId: string): string { + const handle = getLiveSessionById(server!.core.accessor, sessionId); + expect(handle).toBeDefined(); + const parked = interactions.enqueue({ + id: `approval_${randomUUID()}`, + kind: 'approval', + payload: { + toolCallId, + toolName: 'Bash', + action: 'run', + display: { kind: 'command', command: 'echo hi' }, + }, + tags: { agentId: 'main', sessionId, toolCallId }, + }); + return parked.id; + } + + it('lists a pending approval projected onto the wire shape', async () => { + const sid = await createSession(); + const aid = enqueueApproval(sid, 'tc-1'); + + const { body } = await getJson(`/api/v1/sessions/${sid}/approvals?status=pending`); + expect(body.code).toBe(0); + expect(body.data.items).toHaveLength(1); + const item = body.data.items[0]!; + expect(item.approval_id).toBe(aid); + expect(item.session_id).toBe(sid); + expect(item.tool_call_id).toBe('tc-1'); + expect(item.tool_name).toBe('Bash'); + expect(item.action).toBe('run'); + expect(item.tool_input_display).toEqual({ kind: 'command', command: 'echo hi' }); + expect(Number.isNaN(Date.parse(item.created_at))).toBe(false); + expect(Number.isNaN(Date.parse(item.expires_at))).toBe(false); + }); + + it('resolves a pending approval', async () => { + const sid = await createSession(); + const aid = enqueueApproval(sid, 'tc-2'); + + const { body } = await postJson(`/api/v1/sessions/${sid}/approvals/${aid}`, { + decision: 'approved', + }); + expect(body.code).toBe(0); + expect(body.data.resolved).toBe(true); + expect(Number.isNaN(Date.parse(body.data.resolved_at))).toBe(false); + + const listed = await getJson(`/api/v1/sessions/${sid}/approvals?status=pending`); + expect(listed.body.data.items).toHaveLength(0); + }); + + it('returns 40902 on a duplicate resolve (recently-resolved window)', async () => { + const sid = await createSession(); + const aid = enqueueApproval(sid, 'tc-3'); + await postJson(`/api/v1/sessions/${sid}/approvals/${aid}`, { + decision: 'approved', + }); + + const dup = await postJson<{ resolved: false }>(`/api/v1/sessions/${sid}/approvals/${aid}`, { + decision: 'approved', + }); + expect(dup.body.code).toBe(40902); + expect(dup.body.data).toEqual({ resolved: false }); + }); + + it('returns 40404 for an unknown approval id', async () => { + const sid = await createSession(); + const { body } = await postJson(`/api/v1/sessions/${sid}/approvals/nope`, { + decision: 'rejected', + }); + expect(body.code).toBe(40404); + }); + + it('mints distinct approval ids when the provider reuses a tool_call id', async () => { + const sid = await createSession(); + const first = enqueueApproval(sid, 'Bash_0'); + const second = enqueueApproval(sid, 'Bash_0'); + expect(first).not.toBe(second); + + const { body } = await getJson(`/api/v1/sessions/${sid}/approvals?status=pending`); + expect(body.data.items.map((i) => i.approval_id).sort()).toEqual([first, second].sort()); + expect(body.data.items.every((i) => i.tool_call_id === 'Bash_0')).toBe(true); + + for (const aid of [first, second]) { + const resolved = await postJson(`/api/v1/sessions/${sid}/approvals/${aid}`, { + decision: 'approved', + }); + expect(resolved.body.code).toBe(0); + } + }); + + it('returns 40401 for an unknown session', async () => { + const { body } = await getJson('/api/v1/sessions/nope/approvals?status=pending'); + expect(body.code).toBe(40401); + }); +}); diff --git a/packages/kap-server/test/auth.test.ts b/packages/kap-server/test/auth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed7897b11a522177db9acf753d246f2ea2f3158d --- /dev/null +++ b/packages/kap-server/test/auth.test.ts @@ -0,0 +1,173 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { IConfigService } from '@moonshot-ai/agent-core-v2'; +import { authSummarySchema, type AuthSummary } from '@moonshot-ai/agent-core-v2/app/authLegacy/authLegacy'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authedFetch } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +describe('server-v2 GET /api/v1/auth', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-auth-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as never); + home = undefined; + } + }); + + async function boot(toml?: string): Promise { + await writeFile(join(home as string, 'config.toml'), toml ?? '', 'utf-8'); + await (server as RunningServer).core.accessor.get(IConfigService).reload(); + } + + async function getAuth(): Promise { + const res = await authedFetch(server as RunningServer, base, '/api/v1/auth'); + expect(res.status).toBe(200); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(0); + return authSummarySchema.parse(body.data); + } + + it('returns models_ready=false with an empty snapshot on empty config', async () => { + await boot(); + expect(await getAuth()).toEqual({ + models_ready: false, + providers_count: 0, + managed_provider: null, + }); + }); + + it('returns models_ready=true when the default model resolves to a configured provider', async () => { + await boot( + [ + 'default_model = "x"', + '', + '[providers.x]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.x]', + 'provider = "x"', + 'model = "x"', + 'max_context_size = 1000', + '', + ].join('\n'), + ); + expect(await getAuth()).toEqual({ + models_ready: true, + providers_count: 1, + managed_provider: null, + }); + }); + + it('returns models_ready=false when a provider exists but default_model is missing', async () => { + await boot( + [ + '[providers.x]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.x]', + 'provider = "x"', + 'model = "x"', + 'max_context_size = 1000', + '', + ].join('\n'), + ); + const summary = await getAuth(); + expect(summary.models_ready).toBe(false); + expect(summary.providers_count).toBe(1); + expect(summary.managed_provider).toBeNull(); + }); + + it('returns models_ready=false when the default model dangles', async () => { + await boot( + [ + 'default_model = "gone"', + '', + '[providers.x]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.x]', + 'provider = "x"', + 'model = "x"', + 'max_context_size = 1000', + '', + ].join('\n'), + ); + const summary = await getAuth(); + expect(summary.models_ready).toBe(false); + expect(summary.providers_count).toBe(1); + }); + + it('returns models_ready=true for a providerless flat default model', async () => { + await boot( + [ + 'default_model = "flat"', + '', + '[models.flat]', + 'base_url = "https://example.test/v1"', + 'model = "x"', + 'protocol = "openai"', + 'max_context_size = 1000', + 'api_key = "sk-test"', + '', + ].join('\n'), + ); + const summary = await getAuth(); + expect(summary.models_ready).toBe(true); + expect(summary.providers_count).toBe(0); + }); + + it('surfaces managed_provider.unauthenticated without a cached token', async () => { + await boot( + [ + '[providers."managed:kimi-code"]', + 'type = "kimi"', + 'base_url = "https://example.test/v1"', + '', + '[providers."managed:kimi-code".oauth]', + 'storage = "file"', + 'key = "oauth/kimi-code"', + '', + ].join('\n'), + ); + const summary = await getAuth(); + expect(summary.managed_provider).toEqual({ + name: 'managed:kimi-code', + status: 'unauthenticated', + }); + expect(summary.models_ready).toBe(false); + }); +}); diff --git a/packages/kap-server/test/authMiddleware.test.ts b/packages/kap-server/test/authMiddleware.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4dfa20f426a40a30a1150e57807720ecf16b0dd6 --- /dev/null +++ b/packages/kap-server/test/authMiddleware.test.ts @@ -0,0 +1,69 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +describe('server-v2 /api/v1 bearer auth', () => { + let server: RunningServer | undefined; + let home: string | undefined; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-auth-middleware-')); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it('allows healthz without a token', async () => { + const res = await server!.app.inject({ method: 'GET', url: '/api/v1/healthz' }); + expect(res.statusCode).toBe(200); + }); + + it('rejects /api/v1/auth without a token with 40101', async () => { + const res = await server!.app.inject({ method: 'GET', url: '/api/v1/auth' }); + expect(res.statusCode).toBe(401); + const body = res.json() as Record; + expect(body['code']).toBe(40101); + }); + + it('rejects /api/v1/auth with a wrong token', async () => { + const res = await server!.app.inject({ + method: 'GET', + url: '/api/v1/auth', + headers: { authorization: 'Bearer wrong-token' }, + }); + expect(res.statusCode).toBe(401); + const body = res.json() as Record; + expect(body['code']).toBe(40101); + }); + + it('accepts /api/v1/auth with the persistent token', async () => { + const token = server!.authTokenService.getToken(); + const res = await server!.app.inject({ + method: 'GET', + url: '/api/v1/auth', + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.statusCode).toBe(200); + const body = res.json() as Record; + expect(body['code']).toBe(0); + }); + + it('requires auth for /openapi.json', async () => { + const res = await server!.app.inject({ method: 'GET', url: '/openapi.json' }); + expect(res.statusCode).toBe(401); + }); +}); diff --git a/packages/kap-server/test/authTokenStore.test.ts b/packages/kap-server/test/authTokenStore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c3f3bb9b9b756fe22352091a5e9ccad353099c9 --- /dev/null +++ b/packages/kap-server/test/authTokenStore.test.ts @@ -0,0 +1,227 @@ +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + PrivateFileTooPermissiveError, + readPrivateFile, + writePrivateFile, +} from '../src/services/auth/privateFiles'; +import { + loadOrCreateServerToken, + rotateServerToken, +} from '../src/services/auth/persistentToken'; +import { createTokenStore } from '../src/services/auth/tokenStore'; +import { createAuthTokenService } from '../src/services/auth/authTokenService'; +import { resolvePasswordHash, verifyPassword } from '../src/services/auth/password'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-v2-auth-token-')); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('privateFiles', () => { + it.skipIf(process.platform === 'win32')('writes a file with mode 0600', async () => { + const p = join(tmpDir, 'secret'); + await writePrivateFile(p, 'hello'); + expect(statSync(p).mode & 0o777).toBe(0o600); + }); + + it.skipIf(process.platform === 'win32')('creates an absent parent dir with mode 0700', async () => { + const p = join(tmpDir, 'nested', 'dir', 'secret'); + await writePrivateFile(p, 'hello'); + expect(statSync(join(tmpDir, 'nested', 'dir')).mode & 0o777).toBe(0o700); + }); + + it('round-trips string content through readPrivateFile', async () => { + const p = join(tmpDir, 'secret'); + await writePrivateFile(p, 's3cr3t-value'); + const buf = await readPrivateFile(p); + expect(buf.toString('utf8')).toBe('s3cr3t-value'); + }); + + it('round-trips Buffer content through readPrivateFile', async () => { + const p = join(tmpDir, 'bin'); + const data = Buffer.from([0, 1, 2, 254, 255]); + await writePrivateFile(p, data); + const buf = await readPrivateFile(p); + expect(buf.equals(data)).toBe(true); + }); + + it.skipIf(process.platform === 'win32')('readPrivateFile throws on a 0644 file', async () => { + const p = join(tmpDir, 'leaky'); + writeFileSync(p, 'x', { mode: 0o644 }); + chmodSync(p, 0o644); + await expect(readPrivateFile(p)).rejects.toThrowError(PrivateFileTooPermissiveError); + }); +}); + +describe('tokenStore', () => { + it('returns the same token from repeated getToken() calls', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + expect(store.getToken()).toBe(store.getToken()); + await store.dispose(); + }); + + it('produces different tokens for different home dirs', async () => { + const a = await createTokenStore(join(tmpDir, 'home-a')); + const b = await createTokenStore(join(tmpDir, 'home-b')); + expect(a.getToken()).not.toBe(b.getToken()); + await a.dispose(); + await b.dispose(); + }); + + it('reuses the same persistent token across stores in one home dir', async () => { + const home = join(tmpDir, 'home'); + const a = await createTokenStore(home); + const token = a.getToken(); + await a.dispose(); + const b = await createTokenStore(home); + expect(b.getToken()).toBe(token); + await b.dispose(); + }); + + it.skipIf(process.platform === 'win32')('writes the token file with mode 0600 at server.token', async () => { + const home = join(tmpDir, 'home'); + const store = await createTokenStore(home); + expect(store.tokenPath).toBe(join(home, 'server.token')); + expect(statSync(store.tokenPath).mode & 0o777).toBe(0o600); + await store.dispose(); + }); + + it('isValid accepts the token and rejects wrong / empty / same-length candidates', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + const token = store.getToken(); + expect(store.isValid(token)).toBe(true); + expect(store.isValid('wrong')).toBe(false); + expect(store.isValid('')).toBe(false); + + const other = await createTokenStore(join(tmpDir, 'home-other')); + expect(other.getToken().length).toBe(token.length); + expect(store.isValid(other.getToken())).toBe(false); + await store.dispose(); + await other.dispose(); + }); + + it('dispose() keeps the persistent token file on disk', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + expect(existsSync(store.tokenPath)).toBe(true); + await store.dispose(); + expect(existsSync(store.tokenPath)).toBe(true); + }); + + it('re-reads the token after the file is rewritten (live rotation)', async () => { + const home = join(tmpDir, 'home'); + const store = await createTokenStore(home); + const original = store.getToken(); + const rotated = 'r'.repeat(original.length); + await writePrivateFile(store.tokenPath, rotated); + + expect(store.getToken()).toBe(rotated); + expect(store.isValid(rotated)).toBe(true); + expect(store.isValid(original)).toBe(false); + await store.dispose(); + }); +}); + +describe('persistentToken', () => { + it('loadOrCreateServerToken generates once and reuses thereafter', async () => { + const home = join(tmpDir, 'home'); + const a = await loadOrCreateServerToken(home); + const b = await loadOrCreateServerToken(home); + expect(a).toBe(b); + }); + + it.skipIf(process.platform === 'win32')('writes server.token with mode 0600', async () => { + const home = join(tmpDir, 'home'); + await loadOrCreateServerToken(home); + expect(statSync(join(home, 'server.token')).mode & 0o777).toBe(0o600); + }); + + it('rotateServerToken writes a new, different token to server.token', async () => { + const home = join(tmpDir, 'home'); + const original = await loadOrCreateServerToken(home); + const rotated = await rotateServerToken(home); + expect(rotated).not.toBe(original); + expect(readFileSync(join(home, 'server.token'), 'utf8').trim()).toBe(rotated); + }); +}); + +describe('password', () => { + it('resolvePasswordHash returns undefined when env is unset or empty', async () => { + expect(await resolvePasswordHash({})).toBeUndefined(); + expect(await resolvePasswordHash({ KIMI_CODE_PASSWORD: '' })).toBeUndefined(); + }); + + it('hashes a set password with bcrypt and verifies correctly', async () => { + const passwordHash = await resolvePasswordHash({ + KIMI_CODE_PASSWORD: 'correct-horse-battery-staple', + }); + expect(passwordHash?.startsWith('$2')).toBe(true); + expect(await verifyPassword('correct-horse-battery-staple', passwordHash)).toBe(true); + expect(await verifyPassword('wrong-password', passwordHash)).toBe(false); + }); + + it('verifyPassword returns false when the hash is undefined', async () => { + expect(await verifyPassword('anything', undefined)).toBe(false); + }); +}); + +describe('createAuthTokenService', () => { + it('getToken() returns the tokenStore token', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined }); + expect(svc.getToken()).toBe(store.getToken()); + await store.dispose(); + }); + + it('isValid accepts the token', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined }); + expect(await svc.isValid(store.getToken())).toBe(true); + await store.dispose(); + }); + + it('isValid accepts the password when a hash is configured', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + const passwordHash = await resolvePasswordHash({ + KIMI_CODE_PASSWORD: 'correct horse battery staple', + }); + const svc = createAuthTokenService({ tokenStore: store, passwordHash }); + expect(await svc.isValid('correct horse battery staple')).toBe(true); + await store.dispose(); + }); + + it('isValid rejects a wrong candidate', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + const passwordHash = await resolvePasswordHash({ + KIMI_CODE_PASSWORD: 'correct horse battery staple', + }); + const svc = createAuthTokenService({ tokenStore: store, passwordHash }); + expect(await svc.isValid('wrong')).toBe(false); + await store.dispose(); + }); + + it('isValid accepts only the token when passwordHash is undefined', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined }); + expect(await svc.isValid(store.getToken())).toBe(true); + expect(await svc.isValid('any-password')).toBe(false); + await store.dispose(); + }); +}); diff --git a/packages/kap-server/test/authWiring.e2e.test.ts b/packages/kap-server/test/authWiring.e2e.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..23481144c863a2661f12ec4961114d694ac26396 --- /dev/null +++ b/packages/kap-server/test/authWiring.e2e.test.ts @@ -0,0 +1,143 @@ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { WebSocket, type RawData } from 'ws'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +function rawToString(data: RawData): string { + if (typeof data === 'string') return data; + if (Buffer.isBuffer(data)) return data.toString('utf8'); + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} + +function openConn(url: string, protocols: string[]): Promise<{ ws: WebSocket; firstFrame: unknown }> { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, protocols); + ws.once('message', (data) => { + try { + resolve({ ws, firstFrame: JSON.parse(rawToString(data)) }); + } catch { + resolve({ ws, firstFrame: null }); + } + }); + ws.once('error', reject); + }); +} + +function expectRejected(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const done = (err?: Error): void => { + clearTimeout(t); + ws.removeAllListeners(); + try { + ws.terminate(); + } catch { + } + if (err === undefined) resolve(); + else reject(err); + }; + const t = setTimeout( + () => done(new Error('connection was not rejected within timeout')), + 1500, + ); + ws.once('open', () => done(new Error('connection unexpectedly opened'))); + ws.once('error', () => done()); + ws.once('close', () => done()); + }); +} + +describe('production auth wiring', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + const sockets: WebSocket[] = []; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-auth-wiring-')); + await boot(); + }); + + async function boot(): Promise { + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + base = `http://127.0.0.1:${server.port}`; + } + + afterEach(() => { + for (const ws of sockets.splice(0)) { + try { + ws.close(); + } catch { + } + } + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it.skipIf(process.platform === 'win32')('writes a 0600 token file at boot and keeps it on close', async () => { + const p = join(home as string, 'server.token'); + const info = await stat(p); + expect(info.mode & 0o777).toBe(0o600); + const token = (await readFile(p, 'utf8')).trim(); + expect(token.length).toBeGreaterThan(0); + + await (server as RunningServer).close(); + server = undefined; + const after = await stat(p); + expect(after.mode & 0o777).toBe(0o600); + await boot(); + }); + + it('gates HTTP: 200 with the token, 401 without', async () => { + const token = (await readFile(join(home as string, 'server.token'), 'utf8')).trim(); + + const ok = await fetch(`${base}/openapi.json`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(ok.status).toBe(200); + + const bad = await fetch(`${base}/openapi.json`); + expect(bad.status).toBe(401); + const body = (await bad.json()) as { code: number }; + expect(body.code).toBe(40101); + }); + + it('gates /asyncapi.json: 200 with the token, 401 without', async () => { + const token = (await readFile(join(home as string, 'server.token'), 'utf8')).trim(); + + const ok = await fetch(`${base}/asyncapi.json`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(ok.status).toBe(200); + const doc = (await ok.json()) as { asyncapi?: string }; + expect(doc.asyncapi).toBeDefined(); + + const bad = await fetch(`${base}/asyncapi.json`); + expect(bad.status).toBe(401); + }); + + it('gates WS: server_hello with the token, rejected without', async () => { + const token = (await readFile(join(home as string, 'server.token'), 'utf8')).trim(); + const wsUrl = `ws://127.0.0.1:${(server as RunningServer).port}/api/v1/ws`; + + const { ws, firstFrame } = await openConn(wsUrl, [`kimi-code.bearer.${token}`]); + sockets.push(ws); + expect(firstFrame).toMatchObject({ type: 'server_hello' }); + + await expectRejected(wsUrl); + }); +}); diff --git a/packages/kap-server/test/bindClassify.test.ts b/packages/kap-server/test/bindClassify.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd5b9cf2fa60b916e312b8e8762a7632382f0268 --- /dev/null +++ b/packages/kap-server/test/bindClassify.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { classify } from '../src/security/bindClassify'; + +describe('classify', () => { + describe('loopback', () => { + it.each([['127.0.0.1'], ['127.255.255.255'], ['::1'], ['localhost']])( + '%s → loopback', + (host) => { + expect(classify(host)).toBe('loopback'); + }, + ); + }); + + describe('lan', () => { + it.each([ + ['192.168.1.5'], + ['10.0.0.1'], + ['172.16.0.1'], + ['172.31.255.255'], + ['169.254.1.1'], + ['fe80::1'], + ['fe80:0000:0000:0000:0000:0000:0000:0001'], + ['febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff'], + ])('%s → lan', (host) => { + expect(classify(host)).toBe('lan'); + }); + }); + + describe('public', () => { + it.each([ + ['8.8.8.8'], + ['172.32.0.1'], + ['203.0.113.5'], + ['2001:4860:4860::8888'], + ['fec0::1'], + ['example.com'], + ])('%s → public', (host) => { + expect(classify(host)).toBe('public'); + }); + }); + + describe('wildcard binds default to public unless relaxed', () => { + it('0.0.0.0 → public by default', () => { + expect(classify('0.0.0.0')).toBe('public'); + }); + + it('0.0.0.0 → lan when bindClass=lan', () => { + expect(classify('0.0.0.0', { bindClass: 'lan' })).toBe('lan'); + }); + + it('0.0.0.0 → public when bindClass=public', () => { + expect(classify('0.0.0.0', { bindClass: 'public' })).toBe('public'); + }); + + it(':: → public by default', () => { + expect(classify('::')).toBe('public'); + }); + + it(':: → lan when bindClass=lan', () => { + expect(classify('::', { bindClass: 'lan' })).toBe('lan'); + }); + + it('empty string → public by default', () => { + expect(classify('')).toBe('public'); + }); + }); + + it('bindClass override does not reclassify a concrete loopback/lan host', () => { + expect(classify('127.0.0.1', { bindClass: 'public' })).toBe('loopback'); + expect(classify('192.168.1.5', { bindClass: 'public' })).toBe('lan'); + }); +}); diff --git a/packages/kap-server/test/boot.test.ts b/packages/kap-server/test/boot.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0a7145aec4308cf55007599204bf74cd0347a2a --- /dev/null +++ b/packages/kap-server/test/boot.test.ts @@ -0,0 +1,473 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Writable } from 'node:stream'; + +import { pino } from 'pino'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + IBootstrapService, + IFileSystemStorageService, + IHostRequestHeaders, + InMemoryStorageService, + IOAuthToolkit, + ITelemetryService, + noopTelemetryService, +} from '@moonshot-ai/agent-core-v2'; + +import { listLiveServerInstances } from '../src/instanceRegistry'; +import { listenWithPortRetry, type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authedFetch } from './helpers/auth'; + +describe('server-v2 boot', () => { + let server: RunningServer | undefined; + let home: string | undefined; + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it('boots agent-core-v2 and serves the basic /api/v1 routes', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + + const base = `http://127.0.0.1:${server.port}`; + + const healthz = await fetch(`${base}/api/v1/healthz`); + expect(healthz.status).toBe(200); + const healthBody = await healthz.json() as { + code: number; + data: { ok: boolean }; + request_id: string; + }; + expect(healthBody.code).toBe(0); + expect(healthBody.data.ok).toBe(true); + expect(typeof healthBody.request_id).toBe('string'); + + const meta = await authedFetch(server, base, '/api/v1/meta'); + expect(meta.status).toBe(200); + const metaBody = await meta.json() as { + code: number; + data: { server_id: string; server_version: string; capabilities: Record }; + }; + expect(metaBody.code).toBe(0); + expect(typeof metaBody.data.server_id).toBe('string'); + expect(typeof metaBody.data.server_version).toBe('string'); + expect(metaBody.data.capabilities).toBeDefined(); + + const auth = await authedFetch(server, base, '/api/v1/auth'); + expect(auth.status).toBe(200); + const authBody = await auth.json() as { + code: number; + data: { models_ready: boolean; providers_count: number }; + }; + expect(authBody.code).toBe(0); + expect(typeof authBody.data.models_ready).toBe('boolean'); + expect(authBody.data.providers_count).toBeGreaterThanOrEqual(0); + + const oauthPoll = await authedFetch(server, base, '/api/v1/oauth/login'); + expect(oauthPoll.status).toBe(200); + const oauthBody = await oauthPoll.json() as { code: number; data: null }; + expect(oauthBody.code).toBe(0); + expect(oauthBody.data).toBeNull(); + }); + + it('reports opts.serverVersion as server_version instead of the package version', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-version-')); + server = await startServer({ + hostIdentity: { + productName: 'test-host', + version: '9.9.9-host', + platform: 'test_platform', + }, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + serverVersion: '9.9.9-host', + }); + + const base = `http://127.0.0.1:${server.port}`; + const meta = await authedFetch(server, base, '/api/v1/meta'); + const metaBody = await meta.json() as { + code: number; + data: { server_version: string }; + }; + expect(metaBody.data.server_version).toBe('9.9.9-host'); + + const [instance] = await listLiveServerInstances(home); + expect(instance?.serverVersion).toBe('9.9.9-host'); + + const defaults = server.core.accessor.get(IHostRequestHeaders); + expect(defaults.headers['User-Agent']).toBe('test-host/9.9.9-host'); + expect(server.core.accessor.get(IBootstrapService).clientIdentity).toEqual({ + productName: 'test-host', + version: '9.9.9-host', + platform: 'test_platform', + }); + }); + + it('seeds default Kimi identity headers from hostIdentity that opts.seeds can override', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ua-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + const defaults = server.core.accessor.get(IHostRequestHeaders); + expect(defaults.headers['User-Agent']).toBe('test-host/0.0.0-test'); + expect(defaults.headers['X-Msh-Version']).toBe('0.0.0-test'); + expect(defaults.headers['X-Msh-Platform']).toBe('test_platform'); + + await server.close(); + server = undefined; + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds: [[IHostRequestHeaders, { headers: { 'User-Agent': 'custom-host/9.9' } }]], + }); + const overridden = server.core.accessor.get(IHostRequestHeaders); + expect(overridden.headers['User-Agent']).toBe('custom-host/9.9'); + }); + + it('seeds explicit skill dirs into the core scope when skillDirs is provided', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-skills-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + skillDirs: ['/skills/explicit'], + }); + expect(server.core.accessor.get(IBootstrapService).args.skillDirs).toEqual([ + '/skills/explicit', + ]); + + await server.close(); + server = undefined; + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + expect(server.core.accessor.get(IBootstrapService).args.skillDirs).toBeUndefined(); + }); + + it('does not shut down a host-injected telemetry service when server telemetry is disabled', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-host-telemetry-')); + await writeFile(join(home, 'config.toml'), 'telemetry = false\n', 'utf8'); + const shutdown = vi.fn(async () => {}); + + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds: [[ITelemetryService, { ...noopTelemetryService, shutdown }]], + }); + + await server.close(); + server = undefined; + + expect(shutdown).not.toHaveBeenCalled(); + }); + + it('completes server cleanup when owned telemetry shutdown fails', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-telemetry-failure-')); + const storage = new InMemoryStorageService(); + const write = storage.write.bind(storage); + vi.spyOn(storage, 'write').mockImplementation(async (scope, key, data, options) => { + if (scope === 'telemetry') throw new Error('telemetry storage unavailable'); + await write(scope, key, data, options); + }); + const auth = { + _serviceBrand: undefined, + getCachedAccessToken: async () => { + throw new Error('telemetry auth unavailable'); + }, + } as unknown as IOAuthToolkit; + + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + telemetry: true, + seeds: [ + [IFileSystemStorageService, storage], + [IOAuthToolkit, auth], + ], + }); + const core = server.core; + core.accessor.get(ITelemetryService).track2('session_ended', { reason: 'exit' }); + + await server.close(); + server = undefined; + + expect(() => core.accessor.get(IBootstrapService)).toThrow(); + expect(await listLiveServerInstances(home)).toEqual([]); + }); + + it('logs process-level exceptions without exiting and removes the handlers on close', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-')); + const lines: string[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + lines.push(String(chunk)); + callback(); + }, + }); + const rejectionBefore = process.listeners('unhandledRejection'); + const exceptionBefore = process.listeners('uncaughtException'); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logger: pino({ level: 'error' }, stream), + }); + + expect(process.listenerCount('unhandledRejection')).toBe(rejectionBefore.length + 1); + expect(process.listenerCount('uncaughtException')).toBe(exceptionBefore.length + 1); + + const onUncaughtException = process + .listeners('uncaughtException') + .find((listener) => !exceptionBefore.includes(listener)) as + | ((error: Error) => void) + | undefined; + const onUnhandledRejection = process + .listeners('unhandledRejection') + .find((listener) => !rejectionBefore.includes(listener)) as + | ((reason: unknown) => void) + | undefined; + expect(onUncaughtException).toBeDefined(); + expect(onUnhandledRejection).toBeDefined(); + + onUncaughtException?.(new Error('synthetic uncaught')); + onUnhandledRejection?.(new Error('synthetic rejection')); + + const output = lines.join(''); + expect(output).toContain('"msg":"uncaughtException"'); + expect(output).toContain('"msg":"unhandledRejection"'); + + const healthz = await fetch(`http://127.0.0.1:${server.port}/api/v1/healthz`); + expect(healthz.status).toBe(200); + + await server.close(); + server = undefined; + + expect(process.listenerCount('unhandledRejection')).toBe(rejectionBefore.length); + expect(process.listenerCount('uncaughtException')).toBe(exceptionBefore.length); + }); + + it('does not leave process handlers installed when startup fails', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-')); + const emptyAssets = await mkdtemp(join(tmpdir(), 'kimi-server-v2-assets-')); + const rejectionBefore = process.listenerCount('unhandledRejection'); + const exceptionBefore = process.listenerCount('uncaughtException'); + try { + await expect( + startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + webAssetsDir: emptyAssets, + }), + ).rejects.toThrow('web assets'); + expect(process.listenerCount('unhandledRejection')).toBe(rejectionBefore); + expect(process.listenerCount('uncaughtException')).toBe(exceptionBefore); + } finally { + await rm(emptyAssets, { recursive: true, force: true }); + } + }); +}); + +function silentLogger() { + return pino({ level: 'silent' }); +} + +function addrInUse(): NodeJS.ErrnoException { + const err = new Error('listen EADDRINUSE') as NodeJS.ErrnoException; + err.code = 'EADDRINUSE'; + return err; +} + +function listenOnPort(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen({ host, port }, () => resolve(server)); + }); +} + +function closeNetServer(server: Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +async function allocateAdjacentFreePair( + host = '127.0.0.1', +): Promise<{ port: number; next: number }> { + for (let i = 0; i < 30; i++) { + const a = await listenOnPort(host, 0); + const address = a.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + await closeNetServer(a); + if (port <= 0 || port >= 65535) continue; + const probe = await listenOnPort(host, port + 1).catch(() => null); + if (probe === null) continue; + await closeNetServer(probe); + return { port, next: port + 1 }; + } + throw new Error('could not allocate an adjacent free port pair'); +} + +describe('listenWithPortRetry', () => { + it('returns the requested port when the first listen succeeds', async () => { + const attempts: number[] = []; + const result = await listenWithPortRetry({ + listen: async (_host, port) => { + attempts.push(port); + return `http://127.0.0.1:${String(port)}`; + }, + host: '127.0.0.1', + port: 5000, + logger: silentLogger(), + }); + + expect(result.port).toBe(5000); + expect(attempts).toEqual([5000]); + }); + + it('retries with port+1 on EADDRINUSE until a bind succeeds', async () => { + const attempts: number[] = []; + const result = await listenWithPortRetry({ + listen: async (_host, port) => { + attempts.push(port); + if (port < 5002) throw addrInUse(); + return `http://127.0.0.1:${String(port)}`; + }, + host: '127.0.0.1', + port: 5000, + logger: silentLogger(), + }); + + expect(result.port).toBe(5002); + expect(result.address).toBe('http://127.0.0.1:5002'); + expect(attempts).toEqual([5000, 5001, 5002]); + }); + + it('does not retry on non-EADDRINUSE errors', async () => { + const attempts: number[] = []; + const boom = Object.assign(new Error('listen EACCES'), { code: 'EACCES' }); + await expect( + listenWithPortRetry({ + listen: async (_host, port) => { + attempts.push(port); + throw boom; + }, + host: '127.0.0.1', + port: 5000, + logger: silentLogger(), + }), + ).rejects.toBe(boom); + expect(attempts).toEqual([5000]); + }); + + it('throws after exhausting maxRetries', async () => { + const attempts: number[] = []; + await expect( + listenWithPortRetry({ + listen: async (_host, port) => { + attempts.push(port); + throw addrInUse(); + }, + host: '127.0.0.1', + port: 5000, + logger: silentLogger(), + maxRetries: 3, + }), + ).rejects.toMatchObject({ code: 'EADDRINUSE' }); + expect(attempts).toEqual([5000, 5001, 5002, 5003]); + }); + + it('does not walk ports when the requested port is 0 (ephemeral)', async () => { + const attempts: number[] = []; + const result = await listenWithPortRetry({ + listen: async (_host, port) => { + attempts.push(port); + return 'http://127.0.0.1:54321'; + }, + host: '127.0.0.1', + port: 0, + logger: silentLogger(), + }); + + expect(result.port).toBe(0); + expect(attempts).toEqual([0]); + }); +}); + +describe('server-v2 boot — port retry', () => { + let server: RunningServer | undefined; + let home: string | undefined; + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it('retries on port+1 and advertises the bound port in the instance registry', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-port-retry-')); + const { port, next } = await allocateAdjacentFreePair(); + const occupant = await listenOnPort('127.0.0.1', port); + try { + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port, + homeDir: home, + logLevel: 'silent', + }); + + expect(server.port).toBeGreaterThanOrEqual(next); + const [instance] = await listLiveServerInstances(home); + expect(instance?.port).toBe(server.port); + } finally { + await closeNetServer(occupant); + } + }); +}); diff --git a/packages/kap-server/test/capabilities.test.ts b/packages/kap-server/test/capabilities.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..07d08d0bdbd1fce16671573eb6d045fe04460097 --- /dev/null +++ b/packages/kap-server/test/capabilities.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { + capabilityStatusSchema, + listCapabilitiesResponseSchema, +} from '../src/protocol/rest-capability'; +import { sharedAuthHeaders, sharedServer } from './helpers/sharedServer'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +describe('server-v2 /api/v1 capabilities', () => { + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${sharedServer().base}${path}`, { + headers: sharedAuthHeaders(), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function postJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${sharedServer().base}${path}`, { + method: 'POST', + headers: sharedAuthHeaders({ 'content-type': 'application/json' }), + body: '{}', + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + it('lists both built-in capabilities with the documented shape', async () => { + const { body } = await getJson('/api/v1/capabilities'); + expect(body.code).toBe(0); + const parsed = listCapabilitiesResponseSchema.parse(body.data); + const ids = parsed.capabilities.map((c) => c.id).toSorted(); + expect(ids).toEqual(['kimi-cu', 'kimi-webbridge']); + for (const capability of parsed.capabilities) { + expect(capabilityStatusSchema.parse(capability)).toBeTruthy(); + expect(capability.install.running).toBe(false); + } + const kimiCu = parsed.capabilities.find((c) => c.id === 'kimi-cu'); + if (process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64')) { + expect(kimiCu?.supported).toBe(true); + } else { + expect(kimiCu?.supported).toBe(false); + expect(kimiCu?.state).toBe('unsupported'); + } + const webbridge = parsed.capabilities.find((c) => c.id === 'kimi-webbridge'); + expect(webbridge?.supported).toBe(true); + expect(webbridge?.steps.find((s) => s.id === 'skill')?.state).toBe('missing'); + expect(webbridge?.steps.find((s) => s.id === 'extension')?.optional).toBe(true); + }); + + it('gets a single capability and 40418s on an unknown id', async () => { + const { body } = await getJson('/api/v1/capabilities/kimi-webbridge'); + expect(body.code).toBe(0); + expect(capabilityStatusSchema.parse(body.data).id).toBe('kimi-webbridge'); + + const missing = await getJson('/api/v1/capabilities/nope'); + expect(missing.body.code).toBe(40418); + expect(missing.body.data).toBeNull(); + }); + + it('installs 40418 on an unknown id without side effects', async () => { + const { body } = await postJson('/api/v1/capabilities/nope:install'); + expect(body.code).toBe(40418); + }); + + it('rejects bare ids and unknown actions with 40001', async () => { + const bare = await postJson('/api/v1/capabilities/kimi-cu'); + expect(bare.body.code).toBe(40001); + const bogus = await postJson('/api/v1/capabilities/kimi-cu:uninstall'); + expect(bogus.body.code).toBe(40001); + }); + + it.skipIf(process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64'))( + 'rejects kimi-cu install on unsupported platforms with 40925', + async () => { + const { body } = await postJson('/api/v1/capabilities/kimi-cu:install'); + expect(body.code).toBe(40925); + }, + ); +}); diff --git a/packages/kap-server/test/channelRegistry.test.ts b/packages/kap-server/test/channelRegistry.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb6ed3ed34383eccdbc85306f9a214f81bead090 --- /dev/null +++ b/packages/kap-server/test/channelRegistry.test.ts @@ -0,0 +1,47 @@ +import { + createDecorator, + Feature, + IFeatureManager, + LifecycleScope, + Service, + createAppScope, +} from '@moonshot-ai/agent-core-v2'; +import { describe, expect, it } from 'vitest'; + +import { describeAllChannels, resolveAnyScopedServiceId } from '../src/transport/channelRegistry'; + +describe('channelRegistry', () => { + it('describes only real DI scopes', () => { + const scopes = new Set(describeAllChannels().map((channel) => channel.scope)); + expect(scopes).toEqual(new Set(['app', 'session', 'agent'])); + }); + + it('resolves contributed services from the current core only', async () => { + const id = createDecorator('test-contributed-service'); + class TestService extends Service {} + class TestFeature extends Feature { + constructor() { + super(); + this.contributeService(LifecycleScope.Agent, id, TestService); + } + } + const first = createAppScope(); + const second = createAppScope(); + const firstManager = first.accessor.get(IFeatureManager); + const secondManager = second.accessor.get(IFeatureManager); + firstManager.provideUnit(TestFeature); + secondManager.provideUnit(TestFeature); + + expect(resolveAnyScopedServiceId(first, String(id))).toBe(id); + expect(resolveAnyScopedServiceId(second, String(id))).toBe(id); + + await firstManager.unprovideUnit('TestFeature'); + expect(resolveAnyScopedServiceId(first, String(id))).toBeUndefined(); + expect(resolveAnyScopedServiceId(second, String(id))).toBe(id); + + await secondManager.unprovideUnit('TestFeature'); + expect(resolveAnyScopedServiceId(second, String(id))).toBeUndefined(); + first.dispose(); + second.dispose(); + }); +}); diff --git a/packages/kap-server/test/config.test.ts b/packages/kap-server/test/config.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1ea4268a9942b38074ce42d315fb6aabb60813b --- /dev/null +++ b/packages/kap-server/test/config.test.ts @@ -0,0 +1,478 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + ConfigChanged, + IConfigService, + IEventService, + type ConfigSectionChangedEvent, + type Event2, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { configResponseSchema, type ConfigResponse } from '../src/protocol/rest-config'; +import { ErrorCode } from '../src/protocol/error-codes'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { WebSocket } from 'ws'; + +import { type RunningServer, startServer } from '../src/start'; +import { startConfigChangedPublisher } from '../src/services/config/configChangedPublisher'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authedFetch, bearerToken } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +describe('server-v2 /api/v1/config', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-config-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as never); + home = undefined; + } + }); + + async function boot(toml?: string): Promise { + await writeFile(join(home as string, 'config.toml'), toml ?? '', 'utf-8'); + await (server as RunningServer).core.accessor.get(IConfigService).reload(); + } + + async function getConfig(): Promise { + const res = await authedFetch(server as RunningServer, base, '/api/v1/config'); + expect(res.status).toBe(200); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(0); + return configResponseSchema.parse(body.data); + } + + async function patchConfig(patch: Record): Promise { + const res = await authedFetch(server as RunningServer, base, '/api/v1/config', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(0); + return configResponseSchema.parse(body.data); + } + + it('GET echoes default_permission_mode and derives yolo = false', async () => { + await boot('default_permission_mode = "auto"\n'); + const cfg = await getConfig(); + expect(cfg.default_permission_mode).toBe('auto'); + expect(cfg.yolo).toBe(false); + }); + + it('POST { yolo: true } sets default_permission_mode = yolo and echoes yolo = true', async () => { + await boot(); + const cfg = await patchConfig({ yolo: true }); + expect(cfg.default_permission_mode).toBe('yolo'); + expect(cfg.yolo).toBe(true); + + const after = await getConfig(); + expect(after.default_permission_mode).toBe('yolo'); + expect(after.yolo).toBe(true); + }); + + it('POST { default_permission_mode: auto } writes the canonical field and derives yolo = false', async () => { + await boot(); + const cfg = await patchConfig({ default_permission_mode: 'auto' }); + expect(cfg.default_permission_mode).toBe('auto'); + expect(cfg.yolo).toBe(false); + + const after = await getConfig(); + expect(after.default_permission_mode).toBe('auto'); + expect(after.yolo).toBe(false); + }); + + it('POST { secondary_model } persists the subagent model pool and GET echoes it', async () => { + await boot(); + const cfg = await patchConfig({ + secondary_model: { + default_model: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }, + }); + expect(cfg.secondary_model).toMatchObject({ defaultModel: 'provider/fast' }); + + const after = await getConfig(); + expect(after.secondary_model).toMatchObject({ + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast and cheap' }, + }); + }); + + it('POST { secondary_model } preserves pool alias keys containing underscores', async () => { + await boot(); + await patchConfig({ + secondary_model: { default_model: 'provider/fast_model', models: { 'provider/fast_model': '' } }, + }); + + const after = await getConfig(); + expect(after.secondary_model).toMatchObject({ + defaultModel: 'provider/fast_model', + models: { 'provider/fast_model': '' }, + }); + expect( + Object.keys((after.secondary_model as { models: Record }).models), + ).not.toContain('provider/fastModel'); + }); + + it('POST { providers } converts fields of a provider id colliding with a map-valued key', async () => { + await boot(); + await patchConfig({ + providers: { + models: { type: 'openai', base_url: 'https://example.test', api_key: 'sk-test' }, + }, + }); + + const after = await getConfig(); + expect(after.providers['models']).toMatchObject({ + type: 'openai', + base_url: 'https://example.test', + has_api_key: true, + }); + }); + + it('session create with a broken subagent model pool still succeeds', async () => { + await boot('[secondary_model.models]\n"provider/fast" = "fast and cheap"\n'); + const res = await authedFetch(server as RunningServer, base, '/api/v1/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd: home as string } }), + }); + const body = (await res.json()) as Envelope<{ id?: string }>; + expect(body.code).toBe(0); + expect(body.data?.id).toBeTruthy(); + }); +}); + +describe('server-v2 config changed WS notifications', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + const sockets: WebSocket[] = []; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-config-ws-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(() => { + for (const ws of sockets.splice(0)) ws.close(); + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function boot(toml?: string): Promise { + if (server === undefined) { + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + } + await writeFile(join(home as string, 'config.toml'), toml ?? '', 'utf-8'); + await (server as RunningServer).core.accessor.get(IConfigService).reload(); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + interface ConfigChangedFrame { + type: 'event.config.changed'; + payload: { + changedFields: string[]; + config: Record; + }; + } + + async function openWs(): Promise { + const live = server as RunningServer; + const ws = new WebSocket(`ws://127.0.0.1:${live.port}/api/v1/ws`, [ + `kimi-code.bearer.${bearerToken(live)}`, + ]); + sockets.push(ws); + const frames: ConfigChangedFrame[] = []; + ws.on('message', (data) => { + const frame = JSON.parse((data as Buffer).toString()) as { type?: string }; + if (frame.type === 'event.config.changed') frames.push(frame as ConfigChangedFrame); + }); + await new Promise((resolve) => ws.on('open', resolve)); + ws.send(JSON.stringify({ type: 'client_hello', payload: { client_id: 'config-ws-test' } })); + return frames; + } + + async function patchConfig(patch: Record): Promise { + const res = await authedFetch(server as RunningServer, base, '/api/v1/config', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(0); + } + + it('publishes a trailing event with camelCase changedFields and the full snapshot after POST /config', async () => { + await boot('default_permission_mode = "auto"\n'); + const frames = await openWs(); + + await patchConfig({ yolo: true }); + + await vi.waitFor(() => expect(frames.length).toBeGreaterThanOrEqual(1)); + const last = frames.at(-1) as ConfigChangedFrame; + expect(last.payload.changedFields).toEqual(['defaultPermissionMode']); + expect(last.payload.config['default_permission_mode']).toBe('yolo'); + expect(last.payload.config['yolo']).toBe(true); + expect(last.payload.config).toHaveProperty('providers'); + }); + + it('publishes camelCase changedFields on the engine write path used by OAuth refreshes', async () => { + await boot(); + const frames = await openWs(); + + const config = (server as RunningServer).core.accessor.get(IConfigService); + await config.ready; + await config.replace('defaultModel', 'k2'); + + await vi.waitFor(() => expect(frames.length).toBeGreaterThanOrEqual(1)); + const last = frames.at(-1) as ConfigChangedFrame; + expect(last.payload.changedFields).toEqual(['defaultModel']); + expect(last.payload.config['default_model']).toBe('k2'); + }); + + it('publishes an event when config.toml is edited outside the process and reloaded', async () => { + await boot('default_permission_mode = "auto"\n'); + const frames = await openWs(); + + await writeFile( + join(home as string, 'config.toml'), + 'default_permission_mode = "yolo"\n', + 'utf-8', + ); + const config = (server as RunningServer).core.accessor.get(IConfigService); + await config.ready; + await config.reload(); + + await vi.waitFor(() => expect(frames.length).toBeGreaterThanOrEqual(1), { timeout: 10000 }); + const last = frames.at(-1) as ConfigChangedFrame; + expect(last.payload.changedFields).toContain('defaultPermissionMode'); + expect(last.payload.config['default_permission_mode']).toBe('yolo'); + }); + + it('closes the config publisher before the app, so a pending change is never delivered during shutdown', async () => { + await boot(); + const published: string[] = []; + const events = (server as RunningServer).core.accessor.get(IEventService); + const subscription = events.subscribe((event) => { + if (event.type === 'event.config.changed') published.push(event.type); + }); + + const config = (server as RunningServer).core.accessor.get(IConfigService); + await config.ready; + await config.replace('defaultModel', 'k2'); + for (const ws of sockets.splice(0)) ws.close(); + const live = server as RunningServer; + server = undefined; + await live.close(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(published).toHaveLength(0); + subscription.dispose(); + }); +}); + +describe('configChangedPublisher', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + function setup() { + const listeners = new Set<(event: ConfigSectionChangedEvent) => void>(); + let backing: Record = {}; + const published: ConfigChanged[] = []; + let disposed = false; + const core = { + accessor: { + get: (token: unknown) => { + if (token === IConfigService) { + return { + onDidSectionChange: (listener: (event: ConfigSectionChangedEvent) => void) => { + listeners.add(listener); + return { + dispose: () => { + disposed = true; + listeners.delete(listener); + }, + }; + }, + getAll: () => backing, + }; + } + return { + publish: (event: Event2) => published.push(event as ConfigChanged), + }; + }, + }, + } as unknown as Scope; + const publisher = startConfigChangedPublisher(core); + const fire = (domain: string): void => { + for (const listener of listeners) { + listener({ domain, source: 'set', value: undefined, previousValue: undefined }); + } + }; + return { + publisher, + published, + fire, + isDisposed: () => disposed, + setBacking: (value: Record) => { + backing = value; + }, + }; + } + + it('merges section events inside one flush window into a single ConfigChanged', () => { + vi.useFakeTimers(); + const { published, fire } = setup(); + + fire('defaultPermissionMode'); + fire('defaultModel'); + vi.advanceTimersByTime(50); + + expect(published).toHaveLength(1); + expect(published[0]?.payload.changedFields).toEqual(['defaultModel', 'defaultPermissionMode']); + }); + + it('projects the full config snapshot at flush time', () => { + vi.useFakeTimers(); + const { published, fire, setBacking } = setup(); + + fire('defaultModel'); + setBacking({ defaultModel: 'k2', providers: {} }); + vi.advanceTimersByTime(50); + + expect(published).toHaveLength(1); + expect(published[0]?.payload.config).toEqual({ default_model: 'k2', providers: {} }); + }); + + it('redacts inline model credentials from the published config projection', () => { + vi.useFakeTimers(); + const { published, fire, setBacking } = setup(); + setBacking({ + providers: {}, + models: { + 'p/m': { provider: 'p', model: 'm', maxContextSize: 4096, apiKey: 'sk-secret' }, + 'p/flat': { baseUrl: 'https://x.test', model: 'm', oauth: { storage: 'file', key: 'oauth/x' } }, + }, + }); + + fire('models'); + vi.advanceTimersByTime(50); + + expect(published).toHaveLength(1); + const config = published[0]?.payload.config as Record; + const models = config['models'] as Record>; + expect(models['p/m']).toEqual({ provider: 'p', model: 'm', maxContextSize: 4096, has_api_key: true }); + expect(models['p/flat']).toEqual({ baseUrl: 'https://x.test', model: 'm', has_api_key: true }); + expect(JSON.stringify(models)).not.toContain('sk-secret'); + }); + + it('redacts inline service credentials from the published config projection', () => { + vi.useFakeTimers(); + const { published, fire, setBacking } = setup(); + setBacking({ + providers: {}, + services: { + moonshotSearch: { + baseUrl: 'https://s.test', + apiKey: 'sk-svc', + customHeaders: { Authorization: 'Bearer abc', 'x-team': 'core' }, + }, + }, + }); + + fire('services'); + vi.advanceTimersByTime(50); + + expect(published).toHaveLength(1); + const config = published[0]?.payload.config as Record; + const services = config['services'] as Record>; + expect(services['moonshotSearch']).toEqual({ + baseUrl: 'https://s.test', + has_api_key: true, + custom_header_keys: ['Authorization', 'x-team'], + }); + expect(JSON.stringify(services)).not.toContain('sk-svc'); + expect(JSON.stringify(services)).not.toContain('Bearer abc'); + }); + + it('always delivers a trailing event for late-arriving changes', () => { + vi.useFakeTimers(); + const { published, fire } = setup(); + + fire('defaultPermissionMode'); + vi.advanceTimersByTime(12); + fire('models'); + vi.advanceTimersByTime(12); + + expect(published).toHaveLength(2); + expect(published[1]?.payload.changedFields).toEqual(['models']); + }); + + it('drops pending changes on close and never publishes afterwards', () => { + vi.useFakeTimers(); + const { published, publisher, fire, isDisposed } = setup(); + + fire('defaultModel'); + publisher.close(); + vi.advanceTimersByTime(50); + fire('models'); + vi.advanceTimersByTime(50); + + expect(published).toHaveLength(0); + expect(isDisposed()).toBe(true); + }); +}); diff --git a/packages/kap-server/test/connections.test.ts b/packages/kap-server/test/connections.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce3aa1a65a48d508363138b415336881fc665f6e --- /dev/null +++ b/packages/kap-server/test/connections.test.ts @@ -0,0 +1,151 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { connectionsListResponseSchema } from '../src/protocol/rest-connection'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +describe('server-v2 GET /api/v1/connections', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + let wsUrl: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-connections-')); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + base = `http://127.0.0.1:${server.port}`; + wsUrl = `ws://127.0.0.1:${server.port}/api/v1/ws`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function listConnections() { + const res = await fetch(`${base}/api/v1/connections`, { + headers: authHeaders(server as RunningServer), + } as never); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(0); + return connectionsListResponseSchema.parse(body.data).connections; + } + + async function createSession(cwd: string): Promise { + const res = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ metadata: { cwd } }), + } as never); + const body = (await res.json()) as Envelope<{ id: string }>; + expect(body.code).toBe(0); + return body.data.id; + } + + function connect(): Promise { + return new Promise((resolve, reject) => { + const token = (server as RunningServer).authTokenService.getToken(); + const ws = new WebSocket(wsUrl, [`kimi-code.bearer.${token}`]); + ws.once('message', () => resolve(ws)); + ws.once('error', reject); + }); + } + + function send(ws: WebSocket, frame: Record): void { + ws.send(JSON.stringify(frame)); + } + + async function waitForSize(target: number, timeoutMs = 1000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (server?.connectionRegistry.size() === target) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`registry size ${target} not observed within ${timeoutMs}ms`); + } + + it('returns an empty list when no clients are attached', async () => { + const connections = await listConnections(); + expect(connections).toEqual([]); + }); + + it('lists a raw connection without hello', async () => { + const ws = await connect(); + const closed = new Promise((res) => ws.on('close', () => res())); + await waitForSize(1); + + const connections = await listConnections(); + expect(connections).toHaveLength(1); + const c = connections[0]!; + expect(c.id).toMatch(/^conn_/); + expect(c.has_client_hello).toBe(false); + expect(c.subscriptions).toEqual([]); + expect(c.connected_at).toMatch(/Z$/); + expect(typeof c.remote_address).toBe('string'); + expect((c.remote_address ?? '').length).toBeGreaterThan(0); + + ws.close(); + await closed; + }); + + it('reflects client_hello and session subscriptions', async () => { + const sessionId = await createSession(home as string); + const ws = await connect(); + try { + send(ws, { + type: 'client_hello', + id: 'h1', + payload: { client_id: 'connections-test', subscriptions: [sessionId] }, + }); + await new Promise((r) => setTimeout(r, 50)); + + let connections = await listConnections(); + expect(connections).toHaveLength(1); + const c = connections[0]!; + expect(c.has_client_hello).toBe(true); + expect(c.subscriptions).toContain(sessionId); + + send(ws, { type: 'unsubscribe', id: 'u1', payload: { session_ids: [sessionId] } }); + await new Promise((r) => setTimeout(r, 50)); + connections = await listConnections(); + expect(connections[0]!.subscriptions).not.toContain(sessionId); + } finally { + ws.close(); + } + }); + + it('removes the connection after the socket closes', async () => { + const ws = await connect(); + send(ws, { + type: 'client_hello', + id: 'h1', + payload: { client_id: 'connections-test', subscriptions: [] }, + }); + await waitForSize(1); + + ws.close(); + await waitForSize(0); + + const connections = await listConnections(); + expect(connections).toEqual([]); + }); +}); diff --git a/packages/kap-server/test/debugNonloopback.e2e.test.ts b/packages/kap-server/test/debugNonloopback.e2e.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..811f6acf2a6ad4e8e47296f105e9e73c11fbe8e8 --- /dev/null +++ b/packages/kap-server/test/debugNonloopback.e2e.test.ts @@ -0,0 +1,92 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +let prevPassword: string | undefined; +const createdDirs: string[] = []; +const running: RunningServer[] = []; + +beforeEach(() => { + prevPassword = process.env['KIMI_CODE_PASSWORD']; +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + } + } + for (const dir of createdDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } + if (prevPassword === undefined) { + delete process.env['KIMI_CODE_PASSWORD']; + } else { + process.env['KIMI_CODE_PASSWORD'] = prevPassword; + } +}); + +async function tmpHome(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'kimi-v2-debug-loopback-')); + createdDirs.push(dir); + return dir; +} + +async function probeDebug(server: RunningServer): Promise { + const token = server.authTokenService.getToken(); + const res = await fetch(`http://127.0.0.1:${server.port}/api/v1/debug/channels`, { + headers: { authorization: `Bearer ${token}` }, + }); + return res.status; +} + +describe('debug endpoints are not exposed on a non-loopback bind', () => { + it('returns 404 for /api/v1/debug/* on a 0.0.0.0 bind even when requested', async () => { + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + const home = await tmpHome(); + const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '0.0.0.0', + port: 0, + homeDir: home, + logLevel: 'silent', + insecureNoTls: true, + debugEndpoints: true, + }); + running.push(server); + expect(await probeDebug(server)).toBe(404); + }); + + it('is not mounted on loopback by default (without the option)', async () => { + const home = await tmpHome(); + const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + running.push(server); + expect(await probeDebug(server)).toBe(404); + }); + + it('mounts the whitelist-free RPC surface on loopback when requested', async () => { + const home = await tmpHome(); + const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + debugEndpoints: true, + }); + running.push(server); + expect(await probeDebug(server)).toBe(200); + }); +}); diff --git a/packages/kap-server/test/disableAuth.e2e.test.ts b/packages/kap-server/test/disableAuth.e2e.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa4360a1cfc41b5eabb751840521ad8551d826cd --- /dev/null +++ b/packages/kap-server/test/disableAuth.e2e.test.ts @@ -0,0 +1,126 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { WebSocket, type RawData } from 'ws'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { fixedTokenAuth } from './helpers/fixedAuth'; + +const TOKEN = 'test-token'; + +function rawToString(data: RawData): string { + if (typeof data === 'string') return data; + if (Buffer.isBuffer(data)) return data.toString('utf8'); + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} + +function openConn(url: string): Promise<{ ws: WebSocket; firstFrame: unknown }> { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + ws.once('message', (data) => { + try { + resolve({ ws, firstFrame: JSON.parse(rawToString(data)) }); + } catch { + resolve({ ws, firstFrame: null }); + } + }); + ws.once('error', reject); + }); +} + +describe('server-v2 disableAuth (--dangerous-bypass-auth)', () => { + let server: RunningServer | undefined; + let home: string | undefined; + const sockets: WebSocket[] = []; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-disable-auth-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + authTokenService: fixedTokenAuth(TOKEN), + disableAuth: true, + }); + }); + + afterEach(() => { + for (const ws of sockets.splice(0)) { + try { + ws.close(); + } catch { + } + } + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it('disableAuth:true lets REST through without a token and advertises it in /meta', async () => { + const base = `http://127.0.0.1:${server!.port}`; + + const meta = await fetch(`${base}/api/v1/meta`); + expect(meta.status).toBe(200); + const metaBody = (await meta.json()) as { + code: number; + data: { dangerous_bypass_auth: boolean }; + }; + expect(metaBody.code).toBe(0); + expect(metaBody.data.dangerous_bypass_auth).toBe(true); + + const auth = await fetch(`${base}/api/v1/auth`); + expect(auth.status).toBe(200); + }); + + it('disableAuth:true lets WebSocket upgrades through without a token', async () => { + const v1 = await openConn(`ws://127.0.0.1:${server!.port}/api/v1/ws`); + sockets.push(v1.ws); + expect(v1.firstFrame).toMatchObject({ type: 'server_hello' }); + }); + + it('default boot keeps the gate closed and reports dangerous_bypass_auth: false', async () => { + const altHome = await mkdtemp(join(tmpdir(), 'kimi-server-v2-disable-auth-')); + const alt = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: altHome, + logLevel: 'silent', + authTokenService: fixedTokenAuth(TOKEN), + disableAuth: undefined, + }); + try { + const base = `http://127.0.0.1:${alt.port}`; + + const unauthed = await fetch(`${base}/api/v1/meta`); + expect(unauthed.status).toBe(401); + + const meta = await fetch(`${base}/api/v1/meta`, { + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(meta.status).toBe(200); + const metaBody = (await meta.json()) as { + code: number; + data: { dangerous_bypass_auth: boolean }; + }; + expect(metaBody.data.dangerous_bypass_auth).toBe(false); + } finally { + await alt.close(); + await rm(altHome, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/kap-server/test/fileHistory.test.ts b/packages/kap-server/test/fileHistory.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4eee9db5e4067c98ded1a72655c081af6f673fa8 --- /dev/null +++ b/packages/kap-server/test/fileHistory.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +let home: string; +let server: RunningServer | undefined; + +beforeAll(async () => { + home = mkdtempSync(join(tmpdir(), 'kimi-server-v2-file-history-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); +}); + +afterAll(async () => { + try { + await server?.close(); + } catch { + } + server = undefined; + rmSync(home, { recursive: true, force: true }); +}); + +async function boot(): Promise { + return server as RunningServer; +} + +interface InjectResponse { + statusCode: number; + body: string; + json: () => unknown; +} + +interface AppLike { + inject: (req: unknown) => Promise; +} + +function appOf(r: RunningServer): AppLike { + const app = r.app as unknown as AppLike; + return { + inject(req: unknown): Promise { + const request = req as { headers?: Record }; + return app.inject({ + ...request, + headers: { + ...request.headers, + authorization: `Bearer ${r.authTokenService.getToken()}`, + }, + }); + }, + }; +} + +interface Envelope { + code: number; + msg: string; + data: T | null; +} + +async function createSession(r: RunningServer): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd: home } }, + headers: { 'content-type': 'application/json' }, + }); + const envelope = res.json() as Envelope<{ id: string }>; + if (envelope.code !== 0 || envelope.data === null) { + throw new Error(`failed to create session: ${res.body}`); + } + return envelope.data.id; +} + +describe('file history routes', () => { + it('serves empty changes and null content for a live session without history', async () => { + const r = await boot(); + const sessionId = await createSession(r); + + const changes = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions/${sessionId}/file-history/changes?turn_id=1`, + }); + expect(changes.statusCode).toBe(200); + expect((changes.json() as Envelope<{ changes: unknown[] }>).data).toEqual({ + changes: [], + recorded: false, + }); + + const content = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions/${sessionId}/file-history/content?turn_id=1&path=a.txt`, + }); + expect(content.statusCode).toBe(200); + expect((content.json() as Envelope<{ content: unknown }>).data).toEqual({ content: null }); + }); + + it('rejects a session that is not live', async () => { + const r = await boot(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/api/v1/sessions/does-not-exist/file-history/changes?turn_id=1', + }); + const envelope = res.json() as Envelope; + expect(envelope.code).not.toBe(0); + expect(envelope.data).toBeNull(); + }); + + it('rejects a malformed turn_id', async () => { + const r = await boot(); + const sessionId = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions/${sessionId}/file-history/changes?turn_id=abc`, + }); + const envelope = res.json() as Envelope; + expect(envelope.code).not.toBe(0); + }); +}); diff --git a/packages/kap-server/test/fileLaunch.test.ts b/packages/kap-server/test/fileLaunch.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bceb542e4db91f60585a21b855396ab875e566c6 --- /dev/null +++ b/packages/kap-server/test/fileLaunch.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { openInAppCommandFor, revealFileCommandFor } from '../src/lib/fileLaunch'; + +describe('fileLaunch', () => { + describe('win32 explorer /select, quoting', () => { + it('revealFileCommandFor quotes only the path and uses verbatim arguments', () => { + const cmd = revealFileCommandFor('C:\\some dir\\sub\\file.txt', 'win32'); + expect(cmd.command).toBe('explorer.exe'); + expect(cmd.args).toEqual(['/select,"C:\\some dir\\sub\\file.txt"']); + expect(cmd.windowsVerbatimArguments).toBe(true); + }); + + it('openInAppCommandFor (finder) quotes only the path and uses verbatim arguments', () => { + const cmd = openInAppCommandFor( + 'finder', + 'C:\\some dir\\sub\\file.txt', + { isDirectory: false }, + 'win32', + ); + expect(cmd.command).toBe('explorer.exe'); + expect(cmd.args).toEqual(['/select,"C:\\some dir\\sub\\file.txt"']); + expect(cmd.windowsVerbatimArguments).toBe(true); + }); + + it('openInAppCommandFor (finder) opens directories without /select,', () => { + const cmd = openInAppCommandFor( + 'finder', + 'C:\\some dir\\sub', + { isDirectory: true }, + 'win32', + ); + expect(cmd.command).toBe('explorer.exe'); + expect(cmd.args).toEqual(['C:\\some dir\\sub']); + expect(cmd.windowsVerbatimArguments).toBeUndefined(); + }); + + it('drops a trailing backslash so it cannot escape the closing quote', () => { + const cmd = revealFileCommandFor('C:\\some dir\\sub\\', 'win32'); + expect(cmd.args).toEqual(['/select,"C:\\some dir\\sub"']); + }); + + it('paths without spaces keep the same quoting', () => { + const cmd = revealFileCommandFor('C:\\proj\\file.txt', 'win32'); + expect(cmd.args).toEqual(['/select,"C:\\proj\\file.txt"']); + expect(cmd.windowsVerbatimArguments).toBe(true); + }); + }); +}); diff --git a/packages/kap-server/test/files.test.ts b/packages/kap-server/test/files.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3395ea2900403e55b6958ebe6c95ecfdcdb2b8e --- /dev/null +++ b/packages/kap-server/test/files.test.ts @@ -0,0 +1,528 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + getLiveSessionById, + IFileService, + ISessionManager, + ISessionMediaStore, +} from '@moonshot-ai/agent-core-v2'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +let home: string; +let server: RunningServer | undefined; + +beforeAll(async () => { + home = mkdtempSync(join(tmpdir(), 'kimi-server-v2-files-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); +}); + +afterAll(async () => { + try { + await server?.close(); + } catch { + } + server = undefined; + rmSync(home, { recursive: true, force: true }); +}); + +async function boot(): Promise { + if (server === undefined) { + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + } + return server; +} + +interface InjectResponse { + statusCode: number; + headers: Record; + body: string; + payload: string; + rawPayload: Buffer; + json: () => unknown; +} + +interface AppLike { + inject: (req: unknown) => Promise; +} + +function appOf(r: RunningServer): AppLike { + const app = r.app as unknown as AppLike; + return { + inject(req: unknown): Promise { + const request = req as { headers?: Record }; + return app.inject({ + ...request, + headers: { + ...request.headers, + authorization: `Bearer ${r.authTokenService.getToken()}`, + }, + }); + }, + }; +} + +interface Envelope { + code: number; + msg: string; + data: T | null; + request_id?: string; + details?: unknown; +} + +function buildMultipart(parts: { + file: { fieldName: string; filename: string; contentType: string; data: Buffer }; + fields?: Array<{ name: string; value: string }>; +}): { body: Buffer; contentType: string } { + const boundary = '------WebKitFormBoundaryKimiServerV2Test'; + const lines: Array = []; + if (parts.fields) { + for (const f of parts.fields) { + lines.push(`--${boundary}\r\n`); + lines.push(`Content-Disposition: form-data; name="${f.name}"\r\n\r\n${f.value}\r\n`); + } + } + lines.push(`--${boundary}\r\n`); + lines.push( + `Content-Disposition: form-data; name="${parts.file.fieldName}"; filename="${parts.file.filename}"\r\n`, + ); + lines.push(`Content-Type: ${parts.file.contentType}\r\n\r\n`); + lines.push(parts.file.data); + lines.push(`\r\n--${boundary}--\r\n`); + + const chunks: Buffer[] = []; + for (const ln of lines) { + chunks.push(typeof ln === 'string' ? Buffer.from(ln, 'utf8') : ln); + } + return { + body: Buffer.concat(chunks), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +async function createSession(r: RunningServer): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd: home } }, + headers: { 'content-type': 'application/json' }, + }); + const envelope = res.json() as Envelope<{ id: string }>; + if (envelope.code !== 0 || envelope.data === null) { + throw new Error(`failed to create session: ${res.body}`); + } + return envelope.data.id; +} + +async function uploadFile( + r: RunningServer, + data: Buffer, + name: string, + mediaType: string, +): Promise<{ id: string; name: string; media_type: string; size: number }> { + const multipart = buildMultipart({ + file: { fieldName: 'file', filename: name, contentType: mediaType, data }, + }); + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/files', + payload: multipart.body, + headers: { 'content-type': multipart.contentType }, + }); + const envelope = res.json() as Envelope<{ + id: string; + name: string; + media_type: string; + size: number; + }>; + if (envelope.code !== 0 || envelope.data === null) { + throw new Error(`failed to upload file: ${res.body}`); + } + return envelope.data; +} + +async function materializeUploadedFile( + r: RunningServer, + sessionId: string, + meta: { id: string; name: string; media_type: string; size: number }, +): Promise { + const session = getLiveSessionById(r.core.accessor, sessionId); + if (session === undefined) throw new Error(`session ${sessionId} is not live`); + const uploaded = await r.core.accessor.get(IFileService).get(meta.id); + await session.accessor.get(ISessionMediaStore).materialize({ + fileId: meta.id, + name: meta.name, + mimeType: meta.media_type, + size: meta.size, + stream: () => uploaded.stream(), + }); +} + +describe('POST /api/v1/files (server-v2)', () => { + it('upload → GET stream → DELETE → re-GET 40407', async () => { + const r = await boot(); + const data = Buffer.from('hello server v2 files'); + const mp = buildMultipart({ + file: { fieldName: 'file', filename: 'hello.txt', contentType: 'text/plain', data }, + }); + + const upRes = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/files', + payload: mp.body, + headers: { 'content-type': mp.contentType }, + }); + expect(upRes.statusCode).toBe(200); + const upEnv = upRes.json() as Envelope<{ + id: string; + name: string; + media_type: string; + size: number; + created_at: string; + }>; + expect(upEnv.code).toBe(0); + const meta = upEnv.data!; + expect(meta.name).toBe('hello.txt'); + expect(meta.media_type).toBe('text/plain'); + expect(meta.size).toBe(data.length); + + const getRes = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/files/${meta.id}`, + }); + expect(getRes.statusCode).toBe(200); + expect(getRes.headers['content-type']).toBe('text/plain'); + expect(getRes.headers['content-length']).toBe(String(data.length)); + expect(getRes.headers['etag']).toBe(`"${meta.id}-${meta.size}"`); + expect(String(getRes.headers['content-disposition'])).toMatch( + /attachment; filename="hello\.txt"/, + ); + expect(getRes.rawPayload).toEqual(data); + + const delRes = await appOf(r).inject({ + method: 'DELETE', + url: `/api/v1/files/${meta.id}`, + }); + expect(delRes.statusCode).toBe(200); + const delEnv = delRes.json() as Envelope<{ deleted: true }>; + expect(delEnv.code).toBe(0); + expect(delEnv.data?.deleted).toBe(true); + + const get2Res = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/files/${meta.id}`, + }); + expect(get2Res.statusCode).toBe(404); + expect((get2Res.json() as Envelope).code).toBe(40407); + }); + + it('uploads a file larger than the former 50 MiB cap', async () => { + const r = await boot(); + const big = Buffer.alloc(51 * 1024 * 1024, 0); + const mp = buildMultipart({ + file: { + fieldName: 'file', + filename: 'big.bin', + contentType: 'application/octet-stream', + data: big, + }, + }); + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/files', + payload: mp.body, + headers: { 'content-type': mp.contentType }, + }); + expect(res.statusCode).toBe(200); + const env = res.json() as Envelope<{ id: string; size: number }>; + expect(env.code).toBe(0); + expect(env.data!.size).toBe(big.length); + + const getRes = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/files/${env.data!.id}`, + }); + expect(getRes.statusCode).toBe(200); + expect(getRes.rawPayload.equals(big)).toBe(true); + }); + + it('GET / DELETE unknown file_id → 40407', async () => { + const r = await boot(); + const getRes = await appOf(r).inject({ + method: 'GET', + url: '/api/v1/files/f_does_not_exist', + }); + expect(getRes.statusCode).toBe(404); + expect((getRes.json() as Envelope).code).toBe(40407); + + const delRes = await appOf(r).inject({ + method: 'DELETE', + url: '/api/v1/files/f_does_not_exist', + }); + expect(delRes.statusCode).toBe(404); + expect((delRes.json() as Envelope).code).toBe(40407); + }); + + it('survives a server restart (index + blob persist)', async () => { + let r = await boot(); + const data = Buffer.from('persistent payload'); + const mp = buildMultipart({ + file: { fieldName: 'file', filename: 'persist.txt', contentType: 'text/plain', data }, + }); + const upRes = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/files', + payload: mp.body, + headers: { 'content-type': mp.contentType }, + }); + const meta = (upRes.json() as Envelope<{ id: string; size: number }>).data!; + expect(meta.id).toBeDefined(); + + await r.close(); + server = undefined; + r = await boot(); + + const getRes = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/files/${meta.id}`, + }); + expect(getRes.statusCode).toBe(200); + expect(getRes.rawPayload).toEqual(data); + }); + + it('honors the multipart `name` field override', async () => { + const r = await boot(); + const mp = buildMultipart({ + file: { + fieldName: 'file', + filename: 'original.txt', + contentType: 'text/plain', + data: Buffer.from('renamed payload'), + }, + fields: [{ name: 'name', value: 'overridden.txt' }], + }); + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/files', + payload: mp.body, + headers: { 'content-type': mp.contentType }, + }); + expect(res.statusCode).toBe(200); + expect((res.json() as Envelope<{ name: string }>).data?.name).toBe('overridden.txt'); + }); + + it('serves byte ranges with 206 Partial Content for video playback', async () => { + const r = await boot(); + const data = Buffer.from('0123456789abcdefghijklmnopqrstuvwxyz'); + const mp = buildMultipart({ + file: { + fieldName: 'file', + filename: 'clip.mp4', + contentType: 'video/mp4', + data, + }, + }); + const upRes = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/files', + payload: mp.body, + headers: { 'content-type': mp.contentType }, + }); + const meta = (upRes.json() as Envelope<{ id: string; size: number }>).data!; + + const full = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/files/${meta.id}`, + }); + expect(full.statusCode).toBe(200); + expect(full.headers['accept-ranges']).toBe('bytes'); + expect(full.headers['content-type']).toBe('video/mp4'); + expect(String(full.headers['content-disposition'])).toMatch(/^inline;/); + expect(full.rawPayload).toEqual(data); + + const part = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/files/${meta.id}`, + headers: { range: 'bytes=4-9' }, + }); + expect(part.statusCode).toBe(206); + expect(part.headers['content-range']).toBe(`bytes 4-9/${data.length}`); + expect(part.headers['content-length']).toBe('6'); + expect(part.rawPayload).toEqual(data.subarray(4, 10)); + + const tail = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/files/${meta.id}`, + headers: { range: 'bytes=30-' }, + }); + expect(tail.statusCode).toBe(206); + expect(tail.headers['content-range']).toBe(`bytes 30-${data.length - 1}/${data.length}`); + expect(tail.rawPayload).toEqual(data.subarray(30)); + }); + + it('missing file part → 40001 validation error', async () => { + const r = await boot(); + const boundary = '------WebKitFormBoundaryNoFile'; + const body = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="other"\r\n\r\nhi\r\n--${boundary}--\r\n`, + 'utf8', + ); + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/files', + payload: body, + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + }); + expect(res.statusCode).toBe(200); + expect((res.json() as Envelope).code).toBe(40001); + }); +}); + +describe('GET /api/v1/sessions/{session_id}/media/{file_id} (server-v2)', () => { + it('serves the session copy after the transient upload is deleted', async () => { + const r = await boot(); + const data = Buffer.from('canonical image bytes'); + const sessionId = await createSession(r); + const meta = await uploadFile(r, data, 'pasted image.png', 'image/png'); + await materializeUploadedFile(r, sessionId, meta); + + await appOf(r).inject({ method: 'DELETE', url: `/api/v1/files/${meta.id}` }); + const res = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions/${sessionId}/media/${meta.id}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['content-length']).toBe(String(data.length)); + expect(String(res.headers['content-disposition'])).toMatch( + /inline; filename="pasted image\.png"/, + ); + expect(res.rawPayload).toEqual(data); + }); + + it('serves the staged upload before intake materializes the session copy', async () => { + const r = await boot(); + const data = Buffer.from('staged upload bytes'); + const sessionId = await createSession(r); + const meta = await uploadFile(r, data, 'staged.png', 'image/png'); + + const res = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions/${sessionId}/media/${meta.id}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['content-length']).toBe(String(data.length)); + expect(res.rawPayload).toEqual(data); + }); + + it('returns file-not-found when neither the session store nor the staged upload holds it', async () => { + const r = await boot(); + const sessionId = await createSession(r); + + const res = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions/${sessionId}/media/f_does_not_exist`, + }); + + expect(res.statusCode).toBe(404); + expect((res.json() as Envelope).code).toBe(40407); + }); + + it('serves a requested byte range from the session copy', async () => { + const r = await boot(); + const data = Buffer.from('0123456789abcdefghijklmnopqrstuvwxyz'); + const sessionId = await createSession(r); + const meta = await uploadFile(r, data, 'clip.mp4', 'video/mp4'); + await materializeUploadedFile(r, sessionId, meta); + + const res = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions/${sessionId}/media/${meta.id}`, + headers: { range: 'bytes=4-9' }, + }); + + expect(res.statusCode).toBe(206); + expect(res.headers['content-type']).toBe('video/mp4'); + expect(res.headers['content-range']).toBe(`bytes 4-9/${data.length}`); + expect(res.headers['content-length']).toBe('6'); + expect(res.rawPayload).toEqual(data.subarray(4, 10)); + }); + + it('serves the session copy after a server restart', async () => { + let r = await boot(); + const data = Buffer.from('restart-safe media'); + const sessionId = await createSession(r); + const meta = await uploadFile(r, data, 'restart.png', 'image/png'); + await materializeUploadedFile(r, sessionId, meta); + await appOf(r).inject({ method: 'DELETE', url: `/api/v1/files/${meta.id}` }); + + await r.close(); + server = undefined; + r = await boot(); + const res = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions/${sessionId}/media/${meta.id}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.rawPayload).toEqual(data); + }); + + it('serves the copied media from a forked session', async () => { + const r = await boot(); + const data = Buffer.from('forked media'); + const sessionId = await createSession(r); + const meta = await uploadFile(r, data, 'fork.png', 'image/png'); + await materializeUploadedFile(r, sessionId, meta); + const fork = await r.core.accessor.get(ISessionManager).fork({ + sourceSessionId: sessionId, + }); + + const res = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions/${fork.id}/media/${meta.id}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + expect(String(res.headers['content-disposition'])).toMatch(/filename="fork\.png"/); + expect(res.rawPayload).toEqual(data); + }); + + it('returns session-not-found after the owning session is deleted', async () => { + const r = await boot(); + const data = Buffer.from('delete-owned media'); + const sessionId = await createSession(r); + const meta = await uploadFile(r, data, 'delete.png', 'image/png'); + await materializeUploadedFile(r, sessionId, meta); + await r.core.accessor.get(ISessionManager).delete(sessionId); + + const res = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions/${sessionId}/media/${meta.id}`, + }); + + expect(res.statusCode).toBe(404); + expect((res.json() as Envelope).code).toBe(40401); + }); +}); diff --git a/packages/kap-server/test/fs.test.ts b/packages/kap-server/test/fs.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..de43d112504bada16c7be3d60856fd8257297826 --- /dev/null +++ b/packages/kap-server/test/fs.test.ts @@ -0,0 +1,767 @@ +import { chmod, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, sep } from 'node:path'; + +import { IModelCatalog, IWorkspaceInstanceManager } from '@moonshot-ai/agent-core-v2'; +import { HostFileSystem } from '@moonshot-ai/agent-core-v2/os/backends/node-local/hostFsService'; +import { FakeRuntime } from '@moonshot-ai/agent-core-v2/runtime/fakeRuntime'; +import { ErrorCode } from '../src/protocol/error-codes'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; +import { fakeModelCatalog } from './helpers/fakeModelCatalog'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: { path: string; message: string }[]; +} + +interface FsEntryWire { + path: string; + name: string; + kind: string; + size?: number; + modified_at: string; + etag?: string; + mime?: string; +} + +describe('server-v2 /api/v1 fs routes', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let work: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-home-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds: [[IModelCatalog, fakeModelCatalog()]], + }); + base = `http://127.0.0.1:${server.port}`; + }); + + beforeEach(async () => { + work = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-work-')); + }); + + afterEach(async () => { + if (work !== undefined) { + await rm(work, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + work = undefined; + } + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + home = undefined; + } + }); + + async function createSession(): Promise { + const res = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ metadata: { cwd: work as string } }), + } as never); + const body = (await res.json()) as Envelope<{ id: string }>; + expect(body.code).toBe(0); + return body.data.id; + } + + async function postFs(id: string, action: string, body: unknown, runtimeId = 'local'): Promise> { + const res = await fetch(`${base}/api/v1/sessions/${id}/fs:${action}`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ runtime_id: runtimeId, ...(body as object) }), + } as never); + return (await res.json()) as Envelope; + } + + it('defaults fs actions to the local runtime when runtime_id is omitted', async () => { + await writeFile(join(work!, 'a.txt'), 'hello'); + const id = await createSession(); + const res = await fetch(`${base}/api/v1/sessions/${id}/fs:stat`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ path: 'a.txt' }), + } as never); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(0); + expect(body.data.name).toBe('a.txt'); + }); + + it('fs:stat returns a file entry with the protocol shape', async () => { + await writeFile(join(work!, 'a.txt'), 'hello'); + const id = await createSession(); + const body = await postFs(id, 'stat', { path: 'a.txt' }); + expect(body.code).toBe(0); + expect(body.data.name).toBe('a.txt'); + expect(body.data.kind).toBe('file'); + expect(body.data.size).toBe(5); + expect(typeof body.data.modified_at).toBe('string'); + expect(typeof body.data.etag).toBe('string'); + }); + + it('fs:stat maps a missing path to FS_PATH_NOT_FOUND', async () => { + const id = await createSession(); + const body = await postFs(id, 'stat', { path: 'nope.txt' }); + expect(body.code).toBe(ErrorCode.FS_PATH_NOT_FOUND); + }); + + it('fs:read returns utf-8 content', async () => { + await writeFile(join(work!, 'a.txt'), 'hello world'); + const id = await createSession(); + const body = await postFs<{ content: string; encoding: string; size: number }>( + id, + 'read', + { path: 'a.txt' }, + ); + expect(body.code).toBe(0); + expect(body.data.content).toBe('hello world'); + expect(body.data.encoding).toBe('utf-8'); + expect(body.data.size).toBe(11); + }); + + it('fs:read uses the selected non-local runtime and mapped workspace root', async () => { + await writeFile(join(work!, 'selected.txt'), 'local'); + const remote = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-remote-')); + await writeFile(join(remote, 'selected.txt'), 'remote'); + const id = await createSession(); + const provider = await server!.core.accessor.get(IWorkspaceInstanceManager).addProvider({ + id: 'remote-test-provider', + imports: { root: [], imports: [], local: [] }, + attach: async (context, host) => { + const runtime = Object.assign( + new FakeRuntime( + { workspaceId: context.id, runtimeId: 'remote-test', generation: 'remote-generation' }, + { + capabilities: ['fs'], + mapWorkspaceRoots: () => ({ workDir: remote, additionalDirs: [] }), + }, + ), + { fs: new HostFileSystem() }, + ); + const registration = host.registerRuntime(runtime); + return { dispose: () => registration.remove() }; + }, + }); + try { + const body = await postFs<{ content: string }>( + id, + 'read', + { path: 'selected.txt' }, + 'remote-test', + ); + expect(body.code).toBe(0); + expect(body.data.content).toBe('remote'); + } finally { + await provider.dispose(); + await rm(remote, { recursive: true, force: true }); + } + }); + + it('fs:read maps a directory to FS_IS_DIRECTORY', async () => { + const id = await createSession(); + const body = await postFs(id, 'read', { path: '.' }); + expect(body.code).toBe(ErrorCode.FS_IS_DIRECTORY); + }); + + it('fs:read maps a permission-denied host error to FS_PERMISSION_DENIED', async () => { + if (process.getuid?.() === 0) return; + const file = join(work!, 'locked.txt'); + await writeFile(file, 'secret'); + await chmod(file, 0o000); + try { + const id = await createSession(); + const body = await postFs(id, 'read', { path: 'locked.txt' }); + expect(body.code).toBe(ErrorCode.FS_PERMISSION_DENIED); + } finally { + await chmod(file, 0o644); + } + }); + + it('fs:list returns items', async () => { + await writeFile(join(work!, 'a.txt'), ''); + await writeFile(join(work!, 'b.txt'), ''); + const id = await createSession(); + const body = await postFs<{ items: FsEntryWire[]; truncated: boolean }>(id, 'list', {}); + expect(body.code).toBe(0); + const names = body.data.items.map((i) => i.name).sort(); + expect(names).toEqual(['a.txt', 'b.txt']); + expect(body.data.truncated).toBe(false); + }); + + it('fs:mkdir creates a directory and rejects duplicates', async () => { + const id = await createSession(); + const created = await postFs(id, 'mkdir', { path: 'sub' }); + expect(created.code).toBe(0); + expect(created.data.kind).toBe('directory'); + + const dup = await postFs(id, 'mkdir', { path: 'sub' }); + expect(dup.code).toBe(ErrorCode.FS_ALREADY_EXISTS); + }); + + it('fs:stat_many returns null for missing paths', async () => { + await writeFile(join(work!, 'a.txt'), 'hi'); + const id = await createSession(); + const body = await postFs<{ entries: Record }>( + id, + 'stat_many', + { paths: ['a.txt', 'missing.txt'] }, + ); + expect(body.code).toBe(0); + expect(body.data.entries['a.txt']?.kind).toBe('file'); + expect(body.data.entries['missing.txt']).toBeNull(); + }); + + it('fs:search finds files by query', async () => { + await writeFile(join(work!, 'alpha.ts'), ''); + await writeFile(join(work!, 'beta.ts'), ''); + const id = await createSession(); + const body = await postFs<{ items: { path: string }[]; truncated: boolean }>( + id, + 'search', + { query: 'alpha' }, + ); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('alpha.ts'); + }); + + it('fs:search resolves a registered workspace id when no session exists', async () => { + await writeFile(join(work!, 'gamma.ts'), ''); + const res = await fetch(`${base}/api/v1/workspaces`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ root: work }), + } as never); + const created = (await res.json()) as Envelope<{ id: string }>; + expect(created.code).toBe(0); + const body = await postFs<{ items: { path: string }[]; truncated: boolean }>( + created.data.id, + 'search', + { query: 'gamma' }, + ); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('gamma.ts'); + }); + + it('fs:search resolves an unregistered workspace root path', async () => { + await writeFile(join(work!, 'delta.ts'), ''); + const body = await postFs<{ items: { path: string }[]; truncated: boolean }>( + encodeURIComponent(work!), + 'search', + { query: 'delta' }, + ); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('delta.ts'); + }); + + it('fs:search still maps an unknown ref to SESSION_NOT_FOUND', async () => { + const body = await postFs('does-not-exist', 'search', { query: 'x' }); + expect(body.code).toBe(ErrorCode.SESSION_NOT_FOUND); + }); + + it('fs:grep finds matching lines', async () => { + await writeFile(join(work!, 'a.txt'), 'hello world\nfoo bar\n'); + const id = await createSession(); + const body = await postFs<{ files: { path: string; matches: unknown[] }[] }>( + id, + 'grep', + { pattern: 'hello' }, + ); + expect(body.code).toBe(0); + expect(body.data.files.length).toBeGreaterThanOrEqual(1); + }); + + it('fs:git_status maps a non-git workspace to FS_GIT_UNAVAILABLE', async () => { + const id = await createSession(); + const body = await postFs(id, 'git_status', {}); + expect(body.code).toBe(ErrorCode.FS_GIT_UNAVAILABLE); + }); + + it('rejects an unknown action with VALIDATION_FAILED', async () => { + const id = await createSession(); + const body = await postFs(id, 'bogus', {}); + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); + }); + + it('maps an unknown session to SESSION_NOT_FOUND', async () => { + const body = await postFs('does-not-exist', 'stat', { path: 'a.txt' }); + expect(body.code).toBe(ErrorCode.SESSION_NOT_FOUND); + }); + + it('rejects a path that escapes the workspace', async () => { + const id = await createSession(); + const body = await postFs(id, 'stat', { path: '../etc/passwd' }); + expect(body.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); + }); + + it('rejects reads and downloads that escape the workspace through a symlink', async () => { + const outside = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-outside-')); + try { + await writeFile(join(outside, 'secret.txt'), 'top-secret'); + await symlink(outside, join(work!, 'docs'), 'dir'); + const id = await createSession(); + + const body = await postFs(id, 'read', { path: 'docs/secret.txt' }); + expect(body.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); + + const res = await fetch(`${base}/api/v1/sessions/${id}/fs/docs/secret.txt:download?runtime_id=local`, { + headers: authHeaders(server as RunningServer), + } as never); + const downloadBody = (await res.json()) as Envelope; + expect(downloadBody.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + it('serves fs actions when the session cwd itself goes through a symlink', async () => { + const link = join(tmpdir(), `kimi-server-v2-fs-cwd-link-${process.pid}`); + await symlink(work!, link, 'dir'); + try { + const res = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ metadata: { cwd: link } }), + } as never); + const body = (await res.json()) as Envelope<{ id: string }>; + expect(body.code).toBe(0); + + await writeFile(join(work!, 'via-link.txt'), 'through-link'); + const read = await postFs<{ content: string }>(body.data.id, 'read', { + path: 'via-link.txt', + }); + expect(read.code).toBe(0); + expect(read.data.content).toBe('through-link'); + } finally { + await rm(link, { force: true }); + } + }); + + async function postWorkspaceSearch(body: unknown): Promise> { + const res = await fetch(`${base}/api/v1/workspace/fs:search`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ runtime_id: 'local', ...(body as object) }), + } as never); + return (await res.json()) as Envelope; + } + + it('workspace fs:search finds files by registered workspace id', async () => { + await writeFile(join(work!, 'epsilon.ts'), ''); + const res = await fetch(`${base}/api/v1/workspaces`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ root: work }), + } as never); + const created = (await res.json()) as Envelope<{ id: string }>; + expect(created.code).toBe(0); + + const body = await postWorkspaceSearch<{ items: { path: string }[]; truncated: boolean }>({ + workspace: created.data.id, + query: 'epsilon', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('epsilon.ts'); + }); + + it('workspace fs:search finds files by absolute root path', async () => { + await writeFile(join(work!, 'zeta.ts'), ''); + const body = await postWorkspaceSearch<{ items: { path: string }[]; truncated: boolean }>({ + workspace: work, + query: 'zeta', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('zeta.ts'); + }); + + it('workspace fs:search lists top-level entries for an empty query', async () => { + await writeFile(join(work!, 'eta.ts'), ''); + const body = await postWorkspaceSearch<{ items: { path: string }[]; truncated: boolean }>({ + workspace: work, + query: '', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('eta.ts'); + }); + + it('workspace fs:search defaults to the local runtime when runtime_id is omitted', async () => { + await writeFile(join(work!, 'theta.ts'), ''); + const res = await fetch(`${base}/api/v1/workspace/fs:search`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ workspace: work, query: 'theta' }), + } as never); + const body = (await res.json()) as Envelope<{ items: { path: string }[]; truncated: boolean }>; + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('theta.ts'); + }); + + it('workspace fs:search maps an unknown ref to WORKSPACE_NOT_FOUND', async () => { + const body = await postWorkspaceSearch({ workspace: 'does-not-exist', query: 'x' }); + expect(body.code).toBe(ErrorCode.WORKSPACE_NOT_FOUND); + }); + + it('workspace fs:search rejects a missing workspace field with VALIDATION_FAILED', async () => { + const body = await postWorkspaceSearch({ query: 'x' }); + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); + }); + + interface SuggestItemWire { + path: string; + name: string; + kind: string; + score: number; + match_positions: number[]; + } + + async function postWorkspaceSuggest(body: unknown): Promise> { + const res = await fetch(`${base}/api/v1/workspace/fs:suggest`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ runtime_id: 'local', ...(body as object) }), + } as never); + return (await res.json()) as Envelope; + } + + it('workspace fs:suggest finds files by registered workspace id', async () => { + await writeFile(join(work!, 'epsilon.ts'), ''); + const res = await fetch(`${base}/api/v1/workspaces`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ root: work }), + } as never); + const created = (await res.json()) as Envelope<{ id: string }>; + expect(created.code).toBe(0); + + const body = await postWorkspaceSuggest<{ items: SuggestItemWire[]; truncated: boolean }>({ + workspace: created.data.id, + query: 'epsilon', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('epsilon.ts'); + }); + + it('workspace fs:suggest finds files by absolute root path', async () => { + await writeFile(join(work!, 'zeta.ts'), ''); + const body = await postWorkspaceSuggest<{ items: SuggestItemWire[]; truncated: boolean }>({ + workspace: work, + query: 'zeta', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('zeta.ts'); + }); + + it('workspace fs:suggest lists top-level entries for an empty query', async () => { + await writeFile(join(work!, 'eta.ts'), ''); + const body = await postWorkspaceSuggest<{ items: SuggestItemWire[]; truncated: boolean }>({ + workspace: work, + query: '', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('eta.ts'); + }); + + it('workspace fs:suggest matches path segments and returns scored items', async () => { + await mkdir(join(work!, 'apps')); + await mkdir(join(work!, 'apps', 'desktop')); + await writeFile(join(work!, 'apps', 'desktop', 'package.json'), '{}'); + const body = await postWorkspaceSuggest<{ items: SuggestItemWire[]; truncated: boolean }>({ + workspace: work, + query: 'apps/de', + }); + expect(body.code).toBe(0); + expect(body.data.items.length).toBeGreaterThan(0); + expect(body.data.items[0]?.path).toBe('apps/desktop'); + expect(body.data.items[0]?.kind).toBe('directory'); + expect(body.data.items.map((i) => i.path)).toContain('apps/desktop/package.json'); + for (const item of body.data.items) { + expect(item.score).toBeGreaterThan(0); + expect(item.score).toBeLessThanOrEqual(1); + expect(Array.isArray(item.match_positions)).toBe(true); + } + }); + + it('workspace fs:suggest returns an empty list when a path-form query has no match', async () => { + const body = await postWorkspaceSuggest<{ items: SuggestItemWire[]; truncated: boolean }>({ + workspace: work, + query: 'zzz/qqq', + }); + expect(body.code).toBe(0); + expect(body.data.items).toEqual([]); + expect(body.data.truncated).toBe(false); + }); + + it('workspace fs:suggest hides dotfiles by default and shows them with show_hidden', async () => { + await writeFile(join(work!, '.theta.ts'), ''); + const hidden = await postWorkspaceSuggest<{ items: SuggestItemWire[] }>({ + workspace: work, + query: 'theta', + }); + expect(hidden.code).toBe(0); + expect(hidden.data.items.map((i) => i.path)).not.toContain('.theta.ts'); + + const shown = await postWorkspaceSuggest<{ items: SuggestItemWire[] }>({ + workspace: work, + query: 'theta', + show_hidden: true, + }); + expect(shown.code).toBe(0); + expect(shown.data.items.map((i) => i.path)).toContain('.theta.ts'); + }); + + it('workspace fs:suggest defaults to the local runtime when runtime_id is omitted', async () => { + await writeFile(join(work!, 'iota.ts'), ''); + const res = await fetch(`${base}/api/v1/workspace/fs:suggest`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ workspace: work, query: 'iota' }), + } as never); + const body = (await res.json()) as Envelope<{ items: SuggestItemWire[]; truncated: boolean }>; + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('iota.ts'); + }); + + it('workspace fs:suggest maps an unknown ref to WORKSPACE_NOT_FOUND', async () => { + const body = await postWorkspaceSuggest({ workspace: 'does-not-exist', query: 'x' }); + expect(body.code).toBe(ErrorCode.WORKSPACE_NOT_FOUND); + }); + + it('workspace fs:suggest rejects a missing workspace field with VALIDATION_FAILED', async () => { + const body = await postWorkspaceSuggest({ query: 'x' }); + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); + }); + + it('workspace fs:suggest rejects a missing query field with VALIDATION_FAILED', async () => { + const body = await postWorkspaceSuggest({ workspace: work }); + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); + }); + + async function postRootSuggest(body: unknown): Promise> { + const res = await fetch(`${base}/api/v1/fs:suggest`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify(body), + } as never); + return (await res.json()) as Envelope; + } + + async function listWorkspaces(): Promise<{ id: string; root: string }[]> { + const res = await fetch(`${base}/api/v1/workspaces`, { + headers: authHeaders(server as RunningServer), + } as never); + const body = (await res.json()) as Envelope<{ items: { id: string; root: string }[] }>; + expect(body.code).toBe(0); + return body.data.items; + } + + it('fs:suggest serves an unregistered root without touching the workspace catalog', async () => { + await writeFile(join(work!, 'kappa.ts'), ''); + const body = await postRootSuggest<{ items: SuggestItemWire[]; truncated: boolean }>({ + roots: [work], + query: 'kappa', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('kappa.ts'); + + const workAliases = [work!, await realpath(work!)]; + expect((await listWorkspaces()).some((w) => workAliases.includes(w.root))).toBe(false); + expect( + server!.core.accessor + .get(IWorkspaceInstanceManager) + .list() + .some((w) => workAliases.includes(w.root)), + ).toBe(false); + + const again = await postRootSuggest<{ items: SuggestItemWire[] }>({ + roots: [work], + query: 'kappa', + }); + expect(again.code).toBe(0); + expect((await listWorkspaces()).some((w) => workAliases.includes(w.root))).toBe(false); + }); + + it('fs:suggest matches the workspace route for the same single root', async () => { + await mkdir(join(work!, 'apps')); + await mkdir(join(work!, 'apps', 'desktop')); + await writeFile(join(work!, 'apps', 'desktop', 'package.json'), '{}'); + await writeFile(join(work!, 'README.md'), ''); + const viaWorkspace = await postWorkspaceSuggest<{ items: SuggestItemWire[]; truncated: boolean }>({ + workspace: work, + query: 'apps/de', + }); + const viaRoots = await postRootSuggest<{ items: SuggestItemWire[]; truncated: boolean }>({ + roots: [work], + query: 'apps/de', + }); + expect(viaWorkspace.code).toBe(0); + expect(viaRoots.code).toBe(0); + expect(viaRoots.data).toEqual(viaWorkspace.data); + }); + + it('fs:suggest merges candidates across roots with relative paths for the primary root only', async () => { + const extra = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-extra-')); + try { + await writeFile(join(work!, 'shared-name.ts'), ''); + await mkdir(join(extra, 'lib')); + await writeFile(join(extra, 'lib', 'util.ts'), ''); + const body = await postRootSuggest<{ items: SuggestItemWire[]; truncated: boolean }>({ + roots: [work, extra], + query: 'util', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain(join(extra, 'lib', 'util.ts').split(sep).join('/')); + const shared = await postRootSuggest<{ items: SuggestItemWire[] }>({ + roots: [work, extra], + query: 'shared', + }); + expect(shared.data.items.map((i) => i.path)).toContain('shared-name.ts'); + } finally { + await rm(extra, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it('fs:suggest lists top-level entries of every root for an empty query', async () => { + const extra = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-extra-')); + try { + await writeFile(join(work!, 'top-work.ts'), ''); + await writeFile(join(extra, 'top-extra.ts'), ''); + const body = await postRootSuggest<{ items: SuggestItemWire[]; truncated: boolean }>({ + roots: [work, extra], + query: '', + }); + expect(body.code).toBe(0); + const paths = body.data.items.map((i) => i.path); + expect(paths).toContain('top-work.ts'); + expect(paths).toContain(join(extra, 'top-extra.ts').split(sep).join('/')); + } finally { + await rm(extra, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it('fs:suggest deduplicates overlapping roots', async () => { + await mkdir(join(work!, 'sub')); + await writeFile(join(work!, 'sub', 'dup.ts'), ''); + await writeFile(join(work!, 'top.ts'), ''); + const nested = await postRootSuggest<{ items: SuggestItemWire[] }>({ + roots: [work, join(work!, 'sub')], + query: 'dup', + }); + expect(nested.code).toBe(0); + expect(nested.data.items.filter((i) => i.path === join('sub', 'dup.ts').split(sep).join('/'))).toHaveLength(1); + + const reversed = await postRootSuggest<{ items: SuggestItemWire[] }>({ + roots: [join(work!, 'sub'), work], + query: 'dup', + }); + expect(reversed.code).toBe(0); + expect(reversed.data.items.filter((i) => i.path === 'dup.ts')).toHaveLength(1); + const top = await postRootSuggest<{ items: SuggestItemWire[] }>({ + roots: [join(work!, 'sub'), work], + query: 'top', + }); + expect(top.data.items.map((i) => i.path)).toContain(join(work!, 'top.ts').split(sep).join('/')); + }); + + it('fs:suggest applies the limit to the merged ranking across roots', async () => { + const extra = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-extra-')); + try { + await writeFile(join(work!, 'a1.ts'), ''); + await writeFile(join(extra, 'a2.ts'), ''); + await writeFile(join(extra, 'a3.ts'), ''); + const body = await postRootSuggest<{ items: SuggestItemWire[]; truncated: boolean }>({ + roots: [work, extra], + query: 'a', + limit: 2, + }); + expect(body.code).toBe(0); + expect(body.data.items).toHaveLength(2); + expect(body.data.truncated).toBe(true); + } finally { + await rm(extra, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it('fs:suggest honors follow_gitignore on each root', async () => { + const extra = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-extra-')); + try { + await writeFile(join(extra, '.gitignore'), 'ignored-extra.ts\n'); + await writeFile(join(extra, 'ignored-extra.ts'), ''); + const followed = await postRootSuggest<{ items: SuggestItemWire[] }>({ + roots: [extra], + query: 'ignored', + }); + expect(followed.code).toBe(0); + expect(followed.data.items).toEqual([]); + const ignored = await postRootSuggest<{ items: SuggestItemWire[] }>({ + roots: [extra], + query: 'ignored', + follow_gitignore: false, + }); + expect(ignored.code).toBe(0); + expect(ignored.data.items.map((i) => i.path)).toContain('ignored-extra.ts'); + } finally { + await rm(extra, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it('fs:suggest maps a missing root to FS_PATH_NOT_FOUND', async () => { + const body = await postRootSuggest({ + roots: [join(work!, 'no-such-dir')], + query: 'x', + }); + expect(body.code).toBe(ErrorCode.FS_PATH_NOT_FOUND); + }); + + it('fs:suggest maps a non-directory root to FS_PATH_NOT_FOUND', async () => { + await writeFile(join(work!, 'a-file.ts'), ''); + const body = await postRootSuggest({ roots: [join(work!, 'a-file.ts')], query: 'x' }); + expect(body.code).toBe(ErrorCode.FS_PATH_NOT_FOUND); + }); + + it('fs:suggest rejects a relative root with VALIDATION_FAILED', async () => { + const body = await postRootSuggest({ roots: ['relative/path'], query: 'x' }); + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); + }); + + it('fs:suggest rejects a missing roots field with VALIDATION_FAILED', async () => { + const body = await postRootSuggest({ query: 'x' }); + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); + }); + + it('fs:suggest rejects more than 32 roots with VALIDATION_FAILED', async () => { + const roots = Array.from({ length: 33 }, (_, i) => join(work!, `root-${i}`)); + const body = await postRootSuggest({ roots, query: 'x' }); + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); + }); + + it('fs:suggest rejects a missing query field with VALIDATION_FAILED', async () => { + const body = await postRootSuggest({ roots: [work] }); + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); + }); + + it('fs:suggest maps an unknown runtime to RUNTIME_NOT_FOUND', async () => { + const body = await postRootSuggest({ + roots: [work], + query: 'x', + runtime_id: 'no-such-runtime', + }); + expect(body.code).toBe(ErrorCode.RUNTIME_NOT_FOUND); + }); +}); diff --git a/packages/kap-server/test/globalSetup.ts b/packages/kap-server/test/globalSetup.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac5160696a496cc00bf6323e2ea26e896d4a496f --- /dev/null +++ b/packages/kap-server/test/globalSetup.ts @@ -0,0 +1,36 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { IModelCatalog } from '@moonshot-ai/agent-core-v2'; +import type { TestProject } from 'vitest/node'; + +import { startServer } from '../src/start'; +import { fakeModelCatalog } from './helpers/fakeModelCatalog'; +import { fixedTokenAuth } from './helpers/fixedAuth'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +export const SHARED_SERVER_TOKEN = 'test-token'; + +export default async function globalSetup(project: TestProject): Promise<() => Promise> { + process.env['KIMI_CODE_SEARCH_WORKER'] = 'false'; + process.env['KIMI_CODE_PERSISTENCE_MINIDB_READMODEL'] = 'false'; + const home = await mkdtemp(join(tmpdir(), 'kimi-kap-server-shared-home-')); + const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + authTokenService: fixedTokenAuth(SHARED_SERVER_TOKEN), + seeds: [[IModelCatalog, fakeModelCatalog()]], + }); + project.provide('sharedServer', { + base: `http://127.0.0.1:${server.port}`, + token: SHARED_SERVER_TOKEN, + }); + return async () => { + await server.close(); + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }; +} diff --git a/packages/kap-server/test/guiStore.test.ts b/packages/kap-server/test/guiStore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7afacc2e3f4fc15223ff1f197db01105133b5bd --- /dev/null +++ b/packages/kap-server/test/guiStore.test.ts @@ -0,0 +1,196 @@ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +interface InjectResponse { + statusCode: number; + json: () => unknown; +} + +interface AppLike { + inject: (req: unknown) => Promise; +} + +interface Envelope { + code: number; + msg: string; + data: T | null; + request_id: string; +} + +function appOf(r: RunningServer): AppLike { + const app = r.app as unknown as AppLike; + return { + inject(req: unknown): Promise { + const request = req as { headers?: Record }; + return app.inject({ + ...request, + headers: { + ...request.headers, + authorization: `Bearer ${r.authTokenService.getToken()}`, + }, + }); + }, + }; +} + +function envelopeOf(body: unknown): Envelope { + return body as Envelope; +} + +function getItem(api: AppLike, key: string) { + return api.inject({ method: 'GET', url: `/api/v1/gui/store/getItem?key=${key}` }); +} + +function setItem(api: AppLike, key: string, value: string) { + return api.inject({ + method: 'POST', + url: '/api/v1/gui/store/setItem', + payload: { key, value }, + }); +} + +describe('server-v2 gui store routes', () => { + let home: string | undefined; + let server: RunningServer | undefined; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-gui-store-')); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it('getItem returns null when the file does not exist', async () => { + const res = await getItem(appOf(server as RunningServer), 'theme'); + expect(res.statusCode).toBe(200); + const env = envelopeOf<{ value: string | null }>(res.json()); + expect(env.code).toBe(0); + expect(env.data?.value).toBeNull(); + }); + + it('setItem then getItem round-trips and persists to gui.toml', async () => { + const r = server as RunningServer; + const api = appOf(r); + const setRes = await setItem(api, 'theme', 'modern'); + expect(envelopeOf(setRes.json()).code).toBe(0); + + const getEnv = envelopeOf<{ value: string | null }>((await getItem(api, 'theme')).json()); + expect(getEnv.data?.value).toBe('modern'); + + const text = await readFile(join(home as string, 'gui.toml'), 'utf-8'); + expect(text).toContain('theme = "modern"'); + }); + + it('setItem overwrites an existing value', async () => { + const api = appOf(server as RunningServer); + await setItem(api, 'theme', 'modern'); + await setItem(api, 'theme', 'terminal'); + + const env = envelopeOf<{ value: string | null }>((await getItem(api, 'theme')).json()); + expect(env.data?.value).toBe('terminal'); + }); + + it('removeItem deletes a key and leaves others intact', async () => { + const api = appOf(server as RunningServer); + await setItem(api, 'a', '1'); + await setItem(api, 'b', '2'); + const rmRes = await api.inject({ + method: 'POST', + url: '/api/v1/gui/store/removeItem', + payload: { key: 'a' }, + }); + expect(envelopeOf(rmRes.json()).code).toBe(0); + + expect(envelopeOf<{ value: string | null }>((await getItem(api, 'a')).json()).data?.value).toBeNull(); + expect(envelopeOf<{ value: string | null }>((await getItem(api, 'b')).json()).data?.value).toBe('2'); + }); + + it('removeItem on a missing key is a no-op', async () => { + const res = await appOf(server as RunningServer).inject({ + method: 'POST', + url: '/api/v1/gui/store/removeItem', + payload: { key: 'nope' }, + }); + expect(envelopeOf(res.json()).code).toBe(0); + }); + + it('length reports the count and clear empties the store', async () => { + const api = appOf(server as RunningServer); + await api.inject({ method: 'POST', url: '/api/v1/gui/store/clear' }); + await setItem(api, 'a', '1'); + await setItem(api, 'b', '2'); + + const before = envelopeOf<{ length: number }>( + (await api.inject({ method: 'GET', url: '/api/v1/gui/store/length' })).json(), + ); + expect(before.data?.length).toBe(2); + + const clearRes = await api.inject({ method: 'POST', url: '/api/v1/gui/store/clear' }); + expect(envelopeOf(clearRes.json()).code).toBe(0); + + const after = envelopeOf<{ length: number }>( + (await api.inject({ method: 'GET', url: '/api/v1/gui/store/length' })).json(), + ); + expect(after.data?.length).toBe(0); + }); + + it('quotes dotted keys in the persisted TOML and reads them back', async () => { + const api = appOf(server as RunningServer); + await setItem(api, 'kimi-web.theme', 'modern'); + + const text = await readFile(join(home as string, 'gui.toml'), 'utf-8'); + expect(text).toContain('"kimi-web.theme" = "modern"'); + + const env = envelopeOf<{ value: string | null }>((await getItem(api, 'kimi-web.theme')).json()); + expect(env.data?.value).toBe('modern'); + }); + + it('rejects an empty key', async () => { + const res = await appOf(server as RunningServer).inject({ + method: 'POST', + url: '/api/v1/gui/store/setItem', + payload: { key: '', value: 'x' }, + }); + expect(envelopeOf(res.json()).code).toBe(40001); + }); + + it('treats Object.prototype keys as ordinary keys', async () => { + const api = appOf(server as RunningServer); + expect( + envelopeOf<{ value: string | null }>((await getItem(api, 'toString')).json()).data?.value, + ).toBeNull(); + expect( + envelopeOf<{ value: string | null }>((await getItem(api, 'constructor')).json()).data?.value, + ).toBeNull(); + + await setItem(api, 'hasOwnProperty', 'x'); + await setItem(api, '__proto__', 'y'); + expect( + envelopeOf<{ value: string | null }>((await getItem(api, 'hasOwnProperty')).json()).data?.value, + ).toBe('x'); + expect( + envelopeOf<{ value: string | null }>((await getItem(api, '__proto__')).json()).data?.value, + ).toBe('y'); + }); + + it.skipIf(process.platform === 'win32')('writes gui.toml with 0600 permissions', async () => { + await setItem(appOf(server as RunningServer), 'theme', 'modern'); + const mode = (await stat(join(home as string, 'gui.toml'))).mode & 0o777; + expect(mode).toBe(0o600); + }); +}); diff --git a/packages/kap-server/test/helpers/auth.ts b/packages/kap-server/test/helpers/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b7d0b9aa5acda2b87bfb0ea6a5a7c32b2f1eabb --- /dev/null +++ b/packages/kap-server/test/helpers/auth.ts @@ -0,0 +1,33 @@ +import type { RunningServer } from '../../src/start'; + +type HeaderMap = Record; + +interface FetchOptions { + readonly method?: string; + readonly headers?: HeaderMap; + readonly body?: string; + readonly signal?: AbortSignal; +} + +export function bearerToken(server: RunningServer): string { + return server.authTokenService.getToken(); +} + +export function authHeaders(server: RunningServer, extra: HeaderMap = {}): HeaderMap { + return { + ...extra, + authorization: `Bearer ${bearerToken(server)}`, + }; +} + +export async function authedFetch( + server: RunningServer, + base: string, + path: string, + init: FetchOptions = {}, +): Promise { + return fetch(`${base}${path}`, { + ...init, + headers: authHeaders(server, init.headers), + } as never); +} diff --git a/packages/kap-server/test/helpers/fakeModelCatalog.ts b/packages/kap-server/test/helpers/fakeModelCatalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..1752cfaca8daa49f9d9d3651b2eeff0b53a1939a --- /dev/null +++ b/packages/kap-server/test/helpers/fakeModelCatalog.ts @@ -0,0 +1,28 @@ +import { IModelCatalog } from '@moonshot-ai/agent-core-v2'; + +export function fakeModelCatalog(): IModelCatalog { + return { + _serviceBrand: undefined, + get: () => { + throw new Error('modelCatalog.get not exercised in this test'); + }, + getRequester: () => { + throw new Error('modelCatalog.getRequester not exercised in this test'); + }, + generate: () => { + throw new Error('modelCatalog.generate not exercised in this test'); + }, + ping: () => { + throw new Error('modelCatalog.ping not exercised in this test'); + }, + findByName: () => [], + listModels: async () => [], + listProviders: async () => [], + getProvider: async () => { + throw new Error('modelCatalog.getProvider not exercised in this test'); + }, + setDefaultModel: async () => { + throw new Error('modelCatalog.setDefaultModel not exercised in this test'); + }, + }; +} diff --git a/packages/kap-server/test/helpers/fixedAuth.ts b/packages/kap-server/test/helpers/fixedAuth.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c44055aba7d661858c237fcbd953386c5bc6fb8 --- /dev/null +++ b/packages/kap-server/test/helpers/fixedAuth.ts @@ -0,0 +1,9 @@ +import type { IAuthTokenService } from '../../src/services/auth/authTokenService'; + +export function fixedTokenAuth(token = 'test-token'): IAuthTokenService { + return { + _serviceBrand: undefined, + getToken: () => token, + isValid: async (candidate) => candidate === token, + }; +} diff --git a/packages/kap-server/test/helpers/hostIdentity.ts b/packages/kap-server/test/helpers/hostIdentity.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bd494823b4a2f0fac8810c7a496d6a4c619f12b --- /dev/null +++ b/packages/kap-server/test/helpers/hostIdentity.ts @@ -0,0 +1,7 @@ +import type { ServerHostIdentity } from '../../src/start'; + +export const TEST_HOST_IDENTITY: ServerHostIdentity = { + productName: 'test-host', + version: '0.0.0-test', + platform: 'test_platform', +}; diff --git a/packages/kap-server/test/helpers/sharedServer.ts b/packages/kap-server/test/helpers/sharedServer.ts new file mode 100644 index 0000000000000000000000000000000000000000..941bd6fe718c770d237aa5ab007473ce916535ca --- /dev/null +++ b/packages/kap-server/test/helpers/sharedServer.ts @@ -0,0 +1,34 @@ +import { inject } from 'vitest'; + +export interface SharedServerContext { + readonly base: string; + readonly token: string; +} + +declare module 'vitest' { + interface ProvidedContext { + readonly sharedServer: SharedServerContext; + } +} + +export function sharedServer(): SharedServerContext { + return inject('sharedServer'); +} + +export function sharedAuthHeaders(extra: Record = {}): Record { + return { ...extra, authorization: `Bearer ${sharedServer().token}` }; +} + +interface SharedFetchOptions { + readonly method?: string; + readonly headers?: Record; + readonly body?: string; + readonly signal?: AbortSignal; +} + +export async function sharedAuthedFetch(path: string, init: SharedFetchOptions = {}): Promise { + return fetch(`${sharedServer().base}${path}`, { + ...init, + headers: sharedAuthHeaders(init.headers), + } as never); +} diff --git a/packages/kap-server/test/history.test.ts b/packages/kap-server/test/history.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2896fe0ac20d6c04f648561c1944c652fde2b9de --- /dev/null +++ b/packages/kap-server/test/history.test.ts @@ -0,0 +1,359 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { + IAgentLifecycleService, + IEventBus, + ISessionIndex, + getLiveSessionById, + IModelCatalog, + type ScopeSeed, +} from '@moonshot-ai/agent-core-v2'; +import { TurnStarted, TurnStepStarted } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: { path: string; message: string }[]; +} + +interface HistoryWire { + messages: Record[]; + has_more: boolean; + in_flight?: { turn_id: string; step_id: string }; +} + +const T0 = 1_700_000_000_000; + +function rec(type: string, fields: Record, time: number): string { + return JSON.stringify({ type, time, ...fields }); +} + +function loopEvent(event: Record, time: number): string { + return rec('context.append_loop_event', { event }, time); +} + +const MAIN_WIRE = [ + rec('turn.prompt', { + input: [{ type: 'text', text: 'hello world' }], + origin: { kind: 'user' }, + promptId: 'p0', + }, T0), + rec('context.append_message', { + message: { + id: 'p0', + role: 'user', + content: [{ type: 'text', text: 'hello world' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, T0 + 1), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 2), + loopEvent({ type: 'content.part', stepUuid: 'u1', part: { type: 'text', text: 'Hi there' } }, T0 + 3), + loopEvent({ type: 'tool.call', stepUuid: 'u1', toolCallId: 'call_1', name: 'Bash', args: '{"command":"ls"}' }, T0 + 4), + loopEvent({ type: 'tool.result', toolCallId: 'call_1', result: { output: 'file.txt' } }, T0 + 5), + loopEvent({ type: 'step.end', uuid: 'u1', finishReason: 'stop' }, T0 + 6), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 700 }, T0 + 7), + rec( + 'task.started', + { + info: { + taskId: 'task-2', + kind: 'agent', + agentId: 'sub-1', + parentToolCallId: 'call_1', + status: 'running', + }, + }, + T0 + 8, + ), + rec('turn.prompt', { + input: [{ type: 'text', text: 'second question' }], + origin: { kind: 'user' }, + promptId: 'p1', + }, T0 + 9), + rec('context.append_message', { + message: { + id: 'p1', + role: 'user', + content: [{ type: 'text', text: 'second question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, T0 + 10), + loopEvent({ type: 'step.begin', uuid: 'u2', turnId: '1', step: 1 }, T0 + 11), + loopEvent({ type: 'content.part', stepUuid: 'u2', part: { type: 'text', text: 'second answer' } }, T0 + 12), + loopEvent({ type: 'step.end', uuid: 'u2' }, T0 + 13), + rec('turn.ended', { turnId: 1, reason: 'completed' }, T0 + 14), +]; + +const SUB_WIRE = [ + rec('turn.prompt', { + input: [{ type: 'text', text: 'do sub work' }], + origin: { kind: 'system_trigger', name: 'subagent' }, + }, T0 + 20), + loopEvent({ type: 'step.begin', uuid: 's1', turnId: '0', step: 1 }, T0 + 21), + loopEvent({ type: 'content.part', stepUuid: 's1', part: { type: 'text', text: 'sub answer' } }, T0 + 22), + loopEvent({ type: 'step.end', uuid: 's1' }, T0 + 23), + rec('turn.ended', { turnId: 0, reason: 'completed' }, T0 + 24), +]; + +describe('server /api/v1/sessions/{sid}/history', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + let seeds: ScopeSeed | undefined; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-history-')); + const modelCatalog: IModelCatalog = { + _serviceBrand: undefined, + get: () => { + throw new Error('modelCatalog.get not exercised in this test'); + }, + getRequester: () => { + throw new Error('modelCatalog.getRequester not exercised in this test'); + }, + generate: () => { + throw new Error('modelCatalog.generate not exercised in this test'); + }, + ping: () => { + throw new Error('modelCatalog.ping not exercised in this test'); + }, + findByName: () => [], + listModels: async () => [], + listProviders: async () => [], + getProvider: async () => { + throw new Error('modelCatalog.getProvider not exercised in this test'); + }, + setDefaultModel: async () => { + throw new Error('modelCatalog.setDefaultModel not exercised in this test'); + }, + }; + seeds = [[IModelCatalog, modelCatalog]]; + await boot(); + }); + + async function boot(): Promise { + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home as string, + logLevel: 'silent', + seeds, + }); + base = `http://127.0.0.1:${server.port}`; + } + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function createSession(): Promise { + const res = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ metadata: { cwd: home as string } }), + } as never); + const body = (await res.json()) as Envelope<{ id: string }>; + expect(body.code).toBe(0); + return body.data.id; + } + + async function workspaceIdOf(sessionId: string): Promise { + const summary = await server!.core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) throw new Error(`session ${sessionId} not found in index`); + return summary.workspaceId; + } + + async function writeWire(sessionId: string, agentId: string, lines: readonly string[]): Promise { + const workspaceId = await workspaceIdOf(sessionId); + const path = join(home as string, 'sessions', workspaceId, sessionId, 'agents', agentId, 'wire.jsonl'); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${lines.join('\n')}\n`, 'utf8'); + } + + async function reboot(): Promise { + await server!.close(); + server = undefined; + await boot(); + } + + const entityId = (m: Record): unknown => { + switch (m['type']) { + case 'turn': + return m['turn_id']; + case 'step': + return m['step_id']; + case 'user': + case 'assistant': + case 'thinking': + return m['message_id']; + case 'tool_call': + return m['tool_call_id']; + case 'system': + return m['system_id']; + case 'interaction': + return m['interaction_id']; + case 'task': + return m['task_id']; + default: + return m['type']; + } + }; + + it('returns 40401 for an unknown session', async () => { + const { body } = await getJson('/api/v1/sessions/nope/history'); + expect(body.code).toBe(40401); + }); + + it('returns an empty page without in_flight for a live session with no agent history', async () => { + const id = await createSession(); + const { body } = await getJson(`/api/v1/sessions/${id}/history`); + expect(body.code).toBe(0); + expect(body.data.messages).toEqual([]); + expect(body.data.in_flight).toBeUndefined(); + }); + + it('rejects mutually exclusive cursors and invalid agent ids and page sizes', async () => { + const id = await createSession(); + const both = await getJson(`/api/v1/sessions/${id}/history?before_turn=t1&after_step=t0.1`); + expect(both.body.code).toBe(40001); + const badAgent = await getJson(`/api/v1/sessions/${id}/history?agent_id=..%2Fevil`); + expect(badAgent.body.code).toBe(40001); + const badSize = await getJson(`/api/v1/sessions/${id}/history?page_size=0`); + expect(badSize.body.code).toBe(40001); + }); + + it('cold-rebuilds the main agent timeline from wire records with pagination cursors', async () => { + const id = await createSession(); + await writeWire(id, 'main', MAIN_WIRE); + await writeWire(id, 'sub-1', SUB_WIRE); + await reboot(); + + const all = await getJson(`/api/v1/sessions/${id}/history`); + expect(all.body.code).toBe(0); + expect(all.body.data.in_flight).toBeUndefined(); + expect(all.body.data.has_more).toBe(false); + const ids = all.body.data.messages.map(entityId); + expect(ids).toEqual([ + 't0', + 'p0', + 't0.1', + 't0.1.a1', + 'call_1', + 'task-2', + 't1', + 'p1', + 't1.1', + 't1.1.a1', + ]); + const turn0 = all.body.data.messages[0]!; + expect(turn0).toMatchObject({ + type: 'turn', + turn_id: 't0', + status: 'completed', + origin: { kind: 'user' }, + user_message_id: 'p0', + duration_ms: 700, + }); + const assistant = all.body.data.messages.find((m) => m['message_id'] === 't0.1.a1')!; + expect(assistant).toMatchObject({ type: 'assistant', status: 'completed', text: 'Hi there' }); + const tool = all.body.data.messages.find((m) => m['tool_call_id'] === 'call_1')!; + expect(tool).toMatchObject({ type: 'tool_call', status: 'done', output: 'file.txt', task_id: 'task-2' }); + const task = all.body.data.messages.find((m) => m['type'] === 'task')!; + expect(task).toMatchObject({ type: 'task', kind: 'subagent', child_agent_id: 'sub-1' }); + + const page = await getJson(`/api/v1/sessions/${id}/history?page_size=1`); + expect(page.body.data.messages.map(entityId)).toEqual(['t1', 'p1', 't1.1', 't1.1.a1']); + expect(page.body.data.has_more).toBe(true); + + const older = await getJson(`/api/v1/sessions/${id}/history?before_turn=t1`); + expect(older.body.data.messages.map(entityId)).toEqual([ + 't0', + 'p0', + 't0.1', + 't0.1.a1', + 'call_1', + 'task-2', + ]); + expect(older.body.data.has_more).toBe(false); + + const newer = await getJson(`/api/v1/sessions/${id}/history?after_step=t0.1`); + expect(newer.body.data.messages.map(entityId)).toEqual(['task-2', 't1', 'p1', 't1.1', 't1.1.a1']); + expect(newer.body.data.has_more).toBe(false); + + const missing = await getJson(`/api/v1/sessions/${id}/history?before_turn=t99`); + expect(missing.body.data.messages).toEqual([]); + expect(missing.body.data.has_more).toBe(false); + }); + + it('reads a subagent timeline with agent_id and classifies its turn origin from the main wire', async () => { + const id = await createSession(); + await writeWire(id, 'main', MAIN_WIRE); + await writeWire(id, 'sub-1', SUB_WIRE); + await reboot(); + + const main = await getJson(`/api/v1/sessions/${id}/history?agent_id=main`); + const sub = await getJson(`/api/v1/sessions/${id}/history?agent_id=sub-1`); + expect(sub.body.code).toBe(0); + const ids = sub.body.data.messages.map(entityId); + expect(ids).toEqual(['t0', 't0.u0', 't0.1', 't0.1.a1']); + const turn = sub.body.data.messages[0]!; + expect(turn).toMatchObject({ + type: 'turn', + turn_id: 't0', + agent_id: 'sub-1', + origin: { kind: 'task', task_id: 'task-2' }, + }); + const user = sub.body.data.messages.find((m) => m['type'] === 'user')!; + expect(user).toMatchObject({ + message_id: 't0.u0', + text: [{ type: 'text', text: 'do sub work', meta: {} }], + agent_id: 'sub-1', + }); + expect(main.body.data.messages.map(entityId)).toContain('t1'); + }); + + it('marks the in-flight position for a live session once a turn is streaming', async () => { + const id = await createSession(); + const session = getLiveSessionById(server!.core.accessor, id); + if (session === undefined) throw new Error(`session ${id} not live`); + await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); + const agent = session.accessor.get(IAgentLifecycleService).handleOf('main')!; + + const idle = await getJson(`/api/v1/sessions/${id}/history`); + expect(idle.body.data.in_flight).toBeUndefined(); + + const bus = agent.accessor.get(IEventBus); + bus.publish(new TurnStarted({ agentId: 'main', turnId: 0, origin: { kind: 'user' }, prompt: 'hi' })); + bus.publish(new TurnStepStarted({ agentId: 'main', turnId: 0, step: 1 })); + + const streaming = await getJson(`/api/v1/sessions/${id}/history`); + expect(streaming.body.code).toBe(0); + expect(streaming.body.data.in_flight).toEqual({ turn_id: 't0', step_id: 't0.1' }); + }); +}); diff --git a/packages/kap-server/test/hostExposure.e2e.test.ts b/packages/kap-server/test/hostExposure.e2e.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f796f72ad64caf3d1523a41c0402c286fb5b771 --- /dev/null +++ b/packages/kap-server/test/hostExposure.e2e.test.ts @@ -0,0 +1,165 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Writable } from 'node:stream'; + +import { pino, type Logger } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +const createdDirs: string[] = []; +const running: RunningServer[] = []; +let prevPassword: string | undefined; + +async function tmpHome(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'kimi-v2-host-exposure-')); + createdDirs.push(dir); + return dir; +} + +function capturingLogger(): { logger: Logger; lines: string[] } { + const lines: string[] = []; + const dest = new Writable({ + write(chunk, _enc, cb) { + lines.push(String(chunk)); + cb(); + }, + }); + return { logger: pino({ level: 'info' }, dest), lines }; +} + +beforeEach(() => { + prevPassword = process.env['KIMI_CODE_PASSWORD']; +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + } + } + for (const dir of createdDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } + if (prevPassword === undefined) { + delete process.env['KIMI_CODE_PASSWORD']; + } else { + process.env['KIMI_CODE_PASSWORD'] = prevPassword; + } +}); + +describe('public-bind gate', () => { + it('refuses to bind 0.0.0.0 without --insecure-no-tls', async () => { + const home = await tmpHome(); + await expect( + startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '0.0.0.0', port: 0, homeDir: home, logLevel: 'silent' }), + ).rejects.toThrow(/without TLS/); + }); + + it('boots 0.0.0.0 token-only and logs the token-only warning', async () => { + delete process.env['KIMI_CODE_PASSWORD']; + const home = await tmpHome(); + const { logger, lines } = capturingLogger(); + const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '0.0.0.0', + port: 0, + homeDir: home, + logLevel: 'silent', + insecureNoTls: true, + logger, + }); + running.push(server); + const token = server.authTokenService.getToken(); + const res = await fetch(`http://127.0.0.1:${server.port}/api/v1/healthz`, { + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + expect(lines.join('')).toContain('token-only auth'); + }); +}); + +describe('real password path (verifyPassword)', () => { + async function bootPublic(): Promise { + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + const home = await tmpHome(); + const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '0.0.0.0', + port: 0, + homeDir: home, + logLevel: 'silent', + insecureNoTls: true, + }); + running.push(server); + return server; + } + + it('accepts the password as a bearer token and sets security headers', async () => { + const server = await bootPublic(); + const res = await fetch(`http://127.0.0.1:${server.port}/api/v1/sessions`, { + headers: { authorization: 'Bearer test-pw' }, + }); + expect(res.status).toBe(200); + expect(res.headers.get('x-content-type-options')).toBe('nosniff'); + expect(res.headers.get('content-security-policy')).toBe( + "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'self'", + ); + }); + + it('accepts the persistent token on a public bind', async () => { + const server = await bootPublic(); + const token = server.authTokenService.getToken(); + const res = await fetch(`http://127.0.0.1:${server.port}/api/v1/sessions`, { + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + }); + + it('rejects wrong and missing credentials with 401', async () => { + const server = await bootPublic(); + const wrong = await fetch(`http://127.0.0.1:${server.port}/api/v1/sessions`, { + headers: { authorization: 'Bearer wrong-password' }, + }); + expect(wrong.status).toBe(401); + const missing = await fetch(`http://127.0.0.1:${server.port}/api/v1/sessions`); + expect(missing.status).toBe(401); + }); +}); + +describe('auth-failure rate limit on a real bind', () => { + it('returns 429 on the 11th bad token', async () => { + const home = await tmpHome(); + const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '0.0.0.0', + port: 0, + homeDir: home, + logLevel: 'silent', + insecureNoTls: true, + authTokenService: { + _serviceBrand: undefined, + getToken: () => 'persistent-token', + isValid: async (candidate) => candidate === 'persistent-token', + }, + }); + running.push(server); + const url = `http://127.0.0.1:${server.port}/api/v1/sessions`; + let lastStatus = 0; + for (let i = 0; i < 11; i += 1) { + const res = await fetch(url, { headers: { authorization: 'Bearer wrong' } }); + lastStatus = res.status; + if (i < 10) { + expect(res.status).toBe(401); + } + } + expect(lastStatus).toBe(429); + const body = (await (await fetch(url, { headers: { authorization: 'Bearer wrong' } })).json()) as { + code: number; + }; + expect(body.code).toBe(42901); + }); +}); diff --git a/packages/kap-server/test/hostnames.test.ts b/packages/kap-server/test/hostnames.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..64c61c1e6cb683ab6248f1c93aafa6392666210c --- /dev/null +++ b/packages/kap-server/test/hostnames.test.ts @@ -0,0 +1,215 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createHostCheck, + formatHostErrorMessage, + isAllowedHost, + isHostCheckDisabled, + parseAllowedHosts, + stripPort, +} from '../src/middleware/hostnames'; +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +describe('stripPort', () => { + it('strips the port from a hostname', () => { + expect(stripPort('localhost:80')).toBe('localhost'); + }); + + it('strips the port from bracketed IPv6', () => { + expect(stripPort('[::1]:80')).toBe('[::1]'); + }); + + it('strips the port from an IPv4 literal', () => { + expect(stripPort('1.2.3.4:5678')).toBe('1.2.3.4'); + }); + + it('lowercases bare hosts', () => { + expect(stripPort('LOCALHOST')).toBe('localhost'); + }); +}); + +describe('formatHostErrorMessage', () => { + it('includes the rejected host and allow guidance', () => { + expect(formatHostErrorMessage('APP.Example.com:443')).toBe( + "Invalid Host header: app.example.com; allow this host with KIMI_CODE_ALLOWED_HOSTS=app.example.com or 'kimi web --allowed-host app.example.com'.", + ); + }); +}); + +describe('isAllowedHost (default allow set)', () => { + const allow = ['localhost', 'localhost:80', 'foo.localhost', '127.0.0.1', '127.0.0.1:58627', '[::1]', '::1', '8.8.8.8']; + + for (const host of allow) { + it(`allows ${host}`, () => { + expect(isAllowedHost(host, {})).toBe(true); + }); + } + + const deny = ['evil.example.test', 'evil.example.test:80', '127.0.0.1.evil.example.test']; + + for (const host of deny) { + it(`denies ${host}`, () => { + expect(isAllowedHost(host, {})).toBe(false); + }); + } + + it('denies a missing Host header', () => { + expect(isAllowedHost(undefined, {})).toBe(false); + }); +}); + +describe('isAllowedHost (boundHost)', () => { + it('allows the bound host', () => { + expect(isAllowedHost('myhost', { boundHost: 'myhost' })).toBe(true); + }); + + it('strips the port on both sides', () => { + expect(isAllowedHost('myhost:1234', { boundHost: 'myhost:8080' })).toBe(true); + }); + + it('still denies unrelated hosts', () => { + expect(isAllowedHost('otherhost', { boundHost: 'myhost' })).toBe(false); + }); +}); + +describe('isAllowedHost (extra)', () => { + it('matches a subdomain wildcard', () => { + expect(isAllowedHost('a.example.com', { extra: ['.example.com'] })).toBe(true); + }); + + it('matches the bare domain of a wildcard', () => { + expect(isAllowedHost('example.com', { extra: ['.example.com'] })).toBe(true); + }); + + it('does not match a partial suffix', () => { + expect(isAllowedHost('baddexample.com', { extra: ['.example.com'] })).toBe(false); + }); + + it('matches an exact entry', () => { + expect(isAllowedHost('foo', { extra: ['foo'] })).toBe(true); + }); +}); + +describe('isAllowedHost (disable)', () => { + it('allows everything when disabled', () => { + expect(isAllowedHost('evil.example.test', { disable: true })).toBe(true); + }); +}); + +describe('parseAllowedHosts', () => { + it('splits, trims, and drops empties', () => { + expect(parseAllowedHosts({ KIMI_CODE_ALLOWED_HOSTS: ' a, .b.example.com, ' })).toEqual([ + 'a', + '.b.example.com', + ]); + }); + + it('returns [] when unset', () => { + expect(parseAllowedHosts({})).toEqual([]); + }); +}); + +describe('isHostCheckDisabled', () => { + it('is true when set to "1"', () => { + expect(isHostCheckDisabled({ KIMI_CODE_DISABLE_HOST_CHECK: '1' })).toBe(true); + }); + + it('is false when unset', () => { + expect(isHostCheckDisabled({})).toBe(false); + }); +}); + +describe('createHostCheck (onRequest hook)', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = Fastify(); + app.addHook('onRequest', createHostCheck({}).onRequest); + app.get('/api/v1/probe', async () => ({ ok: true })); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('rejects a disallowed Host with the 40301 envelope', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { host: 'evil.example.test' }, + }); + expect(res.statusCode).toBe(403); + const body = res.json() as Record; + expect(body['code']).toBe(40301); + expect(body['msg']).toBe( + "Invalid Host header: evil.example.test; allow this host with KIMI_CODE_ALLOWED_HOSTS=evil.example.test or 'kimi web --allowed-host evil.example.test'.", + ); + expect(body['data']).toBeNull(); + expect(typeof body['request_id']).toBe('string'); + }); + + it('allows the default app.inject Host (localhost:80)', async () => { + const res = await app.inject({ method: 'GET', url: '/api/v1/probe' }); + expect(res.statusCode).toBe(200); + }); +}); + +describe('startServer allowedHosts — env + option merge', () => { + const ENV_KEY = 'KIMI_CODE_ALLOWED_HOSTS'; + let server: RunningServer | undefined; + let home: string | undefined; + let prevEnv: string | undefined; + + beforeEach(() => { + prevEnv = process.env[ENV_KEY]; + }); + + afterEach(async () => { + if (prevEnv === undefined) { + delete process.env[ENV_KEY]; + } else { + process.env[ENV_KEY] = prevEnv; + } + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it('appends opts.allowedHosts to KIMI_CODE_ALLOWED_HOSTS instead of replacing it', async () => { + process.env[ENV_KEY] = 'env-only.example.com'; + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-host-merge-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + allowedHosts: ['opt-only.example.com'], + }); + const token = server.authTokenService.getToken(); + const probe = async (host: string): Promise => { + const res = await (server as RunningServer).app.inject({ + method: 'GET', + url: '/api/v1/meta', + headers: { host, authorization: `Bearer ${token}` }, + }); + return res.statusCode; + }; + + expect(await probe('env-only.example.com')).toBe(200); + expect(await probe('opt-only.example.com')).toBe(200); + expect(await probe('evil.example.com')).toBe(403); + }); +}); diff --git a/packages/kap-server/test/inFlightTurnTracker.test.ts b/packages/kap-server/test/inFlightTurnTracker.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..28e0fa406e37ffd4aa25ff91bd3ec2bb8049cfb4 --- /dev/null +++ b/packages/kap-server/test/inFlightTurnTracker.test.ts @@ -0,0 +1,122 @@ +import type { Event } from '../src/transport/ws/v1/events'; +import { describe, expect, it } from 'vitest'; + +import { InFlightTurnTracker } from '../src/transport/ws/v1/inFlightTurnTracker'; + +const SID = 'sess_1'; + +function ev(partial: Record): Event { + return { agentId: 'main', sessionId: SID, ...partial } as unknown as Event; +} + +describe('InFlightTurnTracker', () => { + it('accumulates assistant text and reports pre-append offsets', () => { + const t = new InFlightTurnTracker(); + t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); + + expect(t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'Hello' }))).toEqual({ + offset: 0, + }); + expect(t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: ' world' }))).toEqual({ + offset: 5, + }); + + expect(t.get(SID)).toMatchObject({ turn_id: 1, assistant_text: 'Hello world' }); + }); + + it('tracks thinking offsets independently', () => { + const t = new InFlightTurnTracker(); + t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); + expect(t.apply(SID, ev({ type: 'thinking.delta', turnId: 1, delta: 'abc' }))).toEqual({ + offset: 0, + }); + expect(t.apply(SID, ev({ type: 'thinking.delta', turnId: 1, delta: 'de' }))).toEqual({ + offset: 3, + }); + expect(t.get(SID)).toMatchObject({ assistant_text: '', thinking_text: 'abcde' }); + }); + + it('clears on turn.ended', () => { + const t = new InFlightTurnTracker(); + t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); + t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'x' })); + t.apply(SID, ev({ type: 'turn.ended', turnId: 1 })); + expect(t.get(SID)).toBeNull(); + }); + + it('ignores non-main agents', () => { + const t = new InFlightTurnTracker(); + t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); + const sub = { agentId: 'agent-sub', sessionId: SID, type: 'assistant.delta', turnId: 1, delta: 'nope' } as unknown as Event; + expect(t.apply(SID, sub)).toEqual({}); + expect(t.get(SID)?.assistant_text).toBe(''); + }); + + it('ignores deltas for a mismatched turn', () => { + const t = new InFlightTurnTracker(); + t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); + expect(t.apply(SID, ev({ type: 'assistant.delta', turnId: 99, delta: 'stale' }))).toEqual({}); + expect(t.get(SID)?.assistant_text).toBe(''); + }); + + it('tracks running tools and their last progress', () => { + const t = new InFlightTurnTracker(); + t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); + t.apply(SID, ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'tc1', name: 'bash' })); + t.apply( + SID, + ev({ type: 'tool.progress', turnId: 1, toolCallId: 'tc1', update: { kind: 'stdout', text: 'hi' } }), + ); + expect(t.get(SID)?.running_tools).toEqual([ + { tool_call_id: 'tc1', name: 'bash', last_progress: { kind: 'stdout', text: 'hi' } }, + ]); + t.apply(SID, ev({ type: 'tool.result', turnId: 1, toolCallId: 'tc1' })); + expect(t.get(SID)?.running_tools).toEqual([]); + }); + + it('resets text accumulation at step boundaries (step-relative in-flight text)', () => { + const t = new InFlightTurnTracker(); + t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); + t.apply(SID, ev({ type: 'turn.step.started', turnId: 1, step: 1 })); + t.apply(SID, ev({ type: 'thinking.delta', turnId: 1, delta: 'think-1' })); + t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'text-1' })); + t.apply(SID, ev({ type: 'turn.step.completed', turnId: 1, step: 1 })); + + t.apply(SID, ev({ type: 'turn.step.started', turnId: 1, step: 2 })); + t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'text-2' })); + + expect(t.get(SID)).toMatchObject({ assistant_text: 'text-2', thinking_text: '' }); + }); + + it('reports step-relative offsets that restart at 0 each step', () => { + const t = new InFlightTurnTracker(); + t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); + t.apply(SID, ev({ type: 'turn.step.started', turnId: 1, step: 1 })); + expect(t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'ab' }))).toEqual({ offset: 0 }); + expect(t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'cd' }))).toEqual({ offset: 2 }); + + t.apply(SID, ev({ type: 'turn.step.started', turnId: 1, step: 2 })); + expect(t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'x' }))).toEqual({ offset: 0 }); + }); + + it('keeps running tools across step boundaries while resetting text', () => { + const t = new InFlightTurnTracker(); + t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); + t.apply(SID, ev({ type: 'turn.step.started', turnId: 1, step: 1 })); + t.apply(SID, ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'tc1', name: 'bash' })); + t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'text-1' })); + + t.apply(SID, ev({ type: 'turn.step.started', turnId: 1, step: 2 })); + + expect(t.get(SID)?.assistant_text).toBe(''); + expect(t.get(SID)?.running_tools).toEqual([{ tool_call_id: 'tc1', name: 'bash' }]); + }); + + it('ignores step boundaries for a mismatched turn', () => { + const t = new InFlightTurnTracker(); + t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); + t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'keep' })); + t.apply(SID, ev({ type: 'turn.step.started', turnId: 99, step: 2 })); + expect(t.get(SID)?.assistant_text).toBe('keep'); + }); +}); diff --git a/packages/kap-server/test/instanceRegistry.test.ts b/packages/kap-server/test/instanceRegistry.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..625e571f437afc0733e1297ce283c84846d77edf --- /dev/null +++ b/packages/kap-server/test/instanceRegistry.test.ts @@ -0,0 +1,318 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createInstanceRegistry, + getLiveServerInstance, + listLiveServerInstances, + type ServerInstanceInfo, +} from '../src/instanceRegistry'; +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +let tmpDir: string; +let instancesDir: string; + +const DEAD_PID = 0x7fffffff; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-instance-registry-test-')); + instancesDir = join(tmpDir, 'instances'); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +interface DiskInstance { + server_id: string; + pid: number; + host: string; + port: number; + started_at: number; + heartbeat_at: number; + host_version?: string; +} + +function writeInstance(serverId: string, fields: Partial & { pid: number }): void { + mkdirSync(instancesDir, { recursive: true }); + const disk: DiskInstance = { + server_id: serverId, + pid: fields.pid, + host: fields.host ?? '127.0.0.1', + port: fields.port ?? 58627, + started_at: fields.started_at ?? 1000, + heartbeat_at: fields.heartbeat_at ?? 1000, + ...(fields.host_version !== undefined ? { host_version: fields.host_version } : {}), + }; + writeFileSync(join(instancesDir, `${serverId}.json`), JSON.stringify(disk)); +} + +function readInstance(serverId: string): DiskInstance { + return JSON.parse(readFileSync(join(instancesDir, `${serverId}.json`), 'utf8')) as DiskInstance; +} + +function sleep(ms: number): Promise { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + +const baseInfo = { + pid: process.pid, + host: '127.0.0.1', + port: 58627, + startedAt: 1000, +}; + +describe('createInstanceRegistry — register / release', () => { + it('writes a .json file and release removes it', async () => { + const registry = createInstanceRegistry({ instancesDir, now: () => 2000 }); + const reg = await registry.register(baseInfo); + + expect(typeof reg.serverId).toBe('string'); + expect(reg.serverId.length).toBeGreaterThan(0); + const filePath = join(instancesDir, `${reg.serverId}.json`); + expect(existsSync(filePath)).toBe(true); + expect(readInstance(reg.serverId)).toEqual({ + server_id: reg.serverId, + pid: process.pid, + host: '127.0.0.1', + port: 58627, + started_at: 1000, + heartbeat_at: 2000, + }); + + await reg.release(); + expect(existsSync(filePath)).toBe(false); + }); + + it('records host_version when provided', async () => { + const registry = createInstanceRegistry({ instancesDir, now: () => 1 }); + const reg = await registry.register({ ...baseInfo, serverVersion: '1.2.3' }); + expect(readInstance(reg.serverId).host_version).toBe('1.2.3'); + await reg.release(); + }); + + it('assigns distinct serverIds to concurrent registrations', async () => { + const registry = createInstanceRegistry({ instancesDir, now: () => 1 }); + const a = await registry.register(baseInfo); + const b = await registry.register(baseInfo); + expect(a.serverId).not.toBe(b.serverId); + await a.release(); + await b.release(); + }); + + it('release is idempotent', async () => { + const registry = createInstanceRegistry({ instancesDir, now: () => 1 }); + const reg = await registry.register(baseInfo); + await reg.release(); + await expect(reg.release()).resolves.toBeUndefined(); + }); + + it('update is a no-op after release', async () => { + const registry = createInstanceRegistry({ instancesDir, now: () => 1 }); + const reg = await registry.register(baseInfo); + const filePath = join(instancesDir, `${reg.serverId}.json`); + await reg.release(); + await expect(reg.update({ port: 9999 })).resolves.toBeUndefined(); + expect(existsSync(filePath)).toBe(false); + }); +}); + +describe('createInstanceRegistry — stale sweep on register', () => { + it('removes dead-pid entries and keeps live ones when registering', async () => { + const registry = createInstanceRegistry({ instancesDir, now: () => 1 }); + writeInstance('stale', { pid: DEAD_PID }); + writeInstance('live-peer', { pid: process.pid, started_at: 500 }); + + const reg = await registry.register(baseInfo); + + expect(existsSync(join(instancesDir, 'stale.json'))).toBe(false); + expect(existsSync(join(instancesDir, 'live-peer.json'))).toBe(true); + expect(existsSync(join(instancesDir, `${reg.serverId}.json`))).toBe(true); + await reg.release(); + }); + + it('leaves unparseable entries alone (may be a live peer mid-write)', async () => { + const registry = createInstanceRegistry({ instancesDir, now: () => 1 }); + mkdirSync(instancesDir, { recursive: true }); + writeFileSync(join(instancesDir, 'garbage.json'), '{not valid'); + const reg = await registry.register(baseInfo); + expect(existsSync(join(instancesDir, 'garbage.json'))).toBe(true); + await reg.release(); + }); +}); + +describe('createInstanceRegistry — listLive', () => { + it('returns live instances, drops dead ones, and sorts by startedAt', async () => { + const registry = createInstanceRegistry({ instancesDir }); + writeInstance('dead', { pid: DEAD_PID, started_at: 1 }); + writeInstance('older', { pid: process.pid, started_at: 100 }); + writeInstance('newer', { pid: process.pid, started_at: 200 }); + + const live = await registry.listLive(); + expect(live.map((i) => i.serverId)).toEqual(['older', 'newer']); + expect(existsSync(join(instancesDir, 'dead.json'))).toBe(false); + }); + + it('returns an empty array when the directory is missing', async () => { + const registry = createInstanceRegistry({ instancesDir }); + await expect(registry.listLive()).resolves.toEqual([]); + }); +}); + +describe('createInstanceRegistry — update', () => { + it('rewrites the port and refreshes heartbeatAt', async () => { + let t = 1000; + const registry = createInstanceRegistry({ instancesDir, now: () => t }); + const reg = await registry.register(baseInfo); + expect(readInstance(reg.serverId).port).toBe(58627); + expect(readInstance(reg.serverId).heartbeat_at).toBe(1000); + + t = 2000; + await reg.update({ port: 58628 }); + + const after = readInstance(reg.serverId); + expect(after.port).toBe(58628); + expect(after.heartbeat_at).toBe(2000); + expect(after.pid).toBe(process.pid); + expect(after.started_at).toBe(1000); + await reg.release(); + }); + + it('refreshes heartbeat without changing port when patch is empty', async () => { + let t = 1000; + const registry = createInstanceRegistry({ instancesDir, now: () => t }); + const reg = await registry.register(baseInfo); + t = 3000; + await reg.update({}); + const after = readInstance(reg.serverId); + expect(after.port).toBe(58627); + expect(after.heartbeat_at).toBe(3000); + await reg.release(); + }); +}); + +describe('createInstanceRegistry — heartbeat', () => { + it('periodically rewrites heartbeatAt until released', async () => { + let tick = 0; + const registry = createInstanceRegistry({ + instancesDir, + heartbeatIntervalMs: 1, + now: () => ++tick, + }); + const reg = await registry.register(baseInfo); + const first = readInstance(reg.serverId).heartbeat_at; + + await sleep(90); + const later = readInstance(reg.serverId).heartbeat_at; + expect(later).toBeGreaterThan(first); + + await reg.release(); + expect(existsSync(join(instancesDir, `${reg.serverId}.json`))).toBe(false); + await sleep(30); + expect(existsSync(join(instancesDir, `${reg.serverId}.json`))).toBe(false); + }); +}); + +describe('convenience readers', () => { + it('listLiveServerInstances reads /server/instances', async () => { + const dir = join(tmpDir, 'server', 'instances'); + const registry = createInstanceRegistry({ instancesDir: dir }); + const reg = await registry.register({ ...baseInfo, startedAt: 100 }); + + const live = await listLiveServerInstances(tmpDir); + expect(live.map((i: ServerInstanceInfo) => i.serverId)).toEqual([reg.serverId]); + await reg.release(); + }); + + it('getLiveServerInstance returns the longest-running live instance', async () => { + const dir = join(tmpDir, 'server', 'instances'); + const registry = createInstanceRegistry({ instancesDir: dir }); + const older = await registry.register({ ...baseInfo, startedAt: 100 }); + const newer = await registry.register({ ...baseInfo, startedAt: 200 }); + + const first = await getLiveServerInstance(tmpDir); + expect(first?.serverId).toBe(older.serverId); + await older.release(); + await newer.release(); + }); + + it('getLiveServerInstance returns undefined when no live instance exists', async () => { + await expect(getLiveServerInstance(tmpDir)).resolves.toBeUndefined(); + }); +}); + +describe('startServer — instance registry wiring', () => { + let home: string | undefined; + const servers: RunningServer[] = []; + + afterEach(async () => { + while (servers.length > 0) { + await servers.pop()!.close(); + } + if (home !== undefined) { + rmSync(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it('lets two servers share one homeDir, each registering a distinct instance and port', async () => { + home = mkdtempSync(join(tmpdir(), 'kimi-server-multi-server-')); + const a = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + servers.push(a); + const b = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + servers.push(b); + + expect(b.port).not.toBe(a.port); + + const live = await listLiveServerInstances(home); + expect(live).toHaveLength(2); + expect(new Set(live.map((i) => i.serverId)).size).toBe(2); + expect(live.map((i) => i.port).sort((x, y) => x - y)).toEqual( + [a.port, b.port].sort((x, y) => x - y), + ); + expect(existsSync(join(home, 'server', 'lock'))).toBe(false); + }); + + it('removes its instance file on close so peers no longer list it', async () => { + home = mkdtempSync(join(tmpdir(), 'kimi-server-multi-server-')); + const a = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + servers.push(a); + const b = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + servers.push(b); + expect(await listLiveServerInstances(home)).toHaveLength(2); + + await a.close(); + servers.splice(servers.indexOf(a), 1); + + const live = await listLiveServerInstances(home); + expect(live).toHaveLength(1); + expect(live[0]?.port).toBe(b.port); + }); + + it('releases its registration on close so a fresh instance on the same home can start', async () => { + home = mkdtempSync(join(tmpdir(), 'kimi-server-multi-server-')); + const first = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + await first.close(); + + const restarted = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + servers.push(restarted); + expect(await listLiveServerInstances(home)).toHaveLength(1); + expect((await listLiveServerInstances(home))[0]?.port).toBe(restarted.port); + }); +}); diff --git a/packages/kap-server/test/mediaRefParity.test.ts b/packages/kap-server/test/mediaRefParity.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f9d1322d52be0c51f53a90a36998eae397b0427 --- /dev/null +++ b/packages/kap-server/test/mediaRefParity.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { + daemonFileRefFromPart as engineRefFromPart, + matchSingleMediaPathTag as engineMatchTag, + parseDaemonFileUrl as engineParse, +} from '@moonshot-ai/agent-core-v2'; +import { + daemonFileRefFromPairingPart as mirrorRefFromPart, + matchMediaPathTagText as mirrorMatchTag, + parseDaemonFileRef as mirrorParse, + type MediaRefPart, +} from '@moonshot-ai/transcript'; +import type { ContentPart } from '@moonshot-ai/agent-core-v2/human/llm/message'; + +const URLS = [ + 'kimi-file://f_1?path=%2Fcache%2Fshot.png', + 'kimi-file://f_1', + 'kimi-file://f_1?path=', + 'kimi-file://?path=%2Fcache%2Fshot.png', + 'kimi-file://f_1?path=%2Fcache%2Fa%20%26%20%22b%22%20%3Cc%3E.png', + 'kimi-file://f_1?path=%zz', + 'kimi-file://', + 'https://example.com/shot.png', + '', +]; + +describe('daemon file url parsing parity (engine vs transcript mirror)', () => { + for (const url of URLS) { + it(JSON.stringify(url), () => { + expect(mirrorParse(url)).toEqual(engineParse(url)); + }); + } +}); + +const TAG_TEXTS = [ + '', + '', + '', + ' \n', + '', + 'open please', + '', + 'plain text', + '', +]; + +describe('standalone media path tag matching parity', () => { + for (const text of TAG_TEXTS) { + it(JSON.stringify(text), () => { + const engine = engineMatchTag(text); + const mirror = mirrorMatchTag(text); + expect(mirror === undefined ? undefined : { kind: mirror.kind, path: mirror.path }).toEqual( + engine === undefined ? undefined : { kind: engine.kind, path: engine.path }, + ); + }); + } +}); + +const PARTS: ReadonlyArray = [ + { type: 'image_url', imageUrl: { url: 'kimi-file://f_1?path=%2Fcache%2Fshot.png' } }, + { type: 'video_url', videoUrl: { url: 'kimi-file://f_3?path=%2Fcache%2Fclip.mp4' } }, + { type: 'image_url', imageUrl: { url: 'kimi-file://f_1' } }, + { type: 'image_url', imageUrl: { url: 'https://example.com/shot.png' } }, + { type: 'text', text: '' }, + { type: 'text', text: 'hello' }, +]; + +describe('daemon ref extraction parity', () => { + for (const [index, part] of PARTS.entries()) { + it(`part ${index}: ${part.type}`, () => { + expect(mirrorRefFromPart(part)).toEqual( + engineRefFromPart(part as unknown as ContentPart), + ); + }); + } +}); diff --git a/packages/kap-server/test/messages.test.ts b/packages/kap-server/test/messages.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..83a7942433692a33d7c9c006c5d2cd872f343366 --- /dev/null +++ b/packages/kap-server/test/messages.test.ts @@ -0,0 +1,340 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + IAgentContextMemoryService, + IAgentLifecycleService, + IWireService, + getLiveSessionById, + IModelCatalog, + type ContextMessage, + type ScopeSeed, +} from '@moonshot-ai/agent-core-v2'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: { path: string; message: string }[]; +} + +interface MessageWire { + id: string; + session_id: string; + role: string; + content: { type: string; [key: string]: unknown }[]; + created_at: string; + metadata?: Record; +} + +interface PageWire { + items: MessageWire[]; + has_more: boolean; +} + +const MSG_ID = /^msg_.+/; + +describe('server-v2 /api/v1/sessions/{sid}/messages', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + let seeds: ScopeSeed | undefined; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-messages-')); + const modelCatalog: IModelCatalog = { + _serviceBrand: undefined, + get: () => { + throw new Error('modelCatalog.get not exercised in this test'); + }, + getRequester: () => { + throw new Error('modelCatalog.getRequester not exercised in this test'); + }, + generate: () => { + throw new Error('modelCatalog.generate not exercised in this test'); + }, + ping: () => { + throw new Error('modelCatalog.ping not exercised in this test'); + }, + findByName: () => [], + listModels: async () => [], + listProviders: async () => [], + getProvider: async () => { + throw new Error('modelCatalog.getProvider not exercised in this test'); + }, + setDefaultModel: async () => { + throw new Error('modelCatalog.setDefaultModel not exercised in this test'); + }, + }; + seeds = [[IModelCatalog, modelCatalog]]; + await boot(); + }); + + async function boot(): Promise { + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home as string, + logLevel: 'silent', + seeds, + }); + base = `http://127.0.0.1:${server.port}`; + } + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function createSession(): Promise { + const res = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ metadata: { cwd: home as string } }), + } as never); + const body = (await res.json()) as Envelope<{ id: string }>; + expect(body.code).toBe(0); + return body.data.id; + } + + async function seedMainAgentMessages( + sessionId: string, + messages: readonly ContextMessage[], + ): Promise { + const session = getLiveSessionById(server!.core.accessor, sessionId); + if (session === undefined) throw new Error(`session ${sessionId} not found`); + let agent = session.accessor.get(IAgentLifecycleService).handleOf('main'); + if (agent === undefined) { + await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); + agent = session.accessor.get(IAgentLifecycleService).handleOf('main')!; + } + if (messages.length > 0) { + agent.accessor.get(IAgentContextMemoryService).append(...messages); + await agent.accessor.get(IWireService).flush(); + } + } + + it('returns an empty page when the session has no main agent', async () => { + const id = await createSession(); + const { body } = await getJson(`/api/v1/sessions/${id}/messages`); + expect(body.code).toBe(0); + expect(body.data.items).toEqual([]); + expect(body.data.has_more).toBe(false); + }); + + it('returns an empty page when the main agent has no messages yet', async () => { + const id = await createSession(); + await seedMainAgentMessages(id, []); + const { body } = await getJson(`/api/v1/sessions/${id}/messages`); + expect(body.code).toBe(0); + expect(body.data.items).toEqual([]); + }); + + it('lists spliced messages newest-first with stable ids and mapped content', async () => { + const id = await createSession(); + await seedMainAgentMessages(id, [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + { + role: 'assistant', + content: [{ type: 'text', text: 'running' }], + toolCalls: [{ type: 'function', id: 'call_1', name: 'Bash', arguments: '{"cmd":"ls"}' }], + }, + { role: 'tool', content: [{ type: 'text', text: 'file.txt' }], toolCalls: [], toolCallId: 'call_1' }, + ]); + + const { body } = await getJson(`/api/v1/sessions/${id}/messages`); + expect(body.code).toBe(0); + expect(body.data.has_more).toBe(false); + expect(body.data.items).toHaveLength(3); + expect(body.data.items.every((m) => MSG_ID.test(m.id))).toBe(true); + expect(body.data.items.every((m) => m.session_id === id)).toBe(true); + + const [tool, assistant, user] = body.data.items; + + expect(user).toMatchObject({ + role: 'user', + content: [{ type: 'text', text: 'hi' }], + }); + + expect(assistant).toMatchObject({ + role: 'assistant', + content: [ + { type: 'text', text: 'running' }, + { + type: 'tool_use', + tool_call_id: 'call_1', + tool_name: 'Bash', + input: { cmd: 'ls' }, + }, + ], + }); + + expect(tool).toMatchObject({ + role: 'tool', + content: [{ type: 'tool_result', tool_call_id: 'call_1', output: 'file.txt' }], + }); + }); + + it('gets a single message by id and 404s for an unknown message', async () => { + const id = await createSession(); + await seedMainAgentMessages(id, [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + { role: 'assistant', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, + ]); + + const list = await getJson(`/api/v1/sessions/${id}/messages`); + const assistant = list.body.data.items.find((m) => m.role === 'assistant'); + expect(assistant).toBeDefined(); + + const got = await getJson( + `/api/v1/sessions/${id}/messages/${assistant!.id}`, + ); + expect(got.body.code).toBe(0); + expect(got.body.data).toMatchObject({ + id: assistant!.id, + role: 'assistant', + content: [{ type: 'text', text: 'hello' }], + }); + + const missing = await getJson( + `/api/v1/sessions/${id}/messages/msg_does_not_exist`, + ); + expect(missing.body.code).toBe(40403); + }); + + it('returns 40403 for a message id not present in the session', async () => { + const id = await createSession(); + await seedMainAgentMessages(id, [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + ]); + const { body } = await getJson( + `/api/v1/sessions/${id}/messages/msg_00NOT_IN_SESSION00`, + ); + expect(body.code).toBe(40403); + }); + + it('returns 40401 for an unknown session on both endpoints', async () => { + const list = await getJson('/api/v1/sessions/nope/messages'); + expect(list.body.code).toBe(40401); + + const got = await getJson('/api/v1/sessions/nope/messages/msg_does_not_exist'); + expect(got.body.code).toBe(40401); + }); + + it('paginates with page_size and before_id / after_id cursors', async () => { + const id = await createSession(); + await seedMainAgentMessages(id, [ + { role: 'user', content: [{ type: 'text', text: 'm0' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: 'm1' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: 'm2' }], toolCalls: [] }, + ]); + const all = await getJson(`/api/v1/sessions/${id}/messages?page_size=100`); + const idsDesc = all.body.data.items.map((m) => m.id); + expect(idsDesc).toHaveLength(3); + + const first = await getJson(`/api/v1/sessions/${id}/messages?page_size=1`); + expect(first.body.data.items.map((m) => m.id)).toEqual([idsDesc[0]]); + expect(first.body.data.has_more).toBe(true); + + const older = await getJson( + `/api/v1/sessions/${id}/messages?before_id=${idsDesc[0]}`, + ); + expect(older.body.data.items.map((m) => m.id)).toEqual([idsDesc[1], idsDesc[2]]); + expect(older.body.data.has_more).toBe(false); + + const newer = await getJson( + `/api/v1/sessions/${id}/messages?after_id=${idsDesc[2]}`, + ); + expect(newer.body.data.items.map((m) => m.id)).toEqual([idsDesc[0], idsDesc[1]]); + expect(newer.body.data.has_more).toBe(false); + }); + + it('filters the page by role after pagination', async () => { + const id = await createSession(); + await seedMainAgentMessages(id, [ + { role: 'user', content: [{ type: 'text', text: 'q' }], toolCalls: [] }, + { role: 'assistant', content: [{ type: 'text', text: 'a' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: 'q2' }], toolCalls: [] }, + ]); + const { body } = await getJson(`/api/v1/sessions/${id}/messages?role=user`); + expect(body.code).toBe(0); + expect(body.data.items.every((m) => m.role === 'user')).toBe(true); + expect(body.data.items).toHaveLength(2); + expect(body.data.items.every((m) => MSG_ID.test(m.id))).toBe(true); + }); + + it('reads the persisted full transcript for a cold session', async () => { + const id = await createSession(); + const session = getLiveSessionById(server!.core.accessor, id); + if (session === undefined) throw new Error(`session ${id} not found`); + await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); + const agent = session.accessor.get(IAgentLifecycleService).handleOf('main')!; + const ctx = agent.accessor.get(IAgentContextMemoryService); + ctx.append( + { role: 'user', content: [{ type: 'text', text: 'm0' }], toolCalls: [] }, + { role: 'assistant', content: [{ type: 'text', text: 'm1' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: 'm2' }], toolCalls: [] }, + ); + ctx.applyCompaction({ + summary: 'summary', + contextSummary: 'summary', + compactedCount: 3, + tokensBefore: 100, + }); + await agent.accessor.get(IWireService).flush(); + + const livePage = await getJson(`/api/v1/sessions/${id}/messages?page_size=100`); + expect(livePage.body.data.items).toHaveLength(4); + const liveSummaryId = livePage.body.data.items[0]!.id; + + await server!.close(); + server = undefined; + await boot(); + + const { body } = await getJson(`/api/v1/sessions/${id}/messages?page_size=100`); + expect(body.code).toBe(0); + expect(body.data.items).toHaveLength(4); + expect(body.data.items.every((m) => MSG_ID.test(m.id))).toBe(true); + + const [summary, _m2, maybeM1] = body.data.items; + if (maybeM1 === undefined) throw new Error('expected m1 message'); + const m1 = maybeM1; + expect(summary!.id).toBe(liveSummaryId); + expect(summary).toMatchObject({ + role: 'user', + metadata: { origin: { kind: 'compaction_summary' } }, + }); + + const got = await getJson(`/api/v1/sessions/${id}/messages/${m1.id}`); + expect(got.body.code).toBe(0); + expect(got.body.data).toMatchObject({ + id: m1.id, + role: 'assistant', + content: [{ type: 'text', text: 'm1' }], + }); + + const missing = await getJson(`/api/v1/sessions/${id}/messages/msg_does_not_exist`); + expect(missing.body.code).toBe(40403); + }); +}); diff --git a/packages/kap-server/test/meta.test.ts b/packages/kap-server/test/meta.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9fdb6cf195fbf7e06bf0caf01c045ff08cf76228 --- /dev/null +++ b/packages/kap-server/test/meta.test.ts @@ -0,0 +1,232 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { IConfigService } from '@moonshot-ai/agent-core-v2'; +import { IFeatureManager } from '@moonshot-ai/agent-core-v2/app/feature/featureManager'; +import { getFeatureRecipes } from '@moonshot-ai/agent-core-v2/features/featureRegistry'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authedFetch } from './helpers/auth'; + +interface MetaBody { + code: number; + data: { experimental_flags?: Record }; +} + +describe('/api/v1/meta experimental_flags', () => { + let server: RunningServer | undefined; + let home: string | undefined; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-meta-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + }); + + beforeEach(() => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TOOL_SELECT', undefined); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + home = undefined; + } + }); + + async function boot(toml?: string): Promise { + await writeFile(join(home as string, 'config.toml'), toml ?? '', 'utf-8'); + await (server as RunningServer).core.accessor.get(IConfigService).reload(); + return `http://127.0.0.1:${(server as RunningServer).port}`; + } + + async function getMetaFlags(base: string): Promise> { + const res = await authedFetch(server as RunningServer, base, '/api/v1/meta'); + expect(res.status).toBe(200); + const body = (await res.json()) as MetaBody; + expect(body.code).toBe(0); + expect(body.data.experimental_flags).toBeDefined(); + return body.data.experimental_flags as Record; + } + + it('reports registered flags as off by default', async () => { + const base = await boot(); + const flags = await getMetaFlags(base); + expect(flags['tool-select']).toBe(false); + }); + + it('reports a config-enabled flag from the very first response', async () => { + const base = await boot('[experimental]\ntool-select = true\n'); + const flags = await getMetaFlags(base); + expect(flags['tool-select']).toBe(true); + }); + + it('reflects a flag enabled via its KIMI_CODE_EXPERIMENTAL_* env var', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TOOL_SELECT', '1'); + const base = await boot(); + const flags = await getMetaFlags(base); + expect(flags['tool-select']).toBe(true); + }); + + it('flips live when the [experimental] config section is written via POST /config', async () => { + const base = await boot(); + expect((await getMetaFlags(base))['tool-select']).toBe(false); + + const res = await authedFetch(server as RunningServer, base, '/api/v1/config', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ experimental: { 'tool-select': true } }), + }); + expect(res.status).toBe(200); + + expect((await getMetaFlags(base))['tool-select']).toBe(true); + }); + + it('keeps an env-forced flag on when the config section disables it', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TOOL_SELECT', '1'); + const base = await boot(); + + const res = await authedFetch(server as RunningServer, base, '/api/v1/config', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ experimental: { 'tool-select': false } }), + }); + expect(res.status).toBe(200); + + expect((await getMetaFlags(base))['tool-select']).toBe(true); + }); +}); + +describe('/api/v1/meta web_title', () => { + let server: RunningServer | undefined; + let home: string | undefined; + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + home = undefined; + } + }); + + async function bootWithWebTitle( + webTitle?: string, + ): Promise<{ base: string; body: { data: { web_title?: string } } }> { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-meta-title-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + webTitle, + }); + const base = `http://127.0.0.1:${server.port}`; + const res = await authedFetch(server, base, '/api/v1/meta'); + expect(res.status).toBe(200); + const body = (await res.json()) as { code: number; data: { web_title?: string } }; + expect(body.code).toBe(0); + return { base, body }; + } + + it('surfaces the boot-time webTitle as web_title', async () => { + const { body } = await bootWithWebTitle('My Dev Box'); + expect(body.data.web_title).toBe('My Dev Box'); + }); + + it('omits web_title when no webTitle was passed', async () => { + const { body } = await bootWithWebTitle(); + expect(body.data.web_title).toBeUndefined(); + }); +}); + +describe('/api/v1/meta features', () => { + let server: RunningServer | undefined; + let home: string | undefined; + + interface FeatureWire { + name: string; + state: string; + meta: Record; + } + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-meta-features-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + home = undefined; + } + }); + + async function boot(): Promise { + return `http://127.0.0.1:${(server as RunningServer).port}`; + } + + async function getMetaFeatures(base: string): Promise { + const res = await authedFetch(server as RunningServer, base, '/api/v1/meta'); + expect(res.status).toBe(200); + const body = (await res.json()) as { code: number; data: { features?: FeatureWire[] } }; + expect(body.code).toBe(0); + expect(body.data.features).toBeDefined(); + return body.data.features as FeatureWire[]; + } + + it('lists every registered built-in feature as Active with an empty meta', async () => { + const base = await boot(); + const features = await getMetaFeatures(base); + const expected = getFeatureRecipes() + .map((recipe) => recipe.name) + .sort(); + expect(features.map((feature) => feature.name).sort()).toEqual(expected); + for (const feature of features) { + expect(feature.state).toBe('Active'); + expect(feature.meta).toEqual({}); + } + }); + + it('drops a feature from the response after it is unprovided at runtime', async () => { + const base = await boot(); + const before = await getMetaFeatures(base); + expect(before.some((feature) => feature.name === 'plan')).toBe(true); + + await (server as RunningServer).core.accessor.get(IFeatureManager).unprovideUnit('plan'); + + const after = await getMetaFeatures(base); + expect(after.some((feature) => feature.name === 'plan')).toBe(false); + expect(after).toHaveLength(before.length - 1); + }); +}); diff --git a/packages/kap-server/test/modelCatalog.test.ts b/packages/kap-server/test/modelCatalog.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f647175f09a793bc884d82090aeac2b8b50371c3 --- /dev/null +++ b/packages/kap-server/test/modelCatalog.test.ts @@ -0,0 +1,430 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + IConfigService, + IModelCatalog, + IOAuthService, + IProviderDiscoveryService, + type IModelCatalog as IModelCatalogType, + type IOAuthService as IOAuthServiceType, + type IProviderDiscoveryService as IProviderDiscoveryServiceType, + type ModelCatalogConfig, + type ScopeSeed, +} from '@moonshot-ai/agent-core-v2'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +const CATALOG_TOML = [ + 'default_model = "k2"', + '', + '[providers.kimi]', + 'type = "kimi"', + 'api_key = "sk-test"', + 'base_url = "https://api.example.test/v1"', + '', + '[providers.openai]', + 'type = "openai"', + '', + '[models.k2]', + 'provider = "kimi"', + 'model = "kimi-k2"', + 'max_context_size = 131072', + 'display_name = "Kimi K2"', + 'capabilities = ["thinking"]', + '', + '[models.turbo]', + 'provider = "kimi"', + 'model = "kimi-turbo"', + 'max_context_size = 32768', + 'display_name = "Kimi Turbo"', + '', + '[models.gpt4o]', + 'provider = "openai"', + 'model = "gpt-4o"', + 'max_context_size = 128000', + '', +].join('\n'); + +describe('server-v2 /api/v1 model/provider catalog', () => { + let server: RunningServer | undefined; + let active: RunningServer | undefined; + const alts: RunningServer[] = []; + let home: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-model-catalog-')); + process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START'] = '0'; + process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS'] = '0'; + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + active = server; + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + for (const alt of alts.splice(0)) { + await alt.close(); + } + active = server; + base = `http://127.0.0.1:${(server as RunningServer).port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + active = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + delete process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START']; + delete process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS']; + }); + + async function boot(toml?: string, seeds?: ScopeSeed): Promise { + if (toml !== undefined) { + await writeFile(join(home as string, 'config.toml'), toml, 'utf-8'); + } + if (seeds !== undefined) { + const alt = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds, + }); + alts.push(alt); + active = alt; + } else { + await (server as RunningServer).core.accessor.get(IConfigService).reload(); + active = server; + } + base = `http://127.0.0.1:${(active as RunningServer).port}`; + } + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(active as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: authHeaders( + active as RunningServer, + body === undefined ? {} : { 'content-type': 'application/json' }, + ), + body: body === undefined ? undefined : JSON.stringify(body), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + it('lists configured models as selectable aliases', async () => { + await boot(CATALOG_TOML); + const { status, body } = await getJson<{ items: unknown[] }>('/api/v1/models'); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.items).toEqual([ + { + provider: 'kimi', + model: 'k2', + display_name: 'Kimi K2', + max_context_size: 131072, + capabilities: ['thinking'], + }, + { + provider: 'kimi', + model: 'turbo', + display_name: 'Kimi Turbo', + max_context_size: 32768, + }, + { + provider: 'openai', + model: 'gpt4o', + display_name: 'gpt-4o', + max_context_size: 128000, + }, + ]); + }); + + it('lists models without refreshing providers', async () => { + const refreshProviderModels = vi.fn(async () => ({ + changed: [], + unchanged: [], + failed: [], + })); + const seeds = [ + [IModelCatalog, catalogStub()], + [IProviderDiscoveryService, discoveryStub(refreshProviderModels)], + ] as unknown as ScopeSeed; + await boot(CATALOG_TOML, seeds); + + const { status, body } = await getJson<{ items: unknown[] }>('/api/v1/models'); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.items).toEqual([]); + expect(refreshProviderModels).not.toHaveBeenCalled(); + }); + + it('lists providers and returns a single provider by id', async () => { + await boot(CATALOG_TOML); + const list = await getJson<{ items: unknown[] }>('/api/v1/providers'); + expect(list.body.code).toBe(0); + expect(list.body.data.items).toEqual([ + { + id: 'kimi', + type: 'kimi', + base_url: 'https://api.example.test/v1', + default_model: 'k2', + has_api_key: true, + status: 'connected', + models: ['k2', 'turbo'], + }, + { + id: 'openai', + type: 'openai', + has_api_key: false, + status: 'unconfigured', + models: ['gpt4o'], + }, + ]); + + const single = await getJson('/api/v1/providers/kimi'); + expect(single.body.code).toBe(0); + expect(single.body.data).toEqual({ + id: 'kimi', + type: 'kimi', + base_url: 'https://api.example.test/v1', + default_model: 'k2', + has_api_key: true, + status: 'connected', + models: ['k2', 'turbo'], + api_key: 'sk-test', + }); + + const noKey = await getJson>('/api/v1/providers/openai'); + expect(noKey.body.code).toBe(0); + expect(noKey.body.data).not.toHaveProperty('api_key'); + }); + + it('sets the global default model and reflects it in /config', async () => { + await boot(CATALOG_TOML); + const { body } = await postJson('/api/v1/models/turbo:set_default', {}); + expect(body.code).toBe(0); + expect(body.data).toEqual({ + default_model: 'turbo', + model: { + provider: 'kimi', + model: 'turbo', + display_name: 'Kimi Turbo', + max_context_size: 32768, + }, + }); + + const config = await getJson<{ default_model: string | null }>('/api/v1/config'); + expect(config.body.code).toBe(0); + expect(config.body.data.default_model).toBe('turbo'); + }); + + it('maps unknown provider and model ids to catalog not-found codes', async () => { + await boot(CATALOG_TOML); + const provider = await getJson('/api/v1/providers/missing'); + expect(provider.body.code).toBe(40412); + + const model = await postJson('/api/v1/models/missing:set_default', {}); + expect(model.body.code).toBe(40413); + }); + + it('returns an empty refresh result through the catalog route', async () => { + await boot(CATALOG_TOML); + const { status, body } = await postJson<{ + changed: unknown[]; + unchanged: unknown[]; + failed: unknown[]; + }>('/api/v1/providers:refresh_oauth', {}); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data).toEqual({ changed: [], unchanged: [], failed: [] }); + }); + + it('returns an empty refresh result through the providers:refresh route', async () => { + await boot(CATALOG_TOML); + const { status, body } = await postJson<{ + changed: unknown[]; + unchanged: unknown[]; + failed: unknown[]; + }>('/api/v1/providers:refresh', {}); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data).toEqual({ changed: [], unchanged: [], failed: [] }); + }); + + function catalogStub(): IModelCatalogType { + return { + _serviceBrand: undefined, + get: () => { + throw new Error('unused'); + }, + getRequester: () => { + throw new Error('unused'); + }, + generate: () => { + throw new Error('unused'); + }, + ping: async () => { + throw new Error('unused'); + }, + findByName: () => [], + listModels: async () => [], + listProviders: async () => [], + getProvider: async () => { + throw new Error('unused'); + }, + setDefaultModel: async () => { + throw new Error('unused'); + }, + }; + } + + function discoveryStub( + refreshProviderModels: IProviderDiscoveryServiceType['refreshProviderModels'], + ): IProviderDiscoveryServiceType { + return { _serviceBrand: undefined, refreshProviderModels }; + } + + function oauthStub( + refreshOAuthProviderModels: IOAuthServiceType['refreshOAuthProviderModels'], + ): IOAuthServiceType { + return { + _serviceBrand: undefined, + startLogin: async () => { + throw new Error('unused'); + }, + getFlow: () => undefined, + cancelLogin: async () => { + throw new Error('unused'); + }, + logout: async () => { + throw new Error('unused'); + }, + status: async () => ({ loggedIn: false }), + refreshOAuthProviderModels, + getManagedUsage: async () => ({ kind: 'error' as const, message: 'unused' }), + getManagedUserInfo: async () => ({ kind: 'error' as const, message: 'unused' }), + resolveTokenProvider: () => undefined, + getCachedAccessToken: async () => undefined, + getRegion: () => 'mainland-cn', + }; + } + + it('refreshes OAuth provider models through POST /providers:refresh_oauth', async () => { + const refreshOAuthProviderModels = vi.fn(async () => ({ + changed: [ + { provider_id: 'managed:kimi-code', provider_name: 'Kimi Code', added: 1, removed: 0 }, + ], + unchanged: [], + failed: [], + })); + const seeds = [[IOAuthService, oauthStub(refreshOAuthProviderModels)]] as unknown as ScopeSeed; + await boot(CATALOG_TOML, seeds); + + const { status, body } = await postJson<{ + changed: unknown[]; + unchanged: unknown[]; + failed: unknown[]; + }>('/api/v1/providers:refresh_oauth', {}); + + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data).toEqual({ + changed: [ + { provider_id: 'managed:kimi-code', provider_name: 'Kimi Code', added: 1, removed: 0 }, + ], + unchanged: [], + failed: [], + }); + expect(refreshOAuthProviderModels).toHaveBeenCalledTimes(1); + }); + + it('refreshes all provider models through POST /providers:refresh', async () => { + const refreshProviderModels = vi.fn(async () => ({ + changed: [ + { provider_id: 'managed:kimi-code', provider_name: 'Kimi Code', added: 2, removed: 1 }, + ], + unchanged: ['moonshot-cn'], + failed: [], + })); + const seeds = [[IProviderDiscoveryService, discoveryStub(refreshProviderModels)]] as unknown as ScopeSeed; + await boot(CATALOG_TOML, seeds); + + const { status, body } = await postJson('/api/v1/providers:refresh', {}); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(refreshProviderModels).toHaveBeenCalledWith({ scope: 'all' }); + }); + + it('refreshes a single provider through POST /providers/{id}:refresh', async () => { + const refreshProviderModels = vi.fn(async () => ({ + changed: [], + unchanged: [], + failed: [], + })); + const seeds = [[IProviderDiscoveryService, discoveryStub(refreshProviderModels)]] as unknown as ScopeSeed; + await boot(CATALOG_TOML, seeds); + + const { status, body } = await postJson('/api/v1/providers/managed%3Akimi-code:refresh', {}); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(refreshProviderModels).toHaveBeenCalledWith({ providerId: 'managed:kimi-code' }); + }); + + it('rejects unsupported provider actions with 40001', async () => { + const refreshProviderModels = vi.fn(async () => ({ + changed: [], + unchanged: [], + failed: [], + })); + const seeds = [[IProviderDiscoveryService, discoveryStub(refreshProviderModels)]] as unknown as ScopeSeed; + await boot(CATALOG_TOML, seeds); + + const { body } = await postJson('/api/v1/providers/foo:bogus', {}); + expect(body.code).toBe(40001); + expect(refreshProviderModels).not.toHaveBeenCalled(); + }); + + it('loads the [model_catalog] config section from TOML', async () => { + await boot( + ['[model_catalog]', 'refresh_interval_ms = 1000', 'refresh_on_start = false', ''].join('\n'), + ); + const cfg = server!.core.accessor.get(IConfigService); + await cfg.ready; + const value = cfg.get('modelCatalog'); + expect(value).toEqual({ refreshIntervalMs: 1000, refreshOnStart: false }); + }); +}); diff --git a/packages/kap-server/test/modelCatalogCatalog.test.ts b/packages/kap-server/test/modelCatalogCatalog.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a7ead4ae7242df6334564f6a4487e84219c5d24 --- /dev/null +++ b/packages/kap-server/test/modelCatalogCatalog.test.ts @@ -0,0 +1,713 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { IConfigService } from '@moonshot-ai/agent-core-v2'; +import { parse as parseToml } from 'smol-toml'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { + resetModelsDevUpstreamForTest, + setModelsDevUpstreamForTest, +} from '@moonshot-ai/agent-core-v2/app/kosongConfig/modelsDevUpstream'; +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +const CATALOG = { + openai: { + id: 'openai', + name: 'OpenAI', + api: 'https://api.openai.com/v1', + npm: '@ai-sdk/openai', + env: ['OPENAI_API_KEY'], + models: { + 'gpt-4.1': { + id: 'gpt-4.1', + name: 'GPT-4.1', + limit: { context: 1047576, input: 1047576, output: 32768 }, + tool_call: true, + reasoning: false, + modalities: { input: ['text', 'image'], output: ['text'] }, + }, + 'gpt-4o-mini': { + id: 'gpt-4o-mini', + name: 'GPT-4o mini', + limit: { context: 128000 }, + tool_call: true, + modalities: { input: ['text'], output: ['text'] }, + }, + }, + }, + bedrock: { + id: 'bedrock', + name: 'Amazon Bedrock', + api: 'https://bedrock-runtime.us-east-1.amazonaws.com', + npm: '@ai-sdk/amazon-bedrock', + models: { + 'claude-sonnet': { + id: 'claude-sonnet', + name: 'Claude Sonnet', + limit: { context: 200000 }, + tool_call: true, + modalities: { input: ['text'], output: ['text'] }, + }, + }, + }, + gateway: { + id: 'gateway', + name: 'Some Gateway', + npm: 'some-gateway-sdk', + models: { + 'gw-model': { + id: 'gw-model', + limit: { context: 64000 }, + tool_call: true, + modalities: { input: ['text'], output: ['text'] }, + }, + }, + }, + 'empty-models': { + id: 'empty-models', + name: 'Empty', + api: 'https://empty.example/v1', + npm: '@ai-sdk/openai', + models: {}, + }, +} as const; + +const MANAGED_OPENAI_TOML = [ + '[providers.openai]', + 'type = "openai"', + 'api_key = "sk-managed"', + 'oauth = { storage = "file", key = "oauth/openai" }', + '', +].join('\n'); + +const DEFAULTED_TOML = [ + 'default_provider = "kimi"', + 'default_model = "k2"', + '', + '[providers.kimi]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.k2]', + 'provider = "kimi"', + 'model = "kimi-k2"', + 'max_context_size = 131072', + '', +].join('\n'); + +function catalogFetchOk(): typeof fetch { + return (async () => + new Response(JSON.stringify(CATALOG), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as unknown as typeof fetch; +} + +function catalogFetchFail(): typeof fetch { + return (async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; +} + +describe('server-v2 /api/v1 catalog browse + import endpoints', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-catalog-')); + process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START'] = '0'; + process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS'] = '0'; + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + beforeEach(() => { + resetModelsDevUpstreamForTest(); + setModelsDevUpstreamForTest({ fetchImpl: catalogFetchOk() }); + }); + + afterEach(() => { + resetModelsDevUpstreamForTest(); + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + delete process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START']; + delete process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS']; + }); + + async function boot(toml?: string): Promise { + await writeFile(join(home as string, 'config.toml'), toml ?? '', 'utf-8'); + await (server as RunningServer).core.accessor.get(IConfigService).reload(); + } + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: authHeaders( + server as RunningServer, + body === undefined ? {} : { 'content-type': 'application/json' }, + ), + body: body === undefined ? undefined : JSON.stringify(body), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function readConfigToml(): Promise> { + const text = await readFile(join(home as string, 'config.toml'), 'utf-8'); + return parseToml(text) as Record; + } + + async function waitForServerState(check: () => Promise, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('waitForServerState timed out'); + } + + it('lists pruned directory entries with import eligibility resolved', async () => { + await boot(); + const { status, body } = await getJson<{ items: Array> }>( + '/api/v1/catalog/providers', + ); + expect(status).toBe(200); + expect(body.code).toBe(0); + const byId = new Map(body.data.items.map((item) => [item['id'], item])); + + const openai = byId.get('openai') as Record; + expect(openai['name']).toBe('OpenAI'); + expect(openai['wire_type']).toBe('openai'); + expect(openai['needs_base_url']).toBe(false); + expect(openai['rejected']).toBe(false); + expect(openai['reject_reason']).toBeNull(); + expect(openai['env_key']).toBe('OPENAI_API_KEY'); + const models = openai['models'] as Array>; + expect(models).toHaveLength(2); + expect(models[0]).toMatchObject({ + id: 'gpt-4.1', + name: 'GPT-4.1', + max_context_size: 1047576, + reasoning: false, + }); + expect(models[0]?.['capabilities']).toEqual(['image_in', 'tool_use']); + + const bedrock = byId.get('bedrock') as Record; + expect(bedrock['rejected']).toBe(true); + expect(bedrock['reject_reason']).toBe('proprietary-sdk'); + expect(bedrock['wire_type']).toBeNull(); + + const gateway = byId.get('gateway') as Record; + expect(gateway['rejected']).toBe(false); + expect(gateway['needs_base_url']).toBe(true); + expect(gateway['wire_type']).toBe('openai'); + }); + + it('serves the second request from the in-memory cache', async () => { + let calls = 0; + const counting = (async () => { + calls += 1; + return new Response(JSON.stringify(CATALOG), { status: 200 }); + }) as unknown as typeof fetch; + setModelsDevUpstreamForTest({ fetchImpl: counting }); + + await boot(); + const first = await getJson('/api/v1/catalog/providers'); + const second = await getJson('/api/v1/catalog/providers'); + expect(first.body.code).toBe(0); + expect(second.body.code).toBe(0); + expect(calls).toBe(1); + }); + + it('falls back to the stale cache when a refetch fails', async () => { + const t0 = 1_000_000; + let now = t0; + setModelsDevUpstreamForTest({ now: () => now }); + + await boot(); + const first = await getJson<{ items: unknown[] }>('/api/v1/catalog/providers'); + expect(first.body.code).toBe(0); + + now = t0 + 11 * 60 * 1000; + setModelsDevUpstreamForTest({ fetchImpl: catalogFetchFail() }); + const second = await getJson<{ items: unknown[] }>('/api/v1/catalog/providers'); + expect(second.body.code).toBe(0); + expect(second.body.data.items.length).toBe(first.body.data.items.length); + }); + + it('answers 50004 when the fetch fails and no cache or snapshot exists', async () => { + setModelsDevUpstreamForTest({ fetchImpl: catalogFetchFail() }); + await boot(); + const { status, body } = await getJson('/api/v1/catalog/providers'); + expect(status).toBe(200); + expect(body.code).toBe(50004); + expect(body.msg).toContain('unavailable'); + }); + + it('gets a single directory entry by id', async () => { + await boot(); + const { status, body } = await getJson>( + '/api/v1/catalog/providers/openai', + ); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data['id']).toBe('openai'); + expect(body.data['wire_type']).toBe('openai'); + }); + + it('answers 40417 for an unknown catalog id', async () => { + await boot(); + const { body } = await getJson('/api/v1/catalog/providers/nope'); + expect(body.code).toBe(40417); + }); + + it('imports a catalog entry as a provider with all model aliases', async () => { + await boot(); + const { status, body } = await postJson<{ + provider: Record; + models_imported: number; + }>('/api/v1/providers:import_catalog', { catalog_id: 'openai', api_key: 'sk-imported' }); + expect(status).toBe(201); + expect(body.code).toBe(0); + expect(body.data.models_imported).toBe(2); + expect(body.data.provider).toMatchObject({ + id: 'openai', + type: 'openai', + base_url: 'https://api.openai.com/v1', + has_api_key: true, + }); + + const config = await readConfigToml(); + const providers = config['providers'] as Record>; + expect(providers['openai']).toMatchObject({ + type: 'openai', + base_url: 'https://api.openai.com/v1', + api_key: 'sk-imported', + }); + const models = config['models'] as Record>; + expect(models['openai/gpt-4.1']).toMatchObject({ + provider: 'openai', + model: 'gpt-4.1', + max_context_size: 1047576, + max_input_size: 1047576, + max_output_size: 32768, + display_name: 'GPT-4.1', + }); + expect(models['openai/gpt-4.1']?.['capabilities']).toEqual(['image_in', 'tool_use']); + expect(models['openai/gpt-4o-mini']).toMatchObject({ provider: 'openai', model: 'gpt-4o-mini' }); + }); + + it('never touches the global default pointers on import', async () => { + await boot(DEFAULTED_TOML); + const { status } = await postJson('/api/v1/providers:import_catalog', { + catalog_id: 'openai', + api_key: 'sk-imported', + }); + expect(status).toBe(201); + const config = await readConfigToml(); + expect(config['default_provider']).toBe('kimi'); + expect(config['default_model']).toBe('k2'); + }); + + it('seeds the global default_model from the first catalog model on a fresh setup', async () => { + await boot(); + const { status } = await postJson('/api/v1/providers:import_catalog', { + catalog_id: 'openai', + api_key: 'sk-imported', + }); + expect(status).toBe(201); + const config = await readConfigToml(); + expect(config['default_model']).toBe('openai/gpt-4.1'); + }); + + it('re-imports an existing id as a refresh: credentials replaced, stale aliases dropped', async () => { + await boot(DEFAULTED_TOML); + const first = await postJson('/api/v1/providers:import_catalog', { + catalog_id: 'openai', + api_key: 'sk-one', + }); + expect(first.status).toBe(201); + + const before = await readConfigToml(); + const models = before['models'] as Record; + models['openai/retired'] = { provider: 'openai', model: 'retired', max_context_size: 1 }; + const { stringify: stringifyToml } = await import('smol-toml'); + await writeFile(join(home as string, 'config.toml'), stringifyToml(before), 'utf-8'); + await waitForServerState(async () => { + const cfg = await getJson<{ models: Record }>('/api/v1/config'); + return 'openai/retired' in (cfg.body.data.models ?? {}); + }); + + const second = await postJson('/api/v1/providers:import_catalog', { + catalog_id: 'openai', + api_key: 'sk-two', + }); + expect(second.status).toBe(201); + + const after = await readConfigToml(); + const providers = after['providers'] as Record>; + expect(providers['openai']?.['api_key']).toBe('sk-two'); + const afterModels = after['models'] as Record; + expect(afterModels['openai/retired']).toBeUndefined(); + expect(afterModels['openai/gpt-4.1']).toBeDefined(); + expect(afterModels['k2']).toBeDefined(); + }); + + it('keeps the stored api_key when a re-import omits it (tri-state like PUT)', async () => { + await boot(DEFAULTED_TOML); + const first = await postJson('/api/v1/providers:import_catalog', { + catalog_id: 'openai', + api_key: 'sk-one', + }); + expect(first.status).toBe(201); + + const second = await postJson<{ provider: { has_api_key: boolean } }>( + '/api/v1/providers:import_catalog', + { catalog_id: 'openai' }, + ); + expect(second.status).toBe(201); + expect(second.body.data.provider.has_api_key).toBe(true); + + const after = await readConfigToml(); + const providers = after['providers'] as Record>; + expect(providers['openai']?.['api_key']).toBe('sk-one'); + }); + + it('answers 40417 for prototype-chain catalog ids (constructor/__proto__)', async () => { + await boot(); + const first = await getJson('/api/v1/catalog/providers/constructor'); + expect(first.body.code).toBe(40417); + const second = await getJson('/api/v1/catalog/providers/__proto__'); + expect(second.body.code).toBe(40417); + }); + + it('honors the id override for the local provider id', async () => { + await boot(); + const { status, body } = await postJson<{ provider: Record }>( + '/api/v1/providers:import_catalog', + { catalog_id: 'openai', api_key: 'sk-x', id: 'my-oai' }, + ); + expect(status).toBe(201); + expect(body.data.provider['id']).toBe('my-oai'); + const config = await readConfigToml(); + const providers = config['providers'] as Record; + expect(providers['my-oai']).toBeDefined(); + expect(providers['openai']).toBeUndefined(); + const models = config['models'] as Record; + expect(models['my-oai/gpt-4.1']).toBeDefined(); + }); + + it('answers 40004 for a rejected catalog entry', async () => { + await boot(); + const { body } = await postJson('/api/v1/providers:import_catalog', { + catalog_id: 'bedrock', + api_key: 'sk-x', + }); + expect(body.code).toBe(40004); + expect(body.msg).toContain('proprietary-sdk'); + }); + + it('answers 40004 when a needs-base-url entry is imported without one, 201 with one', async () => { + await boot(); + const missing = await postJson('/api/v1/providers:import_catalog', { + catalog_id: 'gateway', + api_key: 'sk-x', + }); + expect(missing.body.code).toBe(40004); + expect(missing.body.msg).toContain('base_url'); + + const ok = await postJson<{ provider: Record }>( + '/api/v1/providers:import_catalog', + { catalog_id: 'gateway', api_key: 'sk-x', base_url: 'https://gw.example/v1' }, + ); + expect(ok.status).toBe(201); + expect(ok.body.data.provider['base_url']).toBe('https://gw.example/v1'); + }); + + it('answers 40004 for an entry with no importable models', async () => { + await boot(); + const { body } = await postJson('/api/v1/providers:import_catalog', { + catalog_id: 'empty-models', + api_key: 'sk-x', + }); + expect(body.code).toBe(40004); + expect(body.msg).toContain('no importable models'); + }); + + it('answers 40003 when the target id is OAuth-managed', async () => { + await boot(MANAGED_OPENAI_TOML); + const { body } = await postJson('/api/v1/providers:import_catalog', { + catalog_id: 'openai', + api_key: 'sk-x', + }); + expect(body.code).toBe(40003); + }); + + it('answers 40417 when importing an unknown catalog id', async () => { + await boot(); + const { body } = await postJson('/api/v1/providers:import_catalog', { + catalog_id: 'nope', + api_key: 'sk-x', + }); + expect(body.code).toBe(40417); + }); + + it('answers 50004 when the catalog is unavailable', async () => { + setModelsDevUpstreamForTest({ fetchImpl: catalogFetchFail() }); + await boot(); + const { body } = await postJson('/api/v1/providers:import_catalog', { + catalog_id: 'openai', + api_key: 'sk-x', + }); + expect(body.code).toBe(50004); + }); + + const REGISTRY_URL = 'https://internal.example/api.json'; + const REGISTRY_DOC = { + 'acme-claude': { + id: 'acme-claude', + name: 'Acme Claude', + api: 'https://acme.example/anthropic', + type: 'anthropic', + models: { + 'claude-opus': { + id: 'claude-opus', + name: 'Claude Opus', + limit: { context: 200000, output: 32000 }, + tool_call: true, + reasoning: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + support_efforts: ['low', 'high'], + default_effort: 'high', + }, + }, + }, + 'acme-gpt': { + id: 'acme-gpt', + name: 'Acme GPT', + api: 'https://acme.example/v1', + type: 'openai', + models: { + 'gpt-x': { id: 'gpt-x', limit: { context: 128000 } }, + }, + }, + 'bad-entry': { id: 'bad-entry' }, + } as const; + + function registryFetch(doc: unknown, seen?: { authorization?: string }): typeof fetch { + return (async (input: unknown, init?: RequestInit) => { + const url = String(input); + if (url === REGISTRY_URL) { + const headers = (init?.headers ?? {}) as Record; + if (seen !== undefined) seen.authorization = headers['Authorization']; + return new Response(JSON.stringify(doc), { status: 200 }); + } + return new Response(JSON.stringify(CATALOG), { status: 200 }); + }) as unknown as typeof fetch; + } + + it('imports every valid registry entry with a source blob and full model metadata', async () => { + const seen: { authorization?: string } = {}; + setModelsDevUpstreamForTest({ fetchImpl: registryFetch(REGISTRY_DOC, seen) }); + await boot(); + const { status, body } = await postJson<{ + providers: Array>; + models_imported: number; + }>('/api/v1/providers:import_registry', { url: REGISTRY_URL, api_key: 'tok-1' }); + expect(status).toBe(201); + expect(body.code).toBe(0); + expect(body.data.models_imported).toBe(2); + expect(body.data.providers.map((p) => p['id']).sort()).toEqual(['acme-claude', 'acme-gpt']); + expect(seen.authorization).toBe('Bearer tok-1'); + + const config = await readConfigToml(); + const providers = config['providers'] as Record>; + expect(providers['bad-entry']).toBeUndefined(); + expect(providers['acme-claude']).toMatchObject({ + type: 'anthropic', + base_url: 'https://acme.example/anthropic', + api_key: 'tok-1', + source: { kind: 'apiJson', url: REGISTRY_URL, apiKey: 'tok-1' }, + }); + const models = config['models'] as Record>; + expect(models['acme-claude/claude-opus']).toMatchObject({ + provider: 'acme-claude', + model: 'claude-opus', + max_context_size: 200000, + display_name: 'Claude Opus', + support_efforts: ['low', 'high'], + default_effort: 'high', + }); + expect(models['acme-claude/claude-opus']?.['capabilities']).toEqual([ + 'tool_use', + 'thinking', + 'image_in', + ]); + expect(models['acme-gpt/gpt-x']).toMatchObject({ + max_context_size: 128000, + capabilities: ['tool_use'], + }); + }); + + it('never touches the global default pointers on registry import', async () => { + setModelsDevUpstreamForTest({ fetchImpl: registryFetch(REGISTRY_DOC) }); + await boot(DEFAULTED_TOML); + const { status } = await postJson('/api/v1/providers:import_registry', { + url: REGISTRY_URL, + api_key: 'tok-1', + }); + expect(status).toBe(201); + const config = await readConfigToml(); + expect(config['default_provider']).toBe('kimi'); + expect(config['default_model']).toBe('k2'); + }); + + it('seeds the global default_model from the first registry model on a fresh setup', async () => { + setModelsDevUpstreamForTest({ fetchImpl: registryFetch(REGISTRY_DOC) }); + await boot(); + const { status } = await postJson('/api/v1/providers:import_registry', { + url: REGISTRY_URL, + api_key: 'tok-1', + }); + expect(status).toBe(201); + const config = await readConfigToml(); + expect(config['default_model']).toBe('acme-claude/claude-opus'); + }); + + it('re-imports the same URL as a refresh: vanished providers dropped, survivors rebuilt', async () => { + setModelsDevUpstreamForTest({ fetchImpl: registryFetch(REGISTRY_DOC) }); + await boot(DEFAULTED_TOML); + const first = await postJson('/api/v1/providers:import_registry', { + url: REGISTRY_URL, + api_key: 'tok-1', + }); + expect(first.status).toBe(201); + + const before = await readConfigToml(); + const beforeModels = before['models'] as Record>; + beforeModels['acme-gpt/gpt-x'] = { + ...beforeModels['acme-gpt/gpt-x'], + betaApi: true, + max_context_size: 1, + }; + const { stringify: stringifyToml } = await import('smol-toml'); + await writeFile(join(home as string, 'config.toml'), stringifyToml(before), 'utf-8'); + + const slimDoc = { 'acme-gpt': REGISTRY_DOC['acme-gpt'] }; + setModelsDevUpstreamForTest({ fetchImpl: registryFetch(slimDoc) }); + const second = await postJson('/api/v1/providers:import_registry', { + url: REGISTRY_URL, + api_key: 'tok-2', + }); + expect(second.status).toBe(201); + + const after = await readConfigToml(); + const providers = after['providers'] as Record>; + expect(providers['acme-claude']).toBeUndefined(); + expect(providers['acme-gpt']?.['api_key']).toBe('tok-2'); + const models = after['models'] as Record>; + expect(models['acme-claude/claude-opus']).toBeUndefined(); + expect(models['acme-gpt/gpt-x']).toEqual({ + provider: 'acme-gpt', + model: 'gpt-x', + max_context_size: 128000, + capabilities: ['tool_use'], + display_name: 'gpt-x', + }); + expect(after['default_model']).toBe('k2'); + }); + + it('answers 40003 when a registry entry id is OAuth-managed', async () => { + const managed = { + 'managed-one': { + id: 'managed-one', + name: 'Managed One', + api: 'https://acme.example/v1', + type: 'openai', + models: { m: { id: 'm', limit: { context: 1 } } }, + }, + }; + const managedToml = [ + '[providers."managed-one"]', + 'type = "openai"', + 'api_key = "sk-managed"', + 'oauth = { storage = "file", key = "oauth/managed-one" }', + '', + ].join('\n'); + setModelsDevUpstreamForTest({ fetchImpl: registryFetch(managed) }); + await boot(managedToml); + const { body } = await postJson('/api/v1/providers:import_registry', { + url: REGISTRY_URL, + api_key: 'tok-1', + }); + expect(body.code).toBe(40003); + }); + + it('answers 40005 when the registry is unreachable', async () => { + setModelsDevUpstreamForTest({ + fetchImpl: (async () => { + throw new Error('connection refused'); + }) as unknown as typeof fetch, + }); + await boot(); + const { body } = await postJson('/api/v1/providers:import_registry', { + url: REGISTRY_URL, + api_key: 'tok-1', + }); + expect(body.code).toBe(40005); + }); + + it('answers 40005 when the document has no valid entries', async () => { + setModelsDevUpstreamForTest({ fetchImpl: registryFetch({ bad: { id: 'bad' } }) }); + await boot(); + const { body } = await postJson('/api/v1/providers:import_registry', { + url: REGISTRY_URL, + api_key: 'tok-1', + }); + expect(body.code).toBe(40005); + expect(body.msg).toContain('no importable providers'); + }); + + it('answers 40001 when url is missing', async () => { + await boot(); + const { body } = await postJson('/api/v1/providers:import_registry', { api_key: 'tok-1' }); + expect(body.code).toBe(40001); + expect(body.msg).toContain('url'); + }); +}); diff --git a/packages/kap-server/test/modelCatalogProviderWrite.test.ts b/packages/kap-server/test/modelCatalogProviderWrite.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a86bbe11e36abfd6118630636119161f5f58b8f1 --- /dev/null +++ b/packages/kap-server/test/modelCatalogProviderWrite.test.ts @@ -0,0 +1,957 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { IConfigService } from '@moonshot-ai/agent-core-v2'; +import { parse as parseToml } from 'smol-toml'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: Array<{ path: string; message: string }>; +} + +const DEFAULTED_TOML = [ + 'default_provider = "openai"', + 'default_model = "gpt4o"', + '', + '[providers.kimi]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[providers.openai]', + 'type = "openai"', + 'api_key = "sk-openai"', + '', + '[models.k2]', + 'provider = "kimi"', + 'model = "kimi-k2"', + 'max_context_size = 131072', + '', + '[models.gpt4o]', + 'provider = "openai"', + 'model = "gpt-4o"', + 'max_context_size = 128000', + '', +].join('\n'); + +const KEEP_DEFAULT_TOML = DEFAULTED_TOML.replace('default_provider = "openai"\n', '').replace( + 'default_model = "gpt4o"', + 'default_model = "k2"', +); + +const DANGLING_DEFAULT_TOML = [ + 'default_model = "gone"', + '', + '[providers.kimi]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', +].join('\n'); + +const POOL_TOML = [ + DEFAULTED_TOML, + '[secondary_model]', + 'default_model = "k2"', + '', + '[secondary_model.models]', + 'k2 = "fast"', + 'gpt4o = "smart"', + '', +].join('\n'); + +const POOL_DANGLING_DEFAULT_TOML = POOL_TOML.replace('default_model = "k2"', 'default_model = "gpt4o"'); + +const MANAGED_TOML = [ + '[providers."managed:kimi-code"]', + 'type = "kimi"', + 'api_key = ""', + 'base_url = "https://api.example.test/v1"', + 'oauth = { storage = "file", key = "oauth/kimi-code" }', + '', + '[models."managed:kimi-code/kimi-k2"]', + 'provider = "managed:kimi-code"', + 'model = "kimi-k2"', + 'max_context_size = 131072', + '', +].join('\n'); + +const CREATE_BODY = { + id: 'my-openai', + type: 'openai', + api_key: 'sk-test-openai', + base_url: 'https://api.openai.example/v1', + default_model: 'gpt-4.1', + models: [ + { + model: 'gpt-4.1', + max_context_size: 1047576, + display_name: 'GPT-4.1', + capabilities: ['vision'], + max_output_size: 32768, + }, + { model: 'gpt-4o-mini', max_context_size: 128000 }, + ], +} as const; + +const REPLACE_BODY = { + type: 'openai', + base_url: 'https://api.openai.example/v1', + default_model: 'gpt-4.1', + models: [ + { model: 'gpt-4.1', max_context_size: 1047576, display_name: 'GPT-4.1' }, + { model: 'gpt-4o-mini', max_context_size: 128000 }, + ], +} as const; + +describe('server-v2 /api/v1 provider write endpoints', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-provider-write-')); + process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START'] = '0'; + process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS'] = '0'; + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + delete process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START']; + delete process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS']; + }); + + async function boot(toml?: string): Promise { + await writeFile(join(home as string, 'config.toml'), toml ?? '', 'utf-8'); + await (server as RunningServer).core.accessor.get(IConfigService).reload(); + } + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: authHeaders( + server as RunningServer, + body === undefined ? {} : { 'content-type': 'application/json' }, + ), + body: body === undefined ? undefined : JSON.stringify(body), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function putJson( + path: string, + body: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'PUT', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify(body), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function deleteJson( + path: string, + ): Promise<{ status: number; text: string; body: Envelope | undefined }> { + const res = await fetch(`${base}${path}`, { + method: 'DELETE', + headers: authHeaders(server as RunningServer), + } as never); + const text = await res.text(); + return { + status: res.status, + text, + body: text.length === 0 ? undefined : (JSON.parse(text) as Envelope), + }; + } + + async function readConfigToml(): Promise> { + const text = await readFile(join(home as string, 'config.toml'), 'utf-8'); + return parseToml(text) as Record; + } + + it('creates a provider with model aliases and persists them to config.toml', async () => { + await boot(); + const { status, body } = await postJson('/api/v1/providers', CREATE_BODY); + expect(status).toBe(201); + expect(body.code).toBe(0); + expect(body.data).toEqual({ + id: 'my-openai', + type: 'openai', + base_url: 'https://api.openai.example/v1', + default_model: 'my-openai/gpt-4.1', + has_api_key: true, + status: 'connected', + models: ['my-openai/gpt-4.1', 'my-openai/gpt-4o-mini'], + }); + + const onDisk = await readConfigToml(); + expect(onDisk['providers']).toEqual({ + 'my-openai': { + type: 'openai', + api_key: 'sk-test-openai', + base_url: 'https://api.openai.example/v1', + default_model: 'my-openai/gpt-4.1', + }, + }); + expect(onDisk['models']).toEqual({ + 'my-openai/gpt-4.1': { + provider: 'my-openai', + model: 'gpt-4.1', + max_context_size: 1047576, + display_name: 'GPT-4.1', + capabilities: ['vision'], + max_output_size: 32768, + }, + 'my-openai/gpt-4o-mini': { + provider: 'my-openai', + model: 'gpt-4o-mini', + max_context_size: 128000, + }, + }); + + const providers = await getJson<{ items: unknown[] }>('/api/v1/providers'); + expect(providers.body.data.items).toEqual([body.data]); + + const models = await getJson<{ items: unknown[] }>('/api/v1/models'); + expect(models.body.data.items).toEqual([ + { + provider: 'my-openai', + model: 'my-openai/gpt-4.1', + display_name: 'GPT-4.1', + max_context_size: 1047576, + capabilities: ['vision'], + }, + { + provider: 'my-openai', + model: 'my-openai/gpt-4o-mini', + display_name: 'gpt-4o-mini', + max_context_size: 128000, + }, + ]); + }); + + it('creates a credential-less provider (env-resolved types may omit api_key)', async () => { + await boot(); + const { status, body } = await postJson('/api/v1/providers', { + id: 'vertex', + type: 'vertexai', + models: [{ model: 'gemini-2.5-pro', max_context_size: 1048576 }], + }); + expect(status).toBe(201); + expect(body.code).toBe(0); + expect(body.data).toEqual({ + id: 'vertex', + type: 'vertexai', + default_model: 'vertex/gemini-2.5-pro', + has_api_key: false, + status: 'unconfigured', + models: ['vertex/gemini-2.5-pro'], + }); + }); + + it('seeds the global default_model on a fresh setup (the provider default wins)', async () => { + await boot(); + const { status } = await postJson('/api/v1/providers', CREATE_BODY); + expect(status).toBe(201); + + const onDisk = await readConfigToml(); + expect(onDisk['default_model']).toBe('my-openai/gpt-4.1'); + + const config = await getJson<{ default_model: string | null }>('/api/v1/config'); + expect(config.body.data.default_model).toBe('my-openai/gpt-4.1'); + const auth = await getJson<{ models_ready: boolean }>('/api/v1/auth'); + expect(auth.body.data).toMatchObject({ models_ready: true }); + }); + + it('seeds the first model when the create body names no provider default', async () => { + await boot(); + const { status } = await postJson('/api/v1/providers', { + id: 'my-openai', + type: 'openai', + api_key: 'sk-test-openai', + models: [ + { model: 'gpt-4o-mini', max_context_size: 128000 }, + { model: 'gpt-4.1', max_context_size: 1047576 }, + ], + }); + expect(status).toBe(201); + + const onDisk = await readConfigToml(); + expect(onDisk['default_model']).toBe('my-openai/gpt-4o-mini'); + }); + + it('keeps an existing global default_model on create', async () => { + await boot(DEFAULTED_TOML); + const { status } = await postJson('/api/v1/providers', CREATE_BODY); + expect(status).toBe(201); + + const onDisk = await readConfigToml(); + expect(onDisk['default_model']).toBe('gpt4o'); + }); + + it('leaves even a dangling default_model untouched on create', async () => { + await boot(DANGLING_DEFAULT_TOML); + const { status } = await postJson('/api/v1/providers', CREATE_BODY); + expect(status).toBe(201); + + const onDisk = await readConfigToml(); + expect(onDisk['default_model']).toBe('gone'); + }); + + it('rejects a duplicate provider id with 40921', async () => { + await boot(KEEP_DEFAULT_TOML); + const { body } = await postJson('/api/v1/providers', { + ...CREATE_BODY, + id: 'openai', + }); + expect(body.code).toBe(40921); + expect(body.data).toBeNull(); + + const providers = await getJson<{ items: Array<{ id: string }> }>('/api/v1/providers'); + expect(providers.body.data.items.map((p) => p.id)).toEqual(['kimi', 'openai']); + }); + + it('accepts a Unicode provider id (Chinese + space)', async () => { + await boot(); + const { status, body } = await postJson<{ id: string }>('/api/v1/providers', { + ...CREATE_BODY, + id: '测试 Kimi', + }); + expect(status).toBe(201); + expect(body.code).toBe(0); + expect(body.data.id).toBe('测试 Kimi'); + + const onDisk = await readConfigToml(); + expect(onDisk['providers']).toMatchObject({ '测试 Kimi': { type: 'openai' } }); + expect(onDisk['models']).toMatchObject({ + '测试 Kimi/gpt-4.1': { provider: '测试 Kimi', model: 'gpt-4.1' }, + }); + }); + + it('creates models with support_efforts and adaptive_thinking', async () => { + await boot(); + const { status } = await postJson('/api/v1/providers', { + ...CREATE_BODY, + models: [ + { + model: 'gpt-4.1', + max_context_size: 1047576, + support_efforts: ['low', 'max'], + adaptive_thinking: true, + }, + ], + }); + expect(status).toBe(201); + + const onDisk = await readConfigToml(); + expect(onDisk['models']).toMatchObject({ + 'my-openai/gpt-4.1': { + support_efforts: ['low', 'max'], + adaptive_thinking: true, + }, + }); + }); + + it('rejects invalid create bodies with 40001', async () => { + await boot(); + const cases: Array<{ name: string; body: unknown; path?: string }> = [ + { + name: 'id with illegal characters', + body: { ...CREATE_BODY, id: 'bad!id' }, + path: 'id', + }, + { name: 'unknown wire type', body: { ...CREATE_BODY, type: 'custom' }, path: 'type' }, + { name: 'empty models list', body: { ...CREATE_BODY, models: [] }, path: 'models' }, + { + name: 'default_model outside the models list', + body: { ...CREATE_BODY, default_model: 'gpt-5' }, + path: 'default_model', + }, + ]; + for (const { name, body, path } of cases) { + const { body: envelope } = await postJson('/api/v1/providers', body); + expect(envelope.code, name).toBe(40001); + expect(envelope.data, name).toBeNull(); + if (path !== undefined) { + expect( + envelope.details?.some((detail) => detail.path === path), + name, + ).toBe(true); + } + } + }); + + it('deletes a provider and its model aliases, keeping unrelated defaults', async () => { + await boot(KEEP_DEFAULT_TOML); + const { status, text } = await deleteJson('/api/v1/providers/openai'); + expect(status).toBe(204); + expect(text).toBe(''); + + const onDisk = await readConfigToml(); + expect(onDisk['providers']).toEqual({ kimi: { type: 'kimi', api_key: 'sk-test' } }); + expect(onDisk['models']).toEqual({ + k2: { provider: 'kimi', model: 'kimi-k2', max_context_size: 131072 }, + }); + expect(onDisk['default_model']).toBe('k2'); + + const providers = await getJson<{ items: Array<{ id: string }> }>('/api/v1/providers'); + expect(providers.body.data.items.map((p) => p.id)).toEqual(['kimi']); + const models = await getJson<{ items: Array<{ model: string }> }>('/api/v1/models'); + expect(models.body.data.items.map((m) => m.model)).toEqual(['k2']); + }); + + it('never touches default_provider/default_model when deleting their owner (204, pointers dangling)', async () => { + await boot(DEFAULTED_TOML); + const { status, text } = await deleteJson('/api/v1/providers/openai'); + expect(status).toBe(204); + expect(text).toBe(''); + + const onDisk = await readConfigToml(); + expect(onDisk['default_provider']).toBe('openai'); + expect(onDisk['default_model']).toBe('gpt4o'); + expect(onDisk['providers']).toEqual({ kimi: { type: 'kimi', api_key: 'sk-test' } }); + expect(onDisk['models']).toEqual({ + k2: { provider: 'kimi', model: 'kimi-k2', max_context_size: 131072 }, + }); + }); + + it('leaves the secondary_model pool untouched when a provider is deleted', async () => { + await boot(POOL_TOML); + const { status } = await deleteJson('/api/v1/providers/openai'); + expect(status).toBe(204); + + const onDisk = await readConfigToml(); + expect(onDisk['secondary_model']).toEqual({ + default_model: 'k2', + models: { k2: 'fast', gpt4o: 'smart' }, + }); + }); + + it('keeps the secondary_model section even when its default dangles after deletion', async () => { + await boot(POOL_DANGLING_DEFAULT_TOML); + const { status } = await deleteJson('/api/v1/providers/openai'); + expect(status).toBe(204); + + const onDisk = await readConfigToml(); + expect(onDisk['secondary_model']).toEqual({ + default_model: 'gpt4o', + models: { k2: 'fast', gpt4o: 'smart' }, + }); + }); + + it('round-trips a created provider: delete removes every trace from config.toml', async () => { + await boot(); + const created = await postJson('/api/v1/providers', CREATE_BODY); + expect(created.status).toBe(201); + + const { status } = await deleteJson('/api/v1/providers/my-openai'); + expect(status).toBe(204); + + const onDisk = await readConfigToml(); + expect(onDisk['providers']).toBeUndefined(); + expect(onDisk['models']).toBeUndefined(); + expect(onDisk['default_model']).toBe('my-openai/gpt-4.1'); + + const providers = await getJson<{ items: unknown[] }>('/api/v1/providers'); + expect(providers.body.data.items).toEqual([]); + }); + + it('rejects deleting an OAuth-managed provider with 40003', async () => { + await boot(MANAGED_TOML); + const { body } = await deleteJson('/api/v1/providers/managed%3Akimi-code'); + expect(body?.code).toBe(40003); + expect(body?.msg).toContain('/oauth/logout'); + + const providers = await getJson<{ items: Array<{ id: string }> }>('/api/v1/providers'); + expect(providers.body.data.items.map((p) => p.id)).toEqual(['managed:kimi-code']); + }); + + it('maps an unknown provider id to 40412 on delete', async () => { + await boot(KEEP_DEFAULT_TOML); + const { body } = await deleteJson('/api/v1/providers/missing'); + expect(body?.code).toBe(40412); + }); + + it('replaces a provider, keeping the stored api_key and rebuilding its aliases', async () => { + await boot(KEEP_DEFAULT_TOML); + const { status, body } = await putJson<{ + provider: Record; + }>('/api/v1/providers/openai', REPLACE_BODY); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.provider).toEqual({ + id: 'openai', + type: 'openai', + base_url: 'https://api.openai.example/v1', + default_model: 'openai/gpt-4.1', + has_api_key: true, + status: 'connected', + models: ['openai/gpt-4.1', 'openai/gpt-4o-mini'], + }); + + const onDisk = await readConfigToml(); + expect(onDisk['providers']).toEqual({ + kimi: { type: 'kimi', api_key: 'sk-test' }, + openai: { + type: 'openai', + api_key: 'sk-openai', + base_url: 'https://api.openai.example/v1', + default_model: 'openai/gpt-4.1', + }, + }); + expect(onDisk['models']).toEqual({ + k2: { provider: 'kimi', model: 'kimi-k2', max_context_size: 131072 }, + 'openai/gpt-4.1': { + provider: 'openai', + model: 'gpt-4.1', + max_context_size: 1047576, + display_name: 'GPT-4.1', + }, + 'openai/gpt-4o-mini': { + provider: 'openai', + model: 'gpt-4o-mini', + max_context_size: 128000, + }, + }); + expect(onDisk['default_model']).toBe('k2'); + + const models = await getJson<{ items: Array<{ model: string }> }>('/api/v1/models'); + expect(models.body.data.items.map((m) => m.model)).toEqual([ + 'k2', + 'openai/gpt-4.1', + 'openai/gpt-4o-mini', + ]); + }); + + it('sets a new api_key when a non-empty one is sent', async () => { + await boot(KEEP_DEFAULT_TOML); + const { status, body } = await putJson<{ provider: { has_api_key: boolean } }>( + '/api/v1/providers/openai', + { ...REPLACE_BODY, api_key: 'sk-new-openai' }, + ); + expect(status).toBe(200); + expect(body.data.provider.has_api_key).toBe(true); + + const onDisk = await readConfigToml(); + expect(onDisk['providers']).toEqual({ + kimi: { type: 'kimi', api_key: 'sk-test' }, + openai: { + type: 'openai', + api_key: 'sk-new-openai', + base_url: 'https://api.openai.example/v1', + default_model: 'openai/gpt-4.1', + }, + }); + }); + + it('clears the stored api_key when an empty string is sent', async () => { + await boot(KEEP_DEFAULT_TOML); + const { status, body } = await putJson<{ + provider: { has_api_key: boolean; status: string }; + }>('/api/v1/providers/openai', { ...REPLACE_BODY, api_key: '' }); + expect(status).toBe(200); + expect(body.data.provider.has_api_key).toBe(false); + expect(body.data.provider.status).toBe('unconfigured'); + + const onDisk = await readConfigToml(); + expect(onDisk['providers']).toEqual({ + kimi: { type: 'kimi', api_key: 'sk-test' }, + openai: { + type: 'openai', + api_key: '', + base_url: 'https://api.openai.example/v1', + default_model: 'openai/gpt-4.1', + }, + }); + }); + + it('merges onto existing model records: unknown fields preserved, form fields authoritative', async () => { + const RICH_TOML = [ + '[providers.openai]', + 'type = "openai"', + 'api_key = "sk-openai"', + '', + '[models."openai/gpt-4o"]', + 'provider = "openai"', + 'model = "gpt-4o"', + 'max_context_size = 128000', + 'beta_api = true', + 'default_effort = "high"', + '', + ].join('\n'); + await boot(RICH_TOML); + const { status, body } = await putJson('/api/v1/providers/openai', { + type: 'openai', + models: [ + { + model: 'gpt-4o', + max_context_size: 256000, + capabilities: ['thinking', 'tool_use'], + support_efforts: ['low', 'high', 'max'], + adaptive_thinking: true, + }, + ], + }); + expect(status).toBe(200); + expect(body.code).toBe(0); + + const onDisk = await readConfigToml(); + expect(onDisk['models']).toEqual({ + 'openai/gpt-4o': { + provider: 'openai', + model: 'gpt-4o', + max_context_size: 256000, + beta_api: true, + default_effort: 'high', + capabilities: ['thinking', 'tool_use'], + support_efforts: ['low', 'high', 'max'], + adaptive_thinking: true, + }, + }); + }); + + it('never touches default_model when the rebuild drops its alias (no rename)', async () => { + await boot(DEFAULTED_TOML); + const { status, body } = await putJson( + '/api/v1/providers/openai', + { ...REPLACE_BODY, type: 'openai_responses' }, + ); + expect(status).toBe(200); + expect(body.code).toBe(0); + + const onDisk = await readConfigToml(); + expect(onDisk['default_model']).toBe('gpt4o'); + expect(onDisk['default_provider']).toBe('openai'); + expect(onDisk['providers']).toEqual({ + kimi: { type: 'kimi', api_key: 'sk-test' }, + openai: { + type: 'openai_responses', + api_key: 'sk-openai', + base_url: 'https://api.openai.example/v1', + default_model: 'openai/gpt-4.1', + }, + }); + }); + + it('renames a provider: providers key, aliases, default_provider and default_model all migrate', async () => { + await boot(DEFAULTED_TOML); + const { status, body } = await putJson<{ + provider: Record; + }>('/api/v1/providers/openai', { + ...REPLACE_BODY, + new_id: 'my-openai', + models: [ + { model: 'gpt-4o', max_context_size: 128000 }, + { model: 'gpt-4.1', max_context_size: 1047576 }, + ], + }); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.provider['id']).toBe('my-openai'); + + const onDisk2 = await readConfigToml(); + expect(onDisk2['providers']).toEqual({ + kimi: { type: 'kimi', api_key: 'sk-test' }, + 'my-openai': { + type: 'openai', + api_key: 'sk-openai', + base_url: 'https://api.openai.example/v1', + default_model: 'my-openai/gpt-4.1', + }, + }); + expect(onDisk2['models']).toEqual({ + k2: { provider: 'kimi', model: 'kimi-k2', max_context_size: 131072 }, + 'my-openai/gpt-4o': { provider: 'my-openai', model: 'gpt-4o', max_context_size: 128000 }, + 'my-openai/gpt-4.1': { provider: 'my-openai', model: 'gpt-4.1', max_context_size: 1047576 }, + }); + expect(onDisk2['default_provider']).toBe('my-openai'); + expect(onDisk2['default_model']).toBe('my-openai/gpt-4o'); + }); + + it('migrates default_provider on rename but leaves default_model alone when its model was dropped', async () => { + await boot(DEFAULTED_TOML); + const { status, body } = await putJson( + '/api/v1/providers/openai', + { ...REPLACE_BODY, new_id: 'my-openai' }, + ); + expect(status).toBe(200); + expect(body.code).toBe(0); + + const onDisk = await readConfigToml(); + expect(onDisk['default_provider']).toBe('my-openai'); + expect(onDisk['default_model']).toBe('gpt4o'); + }); + + it('leaves secondary_model pool entries alone on provider rename', async () => { + await boot(POOL_TOML); + const { status } = await putJson('/api/v1/providers/openai', { + type: 'openai', + new_id: 'my-openai', + models: [{ model: 'gpt-4o', max_context_size: 128000 }], + }); + expect(status).toBe(200); + + const onDisk = await readConfigToml(); + expect(onDisk['secondary_model']).toEqual({ + default_model: 'k2', + models: { k2: 'fast', gpt4o: 'smart' }, + }); + }); + + it('leaves secondary_model pool entries alone on provider edit', async () => { + await boot(POOL_TOML); + const { status } = await putJson('/api/v1/providers/openai', REPLACE_BODY); + expect(status).toBe(200); + + const onDisk = await readConfigToml(); + expect(onDisk['secondary_model']).toEqual({ + default_model: 'k2', + models: { k2: 'fast', gpt4o: 'smart' }, + }); + }); + + it('keeps the secondary_model section even when a provider edit orphans its default', async () => { + await boot(POOL_DANGLING_DEFAULT_TOML); + const { status } = await putJson('/api/v1/providers/openai', REPLACE_BODY); + expect(status).toBe(200); + + const onDisk = await readConfigToml(); + expect(onDisk['secondary_model']).toEqual({ + default_model: 'gpt4o', + models: { k2: 'fast', gpt4o: 'smart' }, + }); + }); + + it('rejects a rename to an existing provider id with 40921', async () => { + await boot(KEEP_DEFAULT_TOML); + const { status, body } = await putJson('/api/v1/providers/openai', { + ...REPLACE_BODY, + new_id: 'kimi', + }); + expect(status).toBe(200); + expect(body.code).toBe(40921); + + const onDisk = await readConfigToml(); + expect(onDisk['providers']).toEqual({ + kimi: { type: 'kimi', api_key: 'sk-test' }, + openai: { type: 'openai', api_key: 'sk-openai' }, + }); + }); + + it('rejects invalid replace bodies with 40001', async () => { + await boot(KEEP_DEFAULT_TOML); + const cases: Array<{ name: string; body: unknown; path?: string }> = [ + { name: 'unknown wire type', body: { ...REPLACE_BODY, type: 'custom' }, path: 'type' }, + { name: 'empty models list', body: { ...REPLACE_BODY, models: [] }, path: 'models' }, + { + name: 'default_model outside the models list', + body: { ...REPLACE_BODY, default_model: 'gpt-5' }, + path: 'default_model', + }, + ]; + for (const { name, body, path } of cases) { + const { body: envelope } = await putJson('/api/v1/providers/openai', body); + expect(envelope.code, name).toBe(40001); + expect(envelope.data, name).toBeNull(); + if (path !== undefined) { + expect( + envelope.details?.some((detail) => detail.path === path), + name, + ).toBe(true); + } + } + }); + + it('rejects replacing an OAuth-managed provider with 40003', async () => { + await boot(MANAGED_TOML); + const { body } = await putJson( + '/api/v1/providers/managed%3Akimi-code', + REPLACE_BODY, + ); + expect(body.code).toBe(40003); + expect(body.msg).toContain('/oauth/logout'); + + const providers = await getJson<{ items: Array<{ id: string }> }>('/api/v1/providers'); + expect(providers.body.data.items.map((p) => p.id)).toEqual(['managed:kimi-code']); + const models = await getJson<{ items: Array<{ model: string }> }>('/api/v1/models'); + expect(models.body.data.items.map((m) => m.model)).toEqual(['managed:kimi-code/kimi-k2']); + }); + + it('maps an unknown provider id to 40412 on replace', async () => { + await boot(KEEP_DEFAULT_TOML); + const { body } = await putJson('/api/v1/providers/missing', REPLACE_BODY); + expect(body.code).toBe(40412); + expect(body.data).toBeNull(); + }); + + it('clears omitted provider fields (base_url/default_model) from config.toml for real', async () => { + const FULL_TOML = [ + '[providers.openai]', + 'type = "openai"', + 'api_key = "sk-openai"', + 'base_url = "https://api.openai.example/v1"', + 'default_model = "openai/gpt-4.1"', + 'custom_headers = { "X-Org" = "acme" }', + '', + '[models."openai/gpt-4.1"]', + 'provider = "openai"', + 'model = "gpt-4.1"', + 'max_context_size = 1047576', + 'display_name = "GPT-4.1"', + 'capabilities = ["tool_use"]', + '', + ].join('\n'); + await boot(FULL_TOML); + const { status } = await putJson('/api/v1/providers/openai', { + type: 'openai', + models: [{ model: 'gpt-4.1', max_context_size: 1047576 }], + }); + expect(status).toBe(200); + + const onDisk = await readConfigToml(); + expect(onDisk['providers']).toEqual({ + openai: { + type: 'openai', + api_key: 'sk-openai', + custom_headers: { 'X-Org': 'acme' }, + }, + }); + expect(onDisk['models']).toEqual({ + 'openai/gpt-4.1': { + provider: 'openai', + model: 'gpt-4.1', + max_context_size: 1047576, + }, + }); + + const single = await getJson>('/api/v1/providers/openai'); + expect(single.body.data).not.toHaveProperty('base_url'); + expect(single.body.data).not.toHaveProperty('default_model'); + }); + + it('does not reveal an empty-string api_key on the single GET', async () => { + await boot(KEEP_DEFAULT_TOML); + await putJson('/api/v1/providers/openai', { ...REPLACE_BODY, api_key: '' }); + const { body } = await getJson>('/api/v1/providers/openai'); + expect(body.data).not.toHaveProperty('api_key'); + }); + + it('rejects duplicate model rows with 40001 on both create and replace', async () => { + await boot(KEEP_DEFAULT_TOML); + const duplicate = { + ...CREATE_BODY, + models: [ + { model: 'gpt-4.1', max_context_size: 1047576 }, + { model: 'gpt-4.1', max_context_size: 128000 }, + ], + }; + const created = await postJson('/api/v1/providers', duplicate); + expect(created.body.code).toBe(40001); + expect(created.body.msg).toContain('duplicate model'); + + const replaced = await putJson('/api/v1/providers/openai', { + type: 'openai', + models: [ + { model: 'gpt-4.1', max_context_size: 1047576 }, + { model: 'gpt-4.1', max_context_size: 128000 }, + ], + }); + expect(replaced.body.code).toBe(40001); + }); + + it('rejects a base_url containing an env placeholder with 40001', async () => { + await boot(KEEP_DEFAULT_TOML); + const { body } = await postJson('/api/v1/providers', { + ...CREATE_BODY, + base_url: 'https://${HOST}/v1', + }); + expect(body.code).toBe(40001); + expect(body.msg).toContain('base_url'); + }); + + it('trims a padded base_url before persisting', async () => { + await boot(KEEP_DEFAULT_TOML); + const { status, body } = await postJson<{ base_url?: string }>('/api/v1/providers', { + ...CREATE_BODY, + base_url: ' https://api.openai.example/v1 ', + }); + expect(status).toBe(201); + expect(body.data.base_url).toBe('https://api.openai.example/v1'); + }); + + it('rejects a rename/rebuild whose alias key is owned by another provider (40001, no writes)', async () => { + const FOREIGN_TOML = [ + '[providers.openai]', + 'type = "openai"', + 'api_key = "sk-openai"', + '', + '[providers.other]', + 'type = ' + '"anthropic"', + 'api_key = "sk-other"', + '', + '[models."openai/gpt-4.1"]', + 'provider = "other"', + 'model = "claude-thing"', + 'max_context_size = 200000', + '', + '[models."other/claude-thing"]', + 'provider = "other"', + 'model = "claude-thing"', + 'max_context_size = 200000', + '', + ].join('\n'); + await boot(FOREIGN_TOML); + const { status, body } = await putJson('/api/v1/providers/openai', { + type: 'openai', + models: [{ model: 'gpt-4.1', max_context_size: 1047576 }], + }); + expect(status).toBe(200); + expect(body.code).toBe(40001); + expect(body.msg).toContain('openai/gpt-4.1'); + + const onDisk = await readConfigToml(); + expect(onDisk['providers']).toEqual({ + openai: { type: 'openai', api_key: 'sk-openai' }, + other: { type: 'anthropic', api_key: 'sk-other' }, + }); + const models = onDisk['models'] as Record>; + expect(models['openai/gpt-4.1']).toEqual({ + provider: 'other', + model: 'claude-thing', + max_context_size: 200000, + }); + }); +}); diff --git a/packages/kap-server/test/modelCatalogRefreshScheduler.test.ts b/packages/kap-server/test/modelCatalogRefreshScheduler.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e910529380bf7d9d08ce9e42426146c3f7d0b00 --- /dev/null +++ b/packages/kap-server/test/modelCatalogRefreshScheduler.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + IConfigService, + IProviderDiscoveryService, + ModelCatalogConfig, +} from '@moonshot-ai/agent-core-v2'; + +import { ModelCatalogRefreshScheduler } from '../src/services/modelCatalog/modelCatalogRefreshScheduler'; +import type { ServerLogger } from '../src/services/pinoLoggerService'; + +const EMPTY_RESULT = { changed: [], unchanged: [], failed: [] }; + +function makeCatalog(refreshProviderModels = vi.fn(async () => EMPTY_RESULT)) { + return { refreshProviderModels } as unknown as IProviderDiscoveryService; +} + +function makeConfig(catalogConfig?: ModelCatalogConfig): IConfigService { + return { + ready: Promise.resolve(), + get: vi.fn((domain: string) => (domain === 'modelCatalog' ? catalogConfig : undefined)), + } as unknown as IConfigService; +} + +function makeLogger(): Pick { + return { + info: vi.fn(), + warn: vi.fn(), + }; +} + +describe('ModelCatalogRefreshScheduler', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it('refreshes on start and then on the configured interval', async () => { + const catalog = makeCatalog(); + const scheduler = new ModelCatalogRefreshScheduler(catalog, makeConfig(), makeLogger(), {}); + + await scheduler.start(); + await vi.waitFor(() => { + expect(catalog.refreshProviderModels).toHaveBeenCalledTimes(1); + }); + expect(catalog.refreshProviderModels).toHaveBeenCalledWith({ scope: 'all' }); + + await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000); + expect(catalog.refreshProviderModels).toHaveBeenCalledTimes(2); + + scheduler.dispose(); + await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000); + expect(catalog.refreshProviderModels).toHaveBeenCalledTimes(2); + }); + + it('honors env overrides for interval and refresh-on-start', async () => { + const catalog = makeCatalog(); + const scheduler = new ModelCatalogRefreshScheduler(catalog, makeConfig(), makeLogger(), { + KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS: '1000', + KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START: '0', + }); + + await scheduler.start(); + await vi.advanceTimersByTimeAsync(999); + expect(catalog.refreshProviderModels).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(catalog.refreshProviderModels).toHaveBeenCalledTimes(1); + }); + + it('reads interval and refresh-on-start from the modelCatalog config section', async () => { + const catalog = makeCatalog(); + const scheduler = new ModelCatalogRefreshScheduler( + catalog, + makeConfig({ refreshIntervalMs: 1000, refreshOnStart: false }), + makeLogger(), + {}, + ); + + await scheduler.start(); + await vi.advanceTimersByTimeAsync(999); + expect(catalog.refreshProviderModels).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(catalog.refreshProviderModels).toHaveBeenCalledTimes(1); + }); + + it('lets env override the modelCatalog config section', async () => { + const catalog = makeCatalog(); + const scheduler = new ModelCatalogRefreshScheduler( + catalog, + makeConfig({ refreshIntervalMs: 6 * 60 * 60 * 1000, refreshOnStart: true }), + makeLogger(), + { + KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START: '0', + KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS: '1000', + }, + ); + + await scheduler.start(); + await vi.advanceTimersByTimeAsync(999); + expect(catalog.refreshProviderModels).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(catalog.refreshProviderModels).toHaveBeenCalledTimes(1); + }); + + it('disables the schedule when the config interval is 0', async () => { + const catalog = makeCatalog(); + const scheduler = new ModelCatalogRefreshScheduler( + catalog, + makeConfig({ refreshIntervalMs: 0, refreshOnStart: false }), + makeLogger(), + {}, + ); + + await scheduler.start(); + await vi.advanceTimersByTimeAsync(60 * 1000); + expect(catalog.refreshProviderModels).not.toHaveBeenCalled(); + }); + + it('swallows refresh errors so a failing tick does not break the schedule', async () => { + const refreshProviderModels = vi.fn().mockRejectedValue(new Error('network down')); + const catalog = makeCatalog(refreshProviderModels); + const logger = makeLogger(); + const scheduler = new ModelCatalogRefreshScheduler(catalog, makeConfig(), logger, { + KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS: '1000', + KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START: 'false', + }); + + await scheduler.start(); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + expect(refreshProviderModels).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/kap-server/test/oauthUsage.test.ts b/packages/kap-server/test/oauthUsage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ddda4d90722cd250a9cc6f2accadff0f563b29ff --- /dev/null +++ b/packages/kap-server/test/oauthUsage.test.ts @@ -0,0 +1,277 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + IOAuthService, + type IOAuthService as IOAuthServiceType, + type ScopeSeed, +} from '@moonshot-ai/agent-core-v2'; +import { + managedUsageResultSchema, + managedUserInfoResultSchema, + type ManagedUsageResult, + type ManagedUserInfoResult, +} from '@moonshot-ai/agent-core-v2/app/auth/oauthProtocol'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +describe('server-v2 GET /api/v1/oauth/usage', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-oauth-usage-')); + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + function oauthStub(getManagedUsage: IOAuthServiceType['getManagedUsage']): IOAuthServiceType { + return { + _serviceBrand: undefined, + startLogin: async () => { + throw new Error('unused'); + }, + getFlow: () => undefined, + cancelLogin: async () => { + throw new Error('unused'); + }, + logout: async () => { + throw new Error('unused'); + }, + status: async () => ({ loggedIn: false }), + refreshOAuthProviderModels: async () => ({ changed: [], unchanged: [], failed: [] }), + getManagedUsage, + getManagedUserInfo: async () => ({ kind: 'error' as const, message: 'unused' }), + resolveTokenProvider: () => undefined, + getCachedAccessToken: async () => undefined, + getRegion: () => 'mainland-cn', + }; + } + + async function boot(seeds: ScopeSeed): Promise { + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds, + }); + base = `http://127.0.0.1:${server.port}`; + } + + async function getUsage(query = ''): Promise { + const res = await fetch(`${base}/api/v1/oauth/usage${query}`, { + headers: authHeaders(server as RunningServer), + } as never); + expect(res.status).toBe(200); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(0); + return managedUsageResultSchema.parse(body.data); + } + + it('returns the ok quota payload in the camelCase domain shape', async () => { + const getManagedUsage = vi.fn(async () => ({ + kind: 'ok' as const, + quota: { + usages: { + limit5h: { usedRatio: 0.3, resetAt: '2030-01-01T00:00:00.000Z' }, + monthTotal: { usedRatio: 0.4, resetAt: '2030-02-01T00:00:00.000Z' }, + monthCode: { usedRatio: 0.25 }, + }, + extraUsage: { + balanceCents: 500, + totalCents: 1000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 2000, + monthlyUsedCents: 1500, + currency: 'CNY', + }, + }, + })); + await boot([[IOAuthService, oauthStub(getManagedUsage)]] as unknown as ScopeSeed); + + expect(await getUsage()).toEqual({ + kind: 'ok', + quota: { + usages: { + limit5h: { usedRatio: 0.3, resetAt: '2030-01-01T00:00:00.000Z' }, + monthTotal: { usedRatio: 0.4, resetAt: '2030-02-01T00:00:00.000Z' }, + monthCode: { usedRatio: 0.25 }, + }, + extraUsage: { + balanceCents: 500, + totalCents: 1000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 2000, + monthlyUsedCents: 1500, + currency: 'CNY', + }, + }, + }); + }); + + it('passes through the error payload and forwards the provider query', async () => { + const getManagedUsage = vi.fn(async (_provider?: string) => ({ + kind: 'error' as const, + message: 'Authorization failed.', + status: 401, + })); + await boot([[IOAuthService, oauthStub(getManagedUsage)]] as unknown as ScopeSeed); + + expect(await getUsage('?provider=managed%3Akimi-code')).toEqual({ + kind: 'error', + message: 'Authorization failed.', + status: 401, + }); + expect(getManagedUsage).toHaveBeenCalledWith('managed:kimi-code'); + }); +}); + +describe('server-v2 GET /api/v1/oauth/userinfo', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-oauth-userinfo-')); + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + function oauthStub(getManagedUserInfo: IOAuthServiceType['getManagedUserInfo']): IOAuthServiceType { + return { + _serviceBrand: undefined, + startLogin: async () => { + throw new Error('unused'); + }, + getFlow: () => undefined, + cancelLogin: async () => { + throw new Error('unused'); + }, + logout: async () => { + throw new Error('unused'); + }, + status: async () => ({ loggedIn: false }), + refreshOAuthProviderModels: async () => ({ changed: [], unchanged: [], failed: [] }), + getManagedUsage: async () => ({ kind: 'error' as const, message: 'unused' }), + getManagedUserInfo, + resolveTokenProvider: () => undefined, + getCachedAccessToken: async () => undefined, + getRegion: () => 'mainland-cn', + }; + } + + async function boot(seeds: ScopeSeed): Promise { + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds, + }); + base = `http://127.0.0.1:${server.port}`; + } + + async function getUserInfo(query = ''): Promise { + const res = await fetch(`${base}/api/v1/oauth/userinfo${query}`, { + headers: authHeaders(server as RunningServer), + } as never); + expect(res.status).toBe(200); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(0); + return managedUserInfoResultSchema.parse(body.data); + } + + it('returns the ok profile payload in the camelCase domain shape', async () => { + const getManagedUserInfo = vi.fn(async () => ({ + kind: 'ok' as const, + userInfo: { + userId: 'u_123', + nickname: 'moonwalker', + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + userLevel: 30, + userLevelName: 'Vivace', + domain: 1, + domainName: 'DOMAIN_EXAMPLE', + globalId: 'u_123', + avatar: 'https://example.com/avatar.png', + username: 'moonwalker2333', + email: 'user@example.com', + phone: { countryCode: '86', number: '176****0000' }, + createdTime: '2026-06-11T13:26:47.561184Z', + lastLoginTime: '2026-07-16T03:12:03.033412Z', + }, + })); + await boot([[IOAuthService, oauthStub(getManagedUserInfo)]] as unknown as ScopeSeed); + + expect(await getUserInfo()).toEqual({ + kind: 'ok', + userInfo: { + userId: 'u_123', + nickname: 'moonwalker', + status: 'USER_STATUS_NORMAL', + region: 'REGION_CN', + userLevel: 30, + userLevelName: 'Vivace', + domain: 1, + domainName: 'DOMAIN_EXAMPLE', + globalId: 'u_123', + avatar: 'https://example.com/avatar.png', + username: 'moonwalker2333', + email: 'user@example.com', + phone: { countryCode: '86', number: '176****0000' }, + createdTime: '2026-06-11T13:26:47.561184Z', + lastLoginTime: '2026-07-16T03:12:03.033412Z', + }, + }); + }); + + it('passes through the error payload and forwards the provider query', async () => { + const getManagedUserInfo = vi.fn(async (_provider?: string) => ({ + kind: 'error' as const, + message: 'Authorization failed.', + status: 401, + })); + await boot([[IOAuthService, oauthStub(getManagedUserInfo)]] as unknown as ScopeSeed); + + expect(await getUserInfo('?provider=managed%3Akimi-code')).toEqual({ + kind: 'error', + message: 'Authorization failed.', + status: 401, + }); + expect(getManagedUserInfo).toHaveBeenCalledWith('managed:kimi-code'); + }); +}); diff --git a/packages/kap-server/test/openapi.test.ts b/packages/kap-server/test/openapi.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad265f8a801f23bdc66b67514dfdc117d8382934 --- /dev/null +++ b/packages/kap-server/test/openapi.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; + +import { sharedAuthHeaders, sharedServer } from './helpers/sharedServer'; + +describe('server-v2 OpenAPI', () => { + async function fetchOpenApi(): Promise> { + const res = await fetch(`${sharedServer().base}/openapi.json`, { + headers: sharedAuthHeaders(), + } as never); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + return (await res.json()) as Record; + } + + it('returns a valid OpenAPI 3 document', async () => { + const doc = await fetchOpenApi(); + + expect(doc['openapi']).toMatch(/^3\.\d+\.\d+$/); + const info = asRecord(doc['info']); + expect(info['title']).toBe('Kimi Code Server API'); + expect(typeof info['version']).toBe('string'); + }); + + it('covers the core /api/v1 routes v2 registers', async () => { + const doc = await fetchOpenApi(); + const paths = asRecord(doc['paths']); + + expect(paths['/api/v1/healthz']).toBeDefined(); + expect(paths['/api/v1/meta']).toBeDefined(); + expect(paths['/api/v1/sessions']).toBeDefined(); + expect(paths['/api/v1/files']).toBeDefined(); + expect(paths['/api/v1/sessions/{session_id}/fs/{*}']).toBeDefined(); + }); + + it('projects the session-action dispatcher into archive and delete only', async () => { + const doc = await fetchOpenApi(); + const paths = asRecord(doc['paths']); + + expect(paths['/api/v1/sessions/{tail}']).toBeUndefined(); + expect(paths['/api/v1/sessions/{session_id}:archive']).toBeDefined(); + expect(paths['/api/v1/sessions/{session_id}:delete']).toBeDefined(); + expect(paths['/api/v1/sessions/{session_id}:fork']).toBeUndefined(); + expect(paths['/api/v1/sessions/{session_id}:undo']).toBeUndefined(); + + const archiveOp = operation(doc, '/api/v1/sessions/{session_id}:archive', 'post'); + expect(archiveOp['operationId']).toBe('runSessionArchiveAction'); + const params = archiveOp['parameters'] as Array>; + expect(params.some((p) => p['in'] === 'path' && p['name'] === 'session_id')).toBe(true); + expect(params.some((p) => p['name'] === 'tail')).toBe(false); + + const deleteOp = operation(doc, '/api/v1/sessions/{session_id}:delete', 'post'); + expect(deleteOp['operationId']).toBe('runSessionDeleteAction'); + const deleteParams = deleteOp['parameters'] as Array>; + expect(deleteParams.some((p) => p['in'] === 'path' && p['name'] === 'session_id')).toBe(true); + expect(deleteParams.some((p) => p['name'] === 'tail')).toBe(false); + }); + + it('describes the file upload as multipart/form-data', async () => { + const doc = await fetchOpenApi(); + const uploadOp = operation(doc, '/api/v1/files', 'post'); + const requestBody = asRecord(uploadOp['requestBody']); + const content = asRecord(requestBody['content']); + expect(content['multipart/form-data']).toBeDefined(); + }); + + it('describes session export as a ZIP or JSON error envelope', async () => { + const doc = await fetchOpenApi(); + const exportOp = operation(doc, '/api/v1/sessions/{session_id}/export', 'post'); + const responses = asRecord(exportOp['responses']); + const response = asRecord(responses['200']); + const content = asRecord(response['content']); + const headers = asRecord(response['headers']); + const zipSchema = asRecord(asRecord(content['application/zip'])['schema']); + const errorSchema = asRecord(asRecord(content['application/json'])['schema']); + const errorProperties = asRecord(errorSchema['properties']); + + expect(zipSchema).toMatchObject({ type: 'string', format: 'binary' }); + expect(errorProperties).toMatchObject({ + code: expect.any(Object), + msg: expect.any(Object), + data: expect.any(Object), + request_id: expect.any(Object), + }); + expect(headers['content-disposition']).toBeDefined(); + expect(headers['content-length']).toBeDefined(); + expect(headers['cache-control']).toBeDefined(); + }); + + it('represents the fs-action dispatcher as a oneOf union', async () => { + const doc = await fetchOpenApi(); + const fsActionOp = operation(doc, '/api/v1/sessions/{session_id}/{tail}', 'post'); + const requestBody = asRecord(fsActionOp['requestBody']); + const content = asRecord(requestBody['content']); + const json = asRecord(content['application/json']); + const schema = asRecord(json['schema']); + expect(Array.isArray(schema['oneOf'])).toBe(true); + }); + + it('documents MCP OAuth failures for auth completion', async () => { + const doc = await fetchOpenApi(); + const authCompleteOp = operation(doc, '/api/v2/mcp/auth:complete', 'post'); + const responses = asRecord(authCompleteOp['responses']); + const response = asRecord(responses['200']); + const content = asRecord(response['content']); + const schema = asRecord(asRecord(content['application/json'])['schema']); + const variants = schema['oneOf']; + + expect(Array.isArray(variants)).toBe(true); + expect( + (variants as unknown[]).some((variant) => { + const properties = asRecord(asRecord(variant)['properties']); + const values = asRecord(properties['code'])['enum']; + return Array.isArray(values) && values.includes(40929); + }), + ).toBe(true); + }); +}); + +function asRecord(value: unknown): Record { + if (typeof value !== 'object' || value === null) { + throw new Error('expected object'); + } + return value as Record; +} + +function operation( + doc: Record, + path: string, + method: string, +): Record { + const paths = asRecord(doc['paths']); + const pathItem = asRecord(paths[path]); + return asRecord(pathItem[method]); +} diff --git a/packages/kap-server/test/origin.test.ts b/packages/kap-server/test/origin.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6a0d49471762e52c1ceeeb73c710f672f5ffca8 --- /dev/null +++ b/packages/kap-server/test/origin.test.ts @@ -0,0 +1,186 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createOriginHook, + isOriginAllowed, + originHost, + parseCorsOrigins, +} from '../src/middleware/origin'; + +describe('originHost', () => { + it('returns the host for a valid origin', () => { + expect(originHost('https://foo.example.com')).toBe('foo.example.com'); + }); + + it('drops the default port', () => { + expect(originHost('http://localhost:80')).toBe('localhost'); + }); + + it('keeps a non-default port', () => { + expect(originHost('http://127.0.0.1:58627')).toBe('127.0.0.1:58627'); + }); + + it('returns undefined for a missing origin', () => { + expect(originHost(undefined)).toBeUndefined(); + }); + + it('returns undefined for a malformed origin', () => { + expect(originHost('not a url')).toBeUndefined(); + }); +}); + +describe('isOriginAllowed', () => { + it('allows same-origin', () => { + expect(isOriginAllowed('http://localhost:80', 'localhost:80', [])).toBe(true); + }); + + it('denies cross-origin that is not whitelisted', () => { + expect(isOriginAllowed('http://evil.example.test', 'localhost:80', [])).toBe(false); + }); + + it('allows cross-origin that is whitelisted', () => { + expect(isOriginAllowed('https://foo.example.com', 'localhost:80', ['https://foo.example.com'])).toBe(true); + }); + + it('allows an absent origin', () => { + expect(isOriginAllowed(undefined, 'localhost:80', [])).toBe(true); + }); + + it('treats a malformed origin as absent (allowed)', () => { + expect(isOriginAllowed('not a url', 'h', [])).toBe(true); + }); + + it('treats localhost origin vs 127.0.0.1 host as same-origin (dev proxy)', () => { + expect(isOriginAllowed('http://localhost:5175', '127.0.0.1:58627', [])).toBe(true); + }); + + it('treats 127.0.0.1 origin vs localhost host as same-origin', () => { + expect(isOriginAllowed('http://127.0.0.1:5175', 'localhost:58627', [])).toBe(true); + }); + + it('treats [::1] origin vs localhost host as same-origin (IPv6 loopback)', () => { + expect(isOriginAllowed('http://[::1]:5175', 'localhost:58627', [])).toBe(true); + }); + + it('still denies a non-loopback cross-origin that is not whitelisted', () => { + expect(isOriginAllowed('http://evil.example.test', 'localhost:80', [])).toBe(false); + }); + + it('does not widen to a public host even when the origin is loopback', () => { + expect(isOriginAllowed('http://localhost:5175', 'example.com:80', [])).toBe(false); + }); +}); + +describe('parseCorsOrigins', () => { + it('splits, trims, and drops empties', () => { + expect( + parseCorsOrigins({ + KIMI_CODE_CORS_ORIGINS: ' https://a.example.com, https://b.example.com, ', + }), + ).toEqual(['https://a.example.com', 'https://b.example.com']); + }); + + it('returns [] when unset', () => { + expect(parseCorsOrigins({})).toEqual([]); + }); +}); + +describe('createOriginHook (onRequest hook)', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = Fastify(); + app.addHook('onRequest', createOriginHook({ allowedOrigins: ['https://foo.example.com'] })); + app.get('/api/v1/probe', async () => ({ ok: true })); + app.options('/api/v1/probe', async () => ({ ok: true })); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('echoes CORS headers for a same-origin request', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { origin: 'http://localhost:80', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe('http://localhost:80'); + }); + + it('echoes the whitelisted cross-origin and short-circuits OPTIONS to 204', async () => { + const res = await app.inject({ + method: 'OPTIONS', + url: '/api/v1/probe', + headers: { origin: 'https://foo.example.com', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(204); + expect(res.headers['access-control-allow-origin']).toBe('https://foo.example.com'); + expect(res.headers['access-control-allow-methods']).toBe( + 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + ); + }); + + it('withholds CORS headers for a non-whitelisted cross-origin', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { origin: 'http://evil.example.test', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('returns 204 without CORS headers for a non-whitelisted OPTIONS', async () => { + const res = await app.inject({ + method: 'OPTIONS', + url: '/api/v1/probe', + headers: { origin: 'http://evil.example.test', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(204); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('emits no CORS headers when Origin is absent', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('reflects Access-Control-Request-Headers in Allow-Headers for an allowed origin', async () => { + const res = await app.inject({ + method: 'OPTIONS', + url: '/api/v1/probe', + headers: { + origin: 'https://foo.example.com', + host: 'localhost:80', + 'access-control-request-method': 'GET', + 'access-control-request-headers': 'x-request-id, x-kimi-client-version, authorization', + }, + }); + expect(res.statusCode).toBe(204); + expect(res.headers['access-control-allow-origin']).toBe('https://foo.example.com'); + expect(res.headers['access-control-allow-headers']).toBe( + 'x-request-id, x-kimi-client-version, authorization', + ); + }); + + it('falls back to CORS_ALLOW_HEADERS for non-preflight responses', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { origin: 'https://foo.example.com', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-headers']).toBe( + 'Content-Type, Authorization, X-Kimi-Client-Id, X-Kimi-Client-Name, X-Kimi-Client-Version, X-Kimi-Client-Ui-Mode', + ); + }); +}); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..af26961e12923686d3dad5f93ae2eea5e5d33df6 --- /dev/null +++ b/packages/kap-server/test/plugins.test.ts @@ -0,0 +1,586 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { WebSocket } from 'ws'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders, bearerToken } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +const CATALOG_URL = 'http://marketplace.test/marketplace.json'; + +const CATALOG = { + version: '1', + plugins: [ + { + id: 'demo-plugin', + tier: 'official', + displayName: 'Demo Plugin', + version: 'v2.0.0', + source: 'https://cdn.example.test/demo.zip', + }, + { + id: 'third-party-plugin', + displayName: 'Third Party', + source: 'https://github.com/example/third', + }, + { + id: 'relative-plugin', + displayName: 'Relative', + source: './plugins/relative.zip', + }, + { + id: 'alias-plugin', + displayName: 'Alias', + source: ' ', + url: './plugins/alias.zip', + }, + { + id: 'blank-tier-plugin', + displayName: 'Blank Tier', + tier: ' ', + source: 'https://example.test/bt.zip', + }, + { + id: 'gh-plugin', + displayName: 'GH Plugin', + version: 2, + source: 'https://github.com/example/gh/releases/tag/v2.0.0', + }, + { + id: 'kimi-webbridge', + displayName: 'Kimi Browser Extension', + source: 'https://cdn.example.test/kimi-webbridge.zip', + }, + { + id: 'kimi-cu', + displayName: 'Kimi Computer Use', + source: 'https://cdn.example.test/kimi-cu.zip', + }, + { + id: ' meta-alias-plugin ', + name: 'Meta Alias', + shortDescription: 'Aliased metadata', + websiteURL: 'https://example.test/meta', + keywords: ['web', 3, ' ', 'tools'], + source: 'https://example.test/meta.zip', + }, + ], +}; + +describe('server-v2 /api/v1 plugins', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + let custom = false; + const createdDirs: string[] = []; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-plugins-')); + await bootDefault(); + }); + + async function bootDefault(): Promise { + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home!, + logLevel: 'silent', + pluginMarketplaceUrl: CATALOG_URL, + }); + base = `http://127.0.0.1:${server.port}`; + custom = false; + } + + beforeEach(async () => { + const realFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL, init?: RequestInit) => { + if (url === CATALOG_URL) { + return new Response(JSON.stringify(CATALOG), { status: 200 }); + } + if (url === 'https://github.com/example/third/releases/latest') { + return new Response(null, { + status: 302, + headers: { location: 'https://github.com/example/third/releases/tag/v3.1.0' }, + }); + } + if (typeof url === 'string' && url.includes('/releases/latest')) { + return new Response(null, { status: 404 }); + } + return realFetch(url as never, init); + }), + ); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + if (custom) { + await server?.close(); + server = undefined; + await bootDefault(); + } + for (const dir of createdDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never); + home = undefined; + } + }); + + async function call( + method: 'GET' | 'POST', + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method, + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined, + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function makePluginDir(id: string, version: string): Promise { + const dir = await mkdtemp(join(tmpdir(), `kimi-test-plugin-${id}-`)); + createdDirs.push(dir); + await writeFile( + join(dir, 'kimi.plugin.json'), + JSON.stringify({ name: id, version, description: 'test plugin' }), + ); + return dir; + } + + it('installs, lists, disables, enables, and removes a plugin', async () => { + const empty = await call<{ plugins: unknown[] }>('GET', '/api/v1/plugins'); + expect(empty.body.data.plugins).toEqual([]); + + const source = await makePluginDir('demo-plugin', '1.0.0'); + const installed = await call<{ id: string; version: string; enabled: boolean }>( + 'POST', + '/api/v1/plugins', + { source }, + ); + expect(installed.body.code).toBe(0); + expect(installed.body.data).toMatchObject({ id: 'demo-plugin', version: '1.0.0', enabled: true }); + + const list = await call<{ plugins: { id: string; enabled: boolean }[] }>( + 'GET', + '/api/v1/plugins', + ); + expect(list.body.data.plugins.map((p) => [p.id, p.enabled])).toEqual([['demo-plugin', true]]); + + const disabled = await call<{ ok: true }>('POST', '/api/v1/plugins/demo-plugin:disable'); + expect(disabled.body.code).toBe(0); + const afterDisable = await call<{ plugins: { enabled: boolean }[] }>('GET', '/api/v1/plugins'); + expect(afterDisable.body.data.plugins[0]?.enabled).toBe(false); + + const enabled = await call<{ ok: true }>('POST', '/api/v1/plugins/demo-plugin:enable'); + expect(enabled.body.code).toBe(0); + + const removed = await call<{ ok: true }>('POST', '/api/v1/plugins/demo-plugin:remove'); + expect(removed.body.code).toBe(0); + const afterRemove = await call<{ plugins: unknown[] }>('GET', '/api/v1/plugins'); + expect(afterRemove.body.data.plugins).toEqual([]); + }); + + it('rejects bare ids, bogus actions, and unknown plugins', async () => { + const bare = await call('POST', '/api/v1/plugins/demo-plugin'); + expect(bare.body.code).toBe(40001); + const bogus = await call('POST', '/api/v1/plugins/demo-plugin:explode'); + expect(bogus.body.code).toBe(40001); + const unknown = await call('POST', '/api/v1/plugins/nope:remove'); + expect(unknown.body.code).toBe(40419); + const badSource = await call('POST', '/api/v1/plugins', { source: '' }); + expect(badSource.body.code).toBe(40001); + }); + + it('fans out event.plugin.changed over WS on install and remove', async () => { + const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/ws`, [ + `kimi-code.bearer.${bearerToken(server!)}`, + ]); + const types: string[] = []; + try { + await new Promise((resolve, reject) => { + ws.once('message', () => { + resolve(); + }); + ws.once('error', reject); + }); + ws.on('message', (data: Buffer) => { + const frame = JSON.parse(data.toString('utf8')) as { type?: string }; + if (frame.type !== undefined) types.push(frame.type); + }); + + const source = await makePluginDir('demo-plugin', '1.0.0'); + await call('POST', '/api/v1/plugins', { source }); + await vi.waitFor(() => { + expect(types).toContain('event.plugin.changed'); + }); + + await call('POST', '/api/v1/plugins/demo-plugin:remove'); + await vi.waitFor(() => { + expect(types.filter((t) => t === 'event.plugin.changed').length).toBeGreaterThanOrEqual(2); + }); + } finally { + ws.close(); + } + }); + + it('maps client-fixable install input errors to 4xx, never 50001', async () => { + const relative = await call('POST', '/api/v1/plugins', { source: 'relative/dir' }); + expect(relative.body.code).toBe(40001); + const missing = await call('POST', '/api/v1/plugins', { + source: join(home!, 'no-such-plugin-dir'), + }); + expect(missing.body.code).toBe(40409); + const noManifest = await mkdtemp(join(tmpdir(), 'kimi-no-manifest-')); + createdDirs.push(noManifest); + const unloadable = await call('POST', '/api/v1/plugins', { source: noManifest }); + expect(unloadable.body.code).toBe(40001); + }); + + it('serves the marketplace catalog merged with live install state', async () => { + const before = await call<{ + entries: { + id: string; + tier: string; + displayName: string; + source: string; + version?: string; + capabilityId?: string; + description?: string; + homepage?: string; + keywords?: string[]; + installed?: { version?: string }; + }[]; + }>('GET', '/api/v1/plugins/marketplace'); + expect(before.body.code).toBe(0); + expect(before.body.data.entries.map((e) => [e.id, e.tier])).toEqual([ + ['demo-plugin', 'official'], + ['third-party-plugin', 'third-party'], + ['relative-plugin', 'third-party'], + ['alias-plugin', 'third-party'], + ['blank-tier-plugin', 'third-party'], + ['gh-plugin', 'third-party'], + ['kimi-webbridge', 'third-party'], + ['kimi-cu', 'third-party'], + ['meta-alias-plugin', 'third-party'], + ]); + expect(before.body.data.entries[0]?.installed).toBeUndefined(); + const relative = before.body.data.entries.find((e) => e.id === 'relative-plugin'); + expect(relative?.source).toBe('http://marketplace.test/plugins/relative.zip'); + const alias = before.body.data.entries.find((e) => e.id === 'alias-plugin'); + expect(alias?.source).toBe('http://marketplace.test/plugins/alias.zip'); + expect(before.body.data.entries.find((e) => e.id === 'gh-plugin')?.version).toBe('2.0.0'); + expect(before.body.data.entries.find((e) => e.id === 'third-party-plugin')?.version).toBe( + '3.1.0', + ); + expect( + before.body.data.entries.find((e) => e.id === 'kimi-webbridge')?.capabilityId, + ).toBeUndefined(); + expect(before.body.data.entries.some((e) => e.source.startsWith('capability:'))).toBe(false); + const meta = before.body.data.entries.find((e) => e.id === 'meta-alias-plugin'); + expect(meta?.displayName).toBe('Meta Alias'); + expect(meta?.description).toBe('Aliased metadata'); + expect(meta?.homepage).toBe('https://example.test/meta'); + expect(meta?.keywords).toEqual(['web', 'tools']); + + const source = await makePluginDir('demo-plugin', '1.0.0'); + await call('POST', '/api/v1/plugins', { source }); + + const after = await call<{ + entries: { + id: string; + installed?: { version?: string; enabled: boolean }; + updateAvailable?: boolean; + }[]; + }>('GET', '/api/v1/plugins/marketplace'); + const demo = after.body.data.entries.find((e) => e.id === 'demo-plugin'); + expect(demo?.installed).toEqual({ version: '1.0.0', enabled: true }); + expect(demo?.updateAvailable).toBe(true); + + const ghSource = await makePluginDir('gh-plugin', '1.5.0'); + await call('POST', '/api/v1/plugins', { source: ghSource }); + const afterGh = await call<{ + entries: { id: string; updateAvailable?: boolean }[]; + }>('GET', '/api/v1/plugins/marketplace'); + expect(afterGh.body.data.entries.find((e) => e.id === 'gh-plugin')?.updateAvailable).toBe(true); + }); + + it('rejects a catalog whose entry has no usable source', async () => { + const realFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL, init?: RequestInit) => { + if (url === CATALOG_URL) { + return new Response( + JSON.stringify({ plugins: [{ id: 'bad', source: ' ' }] }), + { status: 200 }, + ); + } + return realFetch(url as never, init); + }), + ); + const { body } = await call('GET', '/api/v1/plugins/marketplace'); + expect(body.code).toBe(50001); + expect(body.msg).toContain('invalid catalog'); + }); + + it('rejects a catalog with an unsupported entry type', async () => { + const realFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL, init?: RequestInit) => { + if (url === CATALOG_URL) { + return new Response( + JSON.stringify({ + plugins: [{ id: 'bad', type: 'integration', source: 'https://example.test/x.zip' }], + }), + { status: 200 }, + ); + } + return realFetch(url as never, init); + }), + ); + const { body } = await call('GET', '/api/v1/plugins/marketplace'); + expect(body.code).toBe(50001); + expect(body.msg).toContain('invalid catalog'); + }); + + it('treats the dev marketplace server as the default catalog', async () => { + await server?.close(); + vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_URL', CATALOG_URL); + vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER', '1'); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home!, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + custom = true; + + const { body } = await call<{ entries: { id: string; capabilityId?: string }[] }>( + 'GET', + '/api/v1/plugins/marketplace', + ); + expect(body.code).toBe(0); + expect(body.data.entries.find((e) => e.id === 'kimi-webbridge')?.capabilityId).toBe( + 'kimi-webbridge', + ); + + const cuSupported = process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64'); + const after0 = await call<{ + entries: { id: string; capabilityId?: string; installed?: { version?: string } }[]; + }>('GET', '/api/v1/plugins/marketplace'); + if (!cuSupported) { + expect(after0.body.data.entries.find((e) => e.id === 'kimi-cu')).toBeUndefined(); + return; + } + + const winSource = await makePluginDir('kimi-cu-win', '0.5.4'); + await call('POST', '/api/v1/plugins', { source: winSource }); + const after = await call<{ + entries: { id: string; capabilityId?: string; installed?: { version?: string } }[]; + }>('GET', '/api/v1/plugins/marketplace'); + const cu = after.body.data.entries.find((e) => e.id === 'kimi-cu'); + expect(cu?.capabilityId).toBe('kimi-cu'); + expect(cu?.installed?.version).toBe('0.5.4'); + + const staleSource = await makePluginDir('kimi-cu', '0.1.0'); + await call('POST', '/api/v1/plugins', { source: staleSource }); + const both = await call<{ + entries: { id: string; installed?: { version?: string } }[]; + }>('GET', '/api/v1/plugins/marketplace'); + const expected = process.platform === 'win32' && process.arch === 'x64' ? '0.5.4' : '0.1.0'; + expect(both.body.data.entries.find((e) => e.id === 'kimi-cu')?.installed?.version).toBe( + expected, + ); + await call('POST', '/api/v1/plugins/kimi-cu-win:remove'); + await call('POST', '/api/v1/plugins/kimi-cu:remove'); + }); + + it('maps an unreachable marketplace to 50001', async () => { + const realFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL, init?: RequestInit) => { + if (url === CATALOG_URL) { + throw new Error('network down'); + } + return realFetch(url as never, init); + }), + ); + const { body } = await call('GET', '/api/v1/plugins/marketplace'); + expect(body.code).toBe(50001); + expect(body.msg).toContain('unreachable'); + }); + + it('reads a local marketplace catalog from disk (plain path or file://)', async () => { + await server?.close(); + const catalogDir = await mkdtemp(join(tmpdir(), 'kimi-local-catalog-')); + createdDirs.push(catalogDir); + const fileUrlPluginPath = join(catalogDir, 'plugins', 'file.zip'); + await writeFile( + join(catalogDir, 'marketplace.json'), + JSON.stringify({ + plugins: [ + { id: 'local-plugin', source: './zips/local.zip' }, + { id: 'file-url-plugin', source: pathToFileURL(fileUrlPluginPath).href }, + ], + }), + ); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home!, + logLevel: 'silent', + pluginMarketplaceUrl: join(catalogDir, 'marketplace.json'), + }); + base = `http://127.0.0.1:${server.port}`; + custom = true; + + const { body } = await call<{ entries: { id: string; source: string }[] }>( + 'GET', + '/api/v1/plugins/marketplace', + ); + expect(body.code).toBe(0); + expect(body.data.entries).toEqual([ + { + id: 'local-plugin', + tier: 'third-party', + displayName: 'local-plugin', + source: join(catalogDir, 'zips', 'local.zip'), + }, + { + id: 'file-url-plugin', + tier: 'third-party', + displayName: 'file-url-plugin', + source: fileUrlPluginPath, + }, + ]); + }); + + it('falls back to the source-checkout catalog when the remote is unreachable', async () => { + await server?.close(); + const realFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL, init?: RequestInit) => { + if (typeof url === 'string' && url.includes('/releases/latest')) { + return new Response(null, { status: 404 }); + } + if (url === 'https://code.kimi.com/kimi-code/plugins/marketplace.json') { + throw new Error('offline'); + } + return realFetch(url as never, init); + }), + ); + vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_URL', undefined as unknown as string); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home!, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + custom = true; + + const { body } = await call<{ + entries: { + id: string; + source: string; + tier?: string; + displayName?: string; + capabilityId?: string; + }[]; + }>('GET', '/api/v1/plugins/marketplace'); + expect(body.code).toBe(0); + const datasource = body.data.entries.find((e) => e.id === 'kimi-datasource'); + expect(datasource?.source.startsWith('http')).toBe(false); + expect(datasource?.source.endsWith(join('plugins', 'official', 'kimi-datasource'))).toBe(true); + const webbridge = body.data.entries.find((e) => e.id === 'kimi-webbridge'); + expect(webbridge?.capabilityId).toBe('kimi-webbridge'); + const cuSupported = process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64'); + const cu = body.data.entries.find((e) => e.id === 'kimi-cu'); + if (!cuSupported) { + expect(cu).toBeUndefined(); + return; + } + expect(cu?.tier).toBe('official'); + expect(cu?.capabilityId).toBe('kimi-cu'); + expect(cu?.source).toBe('capability:kimi-cu'); + expect(cu?.displayName).toBe('Kimi Computer Use'); + + const cuSource = await makePluginDir('kimi-cu', '0.5.8'); + await call('POST', '/api/v1/plugins', { source: cuSource }); + const after = await call<{ + entries: { id: string; installed?: { version?: string; enabled: boolean } }[]; + }>('GET', '/api/v1/plugins/marketplace'); + expect(after.body.data.entries.find((e) => e.id === 'kimi-cu')?.installed).toEqual({ + version: '0.5.8', + enabled: true, + }); + }); + + it('expands ~ in local catalog paths like the CLI loader', async () => { + await server?.close(); + const fakeHome = await mkdtemp(join(tmpdir(), 'kimi-tilde-home-')); + createdDirs.push(fakeHome); + await writeFile( + join(fakeHome, 'marketplace.json'), + JSON.stringify({ + plugins: [ + { id: 'tilde-plugin', source: 'https://example.test/t.zip' }, + { id: 'tilde-entry-plugin', source: '~/plugins/t.zip' }, + ], + }), + ); + vi.stubEnv('HOME', fakeHome); + vi.stubEnv('USERPROFILE', fakeHome); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home!, + logLevel: 'silent', + pluginMarketplaceUrl: '~/marketplace.json', + }); + base = `http://127.0.0.1:${server.port}`; + custom = true; + + const { body } = await call<{ entries: { id: string; source: string }[] }>( + 'GET', + '/api/v1/plugins/marketplace', + ); + expect(body.code).toBe(0); + expect(body.data.entries.map((e) => e.id)).toEqual(['tilde-plugin', 'tilde-entry-plugin']); + expect(body.data.entries[1]?.source).toBe(join(fakeHome, 'plugins', 't.zip')); + }); +}); diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..acc157cc36bb5a49948e9fc4dae4a6d1a23d33b2 --- /dev/null +++ b/packages/kap-server/test/prompts.test.ts @@ -0,0 +1,1997 @@ +import { chmod, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { deflateSync } from 'node:zlib'; + +import { + agentContextOf, + agentRuntimeBindingKey, + IAgentTitlePromptSource, + IAgentContextMemoryService, + IAgentLifecycleService, + IAgentPermissionModeService, + IAgentProfileService, + IAgentLoopService, + IAgentStateService, + IAgentToolPolicyService, + IBootstrapService, + IConfigService, + IEventBus, + IFileService, + ISessionContext, + ISessionMetadata, + MAX_IMAGE_DECODE_BYTES, + closeSessionById, + getLiveSessionById, +} from '@moonshot-ai/agent-core-v2'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { projectPromptSnapshot, watchPromptSettlements } from '../src/routes/prompts'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: { path: string; message: string }[]; +} + +interface PromptItemWire { + prompt_id: string; + user_message_id: string; + status: 'running' | 'queued'; + content: unknown; + created_at: string; + metadata?: Record; +} + +type PromptContentPart = + | { type: 'text'; text: string } + | { + type: 'image'; + source: { kind: 'base64'; media_type: string; data: string }; + }; + +const PROMPT_TOML = [ + 'default_model = "stub"', + '', + '[providers.stub]', + 'type = "openai"', + 'base_url = "http://127.0.0.1:9999"', + 'api_key = "stub"', + '', + '[models.stub]', + 'provider = "stub"', + 'model = "stub"', + 'max_context_size = 1000', + '', +].join('\n'); + +const PROMPT_TOML_NO_DEFAULT = PROMPT_TOML.replace('default_model = "stub"\n\n', ''); +const PROMPT_TOML_DANGLING_DEFAULT = PROMPT_TOML.replace( + 'default_model = "stub"', + 'default_model = "missing"', +); +const PROMPT_TOML_OTHER_DEFAULT = [ + 'default_model = "other"', + '', + '[providers.stub]', + 'type = "openai"', + 'base_url = "http://127.0.0.1:9999"', + 'api_key = "stub"', + '', + '[models.other]', + 'provider = "stub"', + 'model = "other"', + 'max_context_size = 1000', + '', +].join('\n'); + +const PROMPT_TOML_KIMI_VISION = [ + PROMPT_TOML, + '[providers.vision]', + 'type = "kimi"', + 'base_url = "http://127.0.0.1:9999"', + 'api_key = "sk-test"', + '', + '[models.kimi-vision]', + 'provider = "vision"', + 'model = "kimi-vision"', + 'max_context_size = 1000', + '', +].join('\n'); + +const PROMPT_TOML_KIMI_VISION_DEFAULT = PROMPT_TOML_KIMI_VISION.replace( + 'default_model = "stub"', + 'default_model = "kimi-vision"', +); + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const CRC32_TABLE = makeCrc32Table(); + +function makeCrc32Table(): Uint32Array { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[i] = c >>> 0; + } + return table; +} + +function crc32(bytes: Buffer): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(type: string, data: Buffer): Buffer { + const typeBytes = Buffer.from(type, 'ascii'); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length, 0); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 0); + return Buffer.concat([length, typeBytes, data, crc]); +} + +function solidPng(width: number, height: number): Buffer { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + + const row = Buffer.alloc(1 + width * 4); + for (let x = 0; x < width; x++) { + const offset = 1 + x * 4; + row[offset] = 0x33; + row[offset + 1] = 0x66; + row[offset + 2] = 0xcc; + row[offset + 3] = 0xff; + } + const raw = Buffer.alloc(row.length * height); + for (let y = 0; y < height; y++) { + row.copy(raw, y * row.length); + } + + return Buffer.concat([ + PNG_SIGNATURE, + pngChunk('IHDR', ihdr), + pngChunk('IDAT', deflateSync(raw)), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + +function pngDimensions(bytes: Buffer): { width: number; height: number } { + if (!bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { + throw new Error('expected PNG data'); + } + if (bytes.subarray(12, 16).toString('ascii') !== 'IHDR') { + throw new Error('expected IHDR as first PNG chunk'); + } + return { + width: bytes.readUInt32BE(16), + height: bytes.readUInt32BE(20), + }; +} + +async function readFileEventually(path: string): Promise { + return vi.waitFor(() => readFile(path)); +} + +function sessionMediaDir(server: RunningServer, sessionId: string): string { + const session = getLiveSessionById(server.core.accessor, sessionId); + return join(session!.accessor.get(ISessionContext).sessionDir, 'media'); +} + +async function expectSessionMedia( + server: RunningServer, + sessionId: string, + name: string, + bytes: Buffer, +): Promise { + const path = join(sessionMediaDir(server, sessionId), name); + expect(await readFileEventually(path)).toEqual(bytes); + return path; +} + +let configTomlSeq = 0; + +async function writeConfigToml(dir: string, content: string): Promise { + configTomlSeq += 1; + const tmpPath = join(dir, `config.toml.${process.pid}.${configTomlSeq}.tmp`); + await writeFile(tmpPath, content, 'utf-8'); + await rename(tmpPath, join(dir, 'config.toml')); +} + +describe('server-v2 /api/v1 prompts', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-prompts-')); + await writeConfigToml(home, PROMPT_TOML); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + base = `http://127.0.0.1:${server.port}`; + }); + + beforeEach(async () => { + await writeConfigToml(home as string, PROMPT_TOML); + await (server as RunningServer).core.accessor.get(IConfigService).reload(); + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never); + home = undefined; + } + }); + + async function call( + method: 'GET' | 'POST', + path: string, + arg?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const headers = authHeaders( + server as RunningServer, + arg === undefined ? {} : { 'content-type': 'application/json' }, + ); + const init: { method: string; headers: Record; body?: string } = { + method, + headers, + }; + if (arg !== undefined) { + init.body = JSON.stringify(arg); + } + const res = await fetch(`${base}${path}`, init as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function createSession(cwd: string): Promise { + const res = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ metadata: { cwd } }), + } as never); + const body = (await res.json()) as Envelope<{ id: string }>; + expect(body.code).toBe(0); + return body.data.id; + } + + async function createMainAgent(sessionId: string): Promise { + const session = getLiveSessionById(server!.core.accessor, sessionId); + if (session === undefined) throw new Error(`session ${sessionId} not found`); + await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); + } + + async function setSessionModel(sessionId: string, model: string): Promise { + const session = getLiveSessionById(server!.core.accessor, sessionId); + if (session === undefined) throw new Error(`session ${sessionId} not found`); + const agent = session.accessor.get(IAgentLifecycleService).handleOf('main'); + if (agent === undefined) throw new Error(`main agent of session ${sessionId} not found`); + await agent.accessor.get(IAgentProfileService).setModel(model); + } + + it('submits a prompt and lists it as active', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + }); + expect(submitted.body.code).toBe(0); + expect(submitted.body.data.prompt_id).toMatch(/^msg_/); + expect(submitted.body.data.status).toBe('running'); + expect(submitted.body.data.user_message_id).toBe(submitted.body.data.prompt_id); + + const list = await call<{ active: PromptItemWire | null; queued: PromptItemWire[] }>( + 'GET', + `/api/v1/sessions/${id}/prompts`, + ); + expect(list.body.code).toBe(0); + if (list.body.data.active !== null) { + expect(list.body.data.active.prompt_id).toBe(submitted.body.data.prompt_id); + } + expect(Array.isArray(list.body.data.queued)).toBe(true); + }); + + it('accepts a prompt-carried model when default_model is not configured', async () => { + await writeConfigToml(home as string, PROMPT_TOML_NO_DEFAULT); + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + model: 'stub', + }); + expect(submitted.body.code).toBe(0); + }); + + it('accepts the session-bound model when default_model is not configured', async () => { + await writeConfigToml(home as string, PROMPT_TOML_NO_DEFAULT); + const id = await createSession(home as string); + await createMainAgent(id); + await setSessionModel(id, 'stub'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + }); + expect(submitted.body.code).toBe(0); + }); + + it('accepts the session-bound model when default_model dangles', async () => { + await writeConfigToml(home as string, PROMPT_TOML_DANGLING_DEFAULT); + const id = await createSession(home as string); + await createMainAgent(id); + await setSessionModel(id, 'stub'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + }); + expect(submitted.body.code).toBe(0); + }); + + it('accepts the prompt and fails the turn at runtime when no model resolves', async () => { + await writeConfigToml(home as string, PROMPT_TOML_NO_DEFAULT); + const id = await createSession(home as string); + await createMainAgent(id); + + const session = getLiveSessionById(server!.core.accessor, id); + const main = session!.accessor.get(IAgentLifecycleService).handleOf('main')!; + const ended: { reason?: string; error?: { code?: string; message?: string } }[] = []; + const completed: { reason?: string }[] = []; + const subscription = main.accessor.get(IEventBus).subscribe((event) => { + if (event.type === 'turn.ended') ended.push(event as (typeof ended)[number]); + if (event.type === 'prompt.completed') completed.push(event as (typeof completed)[number]); + }); + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + }); + expect(submitted.body.code).toBe(0); + + await vi.waitFor(() => { + expect(ended).toHaveLength(1); + }); + subscription.dispose(); + expect(ended[0]).toMatchObject({ + reason: 'failed', + error: { code: 'model.not_configured', message: 'Model not set' }, + }); + expect(completed).toContainEqual(expect.objectContaining({ reason: 'failed' })); + }); + + it('rejects a bound profile switch with 40001 even when the session model is stale', async () => { + await mkdir(join(home as string, 'agents'), { recursive: true }); + await writeFile( + join(home as string, 'agents', 'route-reviewer.md'), + [ + '---', + 'name: route-reviewer', + 'description: reviewer defined by a user-level agent file', + '---', + '', + 'You are a route-test reviewer.', + '', + ].join('\n'), + 'utf-8', + ); + const id = await createSession(home as string); + await createMainAgent(id); + await setSessionModel(id, 'stub'); + await writeConfigToml(home as string, PROMPT_TOML_OTHER_DEFAULT); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + profile: 'route-reviewer', + }); + expect(submitted.body.code).toBe(40001); + expect(submitted.body.msg).toContain('already bound'); + }); + + it('accepts the prompt and fails the turn at runtime when the session model is stale', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + await setSessionModel(id, 'stub'); + await writeConfigToml(home as string, PROMPT_TOML_OTHER_DEFAULT.replace('default_model = "other"\n\n', '')); + await server!.core.accessor.get(IConfigService).reload(); + + const session = getLiveSessionById(server!.core.accessor, id); + const main = session!.accessor.get(IAgentLifecycleService).handleOf('main')!; + const ended: { reason?: string; error?: { code?: string; message?: string } }[] = []; + const completed: { reason?: string }[] = []; + const subscription = main.accessor.get(IEventBus).subscribe((event) => { + if (event.type === 'turn.ended') ended.push(event as (typeof ended)[number]); + if (event.type === 'prompt.completed') completed.push(event as (typeof completed)[number]); + }); + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + }); + expect(submitted.body.code).toBe(0); + + await vi.waitFor( + () => { + expect(ended).toHaveLength(1); + }, + { timeout: 15000 }, + ); + subscription.dispose(); + expect(ended[0]?.reason).toBe('failed'); + expect(ended[0]?.error?.message).toContain('stub'); + expect(completed).toContainEqual(expect.objectContaining({ reason: 'failed' })); + }); + + it('submits a bundled skill prompt through the skills field', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'Review this change.' }], + skills: [{ name: 'update-config' }, { name: 'check-kimi-code-docs' }], + }); + expect(submitted.body.code).toBe(0); + expect(submitted.body.data.prompt_id).toMatch(/^msg_/); + expect(['running', 'queued']).toContain(submitted.body.data.status); + expect(submitted.body.data.content).toEqual([{ type: 'text', text: 'Review this change.' }]); + + const session = getLiveSessionById(server!.core.accessor, id); + const agent = session!.accessor.get(IAgentLifecycleService).handleOf('main'); + const history = agent!.accessor.get(IAgentContextMemoryService).get(); + const bundled = history.find((message) => message.origin?.kind === 'user'); + expect(bundled?.origin).toMatchObject({ + kind: 'user', + skillActivations: [{ skillName: 'update-config' }, { skillName: 'check-kimi-code-docs' }], + }); + const texts = bundled?.content + .filter((part) => part.type === 'text') + .map((part) => part.text); + expect(texts?.at(-1)).toBe('Review this change.'); + + const projected = projectPromptSnapshot({ + id: 'msg_1', + userMessageId: 'msg_1', + createdAt: '2026-01-01T00:00:00.000Z', + state: 'running', + message: { + role: 'user', + content: [ + { type: 'text', text: 'rendered skill block' }, + { type: 'text', text: 'Review this change.' }, + ], + toolCalls: [], + origin: { + kind: 'user', + skillActivations: [{ activationId: 'a1', skillName: 'update-config' }], + }, + }, + }); + expect(projected.content).toEqual([{ type: 'text', text: 'Review this change.' }]); + const plain = projectPromptSnapshot({ + id: 'msg_2', + userMessageId: 'msg_2', + createdAt: '2026-01-01T00:00:00.000Z', + state: 'pending', + message: { + role: 'user', + content: [{ type: 'text', text: 'plain question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }); + expect(plain.content).toEqual([{ type: 'text', text: 'plain question' }]); + }); + + it('projects client metadata for an active skill activation prompt', () => { + const metadata = { display_text: 'Save button', kimi_code_composer: { version: 1 } }; + const projected = projectPromptSnapshot({ + id: 'msg_skill', + userMessageId: 'msg_skill', + createdAt: '2026-01-01T00:00:00.000Z', + state: 'running', + message: { + role: 'user', + content: [{ type: 'text', text: 'User activated the skill' }], + toolCalls: [], + origin: { kind: 'skill_activation', activationId: 'act-1', skillName: 'update-config', trigger: 'user-slash', clientMetadata: [metadata] }, + }, + }); + expect(projected.metadata).toEqual(metadata); + }); + + it('backfills active skill activation metadata on the first transcript connection', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const session = getLiveSessionById(server!.core.accessor, id)!; + const agent = session.accessor.get(IAgentLifecycleService).handleOf('main')!; + const metadata = [{ display_text: 'Save button', kimi_code_composer: { version: 1 } }]; + const loop = agent.accessor.get(IAgentLoopService); + const handle = { + id: 'active-skill', + userMessageId: 'active-skill', + createdAt: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'User activated the skill' }], toolCalls: [], origin: { kind: 'skill_activation', activationId: 'act-1', skillName: 'update-config', trigger: 'user-slash', clientMetadata: metadata } }, + }; + const listing = vi.spyOn(loop, 'snapshot').mockReturnValue({ ...loop.snapshot(), activePromptId: 'active-skill', queue: [] }); + const lookup = vi.spyOn(loop, 'promptHandle').mockImplementation((promptId) => (promptId === 'active-skill' ? handle : undefined) as never); + try { + const result = await call<{ prompts: unknown[] }>('GET', `/api/v1/sessions/${id}/transcript?agent_id=main`); + expect(result.body.code).toBe(0); + expect(result.body.data.prompts).toContainEqual(expect.objectContaining({ promptId: 'active-skill', status: 'running', clientMetadata: metadata })); + } finally { + lookup.mockRestore(); + listing.mockRestore(); + } + }); + + it('backfills queued prompt metadata on the first transcript connection', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const session = getLiveSessionById(server!.core.accessor, id)!; + const agent = session.accessor.get(IAgentLifecycleService).handleOf('main')!; + const metadata = [{ display_text: 'Save button', kimi_code_composer: { version: 1 } }]; + const loop = agent.accessor.get(IAgentLoopService); + const origin = { kind: 'user', clientMetadata: metadata }; + const listing = vi.spyOn(loop, 'snapshot').mockReturnValue({ + ...loop.snapshot(), + queue: [{ message: { role: 'user', content: [{ type: 'text', text: 'browser wire' }] }, meta: { promptId: 'queued-example', userMessageId: 'queued-example', tracked: true, createdAt: '2026-01-01T00:00:00.000Z', origin } }], + }); + try { + const result = await call<{ prompts: unknown[] }>('GET', `/api/v1/sessions/${id}/transcript?agent_id=main`); + expect(result.body.code).toBe(0); + expect(result.body.data.prompts).toContainEqual(expect.objectContaining({ promptId: 'queued-example', status: 'queued', clientMetadata: metadata })); + } finally { + listing.mockRestore(); + } + }); + + it('honors a client-chosen prompt_id on submit', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + prompt_id: 'submission-1', + }); + expect(submitted.body.code).toBe(0); + expect(submitted.body.data.prompt_id).toBe('submission-1'); + expect(submitted.body.data.user_message_id).toBe('submission-1'); + }); + + it.each([false, true])('preserves client metadata through submission and cold resume (skills=%s)', async (withSkills) => { + const id = await createSession(home as string); + await createMainAgent(id); + const metadata = { + display_text: 'Example browser element · Example comment', + kimi_code_composer: { + version: 1, + doc: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '[literal](example.md)' }] }] }, + browserReferences: [{ id: 'ref-example', captureId: 'capture-example', comment: 'Example comment' }], + }, + }; + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'Visible prompt' }], + metadata, + skills: withSkills ? [{ name: 'update-config' }] : undefined, + }); + expect(submitted.body.code).toBe(0); + expect(submitted.body.data.metadata).toEqual(metadata); + await closeSessionById(server!.core.accessor, id); + const resumed = await call('GET', `/api/v1/sessions/${id}/prompts`); + expect(resumed.body.code).toBe(0); + const session = getLiveSessionById(server!.core.accessor, id); + const agent = session!.accessor.get(IAgentLifecycleService).handleOf('main'); + const history = agent!.accessor.get(IAgentContextMemoryService).get(); + const saved = history.find((message) => message.origin?.kind === 'user'); + expect(saved?.origin).toMatchObject({ clientMetadata: [metadata] }); + expect(await session!.accessor.get(ISessionMetadata).read()).toMatchObject({ title: metadata.display_text, lastPrompt: metadata.display_text }); + expect(JSON.stringify(saved?.content)).not.toContain('ref-example'); + const transcript = await call<{ items: { kind: string; origin?: { payload?: { clientMetadata?: unknown } } }[] }>('GET', `/api/v1/sessions/${id}/transcript?agent_id=main&page_size=20`); + expect(transcript.body.code).toBe(0); + const turn = transcript.body.data.items.find((item) => item.kind === 'turn'); + expect(turn?.origin?.payload?.clientMetadata).toEqual([metadata]); + }); + + it('updates session metadata for a bundled prompt routed to a non-main agent', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const session = getLiveSessionById(server!.core.accessor, id); + if (session === undefined) throw new Error(`session ${id} not found`); + const lifecycle = session.accessor.get(IAgentLifecycleService); + const mainHandle = lifecycle.handleOf('main'); + if (mainHandle === undefined) throw new Error('main agent not found'); + const child = await lifecycle.fork(agentContextOf(mainHandle)); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'bundled side question' }], + agent_id: child.agentId, + skills: [{ name: 'update-config' }], + }); + expect(submitted.body.code).toBe(0); + + expect((await session.accessor.get(ISessionMetadata).read()).lastPrompt).toBe( + 'bundled side question', + ); + }); + + it('rejects a reused prompt_id live and after cold resume without changing metadata', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const first = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'first prompt' }], + prompt_id: 'submission-1', + }); + expect(first.body.code).toBe(0); + + const duplicate = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'must not become metadata' }], + prompt_id: 'submission-1', + }); + expect(duplicate.body.code).toBe(40927); + + const session = getLiveSessionById(server!.core.accessor, id); + expect((await session!.accessor.get(ISessionMetadata).read()).lastPrompt).toBe('first prompt'); + + await closeSessionById(server!.core.accessor, id); + expect(getLiveSessionById(server!.core.accessor, id)).toBeUndefined(); + + const afterResume = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'must not survive a cold resume' }], + prompt_id: 'submission-1', + }); + expect(afterResume.body.code).toBe(40927); + const resumed = getLiveSessionById(server!.core.accessor, id); + expect((await resumed!.accessor.get(ISessionMetadata).read()).lastPrompt).toBe('first prompt'); + }); + + it('rejects a bundled submission with an unknown skill and records nothing', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'Review this change.' }], + skills: [{ name: 'does-not-exist' }], + }); + expect(submitted.body.code).toBe(40415); + + const session = getLiveSessionById(server!.core.accessor, id); + const agent = session!.accessor.get(IAgentLifecycleService).handleOf('main'); + const history = agent!.accessor.get(IAgentContextMemoryService).get(); + expect(history.filter((message) => message.origin?.kind === 'user')).toHaveLength(0); + }); + + it('rejects an unknown bundled skill before any control override binds', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'Review this change.' }], + permission_mode: 'yolo', + skills: [{ name: 'does-not-exist' }], + }); + expect(submitted.body.code).toBe(40415); + + const session = getLiveSessionById(server!.core.accessor, id); + const agent = session!.accessor.get(IAgentLifecycleService).handleOf('main'); + expect(agent!.accessor.get(IAgentPermissionModeService).mode).toBe('manual'); + const history = agent!.accessor.get(IAgentContextMemoryService).get(); + expect(history.filter((message) => message.origin?.kind === 'user')).toHaveLength(0); + }); + + it('rejects an unknown bundled skill without materializing the main agent', async () => { + const id = await createSession(home as string); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'Review this change.' }], + skills: [{ name: 'does-not-exist' }], + }); + expect(submitted.body.code).toBe(40415); + + const session = getLiveSessionById(server!.core.accessor, id); + expect(session!.accessor.get(IAgentLifecycleService).handleOf('main')).toBeUndefined(); + }); + + it('rejects a bundled prompt_id combination before any override or agent materialization', async () => { + const id = await createSession(home as string); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'Review this change.' }], + permission_mode: 'yolo', + prompt_id: 'submission-1', + skills: [{ name: 'update-config' }], + }); + expect(submitted.body.code).toBe(40001); + + const session = getLiveSessionById(server!.core.accessor, id); + expect(session!.accessor.get(IAgentLifecycleService).handleOf('main')).toBeUndefined(); + }); + + it('cleans bundled staging through the settlement tracker', async () => { + const handlers: Array<(event: { type: string; promptId?: string; promptIds?: string[]; activePromptId?: string }) => void> = []; + const events = { + subscribe( + handler: (event: { type: string; promptId?: string; promptIds?: string[]; activePromptId?: string }) => void, + ) { + handlers.push(handler); + return { dispose: vi.fn() }; + }, + }; + + const discard = vi.fn(); + const tracker = watchPromptSettlements(events as never); + tracker.settle('msg_1', discard); + handlers[0]!({ type: 'prompt.completed', promptId: 'msg_other' }); + handlers[0]!({ type: 'turn.started' }); + expect(discard).not.toHaveBeenCalled(); + handlers[0]!({ type: 'prompt.completed', promptId: 'msg_1' }); + expect(discard).toHaveBeenCalledTimes(1); + + const blockedDiscard = vi.fn(); + const blockedTracker = watchPromptSettlements(events as never); + handlers[1]!({ type: 'prompt.completed', promptId: 'msg_blocked' }); + blockedTracker.settle('msg_blocked', blockedDiscard); + expect(blockedDiscard).toHaveBeenCalledTimes(1); + + const steered = vi.fn(); + const steeredTracker = watchPromptSettlements(events as never); + steeredTracker.settle('msg_3', steered); + handlers[2]!({ type: 'prompt.steered', promptIds: ['msg_3'], activePromptId: 'msg_parent' }); + expect(steered).not.toHaveBeenCalled(); + handlers[2]!({ type: 'prompt.completed', promptId: 'msg_other' }); + expect(steered).not.toHaveBeenCalled(); + handlers[2]!({ type: 'prompt.completed', promptId: 'msg_parent' }); + expect(steered).toHaveBeenCalledTimes(1); + + const aborted = vi.fn(); + const abortedTracker = watchPromptSettlements(events as never); + abortedTracker.settle('msg_4', aborted); + handlers[3]!({ type: 'prompt.aborted', promptId: 'msg_4' }); + expect(aborted).toHaveBeenCalledTimes(1); + + const rejected = vi.fn(); + const rejectedTracker = watchPromptSettlements(events as never); + rejectedTracker.settle('msg_5', rejected); + rejectedTracker.dispose(); + handlers[4]!({ type: 'prompt.completed', promptId: 'msg_5' }); + expect(rejected).not.toHaveBeenCalled(); + }); + + it('makes the first three REST prompts available to title generation', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const prompts = ['先搭一个 Vite 项目', '加上路由', '现在配一下 ESLint']; + for (const text of prompts) { + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text }], + }); + expect(submitted.body.code).toBe(0); + } + + const session = getLiveSessionById(server!.core.accessor, id); + const agent = session === undefined ? undefined : session.accessor.get(IAgentLifecycleService).handleOf('main'); + const source = agent?.accessor.get(IAgentTitlePromptSource); + expect(source).toBeDefined(); + await expect(source!.firstUserPrompts(3)).resolves.toEqual(prompts); + }); + + it('rejects a stale file reference without creating the agent or mutating the model', async () => { + const id = await createSession(home as string); + const session = getLiveSessionById(server!.core.accessor, id); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + model: 'stub', + content: [ + { type: 'text', text: 'look' }, + { type: 'video', source: { kind: 'file', file_id: 'f_does_not_exist' } }, + ], + }); + expect(body.code).toBe(40407); + + expect(session!.accessor.get(IAgentLifecycleService).handleOf('main')).toBeUndefined(); + }); + + it('rejects a mis-kinded file reference without creating the agent', async () => { + const id = await createSession(home as string); + const session = getLiveSessionById(server!.core.accessor, id); + + const form = new FormData(); + form.set('file', new Blob([Buffer.from('%PDF-1.4 fake')], { type: 'application/pdf' }), 'spec.pdf'); + const uploadRes = await fetch(`${base}/api/v1/files`, { + method: 'POST', + headers: authHeaders(server as RunningServer), + body: form, + } as never); + const uploaded = (await uploadRes.json()) as Envelope<{ id: string }>; + expect(uploaded.code).toBe(0); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + model: 'stub', + content: [ + { type: 'text', text: 'watch this' }, + { type: 'video', source: { kind: 'file', file_id: uploaded.data.id } }, + ], + }); + expect(body.code).toBe(40001); + expect(session!.accessor.get(IAgentLifecycleService).handleOf('main')).toBeUndefined(); + }); + + it('carries an uploaded video into the prompt as an internal kimi-file reference', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const videoBytes = Buffer.from('tiny fake mp4 bytes'); + const form = new FormData(); + form.set('file', new Blob([videoBytes], { type: 'video/mp4' }), 'clip.mp4'); + const uploadRes = await fetch(`${base}/api/v1/files`, { + method: 'POST', + headers: authHeaders(server as RunningServer), + body: form, + } as never); + const uploaded = (await uploadRes.json()) as Envelope<{ id: string }>; + expect(uploaded.code).toBe(0); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { type: 'text', text: 'what happens in this video?' }, + { type: 'video', source: { kind: 'file', file_id: uploaded.data.id } }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array>; + expect(content).toHaveLength(2); + expect(content[0]).toEqual({ type: 'text', text: 'what happens in this video?' }); + expect(content[1]).toEqual({ + type: 'video', + source: { kind: 'session_media', file_id: uploaded.data.id }, + name: 'clip.mp4', + }); + + await expectSessionMedia(server!, id, `${uploaded.data.id}.mp4`, videoBytes); + }); + + it('carries a compressed uploaded image into the prompt as an internal kimi-file reference', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const bigPng = solidPng(3600, 1800); + const uploaded = await uploadFile(bigPng, 'image/png', 'big.png'); + expect(uploaded.size).toBe(bigPng.length); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'image', source: { kind: 'file', file_id: uploaded.id } }], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array>; + expect(content).toHaveLength(2); + const caption = content[0] as { type: string; text: string }; + expect(caption.type).toBe('text'); + expect(caption.text).toContain('Image compressed'); + expect(caption.text).toContain('3600x1800'); + const pathMatch = /saved at "([^"]+)"/.exec(caption.text); + expect(pathMatch).not.toBeNull(); + expect(pathMatch![1]!).toContain('/media-originals/'); + expect(await readFile(pathMatch![1]!)).toEqual(bigPng); + + const image = content[1] as { type: string; source: { kind: string; file_id: string } }; + expect(image.type).toBe('image'); + expect(image.source.kind).toBe('session_media'); + const finalFileId = image.source.file_id; + expect(finalFileId).not.toBe(uploaded.id); + + const mediaPath = join(sessionMediaDir(server!, id), `${finalFileId}.png`); + expect(pngDimensions(await readFileEventually(mediaPath))).toEqual({ width: 2000, height: 1000 }); + expect(JSON.stringify(content)).not.toContain(mediaPath); + + const original = await server!.core.accessor.get(IFileService).get(uploaded.id); + expect(original.meta.size).toBe(bigPng.length); + + const files = server!.core.accessor.get(IFileService); + await vi.waitFor(async () => { + const result = await files.get(finalFileId).catch((error: unknown) => error); + expect(result).toMatchObject({ code: 'file.not_found' }); + }); + + expect(JSON.stringify(content)).not.toContain('kimi-file://'); + + const session = getLiveSessionById(server!.core.accessor, id); + const main = session!.accessor.get(IAgentLifecycleService).handleOf('main')!; + const memory = main.accessor.get(IAgentContextMemoryService).get(); + const promptMessage = memory.find((m) => m.origin?.kind === 'user'); + const captionPart = promptMessage?.content[0]; + expect(captionPart?.type).toBe('text'); + expect((captionPart as { type: 'text'; text: string }).text).toContain('Image compressed'); + }); + + it('rolls back a compressed upload when a later prompt part fails to resolve', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const first = await uploadFile(solidPng(3600, 1800), 'image/png', 'big.png'); + const second = await uploadFile(solidPng(10, 10), 'image/png', 'small.png'); + const files = server!.core.accessor.get(IFileService); + const originalGet = files.get.bind(files); + const originalSave = files.save.bind(files); + let secondGets = 0; + let compressedFileId: string | undefined; + const getSpy = vi.spyOn(files, 'get').mockImplementation(async (fileId) => { + if (fileId === second.id && ++secondGets === 2) { + throw new Error('injected second-part failure'); + } + return originalGet(fileId); + }); + const saveSpy = vi.spyOn(files, 'save').mockImplementation(async (...args) => { + const saved = await originalSave(...args); + compressedFileId = saved.id; + return saved; + }); + + try { + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { type: 'image', source: { kind: 'file', file_id: first.id } }, + { type: 'image', source: { kind: 'file', file_id: second.id } }, + ], + }); + + expect(submitted.body.code).not.toBe(0); + expect(compressedFileId).toBeDefined(); + if (compressedFileId === undefined) throw new Error('expected a compressed upload'); + await expect(originalGet(compressedFileId)).rejects.toMatchObject({ + code: 'file.not_found', + }); + } finally { + getSpy.mockRestore(); + saveSpy.mockRestore(); + } + }); + + it('carries an uncompressed uploaded image into the prompt as an internal kimi-file reference', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const smallPng = solidPng(10, 10); + const uploaded = await uploadFile(smallPng, 'image/png', 'small.png'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'image', source: { kind: 'file', file_id: uploaded.id } }], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array>; + expect(content).toEqual([ + { type: 'image', source: { kind: 'session_media', file_id: uploaded.id }, name: 'small.png' }, + ]); + + const mediaPath = await expectSessionMedia(server!, id, `${uploaded.id}.png`, smallPng); + expect(JSON.stringify(content)).not.toContain(mediaPath); + + expect(JSON.stringify(content)).not.toContain('kimi-file://'); + }); + + it('accepts a stored session-media reference after the transient upload is deleted', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const smallPng = solidPng(10, 10); + const uploaded = await uploadFile(smallPng, 'image/png', 'small.png'); + + const first = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'image', source: { kind: 'file', file_id: uploaded.id } }], + }); + expect(first.body.code).toBe(0); + await expectSessionMedia(server!, id, `${uploaded.id}.png`, smallPng); + await server!.core.accessor.get(IFileService).delete(uploaded.id); + + const replayed = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { type: 'text', text: 'replay the stored image' }, + { type: 'image', source: { kind: 'session_media', file_id: uploaded.id } }, + ], + }); + + expect(replayed.body.code).toBe(0); + expect(replayed.body.data.content).toEqual([ + { type: 'text', text: 'replay the stored image' }, + { type: 'image', source: { kind: 'session_media', file_id: uploaded.id }, name: 'small.png' }, + ]); + + const session = getLiveSessionById(server!.core.accessor, id); + const main = session!.accessor.get(IAgentLifecycleService).handleOf('main')!; + await vi.waitFor(() => { + const replayedMessage = main.accessor + .get(IAgentContextMemoryService) + .get() + .find( + (message) => + message.role === 'user' && + message.content.some( + (part) => part.type === 'text' && part.text === 'replay the stored image', + ), + ); + expect(replayedMessage).toBeDefined(); + expect(replayedMessage!.content).toContainEqual({ + type: 'image_url', + imageUrl: { + url: `kimi-file://${uploaded.id}`, + id: uploaded.id, + name: 'small.png', + }, + }); + }); + }); + + it('keeps the upload-backed reference when the session media dir is not writable', async () => { + if (process.getuid?.() === 0) return; + const id = await createSession(home as string); + await createMainAgent(id); + const smallPng = solidPng(10, 10); + const uploaded = await uploadFile(smallPng, 'image/png', 'small.png'); + + const mediaDir = sessionMediaDir(server!, id); + await mkdir(mediaDir, { recursive: true }); + await chmod(mediaDir, 0o555); + try { + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'image', source: { kind: 'file', file_id: uploaded.id } }], + }); + expect(submitted.body.code).toBe(0); + + const session = getLiveSessionById(server!.core.accessor, id); + const main = session!.accessor.get(IAgentLifecycleService).handleOf('main')!; + await vi.waitFor(() => { + const message = main.accessor + .get(IAgentContextMemoryService) + .get() + .find((m) => m.role === 'user' && m.content.some((part) => part.type === 'image_url')); + expect(message).toBeDefined(); + expect(message!.content).toContainEqual({ + type: 'image_url', + imageUrl: { url: `kimi-file://${uploaded.id}`, name: 'small.png' }, + }); + }); + + const cacheDir = server!.core.accessor.get(IBootstrapService).cacheDir; + await expect(readFile(join(cacheDir, `${uploaded.id}.png`))).rejects.toThrow(); + } finally { + await chmod(mediaDir, 0o755); + } + }); + + it('compresses inline base64 image prompts into session media-originals', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const bigPng = solidPng(3600, 1800); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { + type: 'image', + source: { + kind: 'base64', + media_type: 'image/png', + data: bigPng.toString('base64'), + }, + }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as PromptContentPart[]; + expect(content).toHaveLength(2); + const caption = content[0]; + if (caption?.type !== 'text') throw new Error('expected compression caption'); + const pathMatch = /saved at "([^"]+)"/.exec(caption.text); + expect(pathMatch).not.toBeNull(); + expect(pathMatch![1]!).toContain('/media-originals/'); + expect((await realpath(pathMatch![1]!)).startsWith(await realpath(home as string))).toBe(true); + expect(await readFile(pathMatch![1]!)).toEqual(bigPng); + + const image = content[1]; + if (image?.type !== 'image' || image.source.kind !== 'base64') { + throw new Error('expected resolved base64 image'); + } + expect(pngDimensions(Buffer.from(image.source.data, 'base64'))).toEqual({ + width: 2000, + height: 1000, + }); + }); + + function avifBytes(): Buffer { + const buf = Buffer.alloc(24); + buf.writeUInt32BE(24, 0); + buf.write('ftyp', 4, 'latin1'); + buf.write('avif', 8, 'latin1'); + buf.write('avif', 16, 'latin1'); + return buf; + } + + it('replaces an inline base64 image in an unsupported format with a text notice', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { + type: 'image', + source: { + kind: 'base64', + media_type: 'image/png', + data: avifBytes().toString('base64'), + }, + }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as PromptContentPart[]; + expect(content).toHaveLength(1); + const notice = content[0]; + if (notice?.type !== 'text') throw new Error('expected a text notice'); + expect(notice.text).toContain('image/avif'); + }); + + function heicBytes(): Buffer { + const buf = Buffer.alloc(24); + buf.writeUInt32BE(24, 0); + buf.write('ftyp', 4, 'latin1'); + buf.write('heic', 8, 'latin1'); + buf.write('heic', 16, 'latin1'); + return buf; + } + + it('keeps an inline image whose format the session model provider accepts', async () => { + await writeConfigToml(home as string, PROMPT_TOML_KIMI_VISION); + await (server as RunningServer).core.accessor.get(IConfigService).reload(); + const id = await createSession(home as string); + await createMainAgent(id); + await setSessionModel(id, 'kimi-vision'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { + type: 'image', + source: { + kind: 'base64', + media_type: 'image/heic', + data: heicBytes().toString('base64'), + }, + }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as PromptContentPart[]; + expect(content).toHaveLength(1); + expect(content[0]?.type).toBe('image'); + }); + + it('gates media against the model selected by the same prompt request', async () => { + await writeConfigToml(home as string, PROMPT_TOML_KIMI_VISION); + await (server as RunningServer).core.accessor.get(IConfigService).reload(); + const id = await createSession(home as string); + await createMainAgent(id); + await setSessionModel(id, 'stub'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + model: 'kimi-vision', + content: [ + { + type: 'image', + source: { + kind: 'base64', + media_type: 'image/heic', + data: heicBytes().toString('base64'), + }, + }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as PromptContentPart[]; + expect(content).toHaveLength(1); + expect(content[0]?.type).toBe('image'); + }); + + it('gates a first-prompt image against the configured default model before any model binds', async () => { + await writeConfigToml(home as string, PROMPT_TOML_KIMI_VISION_DEFAULT); + await (server as RunningServer).core.accessor.get(IConfigService).reload(); + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { + type: 'image', + source: { + kind: 'base64', + media_type: 'image/heic', + data: heicBytes().toString('base64'), + }, + }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as PromptContentPart[]; + expect(content).toHaveLength(1); + expect(content[0]?.type).toBe('image'); + }); + + it('gates media against the default model a same-request profile selection binds', async () => { + await writeConfigToml(home as string, PROMPT_TOML_KIMI_VISION_DEFAULT); + await (server as RunningServer).core.accessor.get(IConfigService).reload(); + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + profile: 'agent', + content: [ + { + type: 'image', + source: { + kind: 'base64', + media_type: 'image/heic', + data: heicBytes().toString('base64'), + }, + }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as PromptContentPart[]; + expect(content).toHaveLength(1); + expect(content[0]?.type).toBe('image'); + }); + + it('replaces an uploaded image file in an unsupported format with a text notice', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const form = new FormData(); + form.set('file', new Blob([avifBytes()], { type: 'image/avif' }), 'photo.avif'); + const uploadRes = await fetch(`${base}/api/v1/files`, { + method: 'POST', + headers: authHeaders(server as RunningServer), + body: form, + } as never); + const uploaded = (await uploadRes.json()) as Envelope<{ id: string }>; + expect(uploaded.code).toBe(0); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'image', source: { kind: 'file', file_id: uploaded.data.id }, name: 'renamed.avif' }], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as PromptContentPart[]; + expect(content).toHaveLength(1); + const notice = content[0]; + if (notice?.type !== 'text') throw new Error('expected a text notice'); + expect(notice.text).toContain('image/avif'); + expect(notice.text).toContain('renamed.avif'); + expect(notice.text).not.toContain('photo.avif'); + }); + + it('replaces a remote image URL with an unsupported extension with a text notice', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'image', source: { kind: 'url', url: 'https://example.com/pic.avif' } }], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as PromptContentPart[]; + expect(content).toHaveLength(1); + const notice = content[0]; + if (notice?.type !== 'text') throw new Error('expected a text notice'); + expect(notice.text).toContain('image/avif'); + expect(notice.text).toContain('https://example.com/pic.avif'); + }); + + async function uploadFile( + bytes: Buffer, + mediaType: string, + name: string, + ): Promise<{ id: string; size: number }> { + const form = new FormData(); + form.set('file', new Blob([bytes], { type: mediaType }), name); + const uploadRes = await fetch(`${base}/api/v1/files`, { + method: 'POST', + headers: authHeaders(server as RunningServer), + body: form, + } as never); + const uploaded = (await uploadRes.json()) as Envelope<{ id: string; size: number }>; + expect(uploaded.code).toBe(0); + return uploaded.data; + } + + function attachedPathFrom(notice: string): string { + const match = /bytes\): (.+) — open it with the Read tool$/.exec(notice); + expect(match).not.toBeNull(); + return match![1]!; + } + + it('materializes an arbitrary file attachment into the session attachments dir', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const pdfBytes = Buffer.from('%PDF-1.4 fake pdf bytes'); + const uploaded = await uploadFile(pdfBytes, 'application/pdf', 'report.pdf'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { type: 'text', text: 'summarize this' }, + { type: 'file', file_id: uploaded.id, name: 'report.pdf', media_type: 'application/pdf', size: pdfBytes.length }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array<{ type: string; text?: string }>; + expect(content).toHaveLength(2); + expect(content[0]).toEqual({ type: 'text', text: 'summarize this' }); + const notice = content[1]; + expect(notice?.type).toBe('text'); + expect(notice?.text).toContain('Attached file "report.pdf"'); + expect(notice?.text).toContain('application/pdf'); + expect(notice?.text).toContain(`${pdfBytes.length} bytes`); + const attachedPath = attachedPathFrom(notice?.text ?? ''); + expect(attachedPath).toContain('/attachments/'); + expect(attachedPath.endsWith(`${uploaded.id}-report.pdf`)).toBe(true); + expect((await realpath(attachedPath)).startsWith(await realpath(home as string))).toBe(true); + expect(await readFile(attachedPath)).toEqual(pdfBytes); + }); + + it('materializes an uploaded SVG image as a path-referenced attachment', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const svgBytes = Buffer.from(''); + const uploaded = await uploadFile(svgBytes, 'image/svg+xml', 'vector.svg'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'image', source: { kind: 'file', file_id: uploaded.id } }], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array<{ type: string; text?: string }>; + expect(content).toHaveLength(1); + const notice = content[0]; + expect(notice?.type).toBe('text'); + expect(notice?.text).not.toContain('[Image omitted'); + expect(notice?.text).toContain('"vector.svg"'); + expect(notice?.text).toContain('image/svg+xml'); + const attachedPath = attachedPathFrom(notice?.text ?? ''); + expect(attachedPath).toContain('/attachments/'); + expect(attachedPath.endsWith(`${uploaded.id}-vector.svg`)).toBe(true); + expect(await readFile(attachedPath)).toEqual(svgBytes); + }); + + it('persists an inline base64 image in an unsupported format as a path-referenced attachment', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const data = avifBytes(); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { + type: 'image', + name: 'scan.avif', + source: { + kind: 'base64', + media_type: 'image/avif', + data: data.toString('base64'), + }, + }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array<{ type: string; text?: string }>; + expect(content).toHaveLength(1); + const notice = content[0]; + expect(notice?.type).toBe('text'); + expect(notice?.text).not.toContain('[Image omitted'); + expect(notice?.text).toContain('"scan.avif"'); + expect(notice?.text).toContain('image/avif'); + const attachedPath = attachedPathFrom(notice?.text ?? ''); + expect(attachedPath).toContain('/attachments/'); + expect(attachedPath.endsWith('-scan.avif')).toBe(true); + expect(await readFile(attachedPath)).toEqual(data); + }); + + it('sanitizes an attachment file name before materializing it', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const scriptBytes = Buffer.from('#!/bin/sh\necho hi'); + const uploaded = await uploadFile(scriptBytes, 'text/plain', '../../etc/evil.sh'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { type: 'file', file_id: uploaded.id, name: '../../etc/evil.sh', media_type: 'text/plain', size: scriptBytes.length }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array<{ type: string; text?: string }>; + expect(content).toHaveLength(1); + const attachedPath = attachedPathFrom(content[0]?.text ?? ''); + expect(dirname(attachedPath).endsWith('/attachments')).toBe(true); + expect((await realpath(attachedPath)).startsWith(await realpath(home as string))).toBe(true); + expect(await readFile(attachedPath)).toEqual(scriptBytes); + }); + + it('attaches a server-local file by path without copying it', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const outside = await mkdtemp(join(tmpdir(), 'kimi-attach-path-')); + try { + const sourcePath = join(outside, 'notes.txt'); + const bytes = Buffer.from('path attachment body'); + await writeFile(sourcePath, bytes); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { type: 'text', text: 'read this' }, + { type: 'file', path: sourcePath }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array<{ type: string; text?: string }>; + expect(content).toHaveLength(2); + expect(content[0]).toEqual({ type: 'text', text: 'read this' }); + expect(content[1]).toEqual({ + type: 'text', + text: `Attached file "notes.txt" (application/octet-stream, ${bytes.length} bytes): ${sourcePath} — open it with the Read tool`, + }); + + const session = getLiveSessionById(server!.core.accessor, id); + const attachmentsDir = join(session!.accessor.get(ISessionContext).sessionDir, 'attachments'); + await expect(readdir(attachmentsDir)).rejects.toMatchObject({ code: 'ENOENT' }); + + const main = session!.accessor.get(IAgentLifecycleService).handleOf('main')!; + await vi.waitFor(() => { + const memory = main.accessor.get(IAgentContextMemoryService).get(); + const promptMessage = memory.find((entry) => entry.origin?.kind === 'user'); + expect(promptMessage?.origin).toEqual({ + kind: 'user', + attachments: [ + { + name: 'notes.txt', + mediaType: 'application/octet-stream', + size: bytes.length, + path: sourcePath, + }, + ], + }); + }); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + it('rejects a relative attachment path', async () => { + const id = await createSession(home as string); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'file', path: 'relative/notes.txt' }], + }); + expect(body.code).toBe(40001); + }); + + it('rejects a sensitive attachment path', async () => { + const id = await createSession(home as string); + const secretPath = join(home as string, '.env'); + await writeFile(secretPath, 'TOKEN=secret'); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'file', path: secretPath }], + }); + expect(body.code).toBe(40001); + }); + + it('rejects a missing attachment path with 40407', async () => { + const id = await createSession(home as string); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'file', path: join(home as string, 'nope.txt') }], + }); + expect(body.code).toBe(40407); + }); + + it('rejects a symlink pointing at a sensitive file', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const secretPath = join(home as string, '.env'); + await writeFile(secretPath, 'TOKEN=secret'); + const linkPath = join(home as string, 'innocent.txt'); + await symlink(secretPath, linkPath); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'file', path: linkPath }], + }); + expect(body.code).toBe(40001); + }); + + it('rejects path attachments on a non-local runtime before touching the filesystem', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const session = getLiveSessionById(server!.core.accessor, id); + const main = session!.accessor.get(IAgentLifecycleService).handleOf('main')!; + main.accessor.get(IAgentStateService).set(agentRuntimeBindingKey, { + workspaceId: session!.accessor.get(ISessionContext).workspaceId, + runtimeId: 'fake-remote', + }); + + const sourcePath = join(home as string, 'note.txt'); + await writeFile(sourcePath, 'x'); + const existing = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'file', path: sourcePath }], + }); + expect(existing.body.code).toBe(40001); + expect(existing.body.msg).toContain('local runtime'); + + const missing = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'file', path: join(home as string, 'nope.txt') }], + }); + expect(missing.body.code).toBe(40001); + + const uploadBytes = Buffer.from('upload unaffected'); + const uploaded = await uploadFile(uploadBytes, 'text/plain', 'up.txt'); + const upload = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { + type: 'file', + file_id: uploaded.id, + name: 'up.txt', + media_type: 'text/plain', + size: uploadBytes.length, + }, + ], + }); + expect(upload.body.code).toBe(0); + }); + + it('rejects an upload file part missing metadata', async () => { + const id = await createSession(home as string); + const uploaded = await uploadFile(Buffer.from('x'), 'text/plain', 'x.txt'); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'file', file_id: uploaded.id }], + }); + expect(body.code).toBe(40001); + }); + + it('rejects a file part carrying both file_id and path', async () => { + const id = await createSession(home as string); + const sourcePath = join(home as string, 'both.txt'); + await writeFile(sourcePath, 'x'); + const uploaded = await uploadFile(Buffer.from('x'), 'text/plain', 'x.txt'); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { + type: 'file', + file_id: uploaded.id, + path: sourcePath, + name: 'both.txt', + media_type: 'text/plain', + size: 1, + }, + ], + }); + expect(body.code).toBe(40001); + }); + + it('carries a server-local image by path as an internal kimi-file reference', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const outside = await mkdtemp(join(tmpdir(), 'kimi-attach-img-')); + try { + const smallPng = solidPng(10, 10); + const sourcePath = join(outside, 'small.png'); + await writeFile(sourcePath, smallPng); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'image', source: { kind: 'path', path: sourcePath } }], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array>; + expect(content).toHaveLength(1); + const image = content[0] as { type: string; source: { kind: string; file_id: string } }; + expect(image.type).toBe('image'); + expect(image.source.kind).toBe('session_media'); + await expectSessionMedia(server!, id, `${image.source.file_id}.png`, smallPng); + expect(JSON.stringify(content)).not.toContain('kimi-file://'); + expect(JSON.stringify(content)).not.toContain(sourcePath); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + it('compresses a server-local image by path and captions the original path', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const outside = await mkdtemp(join(tmpdir(), 'kimi-attach-big-')); + try { + const bigPng = solidPng(3600, 1800); + const sourcePath = join(outside, 'big.png'); + await writeFile(sourcePath, bigPng); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'image', source: { kind: 'path', path: sourcePath } }], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array>; + expect(content).toHaveLength(2); + const caption = content[0] as { type: string; text: string }; + expect(caption.type).toBe('text'); + expect(caption.text).toContain('Image compressed'); + expect(caption.text).toContain(`saved at "${sourcePath}"`); + expect(await readFile(sourcePath)).toEqual(bigPng); + + const image = content[1] as { type: string; source: { kind: string; file_id: string } }; + expect(image.type).toBe('image'); + expect(image.source.kind).toBe('session_media'); + const mediaPath = join(sessionMediaDir(server!, id), `${image.source.file_id}.png`); + expect(pngDimensions(await readFileEventually(mediaPath))).toEqual({ width: 2000, height: 1000 }); + + const session = getLiveSessionById(server!.core.accessor, id); + const originalsDir = join(session!.accessor.get(ISessionContext).sessionDir, 'media-originals'); + await expect(readdir(originalsDir)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + it('carries a server-local video by path as an internal kimi-file reference', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const outside = await mkdtemp(join(tmpdir(), 'kimi-attach-vid-')); + try { + const videoBytes = Buffer.from('tiny fake mp4 bytes'); + const sourcePath = join(outside, 'clip.mp4'); + await writeFile(sourcePath, videoBytes); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'video', source: { kind: 'path', path: sourcePath } }], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array>; + expect(content).toHaveLength(1); + const video = content[0] as { type: string; source: { kind: string; file_id: string } }; + expect(video.type).toBe('video'); + expect(video.source.kind).toBe('session_media'); + await expectSessionMedia(server!, id, `${video.source.file_id}.mp4`, videoBytes); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + it('rejects a mis-kinded server-local media path', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const sourcePath = join(home as string, 'notes.txt'); + await writeFile(sourcePath, 'plain text'); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'video', source: { kind: 'path', path: sourcePath } }], + }); + expect(body.code).toBe(40001); + }); + + it('rejects an over-limit server-local image with 40001', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const sourcePath = join(home as string, 'huge.png'); + const handle = await open(sourcePath, 'w'); + await handle.truncate(MAX_IMAGE_DECODE_BYTES + 1); + await handle.close(); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'image', source: { kind: 'path', path: sourcePath } }], + }); + expect(body.code).toBe(40001); + }); + + it('returns 40402 when aborting a prompt that already settled', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + }); + const promptId = submitted.body.data.prompt_id; + + const aborted = await call<{ aborted: boolean }>( + 'POST', + `/api/v1/sessions/${id}/prompts/${promptId}:abort`, + ); + expect(aborted.body.code).toBe(40402); + }); + + it('returns 40402 when aborting an unknown prompt', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const { body } = await call( + 'POST', + `/api/v1/sessions/${id}/prompts/prompt_does_not_exist:abort`, + ); + expect(body.code).toBe(40402); + }); + + it('returns 40401 for an unknown session', async () => { + const { body } = await call('POST', '/api/v1/sessions/nope/prompts', { + content: [{ type: 'text', text: 'hello' }], + }); + expect(body.code).toBe(40401); + }); + + it('lists prompts for a persisted session with no live handle (cold resume)', async () => { + const id = await createSession(home as string); + await closeSessionById(server!.core.accessor, id); + expect(getLiveSessionById(server!.core.accessor, id)).toBeUndefined(); + + const list = await call<{ active: PromptItemWire | null; queued: PromptItemWire[] }>( + 'GET', + `/api/v1/sessions/${id}/prompts`, + ); + expect(list.body.code).toBe(0); + expect(list.body.data.active).toBeNull(); + expect(list.body.data.queued).toEqual([]); + }); + + it('routes a submitted prompt to the agent named by agent_id (BTW side channel)', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const session = getLiveSessionById(server!.core.accessor, id); + if (session === undefined) throw new Error(`session ${id} not found`); + const lifecycle = session.accessor.get(IAgentLifecycleService); + const mainHandle = lifecycle.handleOf('main'); + if (mainHandle === undefined) throw new Error('main agent not found'); + const childContext = await lifecycle.fork(agentContextOf(mainHandle)); + const child = lifecycle.handleOf(childContext.agentId)!; + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'side question' }], + agent_id: childContext.agentId, + }); + expect(submitted.body.code).toBe(0); + + const contextHasUserText = ( + handle: { accessor: { get: typeof child.accessor.get } }, + text: string, + ): boolean => + handle.accessor + .get(IAgentContextMemoryService) + .get() + .some( + (m) => + m.role === 'user' && + m.content.some((p) => p.type === 'text' && p.text === text), + ); + + expect(contextHasUserText(child, 'side question')).toBe(true); + + const main = lifecycle.handleOf('main'); + expect(main).toBeDefined(); + expect(contextHasUserText(main!, 'side question')).toBe(false); + }); + + it('returns 40401 when agent_id names an unknown agent', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + agent_id: 'agent_does_not_exist', + }); + expect(body.code).toBe(40401); + }); + + it('rejects an unknown agent profile with 40001', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + profile: 'agent_does_not_exist', + model: 'stub', + }); + expect(body.code).toBe(40001); + expect(body.msg).toContain('agent_does_not_exist'); + }); + + it('binds a discovered custom agent profile on the first prompt', async () => { + const work = await mkdtemp(join(tmpdir(), 'kimi-server-v2-prompts-profile-')); + try { + await mkdir(join(home as string, 'agents'), { recursive: true }); + await writeFile( + join(home as string, 'agents', 'route-reviewer.md'), + [ + '---', + 'name: route-reviewer', + 'description: reviewer defined by a user-level agent file', + '---', + '', + 'You are a route-test reviewer.', + '', + ].join('\n'), + 'utf-8', + ); + const id = await createSession(work); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + profile: 'route-reviewer', + }); + expect(submitted.body.code).toBe(0); + + const session = getLiveSessionById(server!.core.accessor, id); + if (session === undefined) throw new Error(`session ${id} not found`); + const main = session.accessor.get(IAgentLifecycleService).handleOf('main'); + expect(main?.accessor.get(IAgentProfileService).data().profileName).toBe('route-reviewer'); + + const again = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'again' }], + profile: 'route-reviewer', + }); + expect(again.body.code).toBe(0); + } finally { + await rm(work, { recursive: true, force: true }); + } + }); + + it('rejects switching to a different profile once bound', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const first = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + model: 'stub', + }); + expect(first.body.code).toBe(0); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'again' }], + profile: 'some-other-agent', + model: 'stub', + }); + expect(body.code).toBe(40001); + expect(body.msg).toContain('already bound'); + }); + + it('applies a requested thinking effort together with the profile bind', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + profile: 'agent', + model: 'stub', + thinking: 'high', + }); + expect(submitted.body.code).toBe(0); + + const session = getLiveSessionById(server!.core.accessor, id); + if (session === undefined) throw new Error(`session ${id} not found`); + const main = session.accessor.get(IAgentLifecycleService).handleOf('main'); + const profile = main?.accessor.get(IAgentProfileService); + expect(profile?.data().profileName).toBe('agent'); + expect(profile?.data().thinkingLevel).toBe('high'); + }); + + it('applies disabled_tools on the first prompt and replaces them on later prompts', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + model: 'stub', + disabled_tools: ['Bash'], + }); + expect(submitted.body.code).toBe(0); + + const session = getLiveSessionById(server!.core.accessor, id); + if (session === undefined) throw new Error(`session ${id} not found`); + const toolPolicy = session.accessor.get(IAgentLifecycleService).handleOf('main')?.accessor + .get(IAgentToolPolicyService); + expect(toolPolicy?.isToolActive('Bash')).toBe(false); + expect(toolPolicy?.isToolActive('Read')).toBe(true); + + const replaced = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'again' }], + disabled_tools: ['Write'], + }); + expect(replaced.body.code).toBe(0); + expect(toolPolicy?.isToolActive('Bash')).toBe(true); + expect(toolPolicy?.isToolActive('Write')).toBe(false); + + const cleared = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'once more' }], + disabled_tools: [], + }); + expect(cleared.body.code).toBe(0); + expect(toolPolicy?.isToolActive('Write')).toBe(true); + }); + + it('shares disabled_tools with agents created after the request', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + model: 'stub', + disabled_tools: ['Bash'], + }); + expect(submitted.body.code).toBe(0); + + const session = getLiveSessionById(server!.core.accessor, id); + if (session === undefined) throw new Error(`session ${id} not found`); + await session.accessor.get(IAgentLifecycleService).create({ + binding: { + profile: 'coder', + model: 'stub', + }, + }); + const child = session.accessor.get(IAgentLifecycleService).list().at(-1); + const childToolPolicy = session.accessor + .get(IAgentLifecycleService) + .handleOf(child!.agentId)! + .accessor.get(IAgentToolPolicyService); + expect(childToolPolicy.isToolActive('Bash')).toBe(false); + expect(childToolPolicy.isToolActive('Read')).toBe(true); + }); + + it('rejects disabled_tools before the agent profile is bound', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const { body } = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + disabled_tools: ['Bash'], + }); + expect(body.code).toBe(40001); + }); + + it('persists disabled_tools across a cold resume', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + model: 'stub', + disabled_tools: ['Bash'], + }); + expect(submitted.body.code).toBe(0); + + await closeSessionById(server!.core.accessor, id); + expect(getLiveSessionById(server!.core.accessor, id)).toBeUndefined(); + + const again = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'again' }], + }); + expect(again.body.code).toBe(0); + + const session = getLiveSessionById(server!.core.accessor, id); + if (session === undefined) throw new Error(`session ${id} not found`); + const toolPolicy = session.accessor.get(IAgentLifecycleService).handleOf('main')?.accessor + .get(IAgentToolPolicyService); + expect(toolPolicy?.isToolActive('Bash')).toBe(false); + expect(toolPolicy?.isToolActive('Read')).toBe(true); + }); +}); diff --git a/packages/kap-server/test/protocolMessages.test.ts b/packages/kap-server/test/protocolMessages.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5906bc4c73a430220dc3c456abf4122b1ccf259f --- /dev/null +++ b/packages/kap-server/test/protocolMessages.test.ts @@ -0,0 +1,596 @@ +import { describe, expect, it } from 'vitest'; + +import { + ContractViolation, + ackMessageSchema, + agentStateMessageSchema, + assistantDeltaMessageSchema, + assistantMessageSchema, + capabilityMessageSchema, + clientMessageSchema, + configMessageSchema, + configWarningMessageSchema, + entityId, + entityKey, + errorMessageSchema, + helloMessageSchema, + historyQuerySchema, + historyResponseSchema, + interactionMessageSchema, + modelCatalogMessageSchema, + parseServerMessage, + pluginMessageSchema, + serverMessageSchema, + sessionMessageSchema, + sessionStateMessageSchema, + stepMessageSchema, + subscribeMessageSchema, + systemMessageSchema, + taskMessageSchema, + thinkingDeltaMessageSchema, + thinkingMessageSchema, + todoMessageSchema, + toolCallDeltaMessageSchema, + toolCallMessageSchema, + toolProgressMessageSchema, + turnMessageSchema, + unsubscribeMessageSchema, + userMessageSchema, + workspaceMessageSchema, + type CapabilityChangedMessage, + type ConfigWarningMessage, + type DeltaMessage, + type ModelCatalogChangedMessage, + type PluginChangedMessage, +} from '../src/protocol/messages'; + +const TS = '2026-09-04T08:00:00.000Z'; +const TS_MS = 1_756_963_200_000; + +const timeline = { + session_id: 'sess_1', + agent_id: 'agent_1', + timestamp: TS_MS, +}; + +const sessionScope = { + session_id: 'sess_1', + timestamp: TS_MS, +}; + +const globalScope = { + timestamp: TS_MS, +}; + + +const sessionInfo = { + id: 'sess_1', + workspace_id: 'wd_example_0123456789ab', + title: 'demo', + created_at: TS, + updated_at: TS, + busy: false, + metadata: { cwd: '/repo' }, + agent_config: { model: 'kimi-k2' }, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + context_tokens: 0, + }, + permission_rules: [], + message_count: 0, + last_seq: 0, +}; + +const turn = { + type: 'turn', + ...timeline, + turn_id: 't1', + ordinal: 0, + status: 'running', + origin: { kind: 'user' }, +}; + +const step = { + type: 'step', + ...timeline, + step_id: 't1.0', + turn_id: 't1', + ordinal: 0, + status: 'running', +}; + +const user = { + type: 'user', + session_id: 'sess_1', + agent_id: 'agent_1', + message_id: 't1.u0', + turn_id: 't1', + status: 'read', + timestamp: TS_MS, + text: [{ type: 'text', text: 'hello', meta: {} }], +}; + +const assistant = { + type: 'assistant', + ...timeline, + message_id: 't1.0.a0', + turn_id: 't1', + step_id: 't1.0', + status: 'streaming', + text: 'partial', +}; + +const assistantDelta = { + type: 'assistant.delta', + ...timeline, + message_id: 't1.0.a0', + text: ' chunk', +}; + +const thinking = { + type: 'thinking', + ...timeline, + message_id: 't1.0.a1', + turn_id: 't1', + step_id: 't1.0', + status: 'completed', + text: 'reasoning', +}; + +const thinkingDelta = { + type: 'thinking.delta', + ...timeline, + message_id: 't1.0.a1', + text: ' bit', +}; + +const toolCall = { + type: 'tool_call', + ...timeline, + tool_call_id: 'tc1', + turn_id: 't1', + step_id: 't1.0', + name: 'Bash', + status: 'running', +}; + +const toolCallDelta = { + type: 'tool_call.delta', + ...timeline, + tool_call_id: 'tc1', + input_text: '{"command":"ls', +}; + +const toolProgress = { + type: 'tool.progress', + ...timeline, + tool_call_id: 'tc1', + progress: { kind: 'stdout', text: 'line' }, +}; + +const systemUndo = { + type: 'system', + ...timeline, + system_id: 'sys1', + subtype: 'undo', + payload: { removed_ids: ['t1'] }, +}; + +const systemNotice = { + type: 'system', + ...timeline, + system_id: 'sys2', + subtype: 'notice', + payload: { text: 'heads up' }, +}; + +const interactionApproval = { + type: 'interaction', + ...timeline, + interaction_id: 'ia1', + kind: 'approval', + status: 'pending', + tool_call_id: 'tc1', + request: { tool_name: 'Bash', action: 'run command', tool_input_display: { command: 'ls' } }, +}; + +const interactionQuestion = { + type: 'interaction', + ...timeline, + interaction_id: 'ia2', + kind: 'question', + status: 'pending', + request: { + questions: [ + { + id: 'q_0', + question: 'pick one', + options: [ + { id: 'opt_0_0', label: 'a' }, + { id: 'opt_0_1', label: 'b' }, + { id: 'opt_0_2', label: 'c' }, + { id: 'opt_0_3', label: 'd' }, + { id: 'opt_0_4', label: 'e' }, + ], + }, + ], + }, +}; + +const task = { + type: 'task', + ...timeline, + task_id: 'task1', + kind: 'shell', + status: 'running', + detached: true, + output_tail: 'tail', +}; + +const todo = { + type: 'todo', + ...timeline, + todo_id: 'todo1', + items: [{ title: 'write tests', status: 'in_progress' }], +}; + +const agentState = { + type: 'agent.state', + session_id: 'sess_1', + agent_id: 'agent-1', + profile: { kind: 'coder' }, + timestamp: TS_MS, + origin: { kind: 'tool-agent', tool_call_id: 'call_1', parent_agent_id: 'main' }, + created_at: TS, + status: 'running', + turn: { status: 'acting' }, +}; + +const sessionState = { + type: 'session.state', + ...sessionScope, + status: 'running', + pending_interaction: 'approval', +}; + +const session = { + type: 'session', + ...globalScope, + subtype: 'created', + session: sessionInfo, +}; + +const workspace = { + type: 'workspace', + ...globalScope, + subtype: 'updated', + workspace: { + id: 'wd_example_0123456789ab', + root: '/repo', + name: 'repo', + created_at: TS, + last_opened_at: TS, + session_count: 1, + }, +}; + +const config = { + type: 'config', + ...globalScope, + config: { model: 'kimi-k2' }, +}; + +const configWarning = { + type: 'config.warning', + ...globalScope, + warnings: ['deprecated key'], +}; + +const modelCatalog = { + type: 'model_catalog', + ...globalScope, +}; + +const plugin = { + type: 'plugin', + ...globalScope, +}; + +const capability = { + type: 'capability', + ...globalScope, + capability_id: 'cap1', +}; + +const hello = { + type: 'hello', + protocol_version: '3', + server_id: 'srv1', + capabilities: ['step_replay_v1'], +}; + +const ack = { + type: 'ack', + id: 1, + code: 0, +}; + +const error = { + type: 'error', + code: 40001, + msg: 'validation failed', +}; + +const subscribe = { + type: 'subscribe', + id: 1, + session_id: 'sess_1', +}; + +const unsubscribe = { + type: 'unsubscribe', + id: 2, + session_id: 'sess_1', +}; + +const serverCases = [ + ['turn', turnMessageSchema, turn], + ['step', stepMessageSchema, step], + ['user', userMessageSchema, user], + ['assistant', assistantMessageSchema, assistant], + ['assistant.delta', assistantDeltaMessageSchema, assistantDelta], + ['thinking', thinkingMessageSchema, thinking], + ['thinking.delta', thinkingDeltaMessageSchema, thinkingDelta], + ['tool_call', toolCallMessageSchema, toolCall], + ['tool_call.delta', toolCallDeltaMessageSchema, toolCallDelta], + ['tool.progress', toolProgressMessageSchema, toolProgress], + ['system(undo)', systemMessageSchema, systemUndo], + ['system(notice)', systemMessageSchema, systemNotice], + ['interaction(approval)', interactionMessageSchema, interactionApproval], + ['interaction(question)', interactionMessageSchema, interactionQuestion], + ['task', taskMessageSchema, task], + ['todo', todoMessageSchema, todo], + ['agent.state', agentStateMessageSchema, agentState], + ['session.state', sessionStateMessageSchema, sessionState], + ['session', sessionMessageSchema, session], + ['workspace', workspaceMessageSchema, workspace], + ['config', configMessageSchema, config], + ['config.warning', configWarningMessageSchema, configWarning], + ['model_catalog', modelCatalogMessageSchema, modelCatalog], + ['plugin', pluginMessageSchema, plugin], + ['capability', capabilityMessageSchema, capability], + ['hello', helloMessageSchema, hello], + ['ack', ackMessageSchema, ack], + ['error', errorMessageSchema, error], +] as const; + +const serverNegativeCases = [ + ['turn missing origin', turnMessageSchema, { ...turn, origin: undefined }], + ['step bad status', stepMessageSchema, { ...step, status: 'cancelled' }], + ['user bad status', userMessageSchema, { ...user, status: 'running' }], + ['assistant missing text', assistantMessageSchema, { ...assistant, text: undefined }], + ['assistant.delta missing text', assistantDeltaMessageSchema, { ...assistantDelta, text: undefined }], + ['thinking bad status', thinkingMessageSchema, { ...thinking, status: 'done' }], + ['thinking.delta missing message_id', thinkingDeltaMessageSchema, { ...thinkingDelta, message_id: undefined }], + ['tool_call missing name', toolCallMessageSchema, { ...toolCall, name: undefined }], + ['tool_call.delta missing input_text', toolCallDeltaMessageSchema, { ...toolCallDelta, input_text: undefined }], + ['tool.progress bad kind', toolProgressMessageSchema, { ...toolProgress, progress: { kind: 'unknown' } }], + ['system undo missing payload', systemMessageSchema, { ...systemUndo, payload: undefined }], + ['interaction wrong kind', interactionMessageSchema, { ...interactionApproval, kind: 'command' }], + ['task missing output_tail', taskMessageSchema, { ...task, output_tail: undefined }], + ['todo bad item status', todoMessageSchema, { ...todo, items: [{ title: 'x', status: 'doing' }] }], + ['agent.state bad status', agentStateMessageSchema, { ...agentState, status: 'paused' }], + ['session.state bad status', sessionStateMessageSchema, { ...sessionState, status: 'busy' }], + ['session bad subtype', sessionMessageSchema, { ...session, subtype: 'renamed' }], + ['workspace bad id', workspaceMessageSchema, { ...workspace, workspace: { ...workspace.workspace, id: 'ws_1' } }], + ['config missing config', configMessageSchema, { ...config, config: undefined }], + ['config.warning missing warnings', configWarningMessageSchema, { ...configWarning, warnings: undefined }], + ['model_catalog missing timestamp', modelCatalogMessageSchema, { ...modelCatalog, timestamp: undefined }], + ['plugin missing timestamp', pluginMessageSchema, { ...plugin, timestamp: undefined }], + ['capability bad capability_id', capabilityMessageSchema, { ...capability, capability_id: 7 }], + ['hello missing capabilities', helloMessageSchema, { ...hello, capabilities: undefined }], + ['ack bad code', ackMessageSchema, { ...ack, code: '0' }], + ['error missing msg', errorMessageSchema, { ...error, msg: undefined }], +] as const; + +describe('serverMessageSchema', () => { + it.each(serverCases)('accepts %s', (_label, schema, message) => { + expect(schema.safeParse(message).success).toBe(true); + const parsed = serverMessageSchema.safeParse(message); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.type).toBe(message.type); + } + }); + + it.each(serverNegativeCases)('rejects %s', (_label, schema, message) => { + expect(schema.safeParse(message).success).toBe(false); + expect(serverMessageSchema.safeParse(message).success).toBe(false); + }); + + it('rejects unknown message types', () => { + expect(serverMessageSchema.safeParse({ type: 'future.message', ...globalScope }).success).toBe(false); + }); + + it('rejects messages without a type discriminator', () => { + expect(serverMessageSchema.safeParse({ ...globalScope }).success).toBe(false); + }); + + it('tolerates unknown fields for open evolution', () => { + const evolved = { + ...turn, + future_field: 1, + origin: { kind: 'user', future_flag: true }, + usage: { input_tokens: 3, future_counter: 9 }, + }; + const parsed = serverMessageSchema.safeParse(evolved); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect('future_field' in parsed.data).toBe(false); + expect('future_counter' in (parsed.data as { usage?: object }).usage!).toBe(false); + } + }); +}); + +describe('clientMessageSchema', () => { + it('accepts subscribe and unsubscribe', () => { + expect(clientMessageSchema.safeParse(subscribe).success).toBe(true); + expect(clientMessageSchema.safeParse(unsubscribe).success).toBe(true); + expect(subscribeMessageSchema.safeParse({ ...subscribe, agent_ids: ['a1'], omit: ['assistant.delta'] }).success).toBe(true); + }); + + it('rejects entity and server-only control messages', () => { + expect(clientMessageSchema.safeParse(turn).success).toBe(false); + expect(clientMessageSchema.safeParse(hello).success).toBe(false); + expect(clientMessageSchema.safeParse(ack).success).toBe(false); + }); + + it('rejects malformed subscriptions', () => { + expect(subscribeMessageSchema.safeParse({ ...subscribe, id: '1' }).success).toBe(false); + expect(subscribeMessageSchema.safeParse({ ...subscribe, session_id: undefined }).success).toBe(false); + expect(unsubscribeMessageSchema.safeParse({ ...unsubscribe, session_id: '' }).success).toBe(false); + }); +}); + +describe('systemMessageSchema payload discrimination', () => { + it('requires removed_ids for undo and clear', () => { + expect(systemMessageSchema.safeParse(systemUndo).success).toBe(true); + expect(systemMessageSchema.safeParse({ ...systemUndo, subtype: 'clear' }).success).toBe(true); + expect(systemMessageSchema.safeParse({ ...systemUndo, payload: {} }).success).toBe(false); + expect(systemMessageSchema.safeParse({ ...systemUndo, payload: { removed_ids: 't1' } }).success).toBe(false); + }); + + it('allows arbitrary payloads for open subtypes', () => { + expect(systemMessageSchema.safeParse(systemNotice).success).toBe(true); + expect(systemMessageSchema.safeParse({ ...systemNotice, payload: undefined }).success).toBe(true); + expect(systemMessageSchema.safeParse({ ...systemNotice, subtype: 'interruption', payload: { reason: 'aborted' } }).success).toBe(true); + }); + + it('rejects unknown subtypes', () => { + expect(systemMessageSchema.safeParse({ ...systemNotice, subtype: 'explosion' }).success).toBe(false); + }); +}); + +describe('interactionMessageSchema kind discrimination', () => { + it('binds request and response shapes to kind', () => { + expect(interactionMessageSchema.safeParse(interactionApproval).success).toBe(true); + expect(interactionMessageSchema.safeParse(interactionQuestion).success).toBe(true); + expect( + interactionMessageSchema.safeParse({ ...interactionApproval, request: interactionQuestion.request }).success, + ).toBe(false); + expect( + interactionMessageSchema.safeParse({ ...interactionQuestion, request: interactionApproval.request }).success, + ).toBe(false); + expect( + interactionMessageSchema.safeParse({ + ...interactionQuestion, + status: 'answered', + response: { answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } } }, + }).success, + ).toBe(true); + expect( + interactionMessageSchema.safeParse({ + ...interactionApproval, + status: 'approved', + response: { answers: {} }, + }).success, + ).toBe(false); + }); +}); + +describe('historyResponseSchema', () => { + it('accepts a timeline entity page with in_flight marker', () => { + const page = { + messages: [turn, step, user, assistant, thinking, toolCall, systemUndo, interactionApproval, task, todo], + has_more: false, + in_flight: { turn_id: 't2', step_id: 't2.0' }, + }; + expect(historyResponseSchema.safeParse(page).success).toBe(true); + expect(historyResponseSchema.safeParse({ messages: [], has_more: true }).success).toBe(true); + expect(historyResponseSchema.safeParse({ messages: [] }).success).toBe(false); + }); + + it('rejects volatile and non-persisted entities in history', () => { + expect(historyResponseSchema.safeParse({ messages: [assistantDelta] }).success).toBe(false); + expect(historyResponseSchema.safeParse({ messages: [toolProgress] }).success).toBe(false); + }); + + it('validates cursor query params', () => { + expect(historyQuerySchema.safeParse({ before_turn: 't4', page_size: 50 }).success).toBe(true); + expect(historyQuerySchema.safeParse({ after_step: 't4.2' }).success).toBe(true); + expect(historyQuerySchema.safeParse({}).success).toBe(true); + expect(historyQuerySchema.safeParse({ page_size: 0 }).success).toBe(false); + expect(historyQuerySchema.safeParse({ page_size: 1.5 }).success).toBe(false); + }); +}); + +describe('timestamp contract', () => { + it('requires epoch-millisecond timestamps on every message base', () => { + expect(turnMessageSchema.safeParse({ ...turn, timestamp: TS }).success).toBe(false); + expect(turnMessageSchema.safeParse({ ...turn, timestamp: TS_MS }).success).toBe(true); + expect(assistantDeltaMessageSchema.safeParse({ ...assistantDelta, timestamp: TS }).success).toBe(false); + expect(configMessageSchema.safeParse({ ...config, timestamp: TS }).success).toBe(false); + expect(configMessageSchema.safeParse({ ...config, timestamp: TS_MS }).success).toBe(true); + }); + + it('normalizes offset datetimes to UTC on field-level time fields', () => { + const parsed = turnMessageSchema.safeParse({ ...turn, started_at: '2026-09-04T16:00:00+08:00' }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.started_at).toBe(TS); + } + }); +}); + +describe('parseServerMessage + entityKey', () => { + it('parses valid messages, throws detailed violations and derives replace-by-id keys', () => { + expect(parseServerMessage(turn)).toMatchObject({ type: 'turn', turn_id: 't1' }); + expect(() => parseServerMessage({ type: 'turn' })).toThrow(ContractViolation); + try { + parseServerMessage({ type: 'turn' }); + expect.unreachable(); + } catch (error) { + const violation = error as ContractViolation; + expect(violation.issues.length).toBeGreaterThan(0); + expect(violation.raw).toEqual({ type: 'turn' }); + expect(violation.message).toContain('contract violation'); + } + expect(entityId(assistant as never)).toBe('t1.0.a0'); + expect(entityId(toolCall as never)).toBe('tc1'); + expect(entityId(interactionApproval as never)).toBe('tc1'); + expect(entityId(interactionQuestion as never)).toBe('ia2'); + expect(entityId(task as never)).toBe('task1'); + expect(entityId(todo as never)).toBe('todo1'); + expect(entityId(systemUndo as never)).toBe('sys1'); + expect(entityId(step as never)).toBe('t1.0'); + expect(entityId(turn as never)).toBe('t1'); + expect(entityKey(turn as never)).toBe('agent_1:turn:t1'); + expect(entityKey(sessionState as never)).toBe(':session.state:'); + expect(entityKey(config as never)).toBe(':config:'); + }); + + it('exposes the delta union and client-facing type aliases', () => { + const deltas: DeltaMessage[] = [assistantDelta, thinkingDelta, toolCallDelta, toolProgress].map( + (m) => parseServerMessage(m) as DeltaMessage, + ); + expect(deltas.map((m) => m.type)).toEqual([ + 'assistant.delta', + 'thinking.delta', + 'tool_call.delta', + 'tool.progress', + ]); + const warning: ConfigWarningMessage = configWarningMessageSchema.parse(configWarning); + const catalog: ModelCatalogChangedMessage = modelCatalogMessageSchema.parse(modelCatalog); + const plug: PluginChangedMessage = pluginMessageSchema.parse(plugin); + const cap: CapabilityChangedMessage = capabilityMessageSchema.parse(capability); + expect([warning.type, catalog.type, plug.type, cap.type]).toEqual([ + 'config.warning', + 'model_catalog', + 'plugin', + 'capability', + ]); + }); +}); diff --git a/packages/kap-server/test/publicApi.test.ts b/packages/kap-server/test/publicApi.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f921da7465f4636047316632ab3a3efedac81947 --- /dev/null +++ b/packages/kap-server/test/publicApi.test.ts @@ -0,0 +1,30 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { classify, rotateServerToken, serverTokenPath } from '../src/index'; + +let tmpDir: string | undefined; + +afterEach(() => { + if (tmpDir !== undefined) { + rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } +}); + +describe('server-v2 public API', () => { + it('exports classify', () => { + expect(classify('127.0.0.1')).toBe('loopback'); + }); + + it('exports rotateServerToken and serverTokenPath', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-v2-public-api-')); + const token = await rotateServerToken(tmpDir); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(0); + expect(serverTokenPath(tmpDir)).toBe(join(tmpDir, 'server.token')); + }); +}); diff --git a/packages/kap-server/test/questions.test.ts b/packages/kap-server/test/questions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2095842f7cead7de0698fa871718e60ec9cb0e95 --- /dev/null +++ b/packages/kap-server/test/questions.test.ts @@ -0,0 +1,439 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { randomUUID } from 'node:crypto'; + +import { + ensureMainAgent, + getLiveSessionById, + interactions, + type InteractionTags, + type QuestionRequest, + type QuestionResult, +} from '@moonshot-ai/agent-core-v2'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: { path: string; message: string }[]; +} + +interface QuestionOptionWire { + id: string; + label: string; + description?: string; +} + +interface QuestionItemWire { + id: string; + question: string; + header?: string; + body?: string; + options: QuestionOptionWire[]; + multi_select?: boolean; + allow_other?: boolean; + other_label?: string; + other_description?: string; +} + +interface QuestionWire { + question_id: string; + session_id: string; + turn_id?: number; + tool_call_id?: string; + questions: QuestionItemWire[]; + created_at: string; +} + +interface ListWire { + items: QuestionWire[]; +} + +interface ResolveWire { + resolved: true; + resolved_at: string; +} + +interface DismissWire { + dismissed: true; + dismissed_at: string; +} + +describe('server-v2 /api/v1/sessions/{sid}/questions', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-questions-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + home = undefined; + } + }); + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const hasBody = body !== undefined; + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: authHeaders( + server as RunningServer, + hasBody ? { 'content-type': 'application/json' } : {}, + ), + body: hasBody ? JSON.stringify(body) : undefined, + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function createSession(): Promise { + const { body } = await postJson<{ id: string }>('/api/v1/sessions', { + metadata: { cwd: home as string }, + }); + expect(body.code).toBe(0); + const handle = getLiveSessionById(server!.core.accessor, body.data.id); + expect(handle).toBeDefined(); + await ensureMainAgent(handle!); + return body.data.id; + } + + function questionTags(sessionId: string, req: QuestionRequest): InteractionTags { + const tags: InteractionTags = { agentId: 'main', sessionId }; + if (req.turnId !== undefined) tags['turnId'] = req.turnId; + if (req.toolCallId !== undefined) tags['toolCallId'] = req.toolCallId; + return tags; + } + + function enqueueQuestion(sessionId: string, req: QuestionRequest): void { + interactions.enqueue({ + id: req.id ?? `question_${randomUUID()}`, + kind: 'question', + payload: req, + tags: questionTags(sessionId, req), + }); + } + + function requestQuestion(sessionId: string, req: QuestionRequest): Promise { + const parked = interactions.enqueue({ + id: req.id ?? `question_${randomUUID()}`, + kind: 'question', + payload: req, + tags: questionTags(sessionId, req), + }); + return interactions.wait(parked.id); + } + + function makeRequest(id: string): QuestionRequest { + return { + id, + toolCallId: `tc-${id}`, + questions: [ + { + question: 'Pick one', + options: [{ label: 'Yes' }, { label: 'No', description: 'decline' }], + }, + ], + }; + } + + it('lists a pending question projected onto the wire shape', async () => { + const sid = await createSession(); + enqueueQuestion(sid, makeRequest('q-1')); + + const { body } = await getJson(`/api/v1/sessions/${sid}/questions?status=pending`); + expect(body.code).toBe(0); + expect(body.data.items).toHaveLength(1); + const item = body.data.items[0]!; + expect(item.question_id).toBe('q-1'); + expect(item.session_id).toBe(sid); + expect(item.tool_call_id).toBe('tc-q-1'); + expect(item.questions).toEqual([ + { + id: 'q_0', + question: 'Pick one', + options: [ + { id: 'opt_0_0', label: 'Yes' }, + { id: 'opt_0_1', label: 'No', description: 'decline' }, + ], + allow_other: true, + }, + ]); + expect(Number.isNaN(Date.parse(item.created_at))).toBe(false); + expect(item).not.toHaveProperty('expires_at'); + }); + + it('resolves a pending question', async () => { + const sid = await createSession(); + enqueueQuestion(sid, makeRequest('q-2')); + + const { body } = await postJson(`/api/v1/sessions/${sid}/questions/q-2`, { + answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } }, + method: 'number_key', + }); + expect(body.code).toBe(0); + expect(body.data.resolved).toBe(true); + expect(Number.isNaN(Date.parse(body.data.resolved_at))).toBe(false); + + const listed = await getJson(`/api/v1/sessions/${sid}/questions?status=pending`); + expect(listed.body.data.items).toHaveLength(0); + }); + + it('flattens the protocol response into the in-process result', async () => { + const sid = await createSession(); + const resultPromise: Promise = requestQuestion(sid, makeRequest('q-3')); + + await postJson(`/api/v1/sessions/${sid}/questions/q-3`, { + answers: { + q_0: { kind: 'multi', option_ids: ['opt_0_0', 'opt_0_1'] }, + }, + method: 'click', + }); + + await expect(resultPromise).resolves.toEqual({ + answers: { 'Pick one': 'Yes, No' }, + }); + }); + + function makeTwoQuestionRequest(id: string): QuestionRequest { + return { + id, + toolCallId: `tc-${id}`, + questions: [ + { + question: 'Which animal?', + options: [{ label: 'Cat' }, { label: 'Dog' }], + }, + { + question: 'Which colors?', + options: [{ label: 'Red' }, { label: 'Green' }, { label: 'Blue' }], + multiSelect: true, + }, + ], + }; + } + + it('translates ids to text across single / other / multi_with_other kinds', async () => { + const sid = await createSession(); + const single: Promise = requestQuestion(sid, + makeTwoQuestionRequest('q-t1'), + ); + await postJson(`/api/v1/sessions/${sid}/questions/q-t1`, { + answers: { + q_0: { kind: 'single', option_id: 'opt_0_1' }, + q_1: { + kind: 'multi_with_other', + option_ids: ['opt_1_0', 'opt_1_1'], + other_text: 'Custom', + }, + }, + }); + await expect(single).resolves.toEqual({ + answers: { 'Which animal?': 'Dog', 'Which colors?': 'Red, Green, Custom' }, + }); + + const other: Promise = requestQuestion(sid, + makeTwoQuestionRequest('q-t2'), + ); + await postJson(`/api/v1/sessions/${sid}/questions/q-t2`, { + answers: { + q_0: { kind: 'other', text: 'Hippopotamus' }, + q_1: { kind: 'skipped' }, + }, + }); + await expect(other).resolves.toEqual({ + answers: { 'Which animal?': 'Hippopotamus' }, + }); + }); + + it('keeps unknown and cross-question option ids verbatim (stale client)', async () => { + const sid = await createSession(); + const resultPromise: Promise = requestQuestion(sid, + makeTwoQuestionRequest('q-t3'), + ); + + await postJson(`/api/v1/sessions/${sid}/questions/q-t3`, { + answers: { + q_0: { kind: 'single', option_id: 'opt_0_9' }, + q_9: { kind: 'single', option_id: 'opt_9_0' }, + q_1: { kind: 'multi', option_ids: ['opt_1_0', 'opt_0_0'] }, + }, + }); + + await expect(resultPromise).resolves.toEqual({ + answers: { + 'Which animal?': 'opt_0_9', + q_9: 'opt_9_0', + 'Which colors?': 'Red, opt_0_0', + }, + }); + }); + + it('produces an empty answers record when all questions are skipped (not a dismissal)', async () => { + const sid = await createSession(); + const resultPromise: Promise = requestQuestion(sid, + makeTwoQuestionRequest('q-t4'), + ); + + await postJson(`/api/v1/sessions/${sid}/questions/q-t4`, { + answers: { + q_0: { kind: 'skipped' }, + q_1: { kind: 'skipped' }, + }, + }); + + await expect(resultPromise).resolves.toEqual({ answers: {} }); + }); + + it('dismisses a pending question', async () => { + const sid = await createSession(); + const resultPromise: Promise = requestQuestion(sid, makeRequest('q-4')); + + const { body } = await postJson( + `/api/v1/sessions/${sid}/questions/q-4:dismiss`, + ); + expect(body.code).toBe(40909); + expect(body.data.dismissed).toBe(true); + expect(Number.isNaN(Date.parse(body.data.dismissed_at))).toBe(false); + + await expect(resultPromise).resolves.toBeNull(); + const listed = await getJson(`/api/v1/sessions/${sid}/questions?status=pending`); + expect(listed.body.data.items).toHaveLength(0); + }); + + it('returns 40902 on a duplicate resolve (recently-resolved window)', async () => { + const sid = await createSession(); + enqueueQuestion(sid, makeRequest('q-5')); + await postJson(`/api/v1/sessions/${sid}/questions/q-5`, { + answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } }, + }); + + const dup = await postJson<{ resolved: false }>(`/api/v1/sessions/${sid}/questions/q-5`, { + answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } }, + }); + expect(dup.body.code).toBe(40902); + expect(dup.body.data).toEqual({ resolved: false }); + }); + + it('returns 40405 for an unknown question id', async () => { + const sid = await createSession(); + const { body } = await postJson(`/api/v1/sessions/${sid}/questions/nope`, { + answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } }, + }); + expect(body.code).toBe(40405); + }); + + it('resolves a question whose id contains a colon', async () => { + const sid = await createSession(); + const resultPromise: Promise = requestQuestion(sid, { + id: 'AskUserQuestion:0', + toolCallId: 'AskUserQuestion:0', + questions: [ + { + question: 'Pick one', + options: [{ label: 'Yes' }, { label: 'No' }], + }, + ], + }); + + const list = await getJson(`/api/v1/sessions/${sid}/questions?status=pending`); + expect(list.body.data.items[0]!.question_id).toBe('AskUserQuestion:0'); + + const { body } = await postJson( + `/api/v1/sessions/${sid}/questions/AskUserQuestion%3A0`, + { answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } } }, + ); + expect(body.code).toBe(0); + expect(body.data.resolved).toBe(true); + await expect(resultPromise).resolves.toEqual({ answers: { 'Pick one': 'Yes' } }); + }); + + it('mints the question id instead of deriving it from the provider tool_call id', async () => { + const sid = await createSession(); + const resultPromise: Promise = requestQuestion(sid, { + toolCallId: 'AskUserQuestion:0', + questions: [ + { + question: 'Pick one', + options: [{ label: 'Yes' }, { label: 'No' }], + }, + ], + }); + + const list = await getJson(`/api/v1/sessions/${sid}/questions?status=pending`); + const item = list.body.data.items[0]!; + expect(item.tool_call_id).toBe('AskUserQuestion:0'); + expect(item.question_id).not.toBe('AskUserQuestion:0'); + + const { body } = await postJson( + `/api/v1/sessions/${sid}/questions/${encodeURIComponent(item.question_id)}`, + { answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } } }, + ); + expect(body.code).toBe(0); + expect(body.data.resolved).toBe(true); + await expect(resultPromise).resolves.toEqual({ answers: { 'Pick one': 'Yes' } }); + }); + + it('keeps 40001 for a colon tail that matches no pending question', async () => { + const sid = await createSession(); + const { body } = await postJson(`/api/v1/sessions/${sid}/questions/q-9:0`, { + answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } }, + }); + expect(body.code).toBe(40001); + }); + + it('returns 40902 on a duplicate resolve of a colon-id question', async () => { + const sid = await createSession(); + enqueueQuestion(sid, { + id: 'AskUserQuestion:1', + toolCallId: 'AskUserQuestion:1', + questions: [{ question: 'Pick one', options: [{ label: 'Yes' }] }], + }); + const url = `/api/v1/sessions/${sid}/questions/AskUserQuestion%3A1`; + await postJson(url, { + answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } }, + }); + + const dup = await postJson<{ resolved: false }>(url, { + answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } }, + }); + expect(dup.body.code).toBe(40902); + expect(dup.body.data).toEqual({ resolved: false }); + }); + + it('returns 40401 for an unknown session', async () => { + const { body } = await getJson('/api/v1/sessions/nope/questions?status=pending'); + expect(body.code).toBe(40401); + }); +}); diff --git a/packages/kap-server/test/rateLimit.test.ts b/packages/kap-server/test/rateLimit.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9cbf0e5486a49a7c4dd8f6a87c8ab359f9ad3064 --- /dev/null +++ b/packages/kap-server/test/rateLimit.test.ts @@ -0,0 +1,192 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAuthHook } from '../src/middleware/auth'; +import { + createAuthFailureLimiter, + type AuthFailureLimiter, +} from '../src/middleware/rateLimit'; +import type { IAuthTokenService } from '../src/services/auth/authTokenService'; + +const TOKEN = 'test-token'; +const IP_A = '203.0.113.10'; +const IP_B = '203.0.113.11'; + +function fixedImpl(): IAuthTokenService { + return { + _serviceBrand: undefined, + getToken: () => TOKEN, + isValid: async (candidate) => candidate === TOKEN, + }; +} + +function buildApp(limiter?: AuthFailureLimiter): FastifyInstance { + const app = Fastify({ trustProxy: true }); + app.addHook('onRequest', createAuthHook(fixedImpl(), { limiter })); + app.get('/api/v1/sessions', async () => ({ ok: true })); + return app; +} + +function badToken(ip: string): { method: 'GET'; url: string; headers: Record } { + return { + method: 'GET', + url: '/api/v1/sessions', + headers: { 'x-forwarded-for': ip, authorization: 'Bearer wrong-token' }, + }; +} + +describe('createAuthHook rate limiting', () => { + let app: FastifyInstance; + let limiter: AuthFailureLimiter; + + beforeEach(async () => { + limiter = createAuthFailureLimiter({ maxFailures: 3, windowMs: 60_000, banMs: 60_000 }); + app = buildApp(limiter); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + limiter.dispose(); + }); + + it('returns 401 for the first N failures, then 429 on the (N+1)th from the same IP', async () => { + expect((await app.inject(badToken(IP_A))).statusCode).toBe(401); + expect((await app.inject(badToken(IP_A))).statusCode).toBe(401); + expect((await app.inject(badToken(IP_A))).statusCode).toBe(401); + const fourth = await app.inject(badToken(IP_A)); + expect(fourth.statusCode).toBe(429); + const body = fourth.json() as Record; + expect(body['code']).toBe(42901); + expect(body['msg']).toBe('Too many failed auth attempts'); + }); + + it('does not ban a different IP that has not hit the threshold', async () => { + await app.inject(badToken(IP_A)); + await app.inject(badToken(IP_A)); + await app.inject(badToken(IP_A)); + expect((await app.inject(badToken(IP_A))).statusCode).toBe(429); + + expect((await app.inject(badToken(IP_B))).statusCode).toBe(401); + }); + + it('returns 429 to a banned IP even when it presents a valid token', async () => { + await app.inject(badToken(IP_A)); + await app.inject(badToken(IP_A)); + await app.inject(badToken(IP_A)); + expect((await app.inject(badToken(IP_A))).statusCode).toBe(429); + + const valid = await app.inject({ + method: 'GET', + url: '/api/v1/sessions', + headers: { 'x-forwarded-for': IP_A, authorization: `Bearer ${TOKEN}` }, + }); + expect(valid.statusCode).toBe(429); + }); + + it('never returns 429 when no limiter is wired (loopback behavior)', async () => { + const noLimiterApp = buildApp(undefined); + await noLimiterApp.ready(); + try { + for (let i = 0; i < 10; i += 1) { + expect((await noLimiterApp.inject(badToken(IP_A))).statusCode).toBe(401); + } + } finally { + await noLimiterApp.close(); + } + }); +}); + +describe('createAuthHook bypass policy (URL encoding)', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = buildApp(undefined); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('requires a token for percent-encoded /api/ paths', async () => { + for (const url of ['/%61pi/v1/sessions', '/%61%70%69/v1/sessions']) { + const res = await app.inject({ method: 'GET', url }); + expect(res.statusCode).toBe(401); + } + }); + + it('serves percent-encoded /api/ paths with a valid token', async () => { + const res = await app.inject({ + method: 'GET', + url: '/%61pi/v1/sessions', + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(res.statusCode).toBe(200); + }); + + it('requires a token for percent-encoded meta documents', async () => { + const res = await app.inject({ method: 'GET', url: '/%6fpenapi.json' }); + expect(res.statusCode).toBe(401); + }); + + it('still bypasses non-API paths without a token', async () => { + const res = await app.inject({ method: 'GET', url: '/index.html' }); + expect(res.statusCode).toBe(404); + }); + + it('still bypasses the healthz probe when percent-encoded', async () => { + const res = await app.inject({ method: 'GET', url: '/%61pi/v1/healthz' }); + expect(res.statusCode).toBe(404); + }); +}); + +describe('createAuthFailureLimiter (unit)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('bans a source at the threshold and clears the ban after banMs', () => { + vi.useFakeTimers(); + const limiter = createAuthFailureLimiter({ maxFailures: 2, windowMs: 1_000, banMs: 500 }); + try { + expect(limiter.isBanned('1.2.3.4')).toBe(false); + limiter.recordFailure('1.2.3.4'); + expect(limiter.isBanned('1.2.3.4')).toBe(false); + limiter.recordFailure('1.2.3.4'); + expect(limiter.isBanned('1.2.3.4')).toBe(true); + + vi.advanceTimersByTime(499); + expect(limiter.isBanned('1.2.3.4')).toBe(true); + vi.advanceTimersByTime(1); + expect(limiter.isBanned('1.2.3.4')).toBe(false); + } finally { + limiter.dispose(); + } + }); + + it('resets the failure count once the window elapses', () => { + vi.useFakeTimers(); + const limiter = createAuthFailureLimiter({ maxFailures: 2, windowMs: 1_000, banMs: 500 }); + try { + limiter.recordFailure('5.5.5.5'); + vi.advanceTimersByTime(1_001); + limiter.recordFailure('5.5.5.5'); + expect(limiter.isBanned('5.5.5.5')).toBe(false); + } finally { + limiter.dispose(); + } + }); + + it('tracks sources independently', () => { + vi.useFakeTimers(); + const limiter = createAuthFailureLimiter({ maxFailures: 1, windowMs: 1_000, banMs: 500 }); + try { + limiter.recordFailure('9.9.9.9'); + expect(limiter.isBanned('9.9.9.9')).toBe(true); + expect(limiter.isBanned('8.8.8.8')).toBe(false); + } finally { + limiter.dispose(); + } + }); +}); diff --git a/packages/kap-server/test/remoteControl.test.ts b/packages/kap-server/test/remoteControl.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a979d63bf49032ef99e29ef861be53910973d82 --- /dev/null +++ b/packages/kap-server/test/remoteControl.test.ts @@ -0,0 +1,252 @@ +import { createServer } from 'node:http'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + FileTokenStorage, + KIMI_CODE_PROVIDER_NAME, + resolveKimiTokenStorageName, + type TokenInfo, +} from '@moonshot-ai/kimi-code-oauth'; +import { remoteControlLockPath } from '@moonshot-ai/remote-control'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { WebSocketServer, type RawData, type WebSocket } from 'ws'; + +import { ErrorCode } from '../src/protocol/error-codes'; +import { writeServerToken } from '../src/services/auth/persistentToken'; +import { type RunningServer, startServer } from '../src/start'; +import { authedFetch } from './helpers/auth'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +interface RemoteControlStatusWire { + enabled: boolean; + state: 'off' | 'starting' | 'on'; + url?: string; + device_id?: string; + device_name?: string; + error?: string; +} + +const TOKEN: TokenInfo = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: 0, + scope: '', + tokenType: 'Bearer', + expiresIn: 0, +}; + +describe('server-v2 /api/v1/remote-control', () => { + let home: string | undefined; + let server: RunningServer | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-rc-')); + await new FileTokenStorage(join(home, 'credentials')).save( + resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }), + TOKEN, + ); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) await server.close(); + if (home !== undefined) await rm(home, { recursive: true, force: true }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + async function postRemoteControl(enabled: boolean): Promise> { + const res = await authedFetch(server as RunningServer, base, '/api/v1/remote-control', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + expect(res.status).toBe(200); + return (await res.json()) as Envelope; + } + + it('starts and stops the tunnel at runtime, dedupes concurrent enables, and tracks relay-initiated shutdown', async () => { + const relay = await startRegisterAckRelay(); + vi.stubEnv('KIMI_CODE_REMOTE_CONTROL_RELAY_URL', `http://127.0.0.1:${relay.port}`); + + const initial = await authedFetch(server as RunningServer, base, '/api/v1/remote-control'); + const initialBody = (await initial.json()) as Envelope; + expect(initialBody.code).toBe(0); + expect(initialBody.data.state).toBe('off'); + + const [first, second] = await Promise.all([postRemoteControl(true), postRemoteControl(true)]); + expect(first.code).toBe(0); + expect(second.code).toBe(0); + expect(first.data.state).toBe('on'); + expect(second.data.state).toBe('on'); + expect(first.data.url).toContain('/devices/'); + expect(first.data.device_id).toBeTruthy(); + expect(first.data.device_name).toBeTruthy(); + expect(relay.registrations).toHaveLength(1); + + const res = await authedFetch(server as RunningServer, base, '/api/v1/remote-control'); + const fetched = (await res.json()) as Envelope; + expect(fetched.data.state).toBe('on'); + + const stopped = await postRemoteControl(false); + expect(stopped.code).toBe(0); + expect(stopped.data.state).toBe('off'); + expect(stopped.data.enabled).toBe(false); + + const restarted = await postRemoteControl(true); + expect(restarted.code).toBe(0); + expect(restarted.data.state).toBe('on'); + + await writeServerToken(home as string, 'rotated-server-token'); + const httpSocket = relay.httpSockets.at(-1)!; + const rotatedResponsePromise = nextJsonMessage(httpSocket); + httpSocket.send( + JSON.stringify({ + request_id: 'request-rotated', + type: 'request', + is_last: true, + body_base64: Buffer.from( + 'GET /api/v1/healthz HTTP/1.1\r\nHost: relay.test\r\n\r\n', + ).toString('base64'), + }), + ); + const rotatedMessage = await rotatedResponsePromise; + const rotatedResponse = Buffer.from( + rotatedMessage['body_base64'] as string, + 'base64', + ).toString(); + expect(rotatedResponse).toContain('HTTP/1.1 200'); + expect(rotatedResponse).toContain('"ok":true'); + + relay.managementSockets.at(-1)!.send( + JSON.stringify({ type: 'disconnect', payload: { reason: 'user_requested' } }), + ); + await waitFor(async () => { + const after = await authedFetch(server as RunningServer, base, '/api/v1/remote-control'); + const body = (await after.json()) as Envelope; + return body.data.state === 'off'; + }); + + const reenabled = await postRemoteControl(true); + expect(reenabled.code).toBe(0); + expect(reenabled.data.state).toBe('on'); + + await postRemoteControl(false); + await relay.close(); + }); + + it('reports REMOTE_CONTROL_ALREADY_RUNNING when another live process holds the lock', async () => { + await mkdir(join(home as string, 'server'), { recursive: true }); + await writeFile( + remoteControlLockPath(home as string), + JSON.stringify({ + pid: process.pid, + nonce: 'other-process', + local_origin: 'http://127.0.0.1:58627', + device_id: 'other-device', + url: 'https://code-rc.kimi.com/devices/other-device/', + started_at: Date.now(), + }), + ); + + const posted = await postRemoteControl(true); + expect(posted.code).toBe(ErrorCode.REMOTE_CONTROL_ALREADY_RUNNING); + expect(posted.msg).toContain('already running'); + }); +}); + +function rawDataText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} + +function nextJsonMessage(socket: WebSocket): Promise> { + return new Promise((resolve) => { + socket.once('message', (data) => { + resolve(JSON.parse(rawDataText(data)) as Record); + }); + }); +} + +async function startRegisterAckRelay(): Promise<{ + port: number; + registrations: unknown[]; + managementSockets: WebSocket[]; + httpSockets: WebSocket[]; + close(): Promise; +}> { + const managementServer = new WebSocketServer({ noServer: true }); + const httpTunnelServer = new WebSocketServer({ noServer: true }); + const relayServer = createServer(); + const registrations: unknown[] = []; + const managementSockets: WebSocket[] = []; + const httpSockets: WebSocket[] = []; + managementServer.on('connection', (ws) => { + managementSockets.push(ws); + ws.on('error', () => {}); + ws.on('message', (data) => { + const message = JSON.parse(rawDataText(data)) as { type?: string }; + if (message.type === 'register') { + registrations.push(message); + ws.send(JSON.stringify({ type: 'register_ack', payload: { success: true } })); + } + }); + }); + httpTunnelServer.on('connection', (ws) => { + httpSockets.push(ws); + ws.on('error', () => {}); + }); + relayServer.on('upgrade', (request, socket, head) => { + const pathname = new URL(request.url ?? '', 'http://relay.test').pathname; + const target = pathname.endsWith('/v1/remote/create') ? managementServer : httpTunnelServer; + target.handleUpgrade(request, socket, head, (ws) => target.emit('connection', ws, request)); + }); + const port = await new Promise((resolve, reject) => { + relayServer.once('error', reject); + relayServer.listen(0, '127.0.0.1', () => { + const address = relayServer.address(); + if (address === null || typeof address === 'string') reject(new Error('missing address')); + else resolve(address.port); + }); + }); + return { + port, + registrations, + managementSockets, + httpSockets, + close: () => + new Promise((resolve, reject) => { + relayServer.close((error) => { + if (error === undefined) resolve(); + else reject(error); + }); + }), + }; +} + +async function waitFor(predicate: () => Promise, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (!(await predicate())) { + if (Date.now() >= deadline) throw new Error('condition timed out'); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} diff --git a/packages/kap-server/test/requestLogging.test.ts b/packages/kap-server/test/requestLogging.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..56913934fa8fdc0714fad28504b542af834696a0 --- /dev/null +++ b/packages/kap-server/test/requestLogging.test.ts @@ -0,0 +1,93 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Writable } from 'node:stream'; + +import { pino, type Logger } from 'pino'; +import { afterAll, beforeAll, assert, describe, expect, it } from 'vitest'; + +import { extractEnvelopeCode } from '../src/requestLogging'; +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +function captureLogger(): { logger: Logger; lines: string[] } { + const lines: string[] = []; + const stream = new Writable({ + write(chunk, _enc, cb) { + lines.push(chunk.toString()); + cb(); + }, + }); + return { logger: pino({ level: 'info' }, stream), lines }; +} + +function parseEntries(lines: string[]): Record[] { + return lines + .map((line) => { + try { + return JSON.parse(line) as Record; + } catch { + return null; + } + }) + .filter((entry): entry is Record => entry !== null); +} + +describe('requestLogging', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let lines: string[]; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-request-log-')); + const captured = captureLogger(); + lines = captured.lines; + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logger: captured.logger }); + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it('logs the envelope code instead of the HTTP status code', async () => { + const res = await fetch(`http://127.0.0.1:${String(server!.port)}/api/v1/healthz`); + expect(res.status).toBe(200); + expect(((await res.json()) as { code: number }).code).toBe(0); + + await new Promise((resolve) => setImmediate(resolve)); + + const completed = parseEntries(lines).filter((entry) => entry['msg'] === 'request completed'); + expect(completed.length).toBeGreaterThanOrEqual(1); + const entry = completed[completed.length - 1]; + assert(entry !== undefined); + + expect(entry['code']).toBe(0); + expect(entry).not.toHaveProperty('statusCode'); + expect(entry['res']).toBeUndefined(); + expect(entry['req']).toMatchObject({ method: 'GET', url: '/api/v1/healthz' }); + expect(typeof entry['responseTime']).toBe('number'); + }); +}); + +describe('extractEnvelopeCode', () => { + it('extracts a leading code from an envelope body', () => { + expect(extractEnvelopeCode('{"code":0,"msg":"success","data":null,"request_id":"r"}')).toBe(0); + expect( + extractEnvelopeCode('{"code":40001,"msg":"validation.failed","data":null,"request_id":"r"}'), + ).toBe(40001); + }); + + it('returns undefined for non-envelope or non-string payloads', () => { + expect(extractEnvelopeCode(undefined)).toBeUndefined(); + expect(extractEnvelopeCode(Buffer.from('{"code":1}'))).toBeUndefined(); + expect(extractEnvelopeCode('')).toBeUndefined(); + expect(extractEnvelopeCode('{"msg":"no code"}')).toBeUndefined(); + }); +}); diff --git a/packages/kap-server/test/rpc.test.ts b/packages/kap-server/test/rpc.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..66f10b7e90abb3664e54dd54096b3fb3b956ddfc --- /dev/null +++ b/packages/kap-server/test/rpc.test.ts @@ -0,0 +1,866 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + ErrorCodes, + IAgentGoalService, + IAgentLifecycleService, + IAgentLoopService, + IAgentPromptChannel, + IAgentPluginCommandService, + IAgentRuntimeBindingService, + IAgentShellCommandService, + IAppendLogStore, + IDebugEventsService, + IEventService, + IInstantiationService, + IPluginService, + ISessionIndex, + ISessionManager, + ISessionMetadata, + IWorkspaceInstanceManager, + IWorkspaceService, + getLiveSessionById, +} from '@moonshot-ai/agent-core-v2'; +import type { + AgentRuntimeBindingSnapshot, + ServiceIdentifier, + SessionWorkspaceAssociationSnapshot, + WorkspaceInstanceSnapshot, +} from '@moonshot-ai/agent-core-v2'; +import { FakeRuntime } from '@moonshot-ai/agent-core-v2/runtime/fakeRuntime'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: { path: string; message: string }[]; +} + +interface SessionMetaWire { + id: string; + title?: string; + lastPrompt?: string; + createdAt: number; + updatedAt: number; + archived: boolean; +} + +function rpc( + scope: 'core' | 'session' | 'agent', + service: ServiceIdentifier, + method: string, + ids: { sid?: string; aid?: string } = {}, +): string { + if (scope === 'core') return `/api/v1/debug/${String(service)}/${method}`; + if (scope === 'session') return `/api/v1/debug/session/${ids.sid}/${String(service)}/${method}`; + return `/api/v1/debug/session/${ids.sid}/agent/${ids.aid}/${String(service)}/${method}`; +} + +describe('server-v2 /api/v1/debug RPC', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-rpc-')); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent', debugEndpoints: true }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never); + home = undefined; + } + }); + + async function call( + method: 'GET' | 'POST', + path: string, + arg?: unknown, + token?: string, + ): Promise<{ status: number; body: Envelope }> { + const headers: Record = {}; + let url = `${base}${path}`; + const init: { method: string; headers: Record; body?: string } = { + method, + headers, + }; + if (method === 'GET') { + if (arg !== undefined) url += `?arg=${encodeURIComponent(JSON.stringify(arg))}`; + } else if (arg !== undefined) { + headers['content-type'] = 'application/json'; + init.body = JSON.stringify(arg); + } + const credential = token ?? (server as RunningServer).authTokenService.getToken(); + headers['authorization'] = `Bearer ${credential}`; + const res = await fetch(url, init); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function createSession(cwd: string): Promise { + const res = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ metadata: { cwd } }), + } as never); + const body = (await res.json()) as Envelope<{ id: string }>; + expect(body.code).toBe(0); + return body.data.id; + } + + async function createMainAgent(sessionId: string): Promise { + const session = getLiveSessionById(server!.core.accessor, sessionId); + if (session === undefined) throw new Error(`session ${sessionId} not found`); + await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); + } + + async function createSubagent(sessionId: string, agentId: string): Promise { + const session = getLiveSessionById(server!.core.accessor, sessionId); + if (session === undefined) throw new Error(`session ${sessionId} not found`); + await session.accessor.get(IAgentLifecycleService).create({ agentId }); + } + + function goalFacade(sessionId: string, agentId = 'main') { + const session = getLiveSessionById(server!.core.accessor, sessionId); + if (session === undefined) throw new Error(`session ${sessionId} not found`); + const manager = session.accessor.get(IAgentLifecycleService); + const handle = manager.handleOf(agentId); + if (handle === undefined) throw new Error(`agent ${agentId} not found`); + return handle.accessor.get(IAgentGoalService); + } + + it('describes all channels via GET /api/v1/debug/channels', async () => { + const { status, body } = await call< + readonly { + name: string; + scope: 'app' | 'session' | 'agent'; + methods: readonly { + name: string; + kind: 'method' | 'property'; + arity: number; + params: string; + }[]; + }[] + >('GET', '/api/v1/debug/channels'); + expect(status).toBe(200); + expect(body.code).toBe(0); + + const byName = new Map(body.data.map((c) => [c.name, c])); + expect(byName.get('sessionIndex')?.scope).toBe('app'); + expect(byName.get('sessionMetadata')?.scope).toBe('session'); + expect(byName.get('agentPromptService')?.scope).toBe('agent'); + + const meta = byName.get('sessionMetadata'); + expect(meta?.methods.map((m) => m.name)).toEqual( + expect.arrayContaining(['read', 'setTitle', 'setArchived']), + ); + expect(meta?.methods.find((m) => m.name === 'read')).toMatchObject({ + kind: 'method', + arity: 0, + params: '', + }); + expect(meta?.methods.find((m) => m.name === 'setTitle')).toMatchObject({ + arity: 1, + params: 'title', + }); + expect(meta?.methods.map((m) => m.name)).not.toContain('dispose'); + + const prompts = byName.get('agentPromptService'); + expect(prompts?.methods.map((m) => m.name)).toEqual( + expect.arrayContaining(['submit', 'submitSteer']), + ); + expect(prompts?.methods.map((m) => m.name)).not.toContain('reserve'); + }); + + it('reaches a runtime-contributed Service absent from /channels (decorator-name fallback)', async () => { + const channels = await call( + 'GET', + '/api/v1/debug/channels', + ); + expect(channels.body.data.some((c) => c.name === String(IDebugEventsService))).toBe(false); + + const { status, body } = await call<{ + subscriptions: unknown[]; + buses: unknown[]; + globalListeners?: number; + }>('GET', rpc('core', IDebugEventsService, 'subscriptions')); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(Array.isArray(body.data.subscriptions)).toBe(true); + expect(Array.isArray(body.data.buses)).toBe(true); + expect(typeof body.data.globalListeners).toBe('number'); + }); + + it('rejects kernel tokens registered neither statically nor by a feature (40001)', async () => { + const { body } = await call('POST', rpc('core', IInstantiationService, 'dispose')); + expect(body.code).toBe(40001); + }); + + it('lists sessions via GET', async () => { + const { body } = await call<{ items: unknown[]; has_more: boolean }>( + 'GET', + rpc('core', ISessionIndex, 'listRecent'), + {}, + ); + expect(body.code).toBe(0); + expect(Array.isArray(body.data.items)).toBe(true); + }); + + it('creates a workspace and reads it back', async () => { + const cwd = home as string; + const created = await call<{ id: string; root: string }>( + 'POST', + rpc('core', IWorkspaceService, 'createOrTouch'), + cwd, + ); + expect(created.body.code).toBe(0); + expect(created.body.data.root).toBe(cwd); + + const got = await call<{ id: string; root: string }>( + 'GET', + rpc('core', IWorkspaceService, 'get'), + created.body.data.id, + ); + expect(got.body.code).toBe(0); + expect(got.body.data.root).toBe(cwd); + }); + + it('exposes workspace, session association, and agent binding business snapshots', async () => { + const sessionId = await createSession(home as string); + await createMainAgent(sessionId); + const summary = await server!.core.accessor.get(ISessionIndex).get(sessionId); + expect(summary).toBeDefined(); + const workspaceId = summary!.workspaceId; + + const workspace = await call( + 'GET', + `/api/v1/debug/workspace/${workspaceId}/snapshot`, + ); + expect(workspace.body.data).toMatchObject({ + metadata: { id: workspaceId, root: home }, + lifecycle: 'active', + program: { + binding: { workspaceId, runtimeId: 'local' }, + }, + runtimes: { + workspaceId, + runtimes: [{ runtimeId: 'local', status: 'ready' }], + }, + }); + expect(workspace.body.data).not.toHaveProperty('accessor'); + expect(workspace.body.data).not.toHaveProperty('container'); + + const association = await call( + 'GET', + `/api/v1/debug/session/${sessionId}/association`, + ); + expect(association.body.data).toEqual({ + sessionId, + workspaceId, + cwd: home, + }); + + const binding = await call( + 'GET', + `/api/v1/debug/session/${sessionId}/agent/main/runtime-binding`, + ); + expect(binding.body.data).toMatchObject({ + binding: { workspaceId, runtimeId: 'local' }, + available: true, + runtime: { runtimeId: 'local', status: 'ready' }, + }); + + const legacy = await fetch( + `${base}/api/v1/debug/workspace/${workspaceId}/workspaceTrust/get`, + { headers: authHeaders(server as RunningServer) }, + ); + expect(legacy.status).toBe(404); + }); + + it('rejects createOrTouch for a missing root directory (40409)', async () => { + const missing = join(home as string, 'never-created'); + const { body } = await call( + 'POST', + rpc('core', IWorkspaceService, 'createOrTouch'), + missing, + ); + expect(body.code).toBe(40409); + }); + + it('renames a workspace via update', async () => { + const cwd = home as string; + const created = await call<{ id: string; name: string }>( + 'POST', + rpc('core', IWorkspaceService, 'createOrTouch'), + cwd, + ); + const id = created.body.data.id; + + const updated = await call<{ id: string; name: string }>( + 'POST', + rpc('core', IWorkspaceService, 'update'), + [id, { name: 'renamed' }], + ); + expect(updated.body.code).toBe(0); + expect(updated.body.data.name).toBe('renamed'); + + const got = await call<{ id: string; name: string }>( + 'GET', + rpc('core', IWorkspaceService, 'get'), + id, + ); + expect(got.body.data.name).toBe('renamed'); + }); + + it('counts active sessions', async () => { + const cwd = home as string; + const created = await call<{ id: string }>('POST', rpc('core', IWorkspaceService, 'createOrTouch'), cwd); + await createSession(cwd); + const { body } = await call( + 'POST', + rpc('core', ISessionIndex, 'count'), + [{ workspaceIds: [created.body.data.id] }], + ); + expect(body.code).toBe(0); + expect(body.data).toBeGreaterThanOrEqual(1); + }); + + it('reads and updates session metadata', async () => { + const id = await createSession(home as string); + + const read = await call('POST', rpc('session', ISessionMetadata, 'read', { sid: id })); + expect(read.body.code).toBe(0); + expect(read.body.data.id).toBe(id); + + const set = await call('POST', rpc('session', ISessionMetadata, 'setTitle', { sid: id }), 'renamed'); + expect(set.body.code).toBe(0); + + const read2 = await call('POST', rpc('session', ISessionMetadata, 'read', { sid: id })); + expect(read2.body.data.title).toBe('renamed'); + }); + + it('reads agent activity state', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const { body } = await call<{ turn?: unknown }>( + 'POST', + rpc('agent', IAgentLoopService, 'snapshot', { sid: id, aid: 'main' }), + ); + expect(body.code).toBe(0); + expect(body.data.turn).toBeUndefined(); + }); + + it('exposes runtime binding through REST and debug dispatcher contracts', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const dispatched = await call<{ workspaceId: string; runtimeId: string }>( + 'POST', + rpc('agent', IAgentRuntimeBindingService, 'get', { sid: id, aid: 'main' }), + ); + expect(dispatched.body.data.runtimeId).toBe('local'); + + const current = await call<{ workspace_id: string; runtime_id: string }>( + 'GET', + `/api/v1/sessions/${id}/runtime`, + ); + expect(current.body.data).toMatchObject({ runtime_id: 'local' }); + + const invalid = await call( + 'POST', + `/api/v1/sessions/${id}/runtime`, + { runtime_id: 'missing-runtime' }, + ); + expect(invalid.body.code).toBe(40420); + + const unchanged = await call<{ workspace_id: string; runtime_id: string }>( + 'GET', + `/api/v1/sessions/${id}/runtime`, + ); + expect(unchanged.body.data).toEqual(current.body.data); + + const provider = await server!.core.accessor.get(IWorkspaceInstanceManager).addProvider({ + id: 'debug-remote-provider', + imports: { root: [], imports: [], local: [] }, + attach: async (context, host) => { + host.registerRuntime(new FakeRuntime({ + workspaceId: context.id, + runtimeId: 'remote', + generation: 'remote-two', + })); + return { dispose: () => {} }; + }, + }); + try { + const switched = await call<{ workspace_id: string; runtime_id: string }>( + 'POST', + `/api/v1/sessions/${id}/runtime`, + { runtime_id: 'remote' }, + ); + expect(switched.body.data.runtime_id).toBe('remote'); + const snapshot = await call( + 'GET', + `/api/v1/debug/session/${id}/agent/main/runtime-binding`, + ); + expect(snapshot.body.data).toMatchObject({ + binding: { workspaceId: current.body.data.workspace_id, runtimeId: 'remote' }, + available: true, + runtime: { runtimeId: 'remote', generation: 'remote-two', status: 'ready' }, + }); + } finally { + await provider.dispose(); + } + }); + + it('archives a session', async () => { + const id = await createSession(home as string); + const { body } = await call('POST', rpc('core', ISessionManager, 'archive'), id); + expect(body.code).toBe(0); + expect(body.data).toBeNull(); + }); + + it('submits a prompt and returns the turn id', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const { body } = await call<{ turn_id: number }>( + 'POST', + rpc('agent', IAgentPromptChannel, 'submit', { sid: id, aid: 'main' }), + { input: [{ type: 'text', text: 'hello' }] }, + ); + expect(body.code).toBe(0); + expect(body.data.turn_id).toBe(0); + }); + + it('maps a duplicate promptId to 40927 before metadata changes', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const path = rpc('agent', IAgentPromptChannel, 'submit', { sid: id, aid: 'main' }); + + const first = await call<{ turn_id: number }>('POST', path, { + input: [{ type: 'text', text: 'first prompt' }], + promptId: 'submission-1', + }); + expect(first.body.code).toBe(0); + + const duplicate = await call('POST', path, { + input: [{ type: 'text', text: 'must not become metadata' }], + promptId: 'submission-1', + }); + expect(duplicate.body.code).toBe(40927); + + const metadata = await call( + 'POST', + rpc('session', ISessionMetadata, 'read', { sid: id }), + ); + expect(metadata.body.data.lastPrompt).toBe('first prompt'); + }); + + it('derives the session title and lastPrompt from the first prompt', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const events: { type: string; payload: unknown }[] = []; + const sub = (server as RunningServer).core.accessor + .get(IEventService) + .subscribe((event) => events.push(event as unknown as { type: string; payload: unknown })); + + const { body } = await call<{ turn_id: number }>( + 'POST', + rpc('agent', IAgentPromptChannel, 'submit', { sid: id, aid: 'main' }), + { input: [{ type: 'text', text: 'hello title' }] }, + ); + expect(body.code).toBe(0); + sub.dispose(); + + const meta = await call('POST', rpc('session', ISessionMetadata, 'read', { sid: id })); + expect(meta.body.code).toBe(0); + expect(meta.body.data.title).toBe('hello title'); + expect(meta.body.data.lastPrompt).toBe('hello title'); + + const updated = events.find((e) => e.type === 'session.meta.updated'); + expect(updated).toBeDefined(); + const payload = updated?.payload as + | { title?: string; patch?: { lastPrompt?: string } } + | undefined; + expect(payload?.title).toBe('hello title'); + expect(payload?.patch?.lastPrompt).toBe('hello title'); + }); + + it('keeps a custom title and only refreshes lastPrompt on a later prompt', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const renamed = await call('POST', rpc('session', ISessionMetadata, 'setTitle', { sid: id }), 'keep-me'); + expect(renamed.body.code).toBe(0); + + const { body } = await call<{ turn_id: number }>( + 'POST', + rpc('agent', IAgentPromptChannel, 'submit', { sid: id, aid: 'main' }), + { input: [{ type: 'text', text: 'should not become the title' }] }, + ); + expect(body.code).toBe(0); + + const meta = await call('POST', rpc('session', ISessionMetadata, 'read', { sid: id })); + expect(meta.body.code).toBe(0); + expect(meta.body.data.title).toBe('keep-me'); + expect(meta.body.data.lastPrompt).toBe('should not become the title'); + }); + + it('runs a shell command through the shell command service', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const { body } = await call<{ stdout: string; stderr: string; isError?: boolean }>( + 'POST', + rpc('agent', IAgentShellCommandService, 'run', { sid: id, aid: 'main' }), + { command: 'printf hello' }, + ); + expect(body.code).toBe(0); + expect(body.data.stdout).toBe('hello'); + expect(body.data.stderr).toBe(''); + expect(body.data.isError).not.toBe(true); + }); + + it('controls goals through the goal runtime facade', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const goal = goalFacade(id); + const created = await goal.createGoal({ objective: 'finish the migration' }); + expect(created).toMatchObject({ + objective: 'finish the migration', + status: 'active', + }); + + expect(goal.getGoal().goal).toMatchObject({ + objective: 'finish the migration', + status: 'active', + }); + + const paused = await goal.pauseGoal({}); + expect(paused.status).toBe('paused'); + + const resumed = await goal.resumeGoal({}); + expect(resumed.status).toBe('active'); + + const cancelled = await goal.cancelGoal({}); + expect(cancelled.status).toBe('active'); + + expect(goal.getGoal().goal).toBeNull(); + }); + + it('rejects a duplicate goal through the goal runtime facade', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const goal = goalFacade(id); + await goal.createGoal({ objective: 'first' }); + await expect(goal.createGoal({ objective: 'second' })).rejects.toMatchObject({ + code: ErrorCodes.GOAL_ALREADY_EXISTS, + }); + }); + + it('rejects goal access for subagents', async () => { + const id = await createSession(home as string); + await createSubagent(id, 'sub-1'); + + await expect(goalFacade(id, 'sub-1').createGoal({ objective: 'sub' })).rejects.toMatchObject({ + code: ErrorCodes.GOAL_UNSUPPORTED_AGENT, + }); + }); + + it('lists and installs plugins through RPC', async () => { + const pluginRoot = await mkdtemp(join(tmpdir(), 'server-v2-plugin-source-')); + try { + await writeFile(join(pluginRoot, 'deploy.md'), '---\ndescription: Deploy\n---\n\nDeploy body', 'utf8'); + await writeFile( + join(pluginRoot, 'kimi.plugin.json'), + JSON.stringify({ name: 'rpc-plugin', commands: ['./deploy.md'] }), + 'utf8', + ); + + const installed = await call<{ id: string }>('POST', rpc('core', IPluginService, 'installPlugin'), { source: pluginRoot }); + expect(installed.body.code).toBe(0); + expect(installed.body.data.id).toBe('rpc-plugin'); + + const listed = await call('GET', rpc('core', IPluginService, 'listPlugins')); + expect(listed.body.code).toBe(0); + expect(listed.body.data).toEqual([ + expect.objectContaining({ id: 'rpc-plugin', state: 'ok' }), + ]); + + const info = await call<{ id: string }>('POST', rpc('core', IPluginService, 'getPluginInfo'), { id: 'rpc-plugin' }); + expect(info.body.code).toBe(0); + expect(info.body.data.id).toBe('rpc-plugin'); + + const commands = await call( + 'GET', + rpc('core', IPluginService, 'listPluginCommands'), + ); + expect(commands.body.code).toBe(0); + expect(commands.body.data).toEqual([ + expect.objectContaining({ pluginId: 'rpc-plugin', name: 'deploy' }), + ]); + + const sessionId = await createSession(home as string); + await createMainAgent(sessionId); + const activated = await call( + 'POST', + rpc('agent', IAgentPluginCommandService, 'activate', { sid: sessionId, aid: 'main' }), + { pluginId: 'rpc-plugin', commandName: 'deploy', args: 'prod' }, + ); + expect(activated.body.code).toBe(0); + } finally { + await rm(pluginRoot, { recursive: true, force: true }); + } + }); + + it('returns 40401 when the agent does not exist', async () => { + const id = await createSession(home as string); + const { body } = await call( + 'POST', + rpc('agent', IAgentPromptChannel, 'submit', { sid: id, aid: 'does-not-exist' }), + { input: [{ type: 'text', text: 'hello' }] }, + ); + expect(body.code).toBe(40401); + expect(body.msg).toBe(`agent does-not-exist not found in session ${id}`); + }); + + it('routes core / session / agent scopes by channel name', async () => { + const cwd = home as string; + await createSession(cwd); + + const listed = await call<{ items: { id: string }[] }>('POST', rpc('core', ISessionIndex, 'listRecent'), {}); + expect(listed.body.code).toBe(0); + expect(listed.body.data.items.length).toBeGreaterThanOrEqual(1); + + const id = listed.body.data.items[0]!.id; + const read = await call('POST', rpc('session', ISessionMetadata, 'read', { sid: id })); + expect(read.body.code).toBe(0); + expect(read.body.data.id).toBe(id); + }); + + it('rejects unknown method (40001)', async () => { + const { body } = await call('POST', rpc('core', ISessionIndex, 'nope')); + expect(body.code).toBe(40001); + }); + + it('rejects unknown service (40001)', async () => { + const { body } = await call('POST', '/api/v1/debug/does-not-exist/list'); + expect(body.code).toBe(40001); + }); + + it('does not serve a missing method segment', async () => { + const { status, body } = await call('POST', '/api/v1/debug/sessionIndex'); + expect(status === 404 || body.code !== 0).toBe(true); + }); + + it('rejects unknown session (40401)', async () => { + const { body } = await call('POST', rpc('session', ISessionMetadata, 'read', { sid: 'nope' })); + expect(body.code).toBe(40401); + }); + + it('rejects oversized body', async () => { + const huge = 'x'.repeat(2 * 1024 * 1024); + const token = (server as RunningServer).authTokenService.getToken(); + let rejected = false; + let code: number | undefined; + try { + const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ big: huge }), + }); + const body = (await res.json()) as Envelope; + rejected = res.status === 413 || body.code !== 0; + code = body.code; + } catch { + rejected = true; + } + expect(rejected).toBe(true); + expect(code).not.toBe(0); + }); + + it('surfaces the originating stack trace on error', async () => { + const { body } = await call('POST', rpc('session', ISessionMetadata, 'read', { sid: 'nope' })); + const json = JSON.stringify(body); + expect(json).toContain('"stack"'); + expect(json).toContain('dispatch'); + }); +}); + +describe('server-v2 /api/v1/debug RPC auth', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + const token = 'test-secret-token'; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-rpc-auth-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + rpcToken: token, debugEndpoints: true, + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never); + home = undefined; + } + }); + + it('rejects calls without a token (40101)', async () => { + const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, { method: 'POST' }); + expect(res.status).toBe(401); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(40101); + }); + + it('accepts calls with the correct rpcToken', async () => { + const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + const body = (await res.json()) as Envelope<{ items: unknown[] }>; + expect(body.code).toBe(0); + }); + + it('accepts the persistent token on /api/v1/debug', async () => { + const persistent = (server as RunningServer).authTokenService.getToken(); + const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, { + method: 'POST', + headers: { authorization: `Bearer ${persistent}`, 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + const body = (await res.json()) as Envelope<{ items: unknown[] }>; + expect(body.code).toBe(0); + }); + + it('rejects a wrong token (40101)', async () => { + const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, { + method: 'POST', + headers: { authorization: 'Bearer wrong' }, + }); + expect(res.status).toBe(401); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(40101); + }); +}); + +describe('server-v2 /api/v1/debug RPC (dev-only, whitelist-free)', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-debug-rpc-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + debugEndpoints: true, + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never); + home = undefined; + } + }); + + async function call( + method: 'GET' | 'POST', + path: string, + arg?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const headers: Record = { + authorization: `Bearer ${(server as RunningServer).authTokenService.getToken()}`, + }; + const init: { method: string; headers: Record; body?: string } = { + method, + headers, + }; + if (arg !== undefined) { + headers['content-type'] = 'application/json'; + init.body = JSON.stringify(arg); + } + const res = await fetch(`${base}${path}`, init); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + it('describes every scoped Service via GET /api/v1/debug/channels', async () => { + const { status, body } = await call( + 'GET', + '/api/v1/debug/channels', + ); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.length).toBeGreaterThan(50); + const names = body.data.map((c) => c.name); + expect(names).toContain(String(IAppendLogStore)); + expect(names).toContain(String(ISessionIndex)); + }); + + it('calls a non-whitelisted Service method', async () => { + const { status, body } = await call( + 'POST', + `/api/v1/debug/${String(IAppendLogStore)}/flush`, + [], + ); + expect(status).toBe(200); + expect(body.code).toBe(0); + }); + + it('also reaches whitelisted Services by the same wire names', async () => { + const { body } = await call<{ items: unknown[] }>( + 'POST', + `/api/v1/debug/${String(ISessionIndex)}/listRecent`, + [{ limit: 1 }], + ); + expect(body.code).toBe(0); + expect(Array.isArray(body.data.items)).toBe(true); + }); + + it('rejects an unknown service with 40001', async () => { + const { body } = await call('POST', '/api/v1/debug/noSuchService/whatever', []); + expect(body.code).toBe(40001); + }); + + it('is gated by the same bearer auth as the rest of /api/*', async () => { + const res = await fetch(`${base}/api/v1/debug/channels`); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/kap-server/test/search/searchRoute.test.ts b/packages/kap-server/test/search/searchRoute.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..92df53fefd5cafcb92fe20d2e1a3f4b84b13d015 --- /dev/null +++ b/packages/kap-server/test/search/searchRoute.test.ts @@ -0,0 +1,305 @@ +import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +process.env['KIMI_CODE_SEARCH_WORKER'] = '1'; + +import { ISessionIndex, type SessionSummary } from '@moonshot-ai/agent-core-v2'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../../src/start'; +import { TEST_HOST_IDENTITY } from '../helpers/hostIdentity'; +import { authedFetch } from '../helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: { path: string; message: string }[]; +} + +interface SearchPageWire { + items: { + session_id: string; + workspace_id: string; + session_title: string; + agent_id: string; + role: string; + snippet: string; + time: number; + turn?: number; + step_id?: string; + score: number; + }[]; + has_more: boolean; + page_token?: string; + incomplete?: string; + index_state: { + state: string; + indexed_sessions: number; + total_sessions: number; + documents: number; + }; + source: string; +} + +const WS = 'ws_route'; + +function stubSessionIndex(summaries: SessionSummary[]): ISessionIndex { + return { + _serviceBrand: undefined, + prepare: async () => ({ state: 'uninitialized', degradedCount: 0 }), + status: () => ({ state: 'uninitialized', degradedCount: 0 }), + listRecent: async () => ({ items: summaries, nextCursor: undefined }), + get: async () => undefined, + count: async () => summaries.length, + remove: async () => {}, + }; +} + +describe('server-v2 /api/v1/search', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-search-')); + const sessionDir = join(home, 'sessions', WS, 's1', 'agents', 'main'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + join(sessionDir, 'wire.jsonl'), + [ + JSON.stringify({ + type: 'context.append_message', + time: 1_700_000_000_000, + message: { + role: 'user', + content: [{ type: 'text', text: '帮我查一下苹果的价格' }], + origin: { kind: 'user' }, + }, + }), + JSON.stringify({ + type: 'context.append_loop_event', + time: 1_700_000_000_100, + event: { type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, + }), + JSON.stringify({ + type: 'context.append_loop_event', + time: 1_700_000_000_200, + event: { + type: 'content.part', + stepUuid: 'u1', + part: { type: 'text', text: '苹果现价每斤九块九。' }, + }, + }), + ].join('\n') + '\n', + 'utf8', + ); + await writeFile(join(home, 'sessions', WS, 's1', 'state.json'), JSON.stringify({ title: '苹果询价' })); + const summaries: SessionSummary[] = [ + { + id: 's1', + workspaceId: WS, + title: '苹果询价', + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + archived: false, + }, + ]; + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds: [[ISessionIndex, stubSessionIndex(summaries)]], + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function postSearch(body: unknown): Promise> { + const res = await authedFetch(server!, base, '/api/v1/search', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return (await res.json()) as Envelope; + } + + it('searches across sessions and returns the wire-shaped page', { timeout: 20_000 }, async () => { + let body: Envelope | undefined; + for (let attempt = 0; attempt < 100; attempt++) { + body = await postSearch({ query: '苹果' }); + expect(body.code).toBe(0); + if (body.data.items.length > 0) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(body).toBeDefined(); + expect(body!.data.items.length).toBeGreaterThan(0); + + const hit = body!.data.items.find((h) => h.role === 'user'); + expect(hit).toBeDefined(); + expect(hit!.session_id).toBe('s1'); + expect(hit!.workspace_id).toBe(WS); + expect(hit!.session_title).toBe('苹果询价'); + expect(hit!.agent_id).toBe('main'); + expect(hit!.snippet).toContain('苹果'); + expect(hit!.step_id).toBeUndefined(); + const assistant = body!.data.items.find((h) => h.role === 'assistant'); + expect(assistant).toBeDefined(); + expect(assistant!.turn).toBe(0); + expect(assistant!.step_id).toBe('t0.1'); + expect(body!.data.items.some((h) => h.role === 'title')).toBe(true); + expect(body!.data.has_more).toBe(false); + expect(['building', 'ready', 'readonly']).toContain(body!.data.index_state.state); + expect(body!.data.source).toBe('index'); + }); + + it('rejects invalid bodies with 40001', async () => { + const emptyQuery = await postSearch({ query: '' }); + expect(emptyQuery.code).toBe(40001); + + const oversizedPage = await postSearch({ query: '苹果', page_size: 51 }); + expect(oversizedPage.code).toBe(40001); + + const badSort = await postSearch({ query: '苹果', sort: 'newest' }); + expect(badSort.code).toBe(40001); + + const badMode = await postSearch({ query: '苹果', mode: 'exact' }); + expect(badMode.code).toBe(40001); + + const shortLiteral = await postSearch({ query: '苹', mode: 'literal' }); + expect(shortLiteral.code).toBe(40001); + expect(shortLiteral.msg).toContain('at least 2 characters'); + + const nullToken = await postSearch({ + query: '苹果', + page_token: Buffer.from('null').toString('base64url'), + }); + expect(nullToken.code).toBe(40001); + }); + + it('serves literal mode through the wire', { timeout: 20_000 }, async () => { + let body: Envelope | undefined; + for (let attempt = 0; attempt < 100; attempt++) { + body = await postSearch({ query: '的价格', mode: 'literal' }); + expect(body.code).toBe(0); + if (body.data.items.length > 0) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(body).toBeDefined(); + const hit = body!.data.items.find((h) => h.role === 'user'); + expect(hit).toBeDefined(); + expect(hit!.snippet).toContain('的价格'); + expect(hit!.score).toBe(0); + expect(body!.data.items.some((h) => h.role === 'assistant')).toBe(false); + expect(body!.data.incomplete).toBeUndefined(); + }); +}); + +describe('server-v2 session routes with the global search DB unavailable', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-search-down-')); + await writeFile(join(home, 'search-index'), 'not a minidb directory', 'utf8'); + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function boot(): Promise { + if (server !== undefined) return; + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home as string, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + } + + async function getJson(path: string): Promise> { + const res = await authedFetch(server as RunningServer, base, path); + return (await res.json()) as Envelope; + } + + async function postJson(path: string, body?: unknown): Promise> { + const res = await authedFetch(server as RunningServer, base, path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body ?? {}), + }); + return (await res.json()) as Envelope; + } + + it('session list / create / get / cold resume pass with the search index down', { timeout: 30_000 }, async () => { + await boot(); + const created = await postJson<{ id: string }>('/api/v1/sessions', { + metadata: { cwd: home }, + }); + expect(created.code).toBe(0); + const id = created.data.id; + const list = await getJson<{ items: { id: string }[] }>('/api/v1/sessions'); + expect(list.code).toBe(0); + expect(list.data.items.map((item) => item.id)).toContain(id); + await server!.close(); + server = undefined; + + await boot(); + const coldList = await getJson<{ items: { id: string }[] }>('/api/v1/sessions'); + expect(coldList.code).toBe(0); + expect(coldList.data.items.map((item) => item.id)).toContain(id); + const got = await getJson<{ id: string }>(`/api/v1/sessions/${id}`); + expect(got.code).toBe(0); + const messages = await getJson<{ items: unknown[] }>(`/api/v1/sessions/${id}/messages`); + expect(messages.code).toBe(0); + + const probe = await stat(join(home as string, 'search-index')); + expect(probe.isFile()).toBe(true); + }); + + it('only the full-text search request reports the index outage', { timeout: 30_000 }, async () => { + await boot(); + const created = await postJson<{ id: string }>('/api/v1/sessions', { + metadata: { cwd: home }, + }); + expect(created.code).toBe(0); + + await expect + .poll( + async () => (await postJson('/api/v1/search', { query: 'anything' })).code, + { timeout: 10_000, interval: 100 }, + ) + .toBe(50001); + const search = await postJson('/api/v1/search', { query: 'anything' }); + expect(search.code).toBe(50001); + expect(search.msg).toContain('search index failed to open'); + + const list = await getJson<{ items: unknown[] }>('/api/v1/sessions'); + expect(list.code).toBe(0); + }); +}); diff --git a/packages/kap-server/test/search/searchService.bench.ts b/packages/kap-server/test/search/searchService.bench.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf7851764ad6f1d85d503053fdd9a42fa96b865f --- /dev/null +++ b/packages/kap-server/test/search/searchService.bench.ts @@ -0,0 +1,236 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { monitorEventLoopDelay, performance, type IntervalHistogram } from 'node:perf_hooks'; + +import type { + IBootstrapService, + IConfigService, + ILogService, + ISessionIndex, + SessionSummary, +} from '@moonshot-ai/agent-core-v2'; +import { DATABASE_SECTION } from '@moonshot-ai/agent-core-v2'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + GlobalSearchService, + drainGlobalSearchDisposals, +} from '../../src/search/searchService'; + +const WS = 'ws_test'; + +const T1 = 1_700_000_000_000; + +function summary(id: string, title: string, updatedAt = T1): SessionSummary { + return { id, workspaceId: WS, title, createdAt: updatedAt, updatedAt, archived: false }; +} + +function makeBootstrap(home: string): IBootstrapService { + return { + homeDir: home, + scope: (name: string) => name, + } as unknown as IBootstrapService; +} + +function makeSessionIndex(list: ISessionIndex['listRecent']): ISessionIndex { + return { + _serviceBrand: undefined, + prepare: async () => ({ state: 'uninitialized', degradedCount: 0 }), + status: () => ({ state: 'uninitialized', degradedCount: 0 }), + listRecent: list, + get: async () => undefined, + count: async () => 0, + remove: async () => {}, + }; +} + +function staticIndex(summaries: SessionSummary[]): ISessionIndex { + return makeSessionIndex(async () => ({ items: summaries, nextCursor: undefined })); +} + +function userLine(text: string, time: number, origin?: unknown): string { + return JSON.stringify({ + type: 'context.append_message', + time, + message: { + role: 'user', + content: [{ type: 'text', text }], + origin: origin ?? { kind: 'user' }, + }, + }); +} + +function assistantLine(text: string, time: number): string { + return JSON.stringify({ + type: 'context.append_loop_event', + time, + event: { type: 'content.part', part: { type: 'text', text } }, + }); +} + +async function writeWire( + home: string, + sessionId: string, + agentId: string, + lines: string[], +): Promise { + const dir = join(home, 'sessions', WS, sessionId, 'agents', agentId); + await mkdir(dir, { recursive: true }); + const file = join(dir, 'wire.jsonl'); + await writeFile(file, lines.map((l) => `${l}\n`).join(''), 'utf8'); + return file; +} + +const noopLog = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, +} as unknown as ILogService; + +function makeConfig(searchEnabled: boolean): IConfigService { + return { + ready: Promise.resolve(), + get: (domain: string) => (domain === DATABASE_SECTION ? { search: searchEnabled } : undefined), + } as unknown as IConfigService; +} + +function makeService(home: string, index: ISessionIndex): GlobalSearchService { + const service = new GlobalSearchService(index, makeBootstrap(home), noopLog, makeConfig(true)); + service.syncDebounceMs = 0; + return service; +} + +describe('baseline: synthetic corpus', () => { + let home: string | undefined; + const services: GlobalSearchService[] = []; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-kap-search-baseline-')); + }); + + afterEach(async () => { + for (const service of services.splice(0)) service.dispose(); + await drainGlobalSearchDisposals(); + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + const TOPICS = ['compaction', 'walrus', 'snapshot', 'recovery', '索引', '持久化']; + + async function writeCorpus(from: number, to: number): Promise { + const summaries: SessionSummary[] = []; + for (let i = from; i < to; i++) { + const id = `s${i}`; + summaries.push(summary(id, `session ${i} 索引讨论`, T1 + i)); + const lines: string[] = []; + for (let j = 0; j < 8; j++) { + lines.push(userLine(`session ${i} message ${j} about ${TOPICS[(i + j) % TOPICS.length]!}`, T1 + i * 100 + j)); + lines.push(assistantLine(`reply ${j} covering ${TOPICS[(i + 2 * j) % TOPICS.length]!}`, T1 + i * 100 + j + 1)); + } + await writeWire(home!, id, 'main', lines); + } + return summaries; + } + + async function medianMs(fn: () => Promise, runs = 5): Promise { + const times: number[] = []; + for (let r = 0; r < runs; r++) { + const t0 = performance.now(); + await fn(); + times.push(performance.now() - t0); + } + times.sort((a, b) => a - b); + return times[(times.length / 2) | 0]!; + } + + it('indexing and search latency scale within a linear budget from 100 to 400 sessions', async () => { + const all: SessionSummary[] = []; + const service = makeService(home!, staticIndex(all)); + services.push(service); + + all.push(...(await writeCorpus(0, 100))); + const t0 = performance.now(); + await service.reindex(); + const index100 = performance.now() - t0; + const terms100 = await medianMs(() => service.search({ query: 'compaction' })); + const literal100 = await medianMs(() => service.search({ query: 'message 3 about', mode: 'literal' })); + + all.push(...(await writeCorpus(100, 400))); + const t1 = performance.now(); + await service.reindex(); + const index400 = performance.now() - t1; + const terms400 = await medianMs(() => service.search({ query: 'compaction' })); + const literal400 = await medianMs(() => service.search({ query: 'message 3 about', mode: 'literal' })); + + const hits = await service.search({ query: 'compaction' }); + expect(hits.items.length).toBeGreaterThan(0); + expect((await service.search({ query: 'message 3 about', mode: 'literal' })).items.length).toBeGreaterThan(0); + + console.log( + `[baseline] searchService ${JSON.stringify({ + sessions: [100, 400], + reindexMs: [index100, index400], + termsMedianMs: [terms100, terms400], + literalMedianMs: [literal100, literal400], + })}`, + ); + expect(index400).toBeLessThan(index100 * 10 + 2000); + expect(terms400).toBeLessThan(terms100 * 10 + 100); + expect(literal400).toBeLessThan(literal100 * 10 + 100); + }, 120_000); + + it('stage-4: deep keyset pages cost like the first page, with a bounded event-loop pause', async () => { + const all: SessionSummary[] = []; + const service = makeService(home!, staticIndex(all)); + services.push(service); + all.push(...(await writeCorpus(0, 400))); + await service.reindex(); + + const eld: IntervalHistogram = monitorEventLoopDelay(); + eld.enable(); + try { + const tokens: (string | undefined)[] = [undefined]; + let page = await service.search({ query: 'message', sort: 'time_desc', pageSize: 20 }); + for (let p = 1; p < 10; p++) { + tokens.push(page.pageToken); + page = await service.search({ + query: 'message', + sort: 'time_desc', + pageSize: 20, + pageToken: page.pageToken, + }); + } + expect(page.items.length).toBe(20); + + const page1Ms = await medianMs(() => + service.search({ query: 'message', sort: 'time_desc', pageSize: 20 }), + ); + const page10Ms = await medianMs(() => + service.search({ query: 'message', sort: 'time_desc', pageSize: 20, pageToken: tokens[9] }), + ); + const literalMs = await medianMs(() => + service.search({ query: 'message 3 about', mode: 'literal' }), + ); + + const eldMaxMs = eld.max / 1e6; + const eldP99Ms = eld.percentile(99) / 1e6; + console.log( + `[baseline] stage4 ${JSON.stringify({ + sessions: 400, + page1MedianMs: page1Ms, + page10MedianMs: page10Ms, + literalMedianMs: literalMs, + eventLoopDelayMs: { p99: eldP99Ms, max: eldMaxMs }, + })}`, + ); + expect(page10Ms).toBeLessThan(page1Ms * 5 + 50); + expect(eldMaxMs).toBeLessThan(500); + } finally { + eld.disable(); + } + }, 120_000); +}); diff --git a/packages/kap-server/test/search/searchService.test.ts b/packages/kap-server/test/search/searchService.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc7580a88f82ec5668f171998466d7adefa1e151 --- /dev/null +++ b/packages/kap-server/test/search/searchService.test.ts @@ -0,0 +1,3415 @@ +import { createHash } from 'node:crypto'; +import fs, { appendFile, chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { syncBuiltinESMExports } from 'node:module'; +import { join } from 'node:path'; +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; +import { Worker } from 'node:worker_threads'; + +import type { + IBootstrapService, + IConfigService, + ILogService, + ISessionIndex, + SessionSummary, +} from '@moonshot-ai/agent-core-v2'; +import { DATABASE_SECTION } from '@moonshot-ai/agent-core-v2'; +import { MiniDb } from '@moonshot-ai/minidb'; +import { TranscriptStore, type TranscriptOperation } from '@moonshot-ai/transcript'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SearchIndexCore, type SyncSessionInput } from '../../src/search/indexCore'; +import { + GlobalSearchError, + GlobalSearchService, + InlineSearchBackend, + drainGlobalSearchDisposals, + type LiveTranscriptSource, + type SearchBackend, +} from '../../src/search/searchService'; +import { + SearchWorkerError, + SearchWorkerHost, +} from '../../src/search/worker/host'; +import type { SearchWorkerRequest } from '../../src/search/worker/protocol'; + +const WS = 'ws_test'; + +const T1 = 1_700_000_000_000; +const T2 = 1_700_000_100_000; +const T3 = 1_700_000_200_000; + +function summary(id: string, title: string, updatedAt = T1): SessionSummary { + return { id, workspaceId: WS, title, createdAt: updatedAt, updatedAt, archived: false }; +} + +function makeBootstrap(home: string): IBootstrapService { + return { + homeDir: home, + scope: (name: string) => name, + } as unknown as IBootstrapService; +} + +function makeSessionIndex(list: ISessionIndex['listRecent']): ISessionIndex { + return { + _serviceBrand: undefined, + prepare: async () => ({ state: 'uninitialized', degradedCount: 0 }), + status: () => ({ state: 'uninitialized', degradedCount: 0 }), + listRecent: list, + get: async () => undefined, + count: async () => 0, + remove: async () => {}, + }; +} + +function staticIndex(summaries: SessionSummary[]): ISessionIndex { + return makeSessionIndex(async () => ({ items: summaries, nextCursor: undefined })); +} + +function userLine(text: string, time: number, origin?: unknown): string { + return JSON.stringify({ + type: 'context.append_message', + time, + message: { + role: 'user', + content: [{ type: 'text', text }], + origin: origin ?? { kind: 'user' }, + }, + }); +} + +function assistantLine(text: string, time: number): string { + return JSON.stringify({ + type: 'context.append_loop_event', + time, + event: { type: 'content.part', part: { type: 'text', text } }, + }); +} + +function stepBeginLine(uuid: string, step: number, time: number): string { + return JSON.stringify({ + type: 'context.append_loop_event', + time, + event: { type: 'step.begin', uuid, turnId: '0', step }, + }); +} + +function assistantStepLine(text: string, stepUuid: string, time: number): string { + return JSON.stringify({ + type: 'context.append_loop_event', + time, + event: { type: 'content.part', stepUuid, part: { type: 'text', text } }, + }); +} + +function rawRecord(value: unknown): string { + return JSON.stringify(value); +} + +async function writeWire( + home: string, + sessionId: string, + agentId: string, + lines: string[], +): Promise { + const dir = join(home, 'sessions', WS, sessionId, 'agents', agentId); + await mkdir(dir, { recursive: true }); + const file = join(dir, 'wire.jsonl'); + await writeFile(file, lines.map((l) => `${l}\n`).join(''), 'utf8'); + return file; +} + +async function writeTitle(home: string, sessionId: string, title: string): Promise { + const dir = join(home, 'sessions', WS, sessionId); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'state.json'), JSON.stringify({ title })); +} + +const noopLog = { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, +} as unknown as ILogService; + +function makeConfig(searchEnabled: boolean): IConfigService { + return { + ready: Promise.resolve(), + get: (domain: string) => (domain === DATABASE_SECTION ? { search: searchEnabled } : undefined), + } as unknown as IConfigService; +} + +function makeService(home: string, index: ISessionIndex): GlobalSearchService { + const service = new GlobalSearchService(index, makeBootstrap(home), noopLog, makeConfig(true)); + service.syncDebounceMs = 0; + return service; +} + +function makeInlineService(home: string, index: ISessionIndex): GlobalSearchService { + const service = new GlobalSearchService(index, makeBootstrap(home), noopLog, makeConfig(false)); + service.syncDebounceMs = 0; + return service; +} + +interface TestDb { + get(key: string): Record | undefined; + set(key: string, value: unknown): Promise; + del(key: string): Promise; + batch(ops: { op: 'set' | 'del'; key: string; value?: unknown }[]): Promise; + query(criteria: { key: { prefix: string }; project?: string[] }): { + key: string; + value: Record; + }[]; + compact(): Promise; + close(): Promise; +} + +type TestableCore = { + db: TestDb | null; + doRefreshReadonly(): Promise; + computeFingerprint(): Promise; + openSearchDb(): Promise; + deleteSessionDocs(db: TestDb, sessionId: string): Promise; + syncSession(db: TestDb, summary: SyncSessionInput): Promise; +}; + +function coreOf(service: GlobalSearchService): TestableCore { + const backend = (service as unknown as { backend: SearchBackend }).backend; + if (!(backend instanceof InlineSearchBackend)) { + throw new Error('this test drives core internals and must use makeInlineService'); + } + return backend.core as unknown as TestableCore; +} + +function syncInput(homeDir: string, s: SessionSummary): SyncSessionInput { + return { + id: s.id, + workspaceId: s.workspaceId, + title: s.title, + updatedAt: s.updatedAt, + dir: join(homeDir, 'sessions', s.workspaceId, s.id), + }; +} + +function syncNow(service: GlobalSearchService): Promise { + return (service as unknown as { ensureSyncStarted(): Promise }).ensureSyncStarted(); +} + +function settleBackend(service: GlobalSearchService): Promise { + return (service as unknown as { ensureBackend(): Promise }).ensureBackend(); +} + +async function settleSync(service: GlobalSearchService): Promise { + await syncNow(service); + await syncNow(service); +} + +function refreshNow(service: GlobalSearchService): Promise { + return (service as unknown as { refreshReadonly(): Promise }).refreshReadonly(); +} + +interface ServiceInternals { + syncPromise: Promise | null; +} + +function internals(service: GlobalSearchService): ServiceInternals { + return service as unknown as ServiceInternals; +} + +async function flush(rounds = 20): Promise { + for (let i = 0; i < rounds; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +function blockFirstCall( + core: TestableCore, + method: 'deleteSessionDocs' | 'computeFingerprint', +): { entered: Promise; release: () => void } { + let enteredResolve!: () => void; + let releaseResolve!: () => void; + const entered = new Promise((resolve) => { + enteredResolve = resolve; + }); + const gate = new Promise((resolve) => { + releaseResolve = resolve; + }); + const host = core as unknown as Record; + const orig = host[method] as (this: unknown, ...args: unknown[]) => Promise; + let first = true; + host[method] = async function (this: unknown, ...args: unknown[]) { + if (first) { + first = false; + enteredResolve(); + await gate; + } + return orig.apply(this, args); + }; + return { entered, release: () => releaseResolve() }; +} + +function recordingLog(): { log: ILogService; warnings: string[] } { + const warnings: string[] = []; + const log = { + error: () => {}, + warn: (message: string, meta?: unknown) => warnings.push(`${message} ${JSON.stringify(meta)}`), + info: () => {}, + debug: () => {}, + } as unknown as ILogService; + return { log, warnings }; +} + +describe('GlobalSearchService', () => { + let home: string | undefined; + const services: GlobalSearchService[] = []; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-kap-search-')); + }); + + afterEach(async () => { + for (const service of services.splice(0)) service.dispose(); + await drainGlobalSearchDisposals(); + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + function track(service: GlobalSearchService): GlobalSearchService { + services.push(service); + return service; + } + + it('indexes user and assistant text and finds Chinese and English terms', async () => { + const s1 = summary('s1', '搜索重构讨论', T1); + await writeTitle(home!, s1.id, s1.title!); + await writeWire(home!, 's1', 'main', [ + userLine('帮我看看苹果怎么挑', T1), + assistantLine('Here is the apple picking guide.', T2), + userLine('忽略我', T3, { kind: 'injection', variant: 'reminder' }), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const cn = await service.search({ query: '苹果' }); + expect(cn.items.length).toBeGreaterThan(0); + const cnHit = cn.items[0]!; + expect(cnHit.sessionId).toBe('s1'); + expect(cnHit.workspaceId).toBe(WS); + expect(cnHit.sessionTitle).toBe('搜索重构讨论'); + expect(cnHit.agentId).toBe('main'); + expect(cnHit.role).toBe('user'); + expect(cnHit.snippet).toContain('苹果'); + expect(cnHit.time).toBe(T1); + expect(cnHit.score).toBeGreaterThan(0); + + const en = await service.search({ query: 'apple' }); + expect(en.items.some((h) => h.role === 'assistant')).toBe(true); + + const injected = await service.search({ query: '忽略我' }); + expect(injected.items).toEqual([]); + }); + + it.each([makeService, makeInlineService])('filters deleted sources before pagination without waiting for a writer sync (%#)', async (make) => { + const removed = summary('removed', 'needle title', T3); + await writeTitle(home!, removed.id, removed.title!); + const retained = summary('retained', 'retained', T1); + await writeWire(home!, removed.id, 'main', [userLine('needle deleted', T3)]); + await writeWire(home!, retained.id, 'main', [userLine('needle first', T1), userLine('needle second', T2)]); + const writer = track(make(home!, staticIndex([removed, retained]))); + await writer.reindex(); + const reader = track(make(home!, staticIndex([removed, retained]))); + await settleSync(reader); + expect((await reader.status()).lifecycle.state).toBe('ready'); + expect((await reader.search({ query: 'needle' })).indexState.state).toBe('readonly'); + await rm(join(home!, 'sessions', WS, removed.id), { recursive: true }); + for (const mode of ['terms', 'literal'] as const) { + const first = await reader.search({ query: 'needle', mode, sort: 'time_desc', pageSize: 1 }); + expect(first.items).toHaveLength(1); + expect(first.items[0]?.sessionId).toBe(retained.id); + expect(first.hasMore).toBe(true); + const second = await reader.search({ query: 'needle', mode, sort: 'time_desc', pageSize: 1, pageToken: first.pageToken }); + expect(second.items).toHaveLength(1); + expect(second.items[0]?.sessionId).toBe(retained.id); + expect(second.items[0]?.time).not.toBe(first.items[0]?.time); + expect(second.hasMore).toBe(false); + } + }); + + it.each([makeService, makeInlineService])('does not serve an old incarnation after the same directory is recreated (%#)', async (make) => { + const s1 = summary('s1', 'original title', T1); + await writeTitle(home!, s1.id, s1.title!); + await writeWire(home!, s1.id, 'main', [userLine('original secret', T1)]); + const writer = track(make(home!, staticIndex([s1]))); + await writer.reindex(); + const reader = track(make(home!, staticIndex([s1]))); + await settleSync(reader); + expect((await reader.search({ query: 'original' })).items.length).toBeGreaterThan(0); + await rm(join(home!, 'sessions', WS, s1.id), { recursive: true }); + await writeWire(home!, s1.id, 'main', [userLine('replacement message', T2)]); + await writeTitle(home!, s1.id, 'replacement title'); + expect((await reader.search({ query: 'original' })).items).toEqual([]); + await settleSync(writer); + await refreshNow(reader); + expect((await reader.search({ query: 'replacement', role: 'user' })).items).toHaveLength(1); + expect((await reader.search({ query: 'original' })).items).toEqual([]); + expect((await reader.search({ query: 'replacement', role: 'user' })).items[0]?.sessionTitle).toBe('replacement title'); + }); + + it.each(['inode', 'birthtime', 'both'])('rejects matching stored identities with unavailable %s and recovers after reindexing', async (missing) => { + const s1 = summary('s1', 'one', T1); + await writeWire(home!, s1.id, 'main', [userLine('original secret', T1)]); + const dir = join(home!, 'sessions', WS, s1.id); + const writer = track(makeInlineService(home!, staticIndex([s1]))); + await writer.reindex(); + const info = await stat(dir, { bigint: true }); + const ino = missing === 'birthtime' ? info.ino : 0n; + const birthtimeNs = missing === 'inode' ? info.birthtimeNs : 0n; + const identity = `${info.dev}:${ino}:${birthtimeNs}`; + const db = coreOf(writer).db!; + for (const row of db.query({ key: { prefix: 's1/' } })) { + await db.set(row.key, { ...row.value, sessionIdentity: identity }); + } + await db.set('\0meta\\session\\s1', { kind: 'sessionMeta', dir, identity }); + const original = fs.stat.bind(fs); + const intercept = vi.spyOn(fs, 'stat').mockImplementation((async (path, options) => { + const result = await original(path, options); + if (path === dir && options?.bigint === true) { + return Object.assign(result, { ino, birthtimeNs }); + } + return result; + }) as typeof fs.stat); + syncBuiltinESMExports(); + const reader = track(makeInlineService(home!, staticIndex([s1]))); + try { + await settleSync(reader); + expect((await reader.search({ query: 'original' })).items).toEqual([]); + await rm(dir, { recursive: true }); + await writeWire(home!, s1.id, 'main', [userLine('replacement message', T2)]); + expect((await reader.search({ query: 'original' })).items).toEqual([]); + await settleSync(writer); + await refreshNow(reader); + expect((await reader.search({ query: 'replacement' })).items).toEqual([]); + expect([...db.query({ key: { prefix: 's1/' } })]).toEqual([]); + expect(await writer.status()).toMatchObject({ sessions: 0, degraded: 'Skipped 1 session(s) during indexing' }); + expect((await reader.search({ query: 'replacement' })).indexState).toMatchObject({ + indexedSessions: 0, degraded: 'Skipped 1 session(s) during indexing', + }); + } finally { + intercept.mockRestore(); + syncBuiltinESMExports(); + } + await settleSync(writer); + await refreshNow(reader); + expect((await reader.search({ query: 'replacement' })).items).toHaveLength(1); + expect(await writer.status()).toMatchObject({ sessions: 1, degraded: undefined }); + expect((await reader.search({ query: 'replacement' })).indexState.degraded).toBeUndefined(); + expect((await reader.search({ query: 'original' })).items).toEqual([]); + }); + + it('keeps a query valid when readonly refresh replaces its handle during source validation', async () => { + const s1 = summary('s1', 'one', T1); + await writeWire(home!, s1.id, 'main', [userLine('needle body', T1)]); + const writer = track(makeInlineService(home!, staticIndex([s1]))); + await writer.reindex(); + const reader = track(makeInlineService(home!, staticIndex([s1]))); + await settleSync(reader); + let enter!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { enter = resolve; }); + const gate = new Promise((resolve) => { release = resolve; }); + const original = fs.stat.bind(fs); + const intercept = vi.spyOn(fs, 'stat').mockImplementation((async (path, options) => { + const result = await original(path, options); + if (path === join(home!, 'sessions', WS, s1.id) && options?.bigint === true) { + enter(); + await gate; + } + return result; + }) as typeof fs.stat); + syncBuiltinESMExports(); + try { + const searching = reader.search({ query: 'needle' }); + await entered; + await coreOf(writer).db!.compact(); + await refreshNow(reader); + release(); + expect((await searching).items).toHaveLength(1); + } finally { + release(); + intercept.mockRestore(); + syncBuiltinESMExports(); + } + }); + + it.each(['resolve', 'reject'])('returns verified partial results when a source stat outlives the deadline and later %ss', async (outcome) => { + const slow = summary('slow', 'slow', T1); + const healthy = summary('healthy', 'healthy', T2); + await writeWire(home!, slow.id, 'main', [userLine('needle slow extra text', T1)]); + await writeWire(home!, healthy.id, 'main', [userLine('needle', T2)]); + const writer = track(makeInlineService(home!, staticIndex([slow, healthy]))); + await writer.reindex(); + const reader = track(makeInlineService(home!, staticIndex([slow, healthy]))); + await settleSync(reader); + expect((await reader.search({ query: 'needle' })).items).toHaveLength(2); + let enter!: () => void; + let release!: () => void; + let finish!: () => void; + const entered = new Promise((resolve) => { enter = resolve; }); + const gate = new Promise((resolve) => { release = resolve; }); + const finished = new Promise((resolve) => { finish = resolve; }); + const original = fs.stat.bind(fs); + const intercept = vi.spyOn(fs, 'stat').mockImplementation((async (path, options) => { + const result = await original(path, options); + if (path === join(home!, 'sessions', WS, slow.id) && options?.bigint === true) { + enter(); + try { + await gate; + if (outcome === 'reject') throw Object.assign(new Error('disconnected source'), { code: 'EIO' }); + } finally { + finish(); + } + } + return result; + }) as typeof fs.stat); + syncBuiltinESMExports(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + reader.queryDeadlineMs = 100; + let completed: Awaited> | undefined; + const searching = reader.search({ query: 'needle', sort: 'time_desc' }).then((page) => { completed = page; }); + try { + await entered; + await vi.advanceTimersByTimeAsync(100); + expect(completed).toMatchObject({ incomplete: 'deadline', items: [{ sessionId: healthy.id }] }); + expect(completed?.items).toHaveLength(1); + release(); + await finished; + await searching; + expect(completed?.items).toHaveLength(1); + } finally { + release(); + await searching.catch(() => {}); + intercept.mockRestore(); + syncBuiltinESMExports(); + vi.useRealTimers(); + } + expect((await reader.search({ query: 'needle' })).items).toHaveLength(2); + }); + + it('bounds outstanding source checks across timed-out searches and verifies fresh state after recovery', async () => { + const s1 = summary('s1', 'one', T1); + await writeWire(home!, s1.id, 'main', [userLine('needle body', T1)]); + const writer = track(makeInlineService(home!, staticIndex([s1]))); + writer.syncDebounceMs = 60_000; + await writer.reindex(); + const reader = track(makeInlineService(home!, staticIndex([s1]))); + await settleSync(reader); + writer.queryDeadlineMs = 30; + reader.queryDeadlineMs = 30; + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let calls = 0; + const dir = join(home!, 'sessions', WS, s1.id); + const original = fs.stat.bind(fs); + const intercept = vi.spyOn(fs, 'stat').mockImplementation((async (path, options) => { + const result = await original(path, options); + if (path === dir && options?.bigint === true) { + calls++; + await gate; + } + return result; + }) as typeof fs.stat); + syncBuiltinESMExports(); + try { + expect(await reader.search({ query: 'needle' })).toMatchObject({ incomplete: 'deadline', items: [] }); + const pages = await Promise.all(Array.from({ length: 8 }, (_, i) => + (i % 2 === 0 ? reader : writer).search({ query: 'needle' }))); + for (const page of pages) expect(page).toMatchObject({ incomplete: 'deadline', items: [] }); + expect(calls).toBe(1); + await rm(dir, { recursive: true }); + release(); + expect((await reader.search({ query: 'needle' })).items).toEqual([]); + } finally { + release(); + intercept.mockRestore(); + syncBuiltinESMExports(); + } + expect((await writer.search({ query: 'needle' })).items).toEqual([]); + }); + + it('fails a query when its session source cannot be verified', async () => { + const s1 = summary('s1', 'one', T1); + await writeWire(home!, s1.id, 'main', [userLine('needle body', T1)]); + const writer = track(makeInlineService(home!, staticIndex([s1]))); + await writer.reindex(); + const reader = track(makeInlineService(home!, staticIndex([s1]))); + await settleSync(reader); + const original = fs.stat.bind(fs); + const intercept = vi.spyOn(fs, 'stat').mockImplementation((async (path, options) => { + if (path === join(home!, 'sessions', WS, s1.id) && options?.bigint === true) { + throw Object.assign(new Error('source unavailable'), { code: 'EACCES' }); + } + return original(path, options); + }) as typeof fs.stat); + syncBuiltinESMExports(); + try { + await expect(reader.search({ query: 'needle' })).rejects.toMatchObject({ reason: 'index_unavailable' }); + } finally { + intercept.mockRestore(); + syncBuiltinESMExports(); + } + }); + + it('updates the displayed title without rewriting unchanged message documents', async () => { + const s1 = summary('s1', 'stale summary', T1); + await writeWire(home!, s1.id, 'main', [userLine('needle body', T1)]); + await writeTitle(home!, s1.id, 'original title'); + const service = track(makeInlineService(home!, staticIndex([s1]))); + await service.reindex(); + expect((await service.search({ query: 'needle' })).items[0]?.sessionTitle).toBe('original title'); + await writeTitle(home!, s1.id, 'renamed title'); + await settleSync(service); + expect((await service.search({ query: 'needle' })).items[0]?.sessionTitle).toBe('renamed title'); + expect((await service.search({ query: 'original', role: 'title' })).items).toEqual([]); + }); + + it.each([false, true])('keeps indexing healthy messages when primary title metadata is damaged (legacy=%s)', async (legacy) => { + const s1 = summary('s1', 'cached title', T1); + const wire = await writeWire(home!, s1.id, 'main', [userLine('needle initial', T1)]); + await writeTitle(home!, s1.id, 'original title'); + const service = track(makeInlineService(home!, staticIndex([s1]))); + await service.reindex(); + await writeFile(join(home!, 'sessions', WS, s1.id, 'state.json'), '{broken'); + if (legacy) { + const dir = join(home!, 'sessions', WS, s1.id, 'session-meta'); + await mkdir(dir); + await writeFile(join(dir, 'state.json'), JSON.stringify({ title: 'legacy title' })); + } + await appendFile(wire, `${userLine('needle appended', T2)}\n`); + await settleSync(service); + const page = await service.search({ query: 'appended' }); + expect(page.items).toHaveLength(1); + expect(page.items[0]?.sessionTitle).toBe(legacy ? 'legacy title' : ''); + }); + + it('rebuilds legacy indexed documents before trusting their session identity', async () => { + const s1 = summary('s1', 'one', T1); + await writeWire(home!, s1.id, 'main', [userLine('needle legacy', T1)]); + const writer = track(makeInlineService(home!, staticIndex([s1]))); + await writer.reindex(); + const db = coreOf(writer).db!; + for (const row of db.query({ key: { prefix: 's1/' } })) { + const { sessionIdentity: _identity, ...legacy } = row.value; + await db.set(row.key, legacy); + } + await db.set('\0meta\\session\\s1', { kind: 'sessionMeta' }); + const reader = track(makeInlineService(home!, staticIndex([s1]))); + await settleSync(reader); + expect((await reader.search({ query: 'needle' })).items).toEqual([]); + await settleSync(writer); + await refreshNow(reader); + expect((await reader.search({ query: 'needle' })).items).toHaveLength(1); + }); + + it('hits session titles as title docs', async () => { + const s1 = summary('s1', '季度总结报告', T1); + await writeTitle(home!, s1.id, s1.title!); + await writeWire(home!, 's1', 'main', [userLine('随便说点什么', T1)]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page = await service.search({ query: '季度' }); + const titleHit = page.items.find((h) => h.role === 'title'); + expect(titleHit).toBeDefined(); + expect(titleHit?.sessionId).toBe('s1'); + expect(titleHit?.snippet).toContain('季度'); + }); + + it('filters by container (session and agent)', async () => { + const s1 = summary('s1', 'one', T1); + const s2 = summary('s2', 'two', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 from s1', T1)]); + await writeWire(home!, 's2', 'main', [userLine('苹果 from s2 main', T1)]); + await writeWire(home!, 's2', 'agent-1', [userLine('苹果 from s2 subagent', T1)]); + const service = track(makeService(home!, staticIndex([s1, s2]))); + await service.reindex(); + + const all = await service.search({ query: '苹果' }); + expect(all.items.length).toBe(3); + + const inS2 = await service.search({ query: '苹果', container: { sessionId: 's2' } }); + expect(inS2.items.length).toBe(2); + expect(inS2.items.every((h) => h.sessionId === 's2')).toBe(true); + + const inSub = await service.search({ + query: '苹果', + container: { sessionId: 's2', agentId: 'agent-1' }, + }); + expect(inSub.items.length).toBe(1); + expect(inSub.items[0]?.agentId).toBe('agent-1'); + }); + + it('filters by role and time range', async () => { + const s1 = summary('s1', 'roles', T1); + await writeWire(home!, 's1', 'main', [ + userLine('苹果 early', T1), + assistantLine('苹果 middle', T2), + userLine('苹果 late', T3), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const users = await service.search({ query: '苹果', role: 'user' }); + expect(users.items.length).toBe(2); + expect(users.items.every((h) => h.role === 'user')).toBe(true); + + const ranged = await service.search({ query: '苹果', startTime: T2, endTime: T2 }); + expect(ranged.items.length).toBe(1); + expect(ranged.items[0]?.time).toBe(T2); + }); + + it('sorts by time in both directions', async () => { + const s1 = summary('s1', 'sort', T1); + await writeWire(home!, 's1', 'main', [ + userLine('苹果 one', T1), + userLine('苹果 two', T2), + userLine('苹果 three', T3), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const desc = await service.search({ query: '苹果', sort: 'time_desc' }); + expect(desc.items.map((h) => h.time)).toEqual([T3, T2, T1]); + const asc = await service.search({ query: '苹果', sort: 'time_asc' }); + expect(asc.items.map((h) => h.time)).toEqual([T1, T2, T3]); + }); + + it('paginates with an opaque cursor and rejects changed conditions', async () => { + const s1 = summary('s1', 'paging', T1); + await writeWire(home!, 's1', 'main', [ + userLine('苹果 one', T1), + userLine('苹果 two', T2), + userLine('苹果 three', T3), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page1 = await service.search({ query: '苹果', sort: 'time_asc', pageSize: 2 }); + expect(page1.items.length).toBe(2); + expect(page1.hasMore).toBe(true); + expect(page1.pageToken).toBeDefined(); + + const page2 = await service.search({ + query: '苹果', + sort: 'time_asc', + pageSize: 2, + pageToken: page1.pageToken, + }); + expect(page2.items.length).toBe(1); + expect(page2.hasMore).toBe(false); + expect(page2.pageToken).toBeUndefined(); + + const times = [...page1.items, ...page2.items].map((h) => h.time); + expect(new Set(times).size).toBe(3); + + await expect( + service.search({ query: '香蕉', sort: 'time_asc', pageToken: page1.pageToken }), + ).rejects.toMatchObject({ reason: 'invalid_page_token' }); + await expect(service.search({ query: '苹果', pageToken: '!!!' })).rejects.toBeInstanceOf( + GlobalSearchError, + ); + }); + + it('reports indexState building before the first full sync and ready after', async () => { + const s1 = summary('s1', 'state', T1); + await writeTitle(home!, s1.id, s1.title!); + await writeWire(home!, 's1', 'main', [userLine('苹果 state', T1)]); + + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const index = makeSessionIndex(async () => { + await gate; + return { items: [s1], nextCursor: undefined }; + }); + const service = track(makeService(home!, index)); + + const building = await service.search({ query: '苹果' }); + expect(building.indexState.state).toBe('building'); + expect(building.items).toEqual([]); + + release(); + await service.reindex(); + const ready = await service.search({ query: '苹果' }); + expect(ready.indexState.state).toBe('ready'); + expect(ready.indexState.indexedSessions).toBe(1); + expect(ready.indexState.totalSessions).toBe(1); + expect(ready.indexState.documents).toBe(2); + }); + + it('drops docs of sessions that disappear between syncs', async () => { + const s1 = summary('s1', 'gone', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 ephemeral', T1)]); + const sessions = [s1]; + const service = track( + makeService( + home!, + makeSessionIndex(async () => ({ items: sessions, nextCursor: undefined })), + ), + ); + await service.reindex(); + expect((await service.search({ query: '苹果' })).items.length).toBe(1); + + sessions.length = 0; + await settleSync(service); + const page = await service.search({ query: '苹果' }); + expect(page.items).toEqual([]); + }); + + it('rescans a wire file that shrank between syncs', async () => { + const s1 = summary('s1', 'shrink', T1); + const file = await writeWire(home!, 's1', 'main', [ + userLine('苹果 old one', T1), + userLine('苹果 old two', T2), + userLine('苹果 old three', T3), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + expect((await service.search({ query: '苹果' })).items.length).toBe(3); + + await writeFile(file, `${userLine('香蕉 fresh', T1)}\n`, 'utf8'); + await settleSync(service); + const stale = await service.search({ query: '苹果' }); + expect(stale.items).toEqual([]); + const fresh = await service.search({ query: '香蕉' }); + expect(fresh.items.length).toBe(1); + expect(fresh.items[0]?.snippet).toContain('fresh'); + }); + + it('does not advance the watermark past an incomplete trailing line', async () => { + const s1 = summary('s1', 'tail', T1); + const file = await writeWire(home!, 's1', 'main', [userLine('苹果 base', T1)]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + await appendFile(file, userLine('苹果 partial', T2), 'utf8'); + await settleSync(service); + expect((await service.search({ query: 'partial' })).items).toEqual([]); + + await appendFile(file, '\n', 'utf8'); + await settleSync(service); + const page = await service.search({ query: 'partial' }); + expect(page.items.length).toBe(1); + expect(page.items[0]?.role).toBe('user'); + }); + + it('sync rounds account truncation, session-local wire failures, and escalating storage failures', async () => { + const s1 = summary('s1', 'budget', T1); + const lines = [ + userLine('苹果 head', T1), + userLine(`苹果 giant ${'x'.repeat(1_700_000)}`, T2), + userLine(`苹果 tail ${'y'.repeat(400_000)}`, T3), + ]; + const s1Wire = await writeWire(home!, 's1', 'main', lines); + const s2 = summary('s2', 'wirefail', T1); + const s2Wire = await writeWire(home!, 's2', 'main', [userLine('苹果 unreachable', T1)]); + const core = new SearchIndexCore({ + indexDir: join(home!, 'search-index'), + log: noopLog, + bootSalt: 'budget-test', + }); + core.syncRoundBytes = 1 << 20; + const input = [syncInput(home!, s1), syncInput(home!, s2)]; + const messageCount = (id: string): number => + core.db?.query({ key: { prefix: `${id}/` }, project: ['kind'] }).filter((row) => row.value.kind === 'message') + .length ?? 0; + try { + const first = await core.sync(input); + expect(first.truncated).toBe(true); + expect(first.failures).toBe(0); + expect(core.fullSyncDone).toBe(false); + expect(messageCount('s1')).toBe(2); + expect(messageCount('s2')).toBe(0); + + const second = await core.sync(input); + expect(second.truncated).toBe(false); + expect(second.failures).toBe(0); + expect(core.fullSyncDone).toBe(true); + expect(messageCount('s1')).toBe(3); + expect(messageCount('s2')).toBe(1); + + core.syncRoundBytes = 64 << 20; + if (process.platform !== 'win32') { + await core.reindex(); + await chmod(s2Wire, 0o000); + for (let round = 0; round < 4; round++) { + const outcome = await core.sync(input); + expect(outcome.failures).toBe(1); + expect(outcome.truncated).toBe(false); + expect(core.fullSyncDone).toBe(false); + } + const skipped = await core.sync(input); + expect(skipped.failures).toBe(0); + expect(core.fullSyncDone).toBe(true); + expect(messageCount('s2')).toBe(0); + expect(messageCount('s1')).toBe(3); + await chmod(s2Wire, 0o644); + } + + await appendFile(s1Wire, `${userLine('苹果 extra', T3 + 1)}\n`, 'utf8'); + (core.db as unknown as { batch: unknown }).batch = () => + Promise.reject(new Error('injected io')); + for (let round = 0; round < 4; round++) { + await expect(core.sync(input)).rejects.toThrow('injected io'); + } + const rebuilt = await core.sync(input); + expect(rebuilt.truncated).toBe(true); + expect(rebuilt.failures).toBe(0); + const converged = await core.sync(input); + expect(converged.truncated).toBe(false); + expect(converged.failures).toBe(0); + expect(core.fullSyncDone).toBe(true); + expect(messageCount('s1')).toBe(4); + expect(messageCount('s2')).toBe(1); + + if (process.platform !== 'win32') { + await appendFile(s2Wire, `${userLine('苹果 cooled', T2)}\n`, 'utf8'); + await chmod(s2Wire, 0o000); + for (let round = 0; round < 4; round++) { + const outcome = await core.sync(input); + expect(outcome.failures).toBe(1); + } + const skipped = await core.sync(input); + expect(skipped.failures).toBe(0); + expect(messageCount('s2')).toBe(1); + + await chmod(s2Wire, 0o644); + const cooling = await core.sync(input); + expect(cooling.failures).toBe(0); + expect(messageCount('s2')).toBe(1); + + const changedInput = [syncInput(home!, s1), { ...syncInput(home!, s2), updatedAt: T1 + 1 }]; + const changed = await core.sync(changedInput); + expect(changed.failures).toBe(0); + expect(messageCount('s2')).toBe(2); + + await appendFile(s2Wire, `${userLine('苹果 retried', T3)}\n`, 'utf8'); + await chmod(s2Wire, 0o000); + for (let round = 0; round < 4; round++) { + const outcome = await core.sync(changedInput); + expect(outcome.failures).toBe(1); + } + const reskipped = await core.sync(changedInput); + expect(reskipped.failures).toBe(0); + expect(messageCount('s2')).toBe(2); + + core.syncSkipCooldownMs = 0; + await chmod(s2Wire, 0o644); + const retried = await core.sync(changedInput); + expect(retried.failures).toBe(0); + expect(messageCount('s2')).toBe(3); + } + } finally { + await chmod(s2Wire, 0o644).catch(() => {}); + core.beginClose(); + await core.close(); + } + }); + + it('indexes legacy root and v2 agents layouts of one session without key collisions', async () => { + const s1 = summary('s1', 'dual layout', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 from agents', T2)]); + await writeFile( + join(home!, 'sessions', WS, 's1', 'wire.jsonl'), + `${userLine('苹果 from root', T1)}\n`, + 'utf8', + ); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page = await service.search({ query: '苹果', sort: 'time_asc', role: 'user' }); + expect(page.items.length).toBe(2); + expect(page.items.every((h) => h.agentId === 'main')).toBe(true); + const snippets = page.items.map((h) => h.snippet); + expect(snippets.some((s) => s.includes('root'))).toBe(true); + expect(snippets.some((s) => s.includes('agents'))).toBe(true); + }); + + it('rejects a pageToken that decodes to a non-object', async () => { + const s1 = summary('s1', 'token', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 token', T1)]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + for (const payload of ['null', '42', '"str"', '[1,2]']) { + const token = Buffer.from(payload).toString('base64url'); + await expect(service.search({ query: '苹果', pageToken: token })).rejects.toMatchObject({ + reason: 'invalid_page_token', + }); + } + }); + + it('drops docs of a wire file that disappears while its session remains', async () => { + const s1 = summary('s1', 'file gone', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 main agent', T1)]); + const subFile = await writeWire(home!, 's1', 'agent-1', [userLine('苹果 sub agent', T2)]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + expect((await service.search({ query: '苹果' })).items.length).toBe(2); + + await rm(subFile); + await settleSync(service); + const page = await service.search({ query: '苹果' }); + expect(page.items.length).toBe(1); + expect(page.items[0]?.agentId).toBe('main'); + }); + + it('runs a second instance read-only and catches up from the WAL', async () => { + const s1 = summary('s1', 'shared', T1); + await writeTitle(home!, s1.id, s1.title!); + const file = await writeWire(home!, 's1', 'main', [userLine('苹果 base', T1)]); + const index = staticIndex([s1]); + + const writer = track(makeInlineService(home!, index)); + await writer.reindex(); + + const reader = track(makeInlineService(home!, index)); + const status = await reader.status(); + expect(status.documents).toBe(2); + + const first = await reader.search({ query: '苹果' }); + expect(first.indexState.state).toBe('readonly'); + expect(first.items.length).toBe(1); + + await appendFile(file, `${userLine('苹果 delta', T2)}\n`, 'utf8'); + await settleSync(writer); + const stalePage = await reader.search({ query: '苹果' }); + expect(stalePage.items.length).toBe(1); + expect(stalePage.indexState.stale).toBe(true); + await refreshNow(reader); + const caughtUp = await reader.search({ query: '苹果' }); + expect(caughtUp.items.length).toBe(2); + expect(caughtUp.items.some((h) => h.snippet.includes('delta'))).toBe(true); + expect(caughtUp.indexState.stale).toBeUndefined(); + + const writerDb = coreOf(writer).db; + await writerDb?.compact(); + await refreshNow(reader); + const afterRotation = await reader.search({ query: '苹果' }); + expect(afterRotation.items.length).toBe(2); + }); + + it('rejects reindex on a read-only instance', { timeout: 30_000 }, async () => { + const s1 = summary('s1', 'lock', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 lock', T1)]); + const index = staticIndex([s1]); + const writer = track(makeService(home!, index)); + await writer.reindex(); + + const reader = track(makeService(home!, index)); + await expect(reader.reindex()).rejects.toMatchObject({ reason: 'readonly_index' }); + }); + + it('serves the building page while the index base is rebuilding, real hits after commit', async () => { + const s1 = summary('s1', 'shared', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 base', T1)]); + const service = track(makeInlineService(home!, staticIndex([s1]))); + await service.reindex(); + + const db = coreOf(service).db as unknown as { textIndexBuilding(name: string): boolean }; + const original = db.textIndexBuilding.bind(db); + db.textIndexBuilding = () => true; + try { + const building = await service.search({ query: '苹果' }); + expect(building.indexState.state).toBe('building'); + expect(building.indexState.stale).toBe(true); + expect(building.items).toEqual([]); + expect(building.pageToken).toBeUndefined(); + const buildingLiteral = await service.search({ query: '苹果', mode: 'literal' }); + expect(buildingLiteral.indexState.state).toBe('building'); + expect(buildingLiteral.items).toEqual([]); + } finally { + db.textIndexBuilding = original; + } + + const ready = await service.search({ query: '苹果' }); + expect(ready.items.length).toBe(1); + expect(ready.indexState.state).toBe('ready'); + }); + + it('read-only instance serves the building page while its base is rebuilding', async () => { + const s1 = summary('s1', 'shared', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 base', T1)]); + const index = staticIndex([s1]); + const writer = track(makeInlineService(home!, index)); + await writer.reindex(); + const reader = track(makeInlineService(home!, index)); + await reader.status(); + + const db = coreOf(reader).db as unknown as { textIndexBuilding(name: string): boolean }; + const original = db.textIndexBuilding.bind(db); + db.textIndexBuilding = () => true; + try { + const building = await reader.search({ query: '苹果' }); + expect(building.indexState.state).toBe('building'); + expect(building.items).toEqual([]); + } finally { + db.textIndexBuilding = original; + } + const ready = await reader.search({ query: '苹果' }); + expect(ready.items.length).toBe(1); + expect(ready.indexState.state).toBe('readonly'); + }); + + it('assigns 0-based turn ordinals to user and assistant hits', async () => { + const s1 = summary('s1', 'turns', T1); + await writeWire(home!, 's1', 'main', [ + userLine('苹果 question zero', T1), + assistantLine('苹果 answer zero', T2), + userLine('苹果 question one', T3), + assistantLine('苹果 answer one', T3 + 1000), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const users = await service.search({ query: '苹果', role: 'user', sort: 'time_asc' }); + expect(users.items.map((h) => h.turn)).toEqual([0, 1]); + const assistants = await service.search({ query: '苹果', role: 'assistant', sort: 'time_asc' }); + expect(assistants.items.map((h) => h.turn)).toEqual([0, 1]); + }); + + it('counts turns independently of indexing (text-less prompts, hidden & marker origins)', async () => { + const s1 = summary('s1', 'counting', T1); + await writeWire(home!, 's1', 'main', [ + rawRecord({ + type: 'context.append_message', + time: T1, + message: { role: 'user', content: [{ type: 'image', source: { kind: 'url', url: 'x' } }] }, + }), + userLine('苹果 injected', T1 + 100, { kind: 'injection', variant: 'reminder' }), + userLine('苹果 continuation', T1 + 200, { + kind: 'system_trigger', + name: 'goal_continuation', + }), + userLine('苹果 skill noise', T1 + 300, { kind: 'skill_activation', trigger: 'model-tool' }), + userLine('苹果 typed', T2, { kind: 'user' }), + userLine('/commit 苹果 ship it', T3, { kind: 'skill_activation', trigger: 'user-slash' }), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page = await service.search({ query: '苹果', sort: 'time_asc' }); + const bySnippet = (needle: string) => + page.items.find((h) => h.snippet.includes(needle) && h.role === 'user'); + expect(bySnippet('injected')).toBeUndefined(); + expect(bySnippet('continuation')).toBeUndefined(); + expect(bySnippet('skill noise')).toBeUndefined(); + expect(bySnippet('typed')?.turn).toBe(2); + expect(bySnippet('ship it')?.turn).toBe(3); + }); + + it('attaches assistant content to a fallback turn when no prompt opened one', async () => { + const s1 = summary('s1', 'fallback', T1); + await writeWire(home!, 's1', 'main', [ + assistantLine('苹果 orphan answer', T1), + userLine('苹果 later question', T2), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page = await service.search({ query: '苹果', sort: 'time_asc' }); + expect(page.items.map((h) => [h.role, h.turn])).toEqual([ + ['assistant', 0], + ['user', 1], + ]); + }); + + it('keeps the turn counter across incremental sync passes', async () => { + const s1 = summary('s1', 'resume', T1); + const file = await writeWire(home!, 's1', 'main', [ + userLine('苹果 first', T1), + assistantLine('苹果 first reply', T2), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + await appendFile( + file, + `${userLine('苹果 second', T3)}\n${assistantLine('苹果 second reply', T3 + 1000)}\n`, + 'utf8', + ); + await settleSync(service); + const page = await service.search({ query: '苹果', sort: 'time_asc' }); + expect(page.items.map((h) => [h.role, h.turn])).toEqual([ + ['user', 0], + ['assistant', 0], + ['user', 1], + ['assistant', 1], + ]); + }); + + it('restarts the turn counter when a shrunk file is rescanned', async () => { + const s1 = summary('s1', 'shrink turns', T1); + const file = await writeWire(home!, 's1', 'main', [ + userLine('苹果 a', T1), + userLine('苹果 b', T2), + userLine('苹果 c', T3), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + expect( + (await service.search({ query: '苹果', sort: 'time_asc' })).items.map((h) => h.turn), + ).toEqual([0, 1, 2]); + + await writeFile(file, `${userLine('苹果 only', T1)}\n`, 'utf8'); + await settleSync(service); + const page = await service.search({ query: '苹果' }); + expect(page.items.length).toBe(1); + expect(page.items[0]?.turn).toBe(0); + }); + + it('rewinds the counter on context.undo and renumbers after it', async () => { + const s1 = summary('s1', 'undo', T1); + await writeWire(home!, 's1', 'main', [ + userLine('苹果 before', T1), + assistantLine('苹果 before reply', T2), + userLine('苹果 undone', T3), + assistantLine('苹果 undone reply', T3 + 1000), + rawRecord({ type: 'context.undo', time: T3 + 2000, count: 1 }), + userLine('苹果 redone', T3 + 3000), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page = await service.search({ query: '苹果', sort: 'time_asc' }); + const bySnippet = (needle: string) => page.items.find((h) => h.snippet.includes(needle)); + expect(bySnippet('before')?.turn).toBe(0); + expect(bySnippet('undone reply')?.turn).toBe(1); + expect(bySnippet('redone')?.turn).toBe(1); + }); + + it('keeps numbering monotonic across context.apply_compaction', async () => { + const s1 = summary('s1', 'compaction', T1); + await writeWire(home!, 's1', 'main', [ + userLine('苹果 before compaction', T1), + assistantLine('苹果 old reply', T2), + rawRecord({ + type: 'context.apply_compaction', + time: T3, + summary: 'condensed', + compactedCount: 2, + }), + userLine('summary', T3 + 1000, { kind: 'compaction_summary' }), + assistantLine('苹果 post-compaction reply', T3 + 1500), + userLine('苹果 after compaction', T3 + 2000), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page = await service.search({ query: '苹果', sort: 'time_asc' }); + const bySnippet = (needle: string) => page.items.find((h) => h.snippet.includes(needle)); + expect(bySnippet('before compaction')?.turn).toBe(0); + expect(bySnippet('old reply')?.turn).toBe(0); + expect(bySnippet('post-compaction reply')?.turn).toBe(0); + expect(bySnippet('after compaction')?.turn).toBe(1); + }); + + it('assigns transcript step ids to assistant hits; user and title hits carry none', async () => { + const s1 = summary('s1', '苹果 steps', T1); + await writeTitle(home!, s1.id, s1.title!); + await writeWire(home!, 's1', 'main', [ + userLine('苹果 question', T1), + stepBeginLine('u1', 1, T1 + 100), + assistantStepLine('苹果 first draft', 'u1', T1 + 200), + stepBeginLine('u2', 2, T1 + 300), + stepBeginLine('u3', 3, T1 + 400), + assistantStepLine('苹果 second draft', 'u3', T1 + 500), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const assistants = await service.search({ query: '苹果', role: 'assistant', sort: 'time_asc' }); + expect(assistants.items.map((h) => [h.turn, h.stepId])).toEqual([ + [0, 't0.1'], + [0, 't0.3'], + ]); + + const users = await service.search({ query: '苹果', role: 'user' }); + expect(users.items[0]?.stepId).toBeUndefined(); + const title = await service.search({ query: '苹果', role: 'title' }); + expect(title.items[0]?.stepId).toBeUndefined(); + }); + + it('omits step ids when no matching step.begin was seen', async () => { + const s1 = summary('s1', 'orphans', T1); + await writeWire(home!, 's1', 'main', [ + userLine('苹果 question', T1), + assistantStepLine('苹果 orphan', 'unknown-uuid', T2), + assistantLine('苹果 legacy', T3), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page = await service.search({ query: '苹果', role: 'assistant', sort: 'time_asc' }); + expect(page.items.map((h) => [h.turn, h.stepId])).toEqual([ + [0, undefined], + [0, undefined], + ]); + }); + + it('resets step numbering at turn boundaries and after an undo', async () => { + const s1 = summary('s1', 'reset', T1); + await writeWire(home!, 's1', 'main', [ + userLine('苹果 first', T1), + stepBeginLine('u1', 1, T1 + 100), + assistantStepLine('苹果 reply one', 'u1', T1 + 200), + userLine('苹果 second', T2), + stepBeginLine('u2', 1, T2 + 100), + assistantStepLine('苹果 reply two', 'u2', T2 + 200), + rawRecord({ type: 'context.undo', time: T2 + 300, count: 1 }), + userLine('苹果 redone', T3), + stepBeginLine('u3', 1, T3 + 100), + assistantStepLine('苹果 redone reply', 'u3', T3 + 200), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page = await service.search({ query: '苹果', role: 'assistant', sort: 'time_asc' }); + expect(page.items.map((h) => h.stepId)).toEqual(['t0.1', 't1.1', 't1.1']); + }); + + it('falls back to counting step.begin records when the wire carries no ordinal', async () => { + const s1 = summary('s1', 'fallback', T1); + await writeWire(home!, 's1', 'main', [ + userLine('苹果 question', T1), + rawRecord({ + type: 'context.append_loop_event', + time: T1 + 100, + event: { type: 'step.begin', uuid: 'u1' }, + }), + assistantStepLine('苹果 reply', 'u1', T1 + 200), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page = await service.search({ query: '苹果', role: 'assistant' }); + expect(page.items[0]?.stepId).toBe('t0.1'); + }); + + it('keeps step attribution across incremental sync passes', async () => { + const s1 = summary('s1', 'resume steps', T1); + const file = await writeWire(home!, 's1', 'main', [ + userLine('苹果 question', T1), + stepBeginLine('u1', 1, T1 + 100), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + await appendFile(file, `${assistantStepLine('苹果 reply', 'u1', T2)}\n`, 'utf8'); + await settleSync(service); + const page = await service.search({ query: '苹果', role: 'assistant' }); + expect(page.items.map((h) => [h.turn, h.stepId])).toEqual([[0, 't0.1']]); + }); + + it('rescans a wire file whose meta predates step tracking', async () => { + const s1 = summary('s1', 'legacy meta', T1); + const file = await writeWire(home!, 's1', 'main', [ + userLine('苹果 question', T1), + stepBeginLine('u1', 1, T1 + 100), + assistantStepLine('苹果 reply one', 'u1', T1 + 200), + ]); + const service = track(makeInlineService(home!, staticIndex([s1]))); + await service.reindex(); + + const db = coreOf(service).db; + expect(db).not.toBeNull(); + const metaRows = db!.query({ key: { prefix: '\0meta\\file\\' } }); + expect(metaRows.length).toBe(1); + for (const row of metaRows) { + const { stepState: _stripped, ...rest } = row.value; + await db!.set(row.key, rest); + } + + await appendFile(file, `${assistantStepLine('苹果 reply two', 'u1', T2)}\n`, 'utf8'); + await settleSync(service); + const page = await service.search({ query: '苹果', role: 'assistant', sort: 'time_asc' }); + expect(page.items.map((h) => h.stepId)).toEqual(['t0.1', 't0.1']); + }); + + describe('literal mode', () => { + async function literalFixture(): Promise { + const s1 = summary('s1', 'literal 会话', T1); + await writeWire(home!, 's1', 'main', [ + userLine('modern C++ patterns', T1), + assistantLine('use foo-bar here', T1 + 100), + userLine('a foo bar without dash', T1 + 200), + userLine('检查项 **已通过** 审核', T1 + 300), + userLine('inline math $\\frac{a}{b}$ here', T1 + 400), + userLine('launch 🚀🎉 today', T1 + 500), + userLine('짧은 한국어 문구 테스트', T1 + 600), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + return service; + } + + it('finds symbol substrings exactly; distractors without the exact substring do not hit', async () => { + const service = await literalFixture(); + + const cpp = await service.search({ query: 'C++', mode: 'literal' }); + expect(cpp.items.length).toBe(1); + expect(cpp.items[0]?.snippet).toContain('C++'); + expect(cpp.items[0]?.score).toBe(0); + expect(cpp.incomplete).toBeUndefined(); + + const dashed = await service.search({ query: 'foo-bar', mode: 'literal' }); + expect(dashed.items.length).toBe(1); + expect(dashed.items[0]?.snippet).toContain('foo-bar'); + const spaced = await service.search({ query: 'foo bar', mode: 'literal' }); + expect(spaced.items.length).toBe(1); + expect(spaced.items[0]?.snippet).toContain('without dash'); + + const passed = await service.search({ query: '**已通过**', mode: 'literal' }); + expect(passed.items.length).toBe(1); + expect(passed.items[0]?.snippet).toContain('已通过'); + + const latex = await service.search({ query: '$\\frac{a}{b}$', mode: 'literal' }); + expect(latex.items.length).toBe(1); + expect(latex.items[0]?.snippet).toContain('\\frac'); + + const korean = await service.search({ query: '한국어 문구', mode: 'literal' }); + expect(korean.items.length).toBe(1); + expect(korean.items[0]?.snippet).toContain('한국어'); + }); + + it('is case-insensitive and orders by time desc regardless of sort', async () => { + const service = await literalFixture(); + const page = await service.search({ query: 'c++', mode: 'literal' }); + expect(page.items.length).toBe(1); + expect(page.items[0]?.snippet).toContain('C++'); + + const foo = await service.search({ query: 'foo', mode: 'literal' }); + expect(foo.items.map((h) => h.time)).toEqual([T1 + 200, T1 + 100]); + }); + + it('serves 2-character queries over the 2-gram path, including emoji pairs', async () => { + const service = await literalFixture(); + const plus = await service.search({ query: '++', mode: 'literal' }); + expect(plus.items.length).toBe(1); + expect(plus.items[0]?.snippet).toContain('C++'); + + const emoji = await service.search({ query: '🚀🎉', mode: 'literal' }); + expect(emoji.items.length).toBe(1); + expect(emoji.items[0]?.snippet).toContain('🚀🎉'); + }); + + it('rejects queries shorter than 2 normalized characters', async () => { + const service = await literalFixture(); + await expect(service.search({ query: 'c', mode: 'literal' })).rejects.toMatchObject({ + reason: 'invalid_query', + }); + await expect(service.search({ query: 'c', mode: 'literal' })).rejects.toThrow( + /literal queries need at least 2 characters/, + ); + const ligature = await service.search({ query: 'ff', mode: 'literal' }); + expect(ligature.items).toEqual([]); + const spaces = await service.search({ query: ' ', mode: 'literal' }); + expect(spaces.items).toEqual([]); + }); + + it('flags candidate-cap truncation as incomplete', async () => { + const s1 = summary('s1', 'capped', T1); + await writeWire(home!, 's1', 'main', [ + userLine('cap-target one', T1), + userLine('cap-target two', T1 + 100), + userLine('cap-target three', T1 + 200), + userLine('cap-target four', T1 + 300), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + service.literalCandidateCap = 2; + + const page = await service.search({ query: 'cap-target', mode: 'literal' }); + expect(page.items.length).toBe(2); + expect(page.incomplete).toBe('candidate_cap'); + expect(page.items.every((h) => h.snippet.includes('cap-target'))).toBe(true); + + const terms = await service.search({ query: 'cap-target' }); + expect(terms.incomplete).toBeUndefined(); + }); + + it('paginates and rejects page tokens after a mode change', async () => { + const s1 = summary('s1', 'paging', T1); + await writeWire(home!, 's1', 'main', [ + userLine('page-target one', T1), + userLine('page-target two', T1 + 100), + userLine('page-target three', T1 + 200), + ]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page1 = await service.search({ query: 'page-target', mode: 'literal', pageSize: 2 }); + expect(page1.items.length).toBe(2); + expect(page1.hasMore).toBe(true); + const page2 = await service.search({ + query: 'page-target', + mode: 'literal', + pageSize: 2, + pageToken: page1.pageToken, + }); + expect(page2.items.length).toBe(1); + expect(page2.hasMore).toBe(false); + const times = [...page1.items, ...page2.items].map((h) => h.time); + expect(new Set(times).size).toBe(3); + + await expect( + service.search({ query: 'page-target', pageToken: page1.pageToken }), + ).rejects.toMatchObject({ reason: 'invalid_page_token' }); + const termsPage = await service.search({ query: 'page-target', pageSize: 2 }); + await expect( + service.search({ query: 'page-target', mode: 'literal', pageToken: termsPage.pageToken }), + ).rejects.toMatchObject({ reason: 'invalid_page_token' }); + }); + + it('keeps terms mode untouched when the query contains symbols', async () => { + const service = await literalFixture(); + const terms = await service.search({ query: 'C++' }); + expect(terms.items.length).toBeGreaterThan(0); + expect(terms.incomplete).toBeUndefined(); + }); + }); + + describe('stage-4 bounded lifecycle', () => { + it('serves the published generation without waiting for a blocked background sync', async () => { + const s1 = summary('s1', 'blocked', T1); + const file = await writeWire(home!, 's1', 'main', [userLine('苹果 base', T1)]); + let block = false; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const index = makeSessionIndex(async () => { + if (block) await gate; + return { items: [s1], nextCursor: undefined }; + }); + const service = track(makeService(home!, index)); + await service.reindex(); + expect((await service.search({ query: '苹果' })).items.length).toBe(1); + await settleSync(service); + + await appendFile(file, `${userLine('苹果 delta', T2)}\n`, 'utf8'); + block = true; + const page = await Promise.race([ + service.search({ query: '苹果' }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('search waited for the blocked sync')), 2_000), + ), + ]); + expect(page.items.length).toBe(1); + expect(page.indexState.stale).toBe(true); + + release(); + await settleSync(service); + const caughtUp = await service.search({ query: '苹果' }); + expect(caughtUp.items.length).toBe(2); + }); + + it('scopes one session sync to its own file-meta keys among 10k sessions', async () => { + const s1 = summary('s1', 'scoped', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 scoped', T1)]); + const service = track(makeInlineService(home!, staticIndex([s1]))); + await service.reindex(); + + const db = coreOf(service).db!; + for (let from = 0; from < 10_000; from += 2_500) { + const ops: { op: 'set'; key: string; value: unknown }[] = []; + for (let i = from; i < from + 2_500; i++) { + for (let j = 0; j < 3; j++) { + const hash = (i * 3 + j).toString(16).padStart(8, '0').repeat(4); + ops.push({ + op: 'set', + key: `\0meta\\file\\fake${i}\\${hash}`, + value: { + kind: 'fileMeta', + sessionId: `fake${i}`, + agentId: 'main', + source: 'agents', + path: `/nonexistent/${i}/${j}`, + offset: 0, + size: 0, + }, + }); + } + } + await db.batch(ops); + } + + let metaRowsScanned = 0; + const origQuery = db.query.bind(db); + db.query = (criteria) => { + const rows = origQuery(criteria); + if (criteria.key.prefix.startsWith('\0meta\\file\\')) metaRowsScanned += rows.length; + return rows; + }; + await coreOf(service).syncSession(db, syncInput(home!, s1)); + expect(metaRowsScanned).toBeLessThanOrEqual(5); + }); + + it('migrates legacy hash-only file-meta keys to the session-scoped format', async () => { + const s1 = summary('s1', 'migration', T1); + const main = await writeWire(home!, 's1', 'main', [userLine('苹果 main', T1)]); + await writeWire(home!, 's1', 'agent-1', [userLine('苹果 sub', T2)]); + const first = track(makeInlineService(home!, staticIndex([s1]))); + await first.reindex(); + expect((await first.search({ query: '苹果' })).items.length).toBe(2); + + const db = coreOf(first).db!; + const metas = db.query({ key: { prefix: '\0meta\\file\\' } }); + expect(metas.length).toBe(2); + const legacyOffsets = new Map(); + for (const row of metas) { + const path = row.value['path'] as string; + const legacyKey = + '\0meta\\file\\' + createHash('sha256').update(path).digest('hex').slice(0, 32); + legacyOffsets.set(legacyKey, row.value['offset'] as number); + await db.del(row.key); + await db.set(legacyKey, row.value); + } + first.dispose(); + await drainGlobalSearchDisposals(); + + const second = track(makeInlineService(home!, staticIndex([s1]))); + await settleSync(second); + const db2 = coreOf(second).db!; + const after = db2.query({ key: { prefix: '\0meta\\file\\' } }); + expect(after.length).toBe(2); + for (const row of after) { + expect(row.key.slice('\0meta\\file\\'.length)).toContain('\\'); + const path = row.value['path'] as string; + const legacyKey = + '\0meta\\file\\' + createHash('sha256').update(path).digest('hex').slice(0, 32); + expect(row.value['offset']).toBe(legacyOffsets.get(legacyKey)); + } + + expect((await second.search({ query: '苹果' })).items.length).toBe(2); + await appendFile(main, `${userLine('苹果 resumed', T3)}\n`, 'utf8'); + await settleSync(second); + const page = await second.search({ query: '苹果' }); + expect(page.items.length).toBe(3); + expect(page.items.some((h) => h.snippet.includes('resumed'))).toBe(true); + }); + + it('paginates by keyset without duplicates or gaps under concurrent additive writes', async () => { + const s1 = summary('s1', 'keyset', T1); + const lines: string[] = []; + for (let i = 0; i < 25; i++) lines.push(userLine(`苹果 doc ${i}`, T1 + i)); + const file = await writeWire(home!, 's1', 'main', lines); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page1 = await service.search({ query: '苹果', sort: 'time_asc', pageSize: 10 }); + expect(page1.items.length).toBe(10); + expect(page1.hasMore).toBe(true); + const decoded = JSON.parse( + Buffer.from(page1.pageToken!, 'base64url').toString('utf8'), + ) as Record; + expect(decoded['v']).toBe(2); + expect(typeof decoded['g']).toBe('string'); + expect(decoded['g']).toMatch(/:\d+$/); + expect(Array.isArray(decoded['b'])).toBe(true); + + const more: string[] = []; + for (let i = 25; i < 30; i++) more.push(`${userLine(`苹果 doc ${i}`, T1 + i)}\n`); + await appendFile(file, more.join(''), 'utf8'); + await settleSync(service); + + const page2 = await service.search({ + query: '苹果', + sort: 'time_asc', + pageSize: 10, + pageToken: page1.pageToken, + }); + const page3 = await service.search({ + query: '苹果', + sort: 'time_asc', + pageSize: 10, + pageToken: page2.pageToken, + }); + expect(page3.hasMore).toBe(false); + + const times = [...page1.items, ...page2.items, ...page3.items].map((h) => h.time); + expect(times).toEqual(Array.from({ length: 30 }, (_, i) => T1 + i)); + expect(new Set(times).size).toBe(30); + }); + + it('rejects page tokens from an older generation after a rescan or a reindex', async () => { + const s1 = summary('s1', 'generation', T1); + const lines: string[] = []; + for (let i = 0; i < 30; i++) lines.push(userLine(`苹果 doc ${i} padding`, T1 + i)); + const file = await writeWire(home!, 's1', 'main', lines); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page1 = await service.search({ query: '苹果', sort: 'time_asc', pageSize: 10 }); + await writeFile( + file, + `${Array.from({ length: 30 }, (_, i) => userLine('苹果 x', T1 + i)).join('\n')}\n`, + 'utf8', + ); + await settleSync(service); + await expect( + service.search({ query: '苹果', sort: 'time_asc', pageToken: page1.pageToken }), + ).rejects.toMatchObject({ reason: 'invalid_page_token' }); + await expect( + service.search({ query: '苹果', sort: 'time_asc', pageToken: page1.pageToken }), + ).rejects.toThrow(/older index generation/); + + const page2 = await service.search({ query: '苹果', sort: 'time_asc', pageSize: 10 }); + await service.reindex(); + await expect( + service.search({ query: '苹果', sort: 'time_asc', pageToken: page2.pageToken }), + ).rejects.toMatchObject({ reason: 'invalid_page_token' }); + }); + + it('terminates a hot 2-character literal query within the postings budget', async () => { + const s1 = summary('s1', 'hot bigram', T1); + const lines: string[] = []; + for (let i = 0; i < 400; i++) lines.push(userLine(`的汉 filler ${i} about stuff`, T1 + i)); + await writeWire(home!, 's1', 'main', lines); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const full = await service.search({ query: '的汉', mode: 'literal' }); + expect(full.incomplete).toBeUndefined(); + expect(full.items.length).toBe(20); + + service.postingsVisitBudget = 50; + const page = await service.search({ query: '的汉', mode: 'literal' }); + expect(page.incomplete).toBe('postings_budget'); + expect(page.items.length).toBeGreaterThan(0); + expect(page.items.every((h) => h.snippet.includes('的汉'))).toBe(true); + }); + + it('exposes degraded state when a read-only refresh fails, and recovers', async () => { + const s1 = summary('s1', 'degraded', T1); + const file = await writeWire(home!, 's1', 'main', [userLine('苹果 base', T1)]); + const index = staticIndex([s1]); + const writer = track(makeInlineService(home!, index)); + await writer.reindex(); + const reader = track(makeInlineService(home!, index)); + await reader.status(); + expect((await reader.search({ query: '苹果' })).items.length).toBe(1); + + const original = coreOf(reader).doRefreshReadonly; + coreOf(reader).doRefreshReadonly = async () => { + throw new Error('refresh boom'); + }; + await appendFile(file, `${userLine('苹果 delta', T2)}\n`, 'utf8'); + await settleSync(writer); + + const stale = await reader.search({ query: '苹果' }); + expect(stale.items.length).toBe(1); + await refreshNow(reader); + const degraded = await reader.search({ query: '苹果' }); + expect(degraded.indexState.state).toBe('readonly'); + expect(degraded.indexState.degraded).toBe('refresh boom'); + expect(degraded.items.length).toBe(1); + + coreOf(reader).doRefreshReadonly = original; + await refreshNow(reader); + const healed = await reader.search({ query: '苹果' }); + expect(healed.indexState.degraded).toBeUndefined(); + expect(healed.items.length).toBe(2); + }); + + it('accepts legacy v1 offset tokens and upgrades them to v2 keyset tokens', async () => { + const s1 = summary('s1', 'legacy token', T1); + const lines: string[] = []; + for (let i = 0; i < 30; i++) lines.push(userLine(`苹果 legacy ${i}`, T1 + i)); + await writeWire(home!, 's1', 'main', lines); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page1 = await service.search({ query: '苹果', sort: 'time_asc', pageSize: 10 }); + const v2 = JSON.parse( + Buffer.from(page1.pageToken!, 'base64url').toString('utf8'), + ) as { v: number; f: string }; + expect(v2.v).toBe(2); + + const legacyToken = Buffer.from(JSON.stringify({ f: v2.f, s: 10 })).toString('base64url'); + const page2 = await service.search({ + query: '苹果', + sort: 'time_asc', + pageSize: 10, + pageToken: legacyToken, + }); + expect(page2.items.map((h) => h.time)).toEqual( + Array.from({ length: 10 }, (_, i) => T1 + 10 + i), + ); + expect(page2.hasMore).toBe(true); + const upgraded = JSON.parse( + Buffer.from(page2.pageToken!, 'base64url').toString('utf8'), + ) as { v: number }; + expect(upgraded.v).toBe(2); + + const page3 = await service.search({ + query: '苹果', + sort: 'time_asc', + pageSize: 10, + pageToken: page2.pageToken, + }); + expect(page3.items.map((h) => h.time)).toEqual( + Array.from({ length: 10 }, (_, i) => T1 + 20 + i), + ); + expect(page3.hasMore).toBe(false); + }); + + it('paginates score sort by (score, time, key) without duplicates', async () => { + const s1 = summary('s1', 'score pages', T1); + const lines: string[] = []; + for (let i = 0; i < 30; i++) { + lines.push(userLine(`${'苹果 '.repeat((i % 5) + 1)}doc ${i}`, T1 + i)); + } + await writeWire(home!, 's1', 'main', lines); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const seen = new Set(); + const boundaryScores: number[] = []; + let token: string | undefined; + for (let p = 0; p < 3; p++) { + const page: Awaited> = await service.search({ + query: '苹果', + sort: 'score', + pageSize: 10, + pageToken: token, + }); + expect(page.items.length).toBe(10); + for (const hit of page.items) { + expect(seen.has(hit.time)).toBe(false); + seen.add(hit.time); + } + for (let i = 1; i < page.items.length; i++) { + expect(page.items[i]!.score).toBeLessThanOrEqual(page.items[i - 1]!.score); + } + boundaryScores.push(page.items[0]!.score); + token = page.pageToken; + } + for (let i = 1; i < boundaryScores.length; i++) { + expect(boundaryScores[i]!).toBeLessThanOrEqual(boundaryScores[i - 1]!); + } + expect(seen.size).toBe(30); + expect(token).toBeUndefined(); + }); + + it('self-heals a failed open through search traffic', async () => { + const s1 = summary('s1', 'heal', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 heal', T1)]); + const service = track(makeInlineService(home!, staticIndex([s1]))); + await settleBackend(service); + + const core = coreOf(service); + const origOpen = core.openSearchDb; + let failOpen = true; + core.openSearchDb = async () => { + if (failOpen) throw new Error('open boom'); + return origOpen.call(core); + }; + + const building = await service.search({ query: '苹果' }); + expect(building.indexState.state).toBe('building'); + await internals(service).syncPromise?.catch(() => {}); + + await expect(service.search({ query: '苹果' })).rejects.toMatchObject({ + reason: 'index_unavailable', + }); + await expect(service.search({ query: '苹果' })).rejects.toThrow(/failed to open: open boom/); + await internals(service).syncPromise?.catch(() => {}); + + failOpen = false; + await expect(service.search({ query: '苹果' })).rejects.toMatchObject({ + reason: 'index_unavailable', + }); + await internals(service).syncPromise; + + const page = await service.search({ query: '苹果' }); + expect(page.items.length).toBe(1); + expect(page.indexState.state).toBe('ready'); + }); + + it('re-serves from the swapped handle when a background refresh lands mid-search', async () => { + const s1 = summary('s1', 'swap', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 base', T1)]); + const index = staticIndex([s1]); + const writer = track(makeInlineService(home!, index)); + await writer.reindex(); + const reader = track(makeInlineService(home!, index)); + await reader.status(); + expect((await reader.search({ query: '苹果' })).items.length).toBe(1); + + await coreOf(writer).db!.compact(); + + const ri = coreOf(reader); + const origFp = ri.computeFingerprint.bind(ri); + let fpCalls = 0; + let releaseProbe!: () => void; + const probeGate = new Promise((resolve) => { + releaseProbe = resolve; + }); + ri.computeFingerprint = async () => { + fpCalls++; + if (fpCalls === 1) await probeGate; + return origFp(); + }; + + const searchPromise = reader.search({ query: '苹果' }); + for (let i = 0; i < 1_000 && fpCalls === 0; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(fpCalls).toBe(1); + + await refreshNow(reader); + releaseProbe(); + + const page = await searchPromise; + expect(page.items.length).toBe(1); + expect(page.indexState.state).toBe('readonly'); + }); + + it('rejects over-budget queries: too many terms, oversized literal', async () => { + const s1 = summary('s1', 'budget', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 budget', T1)]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + service.maxQueryTerms = 3; + await expect(service.search({ query: 'aa bb cc dd' })).rejects.toMatchObject({ + reason: 'invalid_query', + }); + await expect(service.search({ query: 'aa bb cc dd' })).rejects.toThrow(/too many terms/); + expect((await service.search({ query: 'aa aa bb cc' })).items).toEqual([]); + + const oversized = 'x'.repeat(1_025); + await expect(service.search({ query: oversized, mode: 'literal' })).rejects.toMatchObject({ + reason: 'invalid_query', + }); + await expect(service.search({ query: oversized, mode: 'literal' })).rejects.toThrow( + /limited to 1024 characters/, + ); + }); + + it('flags deadline and text-budget stops as incomplete instead of truncating silently', async () => { + const s1 = summary('s1', 'deadline', T1); + const lines: string[] = []; + for (let i = 0; i < 200; i++) lines.push(userLine(`苹果 deadline ${i}`, T1 + i)); + await writeWire(home!, 's1', 'main', lines); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + service.queryDeadlineMs = -1; + const stopped = await service.search({ query: '苹果' }); + expect(stopped.incomplete).toBe('deadline'); + expect(stopped.items).toEqual([]); + service.queryDeadlineMs = 500; + const complete = await service.search({ query: '苹果' }); + expect(complete.incomplete).toBeUndefined(); + expect(complete.items.length).toBe(20); + + service.queryTextBudgetChars = 50; + const textStopped = await service.search({ query: '苹果', mode: 'literal' }); + expect(textStopped.incomplete).toBe('deadline'); + expect(textStopped.items.length).toBeLessThan(20); + service.queryTextBudgetChars = 16_000_000; + const textComplete = await service.search({ query: '苹果', mode: 'literal' }); + expect(textComplete.incomplete).toBeUndefined(); + }); + }); + + describe('live route', () => { + function gettableIndex(summaries: SessionSummary[]): ISessionIndex { + const byId = new Map(summaries.map((s) => [s.id, s])); + return { + _serviceBrand: undefined, + prepare: async () => ({ state: 'uninitialized', degradedCount: 0 }), + status: () => ({ state: 'uninitialized', degradedCount: 0 }), + listRecent: async () => ({ items: summaries, nextCursor: undefined }), + get: async (id) => byId.get(id), + count: async () => summaries.length, + remove: async () => {}, + }; + } + + interface LiveSourceCalls { + whenReady: string[]; + ensureAgentHistory: [string, string][]; + } + + function fakeLiveSource( + stores: Map, + calls?: LiveSourceCalls, + ): LiveTranscriptSource { + return { + forSessionLive: (sessionId) => stores.get(sessionId), + whenReady: async (sessionId) => { + calls?.whenReady.push(sessionId); + }, + ensureAgentHistory: async (sessionId, agentId) => { + calls?.ensureAgentHistory.push([sessionId, agentId]); + }, + }; + } + + function makeLiveStore(sessionId: string): TranscriptStore { + const store = new TranscriptStore(sessionId); + store.ensureAgent('main', { agentId: 'main', type: 'main' }); + store.getAgent('main')!.apply([ + { + op: 'turn.upsert', + turn: { + kind: 'turn', + turnId: 't0', + ordinal: 0, + state: 'completed', + origin: { kind: 'user' }, + prompt: '帮我看看苹果怎么挑', + startedAt: new Date(T1).toISOString(), + }, + }, + { + op: 'step.upsert', + turnId: 't0', + step: { + kind: 'step', + stepId: 't0.1', + turnId: 't0', + ordinal: 1, + state: 'completed', + startedAt: new Date(T2).toISOString(), + }, + }, + { + op: 'frame.upsert', + turnId: 't0', + stepId: 't0.1', + frame: { kind: 'thinking', frameId: 't0.1.f1', text: '苹果 thinking 不可见' }, + }, + { + op: 'frame.upsert', + turnId: 't0', + stepId: 't0.1', + frame: { + kind: 'tool', + frameId: 't0.1.f2', + toolCallId: 'call-1', + name: 'Read', + state: 'done', + }, + }, + { + op: 'frame.upsert', + turnId: 't0', + stepId: 't0.1', + frame: { + kind: 'text', + frameId: 't0.1.f3', + role: 'assistant', + text: '苹果要挑红富士。', + }, + }, + ]); + return store; + } + + function addLiveTurn( + store: TranscriptStore, + agentId: string, + turn: { + ordinal: number; + startedAt: number; + prompt?: string; + state?: 'running' | 'completed'; + steps?: readonly { + stepId: string; + startedAt?: number; + endedAt?: number; + state?: 'running' | 'completed'; + texts?: readonly string[]; + }[]; + }, + ): void { + const turnId = `t${turn.ordinal}`; + const ops: TranscriptOperation[] = [ + { + op: 'turn.upsert', + turn: { + kind: 'turn', + turnId, + ordinal: turn.ordinal, + state: turn.state ?? 'completed', + origin: { kind: 'user' }, + prompt: turn.prompt, + startedAt: new Date(turn.startedAt).toISOString(), + }, + }, + ]; + for (const step of turn.steps ?? []) { + ops.push({ + op: 'step.upsert', + turnId, + step: { + kind: 'step', + stepId: step.stepId, + turnId, + ordinal: Number(step.stepId.split('.')[1] ?? 0), + state: step.state ?? 'completed', + startedAt: + step.startedAt !== undefined ? new Date(step.startedAt).toISOString() : undefined, + endedAt: step.endedAt !== undefined ? new Date(step.endedAt).toISOString() : undefined, + }, + }); + (step.texts ?? []).forEach((text, i) => { + ops.push({ + op: 'frame.upsert', + turnId, + stepId: step.stepId, + frame: { + kind: 'text', + frameId: `${step.stepId}.f${i}`, + role: 'assistant', + text, + }, + }); + }); + } + store.getAgent(agentId)!.apply(ops); + } + + it('serves container-scoped literal queries from the live transcript store', async () => { + const s1 = summary('s1', '苹果标题', T1); + const stores = new Map([['s1', makeLiveStore('s1')]]); + const calls: LiveSourceCalls = { whenReady: [], ensureAgentHistory: [] }; + const service = track(makeService(home!, gettableIndex([s1]))); + service.setLiveTranscriptSource(fakeLiveSource(stores, calls)); + + const page = await service.search({ + query: '苹果', + mode: 'literal', + container: { sessionId: 's1' }, + }); + expect(page.source).toBe('live'); + expect(page.indexState).toEqual({ + state: 'ready', + indexedSessions: 1, + totalSessions: 1, + documents: 3, + }); + expect(calls.whenReady).toEqual(['s1']); + expect(calls.ensureAgentHistory).toEqual([['s1', 'main']]); + + const user = page.items.find((h) => h.role === 'user'); + expect(user).toBeDefined(); + expect(user!.sessionId).toBe('s1'); + expect(user!.workspaceId).toBe(WS); + expect(user!.sessionTitle).toBe('苹果标题'); + expect(user!.agentId).toBe('main'); + expect(user!.turn).toBe(0); + expect(user!.stepId).toBeUndefined(); + expect(user!.time).toBe(T1); + expect(user!.snippet).toContain('苹果'); + + const assistant = page.items.find((h) => h.role === 'assistant'); + expect(assistant).toBeDefined(); + expect(assistant!.turn).toBe(0); + expect(assistant!.stepId).toBe('t0.1'); + expect(assistant!.time).toBe(T2); + + const title = page.items.find((h) => h.role === 'title'); + expect(title).toBeDefined(); + expect(title!.snippet).toBe('苹果标题'); + + const thinking = await service.search({ + query: '不可见', + mode: 'literal', + container: { sessionId: 's1' }, + }); + expect(thinking.items).toEqual([]); + }); + + it('accepts single-character literal queries on the live route', async () => { + const s1 = summary('s1', '苹果标题', T1); + const service = track(makeService(home!, gettableIndex([s1]))); + service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', makeLiveStore('s1')]]))); + + const page = await service.search({ + query: '苹', + mode: 'literal', + container: { sessionId: 's1' }, + }); + expect(page.source).toBe('live'); + expect(page.items.length).toBe(3); + expect(page.items.map((h) => h.role).toSorted()).toEqual(['assistant', 'title', 'user']); + + await expect(service.search({ query: '苹', mode: 'literal' })).rejects.toMatchObject({ + reason: 'invalid_query', + }); + }); + + it('falls back to the index route when no source is wired or the session is not live', async () => { + const s1 = summary('s1', 'fallback', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 from index', T1)]); + const service = track(makeService(home!, gettableIndex([s1]))); + await service.reindex(); + + const unwired = await service.search({ + query: '苹果', + mode: 'literal', + container: { sessionId: 's1' }, + }); + expect(unwired.source).toBe('index'); + expect(unwired.items.length).toBe(1); + + service.setLiveTranscriptSource(fakeLiveSource(new Map())); + const notLive = await service.search({ + query: '苹果', + mode: 'literal', + container: { sessionId: 's1' }, + }); + expect(notLive.source).toBe('index'); + expect(notLive.items.length).toBe(1); + }); + + it('serves terms queries from the live store and orders hits by tf score', async () => { + const s1 = summary('s1', '无关标题', T1); + const store = new TranscriptStore('s1'); + store.ensureAgent('main', { agentId: 'main', type: 'main' }); + addLiveTurn(store, 'main', { ordinal: 0, startedAt: T1, prompt: '苹果怎么挑' }); + addLiveTurn(store, 'main', { ordinal: 1, startedAt: T2, prompt: '苹果苹果都要' }); + const service = track(makeService(home!, gettableIndex([s1]))); + service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', store]]))); + + const page = await service.search({ query: '苹果', container: { sessionId: 's1' } }); + expect(page.source).toBe('live'); + expect(page.items.length).toBe(2); + expect(page.items[0]!.time).toBe(T2); + expect(page.items[1]!.time).toBe(T1); + expect(page.items[0]!.score).toBeGreaterThan(page.items[1]!.score); + expect(page.items[1]!.score).toBeGreaterThan(0); + + const dup = await service.search({ query: '苹果 苹果', container: { sessionId: 's1' } }); + expect(dup.items.map((h) => h.time)).toEqual(page.items.map((h) => h.time)); + }); + + it('returns matching terms result sets on both routes for equivalent data', async () => { + const s1 = summary('s1', '无关标题', T1); + await writeWire(home!, 's1', 'main', [ + userLine('帮我看看苹果怎么挑', T1), + stepBeginLine('u1', 1, T1 + 100), + assistantStepLine('苹果要挑红富士。', 'u1', T2), + ]); + const stores = new Map([['s1', makeLiveStore('s1')]]); + const service = track(makeService(home!, gettableIndex([s1]))); + await service.reindex(); + service.setLiveTranscriptSource(fakeLiveSource(stores)); + + const query = { query: '苹果', container: { sessionId: 's1' } }; + const live = await service.search(query); + expect(live.source).toBe('live'); + expect(live.items.length).toBe(2); + + stores.delete('s1'); + const index = await service.search(query); + expect(index.source).toBe('index'); + expect(index.items.length).toBe(2); + + const identity = (page: typeof live) => + page.items + .map((h) => ({ + sessionId: h.sessionId, + agentId: h.agentId, + role: h.role, + time: h.time, + turn: h.turn, + stepId: h.stepId, + })) + .sort((a, b) => a.time - b.time); + expect(identity(live)).toEqual(identity(index)); + for (const page of [live, index]) { + for (let i = 1; i < page.items.length; i++) { + expect(page.items[i - 1]!.score).toBeGreaterThanOrEqual(page.items[i]!.score); + } + } + }); + + it('applies role, time, agent and sort filters on the live route', async () => { + const s1 = summary('s1', '', T1); + const store = new TranscriptStore('s1'); + store.ensureAgent('main', { agentId: 'main', type: 'main' }); + store.ensureAgent('sub', { agentId: 'sub', type: 'sub' }); + addLiveTurn(store, 'main', { + ordinal: 0, + startedAt: T1, + prompt: '苹果 user question', + steps: [{ stepId: 't0.1', startedAt: T2, texts: ['苹果 assistant answer'] }], + }); + addLiveTurn(store, 'sub', { ordinal: 0, startedAt: T3, prompt: '苹果 subagent prompt' }); + const service = track(makeService(home!, gettableIndex([s1]))); + service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', store]]))); + + const base = { query: '苹果', container: { sessionId: 's1' } }; + const users = await service.search({ ...base, role: 'user' }); + expect(users.items.length).toBe(2); + expect(users.items.every((h) => h.role === 'user')).toBe(true); + + const assistants = await service.search({ ...base, role: 'assistant' }); + expect(assistants.items.length).toBe(1); + expect(assistants.items[0]!.stepId).toBe('t0.1'); + + const ranged = await service.search({ ...base, startTime: T2, endTime: T2 }); + expect(ranged.items.length).toBe(1); + expect(ranged.items[0]!.time).toBe(T2); + + const subOnly = await service.search({ + ...base, + container: { sessionId: 's1', agentId: 'sub' }, + }); + expect(subOnly.items.length).toBe(1); + expect(subOnly.items[0]!.agentId).toBe('sub'); + + const asc = await service.search({ ...base, sort: 'time_asc' }); + expect(asc.items.map((h) => h.time)).toEqual([T1, T2, T3]); + const desc = await service.search({ ...base, sort: 'time_desc' }); + expect(desc.items.map((h) => h.time)).toEqual([T3, T2, T1]); + }); + + it('hits the session title doc on the live route (terms mode)', async () => { + const s1 = summary('s1', '苹果标题', T1); + const store = new TranscriptStore('s1'); + store.ensureAgent('main', { agentId: 'main', type: 'main' }); + addLiveTurn(store, 'main', { ordinal: 0, startedAt: T1, prompt: '随便聊聊' }); + const service = track(makeService(home!, gettableIndex([s1]))); + service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', store]]))); + + const page = await service.search({ query: '苹果', container: { sessionId: 's1' } }); + expect(page.source).toBe('live'); + expect(page.items.length).toBe(1); + const hit = page.items[0]!; + expect(hit.role).toBe('title'); + expect(hit.agentId).toBe(''); + expect(hit.sessionId).toBe('s1'); + expect(hit.snippet).toBe('苹果标题'); + }); + + it('scopes container.agentId queries to that agent only', async () => { + const s1 = summary('s1', '', T1); + const store = new TranscriptStore('s1'); + store.ensureAgent('main', { agentId: 'main', type: 'main' }); + store.ensureAgent('sub', { agentId: 'sub', type: 'sub' }); + addLiveTurn(store, 'main', { ordinal: 0, startedAt: T1, prompt: '苹果 from main' }); + addLiveTurn(store, 'sub', { ordinal: 0, startedAt: T2, prompt: '苹果 from sub' }); + const calls: LiveSourceCalls = { whenReady: [], ensureAgentHistory: [] }; + const service = track(makeService(home!, gettableIndex([s1]))); + service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', store]]), calls)); + + const page = await service.search({ + query: '苹果', + container: { sessionId: 's1', agentId: 'sub' }, + }); + expect(page.source).toBe('live'); + expect(calls.whenReady).toEqual(['s1']); + expect(calls.ensureAgentHistory).toEqual([['s1', 'sub']]); + expect(page.indexState.documents).toBe(1); + expect(page.items.length).toBe(1); + expect(page.items[0]!.agentId).toBe('sub'); + }); + + it('handles an empty roster, prompt-less turns and empty text frames', async () => { + const s1 = summary('s1', '', T1); + const calls: LiveSourceCalls = { whenReady: [], ensureAgentHistory: [] }; + const service = track(makeService(home!, gettableIndex([s1]))); + const stores = new Map([['s1', new TranscriptStore('s1')]]); + service.setLiveTranscriptSource(fakeLiveSource(stores, calls)); + + const empty = await service.search({ query: '苹果', container: { sessionId: 's1' } }); + expect(empty.source).toBe('live'); + expect(empty.items).toEqual([]); + expect(empty.indexState.documents).toBe(0); + expect(calls.whenReady).toEqual(['s1']); + expect(calls.ensureAgentHistory).toEqual([]); + + const store = new TranscriptStore('s1'); + store.ensureAgent('main', { agentId: 'main', type: 'main' }); + addLiveTurn(store, 'main', { + ordinal: 0, + startedAt: T1, + steps: [{ stepId: 't0.1', startedAt: T1, texts: ['', ' '] }], + }); + addLiveTurn(store, 'main', { ordinal: 1, startedAt: T2, prompt: '苹果 survives' }); + stores.set('s1', store); + const page = await service.search({ query: '苹果', container: { sessionId: 's1' } }); + expect(page.items.length).toBe(1); + expect(page.items[0]!.role).toBe('user'); + expect(page.indexState.documents).toBe(1); + }); + + it('does not fall back to the index when the live route fails', async () => { + const s1 = summary('s1', 'boom', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 from index', T1)]); + const service = track(makeService(home!, gettableIndex([s1]))); + await service.reindex(); + + const store = makeLiveStore('s1'); + service.setLiveTranscriptSource({ + forSessionLive: (sessionId) => (sessionId === 's1' ? store : undefined), + whenReady: async () => { + throw new Error('backfill boom'); + }, + ensureAgentHistory: async () => {}, + }); + + await expect( + service.search({ query: '苹果', container: { sessionId: 's1' } }), + ).rejects.toThrow('backfill boom'); + }); + + it('searches the partial text of an in-flight turn', async () => { + const s1 = summary('s1', '', T1); + const store = new TranscriptStore('s1'); + store.ensureAgent('main', { agentId: 'main', type: 'main' }); + addLiveTurn(store, 'main', { + ordinal: 0, + startedAt: T1, + state: 'running', + prompt: '苹果 running prompt', + steps: [{ stepId: 't0.1', startedAt: T2, state: 'running', texts: ['苹果 partial answer'] }], + }); + const service = track(makeService(home!, gettableIndex([s1]))); + service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', store]]))); + + const page = await service.search({ query: '苹果', container: { sessionId: 's1' } }); + expect(page.source).toBe('live'); + expect(page.items.length).toBe(2); + expect(page.items.some((h) => h.role === 'user' && h.snippet.includes('running'))).toBe(true); + expect( + page.items.some((h) => h.role === 'assistant' && h.snippet.includes('partial')), + ).toBe(true); + }); + + it('matches nothing for a query that tokenizes to zero terms', async () => { + const s1 = summary('s1', '', T1); + const stores = new Map([['s1', makeLiveStore('s1')]]); + const service = track(makeService(home!, gettableIndex([s1]))); + service.setLiveTranscriptSource(fakeLiveSource(stores)); + + const page = await service.search({ query: '+++', container: { sessionId: 's1' } }); + expect(page.source).toBe('live'); + expect(page.items).toEqual([]); + expect(page.hasMore).toBe(false); + }); + + it('rejects page tokens across a route flip (the fingerprint covers the source)', async () => { + const s1 = summary('s1', 'flip', T1); + await writeWire(home!, 's1', 'main', [ + userLine('苹果 index one', T1), + assistantLine('苹果 index two', T2), + ]); + const stores = new Map([['s1', makeLiveStore('s1')]]); + const service = track(makeService(home!, gettableIndex([s1]))); + await service.reindex(); + service.setLiveTranscriptSource(fakeLiveSource(stores)); + + const query = { + query: '苹果', + mode: 'literal' as const, + container: { sessionId: 's1' }, + pageSize: 1, + }; + const livePage = await service.search(query); + expect(livePage.source).toBe('live'); + expect(livePage.hasMore).toBe(true); + + stores.delete('s1'); + await expect( + service.search({ ...query, pageToken: livePage.pageToken }), + ).rejects.toMatchObject({ reason: 'invalid_page_token' }); + + const indexPage = await service.search(query); + expect(indexPage.source).toBe('index'); + expect(indexPage.hasMore).toBe(true); + stores.set('s1', makeLiveStore('s1')); + await expect( + service.search({ ...query, pageToken: indexPage.pageToken }), + ).rejects.toMatchObject({ reason: 'invalid_page_token' }); + }); + + it('returns identical literal results on both routes for equivalent data', async () => { + const s1 = summary('s1', '无关标题', T1); + await writeTitle(home!, s1.id, s1.title!); + await writeWire(home!, 's1', 'main', [ + userLine('帮我看看苹果怎么挑', T1), + stepBeginLine('u1', 1, T1 + 100), + assistantStepLine('苹果要挑红富士。', 'u1', T2), + ]); + const stores = new Map([['s1', makeLiveStore('s1')]]); + const service = track(makeService(home!, gettableIndex([s1]))); + await service.reindex(); + service.setLiveTranscriptSource(fakeLiveSource(stores)); + + const query = { query: '苹果', mode: 'literal' as const, container: { sessionId: 's1' } }; + const live = await service.search(query); + expect(live.source).toBe('live'); + + stores.delete('s1'); + const index = await service.search(query); + expect(index.source).toBe('index'); + + const project = (page: typeof live) => + page.items.map((h) => ({ + sessionId: h.sessionId, + workspaceId: h.workspaceId, + sessionTitle: h.sessionTitle, + agentId: h.agentId, + role: h.role, + snippet: h.snippet, + time: h.time, + turn: h.turn, + stepId: h.stepId, + score: h.score, + })); + expect(project(live)).toEqual(project(index)); + }); + }); + + describe('lifecycle drain correctness (plan 13)', () => { + it('closes the handle when post-open index setup fails, and the next open becomes the writer again (review #19)', async () => { + const s1 = summary('s1', 'open failure', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 recovery', T1)]); + const service = track(makeInlineService(home!, staticIndex([s1]))); + + const spy = vi + .spyOn(MiniDb.prototype, 'createTextIndex') + .mockRejectedValueOnce(new Error('injected createTextIndex failure')); + await expect(service.reindex()).rejects.toThrow('injected createTextIndex failure'); + spy.mockRestore(); + + expect(coreOf(service).db).toBeNull(); + + await service.reindex(); + const db = coreOf(service).db; + expect(db).not.toBeNull(); + expect((db as unknown as { readOnly: boolean }).readOnly).toBe(false); + expect((await service.search({ query: '苹果' })).items.length).toBe(1); + }); + + it('dispose drains an in-flight sync before closing the db; the deleteSessionDocs/STATS_KEY windows are gated (review #20)', async () => { + const s1 = summary('s1', 'drain sync', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 drain', T1)]); + const { log, warnings } = recordingLog(); + const sessions = [s1]; + const service = new GlobalSearchService( + makeSessionIndex(async () => ({ items: sessions, nextCursor: undefined })), + makeBootstrap(home!), + log, + makeConfig(false), + ); + service.syncDebounceMs = 0; + track(service); + await service.reindex(); + expect((await service.search({ query: '苹果' })).items.length).toBe(1); + await settleSync(service); + + const db = coreOf(service).db!; + const setKeys: string[] = []; + const origSet = db.set.bind(db); + db.set = async (key: string, value: unknown) => { + setKeys.push(key); + return origSet(key, value); + }; + + sessions.length = 0; + const blocked = blockFirstCall(coreOf(service), 'deleteSessionDocs'); + const sync = syncNow(service); + await blocked.entered; + service.dispose(); + + let drained = false; + const drain = drainGlobalSearchDisposals().then(() => { + drained = true; + }); + await flush(); + expect(drained).toBe(false); + + blocked.release(); + await sync; + await drain; + expect(drained).toBe(true); + expect(coreOf(service).db).toBeNull(); + expect(setKeys).not.toContain('\0meta\\stats'); + expect(warnings.filter((w) => w.includes('closed'))).toEqual([]); + }); + + it('dispose drains an in-flight read-only refresh before closing the db (review #20)', async () => { + const s1 = summary('s1', 'drain refresh', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 refresh', T1)]); + const writer = track(makeInlineService(home!, staticIndex([s1]))); + await writer.reindex(); + + const { log, warnings } = recordingLog(); + const reader = new GlobalSearchService(staticIndex([s1]), makeBootstrap(home!), log, makeConfig(false)); + reader.syncDebounceMs = 0; + track(reader); + await syncNow(reader); + expect((coreOf(reader).db as unknown as { readOnly: boolean } | null)?.readOnly).toBe(true); + + const blocked = blockFirstCall(coreOf(reader), 'computeFingerprint'); + const refresh = refreshNow(reader); + await blocked.entered; + reader.dispose(); + + let drained = false; + const drain = drainGlobalSearchDisposals().then(() => { + drained = true; + }); + await flush(); + expect(drained).toBe(false); + + blocked.release(); + await refresh; + await drain; + expect(drained).toBe(true); + expect(coreOf(reader).db).toBeNull(); + expect(warnings.filter((w) => w.includes('closed'))).toEqual([]); + }); + + it('drainGlobalSearchDisposals also waits for disposals registered while it was draining (review #21)', async () => { + const setupBlockedService = async (root: string) => { + const s1 = summary('s1', 'drain fixpoint', T1); + await writeWire(root, 's1', 'main', [userLine('苹果 fixpoint', T1)]); + const sessions = [s1]; + const service = new GlobalSearchService( + makeSessionIndex(async () => ({ items: sessions, nextCursor: undefined })), + makeBootstrap(root), + noopLog, + makeConfig(false), + ); + service.syncDebounceMs = 0; + track(service); + await service.reindex(); + sessions.length = 0; + const blocked = blockFirstCall(coreOf(service), 'deleteSessionDocs'); + const sync = syncNow(service); + await blocked.entered; + return { service, blocked, sync }; + }; + + const a = await setupBlockedService(home!); + a.service.dispose(); + + let drained = false; + const drain = drainGlobalSearchDisposals().then(() => { + drained = true; + }); + await flush(); + expect(drained).toBe(false); + + const homeB = await mkdtemp(join(tmpdir(), 'kimi-kap-search-drain-')); + try { + const b = await setupBlockedService(homeB); + b.service.dispose(); + + const aDb = coreOf(a.service).db!; + a.blocked.release(); + await a.sync; + await aDb.close(); + await flush(); + expect(drained).toBe(false); + + b.blocked.release(); + await b.sync; + await drain; + expect(drained).toBe(true); + expect(coreOf(a.service).db).toBeNull(); + expect(coreOf(b.service).db).toBeNull(); + } finally { + await rm(homeB, { recursive: true, force: true }); + } + }); + }); +}); + +describe('search worker host (stage 4)', () => { + let home: string | undefined; + const services: GlobalSearchService[] = []; + const hosts: SearchWorkerHost[] = []; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-kap-search-worker-')); + }); + + afterEach(async () => { + for (const service of services.splice(0)) service.dispose(); + for (const host of hosts.splice(0)) await host.dispose().catch(() => {}); + await drainGlobalSearchDisposals(); + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + function track(service: GlobalSearchService): GlobalSearchService { + services.push(service); + return service; + } + + function hostOf(service: GlobalSearchService): SearchWorkerHost { + const backend = (service as unknown as { backend: SearchBackend }).backend; + if (!(backend instanceof SearchWorkerHost)) { + throw new Error('expected the worker backend'); + } + return backend; + } + + const lockPath = (): string => join(home!, 'search-index', 'db.lock'); + + async function waitForGone(path: string): Promise { + await vi.waitFor(async () => { + await expect(stat(path)).rejects.toThrow(); + }); + } + + it('restarts a killed worker, reaps its lock, and keeps serving', { timeout: 30_000 }, async () => { + const s1 = summary('s1', 'crash', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 crash', T1)]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + expect((await service.search({ query: '苹果' })).items.length).toBe(1); + + const lockRaw = JSON.parse(await readFile(lockPath(), 'utf8')) as { pid: number }; + expect(lockRaw.pid).toBe(process.pid); + + await hostOf(service).killWorkerForTest(); + await waitForGone(lockPath()); + + const degraded = await service.search({ query: '苹果' }); + expect(degraded.items).toEqual([]); + expect(degraded.indexState.state).toBe('building'); + expect(degraded.indexState.degraded).toContain('worker'); + + await vi.waitFor( + async () => { + expect((await hostOf(service).status()).readOnly).toBe(false); + }, + { timeout: 10_000 }, + ); + await settleSync(service); + const page = await service.search({ query: '苹果' }); + expect(page.items.length).toBe(1); + expect(page.indexState.state).toBe('ready'); + }); + + it('reports the lock token at acquire time; a mid-open kill leaves a reapable lock', { timeout: 30_000 }, async () => { + const summaries: SessionSummary[] = []; + for (let i = 0; i < 300; i++) { + const s = summary(`midopen-${i}`, `midopen 会话 ${i}`, T1 + i); + summaries.push(s); + const lines: string[] = []; + for (let j = 0; j < 30; j++) lines.push(userLine(`中途退出 ${i}-${j} 检索`, T1 + i * 100 + j)); + await writeWire(home!, s.id, 'main', lines); + } + const inline = track(makeInlineService(home!, staticIndex(summaries))); + await inline.reindex(); + inline.dispose(); + await drainGlobalSearchDisposals(); + + const dir = join(home!, 'search-index'); + const host = new SearchWorkerHost({ dir, log: noopLog }); + hosts.push(host); + let openSettled = false; + const opening = host.ensureOpen().then( + () => { + openSettled = true; + }, + () => { + openSettled = true; + }, + ); + await vi.waitFor( + () => { + expect(host.reportedLockToken).toBeDefined(); + }, + { interval: 5, timeout: 10_000 }, + ); + expect(openSettled).toBe(false); + + await host.killWorkerForTest(); + await opening; + await waitForGone(lockPath()); + + await vi.waitFor( + async () => { + const reopened = await host.ensureOpen(); + expect(reopened.readOnly).toBe(false); + }, + { timeout: 10_000 }, + ); + }); + + it('recovers a read-only open caused by an orphaned same-pid lock', { timeout: 30_000 }, async () => { + const dir = join(home!, 'search-index'); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'db.lock'), + JSON.stringify({ pid: process.pid, ts: Date.now(), token: 'orphan-token' }), + 'utf8', + ); + const host = new SearchWorkerHost({ dir, log: noopLog }); + hosts.push(host); + const first = await host.ensureOpen(); + expect(first.readOnly).toBe(true); + + await vi.waitFor( + async () => { + const status = await host.status(); + expect(status.readOnly).toBe(false); + }, + { timeout: 10_000, interval: 200 }, + ); + }); + + it('beginClose abandons an in-flight worker sync and dispose stays bounded', { timeout: 30_000 }, async () => { + const summaries: SessionSummary[] = []; + for (let i = 0; i < 200; i++) { + const s = summary(`drain-${i}`, `drain 会话 ${i}`, T1 + i); + summaries.push(s); + const lines: string[] = []; + for (let j = 0; j < 30; j++) lines.push(userLine(`排空 ${i}-${j} 检索`, T1 + i * 100 + j)); + await writeWire(home!, s.id, 'main', lines); + } + const inputs = summaries.map((s) => syncInput(home!, s)); + const host = new SearchWorkerHost({ dir: join(home!, 'search-index'), log: noopLog }); + hosts.push(host); + + await host.ensureOpen(); + const sync = host.sync(inputs); + await vi.waitFor( + () => { + expect( + (host as unknown as { requests: Map }).requests.size, + ).toBeGreaterThan(0); + }, + { timeout: 10_000 }, + ); + host.beginClose(); + const outcome = await sync; + expect(outcome.noop).toBe(true); + + const startedAt = performance.now(); + await host.dispose(); + expect(performance.now() - startedAt).toBeLessThan(5_000); + expect((host as unknown as { worker: unknown }).worker).toBeNull(); + }); + + it('times out a wedged request and terminates the worker (watchdog)', { timeout: 30_000 }, async () => { + const dir = join(home!, 'search-index'); + let gate = true; + const host = new SearchWorkerHost({ + dir, + log: noopLog, + requestTimeoutMs: 300, + workerFactory: ({ url, data, execArgv }) => { + const worker = new Worker(url, { workerData: data, execArgv }); + const original = worker.postMessage.bind(worker); + worker.postMessage = ((message: unknown, ...rest: unknown[]) => { + if (gate && (message as { type?: string } | null)?.type === 'status') return true; + return original(message as Parameters[0], ...(rest as never[])); + }) as Worker['postMessage']; + return worker; + }, + }); + hosts.push(host); + await host.ensureOpen(); + + const wedged = host.status(); + wedged.catch(() => {}); + await expect(wedged).rejects.toMatchObject({ code: 'crashed' }); + await expect(wedged).rejects.toThrow(/timed out/); + + gate = false; + await vi.waitFor( + async () => { + const status = await host.status(); + expect(status.readOnly).toBe(false); + }, + { timeout: 10_000 }, + ); + }); + + it('rejects in-flight requests as disposed during a clean close', { timeout: 30_000 }, async () => { + const dir = join(home!, 'search-index'); + const host = new SearchWorkerHost({ + dir, + log: noopLog, + workerFactory: ({ url, data, execArgv }) => { + const worker = new Worker(url, { workerData: data, execArgv }); + const original = worker.postMessage.bind(worker); + worker.postMessage = ((message: unknown, ...rest: unknown[]) => { + if ((message as { type?: string } | null)?.type === 'sync') return true; + return original(message as Parameters[0], ...(rest as never[])); + }) as Worker['postMessage']; + return worker; + }, + }); + hosts.push(host); + await host.ensureOpen(); + + const sync = host.sync([]); + sync.catch(() => {}); + await host.dispose(); + await expect(sync).rejects.toMatchObject({ code: 'disposed' }); + }); + + it('reports index_unavailable for searches after dispose', { timeout: 30_000 }, async () => { + const s1 = summary('s1', 'disposed', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 disposed', T1)]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + service.dispose(); + await expect(service.search({ query: '苹果' })).rejects.toMatchObject({ + reason: 'index_unavailable', + message: 'search service is disposed', + }); + await drainGlobalSearchDisposals(); + }); + + it('invalidates page tokens across a worker restart (boot salt)', { timeout: 30_000 }, async () => { + const s1 = summary('s1', 'boot', T1); + const lines: string[] = []; + for (let i = 0; i < 30; i++) lines.push(userLine(`苹果 boot ${i}`, T1 + i)); + await writeWire(home!, 's1', 'main', lines); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + const page1 = await service.search({ query: '苹果', sort: 'time_asc', pageSize: 10 }); + expect(page1.pageToken).toBeDefined(); + + await hostOf(service).killWorkerForTest(); + await vi.waitFor( + async () => { + expect((await hostOf(service).status()).readOnly).toBe(false); + }, + { timeout: 10_000 }, + ); + await settleSync(service); + + await expect( + service.search({ query: '苹果', sort: 'time_asc', pageSize: 10, pageToken: page1.pageToken }), + ).rejects.toMatchObject({ reason: 'invalid_page_token' }); + const restarted = await service.search({ query: '苹果', sort: 'time_asc', pageSize: 10 }); + expect(restarted.items.length).toBe(10); + }); + + it('rebuilds a corrupt database inside the worker', { timeout: 30_000 }, async () => { + const s1 = summary('s1', 'corrupt', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 corrupt', T1)]); + const first = track(makeService(home!, staticIndex([s1]))); + await first.reindex(); + expect((await first.search({ query: '苹果' })).items.length).toBe(1); + first.dispose(); + await drainGlobalSearchDisposals(); + + await writeFile(join(home!, 'search-index', 'db.snapshot'), 'not a snapshot {{{', 'utf8'); + + const second = track(makeService(home!, staticIndex([s1]))); + await settleSync(second); + const page = await second.search({ query: '苹果' }); + expect(page.items.length).toBe(1); + expect(page.indexState.state).toBe('ready'); + }); + + it('runs a second worker read-only and a fresh worker becomes the writer after the writer dies', { timeout: 30_000 }, async () => { + const s1 = summary('s1', 'election', T1); + const file = await writeWire(home!, 's1', 'main', [userLine('苹果 election', T1)]); + const index = staticIndex([s1]); + + const writer = track(makeService(home!, index)); + await writer.reindex(); + const reader = track(makeService(home!, index)); + await reader.status(); + const ro = await reader.search({ query: '苹果' }); + expect(ro.indexState.state).toBe('readonly'); + expect(ro.items.length).toBe(1); + + await appendFile(file, `${userLine('苹果 delta', T2)}\n`, 'utf8'); + await settleSync(writer); + await refreshNow(reader); + expect((await reader.search({ query: '苹果' })).items.length).toBe(2); + + await hostOf(writer).killWorkerForTest(); + await waitForGone(lockPath()); + writer.dispose(); + await drainGlobalSearchDisposals(); + + const third = track(makeService(home!, index)); + await settleSync(third); + const ready = await third.search({ query: '苹果' }); + expect(ready.indexState.state).toBe('ready'); + expect(ready.items.length).toBe(2); + await third.reindex(); + expect((await third.search({ query: '苹果' })).items.length).toBe(2); + }); + + it('reader worker reopens when the writer replaces the WAL/snapshot (reindex)', { timeout: 30_000 }, async () => { + const s1 = summary('s1', 'rotate', T1); + const file = await writeWire(home!, 's1', 'main', [userLine('苹果 rotate', T1)]); + const index = staticIndex([s1]); + + const writer = track(makeService(home!, index)); + await writer.reindex(); + const reader = track(makeService(home!, index)); + await reader.status(); + expect((await reader.search({ query: '苹果' })).items.length).toBe(1); + + await appendFile(file, `${userLine('苹果 rotated', T2)}\n`, 'utf8'); + await writer.reindex(); + await refreshNow(reader); + const page = await reader.search({ query: '苹果' }); + expect(page.indexState.state).toBe('readonly'); + expect(page.items.length).toBe(2); + expect(page.items.some((h) => h.snippet.includes('rotated'))).toBe(true); + }); + + it('rejects in-flight requests when the worker dies', { timeout: 30_000 }, async () => { + const dir = join(home!, 'search-index'); + let gateSync = false; + const held: SearchWorkerRequest[] = []; + const host = new SearchWorkerHost({ + dir, + log: noopLog, + workerFactory: ({ url, data, execArgv }) => { + const worker = new Worker(url, { workerData: data, execArgv }); + const original = worker.postMessage.bind(worker); + worker.postMessage = ((message: unknown, ...rest: unknown[]) => { + if (gateSync && (message as { type?: string } | null)?.type === 'sync') { + held.push(message as SearchWorkerRequest); + return true; + } + return original(message as Parameters[0], ...(rest as never[])); + }) as Worker['postMessage']; + return worker; + }, + }); + hosts.push(host); + await host.ensureOpen(); + + gateSync = true; + const sync = host.sync([]); + sync.catch(() => {}); + await vi.waitFor(() => { + expect(held.length).toBe(1); + }); + + await host.killWorkerForTest(); + await expect(sync).rejects.toBeInstanceOf(SearchWorkerError); + await expect(sync).rejects.toMatchObject({ code: 'crashed' }); + await waitForGone(join(dir, 'db.lock')); + }); + + it('dispose terminates a wedged worker after the close timeout', { timeout: 30_000 }, async () => { + const dir = join(home!, 'search-index'); + const host = new SearchWorkerHost({ + dir, + log: noopLog, + closeTimeoutMs: 300, + workerFactory: ({ url, data, execArgv }) => { + const worker = new Worker(url, { workerData: data, execArgv }); + const original = worker.postMessage.bind(worker); + worker.postMessage = ((message: unknown, ...rest: unknown[]) => { + if ((message as { type?: string } | null)?.type === 'close') return true; + return original(message as Parameters[0], ...(rest as never[])); + }) as Worker['postMessage']; + return worker; + }, + }); + hosts.push(host); + await host.ensureOpen(); + + const startedAt = performance.now(); + await host.dispose(); + const elapsed = performance.now() - startedAt; + expect(elapsed).toBeGreaterThan(250); + expect(elapsed).toBeLessThan(10_000); + expect((host as unknown as { worker: unknown }).worker).toBeNull(); + }); + + it('keeps the main thread responsive while the worker opens and syncs a corpus', { timeout: 30_000 }, async () => { + const summaries: SessionSummary[] = []; + for (let i = 0; i < 120; i++) { + const s = summary(`probe-${i}`, `probe 会话 ${i}`, T1 + i); + summaries.push(s); + const lines: string[] = []; + for (let j = 0; j < 30; j++) { + lines.push(userLine(`检索词 probe ${i}-${j} 持久化`, T1 + i * 100 + j)); + } + await writeWire(home!, s.id, 'main', lines); + } + + const eld = monitorEventLoopDelay({ resolution: 5 }); + let probing = true; + let ticks = 0; + const probe = (async () => { + while (probing) { + await new Promise((resolve) => setImmediate(resolve)); + ticks++; + } + })(); + eld.enable(); + + const service = track(makeService(home!, staticIndex(summaries))); + const early = await service.search({ query: '检索词' }); + expect(early.indexState.state).toBe('building'); + await settleSync(service); + + probing = false; + await probe; + eld.disable(); + const p99Ms = eld.percentile(99) / 1e6; + const maxMs = eld.max / 1e6; + console.log('[stage-4 worker probe]', JSON.stringify({ p99Ms, maxMs, ticks })); + expect(ticks).toBeGreaterThan(0); + expect(p99Ms).toBeLessThan(20); + expect(maxMs).toBeLessThan(100); + + const page = await service.search({ query: '检索词' }); + expect(page.items.length).toBeGreaterThan(0); + expect(page.indexState.state).toBe('ready'); + }); + + it('keeps the main thread responsive while the worker rebuilds and swaps the generation (reindex)', { timeout: 30_000 }, async () => { + const summaries: SessionSummary[] = []; + for (let i = 0; i < 120; i++) { + const s = summary(`reindex-${i}`, `reindex 会话 ${i}`, T1 + i); + summaries.push(s); + const lines: string[] = []; + for (let j = 0; j < 30; j++) { + lines.push(userLine(`检索词 reindex ${i}-${j} 持久化`, T1 + i * 100 + j)); + } + await writeWire(home!, s.id, 'main', lines); + } + + const service = track(makeService(home!, staticIndex(summaries))); + await service.reindex(); + expect((await service.search({ query: '检索词' })).indexState.state).toBe('ready'); + + const eld = monitorEventLoopDelay({ resolution: 5 }); + const stop = { done: false }; + let ticks = 0; + const probe = (async () => { + while (!stop.done) { + await new Promise((resolve) => setImmediate(resolve)); + ticks++; + } + })(); + eld.enable(); + + await service.reindex(); + + stop.done = true; + await probe; + eld.disable(); + const p99Ms = eld.percentile(99) / 1e6; + const maxMs = eld.max / 1e6; + console.log('[stage-4 reindex probe]', JSON.stringify({ p99Ms, maxMs, ticks })); + expect(ticks).toBeGreaterThan(0); + expect(p99Ms).toBeLessThan(20); + expect(maxMs).toBeLessThan(100); + + const page = await service.search({ query: '检索词' }); + expect(page.items.length).toBeGreaterThan(0); + expect(page.indexState.state).toBe('ready'); + }); +}); + +describe('search lifecycle diagnostics (stage 5)', () => { + let home: string | undefined; + const services: GlobalSearchService[] = []; + const hosts: SearchWorkerHost[] = []; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-kap-search-lifecycle-')); + }); + + afterEach(async () => { + for (const service of services.splice(0)) service.dispose(); + for (const host of hosts.splice(0)) await host.dispose().catch(() => {}); + await drainGlobalSearchDisposals(); + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + function track(service: GlobalSearchService): GlobalSearchService { + services.push(service); + return service; + } + + function hostOf(service: GlobalSearchService): SearchWorkerHost { + const backend = (service as unknown as { backend: SearchBackend }).backend; + if (!(backend instanceof SearchWorkerHost)) { + throw new Error('expected the worker backend'); + } + return backend; + } + + const lockPath = (): string => join(home!, 'search-index', 'db.lock'); + + async function waitForGone(path: string): Promise { + await vi.waitFor(async () => { + await expect(stat(path)).rejects.toThrow(); + }); + } + + it('walks stopped → ready → closing → stopped and never throws (inline)', async () => { + const service = track(makeInlineService(home!, staticIndex([]))); + expect(service.lifecycleReport()).toEqual({ state: 'stopped' }); + await expect(stat(join(home!, 'search-index'))).rejects.toThrow(); + + const status = await service.status(); + expect(status).toMatchObject({ sessions: 0, documents: 0, lifecycle: { state: 'ready' } }); + expect(service.lifecycleReport()).toEqual({ state: 'ready' }); + + service.dispose(); + expect(service.lifecycleReport()).toEqual({ state: 'closing' }); + await expect(service.status()).resolves.toMatchObject({ lifecycle: { state: 'closing' } }); + await drainGlobalSearchDisposals(); + expect(service.lifecycleReport()).toEqual({ state: 'stopped' }); + await expect(service.status()).resolves.toMatchObject({ lifecycle: { state: 'stopped' } }); + }); + + it('reports degraded instead of throwing when the open fails (inline)', async () => { + const s1 = summary('s1', 'open 失败', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 open-failure', T1)]); + const service = track(makeInlineService(home!, staticIndex([s1]))); + await settleBackend(service); + const core = coreOf(service) as unknown as { openSearchDb(): Promise }; + core.openSearchDb = async () => { + throw new Error('disk gone'); + }; + + await settleSync(service).catch(() => {}); + await expect(service.search({ query: '苹果' })).rejects.toMatchObject({ + reason: 'index_unavailable', + }); + expect(service.lifecycleReport().state).toBe('degraded'); + expect(service.lifecycleReport().detail).toContain('disk gone'); + const status = await service.status(); + expect(status.lifecycle.state).toBe('degraded'); + expect(status.degraded).toContain('disk gone'); + }); + + it('logs the corruption rebuild as its own diagnostic outcome (inline)', { timeout: 30_000 }, async () => { + const s1 = summary('s1', 'corrupt 日志', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 corrupt-log', T1)]); + const first = track(makeInlineService(home!, staticIndex([s1]))); + await first.reindex(); + first.dispose(); + await drainGlobalSearchDisposals(); + + await writeFile(join(home!, 'search-index', 'db.textindexes.json'), 'not json {{{', 'utf8'); + + const { log, warnings } = recordingLog(); + const second = new GlobalSearchService(staticIndex([s1]), makeBootstrap(home!), log, makeConfig(false)); + track(second); + second.syncDebounceMs = 0; + await settleSync(second); + expect(warnings.some((line) => line.includes('corruption detected'))).toBe(true); + const page = await second.search({ query: '苹果' }); + expect(page.items.length).toBe(1); + expect(page.indexState.state).toBe('ready'); + }); + + it('a failing session index degrades search only — construction, search and status keep answering', async () => { + const failing = makeSessionIndex(async () => { + throw new Error('metadata store down'); + }); + const service = track(makeInlineService(home!, failing)); + await settleSync(service).catch(() => {}); + const page = await service.search({ query: 'anything' }); + expect(page.items).toEqual([]); + expect(page.indexState.state).toBe('building'); + const status = await service.status(); + expect(status.degraded).toContain('metadata store down'); + }); + + it('a restart attaches the published generation instead of rebuilding (inline)', async () => { + const s1 = summary('s1', '代际复用', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 generation-reuse', T1)]); + const first = track(makeInlineService(home!, staticIndex([s1]))); + await first.reindex(); + const firstCore = coreOf(first) as unknown as { + db: { buildGeneration(trigger: 'manual'): Promise } | null; + }; + await firstCore.db!.buildGeneration('manual'); + first.dispose(); + await drainGlobalSearchDisposals(); + + const second = track(makeInlineService(home!, staticIndex([s1]))); + await settleSync(second); + const page = await second.search({ query: '苹果' }); + expect(page.items.length).toBe(1); + const core = coreOf(second) as unknown as { + db: { lifecycleStatus(): { path: string[] } } | null; + }; + const path = core.db!.lifecycleStatus().path; + expect(path).toContain('generation-load'); + expect(path).not.toContain('full-rebuild'); + }); + + it('concurrent cold calls open the index exactly once (inline)', async () => { + const s1 = summary('s1', '单次打开', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 single-open', T1)]); + const service = track(makeInlineService(home!, staticIndex([s1]))); + await settleBackend(service); + const core = coreOf(service) as unknown as { openSearchDb(): Promise }; + const originalOpen = core.openSearchDb.bind(core); + let openCalls = 0; + core.openSearchDb = async () => { + openCalls++; + return originalOpen(); + }; + + const pages = await Promise.all([ + service.search({ query: '苹果' }), + service.search({ query: '苹果' }), + service.search({ query: '苹果' }), + ]); + for (const page of pages) expect(page.indexState.state).toBe('building'); + await settleSync(service); + expect(openCalls).toBe(1); + expect((await service.search({ query: '苹果' })).items.length).toBe(1); + }); + + it('concurrent first RPCs spawn exactly one worker', { timeout: 30_000 }, async () => { + let spawns = 0; + const host = new SearchWorkerHost({ + dir: join(home!, 'search-index'), + log: noopLog, + workerFactory: ({ url, data, execArgv }) => { + spawns++; + return new Worker(url, { workerData: data, execArgv }); + }, + }); + hosts.push(host); + await Promise.all([host.ensureOpen(), host.ensureOpen(), host.ensureOpen(), host.ensureOpen()]); + expect(spawns).toBe(1); + expect(host.lifecycleSnapshot().state).toBe('ready'); + }); + + it('reports opening while the worker boots, ready after the sync, degraded after a crash', { timeout: 30_000 }, async () => { + const s1 = summary('s1', '生命周期', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 lifecycle', T1)]); + const service = track(makeService(home!, staticIndex([s1]))); + await flush(); + expect(service.lifecycleReport().state).toBe('opening'); + await settleSync(service); + expect(service.lifecycleReport().state).toBe('ready'); + const status = await service.status(); + expect(status.lifecycle.state).toBe('ready'); + expect(status.sessions).toBe(1); + + await hostOf(service).killWorkerForTest(); + const down = service.lifecycleReport(); + expect(down.state).toBe('degraded'); + expect(down.detail).toContain('worker'); + + await vi.waitFor( + async () => { + expect((await hostOf(service).status()).readOnly).toBe(false); + }, + { timeout: 10_000 }, + ); + await settleSync(service); + expect(service.lifecycleReport().state).toBe('ready'); + expect((await service.search({ query: '苹果' })).items.length).toBe(1); + }); + + it('does not serve a dead worker generation’s cached lifecycle after a respawn', { timeout: 30_000 }, async () => { + const s1 = summary('s1', '缓存失效', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 stale-cache', T1)]); + const service = track(makeService(home!, staticIndex([s1]))); + await settleSync(service); + expect(service.lifecycleReport().state).toBe('ready'); + + await hostOf(service).killWorkerForTest(); + const host = hostOf(service); + await vi.waitFor( + () => { + expect( + (host as unknown as { nextRetryAfter: number }).nextRetryAfter, + ).toBeLessThanOrEqual(Date.now()); + }, + { timeout: 10_000 }, + ); + + const respawn = syncNow(service); + respawn.catch(() => {}); + await vi.waitFor( + () => { + expect((host as unknown as { worker: unknown }).worker).not.toBeNull(); + }, + { interval: 5, timeout: 10_000 }, + ); + expect(service.lifecycleReport().state).toBe('opening'); + + await respawn; + await settleSync(service); + expect(service.lifecycleReport().state).toBe('ready'); + expect((await service.search({ query: '苹果' })).items.length).toBe(1); + }); + + it('a clean dispose releases the search-index lock and the lifecycle settles at stopped', { timeout: 30_000 }, async () => { + const s1 = summary('s1', '退出顺序', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 shutdown', T1)]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + await expect(stat(lockPath())).resolves.toBeDefined(); + + const host = hostOf(service); + host.beginClose(); + expect(host.lifecycleSnapshot().state).toBe('closing'); + service.dispose(); + await drainGlobalSearchDisposals(); + await waitForGone(lockPath()); + expect(service.lifecycleReport()).toEqual({ state: 'stopped' }); + + const next = track(makeService(home!, staticIndex([s1]))); + await next.reindex(); + expect((await next.search({ query: '苹果' })).items.length).toBe(1); + }); + + it('a spawn failure never falls back to the inline host and reports degraded', { timeout: 30_000 }, async () => { + const service = track(makeService(home!, staticIndex([]))); + const failingHost = new SearchWorkerHost({ + dir: join(home!, 'search-index'), + log: noopLog, + workerFactory: () => { + throw new Error('threads unavailable'); + }, + }); + hosts.push(failingHost); + (service as unknown as { backend: SearchBackend }).backend = failingHost; + + const page = await service.search({ query: 'anything' }); + expect(page.items).toEqual([]); + expect(page.source).toBe('index'); + expect(page.indexState.state).toBe('building'); + expect(page.indexState.degraded).toContain('threads unavailable'); + await expect(stat(join(home!, 'search-index'))).rejects.toThrow(); + + expect(service.lifecycleReport().state).toBe('degraded'); + const status = await service.status(); + expect(status.lifecycle.state).toBe('degraded'); + expect(status.degraded).toContain('worker'); + }); +}); diff --git a/packages/kap-server/test/search/snippet.test.ts b/packages/kap-server/test/search/snippet.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b4dc80eee0c063866c1ddf37bf2d1880e61b265 --- /dev/null +++ b/packages/kap-server/test/search/snippet.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { makeSnippet } from '../../src/search/snippet'; + +describe('makeSnippet', () => { + it('centers the window on the first hit term', () => { + const text = `${'a'.repeat(200)} needle ${'b'.repeat(200)}`; + const snippet = makeSnippet(text, 'needle', 20); + expect(snippet).toContain('needle'); + expect(snippet.startsWith('…')).toBe(true); + expect(snippet.endsWith('…')).toBe(true); + expect(snippet.length).toBeLessThan(text.length); + }); + + it('matches case-insensitively', () => { + expect(makeSnippet('Hello World', 'hello')).toBe('Hello World'); + }); + + it('matches CJK terms', () => { + const snippet = makeSnippet('我们需要重构全局搜索模块以支持中文', '搜索'); + expect(snippet).toContain('搜索'); + }); + + it('uses the earliest occurrence across multiple terms', () => { + const text = `${'x'.repeat(100)} beta ${'x'.repeat(100)} alpha`; + const snippet = makeSnippet(text, 'alpha beta', 10); + expect(snippet).toContain('beta'); + }); + + it('falls back to the text head when no term matches', () => { + const text = `start ${'y'.repeat(500)}`; + const snippet = makeSnippet(text, 'absent', 40); + expect(snippet.startsWith('start')).toBe(true); + expect(snippet.endsWith('…')).toBe(true); + }); + + it('does not add ellipses when the whole text fits', () => { + expect(makeSnippet('short text', 'text')).toBe('short text'); + }); + + it('collapses whitespace runs', () => { + expect(makeSnippet('a\n\nb c\tneedle', 'needle', 80)).toBe('a b c needle'); + }); +}); diff --git a/packages/kap-server/test/search/wireExtract.test.ts b/packages/kap-server/test/search/wireExtract.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..926fa961f6bd1a24b351387afe8d006aa707c922 --- /dev/null +++ b/packages/kap-server/test/search/wireExtract.test.ts @@ -0,0 +1,461 @@ +import { describe, expect, it } from 'vitest'; + +import { analyzeWireLine, extractFromWireLine } from '../../src/search/wireExtract'; + +const line = (value: unknown): string => JSON.stringify(value); + +function userRecord(text: string, time: number, origin?: unknown): string { + return line({ + type: 'context.append_message', + time, + message: { role: 'user', content: [{ type: 'text', text }], origin }, + }); +} + +describe('extractFromWireLine', () => { + it('extracts user text messages', () => { + const out = extractFromWireLine( + userRecord('帮我重构搜索模块', 1_700_000_000_000, { kind: 'user' }), + ); + expect(out).toEqual([{ role: 'user', text: '帮我重构搜索模块', time: 1_700_000_000_000 }]); + }); + + it('keeps user messages without an origin', () => { + const out = extractFromWireLine(userRecord('hello world', 1_700_000_000_000)); + expect(out).toHaveLength(1); + expect(out[0]?.role).toBe('user'); + }); + + it('joins multiple text parts of one user message', () => { + const out = extractFromWireLine( + line({ + type: 'context.append_message', + time: 1_700_000_000_000, + message: { + role: 'user', + content: [ + { type: 'text', text: 'first part ' }, + { type: 'image', url: 'data:...' }, + { type: 'text', text: 'second part' }, + ], + origin: { kind: 'user' }, + }, + }), + ); + expect(out).toEqual([ + { role: 'user', text: 'first part second part', time: 1_700_000_000_000 }, + ]); + }); + + it.each(['injection', 'system_trigger', 'retry', 'compaction_summary', 'plugin_command'])( + 'filters out origin kind %s', + (kind) => { + expect( + extractFromWireLine(userRecord('not user input', 1_700_000_000_000, { kind })), + ).toEqual([]); + }, + ); + + it('filters out subagent system triggers even though they open transcript turns', () => { + expect( + extractFromWireLine( + userRecord('scan the repo', 1_700_000_000_000, { kind: 'system_trigger', name: 'subagent' }), + ), + ).toEqual([]); + }); + + it.each(['skill_activation', 'plugin_command'])( + 'keeps %s messages the user typed as a slash command', + (kind) => { + const out = extractFromWireLine( + userRecord('/commit 整理提交', 1_700_000_000_000, { + kind, + trigger: 'user-slash', + skillName: 'commit', + }), + ); + expect(out).toEqual([{ role: 'user', text: '/commit 整理提交', time: 1_700_000_000_000 }]); + }, + ); + + it.each(['model-tool', 'nested-skill'])( + 'filters out skill activations triggered by %s', + (trigger) => { + expect( + extractFromWireLine( + userRecord('skill body', 1_700_000_000_000, { kind: 'skill_activation', trigger }), + ), + ).toEqual([]); + }, + ); + + it('ignores non-user append_message roles', () => { + const out = extractFromWireLine( + line({ + type: 'context.append_message', + time: 1_700_000_000_000, + message: { role: 'assistant', content: [{ type: 'text', text: 'assistant as message' }] }, + }), + ); + expect(out).toEqual([]); + }); + + it('indexes only the real text of an upload-carrying user message', () => { + const uploadRef = { + type: 'image_url', + imageUrl: { url: 'kimi-file://f_1?path=%2FUsers%2Falice%2Fmedia%2Ff_1.png' }, + }; + const record = (content: unknown[]): string => + line({ + type: 'context.append_message', + time: 1_700_000_000_000, + message: { role: 'user', content, origin: { kind: 'user' } }, + }); + + expect(extractFromWireLine(record([uploadRef]))).toEqual([]); + expect( + extractFromWireLine(record([{ type: 'text', text: 'what is this? ' }, uploadRef])), + ).toEqual([{ role: 'user', text: 'what is this?', time: 1_700_000_000_000 }]); + }); + + it('never indexes a standalone tag, paired or not', () => { + const legacyPair = [ + { type: 'text', text: '' }, + { + type: 'image_url', + imageUrl: { url: 'kimi-file://f_1?path=%2FUsers%2Falice%2Fmedia%2Ff_1.png' }, + }, + ]; + const record = (content: unknown[]): string => + line({ + type: 'context.append_message', + time: 1_700_000_000_000, + message: { role: 'user', content, origin: { kind: 'user' } }, + }); + + expect(extractFromWireLine(record(legacyPair))).toEqual([]); + expect( + extractFromWireLine(userRecord('', 1_700_000_000_000, { kind: 'user' })), + ).toEqual([]); + expect( + extractFromWireLine( + userRecord('open please', 1_700_000_000_000, { kind: 'user' }), + ), + ).toEqual([ + { role: 'user', text: 'open please', time: 1_700_000_000_000 }, + ]); + }); + + it('extracts assistant text content parts from loop events', () => { + const out = extractFromWireLine( + line({ + type: 'context.append_loop_event', + time: 1_700_000_000_000, + event: { type: 'content.part', part: { type: 'text', text: 'Here is the refactor plan.' } }, + }), + ); + expect(out).toEqual([ + { role: 'assistant', text: 'Here is the refactor plan.', time: 1_700_000_000_000 }, + ]); + }); + + it('ignores thinking parts and tool events', () => { + expect( + extractFromWireLine( + line({ + type: 'context.append_loop_event', + time: 1, + event: { type: 'content.part', part: { type: 'thinking', thinking: 'hmm' } }, + }), + ), + ).toEqual([]); + expect( + extractFromWireLine( + line({ + type: 'context.append_loop_event', + time: 1, + event: { type: 'tool.call', name: 'Bash', args: { command: 'ls' } }, + }), + ), + ).toEqual([]); + expect( + extractFromWireLine( + line({ + type: 'context.append_loop_event', + time: 1, + event: { type: 'tool.result', result: { output: 'file list' } }, + }), + ), + ).toEqual([]); + }); + + it('ignores other record types, blank lines and unparseable JSON', () => { + expect(extractFromWireLine(line({ type: 'metadata', protocol_version: '1.4' }))).toEqual([]); + expect(extractFromWireLine(line({ type: 'turn_begin', time: 1 }))).toEqual([]); + expect(extractFromWireLine('')).toEqual([]); + expect(extractFromWireLine(' ')).toEqual([]); + expect(extractFromWireLine('{not json')).toEqual([]); + expect(extractFromWireLine('[1,2,3]')).toEqual([]); + }); + + it('drops messages whose text is empty after trimming', () => { + expect(extractFromWireLine(userRecord(' ', 1_700_000_000_000, { kind: 'user' }))).toEqual([]); + }); + + it('normalizes second-based timestamps to epoch ms', () => { + const out = extractFromWireLine(userRecord('seconds time', 1_700_000_000, { kind: 'user' })); + expect(out[0]?.time).toBe(1_700_000_000_000); + }); + + it('omits time when the record has no usable timestamp', () => { + const out = extractFromWireLine( + line({ + type: 'context.append_message', + message: { role: 'user', content: [{ type: 'text', text: 'no time' }] }, + }), + ); + expect(out[0]?.time).toBeUndefined(); + }); +}); + +describe('analyzeWireLine turn effects', () => { + const turnOf = (jsonl: string) => analyzeWireLine(jsonl).turn; + + it('user messages without an origin open an anchor turn', () => { + expect(turnOf(userRecord('hi', 1))).toEqual({ kind: 'open', anchor: true }); + expect(turnOf(userRecord('hi', 1, { kind: 'user' }))).toEqual({ kind: 'open', anchor: true }); + }); + + it('text-less user messages still open a turn (counting is index-independent)', () => { + expect( + turnOf( + line({ + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'image', source: { kind: 'url', url: 'x' } }], + }, + }), + ), + ).toEqual({ kind: 'open', anchor: true }); + }); + + it('hidden origins do not open turns, except turn-opening system triggers', () => { + expect(turnOf(userRecord('x', 1, { kind: 'injection', variant: 'v' }))).toEqual({ + kind: 'none', + }); + expect(turnOf(userRecord('x', 1, { kind: 'retry' }))).toEqual({ kind: 'none' }); + expect(turnOf(userRecord('x', 1, { kind: 'system_trigger', name: 'reminder' }))).toEqual({ + kind: 'none', + }); + expect( + turnOf(userRecord('x', 1, { kind: 'system_trigger', name: 'goal_continuation' })), + ).toEqual({ kind: 'open', anchor: false }); + expect(turnOf(userRecord('x', 1, { kind: 'system_trigger', name: 'subagent' }))).toEqual({ + kind: 'open', + anchor: false, + }); + }); + + it('marker origins open a turn only for user-slash prompts', () => { + expect( + turnOf(userRecord('/s', 1, { kind: 'skill_activation', trigger: 'user-slash' })), + ).toEqual({ kind: 'open', anchor: true }); + expect(turnOf(userRecord('/s', 1, { kind: 'plugin_command', trigger: 'user-slash' }))).toEqual({ + kind: 'open', + anchor: true, + }); + expect(turnOf(userRecord('s', 1, { kind: 'skill_activation', trigger: 'model-tool' }))).toEqual( + { + kind: 'none', + }, + ); + expect(turnOf(userRecord('sum', 1, { kind: 'compaction_summary' }))).toEqual({ kind: 'none' }); + }); + + it('cron / task / hook / shell_command origins open non-anchor turns', () => { + expect(turnOf(userRecord('cron', 1, { kind: 'cron_job', jobId: 'j' }))).toEqual({ + kind: 'open', + anchor: false, + }); + expect(turnOf(userRecord('t', 1, { kind: 'task', taskId: 't1' }))).toEqual({ + kind: 'open', + anchor: false, + }); + expect(turnOf(userRecord('h', 1, { kind: 'hook_result', event: 'stop' }))).toEqual({ + kind: 'open', + anchor: false, + }); + expect(turnOf(userRecord('!', 1, { kind: 'shell_command', phase: 'input' }))).toEqual({ + kind: 'open', + anchor: false, + }); + }); + + it('assistant-surviving loop events ensure a turn; tool results and bare steps do not', () => { + expect( + turnOf( + line({ + type: 'context.append_message', + message: { role: 'assistant', content: [{ type: 'text', text: 'a' }] }, + }), + ), + ).toEqual({ kind: 'ensure' }); + expect( + turnOf( + line({ + type: 'context.append_loop_event', + event: { type: 'content.part', part: { type: 'text', text: 'a' } }, + }), + ), + ).toEqual({ kind: 'ensure' }); + expect( + turnOf( + line({ + type: 'context.append_loop_event', + event: { type: 'tool.call', name: 'Bash', args: {} }, + }), + ), + ).toEqual({ kind: 'ensure' }); + expect( + turnOf( + line({ + type: 'context.append_loop_event', + event: { type: 'tool.result', result: { output: 'ok' } }, + }), + ), + ).toEqual({ kind: 'none' }); + expect( + turnOf(line({ type: 'context.append_loop_event', event: { type: 'step.begin' } })), + ).toEqual({ kind: 'none' }); + expect( + turnOf( + line({ + type: 'context.append_loop_event', + event: { type: 'content.part', part: { type: 'text', text: ' ' } }, + }), + ), + ).toEqual({ kind: 'none' }); + expect( + turnOf( + line({ + type: 'context.append_loop_event', + event: { type: 'content.part', part: { type: 'think', think: 'reasoning' } }, + }), + ), + ).toEqual({ kind: 'ensure' }); + expect( + turnOf( + line({ + type: 'context.append_loop_event', + event: { type: 'content.part', part: { type: 'think', think: ' ' } }, + }), + ), + ).toEqual({ kind: 'none' }); + expect( + turnOf( + line({ + type: 'context.append_loop_event', + event: { type: 'content.part', part: { type: 'think', think: '', encrypted: 'sig' } }, + }), + ), + ).toEqual({ kind: 'ensure' }); + }); + + it('system-role and tool-role messages have no effect', () => { + expect( + turnOf(line({ type: 'context.append_message', message: { role: 'system', content: [] } })), + ).toEqual({ kind: 'none' }); + expect( + turnOf( + line({ + type: 'context.append_message', + message: { role: 'tool', content: [{ type: 'text', text: 'out' }] }, + }), + ), + ).toEqual({ kind: 'none' }); + }); + + it('compaction and clear do NOT renumber; undo carries its count; invalid undo is none', () => { + expect( + turnOf(line({ type: 'context.apply_compaction', summary: 's', compactedCount: 2 })), + ).toEqual({ kind: 'none' }); + expect(turnOf(line({ type: 'context.clear' }))).toEqual({ kind: 'none' }); + expect(turnOf(line({ type: 'context.undo', count: 2 }))).toEqual({ kind: 'undo', count: 2 }); + expect(turnOf(line({ type: 'context.undo', count: 0 }))).toEqual({ kind: 'none' }); + expect(turnOf(line({ type: 'context.undo' }))).toEqual({ kind: 'none' }); + }); + + it('other record types have no effect', () => { + expect(turnOf(line({ type: 'metadata', protocol_version: '1.4' }))).toEqual({ kind: 'none' }); + expect(analyzeWireLine('{not json').turn).toEqual({ kind: 'none' }); + }); +}); + +describe('analyzeWireLine step effects', () => { + const stepOf = (jsonl: string) => analyzeWireLine(jsonl).step; + + it('step.begin maps its uuid to the wire-carried ordinal', () => { + expect( + stepOf( + line({ + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'u1', turnId: '0', step: 2 }, + }), + ), + ).toEqual({ kind: 'begin', uuid: 'u1', ordinal: 2 }); + }); + + it('step.begin without a step field leaves the ordinal to the fallback counter', () => { + expect( + stepOf( + line({ type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 'u1' } }), + ), + ).toEqual({ kind: 'begin', uuid: 'u1', ordinal: undefined }); + }); + + it('step.begin without a usable uuid or with an invalid ordinal degrades cleanly', () => { + expect( + stepOf(line({ type: 'context.append_loop_event', event: { type: 'step.begin' } })), + ).toEqual({ kind: 'none' }); + expect( + stepOf( + line({ + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'u1', step: -1 }, + }), + ), + ).toEqual({ kind: 'begin', uuid: 'u1', ordinal: undefined }); + }); + + it('step.end and other loop events have no step effect', () => { + expect( + stepOf(line({ type: 'context.append_loop_event', event: { type: 'step.end', uuid: 'u1' } })), + ).toEqual({ kind: 'none' }); + expect( + stepOf( + line({ + type: 'context.append_loop_event', + event: { type: 'tool.call', stepUuid: 'u1', name: 'Bash', args: {} }, + }), + ), + ).toEqual({ kind: 'none' }); + }); + + it('assistant text carries the content.part stepUuid; user text does not', () => { + const assistant = analyzeWireLine( + line({ + type: 'context.append_loop_event', + time: 1_700_000_000_000, + event: { type: 'content.part', stepUuid: 'u1', part: { type: 'text', text: 'plan' } }, + }), + ); + expect(assistant.messages).toEqual([ + { role: 'assistant', text: 'plan', time: 1_700_000_000_000, stepUuid: 'u1' }, + ]); + + const user = analyzeWireLine(userRecord('hi', 1)); + expect(user.messages[0]?.stepUuid).toBeUndefined(); + expect(user.step).toEqual({ kind: 'none' }); + }); +}); diff --git a/packages/kap-server/test/securityExposure.test.ts b/packages/kap-server/test/securityExposure.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d8444106af82e3ca302dcacdfaba7934b3aeef5 --- /dev/null +++ b/packages/kap-server/test/securityExposure.test.ts @@ -0,0 +1,122 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +describe('server-v2 exposure hardening hooks', () => { + let server: RunningServer | undefined; + let home: string | undefined; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-exposure-')); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it('rejects a disallowed Host header with 40301', async () => { + const res = await server!.app.inject({ + method: 'GET', + url: '/api/v1/healthz', + headers: { host: 'evil.com' }, + }); + expect(res.statusCode).toBe(403); + const body = res.json() as Record; + expect(body['code']).toBe(40301); + }); + + it('allows the default loopback Host header', async () => { + const res = await server!.app.inject({ method: 'GET', url: '/api/v1/healthz' }); + expect(res.statusCode).toBe(200); + }); + + it('echoes CORS headers for a same-origin request', async () => { + const res = await server!.app.inject({ + method: 'GET', + url: '/api/v1/healthz', + headers: { origin: 'http://localhost:80', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe('http://localhost:80'); + }); + + it('refuses to bind non-loopback hosts without TLS opt-out', async () => { + await expect( + startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '0.0.0.0', port: 0, homeDir: home, logLevel: 'silent' }), + ).rejects.toThrow(/Refusing to bind 0\.0\.0\.0/); + }); + + it('sets security headers on a non-loopback bind without HSTS', async () => { + const alt = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '0.0.0.0', + port: 0, + homeDir: home, + logLevel: 'silent', + insecureNoTls: true, + }); + try { + const res = await alt.app.inject({ method: 'GET', url: '/api/v1/healthz' }); + expect(res.statusCode).toBe(200); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + expect(res.headers['referrer-policy']).toBe('no-referrer'); + expect(res.headers['content-security-policy']).toBe( + "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'self'", + ); + expect(res.headers['strict-transport-security']).toBeUndefined(); + } finally { + await alt.close(); + } + }); + + it('does not set security headers on a loopback bind', async () => { + const res = await server!.app.inject({ method: 'GET', url: '/api/v1/healthz' }); + expect(res.statusCode).toBe(200); + expect(res.headers['x-content-type-options']).toBeUndefined(); + expect(res.headers['referrer-policy']).toBeUndefined(); + expect(res.headers['content-security-policy']).toBeUndefined(); + expect(res.headers['strict-transport-security']).toBeUndefined(); + }); + + it('does not register shutdown or terminal routes on non-loopback by default', async () => { + const alt = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '0.0.0.0', + port: 0, + homeDir: home, + logLevel: 'silent', + insecureNoTls: true, + }); + try { + const token = alt.authTokenService.getToken(); + const shutdown = await alt.app.inject({ + method: 'POST', + url: '/api/v1/shutdown', + headers: { authorization: `Bearer ${token}` }, + }); + expect(shutdown.statusCode).toBe(404); + + const terminals = await alt.app.inject({ + method: 'GET', + url: '/api/v1/sessions/missing/terminals', + headers: { authorization: `Bearer ${token}` }, + }); + expect(terminals.statusCode).toBe(404); + } finally { + await alt.close(); + } + }); +}); diff --git a/packages/kap-server/test/securityHeaders.test.ts b/packages/kap-server/test/securityHeaders.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc4672fa1b3a74612bab5e4cc13ad36966572311 --- /dev/null +++ b/packages/kap-server/test/securityHeaders.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { createSecurityHeadersHook } from '../src/middleware/securityHeaders'; + +function captureHeaders() { + const headers = new Map(); + const reply = { + header(name: string, value: string) { + headers.set(name.toLowerCase(), value); + return reply; + }, + }; + return { headers, reply }; +} + +function parseCsp(csp: string): Map { + const directives = new Map(); + for (const part of csp.split(';')) { + const tokens = part.trim().split(/\s+/).filter((t) => t.length > 0); + if (tokens.length === 0) continue; + directives.set(tokens[0] as string, tokens.slice(1)); + } + return directives; +} + +describe('createSecurityHeadersHook', () => { + it('stamps the defensive headers and returns the payload unchanged', async () => { + const { headers, reply } = captureHeaders(); + const hook = createSecurityHeadersHook({ tls: false }); + const payload = { ok: true }; + const result = await hook({} as never, reply as never, payload); + expect(result).toBe(payload); + expect(headers.get('x-content-type-options')).toBe('nosniff'); + expect(headers.get('referrer-policy')).toBe('no-referrer'); + expect(headers.get('content-security-policy')).toBeDefined(); + }); + + it('allows inline styles while keeping inline scripts forbidden', async () => { + const { headers, reply } = captureHeaders(); + const hook = createSecurityHeadersHook({ tls: false }); + await hook({} as never, reply as never, 'payload'); + const csp = headers.get('content-security-policy'); + expect(csp).toBeDefined(); + const directives = parseCsp(csp ?? ''); + const styleSrc = directives.get('style-src'); + expect(styleSrc).toContain("'self'"); + expect(styleSrc).toContain("'unsafe-inline'"); + const effectiveScriptSrc = directives.get('script-src') ?? directives.get('default-src'); + expect(effectiveScriptSrc).toBeDefined(); + expect(effectiveScriptSrc).not.toContain("'unsafe-inline'"); + expect(effectiveScriptSrc).not.toContain("'unsafe-eval'"); + expect(effectiveScriptSrc).not.toContain('data:'); + }); + + it('emits HSTS only when TLS is terminated at the server', async () => { + const plain = captureHeaders(); + await createSecurityHeadersHook({ tls: false })({} as never, plain.reply as never, ''); + expect(plain.headers.has('strict-transport-security')).toBe(false); + + const tls = captureHeaders(); + await createSecurityHeadersHook({ tls: true })({} as never, tls.reply as never, ''); + expect(tls.headers.get('strict-transport-security')).toBe('max-age=31536000'); + }); +}); diff --git a/packages/kap-server/test/services/history.test.ts b/packages/kap-server/test/services/history.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f783f47a96d7f8e84c7f7f2cb6fadb1e771a5fd0 --- /dev/null +++ b/packages/kap-server/test/services/history.test.ts @@ -0,0 +1,1446 @@ +import { describe, expect, it } from 'vitest'; + +import { + historyMessageSchema, + type HistoryMessage, + type ServerMessage, + serverMessageSchema, +} from '../../src/protocol/messages'; +import { AgentMessageProjector } from '../../src/services/projection/agentProjector'; +import type { ProjectionBusEvent } from '../../src/services/projection/events'; +import type { ContextRecord } from '../../src/services/projection/heal'; +import { foldTimelineSeed } from '../../src/services/projection/heal'; +import { foldWireHistory, paginateHistory, type ColdFoldOptions } from '../../src/services/history'; + +const SESSION = 's1'; +const T0 = 1_700_000_000_000; + +function iso(ms: number): string { + return new Date(ms).toISOString(); +} + +function rec(type: string, fields: Record = {}, time = T0): ContextRecord { + return { type, time, ...fields } as ContextRecord; +} + +function loopEvent(event: Record, time = T0): ContextRecord { + return rec('context.append_loop_event', { event }, time); +} + +function fold( + records: readonly ContextRecord[], + opts: Partial = {}, +): HistoryMessage[] { + const out = foldWireHistory(records, { + sessionId: SESSION, + agentId: 'main', + live: false, + fallbackTimestamp: T0, + ...opts, + }); + for (const message of out) historyMessageSchema.parse(message); + return out; +} + +function ofType( + messages: readonly HistoryMessage[], + type: T, +): Extract[] { + return messages.filter((m): m is Extract => m.type === type); +} + +function ev(payload: Record): ProjectionBusEvent { + return { time: T0, ...payload } as unknown as ProjectionBusEvent; +} + +describe('foldWireHistory turn lifecycle', () => { + const records: ContextRecord[] = [ + rec('turn.prompt', { + input: [{ type: 'text', text: 'fix the bug' }], + origin: { kind: 'user' }, + promptId: 'p1', + }), + rec( + 'context.append_message', + { + message: { + id: 'p1', + role: 'user', + content: [{ type: 'text', text: 'fix the bug' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + T0 + 1, + ), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 2), + loopEvent({ type: 'content.part', stepUuid: 'u1', part: { type: 'think', think: 'hmm' } }, T0 + 3), + loopEvent({ type: 'content.part', stepUuid: 'u1', part: { type: 'text', text: 'Hello' } }, T0 + 4), + loopEvent( + { type: 'tool.call', stepUuid: 'u1', toolCallId: 'call_1', name: 'Bash', args: '{"command":"ls"}' }, + T0 + 5, + ), + loopEvent({ type: 'tool.result', toolCallId: 'call_1', result: { output: 'file.txt' } }, T0 + 6), + loopEvent( + { + type: 'step.end', + uuid: 'u1', + finishReason: 'stop', + usage: { inputOther: 10, output: 5, inputCacheRead: 2, inputCacheCreation: 1 }, + llmFirstTokenLatencyMs: 100, + llmStreamDurationMs: 900, + }, + T0 + 7, + ), + loopEvent({ type: 'step.begin', uuid: 'u2', turnId: '0', step: 2 }, T0 + 8), + loopEvent({ type: 'content.part', stepUuid: 'u2', part: { type: 'think', think: 'summary', hidden: true } }, T0 + 9), + loopEvent({ type: 'content.part', stepUuid: 'u2', part: { type: 'text', text: 'done' } }, T0 + 10), + loopEvent({ type: 'step.end', uuid: 'u2', finishReason: 'stop' }, T0 + 11), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 1500 }, T0 + 12), + ]; + + it('rebuilds a full turn into flat entity messages with shared id rules', () => { + const messages = fold(records); + expect(messages.map((m) => m.type)).toEqual([ + 'turn', + 'user', + 'step', + 'thinking', + 'assistant', + 'tool_call', + 'step', + 'assistant', + ]); + const turn = ofType(messages, 'turn')[0]!; + expect(turn).toMatchObject({ + turn_id: 't0', + ordinal: 0, + status: 'completed', + origin: { kind: 'user' }, + user_message_id: 'p1', + started_at: iso(T0), + ended_at: iso(T0 + 12), + duration_ms: 1500, + usage: { input_tokens: 11, output_tokens: 5, cached_tokens: 2 }, + }); + const user = ofType(messages, 'user')[0]!; + expect(user).toMatchObject({ + message_id: 'p1', + turn_id: 't0', + text: [{ type: 'text', text: 'fix the bug', meta: {} }], + status: 'read', + timestamp: T0, + }); + const step = ofType(messages, 'step')[0]!; + expect(step).toMatchObject({ + step_id: 't0.1', + ordinal: 1, + status: 'completed', + started_at: iso(T0 + 2), + ended_at: iso(T0 + 7), + usage: { input_other: 10, output: 5, input_cache_read: 2, input_cache_creation: 1 }, + finish_reason: 'stop', + timing: { llm_first_token_ms: 100, llm_stream_duration_ms: 900 }, + }); + const thinking = ofType(messages, 'thinking')[0]!; + expect(thinking).toMatchObject({ message_id: 't0.1.a1', status: 'completed', text: 'hmm' }); + expect(ofType(messages, 'thinking')).toHaveLength(1); + const assistant = ofType(messages, 'assistant')[0]!; + expect(assistant).toMatchObject({ message_id: 't0.1.a2', status: 'completed', text: 'Hello' }); + const tool = ofType(messages, 'tool_call')[0]!; + expect(tool).toMatchObject({ + tool_call_id: 'call_1', + step_id: 't0.1', + name: 'Bash', + status: 'done', + input: { command: 'ls' }, + output: 'file.txt', + }); + const stepTwoAssistant = ofType(messages, 'assistant')[1]!; + expect(stepTwoAssistant).toMatchObject({ + message_id: 't0.2.a1', + step_id: 't0.2', + status: 'completed', + text: 'done', + }); + }); + + it('finalizes an unfinished turn by session liveness', () => { + const inFlight: ContextRecord[] = [ + rec('turn.prompt', { input: [{ type: 'text', text: 'go' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), + loopEvent({ type: 'content.part', stepUuid: 'u1', part: { type: 'text', text: 'partial' } }, T0 + 2), + loopEvent( + { type: 'tool.call', stepUuid: 'u1', toolCallId: 'call_1', name: 'Bash', args: '{}' }, + T0 + 3, + ), + rec('interaction.request', { + id: 'apr-1', + kind: 'approval', + toolCallId: 'call_1', + request: { toolCallId: 'call_1', toolName: 'Bash', action: 'Run' }, + }), + ]; + const live = fold(inFlight, { live: true }); + expect(ofType(live, 'turn')[0]).toMatchObject({ status: 'running' }); + expect(ofType(live, 'step')[0]).toMatchObject({ status: 'running' }); + expect(ofType(live, 'assistant')[0]).toMatchObject({ status: 'streaming', text: 'partial' }); + expect(ofType(live, 'tool_call')[0]).toMatchObject({ status: 'running' }); + expect(ofType(live, 'user')[0]).toMatchObject({ status: 'read' }); + expect(ofType(live, 'interaction')[0]).toMatchObject({ status: 'pending' }); + + const dead = fold(inFlight); + expect(ofType(dead, 'turn')[0]).toMatchObject({ status: 'completed' }); + expect(ofType(dead, 'step')[0]).toMatchObject({ status: 'interrupted' }); + expect(ofType(dead, 'assistant')[0]).toMatchObject({ status: 'completed' }); + expect(ofType(dead, 'tool_call')[0]).toMatchObject({ status: 'done' }); + expect(ofType(dead, 'user')[0]).toMatchObject({ status: 'read' }); + expect(ofType(dead, 'interaction')[0]).toMatchObject({ status: 'cancelled' }); + }); +}); + +describe('foldWireHistory origin classification', () => { + it('maps prompt origins to turn origins and hides non-visible prompts', () => { + const prompts: [number, Record][] = [ + [0, { kind: 'user' }], + [1, { kind: 'cron_job', jobId: 'j1', cron: '*/5 * * * *' }], + [2, { kind: 'task', taskId: 'task-9' }], + [3, { kind: 'hook_result', event: 'SessionStart' }], + [4, { kind: 'system_trigger', name: 'goal_continuation' }], + [5, { kind: 'system_trigger', name: 'subagent' }], + [6, { kind: 'injection', variant: 'reminder' }], + [7, { kind: 'retry' }], + [8, { kind: 'compaction_summary' }], + [9, { kind: 'skill_activation', trigger: 'user-slash', activationId: 'a1', skillName: 'review' }], + [10, { kind: 'shell_command', phase: 'input' }], + [11, { kind: 'background_task', taskId: 'task-7' }], + ]; + const records: ContextRecord[] = prompts.map(([ordinal, origin]) => + rec('turn.prompt', { input: [{ type: 'text', text: `p${ordinal}` }], origin }, T0 + ordinal), + ); + const messages = fold(records); + const turns = ofType(messages, 'turn'); + expect(turns.map((t) => t.turn_id)).toEqual([ + 't0', + 't1', + 't2', + 't3', + 't4', + 't5', + 't9', + 't10', + 't11', + ]); + expect(turns.map((t) => t.origin)).toEqual([ + { kind: 'user' }, + { kind: 'cron' }, + { kind: 'task', task_id: 'task-9' }, + { kind: 'hook' }, + { kind: 'goal' }, + { kind: 'other' }, + { kind: 'user' }, + { kind: 'user' }, + { kind: 'task', task_id: 'task-7' }, + ]); + const cronUser = ofType(messages, 'user').find((u) => u.turn_id === 't1')!; + expect(cronUser.origin).toEqual({ kind: 'cron', cron_id: 'j1', schedule: '*/5 * * * *' }); + expect(ofType(messages, 'user').some((u) => u.turn_id === 't4')).toBe(false); + const skillUser = ofType(messages, 'user').find((u) => u.turn_id === 't9')!; + expect(skillUser.origin).toEqual({ kind: 'skill', skill_name: 'review', trigger: 'user-slash' }); + expect(ofType(messages, 'system')).toHaveLength(0); + }); + + it('bundles skill activations into the user message skill_activations without a system message', () => { + const messages = fold([ + rec('turn.prompt', { + input: [ + { type: 'text', text: '/review args' }, + { type: 'text', text: 'check this' }, + ], + origin: { + kind: 'user', + skillActivations: [ + { activationId: 'a1', skillName: 'review', skillArgs: 'args' }, + ], + }, + }), + ]); + const user = ofType(messages, 'user')[0]!; + expect(user).toMatchObject({ + text: [{ type: 'text', text: 'check this', meta: {} }], + skill_activations: [{ skill_name: 'review', skill_args: 'args' }], + }); + expect(ofType(messages, 'system')).toHaveLength(0); + }); +}); + +describe('foldWireHistory steer', () => { + it('attaches steers to the running step, buffers between steps, and dedupes the turn-opening steer', () => { + const messages = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'do A' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), + rec('turn.steer', { input: [{ type: 'text', text: 'also B' }], origin: { kind: 'user' } }, T0 + 2), + loopEvent({ type: 'step.end', uuid: 'u1' }, T0 + 3), + rec('turn.steer', { input: [{ type: 'text', text: 'and C' }], origin: { kind: 'user' } }, T0 + 4), + loopEvent({ type: 'step.begin', uuid: 'u2', turnId: '0', step: 2 }, T0 + 5), + rec('turn.ended', { turnId: 0, reason: 'completed' }, T0 + 6), + ]); + const users = ofType(messages, 'user'); + const inStep = users.find((u) => u.message_id === 't0.u1')!; + expect(inStep).toMatchObject({ + turn_id: 't0', + text: [{ type: 'text', text: 'also B', meta: {} }], + status: 'read', + timestamp: T0 + 2, + }); + const betweenSteps = users.find((u) => u.message_id === 't0.u2')!; + expect(betweenSteps).toMatchObject({ + turn_id: 't0', + text: [{ type: 'text', text: 'and C', meta: {} }], + status: 'read', + timestamp: T0 + 4, + }); + + const deduped = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'hello' }], origin: { kind: 'user' } }), + rec('turn.steer', { input: [{ type: 'text', text: 'hello' }], origin: { kind: 'user' } }, T0 + 1), + ]); + const dedupedUsers = ofType(deduped, 'user'); + expect(dedupedUsers).toHaveLength(1); + expect(dedupedUsers[0]).toMatchObject({ + message_id: 't0.u0', + text: [{ type: 'text', text: 'hello', meta: {} }], + }); + }); + + it('emits steers read at their record without synthesizing a step', () => { + const attached = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'do A' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), + loopEvent({ type: 'step.end', uuid: 'u1' }, T0 + 2), + rec('turn.steer', { input: [{ type: 'text', text: 'last' }], origin: { kind: 'user' } }, T0 + 3), + rec('turn.ended', { turnId: 0, reason: 'cancelled' }, T0 + 4), + ]); + expect(ofType(attached, 'step').map((s) => s.step_id)).toEqual(['t0.1']); + const steer = ofType(attached, 'user').find((u) => u.message_id === 't0.u1')!; + expect(steer).toMatchObject({ + turn_id: 't0', + text: [{ type: 'text', text: 'last', meta: {} }], + status: 'read', + timestamp: T0 + 3, + }); + + const stepFree = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'do A' }], origin: { kind: 'user' } }), + rec('turn.steer', { input: [{ type: 'text', text: 'early' }], origin: { kind: 'user' } }, T0 + 1), + rec('turn.ended', { turnId: 0, reason: 'cancelled' }, T0 + 2), + ]); + expect(ofType(stepFree, 'step')).toHaveLength(0); + const early = ofType(stepFree, 'user').find((u) => u.message_id === 't0.u1')!; + expect(early).toMatchObject({ + turn_id: 't0', + text: [{ type: 'text', text: 'early', meta: {} }], + status: 'read', + timestamp: T0 + 1, + }); + }); +}); + +describe('foldWireHistory task notifications', () => { + const xmlFor = (taskId: string, status: string, title: string, severity: string, body: string): string => + `\nTitle: ${title}\nSeverity: ${severity}\n${body}`; + + const notificationMessage = (taskId: string, status: string, xml: string): Record => ({ + message: { + role: 'user', + content: [{ type: 'text', text: xml }], + toolCalls: [], + origin: { kind: 'task', taskId, status, notificationId: `task:${taskId}:${status}` }, + }, + }); + + it('rebuilds notification user messages from idle turns, busy appends and turn-less restores', () => { + const xml1 = xmlFor('task-1', 'completed', 'Task completed', 'info', 'build finished'); + const xml2 = xmlFor('task-2', 'failed', 'Task failed', 'warning', 'tests broke'); + const xml3 = xmlFor('task-9', 'completed', 'Restored', 'info', 'from previous session'); + const messages = fold([ + rec('turn.prompt', { + input: [{ type: 'text', text: xml1 }], + origin: { kind: 'task', taskId: 'task-1', status: 'completed', notificationId: 'task:task-1:completed' }, + }), + rec('context.append_message', notificationMessage('task-1', 'completed', xml1), T0 + 1), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 2), + loopEvent({ type: 'step.end', uuid: 'u1' }, T0 + 3), + rec('turn.ended', { turnId: 0, reason: 'completed' }, T0 + 4), + rec('turn.prompt', { input: [{ type: 'text', text: 'next' }], origin: { kind: 'user' } }, T0 + 5), + loopEvent({ type: 'step.begin', uuid: 'v1', turnId: '1', step: 1 }, T0 + 6), + loopEvent({ type: 'step.end', uuid: 'v1' }, T0 + 7), + rec('context.append_message', notificationMessage('task-2', 'failed', xml2), T0 + 8), + loopEvent({ type: 'step.begin', uuid: 'v2', turnId: '1', step: 2 }, T0 + 9), + rec('turn.ended', { turnId: 1, reason: 'completed' }, T0 + 10), + rec('context.append_message', notificationMessage('task-9', 'completed', xml3), T0 + 11), + ]); + const turn0 = ofType(messages, 'turn')[0]!; + expect(turn0).toMatchObject({ + origin: { kind: 'task', task_id: 'task-1' }, + user_message_id: 't0.u0', + }); + const opening = ofType(messages, 'user').find((u) => u.message_id === 't0.u0')!; + expect(opening).toMatchObject({ + turn_id: 't0', + text: [{ type: 'text', text: 'Task completed\nbuild finished', meta: {} }], + status: 'read', + timestamp: T0, + origin: { + kind: 'task', + task_id: 'task-1', + title: 'Task completed', + body: 'build finished', + severity: 'info', + type: 'task.completed', + source_kind: 'background_task', + source_id: 'task-1', + raw: xml1, + }, + }); + expect( + ofType(messages, 'user').filter( + (u) => u.origin?.kind === 'task' && u.origin.task_id === 'task-1', + ), + ).toHaveLength(1); + const injected = ofType(messages, 'user').find((u) => u.message_id === 't1.u1')!; + expect(injected).toMatchObject({ + turn_id: 't1', + text: [{ type: 'text', text: 'Task failed\ntests broke', meta: {} }], + status: 'read', + timestamp: T0 + 8, + origin: { + kind: 'task', + task_id: 'task-2', + title: 'Task failed', + body: 'tests broke', + severity: 'warning', + type: 'task.failed', + source_kind: 'background_task', + source_id: 'task-2', + raw: xml2, + }, + }); + const phantom = ofType(messages, 'user').find((u) => u.message_id === 't2.u1')!; + expect(phantom).toMatchObject({ + turn_id: 't2', + text: [{ type: 'text', text: 'Restored\nfrom previous session', meta: {} }], + status: 'read', + timestamp: T0 + 11, + origin: { + kind: 'task', + task_id: 'task-9', + title: 'Restored', + body: 'from previous session', + severity: 'info', + type: 'task.completed', + source_kind: 'background_task', + source_id: 'task-9', + raw: xml3, + }, + }); + }); + + it('tags user-slash skill prompts and steers with the skill user origin', () => { + const messages = fold([ + rec('turn.prompt', { + input: [{ type: 'text', text: 'review the code' }], + origin: { + kind: 'skill_activation', + skillName: 'review', + skillArgs: 'src/', + trigger: 'user-slash', + activationId: 'a1', + }, + }), + rec( + 'turn.steer', + { + input: [{ type: 'text', text: 'skill body' }], + origin: { kind: 'skill_activation', skillName: 'deploy', trigger: 'user-slash', activationId: 'a2' }, + }, + T0 + 1, + ), + rec( + 'turn.steer', + { + input: [{ type: 'text', text: 'model triggered' }], + origin: { kind: 'skill_activation', skillName: 'internal', trigger: 'model-tool', activationId: 'a3' }, + }, + T0 + 2, + ), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 3), + ]); + const users = ofType(messages, 'user'); + expect(users).toHaveLength(2); + expect(users[0]).toMatchObject({ + message_id: 't0.u0', + status: 'read', + origin: { kind: 'skill', skill_name: 'review', args: 'src/', trigger: 'user-slash' }, + skill_activations: [{ skill_name: 'review', skill_args: 'src/' }], + }); + expect(users[1]).toMatchObject({ + message_id: 't0.u1', + status: 'read', + origin: { kind: 'skill', skill_name: 'deploy', trigger: 'user-slash' }, + }); + }); +}); + +describe('foldWireHistory undo and clear', () => { + function anchorTurn(ordinal: number, promptId: string, time: number): ContextRecord[] { + return [ + rec( + 'turn.prompt', + { input: [{ type: 'text', text: promptId }], origin: { kind: 'user' }, promptId }, + time, + ), + rec( + 'context.append_message', + { + message: { + id: promptId, + role: 'user', + content: [{ type: 'text', text: promptId }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + time + 1, + ), + rec('turn.ended', { turnId: ordinal, reason: 'completed' }, time + 2), + ]; + } + + it('truncates undone turns and emits system(undo) with the removed top-level ids', () => { + const messages = fold([ + ...anchorTurn(0, 'p0', T0), + ...anchorTurn(1, 'p1', T0 + 10), + rec('context.undo', { count: 1 }, T0 + 20), + ]); + expect(ofType(messages, 'turn').map((t) => t.turn_id)).toEqual(['t0']); + const undo = ofType(messages, 'system').find((m) => m.subtype === 'undo')!; + expect(undo).toMatchObject({ subtype: 'undo', payload: { removed_ids: ['t1'] } }); + expect(ofType(messages, 'user').map((u) => u.message_id)).toEqual(['p0']); + + const both = fold([ + ...anchorTurn(0, 'p0', T0), + ...anchorTurn(1, 'p1', T0 + 10), + rec('context.undo', { count: 2 }, T0 + 20), + ]); + expect(ofType(both, 'turn')).toHaveLength(0); + const undoBoth = ofType(both, 'system').find((m) => m.subtype === 'undo')!; + expect(undoBoth.payload).toEqual({ removed_ids: ['t0', 't1'] }); + }); + + it('does not cross the compaction anchor floor on undo', () => { + const messages = fold([ + ...anchorTurn(0, 'p0', T0), + rec('context.apply_compaction', { summary: 'summary', compactedCount: 1 }, T0 + 10), + ...anchorTurn(1, 'p1', T0 + 20), + rec('context.undo', { count: 1 }, T0 + 30), + rec('context.undo', { count: 1 }, T0 + 31), + ]); + expect(ofType(messages, 'turn').map((t) => t.turn_id)).toEqual(['t0']); + expect(ofType(messages, 'system').filter((m) => m.subtype === 'undo')).toHaveLength(1); + const compaction = ofType(messages, 'system').find((m) => m.subtype === 'compaction')!; + expect(compaction.payload).toEqual({ phase: 'completed', text: 'summary' }); + }); + + it('rewrites the whole timeline on clear with every removed id', () => { + const messages = fold([ + ...anchorTurn(0, 'p0', T0), + rec('goal.create', { objective: 'ship' }, T0 + 10), + rec('context.clear', {}, T0 + 20), + rec( + 'turn.prompt', + { input: [{ type: 'text', text: 'fresh' }], origin: { kind: 'user' }, promptId: 'p1' }, + T0 + 30, + ), + ]); + const clear = ofType(messages, 'system').find((m) => m.subtype === 'clear')!; + expect(clear).toMatchObject({ subtype: 'clear', payload: { removed_ids: ['t0', 'sys_goal_1'] } }); + expect(ofType(messages, 'turn').map((t) => t.turn_id)).toEqual(['t1']); + expect(ofType(messages, 'system').filter((m) => m.subtype === 'goal')).toHaveLength(0); + }); +}); + +describe('foldWireHistory interactions, facts and modes', () => { + it('projects approval interactions and links them to their tool call', () => { + const messages = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'go' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), + loopEvent( + { type: 'tool.call', stepUuid: 'u1', toolCallId: 'call_1', name: 'Bash', args: '{}' }, + T0 + 2, + ), + rec( + 'interaction.request', + { + id: 'apr-1', + kind: 'approval', + toolCallId: 'call_1', + request: { + toolCallId: 'call_1', + toolName: 'Bash', + action: 'Run ls', + display: { kind: 'command' }, + }, + }, + T0 + 3, + ), + rec('interaction.resolved', { id: 'apr-1', response: { decision: 'approved', scope: 'session' } }, T0 + 4), + ]); + const interaction = ofType(messages, 'interaction')[0]!; + expect(interaction).toMatchObject({ + interaction_id: 'apr-1', + kind: 'approval', + status: 'approved', + tool_call_id: 'call_1', + request: { tool_name: 'Bash', action: 'Run ls', tool_input_display: { kind: 'command' } }, + response: { decision: 'approved', scope: 'session' }, + }); + expect(ofType(messages, 'tool_call')[0]!.approval_id).toBe('apr-1'); + }); + + it('rewrites question payloads into the contract shape and maps answers back', () => { + const messages = fold([ + rec('interaction.request', { + id: 'q-1', + kind: 'question', + request: { + questions: [ + { + question: 'pick', + header: 'h', + options: [ + { label: 'a', description: 'da' }, + { label: 'b' }, + ], + multiSelect: true, + }, + ], + }, + }), + rec('interaction.resolved', { id: 'q-1', response: { answers: { pick: 'a' }, method: 'click' } }), + ]); + const interaction = ofType(messages, 'interaction')[0]!; + expect(interaction).toMatchObject({ kind: 'question', status: 'answered' }); + expect(interaction.request).toEqual({ + questions: [ + { + id: 'q_0', + question: 'pick', + header: 'h', + options: [ + { id: 'opt_0_0', label: 'a', description: 'da' }, + { id: 'opt_0_1', label: 'b', description: undefined }, + ], + multi_select: true, + allow_other: true, + }, + ], + }); + expect(interaction.response).toEqual({ + answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } }, + method: 'click', + }); + }); + + it('folds goal, plan, swarm and task records into system and task entities', () => { + const messages = fold([ + rec('goal.create', { objective: 'ship', completionCriterion: 'tests pass' }, T0), + rec('goal.update', { status: 'blocked', tokensUsed: 42, budgetLimits: { tokenBudget: 100 } }, T0 + 1), + rec('goal.clear', {}, T0 + 2), + rec('plan_mode.enter', { id: 'plan-1' }, T0 + 3), + rec( + 'plan.revision', + { id: 'r1', version: 2, key: 'plan/x/v2.md', sha256: 'abc', bytes: 10 }, + T0 + 4, + ), + rec('plan_mode.exit', {}, T0 + 5), + rec('plan_mode.enter', { id: 'plan-2' }, T0 + 6), + rec('plan_mode.cancel', {}, T0 + 7), + rec('swarm_mode.enter', { trigger: 'user' }, T0 + 8), + rec('swarm_mode.exit', {}, T0 + 9), + rec( + 'task.started', + { + info: { + taskId: 'task-1', + kind: 'process', + status: 'running', + description: 'dev server', + detached: true, + startedAt: T0, + }, + }, + T0 + 10, + ), + rec( + 'task.terminated', + { + info: { taskId: 'task-1', kind: 'process', status: 'completed', endedAt: T0 + 11 }, + outputTail: 'logs', + }, + T0 + 11, + ), + ], { + resolvePlanRevisionKey: (key) => `resolved/${key}`, + }); + const goals = ofType(messages, 'system').filter((m) => m.subtype === 'goal'); + expect(goals.map((m) => m.payload)).toEqual([ + { objective: 'ship', status: 'active', completion_criterion: 'tests pass', budget_used: 0, budget_limit: undefined }, + { objective: 'ship', status: 'blocked', completion_criterion: 'tests pass', budget_used: 42, budget_limit: 100 }, + undefined, + ]); + expect(ofType(messages, 'system').map((m) => m.subtype)).toEqual([ + 'goal', + 'goal', + 'goal', + 'plan.enter', + 'plan.revision', + 'plan.exit', + 'plan.enter', + 'swarm.enter', + 'swarm.exit', + ]); + const revision = ofType(messages, 'system').find((m) => m.subtype === 'plan.revision')!; + expect(revision.payload).toEqual({ + id: 'r1', + version: 2, + path: 'resolved/plan/x/v2.md', + sha256: 'abc', + bytes: 10, + }); + const task = ofType(messages, 'task')[0]!; + expect(task).toMatchObject({ + task_id: 'task-1', + kind: 'shell', + status: 'completed', + detached: true, + description: 'dev server', + output_tail: 'logs', + started_at: iso(T0), + ended_at: iso(T0 + 11), + }); + }); + + it('links subagent tasks to their parent tool call with agent refs', () => { + const messages = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'go' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), + loopEvent( + { type: 'tool.call', stepUuid: 'u1', toolCallId: 'call_9', name: 'Agent', args: '{}' }, + T0 + 2, + ), + rec( + 'task.started', + { + info: { + taskId: 'task-2', + kind: 'agent', + agentId: 'sub-1', + parentToolCallId: 'call_9', + status: 'running', + model: 'k2', + thinkingEffort: 'high', + }, + }, + T0 + 3, + ), + ]); + const task = ofType(messages, 'task')[0]!; + expect(task).toMatchObject({ + task_id: 'task-2', + kind: 'subagent', + child_agent_id: 'sub-1', + model: 'k2', + thinking_effort: 'high', + }); + const tool = ofType(messages, 'tool_call')[0]!; + expect(tool).toMatchObject({ + task_id: 'task-2', + agent_refs: [{ agent_id: 'sub-1', role: 'child' }], + }); + }); +}); + +describe('foldWireHistory queued prompts and legacy messages', () => { + it('emits queued prompts as turn-less unread user messages keyed by the prompt id', () => { + const messages = fold( + [ + rec('prompt.accepted', { promptId: 'q1', content: [{ type: 'text', text: 'first' }] }, T0), + rec('prompt.accepted', { promptId: 'q2', content: [{ type: 'text', text: 'second' }] }, T0 + 1), + rec( + 'turn.prompt', + { input: [{ type: 'text', text: 'first' }], origin: { kind: 'user' }, promptId: 'q1' }, + T0 + 2, + ), + rec('turn.ended', { turnId: 0, reason: 'completed' }, T0 + 3), + ], + { live: true }, + ); + const users = ofType(messages, 'user'); + expect(users.map((u) => u.message_id)).toEqual(['q1', 'q2']); + const queued = users[1]!; + expect(queued).toMatchObject({ + text: [{ type: 'text', text: 'second', meta: {} }], + status: 'unread', + }); + expect(queued.turn_id).toBeUndefined(); + expect(queued.timestamp).toBeUndefined(); + expect(ofType(messages, 'turn').map((t) => t.turn_id)).toEqual(['t0']); + + const aborted = fold([ + rec('prompt.accepted', { promptId: 'q1', content: [{ type: 'text', text: 'first' }] }, T0), + rec('prompt.aborted', { promptId: 'q1' }, T0 + 1), + ]); + expect(ofType(aborted, 'user')).toHaveLength(0); + }); + + it('rebuilds legacy append-only assistant and tool messages', () => { + const messages = fold([ + rec('context.append_message', { + message: { + role: 'assistant', + content: [ + { type: 'think', think: 'summary', hidden: true }, + { type: 'text', text: 'hi' }, + ], + toolCalls: [{ id: 'call_1', name: 'Bash', arguments: '{"cmd":"ls"}' }], + }, + }), + rec( + 'context.append_message', + { message: { role: 'tool', content: [{ type: 'text', text: 'file.txt' }], toolCalls: [], toolCallId: 'call_1' } }, + T0 + 1, + ), + ]); + const turn = ofType(messages, 'turn')[0]!; + expect(turn).toMatchObject({ turn_id: 't0', origin: { kind: 'other' } }); + expect(ofType(messages, 'thinking')).toHaveLength(0); + const assistant = ofType(messages, 'assistant')[0]!; + expect(assistant).toMatchObject({ message_id: 't0.1.a1', text: 'hi', status: 'completed' }); + const tool = ofType(messages, 'tool_call')[0]!; + expect(tool).toMatchObject({ tool_call_id: 'call_1', status: 'done', output: 'file.txt' }); + }); +}); + +describe('foldWireHistory todo restoration', () => { + it('restores the todo entity from the last done TodoWrite input and reverts with undo', () => { + const first: ContextRecord[] = [ + rec('turn.prompt', { input: [{ type: 'text', text: 'one' }], origin: { kind: 'user' }, promptId: 'p0' }), + rec( + 'context.append_message', + { + message: { + id: 'p0', + role: 'user', + content: [{ type: 'text', text: 'one' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + T0 + 1, + ), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 2), + loopEvent( + { + type: 'tool.call', + stepUuid: 'u1', + toolCallId: 'call_t1', + name: 'TodoList', + args: '{"todos":[{"title":"a","status":"done"}]}', + }, + T0 + 3, + ), + loopEvent({ type: 'tool.result', toolCallId: 'call_t1', result: { output: 'ok' } }, T0 + 4), + rec('turn.ended', { turnId: 0, reason: 'completed' }, T0 + 5), + ]; + const second: ContextRecord[] = [ + rec( + 'turn.prompt', + { input: [{ type: 'text', text: 'two' }], origin: { kind: 'user' }, promptId: 'p1' }, + T0 + 10, + ), + rec( + 'context.append_message', + { + message: { + id: 'p1', + role: 'user', + content: [{ type: 'text', text: 'two' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + T0 + 11, + ), + loopEvent({ type: 'step.begin', uuid: 'u2', turnId: '1', step: 1 }, T0 + 12), + loopEvent( + { + type: 'tool.call', + stepUuid: 'u2', + toolCallId: 'call_t2', + name: 'TodoList', + args: '{"todos":[{"title":"b","status":"in_progress"}]}', + }, + T0 + 13, + ), + loopEvent({ type: 'tool.result', toolCallId: 'call_t2', result: { output: 'ok' } }, T0 + 14), + rec('turn.ended', { turnId: 1, reason: 'completed' }, T0 + 15), + ]; + const restored = fold([...first, ...second]); + const todo = ofType(restored, 'todo')[0]!; + expect(todo).toMatchObject({ + todo_id: 'todo', + items: [{ title: 'b', status: 'in_progress' }], + updated_at: iso(T0 + 14), + }); + expect(restored.at(-1)).toBe(todo); + + const reverted = fold([...first, ...second, rec('context.undo', { count: 1 }, T0 + 20)]); + const revertedTodo = ofType(reverted, 'todo')[0]!; + expect(revertedTodo).toMatchObject({ todo_id: 'todo', items: [{ title: 'a', status: 'done' }] }); + }); +}); + +describe('paginateHistory', () => { + const base: HistoryMessage[] = []; + for (let turn = 0; turn < 3; turn++) { + base.push( + { + type: 'turn', + session_id: SESSION, + agent_id: 'main', + timestamp: T0, + turn_id: `t${turn}`, + ordinal: turn, + status: 'completed', + origin: { kind: 'user' }, + }, + { + type: 'user', + session_id: SESSION, + agent_id: 'main', + message_id: `t${turn}.u0`, + turn_id: `t${turn}`, + status: 'read', + timestamp: T0, + text: [{ type: 'text', text: `p${turn}`, meta: {} }], + }, + { + type: 'step', + session_id: SESSION, + agent_id: 'main', + timestamp: T0, + step_id: `t${turn}.1`, + turn_id: `t${turn}`, + ordinal: 1, + status: 'completed', + }, + { + type: 'assistant', + session_id: SESSION, + agent_id: 'main', + timestamp: T0, + message_id: `t${turn}.1.a1`, + turn_id: `t${turn}`, + step_id: `t${turn}.1`, + status: 'completed', + text: `a${turn}`, + }, + ); + } + + const ids = (messages: readonly HistoryMessage[]): string[] => + messages.map((m) => { + switch (m.type) { + case 'turn': + return m.turn_id; + case 'step': + return m.step_id; + case 'user': + case 'assistant': + return m.message_id; + default: + return m.type; + } + }); + + it('returns the newest page by default and pages older with before_turn', () => { + expect(ids(paginateHistory(base, {}).messages)).toHaveLength(12); + expect(paginateHistory(base, {}).hasMore).toBe(false); + const newest = paginateHistory(base, { page_size: 2 }); + expect(ids(newest.messages)).toEqual(['t1', 't1.u0', 't1.1', 't1.1.a1', 't2', 't2.u0', 't2.1', 't2.1.a1']); + expect(newest.hasMore).toBe(true); + const older = paginateHistory(base, { before_turn: 't2' }); + expect(ids(older.messages)).toEqual(['t0', 't0.u0', 't0.1', 't0.1.a1', 't1', 't1.u0', 't1.1', 't1.1.a1']); + expect(older.hasMore).toBe(false); + const olderCapped = paginateHistory(base, { before_turn: 't2', page_size: 1 }); + expect(ids(olderCapped.messages)).toEqual(['t1', 't1.u0', 't1.1', 't1.1.a1']); + expect(olderCapped.hasMore).toBe(true); + expect(paginateHistory(base, { before_turn: 't99' })).toEqual({ messages: [], hasMore: false }); + }); + + it('catches up newer messages with after_step', () => { + const tail = paginateHistory(base, { after_step: 't1.1' }); + expect(ids(tail.messages)).toEqual(['t2', 't2.u0', 't2.1', 't2.1.a1']); + expect(tail.hasMore).toBe(false); + const tailCapped = paginateHistory(base, { after_step: 't1.1', page_size: 2 }); + expect(ids(tailCapped.messages)).toEqual(['t2', 't2.u0']); + expect(tailCapped.hasMore).toBe(true); + expect(paginateHistory(base, { after_step: 't0.9' })).toEqual({ messages: [], hasMore: false }); + expect(paginateHistory(base, { after_step: 't2.1' })).toEqual({ messages: [], hasMore: false }); + }); +}); + +describe('live and cold rebuild id consistency', () => { + const IDENTITY_KEY_TYPES = new Set([ + 'turn', + 'step', + 'user', + 'assistant', + 'thinking', + 'tool_call', + 'system', + 'interaction', + 'task', + ]); + + function keyOf(message: ServerMessage): string | undefined { + switch (message.type) { + case 'turn': + return `turn:${message.turn_id}`; + case 'step': + return `step:${message.step_id}`; + case 'user': + case 'assistant': + case 'thinking': + return `${message.type}:${message.message_id}`; + case 'tool_call': + return `tool_call:${message.tool_call_id}`; + case 'system': + return `system:${message.system_id}`; + case 'interaction': + return `interaction:${message.interaction_id}`; + case 'task': + return `task:${message.task_id}`; + default: + return undefined; + } + } + + function clientFold(messages: readonly ServerMessage[]): Map { + const store = new Map(); + const removeSubtree = (id: string): void => { + for (const [key, entity] of [...store]) { + if (key.endsWith(`:${id}`)) { + store.delete(key); + continue; + } + if (!IDENTITY_KEY_TYPES.has(entity.type)) continue; + const turnId = 'turn_id' in entity ? (entity.turn_id as string | undefined) : undefined; + if (turnId === id) store.delete(key); + } + }; + for (const message of messages) { + if (message.type === 'system' && (message.subtype === 'undo' || message.subtype === 'clear')) { + for (const id of (message.payload as { removed_ids: string[] }).removed_ids) { + removeSubtree(id); + } + } + const key = keyOf(message); + if (key !== undefined) store.set(key, message); + } + return store; + } + + it('produces the same entity id set from the live projector and the cold fold', () => { + const projector = new AgentMessageProjector('main', SESSION, new Map()); + const live: ServerMessage[] = []; + const feed = (event: ProjectionBusEvent): void => { + for (const message of projector.map(event)) live.push(serverMessageSchema.parse(message)); + }; + + feed( + ev({ + type: 'prompt.submitted', + promptId: 'p1', + userMessageId: 'p1', + status: 'running', + content: [{ type: 'text', text: 'fix the bug' }], + createdAt: iso(T0), + }), + ); + feed(ev({ type: 'turn.started', turnId: 0, promptId: 'p1', origin: { kind: 'user' }, prompt: 'fix the bug' })); + feed(ev({ type: 'turn.step.started', turnId: 0, step: 1 })); + feed(ev({ type: 'thinking.delta', turnId: 0, delta: 'hmm' })); + feed(ev({ type: 'assistant.delta', turnId: 0, delta: 'Hello' })); + feed(ev({ type: 'tool.call.started', turnId: 0, toolCallId: 'call_1', name: 'Bash', args: '{"command":"ls"}' })); + feed(ev({ type: 'tool.result', turnId: 0, toolCallId: 'call_1', output: 'file.txt' })); + feed( + ev({ + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 10, output: 5, inputCacheRead: 2, inputCacheCreation: 1 }, + finishReason: 'tool_calls', + }), + ); + feed(ev({ type: 'turn.steer', turnId: 0, input: [{ type: 'text', text: 'also B' }], origin: { kind: 'user' } })); + feed(ev({ type: 'turn.step.started', turnId: 0, step: 2 })); + live.push( + ...projector.interactionRequested({ + id: 'apr-1', + kind: 'approval', + payload: { toolCallId: 'call_1', toolName: 'Bash', action: 'Run ls' }, + createdAt: T0, + }), + ); + live.push(...projector.interactionResolved('apr-1', { decision: 'approved' })); + feed( + ev({ + type: 'goal.updated', + snapshot: { + objective: 'ship', + status: 'active', + tokensUsed: 10, + budget: { tokenBudget: 100 }, + }, + }), + ); + feed(ev({ type: 'turn.step.completed', turnId: 0, step: 2 })); + feed( + ev({ + type: 'task.notified', + notificationType: 'task.completed', + title: 'Task completed', + body: 'build finished', + severity: 'info', + sourceKind: 'background_task', + sourceId: 'task-7', + }), + ); + feed(ev({ type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 1500 })); + feed( + ev({ + type: 'prompt.submitted', + promptId: 'p2', + userMessageId: 'p2', + status: 'running', + content: [{ type: 'text', text: 'second' }], + createdAt: iso(T0), + }), + ); + feed(ev({ type: 'turn.started', turnId: 1, promptId: 'p2', origin: { kind: 'user' }, prompt: 'second' })); + feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); + feed(ev({ type: 'assistant.delta', turnId: 1, delta: 'partial' })); + feed(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + feed(ev({ type: 'context.undone', turns: 1, fromTurnId: 1 })); + feed(ev({ type: 'compaction.completed', result: { summary: 'sum' } })); + + const records: ContextRecord[] = [ + rec('turn.prompt', { + input: [{ type: 'text', text: 'fix the bug' }], + origin: { kind: 'user' }, + promptId: 'p1', + }), + rec('context.append_message', { + message: { + id: 'p1', + role: 'user', + content: [{ type: 'text', text: 'fix the bug' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }), + loopEvent({ type: 'content.part', stepUuid: 'u1', part: { type: 'think', think: 'hmm' } }), + loopEvent({ type: 'content.part', stepUuid: 'u1', part: { type: 'text', text: 'Hello' } }), + loopEvent({ + type: 'tool.call', + stepUuid: 'u1', + toolCallId: 'call_1', + name: 'Bash', + args: '{"command":"ls"}', + }), + loopEvent({ type: 'tool.result', toolCallId: 'call_1', result: { output: 'file.txt' } }), + loopEvent({ + type: 'step.end', + uuid: 'u1', + finishReason: 'tool_calls', + usage: { inputOther: 10, output: 5, inputCacheRead: 2, inputCacheCreation: 1 }, + }), + rec('turn.steer', { input: [{ type: 'text', text: 'also B' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u2', turnId: '0', step: 2 }), + rec('interaction.request', { + id: 'apr-1', + kind: 'approval', + toolCallId: 'call_1', + request: { toolCallId: 'call_1', toolName: 'Bash', action: 'Run ls' }, + }), + rec('interaction.resolved', { id: 'apr-1', response: { decision: 'approved' } }), + rec('goal.create', { objective: 'ship' }), + loopEvent({ type: 'step.end', uuid: 'u2' }), + rec('context.append_message', { + message: { + role: 'user', + content: [ + { + type: 'text', + text: '\nTitle: Task completed\nSeverity: info\nbuild finished', + }, + ], + toolCalls: [], + origin: { kind: 'task', taskId: 'task-7', status: 'completed', notificationId: 'task:task-7:completed' }, + }, + }), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 1500 }), + rec('turn.prompt', { + input: [{ type: 'text', text: 'second' }], + origin: { kind: 'user' }, + promptId: 'p2', + }), + rec('context.append_message', { + message: { + id: 'p2', + role: 'user', + content: [{ type: 'text', text: 'second' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }), + loopEvent({ type: 'step.begin', uuid: 'u3', turnId: '1', step: 1 }), + loopEvent({ type: 'content.part', stepUuid: 'u3', part: { type: 'text', text: 'partial' } }), + rec('turn.ended', { turnId: 1, reason: 'completed' }), + rec('context.undo', { count: 1 }), + rec('context.apply_compaction', { summary: 'sum', compactedCount: 2 }), + ]; + const cold = fold(records); + + const liveIds = [...clientFold(live).keys()].toSorted(); + const coldIds = cold + .map((m) => keyOf(m as ServerMessage)) + .filter((k): k is string => k !== undefined) + .toSorted(); + expect(coldIds).toEqual(liveIds); + + const coldUndo = ofType(cold, 'system').find((m) => m.subtype === 'undo')!; + expect(coldUndo.payload).toEqual({ removed_ids: ['t1'] }); + const liveUndo = clientFold(live).get('system:sys_undo_1'); + expect(liveUndo).toMatchObject({ subtype: 'undo', payload: { removed_ids: ['t1'] } }); + }); + + it('keeps system ids deterministic across live-only and multi-phase events', () => { + const projector = new AgentMessageProjector('main', SESSION, new Map()); + const live: ServerMessage[] = []; + const feed = (event: ProjectionBusEvent): void => { + for (const message of projector.map(event)) live.push(serverMessageSchema.parse(message)); + }; + + feed( + ev({ + type: 'prompt.submitted', + promptId: 'p1', + userMessageId: 'p1', + status: 'running', + content: [{ type: 'text', text: 'fix' }], + createdAt: iso(T0), + }), + ); + feed(ev({ type: 'turn.started', turnId: 0, promptId: 'p1', origin: { kind: 'user' }, prompt: 'fix' })); + feed(ev({ type: 'turn.step.started', turnId: 0, step: 1 })); + feed(ev({ type: 'hook.result', turnId: 0, hookEvent: 'PreToolUse', content: 'hook says hi' })); + feed(ev({ type: 'warning', message: 'careful', code: 'W1' })); + feed(ev({ type: 'compaction.started', trigger: 'manual' })); + feed(ev({ type: 'compaction.completed', result: { summary: 'sum' } })); + feed(ev({ type: 'compaction.started', trigger: 'manual' })); + feed(ev({ type: 'compaction.completed', result: { summary: 'sum2' } })); + feed(ev({ type: 'turn.step.completed', turnId: 0, step: 1 })); + feed(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); + feed( + ev({ + type: 'prompt.submitted', + promptId: 'p2', + userMessageId: 'p2', + status: 'running', + content: [{ type: 'text', text: 'run review' }], + createdAt: iso(T0), + }), + ); + feed( + ev({ + type: 'turn.started', + turnId: 1, + promptId: 'p2', + origin: { kind: 'skill_activation', trigger: 'user-slash', activationId: 'sk-1', skillName: 'review' }, + prompt: 'run review', + }), + ); + feed(ev({ type: 'skill.activated', activationId: 'sk-1', skillName: 'review', trigger: 'user-slash' })); + feed(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + + const records: ContextRecord[] = [ + rec('turn.prompt', { + input: [{ type: 'text', text: 'fix' }], + origin: { kind: 'user' }, + promptId: 'p1', + }), + rec('context.append_message', { + message: { + id: 'p1', + role: 'user', + content: [{ type: 'text', text: 'fix' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }), + rec('context.apply_compaction', { summary: 'sum', compactedCount: 1 }), + rec('context.apply_compaction', { summary: 'sum2', compactedCount: 1 }), + loopEvent({ type: 'step.end', uuid: 'u1' }), + rec('turn.ended', { turnId: 0, reason: 'completed' }), + rec('turn.prompt', { + input: [{ type: 'text', text: 'run review' }], + origin: { kind: 'skill_activation', trigger: 'user-slash', activationId: 'sk-1', skillName: 'review' }, + promptId: 'p2', + }), + rec('context.append_message', { + message: { + id: 'p2', + role: 'user', + content: [{ type: 'text', text: 'run review' }], + toolCalls: [], + origin: { kind: 'skill_activation', trigger: 'user-slash' }, + }, + }), + rec('turn.ended', { turnId: 1, reason: 'completed' }), + ]; + const cold = fold(records); + + const liveSystems = live.filter( + (m): m is Extract => m.type === 'system', + ); + expect(liveSystems.filter((m) => m.subtype === 'compaction')).toHaveLength(2); + const coldSysIds = ofType(cold, 'system').map((m) => m.system_id).toSorted(); + expect(coldSysIds).toEqual(['sys_compaction_1', 'sys_compaction_2']); + const liveOnlySysIds = liveSystems + .map((m) => m.system_id) + .filter((id) => !coldSysIds.includes(id)) + .toSorted(); + expect(liveOnlySysIds).toEqual(['sys_hook_1', 'sys_notice_1']); + + const liveIds = [...clientFold(live).keys()].toSorted(); + const coldIds = cold + .map((m) => keyOf(m as ServerMessage)) + .filter((k): k is string => k !== undefined) + .toSorted(); + expect(coldIds.every((id) => liveIds.includes(id))).toBe(true); + expect(liveIds.filter((id) => !coldIds.includes(id)).toSorted()).toEqual([ + 'system:sys_hook_1', + 'system:sys_notice_1', + ]); + }); + + it('seeds the same timeline ids and counters as the cold fold', () => { + const records: ContextRecord[] = [ + rec('turn.prompt', { + input: [{ type: 'text', text: 'one' }], + origin: { kind: 'user' }, + promptId: 'p0', + }), + rec( + 'context.append_message', + { + message: { + id: 'p0', + role: 'user', + content: [{ type: 'text', text: 'one' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + T0 + 1, + ), + rec('turn.ended', { turnId: 0, reason: 'completed' }, T0 + 2), + rec('goal.create', { objective: 'ship' }, T0 + 3), + rec('goal.update', { tokensUsed: 42 }, T0 + 4), + rec( + 'turn.prompt', + { input: [{ type: 'text', text: 'two' }], origin: { kind: 'user' }, promptId: 'p1' }, + T0 + 10, + ), + rec( + 'context.append_message', + { + message: { + id: 'p1', + role: 'user', + content: [{ type: 'text', text: 'two' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + T0 + 11, + ), + rec('turn.ended', { turnId: 1, reason: 'completed' }, T0 + 12), + rec('context.undo', { count: 1 }, T0 + 20), + rec('context.apply_compaction', { summary: 'sum', compactedCount: 1 }, T0 + 21), + rec('plan_mode.enter', { id: 'plan-1' }, T0 + 22), + rec('plan_mode.cancel', {}, T0 + 23), + rec( + 'turn.prompt', + { input: [{ type: 'text', text: 'three' }], origin: { kind: 'cron_job', jobId: 'j1', cron: '* * * * *' } }, + T0 + 24, + ), + rec('turn.ended', { turnId: 2, reason: 'completed' }, T0 + 25), + ]; + const seed = foldTimelineSeed(records); + const cold = fold(records); + const coldTimelineIds = cold + .filter((m) => m.type === 'turn' || m.type === 'system') + .map((m) => (m.type === 'turn' ? m.turn_id : m.system_id)); + expect(seed.timelineIds).toEqual(coldTimelineIds); + expect(seed.timelineIds).toEqual([ + 't0', + 'sys_goal_1', + 'sys_undo_1', + 'sys_compaction_1', + 'sys_plan.enter_1', + 't2', + ]); + expect(seed.nextTurnId).toBe(3); + expect(seed.anchorTurnOrdinals).toEqual([0, 1]); + expect(seed.systemCounts).toEqual( + new Map([['compaction', 1], ['goal', 1], ['plan.enter', 1], ['undo', 1]]), + ); + + const legacy: ContextRecord[] = [ + rec('context.append_message', { + message: { + role: 'assistant', + content: [{ type: 'text', text: 'hi' }], + toolCalls: [{ id: 'call_1', name: 'Bash', arguments: '{"cmd":"ls"}' }], + }, + }), + rec( + 'turn.prompt', + { input: [{ type: 'text', text: 'next' }], origin: { kind: 'user' }, promptId: 'p9' }, + T0 + 10, + ), + ]; + const legacySeed = foldTimelineSeed(legacy); + const legacyColdIds = fold(legacy) + .filter((m) => m.type === 'turn' || m.type === 'system') + .map((m) => (m.type === 'turn' ? m.turn_id : m.system_id)); + expect(legacySeed.timelineIds).toEqual(legacyColdIds); + expect(legacySeed.nextTurnId).toBe(2); + }); +}); diff --git a/packages/kap-server/test/services/messages/messageProjection.test.ts b/packages/kap-server/test/services/messages/messageProjection.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..19568dc35391335d596a2dcbfba246558b3397de --- /dev/null +++ b/packages/kap-server/test/services/messages/messageProjection.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest'; + +import type { ContextMessage } from '@moonshot-ai/agent-core-v2'; + +import { projectPromptContentParts, toProtocolMessage } from '../../../src/services/messages/messageProjection'; + +const SESSION_ID = 'session_1'; +const CREATED_AT = 1_700_000_000_000; + +function userText(text: string): ContextMessage { + return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] }; +} + +describe('toProtocolMessage', () => { + it('maps text/think/image/audio/video content parts', () => { + const msg: ContextMessage = { + role: 'user', + content: [ + { type: 'text', text: 'hello' }, + { type: 'think', think: 'hmm', encrypted: 'sig-1' }, + { type: 'image_url', imageUrl: { url: 'https://example.com/a.png' } }, + { type: 'audio_url', audioUrl: { url: 'https://example.com/a.mp3' } }, + { type: 'video_url', videoUrl: { url: 'https://example.com/a.mp4' } }, + ], + toolCalls: [], + }; + + expect(toProtocolMessage(SESSION_ID, 0, msg, CREATED_AT).content).toEqual([ + { type: 'text', text: 'hello' }, + { type: 'thinking', thinking: 'hmm', signature: 'sig-1' }, + { type: 'image', source: { kind: 'url', url: 'https://example.com/a.png' } }, + { type: 'text', text: '[audio:https://example.com/a.mp3]' }, + { type: 'video', source: { kind: 'url', url: 'https://example.com/a.mp4' } }, + ]); + }); + + it('projects a daemon-ref image part to a session_media source', () => { + const msg: ContextMessage = { + role: 'user', + content: [ + { type: 'text', text: 'what is this?' }, + { type: 'image_url', imageUrl: { url: 'kimi-file://file_9?path=%2Fcache%2Fpic.png' } }, + ], + toolCalls: [], + }; + + expect(toProtocolMessage(SESSION_ID, 0, msg, CREATED_AT).content).toEqual([ + { type: 'text', text: 'what is this?' }, + { type: 'image', source: { kind: 'session_media', file_id: 'file_9' } }, + ]); + }); + + it('preserves media names in live and prompt projections', () => { + const part = { + type: 'image_url' as const, + imageUrl: { url: 'kimi-file://file_9', id: 'file_9', name: 'photo.png' }, + }; + const msg: ContextMessage = { role: 'user', content: [part], toolCalls: [] }; + + expect(toProtocolMessage(SESSION_ID, 0, msg, CREATED_AT).content).toEqual([ + { type: 'image', source: { kind: 'session_media', file_id: 'file_9' }, name: 'photo.png' }, + ]); + expect(projectPromptContentParts([part])).toEqual([ + { type: 'image', source: { kind: 'session_media', file_id: 'file_9' }, name: 'photo.png' }, + ]); + }); + + it('keeps a legacy tag+ref pair as text plus the ref projection', () => { + const msg: ContextMessage = { + role: 'user', + content: [ + { type: 'text', text: '' }, + { type: 'image_url', imageUrl: { url: 'kimi-file://file_9?path=%2Fcache%2Fpic.png' } }, + ], + toolCalls: [], + }; + + expect(toProtocolMessage(SESSION_ID, 0, msg, CREATED_AT).content).toEqual([ + { type: 'text', text: '' }, + { type: 'image', source: { kind: 'session_media', file_id: 'file_9' } }, + ]); + }); + + it('keeps a bare tag as text in user messages', () => { + const msg: ContextMessage = { + role: 'user', + content: [ + { type: 'text', text: '

Kimi
'), + writeFile(join(assetsDir, 'assets', 'index-Dy7xs5tu.js'), 'export {};'), + writeFile(join(assetsDir, 'assets', 'application-configuration.json'), '{}'), + writeFile(join(assetsDir, 'favicon.svg'), ''), + ]); + app = Fastify(); + await registerWebAssetRoutes(app, assetsDir); + }); + + afterEach(async () => { + await app.close(); + await rm(assetsDir, { recursive: true, force: true }); + }); + + it('caches content-hashed assets as immutable', async () => { + const response = await app.inject({ method: 'GET', url: '/assets/index-Dy7xs5tu.js' }); + + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable'); + }); + + it.each([ + '/index.html', + '/sessions/active', + '/favicon.svg', + '/assets/application-configuration.json', + ])( + 'requires revalidation for %s', + async (url) => { + const response = await app.inject({ method: 'GET', url }); + + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-cache'); + }, + ); +}); diff --git a/packages/kap-server/test/workspaceFs.test.ts b/packages/kap-server/test/workspaceFs.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4092e82b8fc9591c9bef3b87d4b83f88d5beefb2 --- /dev/null +++ b/packages/kap-server/test/workspaceFs.test.ts @@ -0,0 +1,437 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: { path: string; message: string }[]; +} + +interface BrowseEntryWire { + name: string; + path: string; + is_dir: true; +} + +interface BrowseWire { + path: string; + parent: string | null; + entries: BrowseEntryWire[]; +} + +interface HomeWire { + home: string; + recent_roots: string[]; +} + +describe('server-v2 /api/v1 fs folder picker', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let instancesDir: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-')); + instancesDir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-instances-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + instancesDir, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + if (instancesDir !== undefined) { + await rm(instancesDir, { recursive: true, force: true }); + instancesDir = undefined; + } + }); + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const hasBody = body !== undefined; + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: authHeaders( + server as RunningServer, + hasBody ? { 'content-type': 'application/json' } : {}, + ), + body: hasBody ? JSON.stringify(body) : undefined, + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + it('defaults browse to $HOME when path is omitted', async () => { + const { status, body } = await getJson('/api/v1/fs:browse'); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.path).toBe(await realpath(homedir())); + expect(typeof body.data.parent === 'string' || body.data.parent === null).toBe(true); + expect(Array.isArray(body.data.entries)).toBe(true); + }); + + it('does not serve the double-colon URL (v1 parity: only /fs:browse is valid)', async () => { + const res = await fetch(`${base}/api/v1/fs::browse`, { + headers: authHeaders(server as RunningServer), + } as never); + expect(res.status).toBe(404); + }); + + it('lists only directories and filters files', async () => { + const root = await mkdtemp(join(home as string, 'browse-filter-')); + await mkdir(join(root, 'alpha')); + await mkdir(join(root, 'beta')); + await writeFile(join(root, 'README.md'), 'hi'); + + const { body } = await getJson( + `/api/v1/fs:browse?path=${encodeURIComponent(root)}`, + ); + expect(body.code).toBe(0); + expect(body.data.path).toBe(await realpath(root)); + const names = body.data.entries.map((e) => e.name).sort(); + expect(names).toEqual(['alpha', 'beta']); + for (const entry of body.data.entries) { + expect(entry.is_dir).toBe(true); + expect(entry.path).toBe(join(await realpath(root), entry.name)); + } + }); + + it('sorts dot-directories after regular ones', async () => { + const root = await mkdtemp(join(home as string, 'browse-dots-')); + await mkdir(join(root, '.zeta')); + await mkdir(join(root, 'alpha')); + + const { body } = await getJson( + `/api/v1/fs:browse?path=${encodeURIComponent(root)}`, + ); + expect(body.code).toBe(0); + expect(body.data.entries.map((e) => e.name)).toEqual(['alpha', '.zeta']); + }); + + it('returns parent=null for the filesystem root', async () => { + const { body } = await getJson('/api/v1/fs:browse?path=%2F'); + expect(body.code).toBe(0); + expect(body.data.path).toBe('/'); + expect(body.data.parent).toBeNull(); + }); + + it('rejects a relative path (40001)', async () => { + const { body } = await getJson( + `/api/v1/fs:browse?path=${encodeURIComponent('relative/path')}`, + ); + expect(body.code).toBe(40001); + }); + + it('rejects a nonexistent path (40409)', async () => { + const missing = join(home as string, 'does-not-exist'); + const { body } = await getJson(`/api/v1/fs:browse?path=${encodeURIComponent(missing)}`); + expect(body.code).toBe(40409); + }); + + it('returns an empty recent_roots when no workspaces are registered', async () => { + const { status, body } = await getJson('/api/v1/fs:home'); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.home).toBe(homedir()); + expect(body.data.recent_roots).toEqual([]); + }); + + it('reflects registered workspace roots in recent_roots', async () => { + const root = home as string; + const created = await postJson<{ id: string }>('/api/v1/workspaces', { root }); + expect(created.body.code).toBe(0); + + const { body } = await getJson('/api/v1/fs:home'); + expect(body.code).toBe(0); + expect(body.data.recent_roots).toContain(root); + }); +}); + +describe('server-v2 /api/v1 fs:mkdir', () => { + let server: RunningServer | undefined; + let dir: string | undefined; + let instancesDir: string | undefined; + let base: string; + + beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fsmkdir-')); + instancesDir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fsmkdir-instances-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: dir, + instancesDir, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (dir !== undefined) { + await rm(dir, { recursive: true, force: true }); + dir = undefined; + } + if (instancesDir !== undefined) { + await rm(instancesDir, { recursive: true, force: true }); + instancesDir = undefined; + } + }); + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify(body), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + it('creates a directory that fs:browse then lists', async () => { + const target = join(dir as string, 'fresh-folder'); + + const { status, body } = await postJson<{ path: string }>('/api/v1/fs:mkdir', { + path: target, + }); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.path).toBe(target); + + const browse = await fetch( + `${base}/api/v1/fs:browse?path=${encodeURIComponent(dir as string)}`, + { headers: authHeaders(server as RunningServer) } as never, + ); + const browseBody = (await browse.json()) as Envelope; + expect(browseBody.data.entries.map((e) => e.name)).toContain('fresh-folder'); + }); + + it('rejects a relative path (40001)', async () => { + const { body } = await postJson('/api/v1/fs:mkdir', { path: 'relative/folder' }); + expect(body.code).toBe(40001); + }); + + it('rejects an existing directory (40919)', async () => { + const target = join(dir as string, 'already-here'); + await mkdir(target); + + const { body } = await postJson('/api/v1/fs:mkdir', { path: target }); + expect(body.code).toBe(40919); + }); + + it('rejects an existing file (40919)', async () => { + const target = join(dir as string, 'file.txt'); + await writeFile(target, 'hi'); + + const { body } = await postJson('/api/v1/fs:mkdir', { path: target }); + expect(body.code).toBe(40919); + }); + + it('rejects a missing parent (40409)', async () => { + const target = join(dir as string, 'no-such-parent', 'child'); + const { body } = await postJson('/api/v1/fs:mkdir', { path: target }); + expect(body.code).toBe(40409); + }); + + it('does not serve the double-colon URL', async () => { + const res = await fetch(`${base}/api/v1/fs::mkdir`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ path: join(dir as string, 'x') }), + } as never); + expect(res.status).toBe(404); + }); +}); + +describe('server-v2 /api/v1 fs:content', () => { + let server: RunningServer | undefined; + let dir: string | undefined; + let instancesDir: string | undefined; + let base: string; + + beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fscontent-')); + instancesDir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fscontent-instances-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: dir, + instancesDir, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (dir !== undefined) { + await rm(dir, { recursive: true, force: true }); + dir = undefined; + } + if (instancesDir !== undefined) { + await rm(instancesDir, { recursive: true, force: true }); + instancesDir = undefined; + } + }); + + function contentUrl(path: string): string { + return `${base}/api/v1/fs:content?path=${encodeURIComponent(path)}`; + } + + async function getContent( + path: string, + headers: Record = {}, + ): Promise { + return fetch(contentUrl(path), { + headers: { connection: 'close', ...authHeaders(server as RunningServer), ...headers }, + } as never); + } + + it('serves a text file raw with mime, etag, and length headers', async () => { + const file = join(dir as string, 'hello.md'); + await writeFile(file, '# hi\n'); + + const res = await getContent(file); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/markdown'); + expect(res.headers.get('content-length')).toBe('5'); + expect(typeof res.headers.get('etag')).toBe('string'); + expect(typeof res.headers.get('last-modified')).toBe('string'); + expect(await res.text()).toBe('# hi\n'); + }); + + it('serves an unknown-extension text file as text/plain', async () => { + const file = join(dir as string, 'notes.weird'); + await writeFile(file, 'just text'); + + const res = await getContent(file); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/plain'); + }); + + it('serves a UTF-8 Chinese .log file as text/plain', async () => { + const file = join(dir as string, 'server.log'); + const log = '2026-08-16 INFO 启动完成 ✅\n'.repeat(100); + await writeFile(file, log); + + const res = await getContent(file); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/plain'); + expect(await res.text()).toBe(log); + }); + + it('serves binary files byte-for-byte with an octet-stream fallback mime', async () => { + const file = join(dir as string, 'blob.bin'); + const original = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0x00, 0x10, 0x80]); + await writeFile(file, original); + + const res = await getContent(file); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/octet-stream'); + expect(Buffer.from(await res.arrayBuffer()).equals(original)).toBe(true); + }); + + it('guesses image mime from the extension', async () => { + const file = join(dir as string, 'pic.png'); + await writeFile(file, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01])); + + const res = await getContent(file); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('image/png'); + }); + + it('answers If-None-Match with 304 when the etag matches', async () => { + const file = join(dir as string, 'cached.txt'); + await writeFile(file, 'cache me'); + + const first = await getContent(file); + const etag = first.headers.get('etag') as string; + + const res = await getContent(file, { 'if-none-match': etag }); + expect(res.status).toBe(304); + expect(res.headers.get('etag')).toBe(etag); + expect(await res.text()).toBe(''); + }); + + it('honors single-range requests with 206', async () => { + const file = join(dir as string, 'long.txt'); + await writeFile(file, '0123456789'); + + const res = await getContent(file, { range: 'bytes=2-5' }); + expect(res.status).toBe(206); + expect(res.headers.get('content-range')).toBe('bytes 2-5/10'); + expect(res.headers.get('content-length')).toBe('4'); + expect(await res.text()).toBe('2345'); + }); + + it('rejects a relative path (40001)', async () => { + const res = await getContent('relative/path.txt'); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(40001); + }); + + it('rejects a nonexistent path (40409)', async () => { + const res = await getContent(join(dir as string, 'does-not-exist.txt')); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(40409); + }); + + it('rejects a directory path (40906)', async () => { + const res = await getContent(dir as string); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(40906); + }); + + it.skipIf(process.platform === 'win32')('rejects non-regular files (40001)', async () => { + const res = await getContent('/dev/null'); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(40001); + }); + + it('does not serve the double-colon URL', async () => { + const res = await fetch(`${base}/api/v1/fs::content?path=%2Ftmp`, { + headers: authHeaders(server as RunningServer), + } as never); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/kap-server/test/workspaceLayout.test.ts b/packages/kap-server/test/workspaceLayout.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4304b5246fad61eec8d5049512bdcbbb60608f43 --- /dev/null +++ b/packages/kap-server/test/workspaceLayout.test.ts @@ -0,0 +1,130 @@ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + IAgentLifecycleService, + getLiveSessionById, +} from '@moonshot-ai/agent-core-v2'; + +import { type RunningServer, startServer } from '../src/start'; +import { authHeaders } from './helpers/auth'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +describe('local/local on-disk layout (byte compatibility)', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let workDir: string | undefined; + let base: string; + const homes: string[] = []; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-layout-home-')); + workDir = await mkdtemp(join(tmpdir(), 'kimi-layout-work-')); + homes.push(home, workDir); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + debugEndpoints: true, + hostIdentity: TEST_HOST_IDENTITY, + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + await Promise.all(homes.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + async function postJson(path: string, body: unknown): Promise> { + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...authHeaders(server!) }, + body: JSON.stringify(body), + }); + return (await res.json()) as Envelope; + } + + it('persists the pre-refactor layout byte-for-byte and serves it through the snapshot reader', async () => { + const created = await postJson<{ id: string; workspace_id: string }>('/api/v1/sessions', { + metadata: { cwd: workDir }, + }); + expect(created.code).toBe(0); + const sessionId = created.data.id; + const workspaceId = created.data.workspace_id; + const sessionDir = join(home!, 'sessions', workspaceId, sessionId); + + const workspacesFile = JSON.parse(await readFile(join(home!, 'workspaces.json'), 'utf8')) as { + version: number; + workspaces: Record< + string, + { root: string; name: string; created_at: string; last_opened_at: string } + >; + deleted_workspace_ids: string[]; + }; + expect(workspacesFile.version).toBe(1); + expect(Object.keys(workspacesFile.workspaces)).toEqual([workspaceId]); + expect(workspacesFile.workspaces[workspaceId]).toMatchObject({ root: workDir }); + expect(workspacesFile.deleted_workspace_ids).toEqual([]); + + const indexLines = (await readFile(join(home!, 'session_index.jsonl'), 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { sessionId: string; sessionDir: string; workDir: string }); + expect(indexLines).toEqual([{ sessionId, sessionDir, workDir }]); + + const metaRaw = JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf8')) as { + id: string; + }; + expect(metaRaw.id).toBe(sessionId); + + const session = getLiveSessionById(server!.core.accessor, sessionId); + expect(session).toBeDefined(); + await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); + const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl'); + await expect(stat(wirePath)).resolves.toBeDefined(); + const firstWireLine = (await readFile(wirePath, 'utf8')).split('\n')[0]!; + expect((JSON.parse(firstWireLine) as { type: string }).type).toBe('metadata'); + const metaWithAgent = JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf8')) as { + agents: Record; + }; + expect(metaWithAgent.agents['main']?.homedir).toBe(join(sessionDir, 'agents', 'main')); + + const snapshot = await fetch(`${base}/api/v1/sessions/${sessionId}/snapshot`, { + headers: authHeaders(server!), + }); + const snapshotBody = (await snapshot.json()) as Envelope<{ session: { id: string } }>; + expect(snapshotBody.code).toBe(0); + expect(snapshotBody.data.session.id).toBe(sessionId); + + const second = await postJson<{ id: string; workspace_id: string }>('/api/v1/sessions', { + metadata: { cwd: workDir }, + }); + expect(second.code).toBe(0); + expect(second.data.workspace_id).toBe(workspaceId); + const workspacesAfter = JSON.parse( + await readFile(join(home!, 'workspaces.json'), 'utf8'), + ) as { workspaces: Record }; + expect(Object.keys(workspacesAfter.workspaces)).toEqual([workspaceId]); + const indexAfter = (await readFile(join(home!, 'session_index.jsonl'), 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { sessionId: string }); + expect(indexAfter.map((entry) => entry.sessionId)).toEqual([sessionId, second.data.id]); + }); +}); diff --git a/packages/kap-server/test/workspaces.test.ts b/packages/kap-server/test/workspaces.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b09511f66ce814c1a429520f60fe97d995620408 --- /dev/null +++ b/packages/kap-server/test/workspaces.test.ts @@ -0,0 +1,333 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: { path: string; message: string }[]; +} + +interface WorkspaceWire { + id: string; + root: string; + name: string; + created_at: string; + last_opened_at: string; + session_count: number; +} + +interface ListWire { + items: WorkspaceWire[]; +} + +interface AddDirWire { + project_root: string; + config_path: string; + additional_dirs: string[]; + persisted: boolean; +} + +describe('server-v2 /api/v1/workspaces', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-workspaces-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const hasBody = body !== undefined; + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: authHeaders( + server as RunningServer, + hasBody ? { 'content-type': 'application/json' } : {}, + ), + body: hasBody ? JSON.stringify(body) : undefined, + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function patchJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'PATCH', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify(body ?? {}), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function deleteJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'DELETE', + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + it('creates a workspace with the full wire shape', async () => { + const root = home as string; + const { status, body } = await postJson('/api/v1/workspaces', { + root, + name: 'proj', + }); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.root).toBe(root); + expect(body.data.name).toBe('proj'); + expect(body.data.id).toMatch(/^wd_[a-z0-9._-]+_[0-9a-f]{12}$/); + expect(typeof body.data.session_count).toBe('number'); + expect(Number.isNaN(Date.parse(body.data.created_at))).toBe(false); + expect(Number.isNaN(Date.parse(body.data.last_opened_at))).toBe(false); + }); + + it('derives the default name from the root when name is omitted', async () => { + const root = home as string; + const { body } = await postJson('/api/v1/workspaces', { root }); + expect(body.code).toBe(0); + expect(body.data.name.length).toBeGreaterThan(0); + }); + + it('is idempotent on root (createOrTouch)', async () => { + const root = home as string; + const first = await postJson('/api/v1/workspaces', { root }); + const second = await postJson('/api/v1/workspaces', { root }); + expect(first.body.data.id).toBe(second.body.data.id); + }); + + it('rejects a relative root (40001)', async () => { + const { body } = await postJson('/api/v1/workspaces', { root: 'relative/path' }); + expect(body.code).toBe(40001); + expect(body.details?.[0]?.path).toBe('root'); + }); + + it('rejects a nonexistent root (40409)', async () => { + const missing = join(home as string, 'does-not-exist'); + const { body } = await postJson('/api/v1/workspaces', { root: missing }); + expect(body.code).toBe(40409); + }); + + it('lists registered workspaces', async () => { + const root = home as string; + const created = await postJson('/api/v1/workspaces', { root }); + const { body } = await getJson('/api/v1/workspaces'); + expect(body.code).toBe(0); + expect(body.data.items.some((w) => w.id === created.body.data.id)).toBe(true); + }); + + it('renames a workspace via PATCH', async () => { + const root = home as string; + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const updated = await patchJson(`/api/v1/workspaces/${id}`, { name: 'renamed' }); + expect(updated.body.code).toBe(0); + expect(updated.body.data.name).toBe('renamed'); + expect(updated.body.data.id).toBe(id); + }); + + it('returns 40410 when patching an unknown workspace', async () => { + const { body } = await patchJson('/api/v1/workspaces/wd_missing_000000000000', { + name: 'nope', + }); + expect(body.code).toBe(40410); + }); + + it('deletes a workspace and 40410 on a second delete', async () => { + const root = home as string; + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const deleted = await deleteJson<{ deleted: boolean }>(`/api/v1/workspaces/${id}`); + expect(deleted.body.code).toBe(0); + expect(deleted.body.data).toEqual({ deleted: true }); + + const again = await deleteJson(`/api/v1/workspaces/${id}`); + expect(again.body.code).toBe(40410); + }); + + it('reflects session_count for sessions created in the workspace', async () => { + const root = home as string; + const created = await postJson('/api/v1/workspaces', { root }); + expect(created.body.data.session_count).toBe(0); + + const session = await postJson<{ id: string }>('/api/v1/sessions', { metadata: { cwd: root } }); + expect(session.body.code).toBe(0); + + const { body } = await getJson('/api/v1/workspaces'); + const ws = body.data.items.find((w) => w.id === created.body.data.id); + expect(ws?.session_count).toBe(1); + }); + + it('sums session_count across legacy split buckets of one root', async () => { + const typedRoot = 'C:\\Users\\Foo\\Proj'; + const lowerRoot = 'c:\\users\\foo\\proj'; + const typedId = encodeWorkDirKey(typedRoot); + const lowerId = encodeWorkDirKey(lowerRoot); + await writeFile( + join(home as string, 'workspaces.json'), + JSON.stringify({ + version: 1, + workspaces: { + [typedId]: { + root: typedRoot, + name: 'proj', + created_at: '2024-01-01T00:00:00.000Z', + last_opened_at: '2024-01-01T00:00:00.000Z', + }, + [lowerId]: { + root: lowerRoot, + name: 'proj', + created_at: '2024-01-01T00:00:00.000Z', + last_opened_at: '2024-01-01T00:00:00.000Z', + }, + }, + }), + 'utf8', + ); + const seedBucket = async ( + wsId: string, + sid: string, + meta: Record, + ): Promise => { + const dir = join(home as string, 'sessions', wsId, sid); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'state.json'), + JSON.stringify({ version: 2, cwd: typedRoot, createdAt: 1, updatedAt: 1, ...meta }), + 'utf8', + ); + }; + await seedBucket(typedId, 's-typed', {}); + await seedBucket(lowerId, 's-lower', { archived: true, updatedAt: 2 }); + + await vi.waitFor(async () => { + const { body } = await getJson('/api/v1/workspaces'); + expect(body.code).toBe(0); + const unions = body.data.items.filter((w) => [typedId, lowerId].includes(w.id)); + expect(unions).toHaveLength(1); + expect(unions[0]?.session_count).toBe(2); + }); + }); + + it('adds an additional directory and persists it by default', async () => { + const root = home as string; + const extra = join(root, 'extra'); + await mkdir(extra); + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const { status, body } = await postJson(`/api/v1/workspaces/${id}/add-dir`, { + path: extra, + }); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.persisted).toBe(true); + expect(body.data.additional_dirs).toContain(extra); + expect(body.data.project_root).toBe(root); + expect(body.data.config_path).toBe(join(root, '.kimi-code', 'local.toml')); + const toml = await readFile(body.data.config_path, 'utf8'); + expect(toml).toContain('additional_dir'); + expect(toml).toContain(extra); + }); + + it('adds a relative directory without persisting when persist is false', async () => { + const root = await mkdtemp(join(tmpdir(), 'kimi-server-v2-workspaces-rel-')); + const extra = join(root, 'extra-rel'); + await mkdir(extra); + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const { body } = await postJson(`/api/v1/workspaces/${id}/add-dir`, { + path: 'extra-rel', + persist: false, + }); + expect(body.code).toBe(0); + expect(body.data.persisted).toBe(false); + expect(body.data.additional_dirs).toContain(extra); + await expect(readFile(body.data.config_path, 'utf8')).rejects.toThrow(); + await rm(root, { recursive: true, force: true }); + }); + + it('returns 40410 when adding a directory to an unknown workspace', async () => { + const { body } = await postJson('/api/v1/workspaces/wd_missing_000000000000/add-dir', { + path: '/tmp', + }); + expect(body.code).toBe(40410); + }); + + it('returns 40409 when the added path does not exist', async () => { + const root = home as string; + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const { body } = await postJson(`/api/v1/workspaces/${id}/add-dir`, { + path: join(root, 'does-not-exist'), + }); + expect(body.code).toBe(40409); + }); + + it('returns 40409 when the added path is a file', async () => { + const root = home as string; + const file = join(root, 'a-file.txt'); + await writeFile(file, 'x', 'utf8'); + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const { body } = await postJson(`/api/v1/workspaces/${id}/add-dir`, { path: file }); + expect(body.code).toBe(40409); + }); + + it('returns 40001 when path is missing', async () => { + const root = home as string; + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const { body } = await postJson(`/api/v1/workspaces/${id}/add-dir`, {}); + expect(body.code).toBe(40001); + }); +}); diff --git a/packages/kap-server/test/wsBearerProtocol.test.ts b/packages/kap-server/test/wsBearerProtocol.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..83bbcaf0043bde772b3d1f63c09fa0645a62880e --- /dev/null +++ b/packages/kap-server/test/wsBearerProtocol.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import WebSocket from 'ws'; + +import { WS_BEARER_PROTOCOL_PREFIX } from '../src/transport/ws/bearerProtocol'; +import { sharedServer } from './helpers/sharedServer'; + +function openWs(url: string, protocols: string | string[]): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, protocols); + ws.once('open', () => resolve(ws)); + ws.once('error', (err) => reject(err)); + }); +} + +describe('server-v2 WS bearer subprotocol', () => { + const sockets: WebSocket[] = []; + + afterEach(() => { + for (const ws of sockets.splice(0)) { + ws.close(); + } + }); + + it('accepts a valid bearer subprotocol', async () => { + const token = sharedServer().token; + const wsUrl = `${sharedServer().base.replace(/^http/, 'ws')}/api/v1/ws`; + const ws = await openWs(wsUrl, `${WS_BEARER_PROTOCOL_PREFIX}${token}`); + sockets.push(ws); + expect(ws.protocol).toBe(`${WS_BEARER_PROTOCOL_PREFIX}${token}`); + }); + + it('rejects an invalid bearer subprotocol', async () => { + const wsUrl = `${sharedServer().base.replace(/^http/, 'ws')}/api/v1/ws`; + await expect(openWs(wsUrl, `${WS_BEARER_PROTOCOL_PREFIX}wrong-token`)).rejects.toThrow(); + }); +}); diff --git a/packages/kap-server/test/wsConnectionV1.test.ts b/packages/kap-server/test/wsConnectionV1.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d29a50dd1c521eff12b547ad216a368b8ffa2f2 --- /dev/null +++ b/packages/kap-server/test/wsConnectionV1.test.ts @@ -0,0 +1,902 @@ +import type { WebSocket } from 'ws'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { IConnectionRegistry } from '../src/transport/ws/connectionRegistry'; +import type { SessionEventBroadcaster } from '../src/transport/ws/v1/sessionEventBroadcaster'; +import { + type WsConnectionV1Options, + WsConnectionV1, + coalesceFrames, +} from '../src/transport/ws/v1/wsConnectionV1'; + +class FakeSocket { + readonly OPEN = 1; + readonly CLOSED = 3; + readyState = 1; + bufferedAmount = 0; + sent: string[] = []; + closeCalls: Array<{ code?: number; reason?: string }> = []; + private readonly handlers = new Map void>>(); + + on(event: string, cb: (...a: unknown[]) => void): this { + const list = this.handlers.get(event) ?? []; + list.push(cb); + this.handlers.set(event, list); + return this; + } + + send(data: string): void { + this.sent.push(data); + } + + close(code?: number, reason?: string): void { + this.closeCalls.push({ code, reason }); + this.readyState = this.CLOSED; + this.emit('close'); + } + + terminate(): void { + this.readyState = this.CLOSED; + this.emit('close'); + } + + emit(event: string, ...a: unknown[]): void { + for (const cb of this.handlers.get(event) ?? []) cb(...a); + } + + frames(): unknown[] { + return this.sent.map((s) => JSON.parse(s)); + } +} + +function makeBroadcaster(): SessionEventBroadcaster { + return { + subscribe: async () => true, + unsubscribe: () => {}, + addGlobalTarget: () => {}, + removeGlobalTarget: () => {}, + getCursor: async () => ({ seq: 0, epoch: '' }), + getBufferedSince: async () => ({ + events: [], + resyncRequired: false, + currentSeq: 0, + epoch: '', + }), + } as unknown as SessionEventBroadcaster; +} + +function makeRegistry(): IConnectionRegistry { + return { + add: () => {}, + remove: () => {}, + get: () => undefined, + values: () => [], + closeAll: () => {}, + size: () => 0, + }; +} + +function makeConn(socket: FakeSocket, opts: Partial = {}): WsConnectionV1 { + return new WsConnectionV1({ + socket: socket as unknown as WebSocket, + broadcaster: makeBroadcaster(), + connectionRegistry: makeRegistry(), + remoteAddress: null, + userAgent: null, + ...opts, + }); +} + +function delta( + sessionId: string, + agentId: string, + turnId: number, + text: string, + offset: number, + type: 'assistant.delta' | 'thinking.delta' = 'assistant.delta', +) { + return { + type, + seq: 1, + volatile: true as const, + offset, + session_id: sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + payload: { type, agentId, sessionId, turnId, delta: text }, + }; +} + +function durable(type: string, sessionId: string, seq: number) { + return { + type, + seq, + session_id: sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + payload: { type, agentId: 'main', sessionId }, + }; +} + +describe('coalesceFrames', () => { + it('merges adjacent compatible assistant deltas', () => { + const out = coalesceFrames([ + delta('s1', 'main', 1, 'Hello', 0), + delta('s1', 'main', 1, ' ', 5), + delta('s1', 'main', 1, 'world', 6), + ]); + expect(out).toHaveLength(1); + const f = out[0] as { offset: number; volatile: boolean; seq: number; payload: { delta: string } }; + expect(f.payload.delta).toBe('Hello world'); + expect(f.offset).toBe(0); + expect(f.volatile).toBe(true); + expect(f.seq).toBe(1); + }); + + it('does not merge across a durable frame', () => { + const out = coalesceFrames([ + delta('s1', 'main', 1, 'a', 0), + durable('turn.ended', 's1', 2), + delta('s1', 'main', 1, 'b', 1), + ]); + expect(out).toHaveLength(3); + expect((out[0] as { payload: { delta: string } }).payload.delta).toBe('a'); + expect((out[1] as { type: string }).type).toBe('turn.ended'); + expect((out[2] as { payload: { delta: string } }).payload.delta).toBe('b'); + }); + + it('does not merge different delta types', () => { + const out = coalesceFrames([ + delta('s1', 'main', 1, 'hi', 0, 'assistant.delta'), + delta('s1', 'main', 1, 'think', 0, 'thinking.delta'), + ]); + expect(out).toHaveLength(2); + }); + + it('does not merge deltas from different sessions / agents / turns', () => { + expect( + coalesceFrames([delta('s1', 'main', 1, 'a', 0), delta('s2', 'main', 1, 'b', 0)]), + ).toHaveLength(2); + expect( + coalesceFrames([delta('s1', 'main', 1, 'a', 0), delta('s1', 'sub', 1, 'b', 0)]), + ).toHaveLength(2); + expect( + coalesceFrames([delta('s1', 'main', 1, 'a', 0), delta('s1', 'main', 2, 'b', 0)]), + ).toHaveLength(2); + }); + + it('leaves non-volatile and non-text frames untouched', () => { + const toolCallDelta = { + type: 'tool.call.delta', + seq: 1, + volatile: true as const, + session_id: 's1', + timestamp: '2026-01-01T00:00:00.000Z', + payload: { type: 'tool.call.delta', agentId: 'main', turnId: 1, args: { x: 1 } }, + }; + expect(coalesceFrames([toolCallDelta, toolCallDelta])).toHaveLength(2); + }); + + it('does not mutate the input frames', () => { + const a = delta('s1', 'main', 1, 'a', 0); + const b = delta('s1', 'main', 1, 'b', 1); + const out = coalesceFrames([a, b]); + expect(out).toHaveLength(1); + expect(a.payload.delta).toBe('a'); + expect(b.payload.delta).toBe('b'); + }); + + it('handles empty and single-element input', () => { + expect(coalesceFrames([])).toEqual([]); + const only = delta('s1', 'main', 1, 'x', 0); + const out = coalesceFrames([only]); + expect(out).toHaveLength(1); + expect(out[0]).toBe(only); + }); +}); + +describe('WsConnectionV1 transcript subscriptions (subscribe_v2)', () => { + interface SubscribeCall { + sessionId: string; + filter: unknown; + grades: unknown; + opts?: { deferTranscriptReset?: boolean; transcriptSince?: Record }; + } + + function makeCapturingBroadcaster(): { + broadcaster: SessionEventBroadcaster; + calls: SubscribeCall[]; + detaches: { sessionId: string; agentIds?: readonly string[] }[]; + } { + const calls: SubscribeCall[] = []; + const detaches: { sessionId: string; agentIds?: readonly string[] }[] = []; + const broadcaster = { + subscribe: async ( + sessionId: string, + _target: unknown, + filter: unknown, + grades: unknown, + opts?: { deferTranscriptReset?: boolean; transcriptSince?: Record }, + ) => { + calls.push({ sessionId, filter, grades, opts }); + return true; + }, + unsubscribe: () => {}, + unsubscribeTranscript: (sessionId: string, _target: unknown, agentIds?: readonly string[]) => { + detaches.push({ sessionId, agentIds }); + }, + addGlobalTarget: () => {}, + removeGlobalTarget: () => {}, + getCursor: async () => ({ seq: 0, epoch: '' }), + getBufferedSince: async () => ({ + events: [], + resyncRequired: false, + currentSeq: 0, + epoch: '', + }), + } as unknown as SessionEventBroadcaster; + return { broadcaster, calls, detaches }; + } + + function controlFrame(type: string, payload: Record): string { + return JSON.stringify({ type, id: 'req-1', payload }); + } + + it('forwards subscribe_v2 grades and transcript_since to the broadcaster and stores them per session', async () => { + const socket = new FakeSocket(); + const { broadcaster, calls } = makeCapturingBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + + socket.emit( + 'message', + controlFrame('subscribe_v2', { + session_id: 's1', + transcript: { '*': 'delta' }, + transcript_since: { main: 7, '*': 3 }, + }), + ); + await vi.waitFor(() => expect(calls).toHaveLength(1)); + + expect(calls[0]).toMatchObject({ + sessionId: 's1', + grades: { '*': 'delta' }, + opts: { transcriptSince: { main: 7, '*': 3 } }, + }); + expect(conn.subscriptions.get('s1')).toEqual({ + agentFilter: undefined, + transcriptGrades: { '*': 'delta' }, + }); + await vi.waitFor(() => + expect(socket.sent.some((f) => JSON.parse(f).type === 'ack')).toBe(true), + ); + const ack = socket.sent.map((f) => JSON.parse(f)).find((f) => f.type === 'ack'); + expect(ack).toMatchObject({ code: 0, payload: { accepted: ['s1'], not_found: [] } }); + conn.close(); + }); + + it('ignores legacy transcript fields on client_hello and subscribe', async () => { + const socket = new FakeSocket(); + const { broadcaster, calls } = makeCapturingBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + + socket.emit( + 'message', + controlFrame('client_hello', { + client_id: 'c1', + subscriptions: ['s1'], + transcript: { s1: { '*': 'delta' } }, + transcript_since: { s1: { main: 7 } }, + }), + ); + await vi.waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0]).toMatchObject({ sessionId: 's1', grades: undefined }); + expect(calls[0]!.opts?.transcriptSince).toBeUndefined(); + expect(conn.subscriptions.get('s1')).toEqual({ + agentFilter: undefined, + transcriptGrades: undefined, + }); + + socket.emit( + 'message', + controlFrame('subscribe', { + session_ids: ['s2'], + transcript: { s2: { '*': 'delta' } }, + }), + ); + await vi.waitFor(() => expect(calls).toHaveLength(2)); + expect(calls[1]).toMatchObject({ sessionId: 's2', grades: undefined }); + expect(conn.subscriptions.get('s2')).toEqual({ + agentFilter: undefined, + transcriptGrades: undefined, + }); + conn.close(); + }); + + it('acks an invalid subscribe_v2 payload with an error and does not attach', async () => { + const socket = new FakeSocket(); + const { broadcaster, calls } = makeCapturingBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + + socket.emit( + 'message', + controlFrame('subscribe_v2', { + session_id: 's1', + transcript: { main: 'everything' }, + }), + ); + await vi.waitFor(() => + expect(socket.sent.some((f) => JSON.parse(f).type === 'ack')).toBe(true), + ); + + expect(calls).toHaveLength(0); + expect(conn.subscriptions.size).toBe(0); + const ack = socket.sent.map((f) => JSON.parse(f)).find((f) => f.type === 'ack'); + expect(ack.code).toBe(1); + conn.close(); + }); + + it('preserves the existing agent filter when subscribe_v2 updates the grades', async () => { + const socket = new FakeSocket(); + const { broadcaster, calls } = makeCapturingBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + + socket.emit( + 'message', + controlFrame('subscribe', { session_ids: ['s1'], agent_filter: { s1: ['main'] } }), + ); + await vi.waitFor(() => expect(calls).toHaveLength(1)); + + socket.emit( + 'message', + controlFrame('subscribe_v2', { session_id: 's1', transcript: { main: 'block' } }), + ); + await vi.waitFor(() => expect(calls).toHaveLength(2)); + + expect(calls[1]).toMatchObject({ sessionId: 's1', grades: { main: 'block' } }); + expect(calls[1]!.filter).toEqual(new Set(['main'])); + expect(conn.subscriptions.get('s1')).toEqual({ + agentFilter: new Set(['main']), + transcriptGrades: { main: 'block' }, + }); + conn.close(); + }); + + it('keeps subscribe_v2 grades across a plain re-subscribe and filters the cursor replay through them', async () => { + const socket = new FakeSocket(); + const backlog = [ + durable('turn.started', 's1', 3), + durable('assistant.delta', 's1', 4), + durable('event.session.work_changed', 's1', 5), + ]; + const PROJECTED = new Set(['turn.started', 'assistant.delta']); + let seenGrades: unknown; + const broadcaster = { + subscribe: async ( + _sid: string, + target: { send: (e: unknown) => void }, + _filter: unknown, + _grades: unknown, + opts?: { deferTranscriptReset?: boolean }, + ) => { + if (opts?.deferTranscriptReset !== true) { + target.send({ type: 'transcript.reset', seq: 10, session_id: 's1', payload: {} }); + } + return true; + }, + flushTranscriptSeed: async (_sid: string, target: { send: (e: unknown) => void }) => { + target.send({ type: 'transcript.reset', seq: 10, session_id: 's1', payload: {} }); + }, + unsubscribe: () => {}, + addGlobalTarget: () => {}, + removeGlobalTarget: () => {}, + getCursor: async () => ({ seq: 10, epoch: 'e1' }), + getBufferedSince: async (_sid: string, _cursor: unknown, _filter: unknown, grades: unknown) => { + seenGrades = grades; + return { + events: backlog + .filter((envelope) => grades === undefined || !PROJECTED.has(envelope.type)) + .map((envelope) => ({ seq: envelope.seq, envelope })), + resyncRequired: false, + currentSeq: 10, + epoch: 'e1', + }; + }, + } as unknown as SessionEventBroadcaster; + const conn = makeConn(socket, { broadcaster, flushIntervalMs: 1 }); + + socket.emit( + 'message', + controlFrame('subscribe_v2', { session_id: 's1', transcript: { '*': 'delta' } }), + ); + await vi.waitFor(() => { + const types = socket.frames().map((f) => (f as { type: string }).type); + expect(types).toContain('transcript.reset'); + }); + expect(conn.subscriptions.get('s1')?.transcriptGrades).toEqual({ '*': 'delta' }); + + socket.emit( + 'message', + controlFrame('subscribe', { + session_ids: ['s1'], + cursors: { s1: { seq: 2, epoch: 'e1' } }, + }), + ); + await vi.waitFor(() => expect(seenGrades).toEqual({ '*': 'delta' })); + expect(conn.subscriptions.get('s1')?.transcriptGrades).toEqual({ '*': 'delta' }); + + const types = socket.frames().map((f) => (f as { type: string }).type); + expect(types).not.toContain('turn.started'); + expect(types).not.toContain('assistant.delta'); + expect( + types.slice(types.indexOf('event.session.work_changed'), types.lastIndexOf('transcript.reset') + 1), + ).toEqual(['event.session.work_changed', 'transcript.reset']); + conn.close(); + }); + + it('reports an unknown session in the subscribe_v2 ack not_found list', async () => { + const socket = new FakeSocket(); + const { broadcaster } = makeCapturingBroadcaster(); + broadcaster.subscribe = async () => false; + const conn = makeConn(socket, { broadcaster }); + + socket.emit( + 'message', + controlFrame('subscribe_v2', { session_id: 'gone', transcript: { '*': 'delta' } }), + ); + await vi.waitFor(() => + expect(socket.sent.some((f) => JSON.parse(f).type === 'ack')).toBe(true), + ); + + const ack = socket.sent.map((f) => JSON.parse(f)).find((f) => f.type === 'ack'); + expect(ack).toMatchObject({ code: 0, payload: { accepted: [], not_found: ['gone'] } }); + expect(conn.subscriptions.size).toBe(0); + conn.close(); + }); + + it('unsubscribe_v2 detaches listed agents with an explicit off, keeping the filter and other grades', async () => { + const socket = new FakeSocket(); + const { broadcaster, calls, detaches } = makeCapturingBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + + socket.emit( + 'message', + controlFrame('subscribe', { session_ids: ['s1'], agent_filter: { s1: ['main'] } }), + ); + await vi.waitFor(() => expect(calls).toHaveLength(1)); + socket.emit( + 'message', + controlFrame('subscribe_v2', { session_id: 's1', transcript: { '*': 'delta' } }), + ); + await vi.waitFor(() => + expect(conn.subscriptions.get('s1')?.transcriptGrades).toEqual({ '*': 'delta' }), + ); + + socket.emit( + 'message', + controlFrame('unsubscribe_v2', { session_id: 's1', agent_ids: ['main'] }), + ); + await vi.waitFor(() => expect(detaches).toHaveLength(1)); + + expect(detaches[0]).toEqual({ sessionId: 's1', agentIds: ['main'] }); + expect(conn.subscriptions.get('s1')).toEqual({ + agentFilter: new Set(['main']), + transcriptGrades: { '*': 'delta', main: 'off' }, + }); + const ack = socket.sent.map((f) => JSON.parse(f)).findLast((f) => f.type === 'ack'); + expect(ack).toMatchObject({ code: 0, payload: { accepted: ['s1'], not_found: [] } }); + conn.close(); + }); + + it('unsubscribe_v2 without agent_ids detaches the whole transcript stream', async () => { + const socket = new FakeSocket(); + const { broadcaster, detaches } = makeCapturingBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + + socket.emit( + 'message', + controlFrame('subscribe_v2', { session_id: 's1', transcript: { '*': 'delta' } }), + ); + await vi.waitFor(() => + expect(conn.subscriptions.get('s1')?.transcriptGrades).toEqual({ '*': 'delta' }), + ); + + socket.emit('message', controlFrame('unsubscribe_v2', { session_id: 's1' })); + await vi.waitFor(() => expect(detaches).toHaveLength(1)); + + expect(detaches[0]).toEqual({ sessionId: 's1', agentIds: undefined }); + expect(conn.subscriptions.get('s1')).toEqual({ + agentFilter: undefined, + transcriptGrades: undefined, + }); + conn.close(); + }); + + it('unsubscribe_v2 is idempotent for an unsubscribed session and never touches the broadcaster', async () => { + const socket = new FakeSocket(); + const { broadcaster, calls, detaches } = makeCapturingBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + + socket.emit('message', controlFrame('unsubscribe_v2', { session_id: 's1' })); + await vi.waitFor(() => + expect(socket.sent.some((f) => JSON.parse(f).type === 'ack')).toBe(true), + ); + + expect(calls).toHaveLength(0); + expect(detaches).toHaveLength(0); + const ack = socket.sent.map((f) => JSON.parse(f)).find((f) => f.type === 'ack'); + expect(ack).toMatchObject({ code: 0, payload: { accepted: ['s1'] } }); + conn.close(); + }); + + it('acks an invalid unsubscribe_v2 payload with an error', async () => { + const socket = new FakeSocket(); + const { broadcaster, detaches } = makeCapturingBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + + socket.emit('message', controlFrame('unsubscribe_v2', { agent_ids: ['main'] })); + socket.emit( + 'message', + controlFrame('unsubscribe_v2', { session_id: 's1', agent_ids: [] }), + ); + await vi.waitFor(() => + expect(socket.sent.filter((f) => JSON.parse(f).type === 'ack')).toHaveLength(2), + ); + + expect(detaches).toHaveLength(0); + const acks = socket.sent.map((f) => JSON.parse(f)).filter((f) => f.type === 'ack'); + expect(acks.every((a) => a.code === 1)).toBe(true); + conn.close(); + }); + + it('serializes back-to-back control frames: subscribe then subscribe_v2 lands filter and grades', async () => { + const socket = new FakeSocket(); + const { broadcaster, calls } = makeCapturingBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + + socket.emit( + 'message', + controlFrame('subscribe', { session_ids: ['s1'], agent_filter: { s1: ['main'] } }), + ); + socket.emit( + 'message', + controlFrame('subscribe_v2', { session_id: 's1', transcript: { '*': 'delta' } }), + ); + await vi.waitFor(() => expect(calls).toHaveLength(2)); + + expect(conn.subscriptions.get('s1')).toEqual({ + agentFilter: new Set(['main']), + transcriptGrades: { '*': 'delta' }, + }); + conn.close(); + }); + + it('re-subscribes an agent at full grade after it was detached', async () => { + const socket = new FakeSocket(); + const { broadcaster, calls } = makeCapturingBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + + socket.emit( + 'message', + controlFrame('subscribe_v2', { session_id: 's1', transcript: { '*': 'delta' } }), + ); + socket.emit('message', controlFrame('unsubscribe_v2', { session_id: 's1' })); + await vi.waitFor(() => + expect(conn.subscriptions.get('s1')?.transcriptGrades).toBeUndefined(), + ); + + socket.emit( + 'message', + controlFrame('subscribe_v2', { session_id: 's1', transcript: { main: 'turn' } }), + ); + await vi.waitFor(() => expect(calls).toHaveLength(2)); + + expect(calls[1]).toMatchObject({ sessionId: 's1', grades: { main: 'turn' } }); + expect(conn.subscriptions.get('s1')?.transcriptGrades).toEqual({ main: 'turn' }); + conn.close(); + }); +}); + +describe('WsConnectionV1 outbound buffer', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('sends server_hello immediately', () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16 }); + expect(socket.frames().map((f) => (f as { type: string }).type)).toEqual(['server_hello']); + conn.close(); + }); + + it('buffers subscribe_v2 transcript frames without merging them', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16 }); + socket.sent = []; + + conn.send(durable('transcript.reset', 's1', 7)); + conn.send(durable('transcript.ops', 's1', 8)); + expect(socket.sent).toHaveLength(0); + await vi.advanceTimersByTimeAsync(15); + expect(socket.sent).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1); + + const frames = socket.frames() as Array<{ type: string; seq: number }>; + expect(frames.map((frame) => frame.type)).toEqual(['transcript.reset', 'transcript.ops']); + expect(frames.map((frame) => frame.seq)).toEqual([7, 8]); + conn.close(); + }); + + it('coalesces adjacent subscribed deltas into one socket.send', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16 }); + socket.sent = []; + + conn.send(delta('s1', 'main', 1, 'Hello', 0)); + conn.send(delta('s1', 'main', 1, ' ', 5)); + conn.send(delta('s1', 'main', 1, 'world', 6)); + expect(socket.sent).toHaveLength(0); + await vi.advanceTimersByTimeAsync(16); + + const frames = socket.frames(); + expect(frames).toHaveLength(1); + const f = frames[0] as { type: string; offset: number; payload: { delta: string } }; + expect(f.type).toBe('assistant.delta'); + expect(f.offset).toBe(0); + expect(f.payload.delta).toBe('Hello world'); + conn.close(); + }); + + it('sends public events immediately and preserves FIFO with subscribed events', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16 }); + socket.sent = []; + + conn.send(delta('s1', 'main', 1, 'before', 0)); + expect(socket.sent).toHaveLength(0); + conn.send(durable('event.session.work_changed', 's1', 2), 'immediate'); + + expect(socket.frames().map((f) => (f as { type: string }).type)).toEqual([ + 'assistant.delta', + 'event.session.work_changed', + ]); + await vi.advanceTimersByTimeAsync(16); + expect(socket.sent).toHaveLength(2); + conn.close(); + }); + + it('flushes immediately once the subscribed batch reaches maxBatchSize', () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 1000, maxBatchSize: 3 }); + socket.sent = []; + + conn.send(delta('s1', 'main', 1, 'a', 0)); + conn.send(delta('s1', 'main', 1, 'b', 1)); + conn.send(delta('s1', 'main', 1, 'c', 2)); + + const frames = socket.frames(); + expect(frames).toHaveLength(1); + expect((frames[0] as { payload: { delta: string } }).payload.delta).toBe('abc'); + conn.close(); + }); + + it('defers flushing while the peer is above the watermark, then coalesces on drain', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { + flushIntervalMs: 16, + highWaterMarkBytes: 100, + }); + socket.sent = []; + + socket.bufferedAmount = 200; + conn.send(delta('s1', 'main', 1, 'Hello', 0)); + await vi.advanceTimersByTimeAsync(16); + expect(socket.sent).toHaveLength(0); + + conn.send(delta('s1', 'main', 1, ' world', 5)); + await vi.advanceTimersByTimeAsync(5); + expect(socket.sent).toHaveLength(0); + + socket.bufferedAmount = 0; + await vi.advanceTimersByTimeAsync(5); + const frames = socket.frames(); + expect(frames).toHaveLength(1); + expect((frames[0] as { payload: { delta: string } }).payload.delta).toBe('Hello world'); + conn.close(); + }); + + it('force-flushes buffered subscription frames on close', () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 1000 }); + socket.sent = []; + + conn.send(delta('s1', 'main', 1, 'tail', 0)); + expect(socket.sent).toHaveLength(0); + + conn.close(); + const frames = socket.frames(); + expect(frames).toHaveLength(1); + expect((frames[0] as { payload: { delta: string } }).payload.delta).toBe('tail'); + }); + + it('drops buffered frames when the socket is already closed at flush time', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16 }); + socket.sent = []; + + socket.readyState = socket.CLOSED; + conn.send(delta('s1', 'main', 1, 'lost', 0)); + await vi.advanceTimersByTimeAsync(16); + expect(socket.sent).toHaveLength(0); + }); +}); + +describe('WsConnectionV1 heartbeat', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + function sentTypes(socket: FakeSocket): string[] { + return socket.frames().map((f) => (f as { type: string }).type); + } + + function sentPings(socket: FakeSocket): Array<{ type: string; payload: { nonce: string } }> { + return socket.frames() as Array<{ type: string; payload: { nonce: string } }>; + } + + it('advertises the heartbeat interval in server_hello', () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { heartbeatIntervalMs: 10 }); + const hello = socket.frames()[0] as { type: string; payload: { heartbeat_ms?: number } }; + expect(hello.type).toBe('server_hello'); + expect(hello.payload.heartbeat_ms).toBe(10); + conn.close(); + }); + + it('defaults to a 10s heartbeat interval', () => { + const socket = new FakeSocket(); + const conn = makeConn(socket); + const hello = socket.frames()[0] as { payload: { heartbeat_ms?: number } }; + expect(hello.payload.heartbeat_ms).toBe(10_000); + conn.close(); + }); + + it('sends a ping every interval while the peer keeps answering', () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { heartbeatIntervalMs: 10 }); + socket.sent = []; + + for (let i = 0; i < 3; i++) { + vi.advanceTimersByTime(10); + expect(sentTypes(socket)).toHaveLength(i + 1); + socket.emit('message', JSON.stringify({ type: 'pong', payload: { nonce: 'n' } })); + } + + const pings = sentPings(socket); + expect(pings.every((f) => f.type === 'ping')).toBe(true); + expect(typeof pings[0]!.payload.nonce).toBe('string'); + expect(new Set(pings.map((f) => f.payload.nonce)).size).toBe(3); + expect(socket.closeCalls).toHaveLength(0); + conn.close(); + }); + + it('reaps the connection after two silent cycles', () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { heartbeatIntervalMs: 10 }); + socket.sent = []; + + vi.advanceTimersByTime(10); + expect(sentTypes(socket)).toEqual(['ping']); + expect(socket.closeCalls).toHaveLength(0); + + vi.advanceTimersByTime(10); + expect(socket.closeCalls).toEqual([{ code: 1001, reason: 'heartbeat timeout' }]); + expect(sentTypes(socket)).toEqual(['ping']); + + vi.advanceTimersByTime(100); + expect(sentTypes(socket)).toEqual(['ping']); + expect(socket.closeCalls).toHaveLength(1); + }); + + it('treats any inbound frame — not just pong — as proof of life', () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { heartbeatIntervalMs: 10 }); + socket.sent = []; + + vi.advanceTimersByTime(15); + socket.emit('message', JSON.stringify({ type: 'some_future_frame', payload: {} })); + + vi.advanceTimersByTime(20); + expect(sentTypes(socket)).toEqual(['ping', 'ping', 'ping']); + expect(socket.closeCalls).toHaveLength(0); + + vi.advanceTimersByTime(5); + expect(socket.closeCalls).toEqual([{ code: 1001, reason: 'heartbeat timeout' }]); + }); + + it('stops heartbeating once the socket closes on its own', () => { + const socket = new FakeSocket(); + makeConn(socket, { heartbeatIntervalMs: 10 }); + socket.sent = []; + + vi.advanceTimersByTime(10); + expect(sentTypes(socket)).toEqual(['ping']); + + socket.terminate(); + vi.advanceTimersByTime(100); + expect(sentTypes(socket)).toEqual(['ping']); + expect(socket.closeCalls).toHaveLength(0); + }); +}); + +describe('WsConnectionV1 global target registration', () => { + function makeGlobalTargetBroadcaster() { + const added: unknown[] = []; + const removed: unknown[] = []; + const diOptIns: unknown[] = []; + const broadcaster = { + subscribe: async () => true, + unsubscribe: () => {}, + addGlobalTarget: (target: unknown) => added.push(target), + removeGlobalTarget: (target: unknown) => removed.push(target), + addDiEventTarget: (target: unknown) => diOptIns.push(target), + getCursor: async () => ({ seq: 0, epoch: '' }), + getBufferedSince: async () => ({ + events: [], + resyncRequired: false, + currentSeq: 0, + epoch: '', + }), + } as unknown as SessionEventBroadcaster; + return { broadcaster, added, removed, diOptIns }; + } + + it('registers the connection as a global target on construction and unregisters on close', () => { + const socket = new FakeSocket(); + const { broadcaster, added, removed } = makeGlobalTargetBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + + expect(added).toEqual([conn]); + expect(removed).toEqual([]); + + conn.close(); + expect(removed).toEqual([conn]); + }); + + it('unregisters when the socket closes on its own', () => { + const socket = new FakeSocket(); + const { broadcaster, added, removed } = makeGlobalTargetBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + expect(added).toEqual([conn]); + + socket.emit('close'); + expect(removed).toEqual([conn]); + }); + + it('opts only kimi-inspect connections into the event.di.* debug feed on client_hello', async () => { + const socket = new FakeSocket(); + const { broadcaster, diOptIns } = makeGlobalTargetBroadcaster(); + const conn = makeConn(socket, { broadcaster }); + + socket.emit( + 'message', + JSON.stringify({ type: 'client_hello', id: 'h1', payload: { client_id: 'kimi-web' } }), + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(diOptIns).toEqual([]); + + socket.emit( + 'message', + JSON.stringify({ + type: 'client_hello', + id: 'h2', + payload: { client_id: 'kimi-inspect' }, + }), + ); + await vi.waitFor(() => expect(diOptIns).toEqual([conn])); + conn.close(); + }); +}); diff --git a/packages/kap-server/test/wsHostOrigin.test.ts b/packages/kap-server/test/wsHostOrigin.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..de63f1b3de03a734e6dc7c354b9aac382037e1ec --- /dev/null +++ b/packages/kap-server/test/wsHostOrigin.test.ts @@ -0,0 +1,122 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { fixedTokenAuth } from './helpers/fixedAuth'; + +const TOKEN = 'test-token'; + +interface ConnectOptions { + readonly headers?: Record; +} + +function openConn(url: string, opts?: ConnectOptions): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, [`kimi-code.bearer.${TOKEN}`], { headers: opts?.headers }); + ws.once('open', () => resolve(ws)); + ws.once('error', reject); + }); +} + +function expectRejected(url: string, opts?: ConnectOptions): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, [`kimi-code.bearer.${TOKEN}`], { headers: opts?.headers }); + const done = (err?: Error): void => { + clearTimeout(t); + ws.removeAllListeners(); + try { + ws.terminate(); + } catch { + } + if (err !== undefined) reject(err); + else resolve(); + }; + const t = setTimeout( + () => done(new Error('connection was not rejected within timeout')), + 1500, + ); + ws.once('open', () => done(new Error('connection unexpectedly opened'))); + ws.once('error', () => done()); + ws.once('close', () => done()); + }); +} + +describe('WS upgrade Host/Origin checks', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let v1Url: string; + const sockets: WebSocket[] = []; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ws-host-origin-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + authTokenService: fixedTokenAuth(TOKEN), + }); + v1Url = `ws://127.0.0.1:${server.port}/api/v1/ws`; + }); + + afterEach(() => { + for (const ws of sockets.splice(0)) { + try { + ws.close(); + } catch { + } + } + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + describe('/api/v1/ws', () => { + const url = (): string => v1Url; + + it('rejects a spoofed Host before token validation', async () => { + await expectRejected(url(), { headers: { Host: 'evil.com' } }); + }); + + it('rejects a disallowed browser Origin', async () => { + await expectRejected(url(), { headers: { origin: 'http://evil.com' } }); + }); + + it('allows a normal Host and a Node client with no Origin', async () => { + const ws = await openConn(url()); + sockets.push(ws); + expect(ws.readyState).toBe(WebSocket.OPEN); + }); + }); + + it('allows an explicitly allowed Origin', async () => { + await server?.close(); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + authTokenService: fixedTokenAuth(TOKEN), + corsOrigins: ['https://app.example.test'], + }); + const url = `ws://127.0.0.1:${server.port}/api/v1/ws`; + const ws = await openConn(url, { headers: { origin: 'https://app.example.test' } }); + sockets.push(ws); + expect(ws.readyState).toBe(WebSocket.OPEN); + }); +}); diff --git a/packages/kap-server/test/wsUpgradeAuth.test.ts b/packages/kap-server/test/wsUpgradeAuth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3cd4c78c2a489c7c5e0e1a0bc65db69bfb6e8f1e --- /dev/null +++ b/packages/kap-server/test/wsUpgradeAuth.test.ts @@ -0,0 +1,166 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { IModelCatalog } from '@moonshot-ai/agent-core-v2'; +import { createActor, setup } from '@moonshot-ai/agent-core-v2/human/xstate2'; +import { afterEach, describe, expect, it } from 'vitest'; +import { WebSocket, type RawData } from 'ws'; + +import { startServer } from '../src/start'; +import { fakeModelCatalog } from './helpers/fakeModelCatalog'; +import { fixedTokenAuth } from './helpers/fixedAuth'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { sharedServer } from './helpers/sharedServer'; + +function rawToString(data: RawData): string { + if (typeof data === 'string') return data; + if (Buffer.isBuffer(data)) return data.toString('utf8'); + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} + +interface ConnectOptions { + readonly protocols?: string[]; + readonly headers?: Record; +} + +function openConn(url: string, opts?: ConnectOptions): Promise<{ ws: WebSocket; firstFrame: unknown }> { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers }); + ws.once('message', (data) => { + try { + resolve({ ws, firstFrame: JSON.parse(rawToString(data)) }); + } catch { + resolve({ ws, firstFrame: null }); + } + }); + ws.once('error', reject); + }); +} + +function expectRejected(url: string, opts?: ConnectOptions): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers }); + const done = (err?: Error): void => { + clearTimeout(t); + ws.removeAllListeners(); + try { + ws.terminate(); + } catch { + } + if (err !== undefined) reject(err); + else resolve(); + }; + const t = setTimeout( + () => done(new Error('connection was not rejected within timeout')), + 1500, + ); + ws.once('open', () => done(new Error('connection unexpectedly opened'))); + ws.once('error', () => done()); + ws.once('close', () => done()); + }); +} + +describe('WS upgrade auth', () => { + const sockets: WebSocket[] = []; + + afterEach(() => { + for (const ws of sockets.splice(0)) { + try { + ws.close(); + } catch { + } + } + }); + + function v1Url(): string { + return `${sharedServer().base.replace(/^http/, 'ws')}/api/v1/ws`; + } + + function token(): string { + return sharedServer().token; + } + + describe('/api/v1/ws', () => { + const firstType = 'server_hello'; + const url = (): string => v1Url(); + + it('accepts a valid bearer subprotocol and echoes it', async () => { + const { ws, firstFrame } = await openConn(url(), { + protocols: [`kimi-code.bearer.${token()}`], + }); + sockets.push(ws); + expect(ws.protocol).toBe(`kimi-code.bearer.${token()}`); + expect(firstFrame).toMatchObject({ type: firstType }); + }); + + it('rejects a wrong bearer token', async () => { + await expectRejected(url(), { protocols: ['kimi-code.bearer.wrong'] }); + }); + + it('rejects a connection with no token', async () => { + await expectRejected(url()); + }); + }); + + describe('/api/v1/debug/ws', () => { + it('streams xstate inspection envelopes to an authorized client', async () => { + const home = await mkdtemp(join(tmpdir(), 'kimi-kap-debug-ws-')); + const server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + debugEndpoints: true, + authTokenService: fixedTokenAuth(), + seeds: [[IModelCatalog, fakeModelCatalog()]], + }); + const ws = new WebSocket(`ws://127.0.0.1:${server.port}/api/v1/debug/ws`, { + headers: { Authorization: 'Bearer test-token' }, + }); + sockets.push(ws); + try { + const envelope = await new Promise>((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('no inspection envelope within timeout')), + 5000, + ); + ws.on('message', (data) => { + const frame = JSON.parse(rawToString(data)) as Record; + if (frame['eventType'] === 'debug.probe') { + clearTimeout(timer); + resolve(frame); + } + }); + ws.on('error', reject); + ws.once('open', () => { + const machine = setup({}).createMachine({ + id: 'debugWsProbe', + initial: 'idle', + states: { idle: { on: { 'debug.probe': 'done' } }, done: {} }, + }); + const actor = createActor(machine); + actor.start(); + actor.send({ type: 'debug.probe' }); + }); + }); + expect(envelope['type']).toBe('@xstate.event'); + expect(envelope['logicId']).toBe('debugWsProbe'); + expect(typeof envelope['actorSessionId']).toBe('string'); + expect(typeof envelope['timestamp']).toBe('number'); + } finally { + await server.close(); + await rm(home, { recursive: true, force: true }); + } + }); + }); + + it('rejects upgrades to a non-WS path', async () => { + const badUrl = `${v1Url().replace('/api/v1/ws', '/api/v1/other')}`; + await expectRejected(badUrl, { protocols: [`kimi-code.bearer.${token()}`] }); + const debugUrl = `${v1Url().replace('/api/v1/ws', '/api/v1/debug/ws')}`; + await expectRejected(debugUrl, { protocols: [`kimi-code.bearer.${token()}`] }); + }); +}); diff --git a/packages/kap-server/test/wsV1Resync.test.ts b/packages/kap-server/test/wsV1Resync.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bef30a62a0e48d45a6b9a659eba9001b1fb6e1d --- /dev/null +++ b/packages/kap-server/test/wsV1Resync.test.ts @@ -0,0 +1,285 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + type Event2, + IEventBus, + IAgentLifecycleService, + getLiveSessionById, +} from '@moonshot-ai/agent-core-v2'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Frame { + type: string; + id?: string; + seq?: number; + session_id?: string; + payload?: Record; + volatile?: boolean; + offset?: number; +} + +interface Conn { + ws: WebSocket; + frames: Frame[]; + waiters: Array<(f: Frame) => void>; + closed: Promise; + send: (f: unknown) => void; + next: (pred: (f: Frame) => boolean, timeoutMs?: number) => Promise; +} + +function openConn(url: string, token: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, [`kimi-code.bearer.${token}`]); + const frames: Frame[] = []; + const waiters: Array<(f: Frame) => void> = []; + const closed = new Promise((res) => ws.on('close', () => res())); + ws.on('message', (data) => { + let frame: Frame; + try { + frame = JSON.parse((data as Buffer).toString()) as Frame; + } catch { + return; + } + const w = waiters.shift(); + if (w) w(frame); + else frames.push(frame); + }); + ws.once('open', () => + resolve({ + ws, + frames, + waiters, + closed, + send: (f) => ws.send(JSON.stringify(f)), + next: (pred, timeoutMs = 2000) => + new Promise((res, rej) => { + const idx = frames.findIndex(pred); + if (idx >= 0) { + res(frames.splice(idx, 1)[0]!); + return; + } + const deadline = Date.now() + timeoutMs; + let t: ReturnType; + const waiter = (f: Frame): void => { + clearTimeout(t); + if (pred(f)) res(f); + else { + frames.push(f); + waiters.push(waiter); + arm(); + } + }; + const arm = (): void => { + const left = deadline - Date.now(); + if (left <= 0) { + const i = waiters.indexOf(waiter); + if (i >= 0) waiters.splice(i, 1); + rej(new Error('timeout waiting for frame')); + return; + } + t = setTimeout(() => { + const i = waiters.indexOf(waiter); + if (i >= 0) waiters.splice(i, 1); + rej(new Error('timeout waiting for frame')); + }, left); + }; + arm(); + waiters.push(waiter); + }), + }), + ); + ws.once('error', reject); + }); +} + +describe('server-v2 /api/v1/ws resync', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + let wsUrl: string; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-wsv1-test-')); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + base = `http://127.0.0.1:${server.port}`; + wsUrl = `ws://127.0.0.1:${server.port}/api/v1/ws`; + }); + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function createSession(): Promise { + const res = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ metadata: { cwd: home } }), + } as never); + const body = (await res.json()) as { code: number; data: { id: string } }; + expect(body.code).toBe(0); + return body.data.id; + } + + async function ensureMainAgent(sessionId: string): Promise { + const session = getLiveSessionById(server!.core.accessor, sessionId); + expect(session).toBeDefined(); + const agents = session!.accessor.get(IAgentLifecycleService); + if (agents.handleOf('main') === undefined) { + await agents.create({ agentId: 'main' }); + } + } + + function withToken>(payload: T): T & { token: string } { + return { ...payload, token: server!.authTokenService.getToken() }; + } + + function emitAgentEvent(sessionId: string, event: Event2): void { + const session = getLiveSessionById(server!.core.accessor, sessionId); + expect(session).toBeDefined(); + const agents = session!.accessor.get(IAgentLifecycleService); + const main = agents.handleOf('main'); + expect(main).toBeDefined(); + main!.accessor.get(IEventBus).publish(event); + } + + it('server_hello then client_hello ack with accepted subscription', async () => { + const sid = await createSession(); + const c = await openConn(wsUrl, server!.authTokenService.getToken()); + + const hello = await c.next((f) => f.type === 'server_hello'); + expect(hello.payload).toMatchObject({ protocol_version: 2 }); + + c.send({ + type: 'client_hello', + id: 'h1', + payload: withToken({ client_id: 'cli', subscriptions: [sid] }), + }); + const ack = await c.next((f) => f.type === 'ack' && f.id === 'h1'); + expect(ack.payload).toMatchObject({ accepted_subscriptions: [sid], resync_required: [] }); + + c.ws.close(); + await c.closed; + }); + + it('delivers a sequenced durable event to a subscribed connection', async () => { + const sid = await createSession(); + await ensureMainAgent(sid); + const c = await openConn(wsUrl, server!.authTokenService.getToken()); + await c.next((f) => f.type === 'server_hello'); + c.send({ type: 'client_hello', id: 'h1', payload: withToken({ client_id: 'cli', subscriptions: [sid] }) }); + await c.next((f) => f.type === 'ack' && f.id === 'h1'); + + emitAgentEvent(sid, { type: 'turn.started', turnId: 1 } as unknown as Event2); + + const ev = await c.next((f) => f.type === 'turn.started'); + expect(ev.seq).toBeGreaterThanOrEqual(1); + expect(ev.session_id).toBe(sid); + expect(ev.volatile).toBeUndefined(); + + c.ws.close(); + await c.closed; + }); + + it('replays durable events since a cursor on reconnect', async () => { + const sid = await createSession(); + await ensureMainAgent(sid); + + const c1 = await openConn(wsUrl, server!.authTokenService.getToken()); + await c1.next((f) => f.type === 'server_hello'); + c1.send({ type: 'client_hello', id: 'h1', payload: withToken({ client_id: 'cli', subscriptions: [sid] }) }); + await c1.next((f) => f.type === 'ack' && f.id === 'h1'); + emitAgentEvent(sid, { type: 'turn.started', turnId: 1 } as unknown as Event2); + emitAgentEvent(sid, { type: 'turn.ended', turnId: 1 } as unknown as Event2); + await c1.next((f) => f.type === 'turn.ended'); + c1.ws.close(); + await c1.closed; + + const c2 = await openConn(wsUrl, server!.authTokenService.getToken()); + await c2.next((f) => f.type === 'server_hello'); + c2.send({ + type: 'client_hello', + id: 'h2', + payload: withToken({ client_id: 'cli', subscriptions: [sid], cursors: { [sid]: { seq: 1 } } }), + }); + const replayed = await c2.next((f) => f.type === 'turn.ended'); + expect(replayed.seq).toBeGreaterThanOrEqual(2); + const ack2 = await c2.next((f) => f.type === 'ack' && f.id === 'h2'); + expect(ack2.payload).toMatchObject({ accepted_subscriptions: [sid] }); + + c2.ws.close(); + await c2.closed; + }); + + it('sends resync_required on epoch mismatch', async () => { + const sid = await createSession(); + const c = await openConn(wsUrl, server!.authTokenService.getToken()); + await c.next((f) => f.type === 'server_hello'); + c.send({ + type: 'client_hello', + id: 'h1', + payload: withToken({ + client_id: 'cli', + subscriptions: [sid], + cursors: { [sid]: { seq: 0, epoch: 'ep_wrong' } }, + }), + }); + const rs = await c.next((f) => f.type === 'resync_required'); + expect(rs.payload).toMatchObject({ session_id: sid, reason: 'epoch_changed' }); + + c.ws.close(); + await c.closed; + }); + + it('delivers only the allowlisted agent events via agent_filter', async () => { + const sid = await createSession(); + await ensureMainAgent(sid); + + const session = getLiveSessionById(server!.core.accessor, sid); + expect(session).toBeDefined(); + const agents = session!.accessor.get(IAgentLifecycleService); + await agents.create({ agentId: 'agent-0' }); + const sub = agents.handleOf('agent-0')!; + + const c = await openConn(wsUrl, server!.authTokenService.getToken()); + await c.next((f) => f.type === 'server_hello'); + c.send({ + type: 'client_hello', + id: 'h1', + payload: withToken({ + client_id: 'cli', + subscriptions: [sid], + agent_filter: { [sid]: ['main'] }, + }), + }); + await c.next((f) => f.type === 'ack' && f.id === 'h1'); + + agents.handleOf('main')! + .accessor.get(IEventBus) + .publish({ type: 'turn.ended', turnId: 1 } as unknown as Event2); + sub.accessor + .get(IEventBus) + .publish({ type: 'turn.ended', turnId: 2 } as unknown as Event2); + + const ev = await c.next((f) => f.type === 'turn.ended'); + expect(ev.payload).toMatchObject({ agentId: 'main' }); + + await expect(c.next((f) => f.type === 'turn.ended', 300)).rejects.toThrow(); + + c.ws.close(); + await c.closed; + }); +}); diff --git a/packages/kap-server/test/wsV3.test.ts b/packages/kap-server/test/wsV3.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..794be6f5db7c190500bacf0876c5bc001e04b039 --- /dev/null +++ b/packages/kap-server/test/wsV3.test.ts @@ -0,0 +1,854 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { IDisposable, Workspace } from '@moonshot-ai/agent-core-v2'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { WebSocket, type RawData } from 'ws'; + +import { ErrorCode } from '../src/protocol/error-codes'; +import type { ServerMessage, WorkspaceInfo } from '../src/protocol/messages'; +import { startServer, type RunningServer } from '../src/start'; +import { + WsConnectionV3, + type WsConnectionV3Options, +} from '../src/transport/ws/v3/wsConnectionV3'; +import type { WsV3CoreEvent, WsV3Logger } from '../src/transport/ws/v3/wsV3Deps'; +import { WsV3Hub } from '../src/transport/ws/v3/wsV3Hub'; +import { authHeaders } from './helpers/auth'; +import { fixedTokenAuth } from './helpers/fixedAuth'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +const TS = '2026-01-01T00:00:00.000Z'; +const TS_MS = 1_767_225_600_000; +const WS_ID = 'wd_test_0123456789ab'; + +class FakeSocket { + readonly OPEN = 1; + readonly CLOSED = 3; + readyState = 1; + bufferedAmount = 0; + sent: string[] = []; + pingCalls = 0; + terminateCalls = 0; + closeCalls: Array<{ code?: number; reason?: string }> = []; + private readonly handlers = new Map void>>(); + + on(event: string, cb: (...a: unknown[]) => void): this { + const list = this.handlers.get(event) ?? []; + list.push(cb); + this.handlers.set(event, list); + return this; + } + + send(data: string): void { + this.sent.push(data); + } + + ping(): void { + this.pingCalls += 1; + } + + close(code?: number, reason?: string): void { + this.closeCalls.push({ code, reason }); + this.readyState = this.CLOSED; + this.emit('close'); + } + + terminate(): void { + this.terminateCalls += 1; + this.readyState = this.CLOSED; + this.emit('close'); + } + + emit(event: string, ...a: unknown[]): void { + for (const cb of this.handlers.get(event) ?? []) cb(...a); + } + + frames(): Array> { + return this.sent.map((s) => JSON.parse(s) as Record); + } +} + +class FakeProjection { + readonly live = new Set(); + readonly recovery = new Map(); + readonly listeners = new Map void>>(); + + onMessage( + sessionId: string, + listener: (message: ServerMessage) => void, + ): IDisposable | undefined { + if (!this.live.has(sessionId)) return undefined; + let set = this.listeners.get(sessionId); + if (set === undefined) { + set = new Set(); + this.listeners.set(sessionId, set); + } + set.add(listener); + return { + dispose: () => { + set.delete(listener); + }, + }; + } + + recoveryMessages(sessionId: string): ServerMessage[] { + return this.recovery.get(sessionId) ?? []; + } + + emit(sessionId: string, message: ServerMessage): void { + for (const listener of [...(this.listeners.get(sessionId) ?? [])]) listener(message); + } +} + +class FakeLifecycle { + readonly existing = new Set(); + private readonly cbs = new Set<(event: { sessionId: string }) => void>(); + + onDidCreateSession(cb: (event: { sessionId: string }) => void): IDisposable { + this.cbs.add(cb); + return { + dispose: () => { + this.cbs.delete(cb); + }, + }; + } + + async sessionExists(sessionId: string): Promise { + return this.existing.has(sessionId); + } + + fireCreated(sessionId: string): void { + for (const cb of [...this.cbs]) cb({ sessionId }); + } +} + +class FakeGlobalSource { + workspaces: Workspace[] = []; + sessionInfoResult: unknown; + private readonly cbs = new Set<(event: WsV3CoreEvent) => void>(); + + subscribe(cb: (event: WsV3CoreEvent) => void): IDisposable { + this.cbs.add(cb); + return { + dispose: () => { + this.cbs.delete(cb); + }, + }; + } + + fire(event: WsV3CoreEvent): void { + for (const cb of [...this.cbs]) cb(event); + } + + async listWorkspaces(): Promise { + return this.workspaces; + } + + async workspaceInfo(workspace: Workspace): Promise { + return { + id: workspace.id, + root: workspace.root, + name: workspace.name, + created_at: new Date(workspace.createdAt).toISOString(), + last_opened_at: new Date(workspace.lastOpenedAt).toISOString(), + session_count: 0, + }; + } + + async sessionInfo(): Promise { + return this.sessionInfoResult; + } +} + +interface Harness { + projection: FakeProjection; + lifecycle: FakeLifecycle; + globalSource: FakeGlobalSource; + hub: WsV3Hub; + logger: WsV3Logger; + warnings: string[]; +} + +function makeHarness(): Harness { + const projection = new FakeProjection(); + const lifecycle = new FakeLifecycle(); + const globalSource = new FakeGlobalSource(); + const warnings: string[] = []; + const logger: WsV3Logger = { + warn: (_obj, msg) => { + warnings.push(msg); + }, + }; + const hub = new WsV3Hub({ projection, lifecycle, globalSource, logger }); + return { projection, lifecycle, globalSource, hub, logger, warnings }; +} + +function makeConn( + hub: WsV3Hub, + socket: FakeSocket, + opts: Partial = {}, +): WsConnectionV3 { + return new WsConnectionV3({ + socket: socket as unknown as WebSocket, + hub, + remoteAddress: null, + userAgent: null, + serverId: 'srv_test', + ...opts, + }); +} + +async function settle(rounds = 5): Promise { + for (let i = 0; i < rounds; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function sessionStateMessage(sessionId: string): ServerMessage { + return { + type: 'session.state', + session_id: sessionId, + timestamp: TS_MS, + status: 'idle', + }; +} + +function assistantMessage(sessionId: string, agentId: string, text = 'hello'): ServerMessage { + return { + type: 'assistant', + session_id: sessionId, + agent_id: agentId, + timestamp: TS_MS, + message_id: `t1.1.a0.${agentId}.${text}`, + turn_id: 't1', + step_id: 't1.1', + status: 'streaming', + text, + }; +} + +function assistantDeltaMessage(sessionId: string, agentId: string): ServerMessage { + return { + type: 'assistant.delta', + session_id: sessionId, + agent_id: agentId, + timestamp: TS_MS, + message_id: `t1.1.a0.${agentId}.delta`, + text: 'chunk', + }; +} + +function sessionInfoWire(id: string): Record { + return { + id, + workspace_id: WS_ID, + title: 'session title', + created_at: TS, + updated_at: TS, + busy: false, + metadata: { cwd: '/tmp' }, + agent_config: { model: 'model-x' }, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + context_tokens: 0, + }, + permission_rules: [], + message_count: 0, + last_seq: 0, + }; +} + +function frameTypes(socket: FakeSocket): unknown[] { + return socket.frames().map((frame) => frame['type']); +} + +describe('WsConnectionV3 handshake and recovery', () => { + it('sends hello immediately with protocol version, server id and capabilities', () => { + const { hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + expect(socket.frames()[0]).toEqual({ + type: 'hello', + protocol_version: '3', + server_id: 'srv_test', + capabilities: ['step_replay_v1'], + }); + }); + + it('acks subscribe and delivers recovery before live messages in one session sequence', async () => { + const { projection, lifecycle, hub, logger, warnings } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recovery.set('s1', [sessionStateMessage('s1'), assistantMessage('s1', 'main')]); + const socket = new FakeSocket(); + makeConn(hub, socket, { logger }); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 7, session_id: 's1' })); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'assistant']); + expect(socket.frames()[1]).toEqual({ type: 'ack', id: 7, code: ErrorCode.SUCCESS }); + + projection.emit('s1', assistantMessage('s1', 'main', 'live')); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'assistant', 'assistant']); + expect(socket.frames()[4]).toMatchObject({ text: 'live' }); + + projection.emit('s1', { type: 'assistant', session_id: 's1' } as ServerMessage); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'assistant', 'assistant']); + expect(warnings.some((msg) => msg.includes('failed schema validation'))).toBe(true); + }); + + it('acks SESSION_NOT_FOUND when subscribing to an unknown session', async () => { + const { hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 3, session_id: 'ghost' })); + await settle(); + expect(socket.frames()[1]).toMatchObject({ + type: 'ack', + id: 3, + code: ErrorCode.SESSION_NOT_FOUND, + }); + }); + + it('replies error for unknown frame types and malformed JSON', () => { + const { hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'bogus_frame', id: 1 })); + socket.emit('message', 'not json at all'); + expect(socket.frames()[1]).toMatchObject({ + type: 'error', + code: ErrorCode.VALIDATION_FAILED, + }); + expect(socket.frames()[2]).toMatchObject({ + type: 'error', + code: ErrorCode.REQUEST_MALFORMED, + }); + }); + + it('filters recovery and live messages by agent_ids and omit at the fanout point', async () => { + const { projection, lifecycle, hub } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recovery.set('s1', [ + sessionStateMessage('s1'), + assistantMessage('s1', 'main'), + assistantMessage('s1', 'sub'), + ]); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit( + 'message', + JSON.stringify({ + type: 'subscribe', + id: 1, + session_id: 's1', + agent_ids: ['main'], + omit: ['assistant.delta'], + }), + ); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'assistant']); + + projection.emit('s1', assistantMessage('s1', 'sub', 'filtered')); + projection.emit('s1', assistantDeltaMessage('s1', 'main')); + projection.emit('s1', assistantMessage('s1', 'main', 'kept')); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'assistant', 'assistant']); + expect(socket.frames()[4]).toMatchObject({ text: 'kept' }); + }); + + it('acks unsubscribe and stops further delivery', async () => { + const { projection, lifecycle, hub } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recovery.set('s1', [sessionStateMessage('s1')]); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 1, session_id: 's1' })); + await settle(); + socket.emit('message', JSON.stringify({ type: 'unsubscribe', id: 2, session_id: 's1' })); + await settle(); + expect(socket.frames()[3]).toEqual({ type: 'ack', id: 2, code: ErrorCode.SUCCESS }); + + projection.emit('s1', assistantMessage('s1', 'main', 'late')); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'ack']); + }); + + it('implicitly unsubscribes on disconnect and disposes the lane listener', async () => { + const { projection, lifecycle, hub } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recovery.set('s1', [sessionStateMessage('s1')]); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 1, session_id: 's1' })); + await settle(); + expect(projection.listeners.get('s1')?.size).toBe(1); + + socket.close(); + await settle(); + expect(projection.listeners.get('s1')?.size ?? 0).toBe(0); + projection.emit('s1', assistantMessage('s1', 'main', 'late')); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state']); + }); + + it('delivers recovery again when a fresh connection resubscribes after disconnect', async () => { + const { projection, lifecycle, hub } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recovery.set('s1', [sessionStateMessage('s1')]); + + const first = new FakeSocket(); + makeConn(hub, first); + first.emit('message', JSON.stringify({ type: 'subscribe', id: 1, session_id: 's1' })); + await settle(); + expect(frameTypes(first)).toEqual(['hello', 'ack', 'session.state']); + first.close(); + await settle(); + + const second = new FakeSocket(); + makeConn(hub, second); + second.emit('message', JSON.stringify({ type: 'subscribe', id: 1, session_id: 's1' })); + await settle(); + expect(frameTypes(second)).toEqual(['hello', 'ack', 'session.state']); + }); + + it('replaces the subscription when the same session is subscribed again', async () => { + const { projection, lifecycle, hub } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recovery.set('s1', [sessionStateMessage('s1')]); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 1, session_id: 's1' })); + await settle(); + socket.emit( + 'message', + JSON.stringify({ type: 'subscribe', id: 2, session_id: 's1', agent_ids: ['sub'] }), + ); + await settle(); + expect(frameTypes(socket)).toEqual([ + 'hello', + 'ack', + 'session.state', + 'ack', + 'session.state', + ]); + expect(socket.frames()[3]).toEqual({ type: 'ack', id: 2, code: ErrorCode.SUCCESS }); + + projection.emit('s1', assistantMessage('s1', 'main', 'filtered')); + projection.emit('s1', assistantMessage('s1', 'sub', 'kept')); + await settle(); + expect(frameTypes(socket)).toEqual([ + 'hello', + 'ack', + 'session.state', + 'ack', + 'session.state', + 'assistant', + ]); + expect(socket.frames()[5]).toMatchObject({ text: 'kept' }); + }); + + it('serves a minimal recovery for non-live sessions and backfills one when the session becomes live', async () => { + const { projection, lifecycle, hub } = makeHarness(); + lifecycle.existing.add('s2'); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 1, session_id: 's2' })); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack']); + expect(socket.frames()[1]).toMatchObject({ code: ErrorCode.SUCCESS }); + + projection.live.add('s2'); + projection.recovery.set('s2', [sessionStateMessage('s2')]); + lifecycle.fireCreated('s2'); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state']); + + projection.emit('s2', assistantMessage('s2', 'main', 'after')); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'assistant']); + }); + + it('acks INTERNAL_ERROR and keeps live traffic flowing when the recovery payload throws', async () => { + const { projection, lifecycle, hub, warnings } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recoveryMessages = () => { + throw new Error('recovery boom'); + }; + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 9, session_id: 's1' })); + await settle(); + expect(socket.frames()[1]).toEqual({ type: 'ack', id: 9, code: ErrorCode.SUCCESS }); + expect(socket.frames()[2]).toMatchObject({ type: 'ack', id: 9, code: ErrorCode.INTERNAL_ERROR }); + expect(warnings.some((msg) => msg.includes('recovery failed'))).toBe(true); + + projection.emit('s1', assistantMessage('s1', 'main', 'after')); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'ack', 'assistant']); + expect(socket.frames()[3]).toMatchObject({ text: 'after' }); + }); +}); + +describe('WsConnectionV3 backpressure and heartbeat', () => { + it('closes slow consumers with a dedicated error code when the outbound queue overflows', () => { + const { hub } = makeHarness(); + const socket = new FakeSocket(); + socket.bufferedAmount = 1 << 21; + const conn = makeConn(hub, socket, { maxOutboundMessages: 3 }); + + for (let i = 0; i < 4; i++) conn.enqueue(sessionStateMessage('s1')); + + expect(socket.frames()[1]).toEqual({ + type: 'error', + code: ErrorCode.WS_SLOW_CONSUMER, + msg: 'outbound queue overflow: slow consumer', + }); + expect(socket.closeCalls).toEqual([{ code: 1008, reason: 'slow consumer' }]); + }); + + it('overflows a stalled queue that never drains within the stall timeout', () => { + vi.useFakeTimers(); + try { + const { hub } = makeHarness(); + const socket = new FakeSocket(); + socket.bufferedAmount = 1 << 21; + const conn = makeConn(hub, socket, { + maxOutboundMessages: 100, + stallTimeoutMs: 50, + backpressureRetryMs: 5, + heartbeatIntervalMs: 60_000, + }); + conn.enqueue(sessionStateMessage('s1')); + vi.advanceTimersByTime(200); + expect(socket.frames()[1]).toMatchObject({ + type: 'error', + code: ErrorCode.WS_SLOW_CONSUMER, + }); + expect(socket.closeCalls).toEqual([{ code: 1008, reason: 'slow consumer' }]); + } finally { + vi.useRealTimers(); + } + }); + + it('pings on the heartbeat interval and terminates after missed pongs', () => { + vi.useFakeTimers(); + try { + const { hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket, { heartbeatIntervalMs: 100 }); + + vi.advanceTimersByTime(100); + expect(socket.pingCalls).toBe(1); + socket.emit('pong'); + vi.advanceTimersByTime(100); + expect(socket.pingCalls).toBe(2); + expect(socket.terminateCalls).toBe(0); + vi.advanceTimersByTime(100); + expect(socket.terminateCalls).toBe(1); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('WsV3 global message fanout', () => { + it('translates config, capability, plugin and catalog events into global messages', async () => { + const { globalSource, hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + await settle(); + + globalSource.fire({ + type: 'event.config.warning', + payload: { warnings: [{ domain: 'model', message: 'bad field' }, { message: 'plain' }] }, + }); + globalSource.fire({ + type: 'event.config.changed', + payload: { changedFields: ['default_model'], config: { default_model: 'm2' } }, + }); + globalSource.fire({ + type: 'event.capability.changed', + payload: { capability_id: 'cap-1', install: { running: true } }, + }); + globalSource.fire({ type: 'event.plugin.changed', payload: {} }); + globalSource.fire({ type: 'event.model_catalog.changed', payload: { changed: [] } }); + await settle(); + + const frames = socket.frames(); + expect(frames[1]).toEqual({ + type: 'config.warning', + timestamp: expect.any(Number), + warnings: ['model: bad field', 'plain'], + }); + expect(frames[2]).toEqual({ + type: 'config', + timestamp: expect.any(Number), + config: { default_model: 'm2' }, + changed_fields: ['default_model'], + }); + expect(frames[3]).toEqual({ + type: 'capability', + timestamp: expect.any(Number), + capability_id: 'cap-1', + }); + expect(frames[4]).toEqual({ type: 'plugin', timestamp: expect.any(Number) }); + expect(frames[5]).toEqual({ type: 'model_catalog', timestamp: expect.any(Number) }); + }); + + it('translates workspace lifecycle events, using the cached entity for deletions', async () => { + const { globalSource, hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + await settle(); + + const workspace = { + id: WS_ID, + root: '/tmp/ws-root', + name: 'ws-root', + createdAt: 1_700_000_000_000, + lastOpenedAt: 1_700_000_100_000, + }; + globalSource.fire({ type: 'event.workspace.created', payload: { workspace } }); + globalSource.fire({ + type: 'event.workspace.deleted', + payload: { workspaceId: WS_ID, root: '/tmp/ws-root' }, + }); + globalSource.fire({ + type: 'event.workspace.deleted', + payload: { workspaceId: 'wd_gone_0123456789ab', root: '/tmp/gone-dir' }, + }); + await settle(); + + const frames = socket.frames(); + expect(frames[1]).toMatchObject({ + type: 'workspace', + subtype: 'created', + workspace: { id: WS_ID, name: 'ws-root', session_count: 0 }, + }); + expect(frames[2]).toMatchObject({ + type: 'workspace', + subtype: 'deleted', + workspace: { id: WS_ID, name: 'ws-root' }, + }); + expect(frames[3]).toMatchObject({ + type: 'workspace', + subtype: 'deleted', + workspace: { id: 'wd_gone_0123456789ab', name: 'gone-dir', session_count: 0 }, + }); + }); + + it('translates session lifecycle events into session messages with entities', async () => { + const { globalSource, hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + await settle(); + + globalSource.sessionInfoResult = sessionInfoWire('s1'); + globalSource.fire({ + type: 'event.session.created', + payload: { sessionId: 's1', session: sessionInfoWire('s1') }, + }); + globalSource.fire({ + type: 'session.meta.updated', + payload: { sessionId: 's1', patch: { title: 'new title' } }, + }); + globalSource.fire({ + type: 'event.session.archived', + payload: { sessionId: 's1', workspaceId: WS_ID }, + }); + await settle(); + + const frames = socket.frames(); + expect(frames[1]).toMatchObject({ + type: 'session', + subtype: 'created', + session: { id: 's1' }, + }); + expect(frames[2]).toMatchObject({ + type: 'session', + subtype: 'updated', + session: { id: 's1' }, + changed_fields: ['title'], + }); + expect(frames[3]).toMatchObject({ + type: 'session', + subtype: 'archived', + session: { id: 's1' }, + }); + }); + + it('drops global messages that fail outbound schema validation and logs telemetry', async () => { + const { globalSource, hub, warnings } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + await settle(); + + globalSource.fire({ + type: 'event.session.created', + payload: { sessionId: 's1', session: { id: 's1' } }, + }); + await settle(); + expect(frameTypes(socket)).toEqual(['hello']); + expect(warnings.some((msg) => msg.includes('failed schema validation'))).toBe(true); + + globalSource.fire({ + type: 'event.session.created', + payload: { sessionId: 's2', session: { id: 's2' } }, + }); + await settle(); + expect(frameTypes(socket)).toEqual(['hello']); + expect(warnings.filter((msg) => msg.includes('failed schema validation'))).toHaveLength(1); + }); +}); + +function rawToString(data: RawData): string { + if (typeof data === 'string') return data; + if (Buffer.isBuffer(data)) return data.toString('utf8'); + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} + +describe('WsV3 endpoint over a real server', () => { + let home: string; + let server: RunningServer; + let base: string; + const sockets: WebSocket[] = []; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v3-ws-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + authTokenService: fixedTokenAuth('v3-token'), + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + await server.close(); + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + afterEach(() => { + for (const ws of sockets.splice(0)) { + try { + ws.close(); + } catch { + } + } + }); + + function v3Url(): string { + return `${base.replace(/^http/, 'ws')}/api/v3/ws`; + } + + function openV3(): Promise<{ ws: WebSocket; frames: Array> }> { + return new Promise((resolve, reject) => { + const ws = new WebSocket(v3Url(), { headers: authHeaders(server) }); + const frames: Array> = []; + ws.on('message', (data: RawData) => { + try { + frames.push(JSON.parse(rawToString(data)) as Record); + } catch { + } + }); + ws.once('message', () => resolve({ ws, frames })); + ws.once('error', reject); + }); + } + + function expectRejected(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const done = (err?: Error): void => { + clearTimeout(timer); + ws.removeAllListeners(); + try { + ws.terminate(); + } catch { + } + if (err !== undefined) reject(err); + else resolve(); + }; + const timer = setTimeout(() => done(new Error('connection was not rejected')), 1500); + ws.once('open', () => done(new Error('connection unexpectedly opened'))); + ws.once('error', () => done()); + ws.once('close', () => done()); + }); + } + + it('rejects upgrade without credentials', async () => { + await expectRejected(v3Url()); + }); + + it('serves hello, global session messages, ack and recovery over the real stack', async () => { + const { ws, frames } = await openV3(); + sockets.push(ws); + expect(frames[0]).toMatchObject({ + type: 'hello', + protocol_version: '3', + capabilities: expect.arrayContaining(['step_replay_v1']), + }); + expect(typeof frames[0]?.['server_id']).toBe('string'); + + const created = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server, { 'content-type': 'application/json' }), + body: JSON.stringify({ metadata: { cwd: home } }), + } as never); + const body = (await created.json()) as { data: { id: string } }; + const sessionId = body.data.id; + + await vi.waitFor( + () => { + const sessionFrames = frames.filter((frame) => frame['type'] === 'session'); + expect(sessionFrames).toHaveLength(1); + expect(sessionFrames[0]).toMatchObject({ + subtype: 'created', + session: { id: sessionId }, + }); + }, + { timeout: 5000 }, + ); + + ws.send(JSON.stringify({ type: 'subscribe', id: 1, session_id: sessionId })); + await vi.waitFor( + () => { + const ackIndex = frames.findIndex( + (frame) => frame['type'] === 'ack' && frame['id'] === 1, + ); + expect(ackIndex).toBeGreaterThan(0); + expect(frames[ackIndex]).toMatchObject({ code: ErrorCode.SUCCESS }); + const stateIndex = frames.findIndex((frame) => frame['type'] === 'session.state'); + expect(stateIndex).toBeGreaterThan(ackIndex); + expect(frames[stateIndex]).toMatchObject({ session_id: sessionId, status: 'idle' }); + }, + { timeout: 5000 }, + ); + }); +}); diff --git a/packages/klient/examples/basic.ts b/packages/klient/examples/basic.ts new file mode 100644 index 0000000000000000000000000000000000000000..c668b531ec02deeee7790b3e96b86a90c5b1a80e --- /dev/null +++ b/packages/klient/examples/basic.ts @@ -0,0 +1,76 @@ +/** + * Minimal end-to-end example driving an in-process engine with klient's + * `global` facade over the memory transport (calls and events never leave + * the process — same facade either way). + * + * Run it (the engine sources need the decorators tsconfig + raw-text loader): + * pnpm -C packages/klient exec tsx --tsconfig ./tsconfig.examples.json \ + * --import ../../build/register-raw-text-loader.mjs examples/basic.ts + */ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { EXAMPLE_CLIENT_IDENTITY } from './identity.js'; + + +import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; +import { createKlient } from '@moonshot-ai/klient/memory'; + +async function main(): Promise { + const homeDir = await mkdtemp(join(tmpdir(), 'klient-basic-')); + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ + ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), + ]); + try { + const klient = createKlient({ scope: app }); + + // 1) Aggregated host snapshot. + const env = await klient.global.env(); + console.log('[env] platform/homeDir ->', env.platform, env.homeDir); + + // 2) Read models. + const sessions = await klient.global.sessions.list({}); + console.log('[sessions] list ->', sessions.items.length, 'sessions'); + const workspaces = await klient.global.workspaces.list(); + console.log('[workspaces] list ->', workspaces.length, 'workspaces'); + const providers = await klient.global.kosong.listProviders(); + console.log('[providers] list ->', providers.length, 'providers'); + + // 3) Events — klient-level forwarding (no onDid*/onWill* in sight). + const sub = klient.events.on('kosong.providers.changed', (event) => { + console.log( + '[event] kosong.providers.changed -> +%s -%s ~%s', + event.added, + event.removed, + event.changed, + ); + }); + await klient.global.kosong.addProvider('__klient_example__', { + type: 'openai', + auth: { method: 'api-key', apiKey: 'example-key' }, + }); + await klient.global.kosong.removeProvider('__klient_example__'); + sub.dispose(); + + // 4) Error path — a missing plugin surfaces an error. + try { + await klient.global.plugins.info('__definitely_missing__'); + } catch (error) { + const e = error as { name: string; code?: number }; + console.log('[error] plugins.info ->', e.name, e.code); + } + + await klient.close(); + } finally { + app.dispose(); + await rm(homeDir, { recursive: true, force: true }); + } +} + +try { + await main(); +} catch (error) { + console.error(error); + process.exit(1); +} diff --git a/packages/klient/examples/context-usage.ts b/packages/klient/examples/context-usage.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebe0fa2c79dde5aaa6450408d69205008f3b6421 --- /dev/null +++ b/packages/klient/examples/context-usage.ts @@ -0,0 +1,209 @@ +/** + * Trace how the reported "Context size" evolves on a brand-new session after + * a single "hi" prompt, against an in-process engine over the memory + * transport. + * + * What gets sampled, all through the klient facade: + * - `agent.getContext()` → `{ history, tokenCount }` — `tokenCount` is the + * last MEASURED exchange total (`contextSize.get().measured` engine-side); + * it is 0 until the first LLM response lands and stays flat between turns. + * - `agent.getUsage()` → accumulated token usage (`byModel` / `currentTurn` + * / `total`), recorded per request. + * - `agent.status.updated` events — the live `contextTokens` / `usage` + * slices that feed the TUI footer. + * + * A 250 ms poll diffs (history length, tokenCount, usage.total) and prints a + * line only when something changed, so the output is a timeline of exactly + * when the Context size reading moves — and when it does NOT. + * + * A throwaway model is seeded into the engine's temp home (an in-process + * engine has no default model), so both env vars are required. Run it (the + * engine sources need the decorators tsconfig + raw-text loader): + * KIMI_EXAMPLE_MODEL=... KIMI_EXAMPLE_API_KEY=... \ + * pnpm -C packages/klient exec tsx --tsconfig ./tsconfig.examples.json \ + * --import ../../build/register-raw-text-loader.mjs examples/context-usage.ts + * + * Env: + * KIMI_EXAMPLE_MODEL — gateway model id to seed (required) + * KIMI_EXAMPLE_API_KEY — API key for the seeded model (required) + * KIMI_EXAMPLE_BASE_URL — optional gateway base URL for the seeded model + * KIMI_EXAMPLE_PROTOCOL — optional wire protocol for the seeded model (default `openai`) + */ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { EXAMPLE_CLIENT_IDENTITY } from './identity.js'; + + +import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; +import { createKlient } from '@moonshot-ai/klient/memory'; + +const SEEDED_MODEL_ID = 'klient-example-model'; + +interface TokenUsage { + inputOther: number; + output: number; + inputCacheRead: number; + inputCacheCreation: number; +} + +function usageTotal(usage: TokenUsage | undefined): number | undefined { + if (usage === undefined) return undefined; + return usage.inputOther + usage.output + usage.inputCacheRead + usage.inputCacheCreation; +} + +const tick = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +async function main(): Promise { + const seedModel = process.env['KIMI_EXAMPLE_MODEL']; + const seedKey = process.env['KIMI_EXAMPLE_API_KEY']; + if (seedModel === undefined || seedKey === undefined) { + throw new Error('KIMI_EXAMPLE_MODEL and KIMI_EXAMPLE_API_KEY are required (see header)'); + } + + const homeDir = await mkdtemp(join(tmpdir(), 'klient-context-usage-')); + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ + ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), + ]); + try { + const klient = createKlient({ scope: app }); + + const session = await klient.global.sessions.create({ workDir: process.cwd() }); + console.log('[session] created ->', session.id); + const agent = klient.session(session.id).agent('main'); + + await klient.global.kosong.addProvider({ + id: SEEDED_MODEL_ID, + model: seedModel, + protocol: (process.env['KIMI_EXAMPLE_PROTOCOL'] ?? 'openai'), + baseUrl: process.env['KIMI_EXAMPLE_BASE_URL'] ?? 'http://127.0.0.1:1', + auth: { method: 'api-key', apiKey: seedKey }, + maxContextSize: 262_144, + }); + await agent.setModel(SEEDED_MODEL_ID); + console.log('[model] bound ->', await agent.getModel()); + + const startedAt = Date.now(); + const elapsed = (): string => `+${String(Date.now() - startedAt).padStart(6)}ms`; + + // Live status slices (what the TUI footer consumes), as they arrive. + agent.events.on('agent.status.updated', (event) => { + const slice: Record = {}; + if ('contextTokens' in event) slice['contextTokens'] = event['contextTokens']; + if ('maxContextTokens' in event) slice['maxContextTokens'] = event['maxContextTokens']; + if ('contextUsage' in event) slice['contextUsage'] = event['contextUsage']; + if ('phase' in event) slice['phase'] = event['phase']; + const usage = event['usage'] as { total?: TokenUsage } | undefined; + if (usage !== undefined) slice['usage.total'] = usageTotal(usage.total); + console.log(`[event] ${elapsed()} agent.status.updated ->`, JSON.stringify(slice)); + }); + agent.events.on('turn.started', (event) => { + console.log(`[event] ${elapsed()} turn.started -> turnId=${String(event.turnId)}`); + }); + agent.events.on('turn.ended', (event) => { + console.log(`[event] ${elapsed()} turn.ended -> reason=${event.reason}`); + }); + agent.events.on('error', (event) => { + console.log(`[event] ${elapsed()} error ->`, JSON.stringify(event)); + }); + agent.events.onError((error) => { + console.log(`[event-err] ${elapsed()} ${error.message.split('\n')[0] ?? error.message}`); + }); + + const completed = new Promise<'completed' | 'failed' | 'timeout'>((resolve) => { + const timer = setTimeout(() => { + sub.dispose(); + resolve('timeout'); + }, 120_000); + const sub = agent.events.on('prompt.completed', (event) => { + clearTimeout(timer); + sub.dispose(); + console.log( + `[event] ${elapsed()} prompt.completed -> reason=${event.reason ?? 'unknown'}`, + ); + resolve(event.reason === 'failed' ? 'failed' : 'completed'); + }); + }); + + // Diff-polled snapshot of the RPC-visible readings. + let lastKey = ''; + const snapshot = async (tag: string): Promise => { + const [ctx, usage] = await Promise.all([agent.getContext(), agent.getUsage()]); + const total = usageTotal(usage.total); + const turn = usageTotal(usage.currentTurn); + const key = `${String(ctx.history.length)}/${String(ctx.tokenCount)}/${String(total)}/${String(turn)}`; + if (key === lastKey) return; + lastKey = key; + console.log( + `[poll] ${elapsed()} ${tag}`.padEnd(46), + `history=${String(ctx.history.length)} tokenCount(measured)=${String(ctx.tokenCount)}` + + ` usage.total=${String(total)} usage.currentTurn=${String(turn)}`, + ); + }; + + let polling = true; + const pollLoop = (async (): Promise => { + while (polling) { + try { + await snapshot(''); + } catch { + // transient RPC failure during the turn — keep polling + } + await tick(250); + } + })(); + + await snapshot('created (pre-prompt)'); + console.log(`[prompt] ${elapsed()} sending "hi"`); + await agent.prompt({ input: [{ type: 'text', text: 'hi' }] }); + + const outcome = await completed; + polling = false; + await pollLoop; + lastKey = ''; // force the final line even if nothing moved since the last poll tick + await snapshot('after prompt.completed'); + + const ctx = await agent.getContext(); + const usage = await agent.getUsage(); + const total = usageTotal(usage.total); + console.log('---'); + console.log('[result] outcome ->', outcome); + console.log('[result] history messages ->', ctx.history.length); + console.log('[result] tokenCount (measured) ->', ctx.tokenCount); + console.log('[result] usage.total ->', JSON.stringify(usage.total)); + console.log('[result] usage.byModel ->', JSON.stringify(usage.byModel)); + console.log( + `[check] tokenCount vs usage.total -> ${String(ctx.tokenCount)} vs ${String(total)}`, + ); + console.log( + '[note] reading guide:\n' + + ' - tokenCount is 0 until the first measured exchange lands, then it\n' + + ' should equal THAT exchange\'s total (input + output); new messages\n' + + ' appended between turns are the unmeasured tail.\n' + + ' - after one covered exchange on a fresh session, cumulative\n' + + ' usage.total and tokenCount should roughly agree; a large gap means\n' + + ' the measured total never made it onto the wire model and the reading\n' + + ' silently fell back to per-message estimates.\n' + + ' - outcome "timeout" means the turn finished its work but the\n' + + ' prompt.completed event never reached the client.', + ); + console.log('[note] session left in the (disposed) temp home ->', session.id); + + await klient.close(); + if (outcome === 'failed') process.exit(1); + } finally { + app.dispose(); + await rm(homeDir, { recursive: true, force: true }); + } +} + +try { + await main(); +} catch (error) { + console.error(error); + process.exit(1); +} diff --git a/packages/klient/examples/identity.ts b/packages/klient/examples/identity.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a94c02401ca2de5cc8e99e149cfa0cb834e7b53 --- /dev/null +++ b/packages/klient/examples/identity.ts @@ -0,0 +1,6 @@ +/** Shared host identity for klient examples (bootstrap requires one). */ +export const EXAMPLE_CLIENT_IDENTITY = { + productName: 'kimi-code-example', + version: '0.0.0-example', + platform: 'example', +} as const; diff --git a/packages/klient/examples/kimi-select-tools.ts b/packages/klient/examples/kimi-select-tools.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e021e0ce8efe75517adf9bdd321763935144d53 --- /dev/null +++ b/packages/klient/examples/kimi-select-tools.ts @@ -0,0 +1,736 @@ +/** + * Probe the `select_tools` (progressive tool disclosure) capability of the + * kimi-type providers, based on the real providers/auth in + * `~/.kimi-code/config.toml`. + * + * Feature recap (from the Tool Select guide): with the `tool-select` + * experimental flag + `tool_use` + `dynamically_loaded_tools` capabilities, + * MCP tool schemas no longer ride the top-level `tools[]`; the model calls + * the builtin `select_tools` with exact names (announced in + * ``/`` system reminders), the engine injects + * the loaded schemas as a `role: 'system'` message whose `tools` field + * carries full definitions, and the model then calls the loaded tool. + * + * The wire fact that makes this kimi-specific: only the kimi vendor trait + * serializes `message.tools` into the outbound request + * (`kimiOpenAITrait.convertMessage`); every other base SKIPS + * tool-declaration-only messages entirely. So the whole mechanism can only + * work on `(kimi, openai)` — this example proves that on a stub, then + * behaviorally tests each real kimi model with a simulated "kimi computer + * use" tool pool. + * + * Part A — deterministic, local stub (OpenAI Chat Completions wire): + * 1. kimi providerType: the tool-declaration message reaches the wire with + * an embedded `tools` array. + * 2. no providerType (plain openai): the same message is dropped — the + * boundary non-kimi providers cannot cross. + * 3. `deferred: true` top-level tools are filtered out of the wire + * `tools[]` by the contract's generate() (their schema travels via the + * declaration message instead). + * + * Part B — live, per kimi model from the real config: a two-step flow that + * mirrors exactly what the agent loop produces. + * step 1: announcement + `select_tools` available, user asks for a + * screenshot — does the model call `select_tools`, with names from + * the announced list? + * step 2: history += the select_tools call, its "Loaded: ..." result, and + * the schema-injection system message — does the model now call + * the loaded computer-use tool? + * + * Run: + * pnpm -C packages/klient exec tsx --tsconfig ./tsconfig.examples.json \ + * --import ../../build/register-raw-text-loader.mjs examples/kimi-select-tools.ts + * + * Env: + * KIMI_CODE_HOME — default `~/.kimi-code` + * KIMI_SELECT_TOOLS_MODELS — comma-separated model ids for the live parts (default: all kimi-type) + * KIMI_SELECT_TOOLS_SKIP_LIVE — set to `1` to skip part B (no real API calls) + * KIMI_SELECT_TOOLS_TAP — set to `1` to run part C instead of B: route + * the flow through a logging proxy and dump + * the actual wire Context of each request. + */ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { EXAMPLE_CLIENT_IDENTITY } from './identity.js'; + +import type { AddressInfo } from 'node:net'; + +import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; +import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config'; +import { renderLoadableToolsAnnouncement } from '@moonshot-ai/agent-core-v2/agent/toolSelect/dynamicTools'; +import { UNKNOWN_CAPABILITY } from '@moonshot-ai/agent-core-v2/llm-adapter/contract/capability'; +import type { Message } from '@moonshot-ai/agent-core-v2/llm-adapter/contract/message'; +import type { ToolDescription as Tool } from '@moonshot-ai/agent-core-v2/human/llm/message'; +import { createStaticCredentialProvider } from '@moonshot-ai/agent-core-v2/human/credentials/credentials'; +import type { Model } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; +import { IModelCatalog } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; +import type { + ModelRequestInput, + ModelRequester, +} from '@moonshot-ai/agent-core-v2/llm-adapter/model/model-requester'; +import { ModelRequesterImpl } from '@moonshot-ai/agent-core-v2/llm-adapter/model/model-requester-impl'; +import { IProtocolAdapterRegistry } from '@moonshot-ai/agent-core-v2/llm-adapter/protocol/protocol'; +import { ProtocolAdapterRegistry } from '@moonshot-ai/agent-core-v2/llm-adapter/protocol/protocolAdapterRegistry'; + +function assert(cond: boolean, message: string): asserts cond { + if (!cond) throw new Error(`assertion failed: ${message}`); +} + +// --------------------------------------------------------------------------- +// The simulated "kimi computer use" MCP tool pool. +// --------------------------------------------------------------------------- + +const COMPUTER_USE_TOOLS: readonly Tool[] = [ + { + name: 'computer_screenshot', + description: 'Take a screenshot of the current screen and return it as an image.', + parameters: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'computer_click', + description: 'Click at a screen coordinate.', + parameters: { + type: 'object', + properties: { + x: { type: 'number', description: 'Screen x coordinate.' }, + y: { type: 'number', description: 'Screen y coordinate.' }, + button: { type: 'string', enum: ['left', 'right', 'middle'] }, + }, + required: ['x', 'y'], + additionalProperties: false, + }, + }, + { + name: 'computer_move', + description: 'Move the mouse to a screen coordinate.', + parameters: { + type: 'object', + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + }, + required: ['x', 'y'], + additionalProperties: false, + }, + }, + { + name: 'computer_type', + description: 'Type text at the current focus.', + parameters: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + additionalProperties: false, + }, + }, + { + name: 'computer_scroll', + description: 'Scroll at a screen coordinate.', + parameters: { + type: 'object', + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + delta_y: { type: 'number' }, + }, + required: ['x', 'y', 'delta_y'], + additionalProperties: false, + }, + }, +]; + +const COMPUTER_USE_NAMES = COMPUTER_USE_TOOLS.map((t) => t.name); + +/** The builtin select_tools schema, mirroring `SelectToolsInputSchema`. */ +const SELECT_TOOLS: Tool = { + name: 'select_tools', + description: + 'Load one or more tools by name so you can call them. ' + + 'All available tool names are listed in the / announcements ' + + 'in the system context — fold them in order to get the current list. ' + + 'Pass the exact name(s) you need; their full definitions become available immediately, ' + + 'so you can call them directly in your next tool call.', + parameters: { + type: 'object', + properties: { + names: { + type: 'array', + items: { type: 'string' }, + minItems: 1, + description: 'Exact tool names to load, taken from the latest announced tool list.', + }, + }, + required: ['names'], + additionalProperties: false, + }, +}; + +const SYSTEM_PROMPT = + 'You are a computer-use agent operating the user\u2019s computer through tool calls. ' + + 'Only a core tool set is available up front; additional tools are announced by name in ' + + ' blocks in the system context. To use an announced tool, first call ' + + 'select_tools with its exact name to load its definition, then call the tool itself.'; + +const userMessage = (text: string): Message => ({ + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], +}); + +/** The schema-injection message the engine appends after a successful load. */ +const toolDeclarationMessage = (tools: readonly Tool[]): Message => ({ + role: 'system', + content: [], + toolCalls: [], + tools, +}); + +const announcementMessage = (names: readonly string[]): Message => ({ + role: 'system', + content: [ + { type: 'text', text: renderLoadableToolsAnnouncement(names, []) }, + ], + toolCalls: [], +}); + +// --------------------------------------------------------------------------- +// Shared request draining. +// --------------------------------------------------------------------------- + +interface Collected { + readonly text: string; + readonly toolCalls: readonly { id: string; name: string; arguments: string | null }[]; + readonly finishReason?: string | undefined; +} + +async function collect( + requester: ModelRequester, + input: ModelRequestInput, + signal?: AbortSignal, +): Promise { + let text = ''; + let toolCalls: Collected['toolCalls'] = []; + let finishReason: string | undefined; + for await (const event of requester.request(input, signal)) { + if (event.type === 'part' && event.part.type === 'text') text += event.part.text; + if (event.type === 'finish') { + finishReason = event.providerFinishReason ?? event.rawFinishReason; + toolCalls = event.message.toolCalls.map((t) => ({ + id: t.id, + name: t.name, + arguments: t.arguments, + })); + } + } + return { text, toolCalls, finishReason }; +} + +// --------------------------------------------------------------------------- +// Part A — deterministic wire-encoding probes against a local stub. +// --------------------------------------------------------------------------- + +const sseChunk = (delta: object, finishReason: string | null): string => + JSON.stringify({ + id: 'chatcmpl-probe', + object: 'chat.completion.chunk', + created: 0, + model: 'probe-model', + choices: [{ index: 0, delta, finish_reason: finishReason }], + }); + +function writePong(res: ServerResponse): void { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write(`data: ${sseChunk({ role: 'assistant' }, null)}\n\n`); + res.write(`data: ${sseChunk({ content: 'pong' }, null)}\n\n`); + res.write(`data: ${sseChunk({}, 'stop')}\n\n`); + res.end('data: [DONE]\n\n'); +} + +interface WireMessage { + readonly role?: string; + readonly tools?: readonly { function?: { name?: string } }[]; +} + +interface WireBody { + readonly messages?: readonly WireMessage[]; + readonly tools?: readonly { function?: { name?: string } }[]; +} + +async function probeWireEncoding(): Promise { + console.log('\n=== part A: wire-encoding boundary (local stub) ==='); + let handler: (req: IncomingMessage, res: ServerResponse) => void = (_req, res) => + writePong(res); + let lastBody: WireBody | undefined; + + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + try { + lastBody = JSON.parse(Buffer.concat(chunks).toString('utf8')) as WireBody; + } catch { + lastBody = undefined; + } + handler(req, res); + }); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + const port = (server.address() as AddressInfo).port; + + const registry = new ProtocolAdapterRegistry(); + const makeRequester = (providerType?: string): ModelRequester => { + const model: Model = { + id: 'probe', + name: 'probe-model', + aliases: [], + protocol: 'openai', + baseUrl: `http://127.0.0.1:${String(port)}`, + headers: {}, + capabilities: { ...UNKNOWN_CAPABILITY, max_context_tokens: 8192 }, + maxContextSize: 8192, + alwaysThinking: false, + providerType, + providerName: providerType ?? 'probe', + credentialProvider: createStaticCredentialProvider('sk-probe'), + }; + return new ModelRequesterImpl(model, registry); + }; + + const input: ModelRequestInput = { + systemPrompt: SYSTEM_PROMPT, + tools: [SELECT_TOOLS, { ...COMPUTER_USE_TOOLS[0]!, deferred: true }], + messages: [ + announcementMessage(COMPUTER_USE_NAMES), + userMessage('take a screenshot'), + toolDeclarationMessage([COMPUTER_USE_TOOLS[0]!]), + ], + }; + + try { + // 1) kimi providerType: the declaration message reaches the wire, with the + // loaded schema embedded in the message — and the deferred top-level tool + // does NOT duplicate into the wire tools[]. + await collect(makeRequester('kimi'), input); + const kimiBody = lastBody; + const declaration = kimiBody?.messages?.find((m) => Array.isArray(m.tools)); + assert(declaration !== undefined, 'kimi: a wire message carries the loaded tool schemas'); + assert( + declaration.tools?.some((t) => t.function?.name === 'computer_screenshot') === true, + 'kimi: declaration embeds computer_screenshot', + ); + assert( + kimiBody?.tools?.some((t) => t.function?.name === 'select_tools') === true, + 'kimi: top-level tools[] keeps select_tools', + ); + assert( + kimiBody?.tools?.some((t) => t.function?.name === 'computer_screenshot') !== true, + 'kimi: deferred tool stays OUT of top-level tools[]', + ); + console.log( + `[ok] kimi: declaration on wire (role=${String(declaration.role)}), ` + + `top-level tools=[${(kimiBody?.tools ?? []).map((t) => t.function?.name ?? '?').join(', ')}]`, + ); + + // 2) plain openai: the same declaration message is dropped outright — the + // dynamic-tool schema can never reach a non-kimi wire. (3 wire messages = + // system prompt + announcement + user; the declaration is gone.) + await collect(makeRequester(), input); + const plainBody = lastBody; + assert( + plainBody?.messages?.every((m) => !Array.isArray(m.tools)) === true, + 'openai: declaration message dropped (no wire message carries tools)', + ); + assert( + plainBody?.messages?.length === 3, + `openai: declaration removed from history (got ${String(plainBody?.messages?.length)} messages)`, + ); + console.log( + '[ok] openai: declaration message dropped — dynamic tools cannot cross a non-kimi wire', + ); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } +} + +// --------------------------------------------------------------------------- +// Part B — live two-step select_tools flow per real kimi model. +// --------------------------------------------------------------------------- + +type Step1Outcome = + | { readonly kind: 'selected'; readonly names: readonly string[]; readonly callId: string; readonly argumentsJson: string } + | { readonly kind: 'no-call'; readonly text: string } + | { readonly kind: 'error'; readonly message: string }; + +type Step2Outcome = + | { readonly kind: 'called'; readonly name: string; readonly argumentsJson: string | null } + | { readonly kind: 'other'; readonly description: string } + | { readonly kind: 'error'; readonly message: string }; + +async function withTimeout(promise: Promise, ms: number, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${String(ms)}ms`)), ms); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +interface Scenario { + readonly prompt: string; + /** The tool a correct model is expected to select and call. */ + readonly expectTool: string; +} + +const SCENARIOS: readonly Scenario[] = [ + { prompt: 'Take a screenshot of my screen right now.', expectTool: 'computer_screenshot' }, + { + prompt: 'Click at the exact center of my screen. The screen resolution is 1920x1080.', + expectTool: 'computer_click', + }, +]; + +async function step1Select(requester: ModelRequester, scenario: Scenario): Promise { + try { + const result = await withTimeout( + collect(requester, { + systemPrompt: SYSTEM_PROMPT, + tools: [SELECT_TOOLS], + messages: [ + announcementMessage(COMPUTER_USE_NAMES), + userMessage(scenario.prompt), + ], + }), + 60_000, + 'step1', + ); + const call = result.toolCalls.find((t) => t.name === 'select_tools'); + if (call === undefined) { + const other = result.toolCalls.map((t) => t.name).join(', '); + return { + kind: 'no-call', + text: + result.toolCalls.length > 0 + ? `called [${other}] instead` + : `answered text: ${result.text.slice(0, 80)}`, + }; + } + let names: string[] = []; + try { + const parsed = JSON.parse(call.arguments ?? '{}') as { names?: unknown }; + if (Array.isArray(parsed.names)) names = parsed.names.filter((n): n is string => typeof n === 'string'); + } catch { + // keep names empty — reported below + } + return { kind: 'selected', names, callId: call.id, argumentsJson: call.arguments ?? '' }; + } catch (error) { + return { kind: 'error', message: error instanceof Error ? error.message.split('\n')[0] ?? '' : String(error) }; + } +} + +async function step2UseLoadedTool( + requester: ModelRequester, + step1: Extract, + scenario: Scenario, +): Promise { + const validNames = step1.names.filter((n) => COMPUTER_USE_NAMES.includes(n)); + const loadName = validNames.includes(scenario.expectTool) + ? scenario.expectTool + : validNames[0]; + if (loadName === undefined) { + return { kind: 'other', description: `selected names not in announced list: [${step1.names.join(', ')}]` }; + } + const loadedTool = COMPUTER_USE_TOOLS.find((t) => t.name === loadName)!; + const messages = (withDeclaration: boolean): ModelRequestInput => ({ + systemPrompt: SYSTEM_PROMPT, + tools: [SELECT_TOOLS, { ...loadedTool, deferred: true }], + messages: [ + announcementMessage(COMPUTER_USE_NAMES), + userMessage(scenario.prompt), + { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: step1.callId, + name: 'select_tools', + arguments: JSON.stringify({ names: [loadName] }), + }, + ], + }, + { + role: 'tool', + toolCallId: step1.callId, + content: [{ type: 'text', text: `Loaded: ${loadName}` }], + toolCalls: [], + }, + // The schema-injection message — the exact wire shape whose acceptance + // this probe measures. Dropped entirely in the isolation retry below. + ...(withDeclaration ? [toolDeclarationMessage([loadedTool])] : []), + ], + }); + try { + const result = await withTimeout(collect(requester, messages(true)), 60_000, 'step2'); + const call = result.toolCalls.find((t) => t.name === loadName); + if (call !== undefined) { + return { kind: 'called', name: call.name, argumentsJson: call.arguments }; + } + const other = result.toolCalls.map((t) => t.name).join(', '); + return { + kind: 'other', + description: + result.toolCalls.length > 0 + ? `called [${other}] instead of ${loadName}` + : `answered text: ${result.text.slice(0, 80)}`, + }; + } catch (error) { + const message = error instanceof Error ? error.message.split('\n')[0] ?? '' : String(error); + // Differential: retry the SAME request minus the schema-injection message. + // If it then succeeds, the backend rejects the dynamic-schema wire shape + // specifically; if it fails too, the problem is elsewhere in the flow. + try { + await withTimeout(collect(requester, messages(false)), 60_000, 'step2-isolation'); + return { + kind: 'error', + message: `${message} (isolation: same request WITHOUT the tools-in-message injection succeeds — backend rejects the dynamic-schema wire shape)`, + }; + } catch { + return { kind: 'error', message: `${message} (isolation: also fails without the injection — not caused by the declaration message)` }; + } + } +} + +async function probeLiveKimiProviders(): Promise { + const homeDir = process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code'); + console.log(`\n=== part B: live select_tools flow on real kimi providers (${homeDir}) ===`); + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ + ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), + ]); + try { + await app.accessor.get(IConfigService).ready; + const catalog = app.accessor.get(IModelCatalog); + + const filter = process.env['KIMI_SELECT_TOOLS_MODELS']?.split(',').map((s) => s.trim()); + const models = await catalog.listModels(); + const targets = models.filter((m) => { + if (filter !== undefined && !filter.includes(m.model)) return false; + try { + return catalog.get(m.model).providerType === 'kimi'; + } catch { + return false; + } + }); + assert(targets.length > 0, 'at least one kimi-type model configured'); + + const summary: string[] = []; + for (const m of targets) { + const model = catalog.get(m.model); + const declared = model.capabilities.dynamically_loaded_tools === true; + const requester = catalog.getRequester(m.model); + + for (const scenario of SCENARIOS) { + const startedAt = Date.now(); + const step1 = await step1Select(requester, scenario); + let row: string; + if (step1.kind === 'selected') { + const step2 = await step2UseLoadedTool(requester, step1, scenario); + const elapsed = `${String(Date.now() - startedAt)}ms`; + if (step2.kind === 'called') { + row = `PASS select=[${step1.names.join(', ')}] then called ${step2.name}(${step2.argumentsJson ?? ''})`; + } else if (step2.kind === 'other') { + row = `PARTIAL select=[${step1.names.join(', ')}] but step2: ${step2.description}`; + } else { + row = `PARTIAL select=[${step1.names.join(', ')}] but step2 error: ${step2.message}`; + } + console.log(`[${m.model}] ${scenario.expectTool} ${elapsed} declared=${String(declared)} ${row}`); + } else if (step1.kind === 'no-call') { + row = `FAIL no select_tools call: ${step1.text}`; + console.log(`[${m.model}] ${scenario.expectTool} ${String(Date.now() - startedAt)}ms declared=${String(declared)} ${row}`); + } else { + row = `ERROR ${step1.message}`; + console.log(`[${m.model}] ${scenario.expectTool} ${String(Date.now() - startedAt)}ms declared=${String(declared)} ${row}`); + } + summary.push(`${m.model.padEnd(40)} ${scenario.expectTool.padEnd(22)} ${row}`); + } + } + + console.log('\n=== live summary (declared = config declares dynamically_loaded_tools) ==='); + for (const line of summary) console.log(line); + } finally { + app.dispose(); + } +} + +// --------------------------------------------------------------------------- +// Part C — TAP mode: sit a logging proxy between the requester and the real +// endpoint, and dump the actual wire Context (structure digest only — auth +// headers are forwarded untouched, never printed). +// --------------------------------------------------------------------------- + +interface TapWireTool { + readonly type?: string; + readonly function?: { readonly name?: string }; +} + +interface TapWireMessage { + readonly role?: string; + readonly content?: unknown; + readonly tools?: readonly TapWireTool[]; + readonly tool_calls?: readonly { readonly function?: { readonly name?: string } }[]; + readonly tool_call_id?: string; +} + +function describeWireBody(raw: Buffer): string[] { + const lines: string[] = []; + let body: { tools?: TapWireTool[]; messages?: TapWireMessage[]; model?: string }; + try { + body = JSON.parse(raw.toString('utf8')) as typeof body; + } catch { + return [' (unparseable body)']; + } + const toolName = (t: TapWireTool): string => t.function?.name ?? t.type ?? '?'; + lines.push(` model=${String(body.model)}`); + lines.push(` top-level tools[] = [${(body.tools ?? []).map(toolName).join(', ')}]`); + for (const [i, m] of (body.messages ?? []).entries()) { + const parts: string[] = [`#${String(i)} role=${String(m.role)}`]; + if (Array.isArray(m.tools)) { + parts.push(`>>> tools=[${m.tools.map(toolName).join(', ')}] (dynamic schema injected HERE)`); + } + if (Array.isArray(m.tool_calls)) { + parts.push(`tool_calls=[${m.tool_calls.map((tc) => tc.function?.name ?? '?').join(', ')}]`); + } + if (m.tool_call_id !== undefined) parts.push(`tool_call_id=${m.tool_call_id}`); + const content = + typeof m.content === 'string' ? m.content : m.content === undefined ? '' : JSON.stringify(m.content); + if (content.length > 0) parts.push(`content=${content.slice(0, 60).replaceAll('\n', ' ')}…`); + lines.push(` ${parts.join(' ')}`); + } + return lines; +} + +async function probeTappedContext(): Promise { + const homeDir = process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code'); + console.log(`\n=== part C: tapped wire context (${homeDir}) ===`); + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ + ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), + ]); + try { + await app.accessor.get(IConfigService).ready; + const catalog = app.accessor.get(IModelCatalog); + const registry = app.accessor.get(IProtocolAdapterRegistry); + + const filter = process.env['KIMI_SELECT_TOOLS_MODELS']?.split(',').map((s) => s.trim()); + const models = await catalog.listModels(); + const targets = models.filter((m) => { + if (filter !== undefined && !filter.includes(m.model)) return false; + try { + return catalog.get(m.model).providerType === 'kimi'; + } catch { + return false; + } + }); + assert(targets.length > 0, 'at least one kimi-type model configured'); + + for (const m of targets) { + const model = catalog.get(m.model); + if (model.baseUrl === undefined) { + console.log(`[${m.model}] skip tap: model has no resolved baseUrl`); + continue; + } + const upstream = model.baseUrl; + let tapCount = 0; + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + void (async () => { + const raw = Buffer.concat(chunks); + tapCount += 1; + console.log(`\n[${m.model}] ── request #${String(tapCount)} → ${upstream}${req.url ?? ''}`); + for (const line of describeWireBody(raw)) console.log(line); + const headers = { ...(req.headers as Record) }; + delete headers['host']; + delete headers['content-length']; + delete headers['connection']; + const response = await fetch(`${upstream}${req.url ?? ''}`, { + method: req.method, + headers, + body: raw.length > 0 ? raw : undefined, + }); + const passthrough: Record = {}; + response.headers.forEach((value, key) => { + if (key !== 'content-length' && key !== 'content-encoding' && key !== 'transfer-encoding') { + passthrough[key] = value; + } + }); + res.writeHead(response.status, passthrough); + res.end(Buffer.from(await response.arrayBuffer())); + })().catch((error: unknown) => { + res.writeHead(502); + res.end(String(error)); + }); + }); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + const port = (server.address() as AddressInfo).port; + + try { + const tapped = new ModelRequesterImpl( + { ...model, baseUrl: `http://127.0.0.1:${String(port)}` }, + registry, + ); + // One scenario is enough to show the mechanism: click (has arguments). + const scenario = SCENARIOS[1]!; + const step1 = await step1Select(tapped, scenario); + if (step1.kind === 'selected') { + const step2 = await step2UseLoadedTool(tapped, step1, scenario); + console.log( + `\n[${m.model}] outcome: select=[${step1.names.join(', ')}] -> ${step2.kind === 'called' ? `called ${step2.name}(${step2.argumentsJson ?? ''})` : JSON.stringify(step2)}`, + ); + } else { + console.log(`\n[${m.model}] step1 outcome: ${JSON.stringify(step1)}`); + } + } finally { + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + } + } finally { + app.dispose(); + } +} + +// --------------------------------------------------------------------------- + +async function main(): Promise { + await probeWireEncoding(); + if (process.env['KIMI_SELECT_TOOLS_TAP'] === '1') { + await probeTappedContext(); + } else if (process.env['KIMI_SELECT_TOOLS_SKIP_LIVE'] !== '1') { + await probeLiveKimiProviders(); + } + console.log('\nselect-tools: OK'); +} + +try { + await main(); +} catch (error) { + console.error(error); + process.exit(1); +} diff --git a/packages/klient/examples/kosong-config-stress.ts b/packages/klient/examples/kosong-config-stress.ts new file mode 100644 index 0000000000000000000000000000000000000000..85ecf70817de8495a939c6b806a814c780d719d6 --- /dev/null +++ b/packages/klient/examples/kosong-config-stress.ts @@ -0,0 +1,269 @@ +/** + * Stress check for the kosong ⇄ kosongConfig read/write path, against an + * in-process engine (memory transport) bootstrapped on a throwaway home. + * + * The contract under pressure: an awaited kosong mutation resolves only after + * the write has been persisted by the kosongConfig bridge (with retries on + * transient disk failures), and config-originated writes land in the kosong + * registries synchronously. Every phase asserts read-after-write visibility + * through BOTH facades — `kosong.*` (registry view) and `config.get` (the + * persisted section the bridge writes) — with no sleeps or flush helpers: + * any settling gap shows up here as a failed assertion. + * + * Phases: + * 1. sequential provider add/remove read-after-write; + * 2. same-name add/remove flip-flop (hammering the persist chain with + * alternating real writes and no-op merges); + * 3. sequential default-model churn; + * 4. concurrent burst adds (persist-chain serialization + merge); + * 5. concurrent mixed sections: removes racing adds racing a default flip; + * 6. config → kosong direction (config.replace must be visible to the + * registry facade immediately); + * 7. restart durability: dispose the app, re-bootstrap on the same home, + * and compare every section against the pre-restart snapshot. + * + * pnpm -C packages/klient stress:kosong-config + * + * Env: KIMI_MODEL_NAME is unset for the run (it would pin `defaultModel` and + * break the pointer assertions); restored on exit. + */ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { EXAMPLE_CLIENT_IDENTITY } from './identity.js'; + + +import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; +import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config'; +import { IKosongConfigService } from '@moonshot-ai/agent-core-v2/app/kosongConfig/kosongConfig'; +import { type Klient } from '@moonshot-ai/klient'; +import { createKlient } from '@moonshot-ai/klient/memory'; + +function assert(cond: boolean, message: string): asserts cond { + if (!cond) throw new Error(`assertion failed: ${message}`); +} + +interface ProvidersSectionView { + readonly [name: string]: { readonly apiKey?: string } | undefined; +} + +interface ModelsSectionView { + readonly [id: string]: { readonly provider?: string; readonly model?: string } | undefined; +} + +const apiKeyProvider = (apiKey: string) => ({ + type: 'openai', + auth: { method: 'api-key' as const, apiKey }, +}); + +const anonymousModel = (id: string) => ({ + id, + model: `${id}-model`, + protocol: 'openai', + baseUrl: 'http://127.0.0.1:1', + auth: { method: 'api-key' as const, apiKey: `sk-${id}` }, + maxContextSize: 8192, +}); + +/** Key-order-independent deep equality (post-restart TOML round-trips reorder keys). */ +const stable = (value: unknown): string => + JSON.stringify(value, (_key, v: unknown) => + v !== null && typeof v === 'object' && !Array.isArray(v) + ? Object.fromEntries( + Object.entries(v as Record).toSorted(([a], [b]) => a.localeCompare(b)), + ) + : v, + ); + +async function phase(label: string, ops: number, run: () => Promise): Promise { + const startedAt = Date.now(); + await run(); + const elapsed = Date.now() - startedAt; + console.log( + `[ok] ${label} ${String(ops)} ops in ${String(elapsed)}ms (${((ops * 1000) / Math.max(elapsed, 1)).toFixed(0)} ops/s)`, + ); +} + +async function main(): Promise { + const homeDir = await mkdtemp(join(tmpdir(), 'klient-kosong-stress-')); + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ + ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), + ]); + // Filled in right before the restart phase. + let snapshot: Record = {}; + try { + const klient = createKlient({ scope: app }); + const config = klient.global.config; + const kosong = klient.global.kosong; + + // 1) Sequential read-after-write: each awaited add must already be in the + // persisted section when it resolves — no settling window allowed. + await phase('sequential provider add read-after-write', 30, async () => { + for (let i = 0; i < 30; i += 1) { + const name = `seq_${String(i)}`; + await kosong.addProvider(name, apiKeyProvider(`sk-seq-${String(i)}`)); + const providers = await config.get('providers'); + assert( + providers[name]?.apiKey === `sk-seq-${String(i)}`, + `providers.${name} persisted before addProvider resolved`, + ); + const got = await kosong.getProvider(name); + assert(got !== undefined, `providers.${name} visible to the registry facade`); + } + }); + + // 2) Same-name flip-flop: add → remove → add on one key, asserting both + // directions of the write land before the await returns. + await phase('same-name add/remove flip-flop', 30, async () => { + for (let i = 0; i < 15; i += 1) { + await kosong.addProvider('flip', apiKeyProvider(`sk-flip-${String(i)}`)); + let providers = await config.get('providers'); + assert(providers['flip']?.apiKey === `sk-flip-${String(i)}`, 'flip add persisted'); + await kosong.removeProvider('flip'); + providers = await config.get('providers'); + assert(providers['flip'] === undefined, 'flip remove persisted'); + } + }); + + // 3) Sequential default-model churn: every awaited pointer write must be + // visible in the persisted defaultModel section immediately. + await phase('default-model churn', 22, async () => { + await kosong.addProvider(anonymousModel('churn_a')); + await kosong.addProvider(anonymousModel('churn_b')); + for (let i = 0; i < 20; i += 1) { + const id = i % 2 === 0 ? 'churn_a' : 'churn_b'; + await kosong.setDefaultModel(id); + const def = await config.get('defaultModel'); + assert(def === id, `defaultModel=${id} persisted before setDefaultModel resolved`); + } + }); + + // 4) Concurrent burst: 40 adds racing on one section. The persist chain + // must serialize them and converge to the full set — no lost updates. + await phase('concurrent burst adds', 40, async () => { + await Promise.all( + Array.from({ length: 40 }, (_, i) => + kosong.addProvider(`burst_${String(i)}`, apiKeyProvider(`sk-burst-${String(i)}`)), + ), + ); + const providers = await config.get('providers'); + for (let i = 0; i < 40; i += 1) { + assert( + providers[`burst_${String(i)}`]?.apiKey === `sk-burst-${String(i)}`, + `burst_${String(i)} survived the concurrent burst`, + ); + } + }); + + // 5) Mixed sections racing: removes of phase-1 providers + anonymous-model + // adds (models section) + a default flip, all in flight at once. + await phase('concurrent mixed sections', 41, async () => { + await Promise.all([ + ...Array.from({ length: 30 }, (_, i) => kosong.removeProvider(`seq_${String(i)}`)), + ...Array.from({ length: 10 }, (_, i) => kosong.addProvider(anonymousModel(`mix_${String(i)}`))), + kosong.setDefaultModel('churn_b'), + ]); + const providers = await config.get('providers'); + const models = await config.get('models'); + for (let i = 0; i < 30; i += 1) { + assert(providers[`seq_${String(i)}`] === undefined, `seq_${String(i)} removal persisted`); + } + for (let i = 0; i < 10; i += 1) { + assert(models[`mix_${String(i)}`]?.model === `mix_${String(i)}-model`, `mix_${String(i)} persisted`); + } + assert((await config.get('defaultModel')) === 'churn_b', 'default flip persisted'); + }); + + // 6) config → kosong: a config-originated write must be visible to the + // registry facade as soon as its await resolves. + await phase('config-originated replace visible to registry', 4, async () => { + await config.replace({ + domain: 'providers', + value: { + ...(await config.get('providers')), + cfg_only: { type: 'openai', apiKey: 'sk-cfg-only' }, + }, + }); + const got = await kosong.getProvider('cfg_only'); + assert(got !== undefined, 'config.replace(providers) visible to kosong.getProvider'); + + await config.replace({ + domain: 'models', + value: { + ...(await config.get('models')), + cfg_model: { provider: 'cfg_only', model: 'cfg-model', maxContextSize: 4096 }, + }, + }); + const modelIds = (await kosong.listModels()).map((m) => m.model); + assert(modelIds.includes('cfg_model'), 'config.replace(models) visible to kosong.listModels'); + + await config.set({ domain: 'providers', patch: { cfg_only: { apiKey: 'sk-cfg-updated' } } }); + const providers = await config.get('providers'); + assert(providers['cfg_only']?.apiKey === 'sk-cfg-updated', 'config.set merge persisted'); + + await kosong.removeProvider('cfg_only'); + assert( + (await config.get('providers'))['cfg_only'] === undefined, + 'registry remove persisted over the config-originated entry', + ); + }); + + snapshot = { + providers: await config.get('providers'), + models: await config.get('models'), + defaultProvider: await config.get('defaultProvider'), + defaultModel: await config.get('defaultModel'), + }; + + await klient.close(); + } finally { + app.dispose(); + } + + // 7) Restart durability: a fresh engine on the SAME home must rehydrate the + // exact pre-restart state — the ultimate proof the writes hit the disk. + await phase('restart durability', 1, async () => { + const { app: app2 } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ + ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), + ]); + try { + // Reads race the async startup otherwise: config loads from disk, then + // the bridge hydrates the registries from it. + await app2.accessor.get(IConfigService).ready; + await app2.accessor.get(IKosongConfigService).ready; + const klient2: Klient = createKlient({ scope: app2 }); + for (const [section, expected] of Object.entries(snapshot)) { + const actual: unknown = await klient2.global.config.get(section); + if (stable(actual) !== stable(expected)) { + console.error(`[diff] ${section}\n expected: ${stable(expected)}\n actual: ${stable(actual)}`); + } + assert( + stable(actual) === stable(expected), + `${section} rehydrated from disk equal to the pre-restart snapshot`, + ); + } + const providerIds = new Set((await klient2.global.kosong.listProviders()).map((p) => p.id)); + for (const name of Object.keys(snapshot['providers'] as Record)) { + assert(providerIds.has(name), `provider ${name} listed after restart`); + } + await klient2.close(); + } finally { + app2.dispose(); + } + }); + + await rm(homeDir, { recursive: true, force: true }); + console.log('kosong-config stress: OK'); +} + +const pinnedModelEnv = process.env['KIMI_MODEL_NAME']; +delete process.env['KIMI_MODEL_NAME']; +try { + await main(); +} catch (error) { + console.error(error); + process.exit(1); +} finally { + if (pinnedModelEnv !== undefined) process.env['KIMI_MODEL_NAME'] = pinnedModelEnv; +} diff --git a/packages/klient/examples/model-requester-boundary.ts b/packages/klient/examples/model-requester-boundary.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8d1560c525618ac775cc2c8e1a50cd1a43d4e06 --- /dev/null +++ b/packages/klient/examples/model-requester-boundary.ts @@ -0,0 +1,783 @@ +/** + * Probe the ModelRequester problem boundary, in two parts. + * + * Part 1 — real config: bootstraps the agent-core-v2 App scope on the REAL + * Kimi home (`KIMI_CODE_HOME` or `~/.kimi-code`), resolves `IModelCatalog` + * with the providers/auth from `config.toml`, lists every provider/model, and + * pings every configured model through its `ModelRequester` (one tiny live + * request per model, real credentials). This is the "does the assembled + * pipeline actually reach each provider" smoke. + * + * Part 2 — deterministic boundary probes: points hand-built Models at a local + * stub HTTP server (OpenAI Chat Completions wire) and drives failure modes + * through `ModelRequesterImpl`, recording for each one WHO owned it: + * + * - wrapped by ChatProvider — the wire adapter converted the SDK/transport + * failure into the typed `ChatProviderError` family (APIStatusError / + * APIConnectionError / APIEmptyResponseError / ...), so the requester could + * translate it into a coded `Error2` (`provider.*` / `context.overflow`). + * - owned by ModelRequester — behavior the ChatProvider layer CANNOT + * provide: per-request credential injection (credentials.resolve per + * attempt), plus the final `translateProviderError` safety net that + * turns even unwrapped raw errors into `Error2` (`internal`). + * - owned by the caller — the OAuth 401 → credentials.invalidate → + * single replay: the requester surfaces the 401, and the call site + * re-resolves and re-sends (mirroring IModelCatalog.generate / ping). + * - owned by neither — user cancellation: the standard AbortError + * DOMException passes through BOTH layers untranslated, by design. + * + * Tool-call probes cover the decode/encode boundary specifically: streamed + * `delta.tool_calls` assembly (single, parallel-interleaved, index-less), + * malformed arguments (deliberately NOT the wire layer's problem), the + * strict-provider tool-exchange 400 staying recognizable through the wrap, + * and request-side encoding of tool declarations and tool results. + * + * Run (the examples tsconfig enables the decorators the engine sources need): + * pnpm -C packages/klient smoke:boundary + * + * Env: + * KIMI_CODE_HOME — default `~/.kimi-code` + * KIMI_BOUNDARY_MODELS — comma-separated model ids to ping (default: all) + * KIMI_BOUNDARY_SKIP_LIVE — set to `1` to skip part 1 (no real API calls) + */ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { EXAMPLE_CLIENT_IDENTITY } from './identity.js'; + +import type { AddressInfo } from 'node:net'; + +import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; +import { isError2 } from '@moonshot-ai/agent-core-v2/_base/errors/errors'; +import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config'; +import { UNKNOWN_CAPABILITY } from '@moonshot-ai/agent-core-v2/llm-adapter/contract/capability'; +import { + APIContextOverflowError, + APIStatusError, + ChatProviderError, + isAbortError, + isToolExchangeAdjacencyError, +} from '@moonshot-ai/agent-core-v2/llm-adapter/contract/errors'; +import { + createOAuthCredentialProvider, + createStaticCredentialProvider, +} from '@moonshot-ai/agent-core-v2/human/credentials/credentials'; +import type { + ToolCall, + ToolDescription as Tool, +} from '@moonshot-ai/agent-core-v2/human/llm/message'; +import type { LlmCredentialProvider } from '@moonshot-ai/agent-core-v2/human/llm/requester/requester'; +import type { Model } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; +import { IModelCatalog } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; +import type { + ModelRequestInput, + ModelRequester, +} from '@moonshot-ai/agent-core-v2/llm-adapter/model/model-requester'; +import { ModelRequesterImpl } from '@moonshot-ai/agent-core-v2/llm-adapter/model/model-requester-impl'; +import { ProtocolAdapterRegistry } from '@moonshot-ai/agent-core-v2/llm-adapter/protocol/protocolAdapterRegistry'; + +function assert(cond: boolean, message: string): asserts cond { + if (!cond) throw new Error(`assertion failed: ${message}`); +} + +const tick = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +// --------------------------------------------------------------------------- +// Part 1 — real config.toml: catalog listing + per-model live ping. +// --------------------------------------------------------------------------- + +async function probeRealConfig(): Promise { + const homeDir = process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code'); + console.log(`\n=== part 1: real config (${homeDir}/config.toml) ===`); + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ + ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), + ]); + try { + // Config (and every section on top of it) loads asynchronously. + await app.accessor.get(IConfigService).ready; + const catalog = app.accessor.get(IModelCatalog); + + const providers = await catalog.listProviders(); + for (const p of providers) { + console.log( + `[provider] ${p.id} type=${p.type} status=${p.status} ` + + `has_api_key=${p.has_api_key} models=${(p.models ?? []).length}`, + ); + } + + const models = await catalog.listModels(); + const filter = process.env['KIMI_BOUNDARY_MODELS']?.split(',').map((s) => s.trim()); + const targets = models.filter((m) => filter === undefined || filter.includes(m.model)); + assert(targets.length > 0, 'at least one configured model to ping'); + + for (const m of targets) { + const startedAt = Date.now(); + const result = await Promise.race([ + catalog.ping(m.model), + tick(45_000).then(() => ({ ok: false as const, durationMs: 45_000, error: 'ping timed out after 45s' })), + ]); + if (result.ok) { + console.log( + `[ping ok] ${m.model} (${m.provider}) ${String(Date.now() - startedAt)}ms ` + + `text=${JSON.stringify(result.text ?? '')} finish=${String(result.finishReason)} ` + + `usage=${JSON.stringify(result.usage ?? null)}`, + ); + } else { + const firstLine = (result.error ?? 'unknown error').split('\n')[0]; + console.log(`[ping fail] ${m.model} (${m.provider}) ${firstLine}`); + } + } + } finally { + app.dispose(); + } +} + +// --------------------------------------------------------------------------- +// Part 2 — stub-driven boundary probes. +// --------------------------------------------------------------------------- + +const PING_INPUT: ModelRequestInput = { + systemPrompt: 'You are a connectivity probe. Answer with the single word "pong".', + tools: [], + messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }], toolCalls: [] }], +}; + +const WEATHER_TOOL: Tool = { + name: 'get_weather', + description: 'Get the weather for a city.', + parameters: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + }, +}; + +const TOOL_INPUT: ModelRequestInput = { ...PING_INPUT, tools: [WEATHER_TOOL] }; + +/** A prior tool exchange: assistant tool call + its tool result. */ +const TOOL_HISTORY_INPUT: ModelRequestInput = { + systemPrompt: PING_INPUT.systemPrompt, + tools: [WEATHER_TOOL], + messages: [ + { role: 'user', content: [{ type: 'text', text: 'weather?' }], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'call_1', + name: 'get_weather', + arguments: '{"location":"Hangzhou"}', + }, + ], + }, + { + role: 'tool', + toolCallId: 'call_1', + content: [{ type: 'text', text: 'sunny' }], + toolCalls: [], + }, + ], +}; + +interface Collected { + readonly events: readonly string[]; + readonly text: string; + readonly toolCalls: readonly ToolCall[]; + readonly usage?: unknown; + readonly finishReason?: string | undefined; +} + +async function collect( + requester: ModelRequester, + signal?: AbortSignal, + input: ModelRequestInput = PING_INPUT, +): Promise { + const events: string[] = []; + let text = ''; + let toolCalls: readonly ToolCall[] = []; + let usage: unknown; + let finishReason: string | undefined; + for await (const event of requester.request(input, signal)) { + events.push(event.type === 'part' ? `part:${event.part.type}` : event.type); + if (event.type === 'part' && event.part.type === 'text') text += event.part.text; + if (event.type === 'usage') usage = event.usage; + if (event.type === 'finish') { + finishReason = event.providerFinishReason ?? event.rawFinishReason; + toolCalls = event.message.toolCalls; + } + } + return { events, text, toolCalls, usage, finishReason }; +} + +async function collectWithRecovery(requester: ModelRequester): Promise { + const credentialProvider = requester.model.credentialProvider; + try { + return await collect(requester); + } catch (error) { + if (credentialProvider?.canRecover?.(error) !== true) throw error; + credentialProvider?.invalidate?.(); + return collect(requester); + } +} + +// --- stub server ----------------------------------------------------------- + +type StubHandler = (req: IncomingMessage, res: ServerResponse) => void; + +const sseChunk = (delta: object, finishReason: string | null): string => + JSON.stringify({ + id: 'chatcmpl-probe', + object: 'chat.completion.chunk', + created: 0, + model: 'probe-model', + choices: [{ index: 0, delta, finish_reason: finishReason }], + }); + +const sseToolDelta = (toolCalls: readonly object[], finishReason: string | null = null): string => + sseChunk({ tool_calls: toolCalls }, finishReason); + +const SSE_USAGE = JSON.stringify({ + id: 'chatcmpl-probe', + object: 'chat.completion.chunk', + created: 0, + model: 'probe-model', + choices: [], + usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }, +}); + +function writeSse(res: ServerResponse, chunks: readonly string[]): void { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + for (const chunk of chunks) res.write(`data: ${chunk}\n\n`); + res.end('data: [DONE]\n\n'); +} + +function writePong(res: ServerResponse): void { + writeSse(res, [ + sseChunk({ role: 'assistant' }, null), + sseChunk({ content: 'pong' }, null), + sseChunk({}, 'stop'), + SSE_USAGE, + ]); +} + +function writeJsonError( + res: ServerResponse, + status: number, + message: string, + headers: Record = {}, +): void { + res.writeHead(status, { 'content-type': 'application/json', ...headers }); + res.end(JSON.stringify({ error: { message, type: 'stub_error' } })); +} + +// --- probe bookkeeping ------------------------------------------------------ + +interface ProbeReport { + readonly probe: string; + readonly outcome: string; + readonly wrappedBy: string; +} + +const reports: ProbeReport[] = []; + +/** Walk the cause chain looking for a typed ChatProviderError. */ +function chatProviderCause(error: unknown): ChatProviderError | undefined { + let current: unknown = error; + while (current !== undefined && current !== null) { + if (current instanceof ChatProviderError) return current; + current = (current as { cause?: unknown }).cause; + } + return undefined; +} + +function describeCaught(error: unknown): { outcome: string; wrappedBy: string } { + if (isAbortError(error)) { + return { + outcome: `${error instanceof DOMException ? 'DOMException' : 'Error'} AbortError`, + wrappedBy: 'neither — abort passes both layers untranslated (by design)', + }; + } + if (isError2(error)) { + const cause = chatProviderCause(error.cause); + return { + outcome: `Error2 ${error.code} (cause: ${cause?.name ?? (error.cause instanceof Error ? error.cause.name : 'none')})`, + wrappedBy: + cause !== undefined + ? 'ChatProvider wrapped → ModelRequester translated' + : 'NOT wrapped by ChatProvider — raw error caught by the ModelRequester safety net', + }; + } + if (error instanceof ChatProviderError) { + return { outcome: `raw ${error.name} (escaped translation!)`, wrappedBy: 'ChatProvider only' }; + } + return { + outcome: `raw ${error instanceof Error ? error.name : typeof error} (escaped EVERYTHING)`, + wrappedBy: 'none', + }; +} + +function report(probe: string, outcome: string, wrappedBy: string): void { + reports.push({ probe, outcome, wrappedBy }); + console.log(`[probe] ${probe.padEnd(30)} -> ${outcome}`); +} + +// --- part 2 main ------------------------------------------------------------ + +async function probeBoundaries(): Promise { + console.log('\n=== part 2: deterministic boundary probes (local stub) ==='); + let handler: StubHandler = () => { + throw new Error('no handler set'); + }; + let requestCount = 0; + let lastAuth: string | null | undefined; + let lastRequestBody: unknown; + + const server = createServer((req, res) => { + requestCount += 1; + lastAuth = req.headers.authorization; + // Drain (and capture) the request body before answering so the SDK never + // sees a reset — tool-call probes assert on the captured wire JSON. + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + try { + lastRequestBody = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + lastRequestBody = undefined; + } + handler(req, res); + }); + }); + // Read through functions so TS literal narrowing on the counters does not + // leak across probes (assert() guards narrow `number` to a literal). + const requests = (): number => requestCount; + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + const port = (server.address() as AddressInfo).port; + const baseUrl = `http://127.0.0.1:${String(port)}`; + + const registry = new ProtocolAdapterRegistry(); + const makeRequester = (credentialProvider: LlmCredentialProvider, url = baseUrl): ModelRequester => { + const model: Model = { + id: 'probe', + name: 'probe-model', + aliases: [], + protocol: 'openai', + baseUrl: url, + headers: {}, + capabilities: { ...UNKNOWN_CAPABILITY, max_context_tokens: 8192 }, + maxContextSize: 8192, + alwaysThinking: false, + providerName: 'probe', + credentialProvider, + }; + return new ModelRequesterImpl(model, registry); + }; + const resetCounts = (): void => { + requestCount = 0; + lastAuth = undefined; + }; + + try { + // 1) happy path — the requester's event envelope on top of the raw stream. + resetCounts(); + handler = (_req, res) => writePong(res); + const ok = await collect(makeRequester(createStaticCredentialProvider('sk-probe'))); + assert(ok.text === 'pong', 'happy path assembles streamed text'); + assert(ok.events.includes('usage'), 'happy path emits a usage event'); + assert(ok.events.includes('finish'), 'happy path emits a finish event'); + assert(ok.events.includes('timing'), 'happy path emits a timing event'); + assert(lastAuth === 'Bearer sk-probe', 'requester injects per-request auth'); + report('happy-path', `events=${ok.events.join('>')} text=${JSON.stringify(ok.text)}`, '—'); + + // 2) 401 with a static key: ChatProvider wraps to APIStatusError(401), the + // requester translates to provider.auth_error. No replay (no canRecover). + resetCounts(); + handler = (_req, res) => writeJsonError(res, 401, 'invalid api key'); + try { + await collect(makeRequester(createStaticCredentialProvider('sk-bad'))); + throw new Error('expected a failure'); + } catch (error) { + const { outcome, wrappedBy } = describeCaught(error); + assert(isError2(error) && error.code === 'provider.auth_error', 'static 401 -> provider.auth_error'); + assert(chatProviderCause(error.cause) instanceof APIStatusError, 'static 401 wrapped as APIStatusError'); + assert(requests() === 1, 'static 401 is NOT replayed'); + report('auth-401-static-key', outcome, wrappedBy); + } + + // 3) 401 with refreshable credentials: the call site invalidates and + // replays ONCE — recovery the requester deliberately does not own. + resetCounts(); + handler = (req, res) => { + if (req.headers.authorization === 'Bearer sk-good') writePong(res); + else writeJsonError(res, 401, 'token expired'); + }; + let resolveCalls = 0; + const refreshable = createOAuthCredentialProvider((options) => { + resolveCalls += 1; + return Promise.resolve(options?.force === true ? 'sk-good' : 'sk-stale'); + }); + const replayed = await collectWithRecovery(makeRequester(refreshable)); + assert(replayed.text === 'pong', 'refresh+replay succeeds'); + assert(resolveCalls === 2, 'resolve called twice (normal + forced)'); + assert(requests() === 2, 'exactly one replay after the 401'); + report('auth-401-refresh-replay', `success after ${String(requestCount)} attempts`, 'caller-owned recovery (ModelRequester surfaces the 401)'); + + // 4) 401 that survives a forced refresh: the provider rejected the account + // — surfaced as provider.auth_error, not a re-login prompt. + resetCounts(); + handler = (_req, res) => writeJsonError(res, 401, 'account disabled'); + try { + await collectWithRecovery(makeRequester(refreshable)); + throw new Error('expected a failure'); + } catch (error) { + const { outcome, wrappedBy } = describeCaught(error); + assert(isError2(error) && error.code === 'provider.auth_error', 'post-refresh 401 -> provider.auth_error'); + assert(requests() === 2, 'exactly one replay before surfacing'); + report('auth-401-refresh-rejected', outcome, wrappedBy); + } + + // 5) 429 with Retry-After: typed rate-limit error carrying the server backoff. + resetCounts(); + handler = (_req, res) => writeJsonError(res, 429, 'too many requests', { 'retry-after': '2' }); + try { + await collect(makeRequester(createStaticCredentialProvider('sk-probe'))); + throw new Error('expected a failure'); + } catch (error) { + const { outcome, wrappedBy } = describeCaught(error); + assert(isError2(error) && error.code === 'provider.rate_limit', '429 -> provider.rate_limit'); + const cause = chatProviderCause(error.cause); + assert(cause instanceof APIStatusError && cause.retryAfterMs === 2000, 'retry-after parsed to ms'); + report('rate-limit-429', outcome, wrappedBy); + } + + // 6) 400 context overflow: routed to its own recovery-owned code. + resetCounts(); + handler = (_req, res) => + writeJsonError(res, 400, 'This model\'s maximum context length is 8192 tokens.'); + try { + await collect(makeRequester(createStaticCredentialProvider('sk-probe'))); + throw new Error('expected a failure'); + } catch (error) { + const { outcome, wrappedBy } = describeCaught(error); + assert(isError2(error) && error.code === 'context.overflow', '400 overflow -> context.overflow'); + assert(chatProviderCause(error.cause) instanceof APIContextOverflowError, 'overflow typed at the ChatProvider layer'); + report('context-overflow-400', outcome, wrappedBy); + } + + // 7) 500 with an HTML error page: status error with a sanitized message. + resetCounts(); + handler = (_req, res) => { + res.writeHead(500, { 'content-type': 'text/html' }); + res.end('500 Internal Server Erroroops'); + }; + try { + await collect(makeRequester(createStaticCredentialProvider('sk-probe'))); + throw new Error('expected a failure'); + } catch (error) { + const { outcome, wrappedBy } = describeCaught(error); + assert(isError2(error) && error.code === 'provider.api_error', '500 -> provider.api_error'); + assert(!error.message.includes('<'), 'HTML body sanitized to its '); + report('server-500-html', outcome, wrappedBy); + } + + // 8) connection refused: transport failure wrapped as APIConnectionError. + resetCounts(); + const dead = createServer(); + await new Promise<void>((resolve) => { + dead.listen(0, '127.0.0.1', () => resolve()); + }); + const deadPort = (dead.address() as AddressInfo).port; + await new Promise<void>((resolve) => { + dead.close(() => resolve()); + }); + handler = (_req, res) => writePong(res); // unused — nothing listens there + try { + await collect(makeRequester(createStaticCredentialProvider('sk-probe'), `http://127.0.0.1:${String(deadPort)}`)); + throw new Error('expected a failure'); + } catch (error) { + const { outcome, wrappedBy } = describeCaught(error); + assert(isError2(error) && error.code === 'provider.connection_error', 'refused -> provider.connection_error'); + report('connection-refused', outcome, wrappedBy); + } + + // 9) empty stream (immediate [DONE]): generate() throws APIEmptyResponseError. + resetCounts(); + handler = (_req, res) => writeSse(res, []); + try { + await collect(makeRequester(createStaticCredentialProvider('sk-probe'))); + throw new Error('expected a failure'); + } catch (error) { + const { outcome, wrappedBy } = describeCaught(error); + assert(isError2(error) && error.code === 'provider.api_error', 'empty stream -> provider.api_error'); + report('empty-stream-done', outcome, wrappedBy); + } + + // 10) malformed SSE chunk: who catches a wire-format violation? Reported, + // not hard-coded — the CODE tells whether ChatProvider wrapped it. + resetCounts(); + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.end('data: {this is not json}\n\ndata: [DONE]\n\n'); + }; + try { + await collect(makeRequester(createStaticCredentialProvider('sk-probe'))); + throw new Error('expected a failure'); + } catch (error) { + const { outcome, wrappedBy } = describeCaught(error); + assert(isError2(error), 'malformed stream still surfaces as a coded Error2'); + report('malformed-sse-chunk', outcome, wrappedBy); + } + + // 11) stream cut mid-flight: one valid chunk, then the socket dies. + resetCounts(); + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write(`data: ${sseChunk({ role: 'assistant' }, null)}\n\n`, () => { + res.socket?.destroy(); + }); + }; + try { + await collect(makeRequester(createStaticCredentialProvider('sk-probe'))); + throw new Error('expected a failure'); + } catch (error) { + const { outcome, wrappedBy } = describeCaught(error); + assert(isError2(error), 'cut stream still surfaces as a coded Error2'); + report('stream-cut-mid-flight', outcome, wrappedBy); + } + + // --- tool-call boundary probes ------------------------------------------- + // Response side: streamed `delta.tool_calls` fragments are buffered per + // index by the OpenAI base and routed into `message.toolCalls` by the + // contract's generate() driver — both BELOW the ModelRequester, which just + // forwards the parts. Request side: tool declarations and tool-result + // history are encoded by the ChatProvider. + + // 12) tool call happy path: header chunk + fragmented arguments, and the + // outbound request carries the tool declaration. + resetCounts(); + handler = (_req, res) => + writeSse(res, [ + sseToolDelta([ + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'get_weather', arguments: '' }, + }, + ]), + sseToolDelta([{ index: 0, function: { arguments: '{"location":"' } }]), + sseToolDelta([{ index: 0, function: { arguments: 'Hangzhou"}' } }]), + sseToolDelta([], 'tool_calls'), + SSE_USAGE, + ]); + const toolOk = await collect(makeRequester(createStaticCredentialProvider('sk-probe')), undefined, TOOL_INPUT); + const wireTools = (lastRequestBody as { tools?: { function?: { name?: string } }[] }).tools; + assert( + wireTools?.some((t) => t.function?.name === 'get_weather') === true, + 'request encodes the tool declaration', + ); + assert(toolOk.events.includes('part:function'), 'function header part streamed'); + assert(toolOk.events.includes('part:tool_call_part'), 'argument fragments streamed'); + assert(toolOk.toolCalls.length === 1, 'one assembled tool call'); + assert(toolOk.toolCalls[0]?.name === 'get_weather', 'tool call name assembled'); + assert( + toolOk.toolCalls[0]?.arguments === '{"location":"Hangzhou"}', + `fragmented arguments reassembled in order (got ${JSON.stringify(toolOk.toolCalls[0]?.arguments)})`, + ); + assert(toolOk.finishReason === 'tool_calls', "finish reason 'tool_calls' normalized"); + report( + 'toolcall-happy', + `toolCalls=[${toolOk.toolCalls[0]?.name}(${toolOk.toolCalls[0]?.arguments ?? ''})] finish=${String(toolOk.finishReason)}`, + 'ChatProvider (decode) + generate() (assembly), below the requester', + ); + + // 13) parallel tool calls with interleaved argument fragments: per-index + // buffering must keep the two calls apart. + resetCounts(); + handler = (_req, res) => + writeSse(res, [ + sseToolDelta([ + { index: 0, id: 'call_a', type: 'function', function: { name: 'tool_a', arguments: '' } }, + ]), + sseToolDelta([ + { index: 1, id: 'call_b', type: 'function', function: { name: 'tool_b', arguments: '' } }, + ]), + sseToolDelta([{ index: 0, function: { arguments: '{"a":' } }]), + sseToolDelta([{ index: 1, function: { arguments: '{"b":' } }]), + sseToolDelta([{ index: 0, function: { arguments: '1}' } }]), + sseToolDelta([{ index: 1, function: { arguments: '2}' } }]), + sseToolDelta([], 'tool_calls'), + SSE_USAGE, + ]); + const parallel = await collect(makeRequester(createStaticCredentialProvider('sk-probe')), undefined, TOOL_INPUT); + assert(parallel.toolCalls.length === 2, 'two parallel tool calls assembled'); + assert( + parallel.toolCalls[0]?.name === 'tool_a' && parallel.toolCalls[0]?.arguments === '{"a":1}', + 'index 0 arguments routed to tool_a', + ); + assert( + parallel.toolCalls[1]?.name === 'tool_b' && parallel.toolCalls[1]?.arguments === '{"b":2}', + 'index 1 arguments routed to tool_b', + ); + report( + 'toolcall-parallel-interleaved', + `toolCalls=[${parallel.toolCalls.map((t) => `${t.name}(${t.arguments ?? ''})`).join(', ')}]`, + 'generate() index routing — interleaving stays separated', + ); + + // 14) malformed tool-call arguments: the wire layer NEVER parses the + // arguments string — invalid JSON sails through both layers and only fails + // later at tool dispatch. A boundary neither layer owns, by design. + resetCounts(); + handler = (_req, res) => + writeSse(res, [ + sseToolDelta([ + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'get_weather', arguments: '' }, + }, + ]), + sseToolDelta([{ index: 0, function: { arguments: '{not json' } }]), + sseToolDelta([], 'tool_calls'), + SSE_USAGE, + ]); + const malformedArgs = await collect(makeRequester(createStaticCredentialProvider('sk-probe')), undefined, TOOL_INPUT); + assert( + malformedArgs.toolCalls[0]?.arguments === '{not json', + 'malformed arguments pass through untouched', + ); + report( + 'toolcall-malformed-arguments', + `success, arguments=${JSON.stringify(malformedArgs.toolCalls[0]?.arguments)} preserved verbatim`, + 'neither — arguments validity is deferred to tool dispatch (by design)', + ); + + // 15) index-less fragments: a single call without `index` still assembles + // (header + trailing merge in generate()). Caveat NOT probed: two + // interleaved index-less calls would silently cross-merge — the wire layer + // trusts the provider's indices and does not guard that. + resetCounts(); + handler = (_req, res) => + writeSse(res, [ + sseToolDelta([ + { id: 'call_1', type: 'function', function: { name: 'get_weather', arguments: '' } }, + ]), + sseToolDelta([{ function: { arguments: '{"location":"HZ"}' } }]), + sseToolDelta([], 'tool_calls'), + SSE_USAGE, + ]); + const indexless = await collect(makeRequester(createStaticCredentialProvider('sk-probe')), undefined, TOOL_INPUT); + assert( + indexless.toolCalls[0]?.arguments === '{"location":"HZ"}', + 'index-less fragments merge into the pending call', + ); + report( + 'toolcall-indexless-fragments', + `success, arguments=${indexless.toolCalls[0]?.arguments ?? ''}`, + 'generate() pending-part merge (indices trusted, not guarded)', + ); + + // 16) tool-exchange adjacency rejection: the strict-provider 400 must stay + // recognizable as `isToolExchangeAdjacencyError` THROUGH the ChatProvider + // wrap — the agent loop's strict-resend recovery keys on that predicate. + resetCounts(); + handler = (_req, res) => + writeJsonError(res, 400, 'tool_call_id "call_1" is not found'); + try { + await collect(makeRequester(createStaticCredentialProvider('sk-probe')), undefined, TOOL_HISTORY_INPUT); + throw new Error('expected a failure'); + } catch (error) { + const { outcome, wrappedBy } = describeCaught(error); + assert(isError2(error) && error.code === 'provider.api_error', 'adjacency 400 -> provider.api_error'); + assert( + isToolExchangeAdjacencyError(chatProviderCause(error.cause)), + 'adjacency classification survives the ChatProvider wrap', + ); + report('toolcall-adjacency-400', outcome, wrappedBy); + } + + // 17) request-side encoding of a tool exchange: assistant tool_calls and + // the tool result must hit the wire in the provider's shape. + resetCounts(); + handler = (_req, res) => writePong(res); + await collect(makeRequester(createStaticCredentialProvider('sk-probe')), undefined, TOOL_HISTORY_INPUT); + const wireMessages = (lastRequestBody as { messages?: Record<string, unknown>[] }).messages; + assert( + wireMessages?.some( + (m) => m['role'] === 'assistant' && Array.isArray(m['tool_calls']), + ) === true, + 'assistant message carries wire tool_calls', + ); + assert( + wireMessages?.some((m) => m['role'] === 'tool' && m['tool_call_id'] === 'call_1') === true, + 'tool result encoded as role=tool with tool_call_id', + ); + report( + 'toolcall-request-encoding', + 'assistant.tool_calls + role=tool/tool_call_id on the wire', + 'ChatProvider (request-side encoding boundary)', + ); + + // 18) user cancellation: the one boundary BOTH layers pass through. + resetCounts(); + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write(`data: ${sseChunk({ role: 'assistant' }, null)}\n\n`); + const timer = setInterval(() => { + res.write(`data: ${sseChunk({ content: '.' }, null)}\n\n`); + }, 25); + res.on('close', () => clearInterval(timer)); + }; + const ac = new AbortController(); + try { + for await (const event of makeRequester(createStaticCredentialProvider('sk-probe')).request(PING_INPUT, ac.signal)) { + if (event.type === 'part') ac.abort(); + } + throw new Error('expected an abort'); + } catch (error) { + const { outcome, wrappedBy } = describeCaught(error); + assert(isAbortError(error), 'abort surfaces as the standard AbortError'); + assert(!isError2(error), 'abort is NOT translated into an Error2'); + assert(!(error instanceof ChatProviderError), 'abort is NOT a ChatProviderError'); + report('abort-mid-stream', outcome, wrappedBy); + } + } finally { + server.closeAllConnections(); + await new Promise<void>((resolve) => { + server.close(() => resolve()); + }); + } + + console.log('\n=== boundary matrix ==='); + for (const r of reports) { + console.log(`${r.probe.padEnd(30)} ${r.outcome}`); + console.log(`${''.padEnd(30)} └ ${r.wrappedBy}`); + } +} + +// --------------------------------------------------------------------------- + +async function main(): Promise<void> { + if (process.env['KIMI_BOUNDARY_SKIP_LIVE'] !== '1') { + await probeRealConfig(); + } + await probeBoundaries(); + console.log('\nboundary: OK'); +} + +try { + await main(); +} catch (error) { + console.error(error); + process.exit(1); +} diff --git a/packages/klient/examples/smoke.ts b/packages/klient/examples/smoke.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5a9fe69292717f81943a0ca8ab3c17903e76bd9 --- /dev/null +++ b/packages/klient/examples/smoke.ts @@ -0,0 +1,133 @@ +/** + * Assert-based smoke check for klient against an in-process engine (memory + * transport). Exercises the `global` facade end-to-end: env snapshot, read + * models, a workspace round-trip, a provider set/delete round-trip with the + * `kosong.providers.changed` event, an anonymous-provider set/delete + * round-trip with the `kosong.models.changed` event, the read-only model + * catalog, and the error path. + * + * pnpm -C packages/klient smoke + */ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { EXAMPLE_CLIENT_IDENTITY } from './identity.js'; + + +import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; +import { createKlient } from '@moonshot-ai/klient/memory'; + +function assert(cond: boolean, message: string): asserts cond { + if (!cond) throw new Error(`assertion failed: ${message}`); +} + +const tick = (ms: number): Promise<void> => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +async function main(): Promise<void> { + const homeDir = await mkdtemp(join(tmpdir(), 'klient-smoke-')); + const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [ + ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), + ]); + try { + const klient = createKlient({ scope: app }); + + const env = await klient.global.env(); + assert(env.platform.length > 0 && env.homeDir.length > 0, 'env snapshot is populated'); + console.log('[ok] env'); + + const page = await klient.global.sessions.list({ limit: 5 }); + assert(Array.isArray(page.items), 'sessions.list returns a page'); + console.log('[ok] sessions.list ->', page.items.length); + + const workspaces = await klient.global.workspaces.list(); + assert(Array.isArray(workspaces), 'workspaces.list returns an array'); + console.log('[ok] workspaces.list ->', workspaces.length); + + // Provider round-trip with the klient-level event. + const seen: string[] = []; + const sub = klient.events.on('kosong.providers.changed', (event) => { + seen.push(...event.added, ...event.changed, ...event.removed); + }); + const name = '__klient_smoke__'; + await klient.global.kosong.addProvider(name, { + type: 'openai', + auth: { method: 'api-key', apiKey: 'smoke-key' }, + }); + const got = await klient.global.kosong.getProvider(name); + assert(got !== undefined, 'kosong.getProvider returns the new provider'); + const deadline = Date.now() + 5_000; + while (!seen.includes(name) && Date.now() < deadline) await tick(25); + assert(seen.includes(name), 'kosong.providers.changed fired for the new provider'); + await klient.global.kosong.removeProvider(name); + sub.dispose(); + console.log('[ok] kosong addProvider/getProvider/removeProvider + kosong.providers.changed'); + + // Anonymous provider round-trip (single-model, all fields inline). + const seenModels: string[] = []; + const modelSub = klient.events.on('kosong.models.changed', (event) => { + seenModels.push(...event.added, ...event.changed, ...event.removed); + }); + const modelId = '__klient_smoke__'; + await klient.global.kosong.addProvider({ + id: modelId, + model: 'smoke-model', + protocol: 'openai', + baseUrl: 'http://127.0.0.1:1', + auth: { method: 'api-key', apiKey: 'smoke-key' }, + maxContextSize: 8192, + }); + const modelDeadline = Date.now() + 5_000; + while (!seenModels.includes(modelId) && Date.now() < modelDeadline) await tick(25); + assert(seenModels.includes(modelId), 'kosong.models.changed fired for the new model'); + await klient.global.kosong.removeProvider(modelId); + modelSub.dispose(); + console.log('[ok] kosong anonymous addProvider/removeProvider + kosong.models.changed'); + + // The read-only catalog projection over the same materialization. + assert( + Array.isArray(await klient.global.kosong.listModels()), + 'kosong.listModels returns an array', + ); + assert( + Array.isArray(await klient.global.kosong.listProviders()), + 'kosong.listProviders returns an array', + ); + console.log('[ok] kosong.listModels / listProviders'); + + const config = await klient.global.config.getAll(); + assert(typeof config === 'object' && config !== null, 'config.getAll returns an object'); + console.log('[ok] config.getAll'); + + assert(Array.isArray(await klient.global.flags.list()), 'flags.list returns an array'); + assert(Array.isArray(await klient.global.plugins.list()), 'plugins.list returns an array'); + const auth = await klient.global.auth.status(); + assert(typeof auth.loggedIn === 'boolean', 'auth.status returns a status'); + console.log('[ok] flags / plugins / auth'); + + let rpcError: { name: string; code?: number } | undefined; + try { + await klient.global.plugins.info('__definitely_missing__'); + } catch (error) { + rpcError = error as { name: string; code?: number }; + } + assert(rpcError !== undefined, 'missing plugin surfaces an error'); + console.log('[ok] error path ->', rpcError.name, rpcError.code); + + await klient.close(); + console.log('smoke: OK'); + } finally { + app.dispose(); + await rm(homeDir, { recursive: true, force: true }); + } +} + +try { + await main(); +} catch (error) { + console.error(error); + process.exit(1); +} diff --git a/packages/klient/scripts/run-docker-e2e.sh b/packages/klient/scripts/run-docker-e2e.sh new file mode 100644 index 0000000000000000000000000000000000000000..66945afbc05349158a7cce8670fe8eb645726a84 --- /dev/null +++ b/packages/klient/scripts/run-docker-e2e.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PACKAGE_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +REPO_ROOT="$(cd -- "${PACKAGE_DIR}/../.." && pwd)" + +workspace_slug="$( + basename -- "${REPO_ROOT}" \ + | tr '[:upper:]' '[:lower:]' \ + | tr -cs 'a-z0-9_.-' '-' \ + | sed -e 's/^[^a-z0-9]*//' -e 's/[^a-z0-9]*$//' \ + | cut -c1-48 +)" +if [[ -z "${workspace_slug}" ]]; then + workspace_slug="workspace" +fi +workspace_hash="$(printf '%s' "${REPO_ROOT}" | cksum | awk '{print $1}')" +RUN_ID="${KIMI_SERVER_E2E_RUN_ID:-${workspace_slug}-${workspace_hash}}" + +BASE_IMAGE="${KIMI_SERVER_E2E_BASE_IMAGE:-kimi-server-e2e-base:${RUN_ID}}" +IMAGE="${KIMI_SERVER_E2E_IMAGE:-kimi-server-e2e:${RUN_ID}}" +CONTAINER="${KIMI_SERVER_E2E_CONTAINER:-kimi-server-e2e-${RUN_ID}}" +STATE_ROOT="${KIMI_SERVER_E2E_STATE_ROOT:-${HOME}/.kimi-code-server-dev}" +PORT="${KIMI_SERVER_E2E_PORT:-58627}" + +KIMI_HOME_HOST="${KIMI_SERVER_E2E_KIMI_HOME_HOST:-${STATE_ROOT}/docker-e2e/${RUN_ID}/kimi-code-home}" +KIMI_HOME_CONTAINER="/data/docker-e2e/kimi-code-home" +SEED_HOME_HOST="${KIMI_SERVER_E2E_SEED_KIMI_HOME_HOST:-${STATE_ROOT}/kimi-home/kimi-code-home}" + +if [[ -n "${KIMI_SERVER_E2E_REPORT_DIR_HOST:-}" ]]; then + REPORT_DIR_HOST="${KIMI_SERVER_E2E_REPORT_DIR_HOST}" + REPORT_ROOT_HOST="$(dirname -- "${REPORT_DIR_HOST}")" + REPORT_DIR_NAME="$(basename -- "${REPORT_DIR_HOST}")" +else + REPORT_ROOT_HOST="${KIMI_SERVER_E2E_REPORT_ROOT_HOST:-${STATE_ROOT}/server-e2e-reports/docker/${RUN_ID}}" + REPORT_DIR_NAME="latest" + REPORT_DIR_HOST="${REPORT_ROOT_HOST}/${REPORT_DIR_NAME}" +fi +REPORT_ROOT_CONTAINER="/data/server-e2e-reports/docker" +REPORT_DIR_CONTAINER="${REPORT_ROOT_CONTAINER}/${REPORT_DIR_NAME}" +TMPDIR_CONTAINER="/data/docker-e2e/tmp" + +NM_ROOT="${STATE_ROOT}/docker-e2e/${RUN_ID}/nm" + +workspace_node_modules=( + "root:/workspace/kimi-code/node_modules" + "apps_kimi-code:/workspace/kimi-code/apps/kimi-code/node_modules" + "apps_kimi-web:/workspace/kimi-code/apps/kimi-web/node_modules" + "apps_vis:/workspace/kimi-code/apps/vis/node_modules" + "apps_vis_server:/workspace/kimi-code/apps/vis/server/node_modules" + "apps_vis_web:/workspace/kimi-code/apps/vis/web/node_modules" + "docs:/workspace/kimi-code/docs/node_modules" + "pkg_kap-server:/workspace/kimi-code/packages/kap-server/node_modules" + "pkg_server-e2e:/workspace/kimi-code/packages/klient/node_modules" + "pkg_kaos:/workspace/kimi-code/packages/kaos/node_modules" + "pkg_kosong:/workspace/kimi-code/packages/kosong/node_modules" + "pkg_migration-legacy:/workspace/kimi-code/packages/migration-legacy/node_modules" + "pkg_node-sdk:/workspace/kimi-code/packages/node-sdk/node_modules" + "pkg_oauth:/workspace/kimi-code/packages/oauth/node_modules" + "pkg_protocol:/workspace/kimi-code/packages/protocol/node_modules" + "pkg_services:/workspace/kimi-code/packages/services/node_modules" + "pkg_telemetry:/workspace/kimi-code/packages/telemetry/node_modules" +) + +mkdir -p "${STATE_ROOT}" "${KIMI_HOME_HOST}" "${REPORT_DIR_HOST}" "${NM_ROOT}" +for mount in "${workspace_node_modules[@]}"; do + mkdir -p "${NM_ROOT}/${mount%%:*}" +done + +# Seed only auth/config into the isolated docker-e2e home. Never copy server +# locks, sessions, uploaded files, or reports from the compose server home. +if [[ -f "${SEED_HOME_HOST}/config.toml" && ! -f "${KIMI_HOME_HOST}/config.toml" ]]; then + cp "${SEED_HOME_HOST}/config.toml" "${KIMI_HOME_HOST}/config.toml" +fi +if [[ -d "${SEED_HOME_HOST}/credentials" && ! -d "${KIMI_HOME_HOST}/credentials" ]]; then + cp -R "${SEED_HOME_HOST}/credentials" "${KIMI_HOME_HOST}/credentials" +fi + +if [[ "${KIMI_SERVER_E2E_SKIP_BUILD:-0}" != "1" ]]; then + docker build -t "${BASE_IMAGE}" -f "${REPO_ROOT}/Dockerfile" "${REPO_ROOT}" + docker build \ + -t "${IMAGE}" \ + -f "${PACKAGE_DIR}/Dockerfile" \ + --build-arg "BASE_IMAGE=${BASE_IMAGE}" \ + "${REPO_ROOT}" +fi + +docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true + +read -r -d '' container_script <<'EOS' || true +set -euo pipefail + +cd /workspace/kimi-code +mkdir -p "${KIMI_CODE_HOME}/server" "${KIMI_SERVER_E2E_REPORT_DIR}" "${TMPDIR}" /data/server-e2e-reports/docker +rm -f "${KIMI_CODE_HOME}/server/lock" + +if [[ ! -e /workspace/kimi-code/node_modules/.modules.yaml || ! -e /workspace/kimi-code/packages/klient/node_modules/ws ]]; then + echo "[server-e2e:docker] installing pnpm deps" + pnpm install --frozen-lockfile +else + echo "[server-e2e:docker] pnpm deps already present" +fi + +server_log="/data/server-e2e-reports/docker/server.log" +: > "${server_log}" + +echo "[server-e2e:docker] starting server on container-local ${KIMI_SERVER_URL}" +pnpm dev:server -- \ + --host 127.0.0.1 \ + --port "${KIMI_SERVER_E2E_PORT}" \ + --log-level debug \ + --debug-endpoints \ + >"${server_log}" 2>&1 & +server_pid=$! + +cleanup() { + status=$? + if kill -0 "${server_pid}" >/dev/null 2>&1; then + kill "${server_pid}" >/dev/null 2>&1 || true + wait "${server_pid}" >/dev/null 2>&1 || true + fi + exit "${status}" +} +trap cleanup EXIT INT TERM + +ready=0 +for attempt in $(seq 1 90); do + if curl -fsS "${KIMI_SERVER_URL}/api/v1/meta" >/tmp/server-meta.json 2>/tmp/server-curl.err; then + ready=1 + echo "[server-e2e:docker] server ready: $(cat /tmp/server-meta.json)" + break + fi + if ! kill -0 "${server_pid}" >/dev/null 2>&1; then + echo "[server-e2e:docker] server exited before readiness" >&2 + tail -n 200 "${server_log}" >&2 || true + exit 1 + fi + sleep 1 +done + +if [[ "${ready}" != "1" ]]; then + echo "[server-e2e:docker] server did not become ready within 90s" >&2 + cat /tmp/server-curl.err >&2 || true + tail -n 200 "${server_log}" >&2 || true + exit 1 +fi + +cd /workspace/kimi-code/packages/klient +pnpm test +EOS + +docker_args=( + run + --rm + --init + --name "${CONTAINER}" + --workdir /workspace/kimi-code/packages/klient + --env "KIMI_CODE_HOME=${KIMI_HOME_CONTAINER}" + --env "KIMI_SERVER_E2E_PORT=${PORT}" + --env "KIMI_SERVER_URL=http://127.0.0.1:${PORT}" + --env "KIMI_SERVER_E2E_REPORT_DIR=${REPORT_DIR_CONTAINER}" + --env "TMPDIR=${TMPDIR_CONTAINER}" + --env "TERM=xterm-256color" + --env "TZ=Asia/Shanghai" + --env "npm_config_store_dir=/workspace/kimi-code/node_modules/.pnpm-store" + --env "npm_config_package_import_method=copy" + --volume "${REPO_ROOT}:/workspace/kimi-code:ro" + --volume "${KIMI_HOME_HOST}:${KIMI_HOME_CONTAINER}" + --volume "${REPORT_ROOT_HOST}:${REPORT_ROOT_CONTAINER}" +) + +for mount in "${workspace_node_modules[@]}"; do + docker_args+=(--volume "${NM_ROOT}/${mount%%:*}:${mount#*:}") +done + +echo "[server-e2e:docker] running ${IMAGE} without host port publishing" +set +e +docker "${docker_args[@]}" "${IMAGE}" bash -lc "${container_script}" +status=$? +set -e + +echo "[server-e2e:docker] report: ${REPORT_DIR_HOST}/index.html" +echo "[server-e2e:docker] server log: ${REPORT_ROOT_HOST}/server.log" +exit "${status}" diff --git a/packages/klient/src/contract/agent/events.ts b/packages/klient/src/contract/agent/events.ts new file mode 100644 index 0000000000000000000000000000000000000000..e657d6550a7ededc004eac74b410b38dde67f832 --- /dev/null +++ b/packages/klient/src/contract/agent/events.ts @@ -0,0 +1,290 @@ +/** + * Klient-level agent-scope events — the public, typed, namespaced event + * surface of one agent. All registrations filter the per-agent `events` + * scope stream by `type`; the payload is the whole flat `{ type, ... }` + * event (schemas keep the `type` literal so listeners receive it intact). + * Payload shapes mirror `protocol/src/events.ts`; events that are loose in + * the engine (or absent from the protocol union) are `z.looseObject`s. + */ + +import { z } from 'zod'; + +import type { EventRegistration } from '../types.js'; + +/** + * Scope-stream registration (`kind: 'stream'`). Declared structurally here + * until `EventRegistration` in `../types.js` gains the `stream` variant; + * compatible with `src/core/events/hub.ts`, which already switches on it. + */ +interface StreamEventRegistration { + readonly kind: 'stream'; + readonly name: string; + readonly type?: string; + readonly schema: z.ZodType; +} + +type AgentEventRegistration = EventRegistration | StreamEventRegistration; + +// ── payload schemas ───────────────────────────────────────────────────────── + +export const turnStartedEventSchema = z.object({ + type: z.literal('turn.started'), + time: z.number().optional(), + turnId: z.number(), + /** Protocol `PromptOrigin` union — mirrored as `unknown`. */ + origin: z.unknown(), + /** The turn's extracted prompt text (present when the turn opened with a text part). */ + prompt: z.string().optional(), + /** The prompt record id when the turn was opened by a prompt submission. */ + promptId: z.string().optional(), +}); + +export const turnEndedEventSchema = z.object({ + type: z.literal('turn.ended'), + time: z.number().optional(), + turnId: z.number(), + reason: z.enum(['completed', 'cancelled', 'failed', 'blocked']), + /** Protocol `KimiErrorPayload` — mirrored as `unknown`. */ + error: z.unknown().optional(), + durationMs: z.number().optional(), + /** Why a non-completed turn stopped early; absent on completion. */ + interruptReason: z + .enum(['user_cancelled', 'aborted', 'max_steps', 'error', 'filtered', 'blocked']) + .optional(), +}); + +export const assistantDeltaEventSchema = z.object({ + type: z.literal('assistant.delta'), + time: z.number().optional(), + turnId: z.number(), + delta: z.string(), +}); + +export const thinkingDeltaEventSchema = z.object({ + type: z.literal('thinking.delta'), + time: z.number().optional(), + turnId: z.number(), + delta: z.string(), +}); + +export const toolCallStartedEventSchema = z.object({ + type: z.literal('tool.call.started'), + time: z.number().optional(), + turnId: z.number(), + toolCallId: z.string(), + name: z.string(), + args: z.unknown(), + description: z.string().optional(), + /** Protocol `ToolInputDisplay` — mirrored as `unknown`. */ + display: z.unknown().optional(), +}); + +export const toolCallDeltaEventSchema = z.object({ + type: z.literal('tool.call.delta'), + time: z.number().optional(), + turnId: z.number(), + toolCallId: z.string(), + name: z.string().optional(), + argumentsPart: z.string().optional(), +}); + +export const toolProgressEventSchema = z.object({ + type: z.literal('tool.progress'), + time: z.number().optional(), + turnId: z.number(), + toolCallId: z.string(), + /** Protocol `ToolUpdate` — mirrored field-for-field. */ + update: z.object({ + kind: z.enum(['stdout', 'stderr', 'progress', 'status', 'custom']), + text: z.string().optional(), + percent: z.number().optional(), + customKind: z.string().optional(), + customData: z.unknown().optional(), + replace: z.boolean().optional(), + }), +}); + +export const toolResultEventSchema = z.object({ + type: z.literal('tool.result'), + time: z.number().optional(), + turnId: z.number(), + toolCallId: z.string(), + output: z.unknown(), + isError: z.boolean().optional(), + synthetic: z.boolean().optional(), +}); + +export const promptCompletedEventSchema = z.object({ + type: z.literal('prompt.completed'), + time: z.number().optional(), + promptId: z.string(), + /** ISO 8601 datetime string on the wire. */ + finishedAt: z.string(), + reason: z.enum(['completed', 'failed', 'blocked']).optional(), +}); + +export const promptAbortedEventSchema = z.object({ + type: z.literal('prompt.aborted'), + time: z.number().optional(), + promptId: z.string(), + /** ISO 8601 datetime string on the wire. */ + abortedAt: z.string(), +}); + +export const compactionStartedEventSchema = z.object({ + type: z.literal('compaction.started'), + time: z.number().optional(), + trigger: z.enum(['manual', 'auto']), + instruction: z.string().optional(), +}); + +export const compactionBlockedEventSchema = z.object({ + type: z.literal('compaction.blocked'), + time: z.number().optional(), + turnId: z.number().optional(), +}); + +export const compactionCancelledEventSchema = z.object({ + type: z.literal('compaction.cancelled'), + time: z.number().optional(), +}); + +/** + * Protocol `CompactionResult` — mirrored field-for-field. The engine's + * internal result additionally carries `contextSummary`, but the service + * strips it before publishing (`fullCompactionService.ts`), so it never + * reaches the wire. + */ +export const compactionCompletedEventSchema = z.object({ + type: z.literal('compaction.completed'), + time: z.number().optional(), + result: z.object({ + summary: z.string(), + compactedCount: z.number(), + tokensBefore: z.number(), + tokensAfter: z.number(), + keptUserMessageCount: z.number().optional(), + keptHeadUserMessageCount: z.number().optional(), + droppedCount: z.number().optional(), + }), +}); + +/** Engine `permission.approval.requested` — not in the protocol union; loose. */ +export const permissionApprovalRequestedEventSchema = z.looseObject({ + time: z.number().optional(), + turnId: z.number(), + toolCallId: z.string(), + toolName: z.string(), + action: z.string(), +}); + +/** Engine `permission.approval.resolved` — not in the protocol union; loose. */ +export const permissionApprovalResolvedEventSchema = z.looseObject({ + time: z.number().optional(), + turnId: z.number(), + toolCallId: z.string(), +}); + +/** `error` payloads carry the full `KimiErrorPayload`; kept loose. */ +export const errorEventSchema = z.looseObject({ + time: z.number().optional(), + message: z.string(), +}); + +export const warningEventSchema = z.object({ + type: z.literal('warning'), + time: z.number().optional(), + message: z.string(), + code: z.string().optional(), +}); + +/** `agent.status.updated` carries a wide optional status bag; kept loose. */ +export const agentStatusUpdatedEventSchema = z.looseObject({ + time: z.number().optional(), + phase: z.string().optional(), +}); + +// ── registrations ─────────────────────────────────────────────────────────── + +/** Public event name → payload type. Keys must stay in sync with `agentEvents`. */ +export interface AgentEventPayloads { + 'turn.started': z.infer<typeof turnStartedEventSchema>; + 'turn.ended': z.infer<typeof turnEndedEventSchema>; + 'assistant.delta': z.infer<typeof assistantDeltaEventSchema>; + 'thinking.delta': z.infer<typeof thinkingDeltaEventSchema>; + 'tool.call.started': z.infer<typeof toolCallStartedEventSchema>; + 'tool.call.delta': z.infer<typeof toolCallDeltaEventSchema>; + 'tool.progress': z.infer<typeof toolProgressEventSchema>; + 'tool.result': z.infer<typeof toolResultEventSchema>; + 'prompt.completed': z.infer<typeof promptCompletedEventSchema>; + 'prompt.aborted': z.infer<typeof promptAbortedEventSchema>; + 'compaction.started': z.infer<typeof compactionStartedEventSchema>; + 'compaction.blocked': z.infer<typeof compactionBlockedEventSchema>; + 'compaction.cancelled': z.infer<typeof compactionCancelledEventSchema>; + 'compaction.completed': z.infer<typeof compactionCompletedEventSchema>; + 'permission.approval.requested': z.infer<typeof permissionApprovalRequestedEventSchema>; + 'permission.approval.resolved': z.infer<typeof permissionApprovalResolvedEventSchema>; + error: z.infer<typeof errorEventSchema>; + warning: z.infer<typeof warningEventSchema>; + 'agent.status.updated': z.infer<typeof agentStatusUpdatedEventSchema>; +} + +export type AgentEventName = keyof AgentEventPayloads; + +/** Public event name → stream binding + payload schema. */ +export const agentEvents = { + 'turn.started': { kind: 'stream', name: 'events', type: 'turn.started', schema: turnStartedEventSchema }, + 'turn.ended': { kind: 'stream', name: 'events', type: 'turn.ended', schema: turnEndedEventSchema }, + 'assistant.delta': { kind: 'stream', name: 'events', type: 'assistant.delta', schema: assistantDeltaEventSchema }, + 'thinking.delta': { kind: 'stream', name: 'events', type: 'thinking.delta', schema: thinkingDeltaEventSchema }, + 'tool.call.started': { kind: 'stream', name: 'events', type: 'tool.call.started', schema: toolCallStartedEventSchema }, + 'tool.call.delta': { kind: 'stream', name: 'events', type: 'tool.call.delta', schema: toolCallDeltaEventSchema }, + 'tool.progress': { kind: 'stream', name: 'events', type: 'tool.progress', schema: toolProgressEventSchema }, + 'tool.result': { kind: 'stream', name: 'events', type: 'tool.result', schema: toolResultEventSchema }, + 'prompt.completed': { kind: 'stream', name: 'events', type: 'prompt.completed', schema: promptCompletedEventSchema }, + 'prompt.aborted': { kind: 'stream', name: 'events', type: 'prompt.aborted', schema: promptAbortedEventSchema }, + 'compaction.started': { + kind: 'stream', + name: 'events', + type: 'compaction.started', + schema: compactionStartedEventSchema, + }, + 'compaction.blocked': { + kind: 'stream', + name: 'events', + type: 'compaction.blocked', + schema: compactionBlockedEventSchema, + }, + 'compaction.cancelled': { + kind: 'stream', + name: 'events', + type: 'compaction.cancelled', + schema: compactionCancelledEventSchema, + }, + 'compaction.completed': { + kind: 'stream', + name: 'events', + type: 'compaction.completed', + schema: compactionCompletedEventSchema, + }, + 'permission.approval.requested': { + kind: 'stream', + name: 'events', + type: 'permission.approval.requested', + schema: permissionApprovalRequestedEventSchema, + }, + 'permission.approval.resolved': { + kind: 'stream', + name: 'events', + type: 'permission.approval.resolved', + schema: permissionApprovalResolvedEventSchema, + }, + error: { kind: 'stream', name: 'events', type: 'error', schema: errorEventSchema }, + warning: { kind: 'stream', name: 'events', type: 'warning', schema: warningEventSchema }, + 'agent.status.updated': { + kind: 'stream', + name: 'events', + type: 'agent.status.updated', + schema: agentStatusUpdatedEventSchema, + }, +} satisfies Record<AgentEventName, AgentEventRegistration>; diff --git a/packages/klient/src/contract/agent/schemas.ts b/packages/klient/src/contract/agent/schemas.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca698d9d80db12bf6e1fa60fd563b6794302b7dd --- /dev/null +++ b/packages/klient/src/contract/agent/schemas.ts @@ -0,0 +1,229 @@ +/** + * Shared agent-scope wire schemas — the payload/result vocabulary reused by + * the per-domain contracts in `agent/services.ts` and pinned against the + * engine types by `test/contract-parity.ts`. `PromptPayload.input` mirrors the + * `PromptPart` subset of `ContentPart` (text / image_url / video_url) from + * `agent-core-v2/llm-adapter/contract/message.ts`. Task wire shapes mirror the + * `TaskInfo` union in `protocol/src/events.ts`. + */ + +import { z } from 'zod'; + +// ── prompt parts ──────────────────────────────────────────────────────────── + +const textPartSchema = z.object({ + type: z.literal('text'), + text: z.string(), +}); + +const imageUrlPartSchema = z.object({ + type: z.literal('image_url'), + imageUrl: z.object({ url: z.string(), id: z.string().optional() }), +}); + +const videoUrlPartSchema = z.object({ + type: z.literal('video_url'), + videoUrl: z.object({ url: z.string(), id: z.string().optional() }), +}); + +/** `PromptPart = Extract<ContentPart, { type: 'text' | 'image_url' | 'video_url' }>`. */ +export const promptPartSchema = z.discriminatedUnion('type', [ + textPartSchema, + imageUrlPartSchema, + videoUrlPartSchema, +]); + +// ── payloads / results ────────────────────────────────────────────────────── + +export const emptyPayloadSchema = z.object({}); + +export const promptPayloadSchema = z.object({ + input: z.array(promptPartSchema), + // Mirrors `PromptPayload.promptId` in the engine (client-chosen prompt + // record id, echoed on the consuming turn's `turn.started`). + promptId: z.string().min(1).optional(), +}); + +/** Same shape as `PromptSkillActivation` in the engine. */ +export const promptSkillActivationSchema = z.object({ + name: z.string(), + args: z.string().optional(), +}); + +/** Same shape as `PromptWithSkillsInput` in the engine. */ +export const promptWithSkillsPayloadSchema = promptPayloadSchema.extend({ + skills: z.array(promptSkillActivationSchema).min(1), +}); + +/** Same shape as `PromptWithSkillsResult` in the engine. */ +export const promptWithSkillsResultSchema = z.object({ + turn_id: z.number().optional(), + prompt_id: z.string(), + created_at: z.string(), + state: z.enum(['running', 'queued', 'blocked']), +}); + +/** Same shape as `SteerPayload` in the engine. */ +export const steerPayloadSchema = z.object({ + input: z.array(promptPartSchema), +}); + +/** Same shape as `SkillActivationInput`'s wire subset in the engine. */ +export const activateSkillPayloadSchema = z.object({ + name: z.string(), + args: z.string().optional(), +}); + +export const promptLaunchResultSchema = z.object({ + turn_id: z.number(), +}); + +export const cancelPayloadSchema = z.object({ + turnId: z.number().optional(), +}); + +export const runShellCommandPayloadSchema = z.object({ + command: z.string(), + commandId: z.string().optional(), +}); + +export const shellCommandResultSchema = z.object({ + stdout: z.string(), + stderr: z.string(), + isError: z.boolean().optional(), + backgrounded: z.boolean().optional(), +}); + +export const cancelShellCommandPayloadSchema = z.object({ + commandId: z.string(), +}); + +export const setModelPayloadSchema = z.object({ + model: z.string(), +}); + +export const setModelResultSchema = z.object({ + model: z.string(), + providerName: z.string().optional(), +}); + +export const runtimeBindingSchema = z.object({ + workspaceId: z.string(), + runtimeId: z.string(), +}); + +export const permissionModeSchema = z.enum(['manual', 'yolo', 'auto']); + +export const setPermissionPayloadSchema = z.object({ + mode: permissionModeSchema, +}); + +export const tokenUsageSchema = z.object({ + inputOther: z.number(), + output: z.number(), + inputCacheRead: z.number(), + inputCacheCreation: z.number(), +}); + +export const usageStatusSchema = z.object({ + byModel: z.record(z.string(), tokenUsageSchema).optional(), + currentTurn: tokenUsageSchema.optional(), + total: tokenUsageSchema.optional(), +}); + +/** + * `AgentContextData` — `history` items are full `ContextMessage`s (deep + * `Message` / `Tool` / `PromptOrigin` unions); mirrored as `unknown` entries. + */ +export const agentContextDataSchema = z.object({ + history: z.array(z.unknown()), + tokenCount: z.number(), +}); + +/** `AgentCommandInfo` (`agent-core-v2/agent/command/agentCommand.ts`). */ +export const agentCommandInfoSchema = z.object({ + name: z.string(), + description: z.string().optional(), + source: z.string(), +}); + +/** The facade's `runCommand` input shape. */ +export const runCommandPayloadSchema = z.object({ + name: z.string(), + args: z.string().optional(), +}); + +/** `PlanData = null | { id, content, path }` — null is JSON-representable. */ +export const planDataSchema = z.union([ + z.null(), + z.object({ + id: z.string(), + content: z.string(), + path: z.string(), + }), +]); + +export const cancelPlanPayloadSchema = z.object({ + id: z.string().optional(), +}); + +export const getTasksPayloadSchema = z.object({ + activeOnly: z.boolean().optional(), + limit: z.number().optional(), +}); + +const taskLifecycleStatusSchema = z.enum([ + 'running', + 'completed', + 'failed', + 'timed_out', + 'killed', + 'lost', +]); + +const taskInfoBaseFields = { + taskId: z.string(), + description: z.string(), + status: taskLifecycleStatusSchema, + detached: z.boolean().optional(), + startedAt: z.number(), + endedAt: z.union([z.number(), z.null()]), + stopReason: z.string().optional(), + terminalNotificationSuppressed: z.boolean().optional(), + timeoutMs: z.number().optional(), +} as const; + +/** Protocol `TaskInfo` union (`protocol/src/events.ts`). */ +export const agentTaskInfoSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('process'), + command: z.string(), + pid: z.number(), + exitCode: z.union([z.number(), z.null()]), + ...taskInfoBaseFields, + }), + z.object({ + kind: z.literal('agent'), + agentId: z.string().optional(), + subagentType: z.string().optional(), + model: z.string().optional(), + thinkingEffort: z.string().optional(), + ...taskInfoBaseFields, + }), + z.object({ + kind: z.literal('question'), + questionCount: z.number(), + toolCallId: z.string().optional(), + ...taskInfoBaseFields, + }), +]); + +export const stopTaskPayloadSchema = z.object({ + taskId: z.string(), + reason: z.string().optional(), +}); + +export const getTaskOutputPayloadSchema = z.object({ + taskId: z.string(), + tail: z.number().optional(), +}); diff --git a/packages/klient/src/contract/agent/services.ts b/packages/klient/src/contract/agent/services.ts new file mode 100644 index 0000000000000000000000000000000000000000..54755a7ab54542bd02a5b39220fbfc822b9aa969 --- /dev/null +++ b/packages/klient/src/contract/agent/services.ts @@ -0,0 +1,141 @@ +/** + * Agent-scope domain service contracts. These mirror the signatures of the + * engine's domain Services (prompt / skill / loop / permissionMode / command / + * contextMemory / tokenCounting / shellCommand / profile / usage / plan / + * task) that the agent facade calls directly; payload and result schemas are + * shared in `agent/schemas.ts` (they mirror the same wire shapes). + */ + +import { z } from 'zod'; + +import { maybe, noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; +import { + activateSkillPayloadSchema, + agentCommandInfoSchema, + agentTaskInfoSchema, + permissionModeSchema, + planDataSchema, + promptLaunchResultSchema, + promptPayloadSchema, + promptWithSkillsPayloadSchema, + promptWithSkillsResultSchema, + runShellCommandPayloadSchema, + runtimeBindingSchema, + setModelResultSchema, + shellCommandResultSchema, + steerPayloadSchema, + usageStatusSchema, +} from './schemas.js'; + +export const agentPromptContract = { + submit: { + input: z.tuple([promptPayloadSchema]), + output: maybe(promptLaunchResultSchema), + }, + submitSteer: { + input: z.tuple([steerPayloadSchema]), + output: maybe(promptLaunchResultSchema), + }, +} satisfies ServiceContract; + +export const agentSkillContract = { + activate: { input: z.tuple([activateSkillPayloadSchema]), output: promptLaunchResultSchema }, + promptWithSkills: { + input: z.tuple([promptWithSkillsPayloadSchema]), + output: promptWithSkillsResultSchema, + }, +} satisfies ServiceContract; + +export const agentLoopContract = { + cancelFromUser: { input: z.tuple([z.number().optional()]), output: noResult }, +} satisfies ServiceContract; + +export const agentPermissionModeContract = { + setModeAndBroadcast: { input: z.tuple([permissionModeSchema]), output: noResult }, +} satisfies ServiceContract; + +export const agentCommandContract = { + list: { input: z.tuple([]), output: z.array(agentCommandInfoSchema) }, + run: { input: z.tuple([z.string(), z.string().optional()]), output: noResult }, +} satisfies ServiceContract; + +export const agentRuntimeBindingContract = { + get: { input: z.tuple([]), output: runtimeBindingSchema }, + set: { input: z.tuple([runtimeBindingSchema]), output: runtimeBindingSchema }, + switch: { input: z.tuple([z.string()]), output: runtimeBindingSchema }, +} satisfies ServiceContract; + +/** `history` items are full `ContextMessage`s, mirrored as `unknown`. */ +export const agentContextMemoryContract = { + get: { input: z.tuple([]), output: z.array(z.unknown()) }, +} satisfies ServiceContract; + +export const agentTokenCountingContract = { + statusSize: { input: z.tuple([]), output: z.number() }, +} satisfies ServiceContract; + +export const agentShellCommandContract = { + run: { + input: z.tuple([runShellCommandPayloadSchema]), + output: shellCommandResultSchema, + }, + cancel: { input: z.tuple([z.string()]), output: noResult }, +} satisfies ServiceContract; + +export const agentProfileContract = { + getModel: { input: z.tuple([]), output: z.string() }, + setModel: { input: z.tuple([z.string()]), output: setModelResultSchema }, + setThinking: { input: z.tuple([z.string()]), output: noResult }, + getEffectiveThinkingLevel: { input: z.tuple([]), output: z.string() }, +} satisfies ServiceContract; + +export const agentUsageContract = { + status: { input: z.tuple([]), output: usageStatusSchema }, +} satisfies ServiceContract; + +export const agentPlanContract = { + status: { input: z.tuple([]), output: planDataSchema }, + enter: { input: z.tuple([]), output: noResult }, + clear: { input: z.tuple([]), output: noResult }, + cancel: { input: z.tuple([z.string().optional()]), output: noResult }, +} satisfies ServiceContract; + +/** `McpServerEntry` from the engine's `mcpCore/connection-manager`. */ +export const mcpServerEntrySchema = z.object({ + name: z.string(), + transport: z.enum(['stdio', 'http', 'sse']), + status: z.enum(['pending', 'connected', 'failed', 'disabled', 'needs-auth', 'removed']), + toolCount: z.number(), + error: z.string().optional(), +}); + +export const agentMcpContract = { + list: { input: z.tuple([]), output: z.array(mcpServerEntrySchema) }, +} satisfies ServiceContract; + +/** `FullCompactionInput` from the engine's `agent/fullCompaction`. */ +export const fullCompactionInputSchema = z.object({ + source: z.enum(['manual', 'auto']), + instruction: z.string().optional(), +}); + +export const agentFullCompactionContract = { + begin: { input: z.tuple([fullCompactionInputSchema]), output: z.boolean() }, +} satisfies ServiceContract; + +export const agentTaskContract = { + list: { + input: z.tuple([z.boolean().optional(), z.number().optional()]), + output: z.array(agentTaskInfoSchema), + }, + stopByUser: { input: z.tuple([z.string()]), output: maybe(agentTaskInfoSchema) }, + stop: { + input: z.tuple([z.string(), z.string().optional()]), + output: maybe(agentTaskInfoSchema), + }, + readOutput: { + input: z.tuple([z.string(), z.number().optional()]), + output: z.string(), + }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/auth.ts b/packages/klient/src/contract/global/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..acafd2f9c91f876cfc85e7fbc784399984effeed --- /dev/null +++ b/packages/klient/src/contract/global/auth.ts @@ -0,0 +1,111 @@ +/** + * `oauthService` + `authSummaryService` — app-scope OAuth flow and auth + * summary. Mirrors `agent-core-v2/app/auth/auth.ts`; wire shapes mirror + * `protocol/src/rest/oauth.ts` (snake_case fields). `resolveTokenProvider` + * and `getCachedAccessToken` are excluded (non-serializable). + */ + +import { z } from 'zod'; + +import { maybe, noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const oAuthFlowStatusSchema = z.enum([ + 'pending', + 'authenticated', + 'denied', + 'expired', + 'cancelled', +]); + +export const oAuthFlowStartSchema = z.discriminatedUnion('status', [ + z.object({ + flow_id: z.string(), + provider: z.string(), + status: z.literal('pending'), + verification_uri: z.string(), + verification_uri_complete: z.string(), + user_code: z.string(), + expires_in: z.number(), + interval: z.number(), + expires_at: z.string(), + }), + z.object({ + flow_id: z.string(), + provider: z.string(), + status: z.literal('authenticated'), + }), +]); + +export const oAuthFlowSnapshotSchema = z.object({ + flow_id: z.string(), + provider: z.string(), + status: oAuthFlowStatusSchema, + verification_uri: z.string(), + verification_uri_complete: z.string(), + user_code: z.string(), + expires_in: z.number(), + expires_at: z.string(), + interval: z.number(), + resolved_at: z.string().optional(), + error_message: z.string().optional(), +}); + +export const oAuthLoginCancelResponseSchema = z.object({ + cancelled: z.boolean(), + status: oAuthFlowStatusSchema, +}); + +export const oAuthLogoutResponseSchema = z.object({ + logged_out: z.literal(true), + provider: z.string(), +}); + +export const authStatusSchema = z.object({ + loggedIn: z.boolean(), + provider: z.string().optional(), +}); + +/** Same shape as `refreshProviderModelsResponseSchema` in `./catalog.js` — keep in sync. */ +export const refreshOAuthProviderModelsResponseSchema = z.object({ + changed: z.array( + z.object({ + provider_id: z.string(), + provider_name: z.string(), + added: z.number(), + removed: z.number(), + }), + ), + unchanged: z.array(z.string()), + failed: z.array(z.object({ provider: z.string(), reason: z.string() })), +}); + +export const oAuthLoginOptionsSchema = z.object({ + region: z.enum(['mainland-cn', 'global']).optional(), +}); + +export const authContract = { + startLogin: { + input: z.tuple([z.string().optional(), oAuthLoginOptionsSchema.optional()]), + output: oAuthFlowStartSchema, + }, + getFlow: { + input: z.tuple([z.string().optional()]), + output: maybe(oAuthFlowSnapshotSchema), + }, + cancelLogin: { + input: z.tuple([z.string().optional()]), + output: oAuthLoginCancelResponseSchema, + }, + logout: { input: z.tuple([z.string().optional()]), output: oAuthLogoutResponseSchema }, + status: { input: z.tuple([z.string().optional()]), output: authStatusSchema }, + refreshOAuthProviderModels: { + input: z.tuple([]), + output: refreshOAuthProviderModelsResponseSchema, + }, +} satisfies ServiceContract; + +export const authSummaryContract = { + summarize: { input: z.tuple([]), output: z.array(authStatusSchema) }, + ensureReady: { input: z.tuple([z.string().optional()]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/capabilities.ts b/packages/klient/src/contract/global/capabilities.ts new file mode 100644 index 0000000000000000000000000000000000000000..c34efd5c6db1a51e4cc762a3b4ddc8883b9d469d --- /dev/null +++ b/packages/klient/src/contract/global/capabilities.ts @@ -0,0 +1,41 @@ +/** + * `capabilityService` — built-in product capability readiness and install + * orchestration. Mirrors `agent-core-v2/app/capability/types.ts`. + */ + +import { z } from 'zod'; + +import type { ServiceContract } from '../types.js'; + +export const capabilityStepSchema = z.object({ + id: z.string(), + state: z.enum(['ok', 'missing', 'failed']), + detail: z.string().optional(), + optional: z.boolean().optional(), +}); + +export const capabilityInstallProgressSchema = z.object({ + running: z.boolean(), + step: z.string().optional(), + percent: z.number().optional(), + error: z.string().optional(), + note: z.string().optional(), +}); + +export const capabilityStatusSchema = z.object({ + id: z.enum(['kimi-cu', 'kimi-webbridge']), + pluginId: z.string().optional(), + displayName: z.string(), + description: z.string(), + supported: z.boolean(), + state: z.enum(['not_installed', 'partial', 'ready', 'unsupported']), + version: z.string().optional(), + steps: z.array(capabilityStepSchema), + install: capabilityInstallProgressSchema, +}); + +export const capabilitiesContract = { + listCapabilities: { input: z.tuple([]), output: z.array(capabilityStatusSchema) }, + getCapability: { input: z.tuple([z.string()]), output: capabilityStatusSchema }, + installCapability: { input: z.tuple([z.string()]), output: capabilityStatusSchema }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/catalog.ts b/packages/klient/src/contract/global/catalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..96350fa8dcf1fb29de65dc567b97e04c32644f69 --- /dev/null +++ b/packages/klient/src/contract/global/catalog.ts @@ -0,0 +1,70 @@ +/** + * `modelResolver` — the engine's `IModelCatalog`: materialized model lookup + * plus the read-only catalog enumeration over configured providers and model + * aliases, and the global default-model selection. Mirrors + * `agent-core-v2/llm-adapter/model/catalog.ts`; wire shapes mirror + * `protocol/src/modelCatalog.ts` and `protocol/src/rest/modelCatalog.ts` + * (snake_case fields). + */ + +import { z } from 'zod'; + +import type { ServiceContract, StreamingProcedureContract } from '../types.js'; + +export const modelCatalogItemSchema = z.object({ + provider: z.string(), + model: z.string(), + display_name: z.string().optional(), + max_context_size: z.number(), + capabilities: z.array(z.string()).optional(), + support_efforts: z.array(z.string()).optional(), + default_effort: z.string().optional(), +}); + +export const providerCatalogStatusSchema = z.enum(['connected', 'error', 'unconfigured']); + +export const providerCatalogItemSchema = z.object({ + id: z.string(), + type: z.string(), + base_url: z.string().optional(), + default_model: z.string().optional(), + has_api_key: z.boolean(), + status: providerCatalogStatusSchema, + models: z.array(z.string()).optional(), +}); + +export const setDefaultModelResponseSchema = z.object({ + default_model: z.string(), + model: modelCatalogItemSchema, +}); + +const generateInputSchema = z.object({ + systemPrompt: z.string(), + messages: z.array(z.unknown()), + tools: z.array(z.unknown()).optional(), + responseFormat: z.unknown().optional(), +}); + +const generateParamsSchema = z.object({ + cacheKey: z.string().optional(), + temperature: z.number().optional(), + topP: z.number().optional(), + thinkingEffort: z.string().optional(), + maxCompletionTokens: z.number().optional(), +}).optional(); + +const generateEventSchema = z.object({ + type: z.string(), +}).passthrough(); + +export const catalogContract = { + listModels: { input: z.tuple([]), output: z.array(modelCatalogItemSchema) }, + listProviders: { input: z.tuple([]), output: z.array(providerCatalogItemSchema) }, + getProvider: { input: z.tuple([z.string()]), output: providerCatalogItemSchema }, + setDefaultModel: { input: z.tuple([z.string()]), output: setDefaultModelResponseSchema }, + generate: { + input: z.tuple([z.string(), generateInputSchema, generateParamsSchema]), + chunk: generateEventSchema, + streaming: true, + } as StreamingProcedureContract, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/config.ts b/packages/klient/src/contract/global/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..57d3f5fca8647212d2b11bb4e98898af60d4ed83 --- /dev/null +++ b/packages/klient/src/contract/global/config.ts @@ -0,0 +1,44 @@ +/** + * `configService` — layered global config service. Mirrors + * `agent-core-v2/app/config/config.ts`. + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const configTargetSchema = z.enum(['user', 'memory']); + +export const configInspectValueSchema = z.object({ + value: z.unknown().optional(), + defaultValue: z.unknown().optional(), + userValue: z.unknown().optional(), + memoryValue: z.unknown().optional(), +}); + +export const configDiagnosticSchema = z.object({ + domain: z.string().optional(), + severity: z.enum(['warning', 'error']), + message: z.string(), +}); + +export const configContract = { + get: { input: z.tuple([z.string()]), output: z.unknown() }, + inspect: { input: z.tuple([z.string()]), output: configInspectValueSchema }, + getAll: { input: z.tuple([]), output: z.record(z.string(), z.unknown()) }, + set: { + input: z.tuple([z.string(), z.unknown(), configTargetSchema.optional()]), + output: noResult, + }, + replace: { + input: z.tuple([z.string(), z.unknown(), configTargetSchema.optional()]), + output: noResult, + }, + replaceSections: { + input: z.tuple([z.record(z.string(), z.unknown()), configTargetSchema.optional()]), + output: noResult, + }, + reload: { input: z.tuple([]), output: noResult }, + diagnostics: { input: z.tuple([]), output: z.array(configDiagnosticSchema) }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/env.ts b/packages/klient/src/contract/global/env.ts new file mode 100644 index 0000000000000000000000000000000000000000..28a62c07e8b4d0ba3d98043857a5db3c013ef8ee --- /dev/null +++ b/packages/klient/src/contract/global/env.ts @@ -0,0 +1,38 @@ +/** + * `bootstrapService` — frozen startup snapshot: host facts and app path + * layout. Mirrors `agent-core-v2/app/bootstrap/bootstrap.ts`. The string + * properties are exposed as zero-arg reads; `clientIdentity` returns the + * host identity object (which replaced the flat `clientVersion` scalar). + */ + +import { z } from 'zod'; + +import type { ServiceContract } from '../types.js'; + +const stringRead = { input: z.tuple([]), output: z.string() }; + +const clientIdentityRead = { + input: z.tuple([]), + output: z.object({ + productName: z.string(), + version: z.string(), + platform: z.string(), + userAgentSuffix: z.string().optional(), + }), +}; + +export const envContract = { + platform: stringRead, + arch: stringRead, + cwd: stringRead, + osHomeDir: stringRead, + homeDir: stringRead, + configPath: stringRead, + clientVersion: stringRead, + clientIdentity: clientIdentityRead, + sessionsDir: stringRead, + blobsDir: stringRead, + storeDir: stringRead, + cacheDir: stringRead, + logsDir: stringRead, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/events.ts b/packages/klient/src/contract/global/events.ts new file mode 100644 index 0000000000000000000000000000000000000000..b293154c85bfac0541070841b1de68c116389ed6 --- /dev/null +++ b/packages/klient/src/contract/global/events.ts @@ -0,0 +1,140 @@ +/** + * Klient-level global events — the public, typed, namespaced event surface. + * Each registration binds a public event name to its underlying source (the + * `IEventService` bus or one service's `onDid*` emitter) plus the zod schema + * its payload must satisfy. Consumers never see the engine's `onDid`/`onWill` + * naming; unknown bus event types are not forwarded. + */ + +import { z } from 'zod'; + +import type { ConfigChangedEvent } from '@moonshot-ai/agent-core-v2/app/config/config'; +import type { ModelsChangedEvent } from '@moonshot-ai/agent-core-v2/llm-adapter/model/model'; +import type { ProvidersChangedEvent } from '@moonshot-ai/agent-core-v2/llm-adapter/provider/provider'; +import type { ReloadSummary } from '@moonshot-ai/agent-core-v2/app/plugin/types'; +import type { IOAuthService } from '@moonshot-ai/agent-core-v2/app/auth/auth'; + +import { stringDeltaSchema } from '../helpers.js'; +import type { EventRegistration } from '../types.js'; + +/** Payload of `event.session.archived` on the global bus. */ +export interface SessionArchivedPayload { + readonly sessionId: string; +} + +/** Payload of `session.meta.updated` on the global bus (`session/sessionMetadata/promptMetadata.ts`). */ +export interface SessionMetaUpdatedPayload { + readonly agentId: string; + readonly sessionId: string; + readonly title?: string; + readonly patch: { + readonly title?: string; + readonly isCustomTitle?: boolean; + readonly lastPrompt?: string; + }; +} + +/** Payload of `event.model_catalog.changed` — same shape as an OAuth refresh result. */ +export type CatalogChangedPayload = Awaited< + ReturnType<IOAuthService['refreshOAuthProviderModels']> +>; + +/** Public event name → payload type. Keys must stay in sync with `globalEvents`. */ +export interface KlientEventPayloads { + 'config.changed': ConfigChangedEvent; + 'config.sectionChanged': ConfigChangedEvent; + 'kosong.providers.changed': ProvidersChangedEvent; + 'kosong.models.changed': ModelsChangedEvent; + 'plugins.reloaded': ReloadSummary; + 'session.archived': SessionArchivedPayload; + 'session.metaUpdated': SessionMetaUpdatedPayload; + 'kosong.changed': CatalogChangedPayload; +} + +export type KlientEventName = keyof KlientEventPayloads; + +const configChangedSchema = z.object({ + domain: z.string(), + source: z.enum(['load', 'reload', 'set']), + value: z.unknown(), + previousValue: z.unknown(), +}); + +const reloadSummarySchema = z.object({ + added: z.array(z.string()), + removed: z.array(z.string()), + errors: z.array(z.object({ id: z.string(), message: z.string() })), +}); + +const sessionMetaUpdatedSchema = z.object({ + agentId: z.string(), + sessionId: z.string(), + title: z.string().optional(), + patch: z.object({ + title: z.string().optional(), + isCustomTitle: z.boolean().optional(), + lastPrompt: z.string().optional(), + }), +}) satisfies z.ZodType<SessionMetaUpdatedPayload>; + +export const catalogChangedSchema = z.object({ + changed: z.array( + z.object({ + provider_id: z.string(), + provider_name: z.string(), + added: z.number(), + removed: z.number(), + }), + ), + unchanged: z.array(z.string()), + failed: z.array(z.object({ provider: z.string(), reason: z.string() })), +}); + +/** Public event name → source binding + payload schema. */ +export const globalEvents = { + 'config.changed': { + kind: 'emitter', + service: 'configService', + event: 'onDidChangeConfiguration', + schema: configChangedSchema, + }, + 'config.sectionChanged': { + kind: 'emitter', + service: 'configService', + event: 'onDidSectionChange', + schema: configChangedSchema, + }, + 'kosong.providers.changed': { + kind: 'emitter', + service: 'providerService', + event: 'onDidChangeProviders', + schema: stringDeltaSchema, + }, + 'kosong.models.changed': { + kind: 'emitter', + service: 'modelService', + event: 'onDidChangeModels', + schema: stringDeltaSchema, + }, + 'plugins.reloaded': { + kind: 'emitter', + service: 'pluginService', + event: 'onDidReload', + schema: reloadSummarySchema, + }, + 'session.archived': { + kind: 'bus', + type: 'event.session.archived', + schema: z.object({ sessionId: z.string() }), + }, + 'session.metaUpdated': { + kind: 'bus', + type: 'session.meta.updated', + schema: sessionMetaUpdatedSchema, + }, + 'kosong.changed': { + kind: 'bus', + type: 'event.model_catalog.changed', + schema: catalogChangedSchema, + }, +} satisfies Record<KlientEventName, EventRegistration>; diff --git a/packages/klient/src/contract/global/files.ts b/packages/klient/src/contract/global/files.ts new file mode 100644 index 0000000000000000000000000000000000000000..74644dbe87fe987187a5ab2976a1ff4615ba9317 --- /dev/null +++ b/packages/klient/src/contract/global/files.ts @@ -0,0 +1,46 @@ +/** + * `fileService` — process-global upload store. Mirrors + * `agent-core-v2/app/file/fileService.ts`. + * + * The wire cannot carry the engine's streams (every value JSON round-trips), + * so bytes cross base64-encoded: `save`'s `Readable` source becomes a base64 + * string as the first argument, and `get`'s result stream is buffered + * server-side and returned as `{ meta, data }` with `data` base64-encoded. + * The dispatcher performs the stream ⇄ base64 adaptation; the facade encodes + * and decodes the caller's `Uint8Array`s. + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +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: z.string(), + expires_at: z.string().optional(), +}); + +export const fileSaveOptionsSchema = z.object({ + name: z.string().optional(), + mimeType: z.string().optional(), + expiresInSec: z.number().optional(), +}); + +/** Wire result of `get` — the engine's `GetResult.stream` buffered to base64. */ +export const fileGetResultSchema = z.object({ + meta: fileMetaSchema, + data: z.string(), +}); + +export const filesContract = { + save: { + input: z.tuple([z.string(), z.string().min(1), fileSaveOptionsSchema]), + output: fileMetaSchema, + }, + get: { input: z.tuple([z.string().min(1)]), output: fileGetResultSchema }, + delete: { input: z.tuple([z.string().min(1)]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/flags.ts b/packages/klient/src/contract/global/flags.ts new file mode 100644 index 0000000000000000000000000000000000000000..d842fd5e7caacd4d186bd086a8422597361f41a2 --- /dev/null +++ b/packages/klient/src/contract/global/flags.ts @@ -0,0 +1,30 @@ +/** + * `flagService` — experimental-flag resolution. Mirrors + * `agent-core-v2/app/flag/flag.ts`. The `registry` property and + * `setConfigOverrides` are excluded (not part of the read-only wire surface). + */ + +import { z } from 'zod'; + +import { maybe } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const experimentalFeatureStateSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string(), + surface: z.enum(['core', 'tui', 'both']), + env: z.string(), + defaultEnabled: z.boolean(), + enabled: z.boolean(), + source: z.enum(['master-env', 'env', 'config', 'default']), + configValue: z.boolean().optional(), +}); + +export const flagsContract = { + enabled: { input: z.tuple([z.string()]), output: z.boolean() }, + snapshot: { input: z.tuple([]), output: z.record(z.string(), z.boolean()) }, + enabledIds: { input: z.tuple([]), output: z.array(z.string()) }, + explain: { input: z.tuple([z.string()]), output: maybe(experimentalFeatureStateSchema) }, + explainAll: { input: z.tuple([]), output: z.array(experimentalFeatureStateSchema) }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/hostFs.ts b/packages/klient/src/contract/global/hostFs.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b19146d895acbd8879d3e49afa44dfa1769f314 --- /dev/null +++ b/packages/klient/src/contract/global/hostFs.ts @@ -0,0 +1,31 @@ +/** + * `hostFolderBrowser` — host-side folder picker for choosing a workspace + * folder. Mirrors `agent-core-v2/app/hostFolderBrowser/hostFolderBrowser.ts`; + * wire shapes mirror `protocol/src/rest/fsBrowse.ts` (snake_case fields). + */ + +import { z } from 'zod'; + +import type { ServiceContract } from '../types.js'; + +export const fsBrowseEntrySchema = z.object({ + name: z.string().min(1), + path: z.string().min(1), + is_dir: z.literal(true), +}); + +export const fsBrowseResponseSchema = z.object({ + path: z.string().min(1), + parent: z.string().min(1).nullable(), + entries: z.array(fsBrowseEntrySchema), +}); + +export const fsHomeResponseSchema = z.object({ + home: z.string().min(1), + recent_roots: z.array(z.string().min(1)), +}); + +export const hostFsContract = { + browse: { input: z.tuple([z.string().optional()]), output: fsBrowseResponseSchema }, + home: { input: z.tuple([]), output: fsHomeResponseSchema }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/mcpManagement.ts b/packages/klient/src/contract/global/mcpManagement.ts new file mode 100644 index 0000000000000000000000000000000000000000..0279df3e2e5b9e6a504e85612b352b53a33d63df --- /dev/null +++ b/packages/klient/src/contract/global/mcpManagement.ts @@ -0,0 +1,189 @@ +/** + * `mcpManagementService` — the unified MCP management plane. Mirrors + * `agent-core-v2/app/mcpManagement/mcpManagement.ts`; `McpServerSource` / + * `McpRegistryPluginOrigin` / `McpRegistryQuery` mirror + * `agent-core-v2/app/mcpRegistry/mcpRegistry.ts`, and the redacted config + * shape mirrors `agent-core-v2/mcpCore/configView.ts`. + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import { + mcpServerHttpConfigSchema, + mcpServerSseConfigSchema, + mcpServerStdioConfigSchema, +} from '../mcp.js'; +import type { ServiceContract } from '../types.js'; + +export const mcpServerSourceSchema = z.enum(['global', 'plugin', 'caller']); + +export const mcpRegistryPluginOriginSchema = z.object({ + id: z.string(), + /** Manifest-local server name (without the runtime prefix). */ + name: z.string(), +}); + +export const mcpRegistryQuerySchema = z.object({ + cwd: z.string().min(1).optional(), +}); + +export const mcpAuthStatusQuerySchema = z.object({ + cwd: z.string().min(1).optional(), + verify: z.boolean().optional(), +}); + +/** `GlobalMcpServerConfig` — a named full config (add/update, inline test target). */ +export const globalMcpServerConfigSchema = z.discriminatedUnion('transport', [ + mcpServerStdioConfigSchema.extend({ name: z.string().min(1) }), + mcpServerHttpConfigSchema.extend({ name: z.string().min(1) }), + mcpServerSseConfigSchema.extend({ name: z.string().min(1) }), +]); + +/** + * The wire config of a managed/inspected server: mutable entries carry the + * full config (edit UIs prefill from it); read-only entries are redacted — + * `env` / `headers` values are replaced by the sorted key lists `envKeys` / + * `headerKeys`. One schema covers both shapes, mirroring the engine's + * `McpServerConfig | McpServerConfigView` union. + */ +export const mcpServerConfigDataSchema = z.discriminatedUnion('transport', [ + mcpServerStdioConfigSchema.extend({ envKeys: z.array(z.string()).optional() }), + mcpServerHttpConfigSchema.extend({ headerKeys: z.array(z.string()).optional() }), + mcpServerSseConfigSchema.extend({ headerKeys: z.array(z.string()).optional() }), +]); + +export const mcpManagedServerSchema = z.object({ + name: z.string(), + config: mcpServerConfigDataSchema, + source: mcpServerSourceSchema, + origin: z.string(), + mutable: z.boolean(), + plugin: mcpRegistryPluginOriginSchema.optional(), +}); + +export const mcpServerTestTargetSchema = z.object({ + name: z.string().min(1).optional(), + server: globalMcpServerConfigSchema.optional(), + cwd: z.string().min(1).optional(), +}); + +export const mcpServerTestResultSchema = z.object({ + success: z.boolean(), + output: z.string(), +}); + +export const mcpServerLocatorSchema = z.discriminatedUnion('source', [ + z.object({ source: z.literal('global'), name: z.string().min(1) }), + z.object({ + source: z.literal('plugin'), + pluginId: z.string().min(1), + serverName: z.string().min(1), + }), +]); + +export const mcpServerAuthStateSchema = z.enum([ + 'not-applicable', + 'bearer-token', + 'oauth-required', + 'oauth-authorized', + 'oauth-expired', + 'unavailable', +]); + +export const mcpServerDescriptorSchema = z.object({ + /** `global:<name>` / `plugin:<pluginId>:<serverName>`, URL-encoded. */ + serverId: z.string(), + locator: mcpServerLocatorSchema, + runtimeName: z.string(), + canonicalUrl: z.string().optional(), + origin: mcpServerSourceSchema, + config: mcpServerConfigDataSchema, + enabled: z.boolean(), + editable: z.boolean(), +}); + +export const mcpServerInspectionSchema = mcpServerDescriptorSchema.extend({ + authStatus: mcpServerAuthStateSchema, + checkedAt: z.number().optional(), + error: z.string().optional(), +}); + +export const mcpServerAuthStatusSchema = z.object({ + name: z.string(), + authStatus: mcpServerAuthStateSchema, +}); + +export const mcpServerAuthBeginResultSchema = z.discriminatedUnion('status', [ + z.object({ + status: z.literal('authorization-required'), + flowId: z.string(), + authorizationUrl: z.string(), + }), + z.object({ status: z.literal('already-authorized') }), +]); + +export const mcpServerAuthFlowHandleSchema = z.object({ + flowId: z.string().min(1), + // Node overflows setTimeout delays above 2^31-1 into ~1ms; the REST schema + // and the engine reject the same range. + timeoutMs: z.number().int().min(1).max(2 ** 31 - 1).optional(), +}); + +export const mcpManagementContract = { + listServers: { + input: z.tuple([mcpRegistryQuerySchema.optional()]), + output: z.array(mcpManagedServerSchema), + }, + getServer: { + input: z.tuple([z.string().min(1), mcpRegistryQuerySchema.optional()]), + output: mcpManagedServerSchema, + }, + addServer: { + input: z.tuple([globalMcpServerConfigSchema, mcpRegistryQuerySchema.optional()]), + output: z.array(mcpManagedServerSchema), + }, + updateServer: { + input: z.tuple([globalMcpServerConfigSchema, mcpRegistryQuerySchema.optional()]), + output: z.array(mcpManagedServerSchema), + }, + removeServer: { + input: z.tuple([z.string().min(1), mcpRegistryQuerySchema.optional()]), + output: z.array(mcpManagedServerSchema), + }, + testServer: { + input: z.tuple([mcpServerTestTargetSchema]), + output: mcpServerTestResultSchema, + }, + listAuthStatuses: { + input: z.tuple([mcpAuthStatusQuerySchema.optional()]), + output: z.array(mcpServerAuthStatusSchema), + }, + inspectServers: { + input: z.tuple([ + z.array(mcpServerLocatorSchema).optional(), + mcpRegistryQuerySchema.optional(), + ]), + output: z.array(mcpServerInspectionSchema), + }, + resolveServerByName: { + input: z.tuple([z.string().min(1), mcpRegistryQuerySchema.optional()]), + output: mcpServerLocatorSchema, + }, + beginServerAuth: { + input: z.tuple([mcpServerLocatorSchema, mcpRegistryQuerySchema.optional()]), + output: mcpServerAuthBeginResultSchema, + }, + completeServerAuth: { + input: z.tuple([mcpServerAuthFlowHandleSchema]), + output: noResult, + }, + cancelServerAuth: { + input: z.tuple([z.object({ flowId: z.string().min(1) })]), + output: noResult, + }, + resetServerAuth: { + input: z.tuple([mcpServerLocatorSchema, mcpRegistryQuerySchema.optional()]), + output: noResult, + }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/models.ts b/packages/klient/src/contract/global/models.ts new file mode 100644 index 0000000000000000000000000000000000000000..97ea1716530e9fa8193f5dbea81075410d88fb1e --- /dev/null +++ b/packages/klient/src/contract/global/models.ts @@ -0,0 +1,78 @@ +/** + * `modelService` — model configuration registry. Mirrors + * `agent-core-v2/llm-adapter/model/model.ts` (`ModelRecordSchema`, including its + * passthrough of unknown keys) and `agent-core-v2/llm-adapter/protocol/protocol.ts` + * (`ProtocolSchema` — the four real wire protocols; `kimi` is a provider + * `type`, not a protocol, and Vertex AI is a `providerOptions` mode of + * `google-genai`). The schema name stays `modelConfigSchema` while the + * engine type is `ModelRecord` — the contract-parity test pins that pairing. + */ + +import { z } from 'zod'; + +import { maybe, noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +const protocolSchema = z.enum([ + 'anthropic', + 'openai', + 'openai_responses', + 'google-genai', +]); + +const oAuthRefSchema = z.object({ + storage: z.enum(['file', 'keyring']), + key: z.string().min(1), + oauthHost: z.string().min(1).optional(), +}); + +const modelBaseSchema = z.object({ + providerId: z.string().optional(), + + baseUrl: z.string().optional(), + apiKey: z.string().optional(), + oauth: oAuthRefSchema.optional(), + + protocol: protocolSchema.optional(), + + name: z.string().optional(), + aliases: z.array(z.string()).optional(), + + provider: z.string().optional(), + model: z.string().optional(), + maxContextSize: z.number().int().min(1).optional(), + maxOutputSize: z.number().int().min(1).optional(), + capabilities: z.array(z.string()).optional(), + displayName: z.string().optional(), + reasoningKey: z.string().optional(), + adaptiveThinking: z.boolean().optional(), + betaApi: z.boolean().optional(), + supportEfforts: z.array(z.string()).optional(), + defaultEffort: z.string().optional(), +}); + +const modelOverrideSchema = modelBaseSchema + .omit({ + providerId: true, + baseUrl: true, + apiKey: true, + oauth: true, + protocol: true, + name: true, + aliases: true, + provider: true, + model: true, + betaApi: true, + }) + .partial(); + +export const modelConfigSchema = modelBaseSchema + .extend({ overrides: modelOverrideSchema.optional() }) + .passthrough(); + +export const modelsContract = { + get: { input: z.tuple([z.string()]), output: maybe(modelConfigSchema) }, + list: { input: z.tuple([]), output: z.record(z.string(), modelConfigSchema) }, + set: { input: z.tuple([z.string(), modelConfigSchema]), output: noResult }, + delete: { input: z.tuple([z.string()]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/plugins.ts b/packages/klient/src/contract/global/plugins.ts new file mode 100644 index 0000000000000000000000000000000000000000..604707362645709659c59d6041c99188da94d3fd --- /dev/null +++ b/packages/klient/src/contract/global/plugins.ts @@ -0,0 +1,211 @@ +/** + * `pluginService` — plugin management and consumption. Mirrors + * `agent-core-v2/app/plugin/plugin.ts` and `agent-core-v2/app/plugin/types.ts`; + * nested `McpServerConfig` mirrors `agent-core-v2/mcpCore/config-schema.ts`, + * `HookDefConfig` mirrors `agent-core-v2/features/externalHooks/configSection.ts`. + * `pluginSkillRoots`, `enabledSessionStarts`, `enabledSystemPrompts`, + * `enabledMcpServers`, and `enabledHooks` are excluded (not part of the + * klient wire surface). + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import { mcpServerConfigSchema } from '../mcp.js'; +import type { ServiceContract } from '../types.js'; + +export const pluginDiagnosticSchema = z.object({ + severity: z.enum(['error', 'warn', 'info']), + message: z.string(), +}); + +const pluginAuthorSchema = z.object({ + name: z.string().optional(), + email: z.string().optional(), +}); + +const pluginSessionStartSchema = z.object({ + skill: z.string(), +}); + +const pluginInterfaceSchema = z.object({ + displayName: z.string().optional(), + shortDescription: z.string().optional(), + longDescription: z.string().optional(), + developerName: z.string().optional(), + websiteURL: z.string().optional(), +}); + +const hookDefSchema = z.object({ + event: z.enum([ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'PermissionResult', + 'UserPromptSubmit', + 'UserPromptQueued', + 'TurnStarted', + 'Stop', + 'StopFailure', + 'Interrupt', + 'SessionStart', + 'SessionEnd', + 'SessionHeartbeat', + 'SubagentStart', + 'SubagentStop', + 'TaskStarted', + 'PreCompact', + 'PostCompact', + 'Notification', + ]), + matcher: z.string().optional(), + command: z.string().min(1), + timeout: z.number().int().min(1).max(600).optional(), +}); + +const pluginCommandEntrySchema = z.object({ + path: z.string(), + name: z.string(), +}); + +const pluginManifestKindSchema = z.enum(['kimi-plugin-root', 'kimi-plugin-dir']); + +const pluginSourceSchema = z.enum(['local-path', 'zip-url', 'github']); + +const pluginStateSchema = z.enum(['ok', 'error']); + +const pluginGithubRefSchema = z.object({ + kind: z.enum(['branch', 'tag', 'sha']), + value: z.string(), +}); + +export const pluginManifestSchema = z.object({ + name: z.string(), + version: z.string().optional(), + description: z.string().optional(), + keywords: z.array(z.string()).optional(), + author: pluginAuthorSchema.optional(), + homepage: z.string().optional(), + license: z.string().optional(), + skills: z.array(z.string()).optional(), + agents: z.array(z.string()).optional(), + sessionStart: pluginSessionStartSchema.optional(), + mcpServers: z.record(z.string(), mcpServerConfigSchema).optional(), + hooks: z.array(hookDefSchema).optional(), + commands: z.array(pluginCommandEntrySchema).optional(), + interface: pluginInterfaceSchema.optional(), + skillInstructions: z.string().optional(), + systemPrompt: z.string().optional(), +}); + +export const pluginMcpServerInfoSchema = z.object({ + name: z.string(), + runtimeName: z.string(), + enabled: z.boolean(), + transport: z.enum(['stdio', 'http', 'sse']), + command: z.string().optional(), + args: z.array(z.string()).optional(), + cwd: z.string().optional(), + url: z.string().optional(), + envKeys: z.array(z.string()).optional(), + headerKeys: z.array(z.string()).optional(), +}); + +export const pluginGithubMetadataSchema = z.object({ + owner: z.string(), + repo: z.string(), + ref: pluginGithubRefSchema, + installedSha: z.string().optional(), +}); + +export const pluginSummarySchema = z.object({ + id: z.string(), + displayName: z.string(), + version: z.string().optional(), + enabled: z.boolean(), + state: pluginStateSchema, + skillCount: z.number(), + mcpServerCount: z.number(), + enabledMcpServerCount: z.number(), + hookCount: z.number(), + commandCount: z.number(), + hasErrors: z.boolean(), + source: pluginSourceSchema, + originalSource: z.string().optional(), + github: pluginGithubMetadataSchema.optional(), +}); + +export const pluginInfoSchema = pluginSummarySchema.extend({ + root: z.string(), + installedAt: z.string(), + updatedAt: z.string().optional(), + manifestKind: pluginManifestKindSchema.optional(), + manifestPath: z.string().optional(), + manifest: pluginManifestSchema.optional(), + mcpServers: z.array(pluginMcpServerInfoSchema), + shadowedManifestPath: z.string().optional(), + diagnostics: z.array(pluginDiagnosticSchema), +}); + +/** Same shape as `reloadSummarySchema` in `./events.js` — keep in sync. */ +export const reloadSummarySchema = z.object({ + added: z.array(z.string()), + removed: z.array(z.string()), + errors: z.array(z.object({ id: z.string(), message: z.string() })), +}); + +export const pluginUpdateStatusSchema = z.object({ + id: z.string(), + source: pluginSourceSchema, + current: pluginGithubRefSchema.optional(), + latest: pluginGithubRefSchema, + displayVersion: z.string(), + updateAvailable: z.boolean(), +}); + +export const pluginCommandDefSchema = z.object({ + pluginId: z.string(), + name: z.string(), + description: z.string(), + body: z.string(), + path: z.string(), +}); + +export const installPluginInputSchema = z.object({ + source: z.string(), +}); + +export const setPluginEnabledInputSchema = z.object({ + id: z.string(), + enabled: z.boolean(), +}); + +export const setPluginMcpServerEnabledInputSchema = z.object({ + id: z.string(), + server: z.string(), + enabled: z.boolean(), +}); + +export const removePluginInputSchema = z.object({ + id: z.string(), +}); + +export const getPluginInfoInputSchema = z.object({ + id: z.string(), +}); + +export const pluginsContract = { + listPlugins: { input: z.tuple([]), output: z.array(pluginSummarySchema) }, + installPlugin: { input: z.tuple([installPluginInputSchema]), output: pluginSummarySchema }, + setPluginEnabled: { input: z.tuple([setPluginEnabledInputSchema]), output: noResult }, + setPluginMcpServerEnabled: { + input: z.tuple([setPluginMcpServerEnabledInputSchema]), + output: noResult, + }, + removePlugin: { input: z.tuple([removePluginInputSchema]), output: noResult }, + reloadPlugins: { input: z.tuple([]), output: reloadSummarySchema }, + getPluginInfo: { input: z.tuple([getPluginInfoInputSchema]), output: pluginInfoSchema }, + listPluginCommands: { input: z.tuple([]), output: z.array(pluginCommandDefSchema) }, + checkUpdates: { input: z.tuple([]), output: z.array(pluginUpdateStatusSchema) }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/providerDiscovery.ts b/packages/klient/src/contract/global/providerDiscovery.ts new file mode 100644 index 0000000000000000000000000000000000000000..eac3bfc3342a6a96b441fe6cd78f563086673505 --- /dev/null +++ b/packages/klient/src/contract/global/providerDiscovery.ts @@ -0,0 +1,35 @@ +/** + * `providerDiscovery` — the engine's `IProviderDiscoveryService`: remote + * provider-model discovery and config sync. Mirrors + * `agent-core-v2/app/kosongConfig/discovery.ts`. + */ + +import { z } from 'zod'; + +import type { ServiceContract } from '../types.js'; + +export const refreshProviderModelsOptionsSchema = z.object({ + scope: z.enum(['all', 'oauth']).optional(), + providerId: z.string().optional(), +}); + +/** Same shape as `refreshOAuthProviderModelsResponseSchema` in `./auth.js` — keep in sync. */ +export const refreshProviderModelsResponseSchema = z.object({ + changed: z.array( + z.object({ + provider_id: z.string(), + provider_name: z.string(), + added: z.number(), + removed: z.number(), + }), + ), + unchanged: z.array(z.string()), + failed: z.array(z.object({ provider: z.string(), reason: z.string() })), +}); + +export const providerDiscoveryContract = { + refreshProviderModels: { + input: z.tuple([refreshProviderModelsOptionsSchema.optional()]), + output: refreshProviderModelsResponseSchema, + }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/providers.ts b/packages/klient/src/contract/global/providers.ts new file mode 100644 index 0000000000000000000000000000000000000000..f499eb72e138794539078de356adc2b08b6be9ab --- /dev/null +++ b/packages/klient/src/contract/global/providers.ts @@ -0,0 +1,45 @@ +/** + * `providerService` — provider configuration registry. Mirrors + * `agent-core-v2/llm-adapter/provider/provider.ts` (`ProviderConfigSchema`). + * `type` is free-form text, not an enum: vendor identity is validated at + * resolve time against the engine's provider-definition registry, so external + * packages can register new vendors without touching this schema. + */ + +import { z } from 'zod'; + +import { maybe, noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +const providerTypeSchema = z.string(); + +const oAuthRefSchema = z.object({ + storage: z.enum(['file', 'keyring']), + key: z.string().min(1), + oauthHost: z.string().min(1).optional(), +}); + +const stringRecordSchema = z.record(z.string(), z.string()); + +const modelSourceSchema = z.enum(['static', 'discover', 'oauth-catalog']); + +export const providerConfigSchema = z.object({ + modelSource: modelSourceSchema.optional(), + + baseUrl: z.string().optional(), + customHeaders: stringRecordSchema.optional(), + defaultModel: z.string().optional(), + + type: providerTypeSchema.optional(), + apiKey: z.string().optional(), + oauth: oAuthRefSchema.optional(), + env: stringRecordSchema.optional(), + source: z.record(z.string(), z.unknown()).optional(), +}); + +export const providersContract = { + get: { input: z.tuple([z.string()]), output: maybe(providerConfigSchema) }, + list: { input: z.tuple([]), output: z.record(z.string(), providerConfigSchema) }, + set: { input: z.tuple([z.string(), providerConfigSchema]), output: noResult }, + delete: { input: z.tuple([z.string()]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/sessions.ts b/packages/klient/src/contract/global/sessions.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a25323aac85f5832e2bf6f442a94105b15175aa --- /dev/null +++ b/packages/klient/src/contract/global/sessions.ts @@ -0,0 +1,44 @@ +/** + * `sessionIndex` — the persisted session read model. Mirrors + * `agent-core-v2/app/sessionIndex/sessionIndex.ts`. + */ + +import { z } from 'zod'; + +import { maybe, pageOf } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const sessionSummarySchema = z.object({ + id: z.string(), + workspaceId: z.string(), + cwd: z.string().optional(), + title: z.string().optional(), + lastPrompt: z.string().optional(), + createdAt: z.number(), + updatedAt: z.number(), + archived: z.boolean(), + archivedAt: z.number().optional(), + custom: z.record(z.string(), z.unknown()).optional(), + lastTurnReason: z.enum(['completed', 'cancelled', 'failed']).optional(), +}); + +export const sessionListQuerySchema = z.object({ + workspaceIds: z.array(z.string()).optional(), + sessionId: z.string().optional(), + includeArchived: z.boolean().optional(), + limit: z.number().optional(), + childOf: z.string().optional(), + before: z.string().optional(), + after: z.string().optional(), +}); + +export const sessionCountQuerySchema = z.object({ + workspaceIds: z.array(z.string()).optional(), + includeArchived: z.boolean().optional(), +}); + +export const sessionsContract = { + listRecent: { input: z.tuple([sessionListQuerySchema]), output: pageOf(sessionSummarySchema) }, + get: { input: z.tuple([z.string()]), output: maybe(sessionSummarySchema) }, + count: { input: z.tuple([sessionCountQuerySchema]), output: z.number() }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/workspaces.ts b/packages/klient/src/contract/global/workspaces.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac70a5a995aeb0fdf38929927512453ef3870121 --- /dev/null +++ b/packages/klient/src/contract/global/workspaces.ts @@ -0,0 +1,35 @@ +/** + * `workspaceService` — process-wide catalog of known workspaces. Mirrors + * `agent-core-v2/app/workspace/workspace.ts`. + */ + +import { z } from 'zod'; + +import { maybe, noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const workspaceSchema = z.object({ + id: z.string(), + root: z.string(), + name: z.string(), + createdAt: z.number(), + lastOpenedAt: z.number(), +}); + +export const workspaceUpdateSchema = z.object({ + name: z.string().optional(), +}); + +export const workspacesContract = { + list: { input: z.tuple([]), output: z.array(workspaceSchema) }, + get: { input: z.tuple([z.string()]), output: maybe(workspaceSchema) }, + createOrTouch: { + input: z.tuple([z.string(), z.string().optional()]), + output: workspaceSchema, + }, + update: { + input: z.tuple([z.string(), workspaceUpdateSchema]), + output: maybe(workspaceSchema), + }, + delete: { input: z.tuple([z.string()]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/helpers.ts b/packages/klient/src/contract/helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..377d5cc9400121579e0003eb88077df4575d8be2 --- /dev/null +++ b/packages/klient/src/contract/helpers.ts @@ -0,0 +1,35 @@ +/** + * Shared schema helpers for contract fragments. + */ + +import { z } from 'zod'; + +/** `Page<T>` on the wire (`persistence/interface/queryStore.ts`). */ +export const pageOf = <T extends z.ZodType>(item: T) => + z.object({ + items: z.array(item), + nextCursor: z.string().optional(), + }); + +/** + * Engine `X | undefined` returns cross the wire as `null` over HTTP (JSON + * has no `undefined`) and as `undefined` in-process — accept both and + * normalize to `undefined`. + */ +export const maybe = <T extends z.ZodType>(schema: T) => + z.union([schema, z.null(), z.undefined()]).transform((value) => value ?? undefined); + +/** + * `void` method results arrive as `null` over HTTP (JSON has no `undefined`) + * and as `undefined` in-process — accept both and normalize to `undefined`. + */ +export const noResult = z + .union([z.void(), z.null()]) + .transform(() => undefined); + +/** Engine `{ added, removed, changed }` change-set events. */ +export const stringDeltaSchema = z.object({ + added: z.array(z.string()), + removed: z.array(z.string()), + changed: z.array(z.string()), +}); diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8462dbaae98741c20c78aeca5dab723768ef29cb --- /dev/null +++ b/packages/klient/src/contract/index.ts @@ -0,0 +1,97 @@ +/** + * The aggregated klient contract — service wire name → method → zod + * input/output schemas, across the core/session/agent scopes. The klient + * factory validates every call against this table; transports never see it. + * Event registrations live in the per-scope `events.ts` files alongside + * their payload schemas. + */ + +import type { KlientContract } from './types.js'; +import { + agentCommandContract, + agentContextMemoryContract, + agentFullCompactionContract, + agentLoopContract, + agentMcpContract, + agentPermissionModeContract, + agentPlanContract, + agentRuntimeBindingContract, + agentProfileContract, + agentPromptContract, + agentShellCommandContract, + agentSkillContract, + agentTaskContract, + agentTokenCountingContract, + agentUsageContract, +} from './agent/services.js'; +import { authContract, authSummaryContract } from './global/auth.js'; +import { capabilitiesContract } from './global/capabilities.js'; +import { catalogContract } from './global/catalog.js'; +import { providerDiscoveryContract } from './global/providerDiscovery.js'; +import { configContract } from './global/config.js'; +import { envContract } from './global/env.js'; +import { filesContract } from './global/files.js'; +import { flagsContract } from './global/flags.js'; +import { hostFsContract } from './global/hostFs.js'; +import { modelsContract } from './global/models.js'; +import { mcpManagementContract } from './global/mcpManagement.js'; +import { pluginsContract } from './global/plugins.js'; +import { providersContract } from './global/providers.js'; +import { sessionsContract } from './global/sessions.js'; +import { workspacesContract } from './global/workspaces.js'; +import { sessionApprovalContract } from './session/approval.js'; +import { sessionActivityViewContract } from './session/activity.js'; +import { sessionInteractionContract } from './session/interaction.js'; +import { sessionManagerContract } from './session/lifecycle.js'; +import { sessionMetadataContract } from './session/metadata.js'; +import { sessionQuestionContract } from './session/question.js'; +import { sessionSkillCatalogContract } from './session/skills.js'; +import { sessionTitleContract } from './session/title.js'; + +export const globalContract: KlientContract = { + // core (app scope) + sessionIndex: sessionsContract, + workspaceService: workspacesContract, + configService: configContract, + providerService: providersContract, + modelService: modelsContract, + modelResolver: catalogContract, + providerDiscovery: providerDiscoveryContract, + oauthService: authContract, + authSummaryService: authSummaryContract, + flagService: flagsContract, + pluginService: pluginsContract, + capabilityService: capabilitiesContract, + hostFolderBrowser: hostFsContract, + bootstrapService: envContract, + fileService: filesContract, + mcpManagementService: mcpManagementContract, + sessionManager: sessionManagerContract, + // session scope + sessionMetadata: sessionMetadataContract, + sessionInteractionService: sessionInteractionContract, + sessionApprovalService: sessionApprovalContract, + sessionQuestionService: sessionQuestionContract, + sessionSkillCatalog: sessionSkillCatalogContract, + sessionTitleService: sessionTitleContract, + sessionActivityView: sessionActivityViewContract, + // agent scope + agentPromptService: agentPromptContract, + agentSkillService: agentSkillContract, + agentLoopService: agentLoopContract, + agentPermissionModeService: agentPermissionModeContract, + agentCommandService: agentCommandContract, + agentRuntimeBindingService: agentRuntimeBindingContract, + agentContextMemoryService: agentContextMemoryContract, + agentTokenCountingService: agentTokenCountingContract, + agentShellCommandService: agentShellCommandContract, + agentProfileService: agentProfileContract, + agentUsageService: agentUsageContract, + agentPlanService: agentPlanContract, + agentTaskService: agentTaskContract, + agentMcpService: agentMcpContract, + agentFullCompactionService: agentFullCompactionContract, +}; + +export type { KlientContract, ProcedureContract, ServiceContract, StreamingProcedureContract } from './types.js'; +export { isStreamingContract } from './types.js'; diff --git a/packages/klient/src/contract/mcp.ts b/packages/klient/src/contract/mcp.ts new file mode 100644 index 0000000000000000000000000000000000000000..674e0f05c084fa2073097e559d13a8b9fca17a37 --- /dev/null +++ b/packages/klient/src/contract/mcp.ts @@ -0,0 +1,58 @@ +/** + * Shared MCP server wire schema for session creation and plugin manifests. + * Mirrors `agent-core-v2/mcpCore/config-schema.ts`. Unlike the config files, + * the wire requires the explicit `transport` discriminant (the engine's + * command/url inference preprocess is a file-format convenience). + */ + +import { z } from 'zod'; + +const stringRecordSchema = z.record(z.string(), z.string()); + +export const mcpTimeoutMsSchema = z.number().int().min(1).max(2_147_483_647); + +const mcpServerCommonFields = { + enabled: z.boolean().optional(), + deferred: z.boolean().optional(), + startupTimeoutMs: mcpTimeoutMsSchema.optional(), + toolTimeoutMs: mcpTimeoutMsSchema.optional(), + enabledTools: z.array(z.string()).optional(), + disabledTools: z.array(z.string()).optional(), +} as const; + +export const mcpServerStdioConfigSchema = z.object({ + transport: z.literal('stdio'), + runtime_id: z.string().min(1).optional(), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: stringRecordSchema.optional(), + cwd: z.string().optional(), + executor: z.enum(['local', 'kaos']).optional(), + ...mcpServerCommonFields, +}); + +export const mcpServerHttpConfigSchema = z.object({ + transport: z.literal('http'), + url: z.string().url(), + headers: stringRecordSchema.optional(), + auth: z.literal('oauth').optional(), + bearerTokenEnvVar: z.string().min(1).optional(), + ...mcpServerCommonFields, +}); + +export const mcpServerSseConfigSchema = z.object({ + transport: z.literal('sse'), + url: z.string().url(), + headers: stringRecordSchema.optional(), + auth: z.literal('oauth').optional(), + bearerTokenEnvVar: z.string().min(1).optional(), + ...mcpServerCommonFields, +}); + +export const mcpServerConfigSchema = z.discriminatedUnion('transport', [ + mcpServerStdioConfigSchema, + mcpServerHttpConfigSchema, + mcpServerSseConfigSchema, +]); + +export type McpServerConfig = z.infer<typeof mcpServerConfigSchema>; diff --git a/packages/klient/src/contract/session/activity.ts b/packages/klient/src/contract/session/activity.ts new file mode 100644 index 0000000000000000000000000000000000000000..4084ae8662d0d07450c8838e10182b51156a0972 --- /dev/null +++ b/packages/klient/src/contract/session/activity.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import type { ServiceContract } from '../types.js'; + +export const sessionActivityStateSchema = z.object({ + busy: z.boolean(), + mainTurnActive: z.boolean(), + pendingInteraction: z.enum(['none', 'approval', 'question']), + lastTurnReason: z.enum(['completed', 'cancelled', 'failed']).optional(), +}); + +export const sessionActivityViewContract = { + state: { input: z.tuple([]), output: sessionActivityStateSchema }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/session/approval.ts b/packages/klient/src/contract/session/approval.ts new file mode 100644 index 0000000000000000000000000000000000000000..aab17766f1f95ad25dacfa8625f3a69531e07548 --- /dev/null +++ b/packages/klient/src/contract/session/approval.ts @@ -0,0 +1,35 @@ +/** + * `sessionApprovalService` — session-scope approval broker. Mirrors + * `agent-core-v2/agent/interaction/approval.ts`. `ApprovalRequest.display` is + * the protocol `ToolInputDisplay` union (huge); it crosses the wire + * uninspected, so it is `z.unknown()` here. + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const approvalRequestSchema = z.object({ + id: z.string().optional(), + sessionId: z.string().optional(), + agentId: z.string().optional(), + turnId: z.number().optional(), + toolCallId: z.string().optional(), + toolName: z.string(), + action: z.string(), + /** Protocol `ToolInputDisplay` — mirrored as `unknown` (see file header). */ + display: z.unknown(), +}); + +export const approvalResponseSchema = z.object({ + decision: z.enum(['approved', 'rejected', 'cancelled']), + scope: z.literal('session').optional(), + feedback: z.string().optional(), + selectedLabel: z.string().optional(), +}); + +export const sessionApprovalContract = { + listPending: { input: z.tuple([]), output: z.array(approvalRequestSchema) }, + decide: { input: z.tuple([z.string(), approvalResponseSchema]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/session/events.ts b/packages/klient/src/contract/session/events.ts new file mode 100644 index 0000000000000000000000000000000000000000..ddbf5701d170cf18e47623b844af62a43623ac83 --- /dev/null +++ b/packages/klient/src/contract/session/events.ts @@ -0,0 +1,74 @@ +/** + * Klient-level session-scope events — the public, typed, namespaced event + * surface of one session. Mirrors the pattern of `../global/events.ts`; + * stream names match the kap-server session event map (`interactions`, + * `interactions:resolved`). + */ + +import { z } from 'zod'; + +import type { + Interaction, + InteractionResolution, +} from '@moonshot-ai/agent-core-v2/human/interaction/interaction'; +import type { SessionMetadataChangedEvent } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; + +import type { EventRegistration } from '../types.js'; +import { + interactionResolutionSchema, + interactionSchema, +} from './interaction.js'; +import { sessionMetadataChangedEventSchema } from './metadata.js'; + +/** + * Scope-stream registration (`kind: 'stream'`). Declared structurally here + * until `EventRegistration` in `../types.js` gains the `stream` variant; + * compatible with `src/core/events/hub.ts`, which already switches on it. + */ +interface StreamEventRegistration { + readonly kind: 'stream'; + readonly name: string; + readonly type?: string; + readonly schema: z.ZodType; +} + +type SessionEventRegistration = EventRegistration | StreamEventRegistration; + +/** Public event name → payload type. Keys must stay in sync with `sessionEvents`. */ +export interface SessionEventPayloads { + 'metadata.changed': SessionMetadataChangedEvent; + 'interactions.changed': readonly Interaction[]; + 'interactions.resolved': InteractionResolution; + /** The merged skill catalog changed; the payload is the changed source id. */ + 'skills.changed': string; +} + +export type SessionEventName = keyof SessionEventPayloads; + +/** Public event name → source binding + payload schema. */ +export const sessionEvents = { + 'metadata.changed': { + kind: 'emitter', + service: 'sessionMetadata', + event: 'onDidChangeMetadata', + schema: sessionMetadataChangedEventSchema, + }, + 'skills.changed': { + kind: 'emitter', + service: 'sessionSkillCatalog', + event: 'onDidChange', + schema: z.string(), + }, + // Passthrough stream (no `type` filter): the source pushes the full + // pending interaction set on every change. + 'interactions.changed': { + kind: 'stream', + name: 'interactions', + schema: z.array(interactionSchema), + }, + 'interactions.resolved': { + kind: 'stream', + name: 'interactions:resolved', + schema: interactionResolutionSchema, + }, +} satisfies Record<SessionEventName, SessionEventRegistration>; diff --git a/packages/klient/src/contract/session/interaction.ts b/packages/klient/src/contract/session/interaction.ts new file mode 100644 index 0000000000000000000000000000000000000000..6da8eb911b95bfaa4628906511e3e81b9aadccbb --- /dev/null +++ b/packages/klient/src/contract/session/interaction.ts @@ -0,0 +1,35 @@ +/** + * `sessionInteractionService` — blocking human-in-the-loop request kernel. + * Mirrors `agent-core-v2/human/interaction/interaction.ts`. + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const interactionKindSchema = z.enum(['approval', 'question', 'user_tool']); + +export const interactionTagsSchema = z.record(z.string(), z.union([z.string(), z.number()])); + +export const interactionSchema = z.object({ + id: z.string(), + kind: interactionKindSchema, + payload: z.unknown(), + tags: interactionTagsSchema, + createdAt: z.number(), +}); + +export const interactionResolutionSchema = z.object({ + id: z.string(), + response: z.unknown(), +}); + +export const sessionInteractionContract = { + listPending: { + input: z.tuple([interactionKindSchema.optional()]), + output: z.array(interactionSchema), + }, + respond: { input: z.tuple([z.string(), z.unknown()]), output: noResult }, + isRecentlyResolved: { input: z.tuple([z.string()]), output: z.boolean() }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/session/lifecycle.ts b/packages/klient/src/contract/session/lifecycle.ts new file mode 100644 index 0000000000000000000000000000000000000000..f50ed2cb7b291b77fc9ecf3a4146e4d33fdc165e --- /dev/null +++ b/packages/klient/src/contract/session/lifecycle.ts @@ -0,0 +1,70 @@ +/** + * `sessionManager` — App-scope session lifecycle after the Workspace-domain + * split. It creates, resumes, closes, archives, restores, deletes, and forks + * sessions through the App-owned manager; create/resume/restore return scope + * handles on the wire (`{ id, kind }`), fork/createChild return the forked + * session's metadata directly. + */ + +import { z } from 'zod'; + +import { maybe, noResult } from '../helpers.js'; +import { mcpServerConfigSchema } from '../mcp.js'; +import type { ServiceContract } from '../types.js'; +import { sessionMetaSchema } from './metadata.js'; + +export const createSessionOptionsSchema = z.object({ + sessionId: z.string().optional(), + workDir: z.string(), + additionalDirs: z.array(z.string()).optional(), + /** + * Ephemeral per-session MCP servers (engine `CreateSessionOptions.mcpServers`): + * connected only for the created session, never persisted. + */ + mcpServers: z.record(z.string(), mcpServerConfigSchema).optional(), +}); + +/** Same fields as `ResumeSessionOptions` in the engine — keep in sync. */ +export const resumeSessionOptionsSchema = z.object({ + additionalDirs: z.array(z.string()).optional(), + /** + * Ephemeral per-session MCP servers, applied when resume re-materializes a + * cold session (ignored when the session is already live). + */ + mcpServers: z.record(z.string(), mcpServerConfigSchema).optional(), +}); + +/** Same fields as `ForkSessionOptions` in the engine — keep in sync. */ +export const forkSessionOptionsSchema = z.object({ + sourceSessionId: z.string(), + newSessionId: z.string().optional(), + title: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + turnIndex: z.number().optional(), +}); + +/** Same fields as `ForkSessionOptions` in the engine, minus the fork-only truncation. */ +export const createChildSessionOptionsSchema = forkSessionOptionsSchema.omit({ turnIndex: true }); + +/** `IScopeHandle` as it survives JSON — `{ id, kind }` plus extras. */ +export const handleWireSchema = z.looseObject({ + id: z.string(), + kind: z.string(), +}); + +export const sessionManagerContract = { + create: { input: z.tuple([createSessionOptionsSchema]), output: handleWireSchema }, + resume: { + input: z.tuple([z.string(), resumeSessionOptionsSchema.optional()]), + output: maybe(handleWireSchema), + }, + close: { input: z.tuple([z.string()]), output: noResult }, + archive: { input: z.tuple([z.string()]), output: noResult }, + restore: { + input: z.tuple([z.string(), resumeSessionOptionsSchema.optional()]), + output: maybe(handleWireSchema), + }, + delete: { input: z.tuple([z.string()]), output: noResult }, + fork: { input: z.tuple([forkSessionOptionsSchema]), output: sessionMetaSchema }, + createChild: { input: z.tuple([createChildSessionOptionsSchema]), output: sessionMetaSchema }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/session/metadata.ts b/packages/klient/src/contract/session/metadata.ts new file mode 100644 index 0000000000000000000000000000000000000000..39f50e41035b84e5b4da3c3948cf960e5a0b9ce9 --- /dev/null +++ b/packages/klient/src/contract/session/metadata.ts @@ -0,0 +1,82 @@ +/** + * `sessionMetadata` — typed session metadata. Mirrors + * `agent-core-v2/session/sessionMetadata/sessionMetadata.ts`. The `ready` + * promise property is excluded (not a wire method). + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const agentMetaSchema = z.object({ + homedir: z.string().optional(), + type: z.enum(['main', 'sub', 'independent']).optional(), + parentAgentId: z.union([z.string(), z.null()]).optional(), + forkedFrom: z.string().optional(), + labels: z.record(z.string(), z.string()).optional(), + swarmItem: z.string().optional(), +}); + +export const sessionMetaSchema = z.object({ + id: z.string(), + version: z.number().optional(), + title: z.string().optional(), + titleKind: z.enum(['replaceable', 'generated', 'custom']).optional(), + lastPrompt: z.string().optional(), + createdAt: z.number(), + updatedAt: z.number(), + archived: z.boolean(), + archivedAt: z.number().optional(), + cwd: z.string().optional(), + forkedFrom: z.string().optional(), + agents: z.record(z.string(), agentMetaSchema).optional(), + custom: z.record(z.string(), z.unknown()).optional(), + lastTurnReason: z.enum(['completed', 'cancelled', 'failed']).optional(), +}); + +/** `Partial<Omit<SessionMeta, 'id' | 'createdAt'>>` — every key optional. */ +export const sessionMetaPatchSchema = z.object({ + version: z.number().optional(), + title: z.string().optional(), + titleKind: z.enum(['replaceable', 'generated', 'custom']).optional(), + lastPrompt: z.string().optional(), + updatedAt: z.number().optional(), + archived: z.boolean().optional(), + archivedAt: z.number().optional(), + cwd: z.string().optional(), + forkedFrom: z.string().optional(), + agents: z.record(z.string(), agentMetaSchema).optional(), + custom: z.record(z.string(), z.unknown()).optional(), + lastTurnReason: z.enum(['completed', 'cancelled', 'failed']).optional(), +}); + +/** `keyof SessionMeta` — keep in sync with `sessionMetaSchema`. */ +export const sessionMetaKeySchema = z.enum([ + 'id', + 'version', + 'title', + 'titleKind', + 'lastPrompt', + 'createdAt', + 'updatedAt', + 'archived', + 'archivedAt', + 'cwd', + 'forkedFrom', + 'agents', + 'custom', + 'lastTurnReason', +]); + +export const sessionMetadataChangedEventSchema = z.object({ + changed: z.array(sessionMetaKeySchema), +}); + +export const sessionMetadataContract = { + read: { input: z.tuple([]), output: sessionMetaSchema }, + update: { input: z.tuple([sessionMetaPatchSchema]), output: noResult }, + setTitle: { input: z.tuple([z.string()]), output: noResult }, + setArchived: { input: z.tuple([z.boolean()]), output: noResult }, + registerAgent: { input: z.tuple([z.string(), agentMetaSchema]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/session/question.ts b/packages/klient/src/contract/session/question.ts new file mode 100644 index 0000000000000000000000000000000000000000..31e5cc6acf6abff89bdaa5ffb414937831bb7fec --- /dev/null +++ b/packages/klient/src/contract/session/question.ts @@ -0,0 +1,56 @@ +/** + * `sessionQuestionService` — ask-user request broker. Mirrors + * `agent-core-v2/agent/interaction/question.ts` (the in-process camelCase + * representation; the snake_case protocol shape is adapted at the edge). + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const questionOptionSchema = z.object({ + label: z.string(), + description: z.string().optional(), +}); + +export const questionItemSchema = z.object({ + question: z.string(), + header: z.string().optional(), + body: z.string().optional(), + options: z.array(questionOptionSchema), + multiSelect: z.boolean().optional(), + otherLabel: z.string().optional(), + otherDescription: z.string().optional(), +}); + +/** `QuestionAnswers = Record<string, string | true>`. */ +export const questionAnswersSchema = z.record( + z.string(), + z.union([z.string(), z.literal(true)]), +); + +export const questionResponseSchema = z.object({ + answers: questionAnswersSchema, + method: z.enum(['enter', 'space', 'number_key']).optional(), +}); + +/** `QuestionResult = null | QuestionAnswers | QuestionResponse`. */ +export const questionResultSchema = z.union([ + z.null(), + questionAnswersSchema, + questionResponseSchema, +]); + +export const questionRequestSchema = z.object({ + id: z.string().optional(), + turnId: z.number().optional(), + toolCallId: z.string().optional(), + questions: z.array(questionItemSchema), +}); + +export const sessionQuestionContract = { + listPending: { input: z.tuple([]), output: z.array(questionRequestSchema) }, + answer: { input: z.tuple([z.string(), questionResultSchema]), output: noResult }, + dismiss: { input: z.tuple([z.string()]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/session/skills.ts b/packages/klient/src/contract/session/skills.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c74d5796af5a4d22b1cc0ac7e4ebddd9531aa26 --- /dev/null +++ b/packages/klient/src/contract/session/skills.ts @@ -0,0 +1,25 @@ +/** + * `sessionSkillCatalog` — the session-merged skill catalog. Mirrors + * `agent-core-v2/features/skill/session/skillCatalog.ts` (the thin + * `list()` method; the `catalog` / `ready` properties and the sink interface + * are not wire methods). `SkillSummary` mirrors + * `agent-core-v2/features/skill/catalog/types.ts`. + */ + +import { z } from 'zod'; + +import type { ServiceContract } from '../types.js'; + +export const skillSummarySchema = z.object({ + name: z.string(), + description: z.string(), + path: z.string(), + source: z.enum(['project', 'user', 'extra', 'builtin']), + type: z.string().optional(), + disableModelInvocation: z.boolean().optional(), + isSubSkill: z.boolean().optional(), +}); + +export const sessionSkillCatalogContract = { + list: { input: z.tuple([]), output: z.array(skillSummarySchema) }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/session/title.ts b/packages/klient/src/contract/session/title.ts new file mode 100644 index 0000000000000000000000000000000000000000..3777e8103bd07d40e4df3ec2e268af01bfc032f2 --- /dev/null +++ b/packages/klient/src/contract/session/title.ts @@ -0,0 +1,23 @@ +/** + * `sessionTitleService` — on-demand session title generation. Mirrors + * `agent-core-v2/session/sessionTitle/sessionTitle.ts`. + */ + +import { z } from 'zod'; + +import { maybe } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const sessionTitleContract = { + generateTitle: { + input: z.tuple([ + z + .object({ + force: z.boolean().optional(), + source: z.enum(['user_prompts', 'first_turn', 'digest']).optional(), + }) + .optional(), + ]), + output: maybe(z.string()), + }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/types.ts b/packages/klient/src/contract/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..503495df4a7b22b79067187ebad3eb1f9d537dd3 --- /dev/null +++ b/packages/klient/src/contract/types.ts @@ -0,0 +1,68 @@ +/** + * Contract vocabulary. A procedure mirrors one engine service method: `input` + * is a zod tuple over its positional arguments, `output` the zod schema of its + * resolved result. The facade reshapes positional args into single-object + * params; the wire keeps the engine's original argument order so contracts + * stay mechanical to write and audit. + */ + +import type { z } from 'zod'; + +export interface ProcedureContract { + /** Tuple schema over the engine method's positional args. */ + readonly input: z.ZodType; + /** Schema of the method's resolved return value as it appears on the wire. */ + readonly output: z.ZodType; +} + +/** + * A streaming procedure yields an `AsyncIterable` of chunks instead of a + * single resolved value. `chunk` validates each yielded element; `streaming` + * is a compile-time discriminator so callers can branch without runtime + * checks. + */ +export interface StreamingProcedureContract { + /** Tuple schema over the engine method's positional args. */ + readonly input: z.ZodType; + /** Schema applied to every yielded chunk. */ + readonly chunk: z.ZodType; + /** Discriminator — always `true`. */ + readonly streaming: true; +} + +/** Type guard: is this contract entry streaming? */ +export function isStreamingContract( + contract: ProcedureContract | StreamingProcedureContract, +): contract is StreamingProcedureContract { + return 'streaming' in contract && contract.streaming === true; +} + +/** method name → procedure */ +export type ServiceContract = Readonly<Record<string, ProcedureContract | StreamingProcedureContract>>; + +/** service wire name (decorator id string) → its methods */ +export type KlientContract = Readonly<Record<string, ServiceContract>>; + +/** + * Where a klient-level event reads from: + * - `bus` — filter the process-wide `IEventService` stream by `type` + * (payload unwrapped from `{ type, payload }`). + * - `stream` — a named scope stream (`events`, `interactions`, + * `interactions:resolved`); with `type` set, only flat `{ type, ...fields }` + * events of that type are forwarded, whole. + * - `emitter` — subscribe one service's `onDid*` property. + */ +export type EventRegistration = + | { readonly kind: 'bus'; readonly type: string; readonly schema: z.ZodType } + | { + readonly kind: 'stream'; + readonly name: string; + readonly type?: string; + readonly schema: z.ZodType; + } + | { + readonly kind: 'emitter'; + readonly service: string; + readonly event: string; + readonly schema: z.ZodType; + }; diff --git a/packages/klient/src/core/channel.ts b/packages/klient/src/core/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..c674ee6b6904e3034e2127449689d950284ea147 --- /dev/null +++ b/packages/klient/src/core/channel.ts @@ -0,0 +1,73 @@ +/** + * Transport SPI — the single abstraction every klient transport implements. + * + * A `KlientChannel` carries service calls and event subscriptions for one + * scope triple. The facade above it never knows which transport is underneath + * (http, ipc, or in-memory); transports never know which facade method + * triggered a frame. `ScopeRef` already carries session/agent coordinates so + * future session/agent facades plug in without changing this interface. + */ + +export interface IDisposable { + dispose(): void; +} + +/** Optional per-call knobs a transport may honor. */ +export interface CallOptions { + /** + * Per-call deadline (ms). A transport with a default call timeout (ipc) + * takes it as an override — long-poll calls pass a deadline covering the + * engine-side wait; transports without a timeout (memory) ignore it. + */ + readonly timeoutMs?: number; +} + +/** Scope coordinates of a call/subscription. Empty object = core (app) scope. */ +export interface ScopeRef { + readonly workspaceId?: string; + readonly sessionId?: string; + readonly agentId?: string; +} + +/** + * Where an event subscription reads from: + * - `stream` — a scope's named event stream, mirroring kap-server's WS + * `eventMap`: core `events` (the global `IEventService` bus), session + * `interactions` / `interactions:resolved`, agent `events` (the per-agent + * `IEventBus`). The scope coordinates disambiguate which scope's stream. + * - `emitter` — one service's `onDid*` `Event<T>` property, addressed by the + * service's wire name and the property name (e.g. `onDidChangeModels`). + */ +export type EventSourceRef = + | { readonly kind: 'stream'; readonly name: string } + | { readonly kind: 'emitter'; readonly service: string; readonly event: string }; + +export interface KlientChannel { + /** Invoke `service.method(...args)` in the given scope; resolves with the raw wire result. */ + call( + scope: ScopeRef, + service: string, + method: string, + args: unknown[], + options?: CallOptions, + ): Promise<unknown>; + /** + * Invoke `service.method(...args)` in the given scope and return a streaming + * result. The callee must return an `AsyncIterable`; each yielded chunk is + * surfaced as-is (after the transport's serialization round-trip). + */ + stream(scope: ScopeRef, service: string, method: string, args: unknown[]): AsyncIterable<unknown>; + /** + * Subscribe to an event source; `handler` receives raw wire payloads. + * `onError` reports asynchronous subscription failures (bad source, dropped + * remote subscription) — synchronous validation may also throw. + */ + listen( + scope: ScopeRef, + source: EventSourceRef, + handler: (data: unknown) => void, + onError?: (error: Error) => void, + ): IDisposable; + /** Tear the transport down (sockets, lazy bridges). Rejects in-flight calls. */ + close(): Promise<void>; +} diff --git a/packages/klient/src/core/errors.ts b/packages/klient/src/core/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb46bd1fa57f0aeebebaa8358f4cad940fc6bc20 --- /dev/null +++ b/packages/klient/src/core/errors.ts @@ -0,0 +1,15 @@ +/** + * Client-side RPC error surfaced when the wire envelope carries a non-zero + * `code`. Mirrors the server envelope (`{ code, msg, data, request_id }`) — the + * numeric `code` is the stable branch key across the wire, not `instanceof`. + */ +export class RPCError extends Error { + constructor( + readonly code: number, + message: string, + readonly details?: unknown, + ) { + super(message); + this.name = 'RPCError'; + } +} diff --git a/packages/klient/src/core/events/hub.ts b/packages/klient/src/core/events/hub.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb14b4dcd367b6cb1592ddcbd9a7a765e751aa0e --- /dev/null +++ b/packages/klient/src/core/events/hub.ts @@ -0,0 +1,227 @@ +/** + * The event hub — klient-level event forwarding. It exposes typed, namespaced + * events and hides the engine's `onDid*`/`onWill*` surface: each public event + * name resolves to one registration (a global-bus type filter, a scope + * stream, or a service emitter). Underlying channel subscriptions are shared + * per source and ref-counted by listener count. Payloads are validated + * against the event schema before delivery; bad payloads are dropped and + * reported through `onError`, never thrown. + * + * One hub serves one scope: the global klient hub binds `{}`, session/agent + * handles bind their scope coordinates, so `stream` sources resolve to the + * right scope's event stream on every transport. + */ + +import type { IDisposable, KlientChannel, ScopeRef } from '../channel.js'; +import type { EventRegistration } from '#/contract/types'; +import type { KlientEventPayloads } from '#/contract/global/events'; +import { parseEvent } from '../validation.js'; + +export interface KlientEvents<TPayloadMap extends object = KlientEventPayloads> { + on<E extends keyof TPayloadMap & string>( + event: E, + listener: (payload: TPayloadMap[E]) => void, + ): IDisposable; + /** Validation failures and listener exceptions surface here. */ + onError(listener: (error: Error) => void): IDisposable; +} + +type AnyListener = (payload: never) => void; + +interface SharedSub { + readonly reg: EventRegistration; + disposable: IDisposable; + refs: number; +} + +/** Stable identity of a registration's underlying channel subscription. */ +function keyOf(reg: EventRegistration): string { + switch (reg.kind) { + case 'bus': + return 'bus'; + case 'stream': + return `stream:${reg.name}`; + case 'emitter': + return `emitter:${reg.service}:${reg.event}`; + } +} + +function rawTypeOf(raw: unknown): string | undefined { + if (typeof raw !== 'object' || raw === null) return undefined; + const type = (raw as { type?: unknown }).type; + return typeof type === 'string' ? type : undefined; +} + +export class EventHub<TPayloadMap extends object = KlientEventPayloads> + implements KlientEvents<TPayloadMap> +{ + private readonly listeners = new Map<string, Set<AnyListener>>(); + private readonly subs = new Map<string, SharedSub>(); + private readonly errorListeners = new Set<(error: Error) => void>(); + private closed = false; + + constructor( + private readonly channel: KlientChannel, + private readonly validate: boolean, + private readonly scope: ScopeRef, + private readonly registrations: Record<string, EventRegistration>, + ) {} + + on<E extends keyof TPayloadMap & string>( + event: E, + listener: (payload: TPayloadMap[E]) => void, + ): IDisposable { + if (this.closed) throw new Error('event hub is closed'); + if (this.registrations[event] === undefined) { + throw new Error(`unknown event: ${event}`); + } + let set = this.listeners.get(event); + if (set === undefined) { + set = new Set(); + this.listeners.set(event, set); + } + const entry = listener as AnyListener; + set.add(entry); + this.acquire(event); + + let disposed = false; + return { + dispose: () => { + if (disposed) return; + disposed = true; + set.delete(entry); + if (set.size === 0) { + this.listeners.delete(event); + } + this.release(event); + }, + }; + } + + onError(listener: (error: Error) => void): IDisposable { + this.errorListeners.add(listener); + return { + dispose: () => { + this.errorListeners.delete(listener); + }, + }; + } + + /** Detach every subscription; the hub may not be reused after. */ + close(): void { + this.closed = true; + for (const sub of this.subs.values()) { + sub.disposable.dispose(); + } + this.subs.clear(); + this.listeners.clear(); + } + + private acquire(event: string): void { + const reg = this.registrations[event]; + if (reg === undefined) return; + const key = keyOf(reg); + let sub = this.subs.get(key); + if (sub === undefined) { + sub = { + reg, + disposable: this.subscribe(key, reg), + refs: 0, + }; + this.subs.set(key, sub); + } + sub.refs += 1; + } + + private release(event: string): void { + const reg = this.registrations[event]; + if (reg === undefined) return; + const key = keyOf(reg); + const sub = this.subs.get(key); + if (sub === undefined) return; + sub.refs -= 1; + if (sub.refs <= 0) { + sub.disposable.dispose(); + this.subs.delete(key); + } + } + + private subscribe(key: string, reg: EventRegistration): IDisposable { + if (reg.kind === 'emitter') { + return this.channel.listen( + this.scope, + { kind: 'emitter', service: reg.service, event: reg.event }, + (data) => { + this.deliver(key, data); + }, + (error) => { + this.reportError(error); + }, + ); + } + const name = reg.kind === 'bus' ? 'events' : reg.name; + return this.channel.listen( + this.scope, + { kind: 'stream', name }, + (data) => { + this.deliver(key, data); + }, + (error) => { + this.reportError(error); + }, + ); + } + + /** Fan one raw payload out to the registrations attached to this source. */ + private deliver(key: string, raw: unknown): void { + for (const [event, reg] of Object.entries(this.registrations)) { + if (keyOf(reg) !== key || !this.listeners.has(event)) continue; + if (reg.kind === 'bus') { + // Global bus events are `{ type, payload }` facts; only registered + // types are forwarded, with the payload unwrapped. + if (rawTypeOf(raw) !== reg.type) continue; + this.deliverValidated(event, reg, (raw as { payload?: unknown }).payload); + continue; + } + if (reg.kind === 'stream' && reg.type !== undefined) { + // Scoped streams (e.g. the agent `events` bus) carry flat + // `{ type, ...fields }` events; forward the whole event. + if (rawTypeOf(raw) !== reg.type) continue; + this.deliverValidated(event, reg, raw); + continue; + } + this.deliverValidated(event, reg, raw); + } + } + + private deliverValidated(event: string, reg: EventRegistration, data: unknown): void { + let payload: unknown = data; + if (this.validate) { + const parsed = parseEvent(event, reg.schema, data); + if (!parsed.ok) { + this.reportError(parsed.error); + return; + } + payload = parsed.data; + } + const set = this.listeners.get(event); + if (set === undefined) return; + for (const listener of set) { + try { + (listener as (payload: unknown) => void)(payload); + } catch (error) { + this.reportError(error instanceof Error ? error : new Error(String(error))); + } + } + } + + private reportError(error: Error): void { + for (const listener of this.errorListeners) { + try { + listener(error); + } catch { + // error listeners must not take the hub down + } + } + } +} diff --git a/packages/klient/src/core/facade/agent.ts b/packages/klient/src/core/facade/agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7e9adeb8d9fc0ea8d79aecc573a6a64872083e9 --- /dev/null +++ b/packages/klient/src/core/facade/agent.ts @@ -0,0 +1,182 @@ +/** + * The agent facade — one `session.agent(id)` handle over the agent-scope + * services the wire exposes. Turn-driving calls (prompt / steer / cancel), + * skill activation, permission mode, and commands go straight to their domain + * services, as do shell commands, model, usage, plan, and task calls; + * `getContext` merges two reads client-side. Prompt streaming is + * NOT on this interface: it flows through the agent's `events` hub + * (`turn.*`, `assistant.delta`, `tool.call.*`, `prompt.completed`, …). + */ + +import type { IAgentCommandService } from '@moonshot-ai/agent-core-v2/agent/command/agentCommand'; +import type { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; +import type { IAgentMcpService } from '@moonshot-ai/agent-core-v2/agent/mcp/mcp'; +import type { IAgentRuntimeBindingService } from '@moonshot-ai/agent-core-v2/agent/runtimeBinding/runtimeBinding'; + +import type { ISessionTokenCountingService } from '@moonshot-ai/agent-core-v2/session/tokenCounting/sessionTokenCounting'; +import type { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; +import type { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; +import type { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; +import type { IAgentSkillService } from '@moonshot-ai/agent-core-v2/features/skill/skillService'; +import type { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task'; +import type { ISessionUsageService } from '@moonshot-ai/agent-core-v2/session/usage/sessionUsage'; +import type { ContentPart } from '@moonshot-ai/agent-core-v2/human/llm/message'; +import type { PermissionMode } from '@moonshot-ai/agent-core-v2/agent/permissionPolicy/types'; + +import type { ScopeRef } from '../channel.js'; +import type { ScopedCaller } from './session.js'; + +// Wire-type aliases derived through the engine service interfaces (keeps +// klient free of protocol-package imports). +export type PromptLaunchResult = import('@moonshot-ai/agent-core-v2/agent/loop/loop').PromptLaunchResult | undefined; +export type PromptWithSkillsInput = Parameters<IAgentSkillService['promptWithSkills']>[0]; +export type PromptWithSkillsResult = Awaited<ReturnType<IAgentSkillService['promptWithSkills']>>; +export type ShellCommandResult = Awaited<ReturnType<IAgentShellCommandService['run']>>; +export type SetModelResult = Awaited<ReturnType<IAgentProfileService['setModel']>>; +export type ThinkingLevel = ReturnType<IAgentProfileService['getEffectiveThinkingLevel']>; +export type UsageStatus = Awaited<ReturnType<ISessionUsageService['status']>>; +export type AgentContextData = { + history: ReturnType<IAgentContextMemoryService['get']>; + tokenCount: ReturnType<ISessionTokenCountingService['statusSize']>; +}; +export type AgentCommandInfo = Awaited<ReturnType<IAgentCommandService['list']>>[number]; +export type RuntimeBinding = ReturnType<IAgentRuntimeBindingService['get']>; +export type PlanData = Awaited<ReturnType<IAgentPlanService['status']>>; +export type AgentTaskInfo = Awaited<ReturnType<IAgentTaskService['list']>>[number]; +export type McpServerEntry = ReturnType<IAgentMcpService['list']>[number]; + +export interface AgentFacade { + prompt(input: { + input: readonly ContentPart[]; + promptId?: string; + }): Promise<PromptLaunchResult>; + /** + * Submit one prompt with one or more skill activations bundled into the + * same user message: the skills are validated up front (an unknown name or + * an empty list rejects the whole submission), rendered ahead of the + * caller's parts in the same turn, and the bundle undoes as a single + * anchor. Resolves with the submitted bundle's queue identity (`prompt_id` + * / `created_at` / `state`), plus `turn_id` once launched — `state` is + * `queued` when the submission queued behind a running turn. + */ + promptWithSkills(input: PromptWithSkillsInput): Promise<PromptWithSkillsResult>; + steer(input: { input: readonly ContentPart[] }): Promise<PromptLaunchResult>; + /** + * Activate a skill as a user-slash activation: the engine renders the skill + * prompt and drives it as a normal turn (same settlement/event flow as + * `prompt`). Resolves with the launched turn id; rejects when the skill is + * unknown or the agent is busy. + */ + activateSkill(input: { name: string; args?: string }): Promise<PromptLaunchResult>; + cancel(input?: { turnId?: number }): Promise<void>; + runShellCommand(input: { command: string; commandId?: string }): Promise<ShellCommandResult>; + cancelShellCommand(input: { commandId: string }): Promise<void>; + getModel(): Promise<string>; + setModel(model: string): Promise<SetModelResult>; + getThinking(): Promise<ThinkingLevel>; + setThinking(level: string): Promise<void>; + setPermission(mode: PermissionMode): Promise<void>; + getUsage(): Promise<UsageStatus>; + getContext(): Promise<AgentContextData>; + listCommands(): Promise<readonly AgentCommandInfo[]>; + runCommand(input: { name: string; args?: string }): Promise<void>; + getRuntime(): Promise<RuntimeBinding>; + switchRuntime(runtimeId: string): Promise<RuntimeBinding>; + getPlan(): Promise<PlanData>; + enterPlan(): Promise<void>; + clearPlan(): Promise<void>; + cancelPlan(input?: { id?: string }): Promise<void>; + getTasks(input?: { activeOnly?: boolean; limit?: number }): Promise<readonly AgentTaskInfo[]>; + stopTask(input: { taskId: string; reason?: string }): Promise<void>; + getTaskOutput(input: { taskId: string; tail?: number }): Promise<string>; + /** + * Session-merged MCP server entries (workspace set + ephemeral session + * overlay). This is a live snapshot, so entries may still be pending while + * the initial connection attempt runs. + */ + getMcpServers(): Promise<readonly McpServerEntry[]>; + /** + * Trigger a manual full compaction. Async: `true` means the compaction was + * started (it runs in the background); `false` means one is already running. + * Throws when there is nothing to compact or a turn is active. + */ + compact(input?: { instruction?: string }): Promise<boolean>; +} + +export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFacade { + return { + prompt: (input) => + call(scope, 'agentPromptService', 'submit', [input]) as Promise<PromptLaunchResult>, + promptWithSkills: (input) => + call(scope, 'agentSkillService', 'promptWithSkills', [input]) as Promise<PromptWithSkillsResult>, + steer: (input) => + call(scope, 'agentPromptService', 'submitSteer', [input]) as Promise<PromptLaunchResult>, + activateSkill: (input) => + call(scope, 'agentSkillService', 'activate', [input]) as Promise<PromptLaunchResult>, + cancel: (input) => + // No turnId sends an empty arg list: `[undefined]` would cross the wire + // as `[null]`, and `cancelFromUser(null)` would not match the active turn. + call(scope, 'agentLoopService', 'cancelFromUser', input?.turnId === undefined ? [] : [input.turnId]) as Promise<void>, + runShellCommand: (input) => + call(scope, 'agentShellCommandService', 'run', [input]) as Promise<ShellCommandResult>, + cancelShellCommand: (input) => + call(scope, 'agentShellCommandService', 'cancel', [input.commandId]) as Promise<void>, + getModel: () => call(scope, 'agentProfileService', 'getModel', []) as Promise<string>, + setModel: (model) => + call(scope, 'agentProfileService', 'setModel', [model]) as Promise<SetModelResult>, + getThinking: () => + call(scope, 'agentProfileService', 'getEffectiveThinkingLevel', []) as Promise<ThinkingLevel>, + setThinking: (level) => + call(scope, 'agentProfileService', 'setThinking', [level]) as Promise<void>, + setPermission: (mode) => + call(scope, 'agentPermissionModeService', 'setModeAndBroadcast', [mode]) as Promise<void>, + getUsage: () => call(scope, 'agentUsageService', 'status', []) as Promise<UsageStatus>, + getContext: async () => { + const [history, tokenCount] = await Promise.all([ + call(scope, 'agentContextMemoryService', 'get', []), + call(scope, 'agentTokenCountingService', 'statusSize', []), + ]); + return { history, tokenCount } as AgentContextData; + }, + listCommands: () => + call(scope, 'agentCommandService', 'list', []) as Promise<readonly AgentCommandInfo[]>, + runCommand: (input) => + // Same `[undefined]` → `[null]` wire hazard as `cancel`: the engine's + // `args = ''` default only applies to a missing arg. + call( + scope, + 'agentCommandService', + 'run', + input.args === undefined ? [input.name] : [input.name, input.args], + ) as Promise<void>, + getRuntime: () => + call(scope, 'agentRuntimeBindingService', 'get', []) as Promise<RuntimeBinding>, + switchRuntime: (runtimeId) => + call(scope, 'agentRuntimeBindingService', 'switch', [runtimeId]) as Promise<RuntimeBinding>, + getPlan: () => call(scope, 'agentPlanService', 'status', []) as Promise<PlanData>, + enterPlan: () => call(scope, 'agentPlanService', 'enter', []) as Promise<void>, + clearPlan: () => call(scope, 'agentPlanService', 'clear', []) as Promise<void>, + cancelPlan: (input) => + call(scope, 'agentPlanService', 'cancel', [input?.id]) as Promise<void>, + getTasks: (input) => + call(scope, 'agentTaskService', 'list', [ + input?.activeOnly ?? false, + input?.limit, + ]) as Promise<readonly AgentTaskInfo[]>, + stopTask: async (input) => { + if (input.reason === undefined) { + await call(scope, 'agentTaskService', 'stopByUser', [input.taskId]); + return; + } + await call(scope, 'agentTaskService', 'stop', [input.taskId, input.reason]); + }, + getTaskOutput: (input) => + call(scope, 'agentTaskService', 'readOutput', [input.taskId, input.tail]) as Promise<string>, + getMcpServers: () => + call(scope, 'agentMcpService', 'list', []) as Promise<readonly McpServerEntry[]>, + compact: (input) => + call(scope, 'agentFullCompactionService', 'begin', [ + { source: 'manual', instruction: input?.instruction }, + ]) as Promise<boolean>, + }; +} diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts new file mode 100644 index 0000000000000000000000000000000000000000..196926d4e8ba3c79cf18f4e30759cdf330d4bb2c --- /dev/null +++ b/packages/klient/src/core/facade/global.ts @@ -0,0 +1,655 @@ +/** + * The `global` facade — aggregated, single-object-param methods over the + * engine's app-scope services. Each method maps to one underlying service + * call (except `env()`, which fans out and merges); the `Caller` underneath + * applies contract validation and hands the call to the transport. Facade + * code never sees service tokens, scope routing, or transport details. + */ + +import type { + SessionListQuery, + SessionSummary, +} from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; +import type { SessionMeta } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +import type { Page } from '@moonshot-ai/agent-core-v2/persistence/interface/queryStore'; +import type { + Workspace, + WorkspaceUpdate, +} from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; +import type { + ConfigDiagnostic, + ConfigInspectValue, + ConfigTarget, +} from '@moonshot-ai/agent-core-v2/app/config/config'; +import type { ProviderConfig } from '@moonshot-ai/agent-core-v2/llm-adapter/provider/provider'; +import type { + AuthStatus, + IOAuthService, + OAuthLoginOptions, +} from '@moonshot-ai/agent-core-v2/app/auth/auth'; +import type { ExperimentalFeatureState } from '@moonshot-ai/agent-core-v2/app/flag/flag'; +import type { + FsBrowseResponse, + FsHomeResponse, +} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +import type { FileMeta } from '@moonshot-ai/agent-core-v2/app/file/fileService'; +import type { ModelRecord } from '@moonshot-ai/agent-core-v2/llm-adapter/model/model'; +import type { IModelCatalog } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; +import type { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/app/kosongConfig/discovery'; + +import type { McpServerConfig } from '../../contract/mcp.js'; +import type { CallOptions } from '../channel.js'; +import type { + GlobalMcpServerConfig, + McpManagedServer, + McpServerAuthBeginResult, + McpServerAuthStatus, + McpServerInspection, + McpServerLocator, + McpServerTestResult, + McpServerTestTarget, +} from '@moonshot-ai/agent-core-v2/app/mcpManagement/mcpManagement'; +import type { AnonymousProviderInput, GenerateEvent, GenerateInput, GenerateParams, ProviderInput } from './kosong-types.js'; +import type { + PluginCommandDef, + PluginInfo, + PluginSummary, + PluginUpdateStatus, + ReloadSummary, +} from '@moonshot-ai/agent-core-v2/app/plugin/types'; +import type { CapabilityStatus } from '@moonshot-ai/agent-core-v2/app/capability/types'; + +/** Low-level caller the klient factory builds: routes + validates one service call. */ +export type Caller = ( + service: string, + method: string, + args: unknown[], + options?: CallOptions, +) => Promise<unknown>; + +/** Scoped variant — the factory's real signature; global methods bind the core scope. */ +export type ScopedCaller = ( + scope: { readonly workspaceId?: string; readonly sessionId?: string; readonly agentId?: string }, + service: string, + method: string, + args: unknown[], + options?: CallOptions, +) => Promise<unknown>; + +/** Streaming variant of `ScopedCaller` — returns a validated `AsyncIterable`. */ +export type ScopedStreamCaller = ( + scope: { readonly workspaceId?: string; readonly sessionId?: string; readonly agentId?: string }, + service: string, + method: string, + args: unknown[], +) => AsyncIterable<unknown>; + +// --------------------------------------------------------------------------- +// Wire-type aliases for engine-sourced shapes (not direct klient +// dependencies) — derived through the service interfaces. +// --------------------------------------------------------------------------- + +export type RefreshProviderModelsResponse = Awaited< + ReturnType<IOAuthService['refreshOAuthProviderModels']> +>; +export type OAuthFlowStart = Awaited<ReturnType<IOAuthService['startLogin']>>; +export type OAuthFlowSnapshot = NonNullable<Awaited<ReturnType<IOAuthService['getFlow']>>>; +export type OAuthLoginCancelResponse = Awaited<ReturnType<IOAuthService['cancelLogin']>>; +export type OAuthLogoutResponse = Awaited<ReturnType<IOAuthService['logout']>>; + +export type ModelCatalogItem = Awaited<ReturnType<IModelCatalog['listModels']>>[number]; +export type ProviderCatalogItem = Awaited< + ReturnType<IModelCatalog['listProviders']> +>[number]; +export type SetDefaultModelResponse = Awaited< + ReturnType<IModelCatalog['setDefaultModel']> +>; +export type RefreshProviderModelsOptions = NonNullable< + Parameters<IProviderDiscoveryService['refreshProviderModels']>[0] +>; + +/** String-literal form of the engine's `ConfigTarget` enum, so consumers never import the enum value. */ +export type ConfigTargetLiteral = `${ConfigTarget}`; + +// --------------------------------------------------------------------------- +// Facade interfaces +// --------------------------------------------------------------------------- + +export interface GlobalSessionsFacade { + list(query: SessionListQuery): Promise<Page<SessionSummary>>; + get(id: string): Promise<SessionSummary | undefined>; + countActive(workspaceIds: readonly string[]): Promise<number>; + /** + * Create a session rooted at `workDir` (the workspace is registered + * implicitly), optionally titled. Returns the persisted metadata. No agent + * is created — `session(id).agent('main')` materializes it on first use. + * `mcpServers` injects ephemeral per-session MCP servers: connected only + * for this session, never persisted. + */ + create(input: { + workDir: string; + additionalDirs?: readonly string[]; + title?: string; + mcpServers?: Readonly<Record<string, McpServerConfig>>; + }): Promise<SessionMeta>; +} + +export interface GlobalWorkspacesFacade { + list(): Promise<readonly Workspace[]>; + get(id: string): Promise<Workspace | undefined>; + createOrTouch(input: { root: string; name?: string }): Promise<Workspace>; + update(input: { id: string; patch: WorkspaceUpdate }): Promise<Workspace | undefined>; + delete(id: string): Promise<void>; +} + +export interface GlobalConfigFacade { + get<T = unknown>(domain: string): Promise<T>; + getAll(): Promise<Record<string, unknown>>; + inspect<T = unknown>(domain: string): Promise<ConfigInspectValue<T>>; + set(input: { domain: string; patch: unknown; target?: ConfigTargetLiteral }): Promise<void>; + replace(input: { + domain: string; + value: unknown; + target?: ConfigTargetLiteral; + }): Promise<void>; + /** + * Replace several domains in ONE atomic write (the engine's + * `IConfigService.replaceSections`): a domain mapped to `undefined` is + * cleared, domains absent from `sections` are left untouched. + */ + replaceSections(input: { + sections: Record<string, unknown>; + target?: ConfigTargetLiteral; + }): Promise<void>; + reload(): Promise<void>; + diagnostics(): Promise<readonly ConfigDiagnostic[]>; +} + +export interface GlobalKosongFacade { + // -- Provider --------------------------------------------------------- + listProviders(): Promise<readonly ProviderCatalogItem[]>; + getProvider(id: string): Promise<ProviderCatalogItem>; + /** Add a named provider (string id + config) or an anonymous single-model provider (object). */ + addProvider(id: string, config: ProviderInput): Promise<void>; + addProvider(config: AnonymousProviderInput): Promise<void>; + removeProvider(id: string): Promise<void>; + refreshProviders(opts?: RefreshProviderModelsOptions): Promise<RefreshProviderModelsResponse>; + + // -- Model ------------------------------------------------------------ + listModels(): Promise<readonly ModelCatalogItem[]>; + setDefaultModel(id: string): Promise<SetDefaultModelResponse>; + + // -- Generate (streaming) ----------------------------------------------- + generate( + modelId: string, + input: GenerateInput, + params?: GenerateParams, + ): AsyncIterable<GenerateEvent>; +} + +export interface GlobalAuthFacade { + status(provider?: string): Promise<AuthStatus>; + summarize(): Promise<readonly AuthStatus[]>; + /** + * The engine's own auth-readiness probe for a model (the default model when + * omitted): resolves config-file apiKey / provider env-bag credentials or an + * OAuth token, throwing a typed auth error when nothing resolves. Actual + * model usage does not depend on the OAuth-only {@link summarize} view. + */ + ensureReady(modelOverride?: string): Promise<void>; + startLogin(provider?: string, options?: OAuthLoginOptions): Promise<OAuthFlowStart>; + flow(provider?: string): Promise<OAuthFlowSnapshot | undefined>; + cancelLogin(provider?: string): Promise<OAuthLoginCancelResponse>; + logout(provider?: string): Promise<OAuthLogoutResponse>; + /** + * @deprecated Use `kosong.refreshProviders({ scope: 'oauth' })` — the + * kosong facade owns provider-model refresh; this alias remains for one + * release cycle. + */ + refreshProviderModels(): Promise<RefreshProviderModelsResponse>; +} + +export interface GlobalFlagsFacade { + list(): Promise<readonly ExperimentalFeatureState[]>; + enabled(id: string): Promise<boolean>; + enabledIds(): Promise<readonly string[]>; + explain(id: string): Promise<ExperimentalFeatureState | undefined>; + snapshot(): Promise<Record<string, boolean>>; +} + +export interface GlobalCapabilitiesFacade { + list(): Promise<readonly CapabilityStatus[]>; + get(id: string): Promise<CapabilityStatus>; + install(id: string): Promise<CapabilityStatus>; +} + +export interface GlobalPluginsFacade { + list(): Promise<readonly PluginSummary[]>; + info(id: string): Promise<PluginInfo>; + install(source: string): Promise<PluginSummary>; + setEnabled(input: { id: string; enabled: boolean }): Promise<void>; + setMcpServerEnabled(input: { id: string; server: string; enabled: boolean }): Promise<void>; + remove(id: string): Promise<void>; + reload(): Promise<ReloadSummary>; + checkUpdates(): Promise<readonly PluginUpdateStatus[]>; + listCommands(): Promise<readonly PluginCommandDef[]>; +} + +export interface GlobalHostFsFacade { + browse(absPath?: string): Promise<FsBrowseResponse>; + home(): Promise<FsHomeResponse>; +} + +/** + * The unified MCP management plane (engine `IMcpManagementService`, App + * scope): CRUD on the user-level `mcp.json`, a connection test probe, the + * locator-addressed inspection catalog, the auth-status surface, and the + * locator-addressed OAuth flow operations. + */ +export interface GlobalMcpFacade { + list(input?: { cwd?: string }): Promise<readonly McpManagedServer[]>; + get(input: { name: string; cwd?: string }): Promise<McpManagedServer>; + /** Add a user-level entry; a same-named read-only entry rejects. Returns the refreshed list. */ + add(input: { + server: GlobalMcpServerConfig; + cwd?: string; + }): Promise<readonly McpManagedServer[]>; + /** Replace a user-level entry; read-only entries reject. Returns the refreshed list. */ + update(input: { + server: GlobalMcpServerConfig; + cwd?: string; + }): Promise<readonly McpManagedServer[]>; + /** Remove a user-level entry; read-only entries reject. Returns the refreshed list. */ + remove(input: { name: string; cwd?: string }): Promise<readonly McpManagedServer[]>; + /** Probe a real connection: a registry `name`, or an inline `server` config as-is. */ + test(input: McpServerTestTarget): Promise<McpServerTestResult>; + /** The locator-addressed catalog plus a batched real-connection probe of OAuth candidates. */ + inspect(input?: { + targets?: readonly McpServerLocator[]; + cwd?: string; + }): Promise<readonly McpServerInspection[]>; + /** Per-server OAuth state; omitted `verify` detects implicit OAuth, `false` stays offline. */ + authStatuses(input?: { + cwd?: string; + verify?: boolean; + }): Promise<readonly McpServerAuthStatus[]>; + /** Resolve a legacy name-only auth target to its unambiguous locator. */ + resolveByName(input: { name: string; cwd?: string }): Promise<McpServerLocator>; + beginAuth(input: { + locator: McpServerLocator; + cwd?: string; + }): Promise<McpServerAuthBeginResult>; + completeAuth(input: { flowId: string; timeoutMs?: number }): Promise<void>; + cancelAuth(input: { flowId: string }): Promise<void>; + resetAuth(input: { locator: McpServerLocator; cwd?: string }): Promise<void>; +} + +/** One downloaded upload: its metadata plus the buffered bytes. */ +export interface FileDownload { + readonly meta: FileMeta; + readonly data: Uint8Array; +} + +export interface GlobalFilesFacade { + /** + * Upload buffered bytes to the daemon's file store. Bytes cross the wire + * base64-encoded (JSON cannot carry them), so very large uploads pay one + * encode here and one decode in the dispatcher. + */ + save(input: { + data: Uint8Array; + filename: string; + name?: string; + mimeType?: string; + expiresInSec?: number; + }): Promise<FileMeta>; + /** Download one upload back into memory. */ + get(fileId: string): Promise<FileDownload>; + delete(fileId: string): Promise<void>; +} + +/** Aggregated host/environment snapshot (`bootstrapService` properties). */ +export interface KlientEnvInfo { + readonly platform: string; + readonly arch: string; + readonly cwd: string; + readonly osHomeDir: string; + readonly homeDir: string; + readonly configPath: string; + readonly clientVersion: string; + readonly sessionsDir: string; + readonly blobsDir: string; + readonly storeDir: string; + readonly cacheDir: string; + readonly logsDir: string; +} + +export interface GlobalFacade { + readonly sessions: GlobalSessionsFacade; + readonly workspaces: GlobalWorkspacesFacade; + readonly config: GlobalConfigFacade; + readonly kosong: GlobalKosongFacade; + readonly auth: GlobalAuthFacade; + readonly flags: GlobalFlagsFacade; + readonly plugins: GlobalPluginsFacade; + readonly capabilities: GlobalCapabilitiesFacade; + readonly hostFs: GlobalHostFsFacade; + readonly files: GlobalFilesFacade; + readonly mcp: GlobalMcpFacade; + env(): Promise<KlientEnvInfo>; +} + +// --------------------------------------------------------------------------- +// Implementation — thin reshaping over `Caller`. Casts are safe by +// construction: the contract validates outputs, and type-parity assertions +// tie every contract schema to its engine type. +// --------------------------------------------------------------------------- + +const ENV_SCALAR_PROPERTIES = [ + 'platform', + 'arch', + 'cwd', + 'osHomeDir', + 'homeDir', + 'configPath', + 'sessionsDir', + 'blobsDir', + 'storeDir', + 'cacheDir', + 'logsDir', +] as const; + +// The IPC transport enforces a per-call deadline (default 30s) that would +// truncate the completeAuth long poll: the engine waits up to +// `DEFAULT_AUTH_TIMEOUT_MS` for the browser callback when the caller omits +// `timeoutMs` (agent-core-v2 `mcpManagementService.ts`), and the +// authorization-code exchange afterwards is itself bounded at 30s per grant +// request (agent-core-v2 `mcpCore/oauth/service.ts`). The per-call deadline +// below covers both, so IPC behaves like the timeout-free memory transport. +const DEFAULT_AUTH_TIMEOUT_MS = 15 * 60_000; +const AUTH_COMPLETION_MARGIN_MS = 30_000; + +export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStreamCaller): GlobalFacade { + const call: Caller = (service, method, args, options) => scoped({}, service, method, args, options); + const streamCall = (service: string, method: string, args: unknown[]) => + scopedStream({}, service, method, args); + // The bootstrap snapshot is frozen at process start, so the aggregated + // env() result can never change — resolve it once and reuse the promise. + let envPromise: Promise<KlientEnvInfo> | undefined; + const env = (): Promise<KlientEnvInfo> => { + envPromise ??= Promise.all([ + ...ENV_SCALAR_PROPERTIES.map((prop) => call('bootstrapService', prop, []) as Promise<string>), + // The wire surface keeps `clientVersion` (a string); it is sourced from + // the bootstrap clientIdentity, which replaced the flat scalar. + call('bootstrapService', 'clientIdentity', []) as Promise<{ version: string }>, + ]).then((values) => { + const scalars = Object.fromEntries( + ENV_SCALAR_PROPERTIES.map((prop, index) => [prop, values[index]]), + ); + const identity = values[values.length - 1] as { version: string }; + return { ...scalars, clientVersion: identity.version } as unknown as KlientEnvInfo; + }); + return envPromise; + }; + + return { + sessions: { + list: (query) => + call('sessionIndex', 'listRecent', [query]) as Promise<Page<SessionSummary>>, + get: (id) => call('sessionIndex', 'get', [id]) as Promise<SessionSummary | undefined>, + countActive: (workspaceIds) => + call('sessionIndex', 'count', [{ workspaceIds }]) as Promise<number>, + create: async ({ workDir, additionalDirs, title, mcpServers }) => { + const handle = (await scoped({}, 'sessionManager', 'create', [ + { workDir, additionalDirs, mcpServers }, + ])) as { id: string }; + const scope = { sessionId: handle.id }; + if (title !== undefined) { + await scoped(scope, 'sessionMetadata', 'setTitle', [title]); + } + return scoped(scope, 'sessionMetadata', 'read', []) as Promise<SessionMeta>; + }, + }, + + workspaces: { + list: () => call('workspaceService', 'list', []) as Promise<readonly Workspace[]>, + get: (id) => call('workspaceService', 'get', [id]) as Promise<Workspace | undefined>, + createOrTouch: ({ root, name }) => + call('workspaceService', 'createOrTouch', [root, name]) as Promise<Workspace>, + update: ({ id, patch }) => + call('workspaceService', 'update', [id, patch]) as Promise<Workspace | undefined>, + delete: (id) => call('workspaceService', 'delete', [id]) as Promise<void>, + }, + + config: { + get: <T>(domain: string) => call('configService', 'get', [domain]) as Promise<T>, + getAll: () => call('configService', 'getAll', []) as Promise<Record<string, unknown>>, + inspect: <T>(domain: string) => + call('configService', 'inspect', [domain]) as Promise<ConfigInspectValue<T>>, + set: ({ domain, patch, target }) => + call('configService', 'set', [domain, patch, target]) as Promise<void>, + replace: ({ domain, value, target }) => + // `null` is the wire encoding of "clear this domain" — JSON + // round-trips cannot carry `undefined` (see IConfigService.replace). + call('configService', 'replace', [domain, value === undefined ? null : value, target]) as Promise<void>, + replaceSections: ({ sections, target }) => + call('configService', 'replaceSections', [ + Object.fromEntries( + Object.entries(sections).map(([domain, value]) => [ + domain, + value === undefined ? null : value, + ]), + ), + target, + ]) as Promise<void>, + reload: () => call('configService', 'reload', []) as Promise<void>, + diagnostics: () => + call('configService', 'diagnostics', []) as Promise<readonly ConfigDiagnostic[]>, + }, + + kosong: { + listProviders: () => + call('modelResolver', 'listProviders', []) as Promise< + readonly ProviderCatalogItem[] + >, + getProvider: (id) => + call('modelResolver', 'getProvider', [id]) as Promise<ProviderCatalogItem>, + addProvider: (( + idOrConfig: string | AnonymousProviderInput, + maybeConfig?: ProviderInput, + ): Promise<void> => { + if (typeof idOrConfig === 'string') { + // Named provider — map ProviderInput to ProviderConfig wire shape. + const config = maybeConfig!; + const wire: ProviderConfig = { + type: config.type, + baseUrl: config.baseUrl, + defaultModel: config.defaultModel, + apiKey: config.auth.method === 'api-key' ? config.auth.apiKey : '', + }; + return call('providerService', 'set', [idOrConfig, wire]) as Promise<void>; + } + // Anonymous provider — map AnonymousProviderInput to ModelRecord wire shape. + const anon = idOrConfig; + const capabilities = anon.capabilities + ? Object.entries(anon.capabilities) + .filter(([, v]) => v) + .map(([k]) => k) + : undefined; + const wire: ModelRecord = { + model: anon.model, + protocol: anon.protocol as ModelRecord['protocol'], + baseUrl: anon.baseUrl, + apiKey: anon.auth.method === 'api-key' ? anon.auth.apiKey : '', + displayName: anon.displayName, + maxContextSize: anon.maxContextSize, + capabilities, + }; + return call('modelService', 'set', [anon.id, wire]) as Promise<void>; + }) as GlobalKosongFacade['addProvider'], + removeProvider: async (id) => { + // Try provider registry first; fall back to model registry. + const existing = await call('providerService', 'get', [id]); + if (existing !== undefined) { + return call('providerService', 'delete', [id]) as Promise<void>; + } + return call('modelService', 'delete', [id]) as Promise<void>; + }, + refreshProviders: (opts) => + call('providerDiscovery', 'refreshProviderModels', [ + opts, + ]) as Promise<RefreshProviderModelsResponse>, + + listModels: () => + call('modelResolver', 'listModels', []) as Promise<readonly ModelCatalogItem[]>, + setDefaultModel: (id) => + call('modelResolver', 'setDefaultModel', [id]) as Promise<SetDefaultModelResponse>, + + generate: (modelId, input, params) => + streamCall('modelResolver', 'generate', [modelId, input, params]) as AsyncIterable<GenerateEvent>, + }, + + auth: { + status: (provider) => call('oauthService', 'status', [provider]) as Promise<AuthStatus>, + summarize: () => call('authSummaryService', 'summarize', []) as Promise<readonly AuthStatus[]>, + ensureReady: (modelOverride) => + call('authSummaryService', 'ensureReady', [modelOverride]) as Promise<void>, + startLogin: (provider, options) => + call('oauthService', 'startLogin', [provider, options]) as Promise<OAuthFlowStart>, + flow: (provider) => + call('oauthService', 'getFlow', [provider]) as Promise<OAuthFlowSnapshot | undefined>, + cancelLogin: (provider) => + call('oauthService', 'cancelLogin', [provider]) as Promise<OAuthLoginCancelResponse>, + logout: (provider) => + call('oauthService', 'logout', [provider]) as Promise<OAuthLogoutResponse>, + refreshProviderModels: () => + call('oauthService', 'refreshOAuthProviderModels', []) as Promise<RefreshProviderModelsResponse>, + }, + + flags: { + list: () => call('flagService', 'explainAll', []) as Promise<readonly ExperimentalFeatureState[]>, + enabled: (id) => call('flagService', 'enabled', [id]) as Promise<boolean>, + enabledIds: () => call('flagService', 'enabledIds', []) as Promise<readonly string[]>, + explain: (id) => + call('flagService', 'explain', [id]) as Promise<ExperimentalFeatureState | undefined>, + snapshot: () => call('flagService', 'snapshot', []) as Promise<Record<string, boolean>>, + }, + + plugins: { + list: () => call('pluginService', 'listPlugins', []) as Promise<readonly PluginSummary[]>, + info: (id) => call('pluginService', 'getPluginInfo', [{ id }]) as Promise<PluginInfo>, + install: (source) => + call('pluginService', 'installPlugin', [{ source }]) as Promise<PluginSummary>, + setEnabled: (input) => call('pluginService', 'setPluginEnabled', [input]) as Promise<void>, + setMcpServerEnabled: (input) => + call('pluginService', 'setPluginMcpServerEnabled', [input]) as Promise<void>, + remove: (id) => call('pluginService', 'removePlugin', [{ id }]) as Promise<void>, + reload: () => call('pluginService', 'reloadPlugins', []) as Promise<ReloadSummary>, + checkUpdates: () => + call('pluginService', 'checkUpdates', []) as Promise<readonly PluginUpdateStatus[]>, + listCommands: () => + call('pluginService', 'listPluginCommands', []) as Promise<readonly PluginCommandDef[]>, + }, + + capabilities: { + list: () => call('capabilityService', 'listCapabilities', []) as Promise<readonly CapabilityStatus[]>, + get: (id) => call('capabilityService', 'getCapability', [id]) as Promise<CapabilityStatus>, + install: (id) => + call('capabilityService', 'installCapability', [id]) as Promise<CapabilityStatus>, + }, + + hostFs: { + browse: (absPath) => + call('hostFolderBrowser', 'browse', [absPath]) as Promise<FsBrowseResponse>, + home: () => call('hostFolderBrowser', 'home', []) as Promise<FsHomeResponse>, + }, + + files: { + save: ({ data, filename, name, mimeType, expiresInSec }) => + call('fileService', 'save', [ + Buffer.from(data).toString('base64'), + filename, + { name, mimeType, expiresInSec }, + ]) as Promise<FileMeta>, + get: async (fileId) => { + const wire = (await call('fileService', 'get', [fileId])) as { + meta: FileMeta; + data: string; + }; + return { meta: wire.meta, data: Buffer.from(wire.data, 'base64') }; + }, + delete: (fileId) => call('fileService', 'delete', [fileId]) as Promise<void>, + }, + + mcp: { + list: (input) => + call('mcpManagementService', 'listServers', [ + input === undefined ? undefined : { cwd: input.cwd }, + ]) as Promise<readonly McpManagedServer[]>, + get: ({ name, cwd }) => + call('mcpManagementService', 'getServer', [ + name, + cwd === undefined ? undefined : { cwd }, + ]) as Promise<McpManagedServer>, + add: ({ server, cwd }) => + call('mcpManagementService', 'addServer', [ + server, + cwd === undefined ? undefined : { cwd }, + ]) as Promise< + readonly McpManagedServer[] + >, + update: ({ server, cwd }) => + call('mcpManagementService', 'updateServer', [ + server, + cwd === undefined ? undefined : { cwd }, + ]) as Promise< + readonly McpManagedServer[] + >, + remove: ({ name, cwd }) => + call('mcpManagementService', 'removeServer', [ + name, + cwd === undefined ? undefined : { cwd }, + ]) as Promise< + readonly McpManagedServer[] + >, + test: (target) => + call('mcpManagementService', 'testServer', [target]) as Promise<McpServerTestResult>, + inspect: (input) => + call('mcpManagementService', 'inspectServers', [ + input?.targets, + input === undefined ? undefined : { cwd: input.cwd }, + ]) as Promise< + readonly McpServerInspection[] + >, + authStatuses: (input) => + call('mcpManagementService', 'listAuthStatuses', [ + input === undefined ? undefined : { cwd: input.cwd, verify: input.verify }, + ]) as Promise<readonly McpServerAuthStatus[]>, + resolveByName: ({ name, cwd }) => + call('mcpManagementService', 'resolveServerByName', [name, { cwd }]) as Promise< + McpServerLocator + >, + beginAuth: ({ locator, cwd }) => + call('mcpManagementService', 'beginServerAuth', [ + locator, + { cwd }, + ]) as Promise<McpServerAuthBeginResult>, + completeAuth: ({ flowId, timeoutMs }) => + call('mcpManagementService', 'completeServerAuth', [{ flowId, timeoutMs }], { + // Clamp to Node's 32-bit timer ceiling: `timeoutMs` may legally be + // the contract max (2**31 - 1), and adding the margin would + // overflow setTimeout into a ~1ms deadline. + timeoutMs: Math.min( + (timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS) + AUTH_COMPLETION_MARGIN_MS, + 2 ** 31 - 1, + ), + }) as Promise<void>, + cancelAuth: ({ flowId }) => + call('mcpManagementService', 'cancelServerAuth', [{ flowId }]) as Promise<void>, + resetAuth: ({ locator, cwd }) => + call('mcpManagementService', 'resetServerAuth', [locator, { cwd }]) as Promise<void>, + }, + + env, + }; +} diff --git a/packages/klient/src/core/facade/kosong-types.ts b/packages/klient/src/core/facade/kosong-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..84116e993736aa541854bd29bb1b93167d5d597b --- /dev/null +++ b/packages/klient/src/core/facade/kosong-types.ts @@ -0,0 +1,78 @@ +/** + * Public input types for the merged `global.kosong` facade — the shapes + * callers use to add providers and models. These are facade-level + * abstractions; the implementation maps them to the underlying + * `providerService`/`modelService` wire formats. + */ + +import type { Message } from '@moonshot-ai/agent-core-v2/llm-adapter/contract/message'; +import type { + StreamedMessagePart, + ToolDescription as Tool, +} from '@moonshot-ai/agent-core-v2/human/llm/message'; +import type { TokenUsage } from '@moonshot-ai/agent-core-v2/human/llm/usage'; +import type { ResponseFormat } from '@moonshot-ai/agent-core-v2/human/llm/response-format'; + +// --------------------------------------------------------------------------- +// Provider auth +// --------------------------------------------------------------------------- + +/** How the provider authenticates — API key or managed OAuth. */ +export type ProviderAuth = + | { method: 'api-key'; apiKey: string } + | { method: 'oauth' }; + +// --------------------------------------------------------------------------- +// Provider / model inputs +// --------------------------------------------------------------------------- + +/** Named provider configuration passed to `kosong.addProvider(id, config)`. */ +export interface ProviderInput { + type: string; + baseUrl?: string; + auth: ProviderAuth; + defaultModel?: string; +} + +/** + * Anonymous (single-model) provider input — all connectivity details inline. + * Passed as a single object to `kosong.addProvider(config)`. + */ +export interface AnonymousProviderInput { + /** Used as the model identifier in the model registry. */ + id: string; + /** Wire model name sent to the provider. */ + model: string; + /** Protocol identifier (`'openai'`, `'anthropic'`, …). */ + protocol: string; + baseUrl: string; + auth: ProviderAuth; + displayName?: string; + maxContextSize?: number; + capabilities?: Record<string, boolean>; +} + +// --------------------------------------------------------------------------- +// Generate (streaming) +// --------------------------------------------------------------------------- + +export interface GenerateInput { + readonly systemPrompt: string; + readonly messages: readonly Message[]; + readonly tools?: readonly Tool[]; + readonly responseFormat?: ResponseFormat; +} + +export interface GenerateParams { + readonly cacheKey?: string; + readonly temperature?: number; + readonly topP?: number; + readonly thinkingEffort?: string; + readonly maxCompletionTokens?: number; +} + +export type GenerateEvent = + | { readonly type: 'part'; readonly part: StreamedMessagePart } + | { readonly type: 'usage'; readonly usage: TokenUsage; readonly model?: string } + | { readonly type: 'finish'; readonly message: Message; readonly finishReason?: string; readonly id?: string } + | { readonly type: 'timing'; readonly firstTokenLatencyMs: number; readonly streamDurationMs: number; readonly [key: string]: unknown }; diff --git a/packages/klient/src/core/facade/session.ts b/packages/klient/src/core/facade/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f4487a7bfbaf087fdc4c2f0e9340961cbac82a1 --- /dev/null +++ b/packages/klient/src/core/facade/session.ts @@ -0,0 +1,198 @@ +/** + * The session facade — one `klient.session(id)` handle aggregating the + * session-scope services (metadata, activity, approvals, questions, + * interactions) plus the app-scope lifecycle service for close/archive/ + * restore/delete/fork/createChild. `agents()` reads the metadata registry (agent + * handles are not serializable, so no agent-lifecycle channel exists on the + * wire). + */ + +import type { + ApprovalRequest, + ApprovalResponse, +} from '@moonshot-ai/agent-core-v2/agent/interaction/approval'; +import type { + Interaction, + InteractionKind, +} from '@moonshot-ai/agent-core-v2/human/interaction/interaction'; +import type { + QuestionRequest, + QuestionResult, +} from '@moonshot-ai/agent-core-v2/agent/interaction/question'; +import type { + AgentMeta, + SessionMeta, + SessionMetaPatch, +} from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +import type { SkillSummary } from '@moonshot-ai/agent-core-v2/features/skill/catalog/types'; + +import type { ScopeRef } from '../channel.js'; +import type { McpServerConfig } from '../../contract/mcp.js'; +import type { ScopedCaller } from './global.js'; + +export type { ScopedCaller } from './global.js'; + +/** What `sessionLifecycleService.create` and `sessionManager.restore` leave on the wire. */ +interface HandleWire { + readonly id: string; +} + +/** + * Options for `SessionFacade.restore` — mirrors the engine's + * `ResumeSessionOptions`. `mcpServers` injects ephemeral per-session MCP + * servers when restore re-materializes a cold session (ignored when the + * session is already live). + */ +export interface SessionRestoreOptions { + readonly additionalDirs?: readonly string[]; + readonly mcpServers?: Readonly<Record<string, McpServerConfig>>; +} + +export interface SessionApprovalsFacade { + list(): Promise<readonly ApprovalRequest[]>; + decide(id: string, response: ApprovalResponse): Promise<void>; +} + +export interface SessionQuestionsFacade { + list(): Promise<readonly QuestionRequest[]>; + answer(id: string, result: QuestionResult): Promise<void>; + dismiss(id: string): Promise<void>; +} + +export interface SessionInteractionsFacade { + list(kind?: InteractionKind): Promise<readonly Interaction[]>; + respond(id: string, response: unknown): Promise<void>; +} + +export interface SessionSkillsFacade { + /** + * Every skill in the session-merged catalog as a plain summary (the + * catalog's readiness is resolved engine-side). Subscribe to + * `session.events` `'skills.changed'` for updates. + */ + list(): Promise<readonly SkillSummary[]>; +} + +/** + * Derived session lifecycle phase. The facade reads the engine's session + * activity view (busy + pending interaction) and maps it onto the v1 + * precedence: pending approvals and questions first, then busy, then idle. + */ +export type SessionStatus = 'running' | 'idle' | 'awaiting_approval' | 'awaiting_question'; + +export interface SessionFacade { + get(): Promise<SessionMeta>; + setTitle(title: string): Promise<void>; + /** + * Generate and apply a title from the main agent's first prompts via the + * managed `chat_title` tool. `undefined` when generation is unavailable + * (no managed OAuth login, no prompt yet, or a custom title is set). + * `force` regenerates anyway, overwriting a generated or custom title. + * `source` picks the conversation excerpt: `user_prompts` (default), + * `first_turn` (opening prompt + first reply; strict), or `digest` + * (head+tail of a multi-turn conversation). + */ + generateTitle(opts?: { + force?: boolean; + source?: 'user_prompts' | 'first_turn' | 'digest'; + }): Promise<string | undefined>; + update(patch: SessionMetaPatch): Promise<void>; + setArchived(archived: boolean): Promise<void>; + status(): Promise<SessionStatus>; + close(): Promise<void>; + archive(): Promise<void>; + /** Re-materialize a closed session; `false` when it no longer exists. */ + restore(opts?: SessionRestoreOptions): Promise<boolean>; + /** Permanently delete the session and its persisted data; throws when missing. */ + delete(): Promise<void>; + fork(input?: { title?: string; metadata?: Record<string, unknown> }): Promise<SessionMeta>; + createChild(input?: { title?: string; metadata?: Record<string, unknown> }): Promise<SessionMeta>; + readonly approvals: SessionApprovalsFacade; + readonly questions: SessionQuestionsFacade; + readonly interactions: SessionInteractionsFacade; + readonly skills: SessionSkillsFacade; + /** Agent id → metadata for every agent registered in this session. */ + agents(): Promise<Readonly<Record<string, AgentMeta>>>; +} + +export function createSessionFacade(call: ScopedCaller, sessionId: string): SessionFacade { + const scope: ScopeRef = { sessionId }; + const read = (): Promise<SessionMeta> => + call(scope, 'sessionMetadata', 'read', []) as Promise<SessionMeta>; + const spawn = async ( + method: 'fork' | 'createChild', + input: { title?: string; metadata?: Record<string, unknown> } = {}, + ): Promise<SessionMeta> => { + return call({}, 'sessionManager', method, [ + { sourceSessionId: sessionId, title: input.title, metadata: input.metadata }, + ]) as Promise<SessionMeta>; + }; + + return { + get: read, + setTitle: (title) => call(scope, 'sessionMetadata', 'setTitle', [title]) as Promise<void>, + generateTitle: (opts) => + call(scope, 'sessionTitleService', 'generateTitle', [opts]) as Promise< + string | undefined + >, + update: (patch) => call(scope, 'sessionMetadata', 'update', [patch]) as Promise<void>, + setArchived: (archived) => + call(scope, 'sessionMetadata', 'setArchived', [archived]) as Promise<void>, + status: async () => { + const activity = (await call(scope, 'sessionActivityView', 'state', [])) as { + readonly busy: boolean; + readonly pendingInteraction: 'none' | 'approval' | 'question'; + }; + if (activity.pendingInteraction === 'approval') return 'awaiting_approval'; + if (activity.pendingInteraction === 'question') return 'awaiting_question'; + return activity.busy ? 'running' : 'idle'; + }, + close: () => call({}, 'sessionManager', 'close', [sessionId]) as Promise<void>, + archive: () => call({}, 'sessionManager', 'archive', [sessionId]) as Promise<void>, + restore: async (opts) => { + const handle = (await call({}, 'sessionManager', 'restore', [sessionId, opts])) as HandleWire | null; + return handle !== null && handle !== undefined; + }, + delete: () => call({}, 'sessionManager', 'delete', [sessionId]) as Promise<void>, + fork: (input) => spawn('fork', input), + createChild: (input) => spawn('createChild', input), + + approvals: { + list: () => + call(scope, 'sessionApprovalService', 'listPending', []) as Promise< + readonly ApprovalRequest[] + >, + decide: (id, response) => + call(scope, 'sessionApprovalService', 'decide', [id, response]) as Promise<void>, + }, + + questions: { + list: () => + call(scope, 'sessionQuestionService', 'listPending', []) as Promise< + readonly QuestionRequest[] + >, + answer: (id, result) => + call(scope, 'sessionQuestionService', 'answer', [id, result]) as Promise<void>, + dismiss: (id) => call(scope, 'sessionQuestionService', 'dismiss', [id]) as Promise<void>, + }, + + interactions: { + list: (kind) => + call(scope, 'sessionInteractionService', 'listPending', [kind]) as Promise< + readonly Interaction[] + >, + respond: (id, response) => + call(scope, 'sessionInteractionService', 'respond', [id, response]) as Promise<void>, + }, + + skills: { + list: () => + call(scope, 'sessionSkillCatalog', 'list', []) as Promise<readonly SkillSummary[]>, + }, + + agents: async () => { + const meta = await read(); + return meta.agents ?? {}; + }, + }; +} diff --git a/packages/klient/src/core/klient.ts b/packages/klient/src/core/klient.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0312c2c9b9fb2ef6c022e6a420926199b6245df --- /dev/null +++ b/packages/klient/src/core/klient.ts @@ -0,0 +1,134 @@ +/** + * The transport-agnostic klient factory. Every transport entry point + * (`@moonshot-ai/klient/ipc|memory`) builds a `KlientChannel` and hands + * it here; the returned `Klient` is identical in shape and behavior no matter + * which transport carried the bytes. + */ + +import type { KlientChannel, ScopeRef } from './channel.js'; +import { globalContract, isStreamingContract } from '#/contract/index'; +import { globalEvents, type KlientEventPayloads } from '#/contract/global/events'; +import { sessionEvents, type SessionEventPayloads } from '#/contract/session/events'; +import { agentEvents, type AgentEventPayloads } from '#/contract/agent/events'; +import type { EventRegistration, StreamingProcedureContract } from '#/contract/types'; +import { EventHub, type KlientEvents } from './events/hub.js'; +import { createGlobalFacade, type GlobalFacade, type ScopedCaller, type ScopedStreamCaller } from './facade/global.js'; +import { createSessionFacade, type SessionFacade } from './facade/session.js'; +import { createAgentFacade, type AgentFacade } from './facade/agent.js'; +import { parseChunk, parseInput, parseOutput } from './validation.js'; + +export interface KlientOptions { + /** + * Validate wire inputs/outputs and event payloads against the contract. + * Default `true`. Disable only on measured hot paths — validation is cheap + * (sub-µs for typical payloads) and is the drift tripwire. + */ + readonly validate?: boolean; +} + +export interface SessionHandle extends SessionFacade { + readonly events: KlientEvents<SessionEventPayloads>; + agent(agentId: string): AgentHandle; +} + +export interface AgentHandle extends AgentFacade { + readonly events: KlientEvents<AgentEventPayloads>; +} + +export interface Klient { + readonly global: GlobalFacade; + readonly events: KlientEvents; + session(sessionId: string): SessionHandle; + close(): Promise<void>; +} + +export function createKlientFromChannel( + channel: KlientChannel, + options: KlientOptions = {}, +): Klient { + const validate = options.validate ?? true; + + const call: ScopedCaller = async (scope, service, method, args, options) => { + const procedure = globalContract[service]?.[method]; + if (procedure === undefined) { + // A facade method without a contract entry is a klient bug, not a wire error. + throw new Error(`no contract registered for ${service}.${method}`); + } + if (isStreamingContract(procedure)) { + throw new Error(`${service}.${method} is a streaming procedure — use callStream instead`); + } + const name = `${service}.${method}`; + const wireArgs = validate ? parseInput(name, procedure, args) : args; + const data = await channel.call(scope, service, method, wireArgs, options); + return validate ? parseOutput(name, procedure, data) : data; + }; + + const callStream: ScopedStreamCaller = (scope, service, method, args) => { + const procedure = globalContract[service]?.[method]; + if (procedure === undefined) { + throw new Error(`no contract registered for ${service}.${method}`); + } + if (!isStreamingContract(procedure)) { + throw new Error(`${service}.${method} is not a streaming procedure — use call instead`); + } + const name = `${service}.${method}`; + const wireArgs = validate ? parseInput(name, procedure, args) : args; + const source = channel.stream(scope, service, method, wireArgs); + if (!validate) return source; + + // Wrap the iterable to validate each chunk. + const contract = procedure as StreamingProcedureContract; + return { + [Symbol.asyncIterator]() { + const iter = source[Symbol.asyncIterator](); + return { + async next() { + const result = await iter.next(); + if (result.done) return { done: true as const, value: undefined }; + return { done: false, value: parseChunk(name, contract, result.value) }; + }, + async return(value?: unknown) { + await iter.return?.(value); + return { done: true as const, value: undefined }; + }, + }; + }, + }; + }; + + const hubs = new Set<{ close(): void }>(); + const makeHub = <TPayloadMap extends object>( + scope: ScopeRef, + registrations: Record<string, EventRegistration>, + ): KlientEvents<TPayloadMap> => { + const hub = new EventHub<TPayloadMap>(channel, validate, scope, registrations); + hubs.add(hub); + return hub; + }; + + return { + global: createGlobalFacade(call, callStream), + events: makeHub<KlientEventPayloads>({}, globalEvents), + session(sessionId: string): SessionHandle { + const scope: ScopeRef = { sessionId }; + return { + ...createSessionFacade(call, sessionId), + events: makeHub<SessionEventPayloads>(scope, sessionEvents), + agent(agentId: string): AgentHandle { + const agentScope: ScopeRef = { sessionId, agentId }; + return { + ...createAgentFacade(call, agentScope), + events: makeHub<AgentEventPayloads>(agentScope, agentEvents), + }; + }, + }; + }, + close: () => { + for (const hub of hubs) { + hub.close(); + } + hubs.clear(); + return channel.close(); + }, + }; +} diff --git a/packages/klient/src/core/validation.ts b/packages/klient/src/core/validation.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f74d2688465fc72a5b84b7ad2e953fa124d55ab --- /dev/null +++ b/packages/klient/src/core/validation.ts @@ -0,0 +1,83 @@ +/** + * zod-backed wire validation. Every procedure in the contract validates its + * input tuple before it goes on the wire (caller bugs) and its output when it + * comes back (server drift). Event payloads are validated on delivery; + * failures there are reported, not thrown, so one bad event cannot kill a + * subscription stream. + */ + +import { z } from 'zod'; + +import type { ProcedureContract, StreamingProcedureContract } from '#/contract/types'; + +export type ValidationPhase = 'input' | 'output' | 'event' | 'chunk'; + +export class KlientValidationError extends Error { + constructor( + readonly phase: ValidationPhase, + /** `service.method` for calls, the klient event name for events. */ + readonly procedure: string, + readonly issues: z.ZodError['issues'], + /** The offending raw payload (input args, output data, or event data). */ + readonly payload: unknown, + ) { + super( + `${phase} validation failed for ${procedure}: ${issues + .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`) + .join('; ')}`, + ); + this.name = 'KlientValidationError'; + } +} + +/** Parse the positional-args tuple; returns the normalized args to send. */ +export function parseInput( + procedure: string, + contract: ProcedureContract | StreamingProcedureContract, + args: unknown[], +): unknown[] { + const result = contract.input.safeParse(args); + if (!result.success) { + throw new KlientValidationError('input', procedure, result.error.issues, args); + } + return result.data as unknown[]; +} + +/** Parse a wire result; returns the normalized data to hand to the caller. */ +export function parseOutput( + procedure: string, + contract: ProcedureContract, + data: unknown, +): unknown { + const result = contract.output.safeParse(data); + if (!result.success) { + throw new KlientValidationError('output', procedure, result.error.issues, data); + } + return result.data; +} + +/** Validate one streamed chunk; throws on mismatch. */ +export function parseChunk( + procedure: string, + contract: StreamingProcedureContract, + data: unknown, +): unknown { + const result = contract.chunk.safeParse(data); + if (!result.success) { + throw new KlientValidationError('chunk', procedure, result.error.issues, data); + } + return result.data; +} + +/** Parse an event payload without throwing; `undefined` on failure. */ +export function parseEvent( + event: string, + schema: z.ZodType, + data: unknown, +): { ok: true; data: unknown } | { ok: false; error: KlientValidationError } { + const result = schema.safeParse(data); + if (!result.success) { + return { ok: false, error: new KlientValidationError('event', event, result.error.issues, data) }; + } + return { ok: true, data: result.data }; +} diff --git a/packages/klient/src/index.ts b/packages/klient/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..08b5239fa481eaf80b2242400a63df4db433f391 --- /dev/null +++ b/packages/klient/src/index.ts @@ -0,0 +1,158 @@ +/** + * `@moonshot-ai/klient` public surface — the transport-agnostic client facade + * over the agent-core-v2 engine. Create a klient with one of the transport + * entry points (`@moonshot-ai/klient/ipc` or `/memory`); everything + * exported here behaves identically regardless of which one carried the + * bytes. + */ + +export type { + CallOptions, + EventSourceRef, + IDisposable, + KlientChannel, + ScopeRef, +} from './core/channel.js'; +export { RPCError } from './core/errors.js'; +export { KlientValidationError, type ValidationPhase } from './core/validation.js'; +export { + createKlientFromChannel, + type AgentHandle, + type Klient, + type KlientOptions, + type SessionHandle, +} from './core/klient.js'; +export type { KlientEvents } from './core/events/hub.js'; +export type { Caller, ScopedCaller, ScopedStreamCaller } from './core/facade/global.js'; + +export type { + ConfigTargetLiteral, + FileDownload, + GlobalAuthFacade, + GlobalConfigFacade, + GlobalFacade, + GlobalFilesFacade, + GlobalFlagsFacade, + GlobalHostFsFacade, + GlobalKosongFacade, + GlobalMcpFacade, + GlobalPluginsFacade, + GlobalSessionsFacade, + GlobalWorkspacesFacade, + KlientEnvInfo, + ModelCatalogItem, + OAuthFlowSnapshot, + OAuthFlowStart, + OAuthLoginCancelResponse, + OAuthLogoutResponse, + ProviderCatalogItem, + RefreshProviderModelsOptions, + RefreshProviderModelsResponse, + SetDefaultModelResponse, +} from './core/facade/global.js'; + +export type { + AnonymousProviderInput, + GenerateEvent, + GenerateInput, + GenerateParams, + ProviderAuth, + ProviderInput, +} from './core/facade/kosong-types.js'; + +export type { + SessionApprovalsFacade, + SessionFacade, + SessionInteractionsFacade, + SessionQuestionsFacade, + SessionRestoreOptions, + SessionSkillsFacade, + SessionStatus, +} from './core/facade/session.js'; +export type { + AgentCommandInfo, + AgentContextData, + AgentFacade, + AgentTaskInfo, + McpServerEntry, + PlanData, + PromptLaunchResult, + PromptWithSkillsInput, + PromptWithSkillsResult, + SetModelResult, + ShellCommandResult, + ThinkingLevel, + UsageStatus, +} from './core/facade/agent.js'; + +export type { + CatalogChangedPayload, + KlientEventName, + KlientEventPayloads, + SessionArchivedPayload, + SessionMetaUpdatedPayload, +} from './contract/global/events.js'; +export type { SessionEventPayloads } from './contract/session/events.js'; +export type { AgentEventPayloads } from './contract/agent/events.js'; + +// Wire types re-exported for consumer convenience (type-only; the engine is +// not pulled in at runtime for http consumers). +export type { + SessionListQuery, + SessionSummary, +} from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; +export type { Page } from '@moonshot-ai/agent-core-v2/persistence/interface/queryStore'; +export type { + Workspace, + WorkspaceUpdate, +} from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; +export type { + ConfigDiagnostic, + ConfigInspectValue, +} from '@moonshot-ai/agent-core-v2/app/config/config'; +export type { ProviderConfig } from '@moonshot-ai/agent-core-v2/llm-adapter/provider/provider'; +export type { AuthStatus } from '@moonshot-ai/agent-core-v2/app/auth/auth'; +export type { ExperimentalFeatureState } from '@moonshot-ai/agent-core-v2/app/flag/flag'; +export type { + FsBrowseResponse, + FsHomeResponse, +} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +export type { FileMeta } from '@moonshot-ai/agent-core-v2/app/file/fileService'; +export type { + PluginCommandDef, + PluginInfo, + PluginSummary, + PluginUpdateStatus, + ReloadSummary, +} from '@moonshot-ai/agent-core-v2/app/plugin/types'; +export type { + AgentMeta, + SessionMeta, + SessionMetaPatch, +} from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +export type { + ApprovalRequest, + ApprovalResponse, +} from '@moonshot-ai/agent-core-v2/agent/interaction/approval'; +export type { + QuestionRequest, + QuestionResult, +} from '@moonshot-ai/agent-core-v2/agent/interaction/question'; +export type { + Interaction, + InteractionKind, +} from '@moonshot-ai/agent-core-v2/human/interaction/interaction'; +export type { SkillSummary } from '@moonshot-ai/agent-core-v2/features/skill/catalog/types'; +export type { + GlobalMcpServerConfig, + McpManagedServer, + McpServerAuthBeginResult, + McpServerAuthState, + McpServerAuthStatus, + McpServerInspection, + McpServerLocator, + McpServerTestResult, + McpServerTestTarget, +} from '@moonshot-ai/agent-core-v2/app/mcpManagement/mcpManagement'; +export type { ContentPart } from '@moonshot-ai/agent-core-v2/human/llm/message'; +export type { PermissionMode } from '@moonshot-ai/agent-core-v2/agent/permissionPolicy/types'; diff --git a/packages/klient/src/transports/args.ts b/packages/klient/src/transports/args.ts new file mode 100644 index 0000000000000000000000000000000000000000..7351d4c4f453caffc73f3135c2b4d7a4afa25a35 --- /dev/null +++ b/packages/klient/src/transports/args.ts @@ -0,0 +1,11 @@ +/** + * JSON has no `undefined`: an args tuple ending in optional parameters would + * cross the wire as `null` and defeat server-side default parameters (and + * `z.string().optional()`-style fields). Contracts only ever make trailing + * args optional, so trimming the tail is sufficient. + */ +export function trimTrailingUndefined(args: readonly unknown[]): unknown[] { + let end = args.length; + while (end > 0 && args[end - 1] === undefined) end -= 1; + return end === args.length ? [...args] : args.slice(0, end); +} diff --git a/packages/klient/src/transports/ipc/channel.ts b/packages/klient/src/transports/ipc/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..0136078dd29de34809724d229f1dfae66143e388 --- /dev/null +++ b/packages/klient/src/transports/ipc/channel.ts @@ -0,0 +1,399 @@ +/** + * IPC client channel — connects to a `serveKlientIpc` host over a unix + * domain socket. Calls are correlated by client-chosen ids with a per-call + * deadline; event subscriptions are registered before the handshake + * completes and flushed once it does. There is no automatic reconnect: a + * broken socket rejects in-flight calls and stays closed (the WS transport + * owns the resumable-connection story). + */ + +import { createConnection, type Socket } from 'node:net'; + +import type { + CallOptions, + EventSourceRef, + IDisposable, + KlientChannel, + ScopeRef, +} from '../../core/channel.js'; +import { RPCError } from '../../core/errors.js'; +import { trimTrailingUndefined } from '../args.js'; +import { encodeFrame, NdjsonDecoder, type IpcFrame } from './codec.js'; + +const DEFAULT_CALL_TIMEOUT_MS = 30_000; + +export interface IpcChannelOptions { + readonly socketPath: string; + readonly token?: string; + /** Per-call deadline (ms). Default `30000`; `0` disables. */ + readonly callTimeoutMs?: number; +} + +interface PendingCall { + readonly resolve: (data: unknown) => void; + readonly reject: (err: Error) => void; + readonly timer: ReturnType<typeof setTimeout> | undefined; +} + +/** + * Async queue for streaming responses. The server pushes chunks via + * `stream_data` frames; the client pulls them with `next()`. Back-pressure + * is implicit: the queue buffers until the consumer drains. + */ +interface PendingStream { + push(chunk: unknown): void; + end(): void; + error(err: Error): void; +} + +function scopeKindOf(scope: ScopeRef): 'core' | 'workspace' | 'session' | 'agent' { + if (scope.agentId !== undefined) return 'agent'; + if (scope.sessionId !== undefined) return 'session'; + if (scope.workspaceId !== undefined) return 'workspace'; + return 'core'; +} + +export class IpcChannel implements KlientChannel { + private readonly socket: Socket; + private readonly decoder = new NdjsonDecoder(); + private readonly callTimeoutMs: number; + private readonly pending = new Map<string, PendingCall>(); + private readonly streams = new Map<string, PendingStream>(); + private readonly listens = new Map< + string, + { handler: (data: unknown) => void; onError?: (error: Error) => void } + >(); + private readonly ready: Promise<void>; + private closed = false; + private seq = 0; + private readonly idPrefix = `i${Date.now().toString(36)}`; + + constructor(options: IpcChannelOptions) { + this.callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS; + this.socket = createConnection(options.socketPath); + this.ready = new Promise<void>((resolve, reject) => { + const onError = (error: Error): void => { + reject(error); + }; + this.socket.once('error', onError); + this.socket.once('connect', () => { + // The host sends `ready` immediately; answer with the handshake. + this.send({ type: 'hello', token: options.token }); + this.socket.off('error', onError); + resolve(); + }); + }); + // The promise is consumed lazily by call/listen; never let it reject unhandled. + this.ready.catch(() => {}); + + this.socket.on('data', (chunk) => { + for (const frame of this.decoder.push(chunk.toString('utf8'))) { + this.onFrame(frame); + } + }); + this.socket.on('close', () => { + this.closed = true; + this.failAll(new Error('ipc closed')); + this.listens.clear(); + }); + this.socket.on('error', () => { + // 'close' always follows; teardown lives there. + }); + } + + async call( + scope: ScopeRef, + service: string, + method: string, + args: unknown[], + options?: CallOptions, + ): Promise<unknown> { + await this.ready; + if (this.closed) throw new Error('ipc closed'); + const timeoutMs = options?.timeoutMs ?? this.callTimeoutMs; + const id = this.nextId(); + const promise = new Promise<unknown>((resolve, reject) => { + const timer = + timeoutMs > 0 + ? setTimeout(() => { + this.pending.delete(id); + reject(new RPCError(50001, `call timed out after ${timeoutMs}ms`)); + }, timeoutMs) + : undefined; + this.pending.set(id, { resolve, reject, timer }); + }); + this.send({ + type: 'call', + id, + scope: scopeKindOf(scope), + service, + method, + // NDJSON is JSON: trailing optional args would cross as `null` and + // defeat the host's default parameters — trim them. + arg: trimTrailingUndefined(args), + workspaceId: scope.workspaceId, + sessionId: scope.sessionId, + agentId: scope.agentId, + }); + return promise; + } + + stream(scope: ScopeRef, service: string, method: string, args: unknown[]): AsyncIterable<unknown> { + return { + [Symbol.asyncIterator]: () => { + // Simple queue: push/pull with deferred promises. `buffer` holds + // already-received chunks waiting for a `next()` call; `waiters` + // holds unresolved `next()` calls waiting for a chunk. + const buffer: Array<IteratorResult<unknown>> = []; + const waiters: Array<{ + resolve: (result: IteratorResult<unknown>) => void; + reject: (err: Error) => void; + }> = []; + let done = false; + let streamId: string | undefined; + + const pending: PendingStream = { + push(chunk: unknown) { + if (done) return; + const result: IteratorResult<unknown> = { done: false, value: chunk }; + const waiter = waiters.shift(); + if (waiter !== undefined) { + waiter.resolve(result); + } else { + buffer.push(result); + } + }, + end: () => { + if (done) return; + done = true; + if (streamId !== undefined) this.streams.delete(streamId); + const terminal: IteratorResult<unknown> = { done: true, value: undefined }; + const waiter = waiters.shift(); + if (waiter !== undefined) { + waiter.resolve(terminal); + } else { + buffer.push(terminal); + } + // Resolve remaining waiters with done + for (const w of waiters) { + w.resolve({ done: true, value: undefined }); + } + waiters.length = 0; + }, + error: (err: Error) => { + if (done) return; + done = true; + if (streamId !== undefined) this.streams.delete(streamId); + const waiter = waiters.shift(); + if (waiter !== undefined) { + waiter.reject(err); + } else { + // Store as a throwing result + buffer.push({ done: true, value: err } as IteratorResult<unknown>); + } + for (const w of waiters) { + w.reject(err); + } + waiters.length = 0; + }, + }; + + // Start the stream after the handshake is done. + let started = false; + const ensureStarted = (): void => { + if (started) return; + started = true; + void this.ready.then(() => { + if (this.closed) { + pending.error(new Error('ipc closed')); + return; + } + streamId = this.nextId(); + this.streams.set(streamId, pending); + this.send({ + type: 'stream', + id: streamId, + scope: scopeKindOf(scope), + service, + method, + arg: trimTrailingUndefined(args), + workspaceId: scope.workspaceId, + sessionId: scope.sessionId, + agentId: scope.agentId, + }); + }); + }; + + return { + next(): Promise<IteratorResult<unknown>> { + ensureStarted(); + const buffered = buffer.shift(); + if (buffered !== undefined) { + // Check if this is an error stored as { done: true, value: Error } + if (buffered.done && buffered.value instanceof Error) { + return Promise.reject(buffered.value); + } + return Promise.resolve(buffered); + } + if (done) return Promise.resolve({ done: true, value: undefined }); + return new Promise((resolve, reject) => { + waiters.push({ resolve, reject }); + }); + }, + return: (): Promise<IteratorResult<unknown>> => { + if (!done) { + done = true; + if (streamId !== undefined) { + this.streams.delete(streamId); + this.send({ type: 'stream_cancel', id: streamId }); + } + // Resolve any pending waiters + for (const w of waiters) { + w.resolve({ done: true, value: undefined }); + } + waiters.length = 0; + } + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }; + } + + listen( + scope: ScopeRef, + source: EventSourceRef, + handler: (data: unknown) => void, + onError?: (error: Error) => void, + ): IDisposable { + const id = this.nextId(); + this.listens.set(id, { handler, onError }); + const base = { + type: 'listen', + id, + scope: scopeKindOf(scope), + workspaceId: scope.workspaceId, + sessionId: scope.sessionId, + agentId: scope.agentId, + }; + const frame: IpcFrame = + source.kind === 'stream' + ? { ...base, event: source.name } + : { ...base, service: source.service, event: source.event }; + void this.ready.then(() => { + this.send(frame); + }); + return { + dispose: () => { + if (!this.listens.delete(id)) return; + void this.ready.then(() => { + this.send({ type: 'unlisten', id }); + }); + }, + }; + } + + close(): Promise<void> { + if (this.closed) return Promise.resolve(); + this.closed = true; + this.failAll(new Error('ipc closed')); + this.listens.clear(); + this.socket.end(); + return Promise.resolve(); + } + + // ------------------------------------------------------------------------- + + private nextId(): string { + this.seq += 1; + return `${this.idPrefix}_${this.seq}`; + } + + private onFrame(frame: IpcFrame): void { + const id = typeof frame.id === 'string' ? frame.id : ''; + switch (frame.type) { + case 'ready': + return; + case 'result': { + const p = this.take(id); + p?.resolve(frame.data); + return; + } + case 'error': { + const error = new RPCError( + typeof frame.code === 'number' ? frame.code : 50001, + frame.msg ?? 'error', + ); + const p = this.take(id); + if (p !== undefined) { + p.reject(error); + return; + } + const sub = this.listens.get(id); + if (sub !== undefined) { + this.listens.delete(id); + sub.onError?.(error); + } + return; + } + case 'listen_result': + return; + case 'event': { + this.listens.get(id)?.handler(frame.data); + return; + } + case 'stream_data': { + this.streams.get(id)?.push(frame.data); + return; + } + case 'stream_end': { + this.streams.get(id)?.end(); + return; + } + case 'stream_error': { + const s = this.streams.get(id); + if (s !== undefined) { + s.error( + new RPCError( + typeof frame.code === 'number' ? frame.code : 50001, + frame.msg ?? 'stream error', + ), + ); + } + return; + } + default: + return; + } + } + + private take(id: string): PendingCall | undefined { + const p = this.pending.get(id); + if (p !== undefined) { + this.pending.delete(id); + if (p.timer !== undefined) clearTimeout(p.timer); + } + return p; + } + + private failAll(err: Error): void { + for (const p of this.pending.values()) { + if (p.timer !== undefined) clearTimeout(p.timer); + p.reject(err); + } + this.pending.clear(); + for (const s of this.streams.values()) { + s.error(err); + } + this.streams.clear(); + } + + private send(frame: IpcFrame): void { + if (this.closed || this.socket.destroyed) return; + try { + this.socket.write(encodeFrame(frame)); + } catch { + // best-effort; the close handler handles teardown + } + } +} diff --git a/packages/klient/src/transports/ipc/codec.ts b/packages/klient/src/transports/ipc/codec.ts new file mode 100644 index 0000000000000000000000000000000000000000..21f3262240e90fd3de7867790508210ef06fb067 --- /dev/null +++ b/packages/klient/src/transports/ipc/codec.ts @@ -0,0 +1,49 @@ +/** + * IPC wire framing — newline-delimited JSON over a `node:net` stream. One + * socket multiplexes RPC `call`s and event `listen`s: `hello`/`call`/ + * `listen`/`unlisten` go out, `ready`/`result`/`error`/`listen_result`/ + * `event` come back. + */ + +/** One NDJSON message. `type` discriminates; other fields depend on it. */ +export interface IpcFrame { + readonly type: string; + readonly id?: string; + readonly scope?: string; + readonly service?: string; + readonly method?: string; + readonly arg?: unknown; + readonly workspaceId?: string; + readonly sessionId?: string; + readonly agentId?: string; + readonly event?: string; + readonly token?: string; + readonly code?: number; + readonly msg?: string; + readonly data?: unknown; +} + +export function encodeFrame(frame: IpcFrame): string { + return `${JSON.stringify(frame)}\n`; +} + +/** Incremental NDJSON decoder; malformed lines are dropped. */ +export class NdjsonDecoder { + private buffer = ''; + + push(chunk: string): IpcFrame[] { + this.buffer += chunk; + const lines = this.buffer.split('\n'); + this.buffer = lines.pop() ?? ''; + const frames: IpcFrame[] = []; + for (const line of lines) { + if (line.length === 0) continue; + try { + frames.push(JSON.parse(line) as IpcFrame); + } catch { + // drop malformed frames + } + } + return frames; + } +} diff --git a/packages/klient/src/transports/ipc/host.ts b/packages/klient/src/transports/ipc/host.ts new file mode 100644 index 0000000000000000000000000000000000000000..f734af46cdb28cc48fa6e565eb4086124d4495fd --- /dev/null +++ b/packages/klient/src/transports/ipc/host.ts @@ -0,0 +1,247 @@ +/** + * IPC host — serves one engine scope over a unix domain socket. Incoming + * frames are bridged to the shared in-process dispatcher (the same code the + * memory transport uses), so ipc and in-memory behavior are identical by + * construction; only serialization separates them. + */ + +import { createServer, type Server, type Socket } from 'node:net'; +import { unlink } from 'node:fs/promises'; + +import type { EventSourceRef, IDisposable, ScopeRef } from '../../core/channel.js'; +import { RPCError } from '../../core/errors.js'; +import { createMemoryDispatcher, type ScopeLike } from '../memory/dispatcher.js'; +import { encodeFrame, NdjsonDecoder, type IpcFrame } from './codec.js'; + +const REQUEST_INVALID = 40001; +const UNAUTHORIZED = 40100; + +export interface ServeKlientIpcOptions { + /** A bootstrapped engine app scope (same value `createKlient({ scope })` takes). */ + readonly scope: ScopeLike; + /** Unix socket path to listen on. A stale file at the path is removed first. */ + readonly socketPath: string; + /** Optional token; when set, the client's `hello` must carry the same token. */ + readonly token?: string; +} + +export interface KlientIpcHost { + readonly socketPath: string; + close(): Promise<void>; +} + +function scopeRefFromFrame(frame: IpcFrame): ScopeRef { + const scope: { workspaceId?: string; sessionId?: string; agentId?: string } = {}; + if (typeof frame.workspaceId === 'string') scope.workspaceId = frame.workspaceId; + if (typeof frame.sessionId === 'string') scope.sessionId = frame.sessionId; + if (typeof frame.agentId === 'string') scope.agentId = frame.agentId; + return scope; +} + +function eventSourceFromFrame(frame: IpcFrame): EventSourceRef { + if (typeof frame.service === 'string' && typeof frame.event === 'string') { + return { kind: 'emitter', service: frame.service, event: frame.event }; + } + if (typeof frame.event === 'string' && frame.event.length > 0) { + return { kind: 'stream', name: frame.event }; + } + throw new RPCError(REQUEST_INVALID, `unknown event stream: ${String(frame.event)}`); +} + +export async function serveKlientIpc(options: ServeKlientIpcOptions): Promise<KlientIpcHost> { + const dispatcher = createMemoryDispatcher(options.scope); + + // Best-effort cleanup of a stale socket file; ignore everything but a real + // leftover (ENOENT = nothing to remove). + try { + await unlink(options.socketPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + const connections = new Set<Socket>(); + + const server: Server = createServer((socket) => { + connections.add(socket); + const decoder = new NdjsonDecoder(); + const listens = new Map<string, IDisposable>(); + const activeStreams = new Map<string, AbortController>(); + let helloDone = false; + + const send = (frame: IpcFrame): void => { + if (!socket.destroyed) socket.write(encodeFrame(frame)); + }; + const sendError = (id: string, error: unknown): void => { + if (error instanceof RPCError) { + send({ type: 'error', id, code: error.code, msg: error.message }); + } else { + send({ + type: 'error', + id, + code: 50001, + msg: error instanceof Error ? error.message : String(error), + }); + } + }; + + const sendStreamError = (id: string, error: unknown): void => { + if (error instanceof RPCError) { + send({ type: 'stream_error', id, code: error.code, msg: error.message }); + } else { + send({ + type: 'stream_error', + id, + code: 50001, + msg: error instanceof Error ? error.message : String(error), + }); + } + }; + + const handleFrame = (frame: IpcFrame): void => { + const id = typeof frame.id === 'string' ? frame.id : ''; + switch (frame.type) { + case 'hello': { + if (options.token !== undefined && frame.token !== options.token) { + send({ type: 'error', id: 'hello', code: UNAUTHORIZED, msg: 'unauthorized' }); + socket.end(); + return; + } + helloDone = true; + return; + } + case 'call': { + if (!helloDone) { + sendError(id, new RPCError(REQUEST_INVALID, 'expected hello first')); + return; + } + const args = Array.isArray(frame.arg) ? frame.arg : frame.arg === undefined ? [] : [frame.arg]; + dispatcher + .call(scopeRefFromFrame(frame), String(frame.service), String(frame.method), args) + .then((data) => { + send({ type: 'result', id, data }); + }) + .catch((error: unknown) => { + sendError(id, error); + }); + return; + } + case 'listen': { + if (!helloDone) { + sendError(id, new RPCError(REQUEST_INVALID, 'expected hello first')); + return; + } + try { + const source = eventSourceFromFrame(frame); + const sub = dispatcher.listen( + scopeRefFromFrame(frame), + source, + (data) => { + send({ type: 'event', id, data }); + }, + (error) => { + sendError(id, error); + }, + ); + listens.set(id, sub); + send({ type: 'listen_result', id }); + } catch (error) { + sendError(id, error); + } + return; + } + case 'unlisten': { + listens.get(id)?.dispose(); + listens.delete(id); + return; + } + case 'stream': { + if (!helloDone) { + sendStreamError(id, new RPCError(REQUEST_INVALID, 'expected hello first')); + return; + } + const args = Array.isArray(frame.arg) ? frame.arg : frame.arg === undefined ? [] : [frame.arg]; + const ac = new AbortController(); + activeStreams.set(id, ac); + const iterable = dispatcher.stream( + scopeRefFromFrame(frame), + String(frame.service), + String(frame.method), + args, + ); + void (async () => { + try { + for await (const chunk of iterable) { + if (ac.signal.aborted || socket.destroyed) break; + send({ type: 'stream_data', id, data: chunk }); + } + if (!ac.signal.aborted && !socket.destroyed) { + send({ type: 'stream_end', id }); + } + } catch (error) { + if (!ac.signal.aborted && !socket.destroyed) { + sendStreamError(id, error); + } + } finally { + activeStreams.delete(id); + } + })(); + return; + } + case 'stream_cancel': { + const ac = activeStreams.get(id); + if (ac !== undefined) { + ac.abort(); + activeStreams.delete(id); + } + return; + } + default: + return; + } + }; + + socket.on('data', (chunk) => { + for (const frame of decoder.push(chunk.toString('utf8'))) { + handleFrame(frame); + } + }); + const teardown = (): void => { + for (const sub of listens.values()) sub.dispose(); + listens.clear(); + for (const ac of activeStreams.values()) ac.abort(); + activeStreams.clear(); + connections.delete(socket); + }; + socket.on('close', teardown); + socket.on('error', teardown); + + send({ type: 'ready' }); + }); + + await new Promise<void>((resolve, reject) => { + server.once('error', reject); + server.listen(options.socketPath, resolve); + }); + + return { + socketPath: options.socketPath, + close: () => { + for (const socket of connections) { + socket.destroy(); + } + connections.clear(); + return new Promise<void>((resolve) => { + server.close(() => { + void unlink(options.socketPath).then( + () => { + resolve(); + }, + () => { + resolve(); + }, + ); + }); + }); + }, + }; +} diff --git a/packages/klient/src/transports/ipc/index.ts b/packages/klient/src/transports/ipc/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..58fe1e724307d0cb30c9ea072d8bee3b54667ce4 --- /dev/null +++ b/packages/klient/src/transports/ipc/index.ts @@ -0,0 +1,19 @@ +/** + * `createKlient` over a unix domain socket, plus the matching host + * (`serveKlientIpc`) for processes embedding the engine. Node-only. + */ + +import { createKlientFromChannel, type Klient, type KlientOptions } from '../../core/klient.js'; +import { IpcChannel, type IpcChannelOptions } from './channel.js'; + +export { + serveKlientIpc, + type KlientIpcHost, + type ServeKlientIpcOptions, +} from './host.js'; + +export interface IpcKlientOptions extends KlientOptions, IpcChannelOptions {} + +export function createKlient(options: IpcKlientOptions): Klient { + return createKlientFromChannel(new IpcChannel(options), options); +} diff --git a/packages/klient/src/transports/memory/dispatcher.ts b/packages/klient/src/transports/memory/dispatcher.ts new file mode 100644 index 0000000000000000000000000000000000000000..84a5008df8c01275be0de831f98807c55280382c --- /dev/null +++ b/packages/klient/src/transports/memory/dispatcher.ts @@ -0,0 +1,623 @@ +/** + * In-process dispatcher — resolves a wire triple `(service, method, args)` + * against a live engine scope and mirrors kap-server's dispatcher semantics + * (reflection call, non-function members are property reads, `main` agent + * auto-materialized via `ensureMainAgent`). Scope routing resolves workspace + * instances through `IWorkspaceInstanceManager` and live sessions through the + * App `SessionManager`, matching the server's `resolveScope`. Every argument, + * result, and event payload passes + * through `wireClone` (a JSON round-trip), so consumers observe + * byte-identical data no matter whether the call crossed a socket or stayed + * in-process — and non-serializable leaks fail early. + * + * Shared by the memory transport and the IPC host, which guarantees ipc and + * memory behave identically by construction. + */ + +import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; +import type { IAgentScopeHandle } from '@moonshot-ai/agent-core-v2/_base/di/scope'; +import { IWorkspaceInstanceManager } from '@moonshot-ai/agent-core-v2/workspace/workspaceInstance/workspaceInstanceManager'; +import { ISessionManager } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionManager'; +import { getLiveSessionById } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionLookup'; +import { IAgentLifecycleService } from '@moonshot-ai/agent-core-v2/session/agentLifecycle/agentLifecycle'; +import { MAIN_AGENT_ID } from '@moonshot-ai/agent-core-v2/session/agentLifecycle/agentLifecycle'; +import { ensureMainAgent } from '@moonshot-ai/agent-core-v2/session/agentLifecycle/mainAgent'; +import { agentContextOf } from '@moonshot-ai/agent-core-v2/agent/scopeContext/scopeContext'; +import { + INTERACTION_TAG_AGENT_ID, + INTERACTION_TAG_SESSION_ID, + type Interaction, + type InteractionKind, + type InteractionRequest, +} from '@moonshot-ai/agent-core-v2/human/interaction/interaction'; +import { interactions } from '@moonshot-ai/agent-core-v2/human/interaction/facade'; +import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; +import type { SkillActivationOrigin } from '@moonshot-ai/agent-core-v2/agent/contextMemory/types'; +import { ITelemetryService } from '@moonshot-ai/agent-core-v2/app/telemetry/telemetry'; +import type { + PromptWithSkillsInput, + SkillActivationInput, +} from '@moonshot-ai/agent-core-v2/features/skill/skill'; +import { IAgentSkillService } from '@moonshot-ai/agent-core-v2/features/skill/skillService'; +import { IEventBus } from '@moonshot-ai/agent-core-v2/app/event/eventBus'; +import type { + FileMeta, + GetResult, + SaveOptions, +} from '@moonshot-ai/agent-core-v2/app/file/fileService'; +import { FileErrors } from '@moonshot-ai/agent-core-v2/app/file/fileService'; +import { Error2, ErrorCodes } from '@moonshot-ai/agent-core-v2/errors'; + +import { Readable } from 'node:stream'; + +import type { EventSourceRef, IDisposable, ScopeRef } from '../../core/channel.js'; +import { RPCError } from '../../core/errors.js'; +import { IEventService, serviceTokens } from './serviceRegistry.js'; + +/** Structural minimum of an engine `Scope` / `IScopeHandle`. */ +export interface ScopeLike { + readonly accessor: { + get<T>(id: ServiceIdentifier<T>): T; + }; +} + +/** JSON round-trip so in-process data matches wire data exactly. */ +export function wireClone<T>(value: T): T { + if (value === undefined) return value; + return JSON.parse(JSON.stringify(value)) as T; +} + +/** + * `sessionInteractionService` stays on the wire after the engine moved the + * interaction kernel into a process-global module singleton: the view + * forwards to the singleton, scoping every call by the `sessionId` tag. + */ +function pendingInteractionsOfSession(sessionId: string): readonly Interaction[] { + return interactions.findAll({ + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: sessionId }, + }); +} + +function pendingIdsOfSession(sessionId: string): readonly string[] { + return pendingInteractionsOfSession(sessionId).map((i) => i.id); +} + +function respondScoped(sessionId: string, id: string, response: unknown): boolean { + if ( + interactions.findOne({ + id, + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: sessionId }, + }) === undefined + ) { + return false; + } + return interactions.respond(id, response); +} + +function withSessionTags<TPayload>( + sessionId: string, + req: InteractionRequest<TPayload>, +): InteractionRequest<TPayload> { + return { + ...req, + tags: { + ...req.tags, + [INTERACTION_TAG_AGENT_ID]: req.tags?.[INTERACTION_TAG_AGENT_ID] ?? MAIN_AGENT_ID, + [INTERACTION_TAG_SESSION_ID]: sessionId, + }, + }; +} + +function interactionServiceView(sessionId: string): Record<string, unknown> { + return { + request: (req: InteractionRequest<unknown>) => + interactions.request(withSessionTags(sessionId, req)), + enqueue: (req: InteractionRequest<unknown>) => + interactions.enqueue(withSessionTags(sessionId, req)), + respond: (id: string, response: unknown) => { + respondScoped(sessionId, id, response); + }, + listPending: (kind?: InteractionKind) => + interactions.findAll({ + kind, + resolved: false, + tags: { [INTERACTION_TAG_SESSION_ID]: sessionId }, + }), + isRecentlyResolved: (id: string) => + interactions.findOne({ + id, + resolved: true, + tags: { [INTERACTION_TAG_SESSION_ID]: sessionId }, + }) !== undefined, + onDidChangePending: (listener: (event: unknown) => void) => + interactions.onDidChangePending(listener), + onDidResolve: (listener: (event: unknown) => void) => + interactions.onDidResolve(listener), + }; +} + +/** + * `sessionApprovalService` / `sessionQuestionService` stay on the wire as + * facade views over the same singleton: lists are the session's pending + * payloads with their interaction ids, decisions write back through + * `respond`. + */ +function approvalServiceView(sessionId: string): Record<string, unknown> { + return { + listPending: () => + pendingInteractionsOfSession(sessionId) + .filter((i) => i.kind === 'approval') + .map((i) => ({ ...(i.payload as Record<string, unknown>), id: i.id })), + decide: (id: string, response: unknown) => { + respondScoped(sessionId, id, response); + }, + }; +} + +function questionServiceView(sessionId: string): Record<string, unknown> { + return { + listPending: () => + pendingInteractionsOfSession(sessionId) + .filter((i) => i.kind === 'question') + .map((i) => ({ ...(i.payload as Record<string, unknown>), id: i.id })), + answer: (id: string, result: unknown) => { + respondScoped(sessionId, id, result); + }, + dismiss: (id: string) => { + respondScoped(sessionId, id, null); + }, + }; +} + +/** + * `agentSkillService` stays on the wire after the engine moved the skill + * kernel into a per-agent DI service: the view forwards to the agent's + * `IAgentSkillService` resolved straight from the agent scope handle. + */ +function agentSkillServiceView(agent: IAgentScopeHandle): Record<string, unknown> { + const skill = agent.accessor.get(IAgentSkillService); + return { + activate: (input: SkillActivationInput) => skill.activate(input), + promptWithSkills: (input: PromptWithSkillsInput) => skill.promptWithSkills(input), + recordModelToolActivation: (origin: SkillActivationOrigin) => { + skill.recordModelToolActivation(origin); + }, + }; +} + +function agentLoopServiceView(agent: IAgentScopeHandle): Record<string, unknown> { + const loop = agent.accessor.get(IAgentLoopService); + return { + cancelFromUser: (turnId?: number) => { + const snapshot = loop.snapshot(); + if (snapshot.state === 'running') { + agent.accessor.get(ITelemetryService).track2('cancel', { + from: 'streaming', + trace_id: snapshot.activeTraceId, + }); + } + loop.cancel(turnId === undefined ? undefined : { turnId }); + }, + }; +} + +export interface MemoryDispatcher { + call(scope: ScopeRef, service: string, method: string, args: unknown[]): Promise<unknown>; + stream(scope: ScopeRef, service: string, method: string, args: unknown[]): AsyncIterable<unknown>; + listen( + scope: ScopeRef, + source: EventSourceRef, + handler: (data: unknown) => void, + onError?: (error: Error) => void, + ): IDisposable; +} + +const REQUEST_INVALID = 40001; +const NOT_FOUND = 40404; +/** kap-server wire codes mirrored so memory/ipc surface the same numeric codes as `/api/v2/mcp`. */ +const MCP_SERVER_NOT_FOUND = 40408; +const MCP_OAUTH_FAILED = 40929; +const PROMPT_ID_CONFLICT = 40927; + +/** Wire name of the engine's `IMcpManagementService` decorator id. */ +const MCP_MANAGEMENT_SERVICE = 'mcpManagementService'; + +/** + * Session-scope domain services whose methods take the lifecycle-issued + * `AgentContext` as their first argument. The wire stays agentId-only (the + * scope ref already carries it), so the live context is resolved here at the + * edge — after `wireClone`, since the context is a live object that must + * never cross the JSON round-trip. + */ +const AGENT_CONTEXT_SERVICES: ReadonlySet<string> = new Set([ + 'agentTokenCountingService', + 'agentUsageService', +]); + +/** + * Engine file errors cross the facade as public `RPCError`s, never as the + * engine's raw `Error2`. The dispatcher is shared by both transports, so + * memory and ipc then surface the identical `NOT_FOUND` code for a stale or + * expired upload id. + */ +function rethrowFileErrorAsRpc(error: unknown): never { + if (error instanceof Error2 && error.code === FileErrors.codes.FILE_NOT_FOUND) { + throw new RPCError(NOT_FOUND, error.message, error.details); + } + throw error; +} + +/** + * Same treatment for the MCP management plane: its coded rejections cross as + * `RPCError`s carrying the kap-server wire codes, so memory and ipc behave + * identically (a raw `Error2` would cross ipc as a generic 50001) and both + * match `/api/v2/mcp` — `mcp.server_not_found` → 40408, `request.invalid` / + * `config.invalid` → 40001, `mcp.oauth_failed` → 40929. + */ +function rethrowMcpManagementErrorAsRpc(error: unknown): never { + if (error instanceof Error2) { + switch (error.code) { + case ErrorCodes.MCP_SERVER_NOT_FOUND: + throw new RPCError(MCP_SERVER_NOT_FOUND, error.message, error.details); + case ErrorCodes.REQUEST_INVALID: + case ErrorCodes.CONFIG_INVALID: + throw new RPCError(REQUEST_INVALID, error.message, error.details); + case ErrorCodes.MCP_OAUTH_FAILED: + throw new RPCError(MCP_OAUTH_FAILED, error.message, error.details); + } + } + throw error; +} + +type ScopeKind = 'core' | 'workspace' | 'session' | 'agent'; + +interface ResolvedScope { + readonly kind: ScopeKind; + readonly like: ScopeLike; + readonly sessionId?: string; +} + +/** Structural view of the engine's `IFileService` used by the wire adaptation. */ +type FileServiceWireTarget = { + save(source: Readable, filename: string, options?: SaveOptions): Promise<FileMeta>; + get(fileId: string): Promise<GetResult>; +}; + +export function createMemoryDispatcher(root: ScopeLike): MemoryDispatcher { + /** Mirrors kap-server's `resolveScope`, incl. main-agent materialization. */ + async function resolveScope(scope: ScopeRef): Promise<ResolvedScope> { + if (scope.workspaceId !== undefined) { + const workspace = await root.accessor + .get(IWorkspaceInstanceManager) + .getOrCreate({ workspaceId: scope.workspaceId }); + void workspace.program; + return { kind: 'workspace', like: root }; + } + if (scope.sessionId === undefined) return { kind: 'core', like: root }; + const session = root.accessor.get(ISessionManager).get(scope.sessionId) ?? getLiveSessionById(root.accessor, scope.sessionId); + if (session === undefined) { + throw new RPCError(NOT_FOUND, `session not found: ${scope.sessionId}`); + } + if (scope.agentId === undefined) { + return { kind: 'session', like: session, sessionId: scope.sessionId }; + } + if (scope.agentId === 'main') { + const context = await ensureMainAgent(session); + const handle = session.accessor.get(IAgentLifecycleService).handleOf(context.agentId); + if (handle === undefined) { + throw new RPCError(NOT_FOUND, 'main agent was not found'); + } + return { kind: 'agent', like: handle }; + } + const agent = session.accessor.get(IAgentLifecycleService).handleOf(scope.agentId); + if (agent === undefined) { + throw new RPCError(NOT_FOUND, `agent not found: ${scope.agentId}`); + } + return { kind: 'agent', like: agent }; + } + + function sessionInteractionView( + resolved: ResolvedScope, + service: string, + ): Record<string, unknown> | undefined { + if (resolved.kind !== 'session') return undefined; + const sessionId = resolved.sessionId as string; + if (service === 'sessionInteractionService') return interactionServiceView(sessionId); + if (service === 'sessionApprovalService') return approvalServiceView(sessionId); + if (service === 'sessionQuestionService') return questionServiceView(sessionId); + return undefined; + } + + function resolveService(resolved: ResolvedScope, service: string): Record<string, unknown> { + if ( + service === 'sessionInteractionService' || + service === 'sessionApprovalService' || + service === 'sessionQuestionService' + ) { + const view = sessionInteractionView(resolved, service); + if (view === undefined) { + throw new RPCError(REQUEST_INVALID, `service not available in ${resolved.kind} scope: ${service}`); + } + return view; + } + if (service === 'agentSkillService') { + if (resolved.kind !== 'agent') { + throw new RPCError(REQUEST_INVALID, `service not available in ${resolved.kind} scope: ${service}`); + } + return agentSkillServiceView(resolved.like as IAgentScopeHandle); + } + if (service === 'agentLoopService') { + if (resolved.kind !== 'agent') { + throw new RPCError(REQUEST_INVALID, `service not available in ${resolved.kind} scope: ${service}`); + } + return agentLoopServiceView(resolved.like as IAgentScopeHandle); + } + const token = serviceTokens[service]; + if (token === undefined) { + throw new RPCError(REQUEST_INVALID, `unknown service: ${service}`); + } + return resolved.like.accessor.get(token) as Record<string, unknown>; + } + + /** Mirrors kap-server's WS `eventMap` per scope kind. */ + function subscribeStream( + resolved: ResolvedScope, + name: string, + handler: (data: unknown) => void, + ): IDisposable { + if (resolved.kind === 'core' && name === 'events') { + const bus = resolved.like.accessor.get(IEventService); + return bus.subscribe((event) => { + handler(wireClone(event)); + }); + } + if (resolved.kind === 'session' && name === 'interactions') { + const sessionId = resolved.sessionId as string; + let last = pendingIdsOfSession(sessionId).join(''); + return { + dispose: interactions.onDidChangePending(() => { + const next = pendingIdsOfSession(sessionId).join(''); + if (next === last) return; + last = next; + handler(wireClone(pendingInteractionsOfSession(sessionId))); + }), + }; + } + if (resolved.kind === 'session' && name === 'interactions:resolved') { + const sessionId = resolved.sessionId as string; + const known = new Set(pendingIdsOfSession(sessionId)); + const detachChange = interactions.onDidChangePending(() => { + for (const id of pendingIdsOfSession(sessionId)) known.add(id); + }); + const detachResolve = interactions.onDidResolve((resolution) => { + if (!known.delete(resolution.id)) return; + handler(wireClone(resolution)); + }); + return { + dispose: () => { + detachChange(); + detachResolve(); + }, + }; + } + if (resolved.kind === 'agent' && name === 'events') { + const bus = resolved.like.accessor.get(IEventBus); + return bus.subscribe((event) => { + handler(wireClone(event)); + }); + } + throw new RPCError(REQUEST_INVALID, `unknown event stream: ${name} (${resolved.kind})`); + } + + function subscribeSource( + resolved: ResolvedScope, + source: EventSourceRef, + handler: (data: unknown) => void, + ): IDisposable { + if (source.kind === 'stream') { + return subscribeStream(resolved, source.name, handler); + } + if (!/^on[A-Z]/.test(source.event)) { + throw new RPCError(REQUEST_INVALID, `not an event property: ${source.event}`); + } + const instance = resolveService(resolved, source.service); + const emitter = instance[source.event]; + if (typeof emitter !== 'function') { + throw new RPCError(REQUEST_INVALID, `event not found: ${source.service}.${source.event}`); + } + return (emitter as (listener: (data: unknown) => void) => IDisposable).call( + instance, + (data) => { + handler(wireClone(data)); + }, + ); + } + + return { + async call(scope, service, method, args) { + const resolved = await resolveScope(scope); + const instance = resolveService(resolved, service); + // `fileService` adapts bytes ⇄ streams: the JSON wire cannot carry + // `save`'s Readable source or `get`'s result stream, so both cross as + // base64 strings (the same kind of wire adaptation as + // `modelResolver.generate` in `stream`). + if (service === 'fileService' && method === 'save') { + const [data, filename, options] = args as [string, string, SaveOptions | undefined]; + const files = instance as FileServiceWireTarget; + try { + const meta = await files.save( + Readable.from(Buffer.from(data, 'base64')), + filename, + options, + ); + return wireClone(meta); + } catch (error) { + rethrowFileErrorAsRpc(error); + } + } + if (service === 'fileService' && method === 'get') { + const [fileId] = args as [string]; + const files = instance as FileServiceWireTarget; + try { + const { meta, stream } = await files.get(fileId); + const chunks: Buffer[] = []; + for await (const chunk of stream()) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)); + } + return { meta: wireClone(meta), data: Buffer.concat(chunks).toString('base64') }; + } catch (error) { + rethrowFileErrorAsRpc(error); + } + } + const member = instance[method]; + if (member === undefined) { + throw new RPCError(REQUEST_INVALID, `method not found: ${service}.${method}`); + } + if (typeof member !== 'function') { + return wireClone(member); + } + const clonedArgs = args.map(wireClone); + const callArgs = AGENT_CONTEXT_SERVICES.has(service) + ? [agentContextOf(resolved.like as IAgentScopeHandle), ...clonedArgs] + : clonedArgs; + try { + const result = await (member as (...a: unknown[]) => unknown).apply(instance, callArgs); + return wireClone(result); + } catch (error) { + if (service === MCP_MANAGEMENT_SERVICE) { + rethrowMcpManagementErrorAsRpc(error); + } + if (error instanceof Error2 && error.code === ErrorCodes.PROMPT_ID_CONFLICT) { + throw new RPCError(PROMPT_ID_CONFLICT, error.message, error.details); + } + throw error; + } + }, + + stream(scope, service, method, args): AsyncIterable<unknown> { + // Special case: modelResolver.generate routes to IModelCatalog.generate + // (which owns credential recovery); the dispatcher only supplies the + // abort signal so client cancellation still reaches the request. + if (service === 'modelResolver' && method === 'generate') { + return { + [Symbol.asyncIterator]() { + let source: AsyncIterator<unknown> | undefined; + let started: Promise<void> | undefined; + const controller = new AbortController(); + + const ensureStarted = (): Promise<void> => { + started ??= (async () => { + const resolved = await resolveScope(scope); + const catalog = resolveService(resolved, 'modelResolver'); + const [modelId, input, params] = args; + const iterable = ( + catalog as { + generate( + id: string, + input: unknown, + signal: AbortSignal, + params: unknown, + ): AsyncIterable<unknown>; + } + ).generate( + modelId as string, + wireClone(input), + controller.signal, + wireClone(params), + ); + source = iterable[Symbol.asyncIterator](); + })(); + return started; + }; + + return { + async next() { + await ensureStarted(); + const result = await source!.next(); + if (result.done) return { done: true, value: undefined }; + return { done: false, value: wireClone(result.value) }; + }, + async return(value?: unknown) { + controller.abort(); + await source?.return?.(value); + return { done: true as const, value: undefined }; + }, + }; + }, + }; + } + + // The underlying service method returns an AsyncIterable; we wire-clone + // each yielded chunk so in-process consumers observe the same data as + // networked ones. + return { + [Symbol.asyncIterator]() { + let source: AsyncIterator<unknown> | undefined; + let started: Promise<void> | undefined; + + const ensureStarted = (): Promise<void> => { + started ??= (async () => { + const resolved = await resolveScope(scope); + const instance = resolveService(resolved, service); + const member = instance[method]; + if (member === undefined) { + throw new RPCError(REQUEST_INVALID, `method not found: ${service}.${method}`); + } + if (typeof member !== 'function') { + throw new RPCError(REQUEST_INVALID, `not a streaming method: ${service}.${method}`); + } + const clonedArgs = args.map(wireClone); + const iterable = (member as (...a: unknown[]) => unknown).apply( + instance, + clonedArgs, + ) as AsyncIterable<unknown>; + source = iterable[Symbol.asyncIterator](); + })(); + return started; + }; + + return { + async next() { + await ensureStarted(); + const result = await source!.next(); + if (result.done) return { done: true, value: undefined }; + return { done: false, value: wireClone(result.value) }; + }, + async return(value?: unknown) { + await source?.return?.(value); + return { done: true as const, value: undefined }; + }, + }; + }, + }; + }, + + listen(scope, source, handler, onError) { + // Scope resolution can be async (main-agent materialization); the + // subscription attaches once settled. Disposing early cancels it. + let inner: IDisposable | undefined; + let disposed = false; + void resolveScope(scope).then( + (resolved) => { + if (disposed) return; + try { + inner = subscribeSource(resolved, source, handler); + } catch (error) { + onError?.(error instanceof Error ? error : new Error(String(error))); + } + }, + (error: unknown) => { + onError?.(error instanceof Error ? error : new Error(String(error))); + }, + ); + return { + dispose: () => { + disposed = true; + inner?.dispose(); + }, + }; + }, + }; +} diff --git a/packages/klient/src/transports/memory/index.ts b/packages/klient/src/transports/memory/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..35751fdcd31618f1affa2a16823e7c7d3e2a48ee --- /dev/null +++ b/packages/klient/src/transports/memory/index.ts @@ -0,0 +1,61 @@ +/** + * `createKlient` over an in-process engine scope — the host bootstraps the + * engine (`bootstrap()` from agent-core-v2) and passes the app scope (or its + * handle) in. Calls and events never leave the process, but everything the + * facade returns has crossed the same JSON round-trip as the networked + * transports, so behavior is indistinguishable. + */ + +import type { + EventSourceRef, + IDisposable, + KlientChannel, + ScopeRef, +} from '../../core/channel.js'; +import { createKlientFromChannel, type Klient, type KlientOptions } from '../../core/klient.js'; +import { createMemoryDispatcher, type ScopeLike } from './dispatcher.js'; + +export type { ScopeLike } from './dispatcher.js'; + +export interface MemoryKlientOptions extends KlientOptions { + /** + * A bootstrapped engine app scope (`bootstrap(...).app` or an + * `IAppScopeHandle`). The klient does NOT own its lifecycle — `close()` + * leaves the scope alone. + */ + readonly scope: ScopeLike; +} + +class MemoryChannel implements KlientChannel { + private readonly dispatcher; + + constructor(scope: ScopeLike) { + this.dispatcher = createMemoryDispatcher(scope); + } + + call(scope: ScopeRef, service: string, method: string, args: unknown[]): Promise<unknown> { + return this.dispatcher.call(scope, service, method, args); + } + + stream(scope: ScopeRef, service: string, method: string, args: unknown[]): AsyncIterable<unknown> { + return this.dispatcher.stream(scope, service, method, args); + } + + listen( + scope: ScopeRef, + source: EventSourceRef, + handler: (data: unknown) => void, + onError?: (error: Error) => void, + ): IDisposable { + return this.dispatcher.listen(scope, source, handler, onError); + } + + close(): Promise<void> { + // The scope belongs to the host; nothing transport-side to release. + return Promise.resolve(); + } +} + +export function createKlient(options: MemoryKlientOptions): Klient { + return createKlientFromChannel(new MemoryChannel(options.scope), options); +} diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..3359d6bfbef65785604939230e59ca3ad15a1e8a --- /dev/null +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -0,0 +1,89 @@ +/** + * Service name → DI token registry for the in-process dispatcher. Only leaf + * modules are imported (tokens + types) — never the engine root barrel, so + * hosting klient in-process does not force the full registration side effects + * beyond what the host already bootstrapped. + */ + +import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; +import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; +import { IWorkspaceService } from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; +import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config'; +import { IModelService } from '@moonshot-ai/agent-core-v2/llm-adapter/model/model'; +import { IModelCatalog } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; +import { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/app/kosongConfig/discovery'; +import { IProviderService } from '@moonshot-ai/agent-core-v2/llm-adapter/provider/provider'; +import { + IAuthSummaryService, + IOAuthService, +} from '@moonshot-ai/agent-core-v2/app/auth/auth'; +import { IFlagService } from '@moonshot-ai/agent-core-v2/app/flag/flag'; +import { IPluginService } from '@moonshot-ai/agent-core-v2/app/plugin/plugin'; +import { ICapabilityService } from '@moonshot-ai/agent-core-v2/app/capability/capability'; +import { IBootstrapService } from '@moonshot-ai/agent-core-v2/app/bootstrap/bootstrap'; +import { IEventService } from '@moonshot-ai/agent-core-v2/app/event/event'; +import { IFileService } from '@moonshot-ai/agent-core-v2/app/file/fileService'; +import { IHostFolderBrowser } from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +import { IWorkspaceInstanceManager } from '@moonshot-ai/agent-core-v2/workspace/workspaceInstance/workspaceInstanceManager'; +import { ISessionManager } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionManager'; +import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +import { ISessionSkillCatalog } from '@moonshot-ai/agent-core-v2/features/skill/session/skillCatalog'; +import { ISessionTitleService } from '@moonshot-ai/agent-core-v2/session/sessionTitle/sessionTitle'; +import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; +import { IAgentPromptChannel } from '@moonshot-ai/agent-core-v2/agent/loop/promptChannel'; +import { IAgentPermissionModeService } from '@moonshot-ai/agent-core-v2/agent/permissionMode/permissionMode'; +import { IAgentCommandService } from '@moonshot-ai/agent-core-v2/agent/command/agentCommand'; +import { IAgentRuntimeBindingService } from '@moonshot-ai/agent-core-v2/agent/runtimeBinding/runtimeBinding'; +import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; +import { ISessionTokenCountingService } from '@moonshot-ai/agent-core-v2/session/tokenCounting/sessionTokenCounting'; +import { ISessionActivityView } from '@moonshot-ai/agent-core-v2/session/sessionActivity/sessionActivity'; +import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan'; +import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; +import { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; +import { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task'; +import { ISessionUsageService } from '@moonshot-ai/agent-core-v2/session/usage/sessionUsage'; +import { IAgentMcpService } from '@moonshot-ai/agent-core-v2/agent/mcp/mcp'; +import { IAgentFullCompactionService } from '@moonshot-ai/agent-core-v2/agent/fullCompaction/fullCompaction'; +import { IMcpManagementService } from '@moonshot-ai/agent-core-v2/app/mcpManagement/mcpManagement'; + +/** Wire service name (decorator id string) → token. */ +export const serviceTokens: Readonly<Record<string, ServiceIdentifier<unknown>>> = { + sessionIndex: ISessionIndex, + workspaceService: IWorkspaceService, + configService: IConfigService, + modelService: IModelService, + modelResolver: IModelCatalog, + providerDiscovery: IProviderDiscoveryService, + providerService: IProviderService, + oauthService: IOAuthService, + authSummaryService: IAuthSummaryService, + flagService: IFlagService, + pluginService: IPluginService, + capabilityService: ICapabilityService, + hostFolderBrowser: IHostFolderBrowser, + bootstrapService: IBootstrapService, + fileService: IFileService, + workspaceInstanceManager: IWorkspaceInstanceManager, + sessionManager: ISessionManager, + sessionMetadata: ISessionMetadata, + sessionSkillCatalog: ISessionSkillCatalog, + sessionTitleService: ISessionTitleService, + agentPromptService: IAgentPromptChannel, + agentLoopService: IAgentLoopService, + agentPermissionModeService: IAgentPermissionModeService, + agentCommandService: IAgentCommandService, + agentRuntimeBindingService: IAgentRuntimeBindingService, + agentContextMemoryService: IAgentContextMemoryService, + agentTokenCountingService: ISessionTokenCountingService, + sessionActivityView: ISessionActivityView, + agentShellCommandService: IAgentShellCommandService, + agentProfileService: IAgentProfileService, + agentUsageService: ISessionUsageService, + agentPlanService: IAgentPlanService, + agentTaskService: IAgentTaskService, + agentMcpService: IAgentMcpService, + agentFullCompactionService: IAgentFullCompactionService, + mcpManagementService: IMcpManagementService, +}; + +export { IEventService }; diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts new file mode 100644 index 0000000000000000000000000000000000000000..64211afed8e18d95153c9b2e3787140c44d400c9 --- /dev/null +++ b/packages/klient/test/contract-parity.ts @@ -0,0 +1,720 @@ +/** + * Compile-time parity checks between klient wire schemas and the engine + * types they mirror. Plain `.ts` (not `.test.ts`) — vitest must not pick it + * up; `tsc -p tsconfig.json --noEmit` is the check. + * + * Wire shapes are reached through indexed access on the engine service + * interfaces, so klient needs no direct dependency for most of them. + */ + +import type { z } from 'zod'; + +import type { AgentContextData } from '@moonshot-ai/agent-core-v2/agent/contextMemory/types'; +import type { IAgentCommandService } from '@moonshot-ai/agent-core-v2/agent/command/agentCommand'; +import type { IAgentRuntimeBindingService } from '@moonshot-ai/agent-core-v2/agent/runtimeBinding/runtimeBinding'; +import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; +import type { SessionActivityState } from '@moonshot-ai/agent-core-v2/session/sessionActivity/sessionActivity'; +import type { PermissionMode } from '@moonshot-ai/agent-core-v2/agent/permissionPolicy/types'; +import type { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; +import type { PromptLaunchResult, PromptPayload, SteerPayload } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; +import type { IAgentShellCommandService } from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommand'; +import type { IAgentSkillService } from '@moonshot-ai/agent-core-v2/features/skill/skillService'; +import type { ContentPart } from '@moonshot-ai/agent-core-v2/human/llm/message'; +import type { PlanData } from '@moonshot-ai/agent-core-v2/features/plan/plan'; +import type { UsageStatus } from '@moonshot-ai/agent-core-v2/agent/usage/usage'; +import type { SkillSummary } from '@moonshot-ai/agent-core-v2/features/skill/catalog/types'; +import type { McpServerEntry } from '@moonshot-ai/agent-core-v2/mcpCore/connection-manager'; +import type { + GlobalMcpServerConfig, + McpAuthStatusQuery, + McpManagedServer, + McpServerAuthBeginResult, + McpServerAuthFlowHandle, + McpServerAuthState, + McpServerAuthStatus, + McpServerInspection, + McpServerLocator, + McpServerTestResult, + McpServerTestTarget, +} from '@moonshot-ai/agent-core-v2/app/mcpManagement/mcpManagement'; +import type { + McpRegistryPluginOrigin, + McpRegistryQuery, + McpServerSource, +} from '@moonshot-ai/agent-core-v2/app/mcpRegistry/mcpRegistry'; +import type { McpServerConfig } from '@moonshot-ai/agent-core-v2/mcpCore/config-schema'; +import type { McpServerConfigView } from '@moonshot-ai/agent-core-v2/mcpCore/configView'; +import type { FullCompactionInput } from '@moonshot-ai/agent-core-v2/agent/fullCompaction/fullCompaction'; +import type { ISessionScopeHandle } from '@moonshot-ai/agent-core-v2/_base/di/scope'; +import type { + CreateChildSessionOptions, + CreateSessionOptions, + ForkSessionOptions, + ResumeSessionOptions, +} from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; +import type { + ApprovalRequest, + ApprovalResponse, +} from '@moonshot-ai/agent-core-v2/agent/interaction/approval'; +import type { + Interaction, + InteractionResolution, +} from '@moonshot-ai/agent-core-v2/human/interaction/interaction'; +import type { + QuestionAnswers, + QuestionItem, + QuestionOption, + QuestionRequest, + QuestionResponse, + QuestionResult, +} from '@moonshot-ai/agent-core-v2/agent/interaction/question'; +import type { + AgentMeta, + SessionMeta, + SessionMetadataChangedEvent, + SessionMetaPatch, +} from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +import type { ISessionTitleService } from '@moonshot-ai/agent-core-v2/session/sessionTitle/sessionTitle'; +import type { + AuthStatus, + IOAuthService, +} from '@moonshot-ai/agent-core-v2/app/auth/auth'; +import type { IBootstrapService } from '@moonshot-ai/agent-core-v2/app/bootstrap/bootstrap'; +import type { + ConfigDiagnostic, + ConfigInspectValue, + ConfigTarget, +} from '@moonshot-ai/agent-core-v2/app/config/config'; +import type { + CapabilityInstallProgress, + CapabilityStatus, + CapabilityStep, +} from '@moonshot-ai/agent-core-v2/app/capability/types'; +import type { ExperimentalFeatureState } from '@moonshot-ai/agent-core-v2/app/flag/flag'; +import type { + FileMeta, + SaveOptions, +} from '@moonshot-ai/agent-core-v2/app/file/fileService'; +import type { + FsBrowseResponse, + FsHomeResponse, +} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +import type { ModelRecord } from '@moonshot-ai/agent-core-v2/llm-adapter/model/model'; +import type { IModelCatalog } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; +import type { IProviderDiscoveryService } from '@moonshot-ai/agent-core-v2/app/kosongConfig/discovery'; +import type { + GetPluginInfoInput, + InstallPluginInput, + RemovePluginInput, + SetPluginEnabledInput, + SetPluginMcpServerEnabledInput, +} from '@moonshot-ai/agent-core-v2/app/plugin/plugin'; +import type { + PluginCommandDef, + PluginDiagnostic, + PluginGithubMetadata, + PluginInfo, + PluginManifest, + PluginMcpServerInfo, + PluginSummary, + PluginUpdateStatus, + ReloadSummary, +} from '@moonshot-ai/agent-core-v2/app/plugin/types'; +import type { ProviderConfig } from '@moonshot-ai/agent-core-v2/llm-adapter/provider/provider'; +import type { + SessionListQuery, + SessionSummary, +} from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; +import type { + Workspace, + WorkspaceUpdate, +} from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; +// Test-only: the v1 wire event types now live in agent-core-v2; importing +// them here (never in `src/`) strengthens parity for the agent event stream. +import type { ToolResultEvent } from '@moonshot-ai/agent-core-v2/events'; +import type { + CompactionBlockedEvent, + CompactionCancelledEvent, + CompactionCompletedEvent, + CompactionStartedEvent, +} from '@moonshot-ai/agent-core-v2/agent/fullCompaction/compactionOps'; +import type { + AssistantDeltaEvent, + ThinkingDeltaEvent, + TurnStartedEvent, +} from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; +import type { TurnEndedEvent } from '@moonshot-ai/agent-core-v2/agent/loop/turnOps'; +import type { + PromptAbortedEvent, + PromptCompletedEvent, +} from '@moonshot-ai/agent-core-v2/agent/prompt/promptEvents'; +import type { TaskInfo } from '@moonshot-ai/agent-core-v2/agent/task/types'; +import type { + ToolCallDeltaEvent, + ToolCallStartedEvent, + ToolProgressEvent, +} from '@moonshot-ai/agent-core-v2/agent/toolExecutor/toolExecutorEvents'; +import type { WarningEvent } from '@moonshot-ai/agent-core-v2/errors'; + +import { sessionActivityStateSchema } from '../src/contract/session/activity.js'; +import { + agentCommandInfoSchema, + agentContextDataSchema, + agentTaskInfoSchema, + activateSkillPayloadSchema, + cancelPayloadSchema, + cancelPlanPayloadSchema, + cancelShellCommandPayloadSchema, + emptyPayloadSchema, + getTaskOutputPayloadSchema, + getTasksPayloadSchema, + planDataSchema, + promptLaunchResultSchema, + promptPartSchema, + promptPayloadSchema, + promptSkillActivationSchema, + promptWithSkillsPayloadSchema, + promptWithSkillsResultSchema, + runCommandPayloadSchema, + runShellCommandPayloadSchema, + runtimeBindingSchema, + setModelPayloadSchema, + setModelResultSchema, + setPermissionPayloadSchema, + shellCommandResultSchema, + steerPayloadSchema, + stopTaskPayloadSchema, + tokenUsageSchema, + usageStatusSchema, +} from '../src/contract/agent/schemas.js'; +import { + assistantDeltaEventSchema, + compactionBlockedEventSchema, + compactionCancelledEventSchema, + compactionCompletedEventSchema, + compactionStartedEventSchema, + promptAbortedEventSchema, + promptCompletedEventSchema, + thinkingDeltaEventSchema, + toolCallDeltaEventSchema, + toolCallStartedEventSchema, + toolProgressEventSchema, + toolResultEventSchema, + turnEndedEventSchema, + turnStartedEventSchema, + warningEventSchema, +} from '../src/contract/agent/events.js'; +import { + approvalRequestSchema, + approvalResponseSchema, +} from '../src/contract/session/approval.js'; +import { + fullCompactionInputSchema, + mcpServerEntrySchema, +} from '../src/contract/agent/services.js'; +import { + createChildSessionOptionsSchema, + createSessionOptionsSchema, + forkSessionOptionsSchema, + handleWireSchema, + resumeSessionOptionsSchema, +} from '../src/contract/session/lifecycle.js'; +import { + interactionResolutionSchema, + interactionSchema, +} from '../src/contract/session/interaction.js'; +import { + agentMetaSchema, + sessionMetaPatchSchema, + sessionMetaSchema, + sessionMetadataChangedEventSchema, +} from '../src/contract/session/metadata.js'; +import { + questionAnswersSchema, + questionItemSchema, + questionOptionSchema, + questionRequestSchema, + questionResponseSchema, + questionResultSchema, +} from '../src/contract/session/question.js'; +import { skillSummarySchema } from '../src/contract/session/skills.js'; +import { sessionTitleContract } from '../src/contract/session/title.js'; + +import { + authStatusSchema, + oAuthFlowSnapshotSchema, + oAuthFlowStartSchema, + oAuthLoginCancelResponseSchema, + oAuthLogoutResponseSchema, + refreshOAuthProviderModelsResponseSchema, +} from '../src/contract/global/auth.js'; +import { + configDiagnosticSchema, + configInspectValueSchema, + configTargetSchema, +} from '../src/contract/global/config.js'; +import { + capabilityInstallProgressSchema, + capabilityStatusSchema, + capabilityStepSchema, +} from '../src/contract/global/capabilities.js'; +import { + modelCatalogItemSchema, + providerCatalogItemSchema, + setDefaultModelResponseSchema, +} from '../src/contract/global/catalog.js'; +import { + refreshProviderModelsOptionsSchema, + refreshProviderModelsResponseSchema, +} from '../src/contract/global/providerDiscovery.js'; +import { experimentalFeatureStateSchema } from '../src/contract/global/flags.js'; +import { + fileMetaSchema, + fileSaveOptionsSchema, +} from '../src/contract/global/files.js'; +import { + fsBrowseResponseSchema, + fsHomeResponseSchema, +} from '../src/contract/global/hostFs.js'; +import { modelConfigSchema } from '../src/contract/global/models.js'; +import { + globalMcpServerConfigSchema, + mcpAuthStatusQuerySchema, + mcpManagedServerSchema, + mcpRegistryPluginOriginSchema, + mcpRegistryQuerySchema, + mcpServerAuthBeginResultSchema, + mcpServerAuthFlowHandleSchema, + mcpServerAuthStateSchema, + mcpServerAuthStatusSchema, + mcpServerConfigDataSchema, + mcpServerInspectionSchema, + mcpServerLocatorSchema, + mcpServerSourceSchema, + mcpServerTestResultSchema, + mcpServerTestTargetSchema, +} from '../src/contract/global/mcpManagement.js'; +import { + getPluginInfoInputSchema, + installPluginInputSchema, + pluginCommandDefSchema, + pluginDiagnosticSchema, + pluginGithubMetadataSchema, + pluginInfoSchema, + pluginManifestSchema, + pluginMcpServerInfoSchema, + pluginSummarySchema, + pluginUpdateStatusSchema, + reloadSummarySchema, + removePluginInputSchema, + setPluginEnabledInputSchema, + setPluginMcpServerEnabledInputSchema, +} from '../src/contract/global/plugins.js'; +import { providerConfigSchema } from '../src/contract/global/providers.js'; +import { + sessionListQuerySchema, + sessionSummarySchema, +} from '../src/contract/global/sessions.js'; +import { + workspaceSchema, + workspaceUpdateSchema, +} from '../src/contract/global/workspaces.js'; + +import type { AssertWire, MutableDeep } from './helpers/typeAssert.js'; +import type { AgentFacade } from '../src/core/facade/agent.js'; + +/** One-directional: the engine type must be assignable TO the schema's infer. */ +type AssertEngineToWire<TSchema extends z.ZodType, TEngine> = [MutableDeep<TEngine>] extends [ + z.infer<TSchema>, +] + ? true + : never; + +/** One-directional: the schema's infer must be assignable TO the engine type. */ +type AssertWireToEngine<TSchema extends z.ZodType, TEngine> = [z.infer<TSchema>] extends [ + MutableDeep<TEngine>, +] + ? true + : never; + +// Wire shapes, derived from the engine interfaces. +type OAuthFlowStart = Awaited<ReturnType<IOAuthService['startLogin']>>; +type OAuthFlowSnapshot = NonNullable<ReturnType<IOAuthService['getFlow']>>; +type OAuthLoginCancelResponse = Awaited<ReturnType<IOAuthService['cancelLogin']>>; +type OAuthLogoutResponse = Awaited<ReturnType<IOAuthService['logout']>>; +type RefreshOAuthProviderModelsResponse = Awaited< + ReturnType<IOAuthService['refreshOAuthProviderModels']> +>; +/** String-enum value union (`'user' | 'memory'`). */ +type ConfigTargetValues = `${ConfigTarget}`; + +// sessions.ts +const _sessionSummary: AssertWire<typeof sessionSummarySchema, SessionSummary> = true; +const _sessionListQuery: AssertWire<typeof sessionListQuerySchema, SessionListQuery> = true; + +// workspaces.ts +const _workspace: AssertWire<typeof workspaceSchema, Workspace> = true; +const _workspaceUpdate: AssertWire<typeof workspaceUpdateSchema, WorkspaceUpdate> = true; + +// config.ts +// One-directional: the engine declares the `ConfigInspectValue` keys as +// required with `| undefined` values, while the wire schema marks them +// `.optional()`; optional → required is not assignable, so only the +// engine → wire direction holds. +const _configInspectValue: AssertEngineToWire<typeof configInspectValueSchema, ConfigInspectValue> = + true; +const _configDiagnostic: AssertWire<typeof configDiagnosticSchema, ConfigDiagnostic> = true; +const _configTarget: AssertWire<typeof configTargetSchema, ConfigTargetValues> = true; + +// capabilities.ts +const _capabilityStep: AssertWire<typeof capabilityStepSchema, CapabilityStep> = true; +const _capabilityInstallProgress: AssertWire< + typeof capabilityInstallProgressSchema, + CapabilityInstallProgress +> = true; +const _capabilityStatus: AssertWire<typeof capabilityStatusSchema, CapabilityStatus> = true; + +// providers.ts +const _providerConfig: AssertWire<typeof providerConfigSchema, ProviderConfig> = true; + +// auth.ts +const _oAuthFlowStart: AssertWire<typeof oAuthFlowStartSchema, OAuthFlowStart> = true; +const _oAuthFlowSnapshot: AssertWire<typeof oAuthFlowSnapshotSchema, OAuthFlowSnapshot> = true; +const _oAuthLoginCancelResponse: AssertWire< + typeof oAuthLoginCancelResponseSchema, + OAuthLoginCancelResponse +> = true; +const _oAuthLogoutResponse: AssertWire<typeof oAuthLogoutResponseSchema, OAuthLogoutResponse> = + true; +const _authStatus: AssertWire<typeof authStatusSchema, AuthStatus> = true; +const _refreshOAuthProviderModelsResponse: AssertWire< + typeof refreshOAuthProviderModelsResponseSchema, + RefreshOAuthProviderModelsResponse +> = true; + +// flags.ts +const _experimentalFeatureState: AssertWire< + typeof experimentalFeatureStateSchema, + ExperimentalFeatureState +> = true; + +// hostFs.ts +const _fsBrowseResponse: AssertWire<typeof fsBrowseResponseSchema, FsBrowseResponse> = true; +const _fsHomeResponse: AssertWire<typeof fsHomeResponseSchema, FsHomeResponse> = true; + +// files.ts (`fileGetResultSchema` has no engine counterpart — the wire +// adaptation replaces `GetResult.stream` with base64 `data`). +const _fileMeta: AssertWire<typeof fileMetaSchema, FileMeta> = true; +const _fileSaveOptions: AssertWire<typeof fileSaveOptionsSchema, SaveOptions> = true; + +// catalog.ts / providerDiscovery.ts — protocol wire shapes derived through the +// catalog and discovery service interfaces. +type ModelCatalogItem = Awaited<ReturnType<IModelCatalog['listModels']>>[number]; +type ProviderCatalogItem = Awaited<ReturnType<IModelCatalog['listProviders']>>[number]; +type SetDefaultModelResponse = Awaited<ReturnType<IModelCatalog['setDefaultModel']>>; +type RefreshProviderModelsOptions = NonNullable< + Parameters<IProviderDiscoveryService['refreshProviderModels']>[0] +>; +type RefreshProviderModelsResponse = Awaited< + ReturnType<IProviderDiscoveryService['refreshProviderModels']> +>; +const _modelCatalogItem: AssertWire<typeof modelCatalogItemSchema, ModelCatalogItem> = true; +const _providerCatalogItem: AssertWire<typeof providerCatalogItemSchema, ProviderCatalogItem> = + true; +const _setDefaultModelResponse: AssertWire< + typeof setDefaultModelResponseSchema, + SetDefaultModelResponse +> = true; +const _refreshProviderModelsOptions: AssertWire< + typeof refreshProviderModelsOptionsSchema, + RefreshProviderModelsOptions +> = true; +const _refreshProviderModelsResponse: AssertWire< + typeof refreshProviderModelsResponseSchema, + RefreshProviderModelsResponse +> = true; + +// models.ts +const _modelConfig: AssertWire<typeof modelConfigSchema, ModelRecord> = true; + +// plugins.ts +const _pluginSummary: AssertWire<typeof pluginSummarySchema, PluginSummary> = true; +const _pluginInfo: AssertWire<typeof pluginInfoSchema, PluginInfo> = true; +const _pluginManifest: AssertWire<typeof pluginManifestSchema, PluginManifest> = true; +const _pluginMcpServerInfo: AssertWire<typeof pluginMcpServerInfoSchema, PluginMcpServerInfo> = + true; +const _pluginDiagnostic: AssertWire<typeof pluginDiagnosticSchema, PluginDiagnostic> = true; +const _pluginGithubMetadata: AssertWire<typeof pluginGithubMetadataSchema, PluginGithubMetadata> = + true; +const _reloadSummary: AssertWire<typeof reloadSummarySchema, ReloadSummary> = true; +const _pluginUpdateStatus: AssertWire<typeof pluginUpdateStatusSchema, PluginUpdateStatus> = true; +const _pluginCommandDef: AssertWire<typeof pluginCommandDefSchema, PluginCommandDef> = true; +const _installPluginInput: AssertWire<typeof installPluginInputSchema, InstallPluginInput> = true; +const _setPluginEnabledInput: AssertWire< + typeof setPluginEnabledInputSchema, + SetPluginEnabledInput +> = true; +const _setPluginMcpServerEnabledInput: AssertWire< + typeof setPluginMcpServerEnabledInputSchema, + SetPluginMcpServerEnabledInput +> = true; +const _removePluginInput: AssertWire<typeof removePluginInputSchema, RemovePluginInput> = true; +const _getPluginInfoInput: AssertWire<typeof getPluginInfoInputSchema, GetPluginInfoInput> = true; + +// global/mcpManagement.ts — the `McpServerConfig | McpServerConfigView` union +// a managed server's `config` carries (full for mutable entries, redacted for +// read-only ones) is mirrored by one schema covering both shapes; the +// inspection's `config` is always the redacted view, and both assignability +// directions hold against either engine type. +const _mcpServerSource: AssertWire<typeof mcpServerSourceSchema, McpServerSource> = true; +const _mcpRegistryPluginOrigin: AssertWire< + typeof mcpRegistryPluginOriginSchema, + McpRegistryPluginOrigin +> = true; +const _mcpRegistryQuery: AssertWire<typeof mcpRegistryQuerySchema, McpRegistryQuery> = true; +const _mcpAuthStatusQuery: AssertWire<typeof mcpAuthStatusQuerySchema, McpAuthStatusQuery> = true; +const _globalMcpServerConfig: AssertWire< + typeof globalMcpServerConfigSchema, + GlobalMcpServerConfig +> = true; +const _mcpServerConfigData: AssertWire< + typeof mcpServerConfigDataSchema, + McpServerConfig | McpServerConfigView +> = true; +const _mcpServerConfigViewData: AssertWire< + typeof mcpServerConfigDataSchema, + McpServerConfigView +> = true; +const _mcpManagedServer: AssertWire<typeof mcpManagedServerSchema, McpManagedServer> = true; +const _mcpServerTestTarget: AssertWire<typeof mcpServerTestTargetSchema, McpServerTestTarget> = + true; +const _mcpServerTestResult: AssertWire<typeof mcpServerTestResultSchema, McpServerTestResult> = + true; +const _mcpServerLocator: AssertWire<typeof mcpServerLocatorSchema, McpServerLocator> = true; +const _mcpServerAuthState: AssertWire<typeof mcpServerAuthStateSchema, McpServerAuthState> = true; +const _mcpServerInspection: AssertWire<typeof mcpServerInspectionSchema, McpServerInspection> = + true; +const _mcpServerAuthStatus: AssertWire<typeof mcpServerAuthStatusSchema, McpServerAuthStatus> = + true; +const _mcpServerAuthBeginResult: AssertWire< + typeof mcpServerAuthBeginResultSchema, + McpServerAuthBeginResult +> = true; +const _mcpServerAuthFlowHandle: AssertWire< + typeof mcpServerAuthFlowHandleSchema, + McpServerAuthFlowHandle +> = true; + +// env.ts has no named schemas; `platform` narrows to `NodeJS.Platform` in the +// engine — assert the bootstrap properties are all strings instead. The +// object-typed `clientIdentity` is intentionally not in this list. +type _bootstrapStringProps = AssertStringProps< + Pick< + IBootstrapService, + | 'platform' + | 'arch' + | 'cwd' + | 'osHomeDir' + | 'homeDir' + | 'configPath' + | 'sessionsDir' + | 'blobsDir' + | 'storeDir' + | 'cacheDir' + | 'logsDir' + > +>; +type AssertStringProps<T> = T extends Record<string, string> ? true : never; +const _envProps: _bootstrapStringProps = true; + +// ── session scope ─────────────────────────────────────────────────────────── + +// session/metadata.ts +const _sessionMeta: AssertWire<typeof sessionMetaSchema, SessionMeta> = true; +const _agentMeta: AssertWire<typeof agentMetaSchema, AgentMeta> = true; +const _sessionMetaPatch: AssertWire<typeof sessionMetaPatchSchema, SessionMetaPatch> = true; +const _sessionMetadataChangedEvent: AssertWire< + typeof sessionMetadataChangedEventSchema, + SessionMetadataChangedEvent +> = true; + +// session/lifecycle.ts +const _createSessionOptions: AssertWire<typeof createSessionOptionsSchema, CreateSessionOptions> = + true; +const _forkSessionOptions: AssertWire<typeof forkSessionOptionsSchema, ForkSessionOptions> = true; +const _resumeSessionOptions: AssertWire<typeof resumeSessionOptionsSchema, ResumeSessionOptions> = + true; +const _createChildSessionOptions: AssertWire< + typeof createChildSessionOptionsSchema, + CreateChildSessionOptions +> = true; +// One-directional: the wire handle is `z.looseObject` — the in-process +// `ISessionScopeHandle` carries an `accessor` and `dispose()` that JSON +// drops, so only the engine → wire direction holds. +const _handleWire: AssertEngineToWire<typeof handleWireSchema, ISessionScopeHandle> = true; + +// session/interaction.ts +const _interaction: AssertWire<typeof interactionSchema, Interaction> = true; +const _interactionResolution: AssertWire< + typeof interactionResolutionSchema, + InteractionResolution +> = true; + +// session/approval.ts +// One-directional: `display` is the protocol `ToolInputDisplay` union (huge) +// and crosses the wire as `unknown`; the wire schema cannot be assignable +// back to the engine type. +const _approvalRequest: AssertEngineToWire<typeof approvalRequestSchema, ApprovalRequest> = true; +const _approvalResponse: AssertWire<typeof approvalResponseSchema, ApprovalResponse> = true; + +// session/question.ts +const _questionRequest: AssertWire<typeof questionRequestSchema, QuestionRequest> = true; +const _questionItem: AssertWire<typeof questionItemSchema, QuestionItem> = true; +const _questionOption: AssertWire<typeof questionOptionSchema, QuestionOption> = true; +const _questionAnswers: AssertWire<typeof questionAnswersSchema, QuestionAnswers> = true; +const _questionResponse: AssertWire<typeof questionResponseSchema, QuestionResponse> = true; +const _questionResult: AssertWire<typeof questionResultSchema, QuestionResult> = true; + +// session/skills.ts +const _skillSummary: AssertWire<typeof skillSummarySchema, SkillSummary> = true; + +// session/title.ts +const _generateTitleOutput: AssertWire< + (typeof sessionTitleContract)['generateTitle']['output'], + Awaited<ReturnType<ISessionTitleService['generateTitle']>> +> = true; + +// session/activity.ts +const _sessionActivityState: AssertWire<typeof sessionActivityStateSchema, SessionActivityState> = + true; + +// ── agent scope (services.ts / schemas.ts) ────────────────────────────────── +// Payload/result types are derived from the domain service interfaces the +// facade calls, so the assertions track the exact methods the contract +// mirrors; facade-only payload shapes (cancel / setPermission / plan / task / +// command) derive from the `AgentFacade` input types. +type ActivateSkillPayload = Parameters<IAgentSkillService['activate']>[0]; +type PromptWithSkillsPayload = Parameters<IAgentSkillService['promptWithSkills']>[0]; +type PromptSkillActivation = PromptWithSkillsPayload['skills'][number]; +type AgentCommandInfo = ReturnType<IAgentCommandService['list']>[number]; +type RuntimeBinding = ReturnType<IAgentRuntimeBindingService['get']>; +type RunShellCommandPayload = Parameters<IAgentShellCommandService['run']>[0]; +type ShellCommandResult = Awaited<ReturnType<IAgentShellCommandService['run']>>; +type SetModelResult = Awaited<ReturnType<IAgentProfileService['setModel']>>; +type TokenUsage = NonNullable<UsageStatus['total']>; +type PromptPart = Extract<ContentPart, { type: 'text' | 'image_url' | 'video_url' }>; + +type EmptyPayload = {}; +type CancelPayload = NonNullable<Parameters<AgentFacade['cancel']>[0]>; +type SetPermissionPayload = { mode: PermissionMode }; +type RunCommandPayload = Parameters<AgentFacade['runCommand']>[0]; +type CancelShellCommandPayload = Parameters<AgentFacade['cancelShellCommand']>[0]; +type SetModelPayload = { model: string }; +type CancelPlanPayload = NonNullable<Parameters<AgentFacade['cancelPlan']>[0]>; +type GetTasksPayload = NonNullable<Parameters<AgentFacade['getTasks']>[0]>; +type StopTaskPayload = Parameters<AgentFacade['stopTask']>[0]; +type GetTaskOutputPayload = Parameters<AgentFacade['getTaskOutput']>[0]; + +const _emptyPayload: AssertWire<typeof emptyPayloadSchema, EmptyPayload> = true; +const _promptPart: AssertWire<typeof promptPartSchema, PromptPart> = true; +// One-directional (wire → engine): the engine's `PromptPayload.input` accepts +// the full `ContentPart` union (also think/audio parts); the wire mirrors the +// `PromptPart` subset clients may send, so the reverse direction fails. +const _promptPayload: AssertWireToEngine<typeof promptPayloadSchema, PromptPayload> = true; +const _promptSkillActivation: AssertWire< + typeof promptSkillActivationSchema, + PromptSkillActivation +> = true; +// Same one-directional rule as `promptPayload`: the engine's `input` accepts +// the full `ContentPart` union; the wire mirrors the `PromptPart` subset. +const _promptWithSkillsPayload: AssertWireToEngine< + typeof promptWithSkillsPayloadSchema, + PromptWithSkillsPayload +> = true; +const _steerPayload: AssertWireToEngine<typeof steerPayloadSchema, SteerPayload> = true; +const _activateSkillPayload: AssertWire<typeof activateSkillPayloadSchema, ActivateSkillPayload> = + true; +const _promptLaunchResult: AssertWire<typeof promptLaunchResultSchema, PromptLaunchResult> = true; +type PromptWithSkillsResult = Awaited<ReturnType<IAgentSkillService['promptWithSkills']>>; +const _promptWithSkillsResult: AssertWire< + typeof promptWithSkillsResultSchema, + PromptWithSkillsResult +> = true; +const _cancelPayload: AssertWire<typeof cancelPayloadSchema, CancelPayload> = true; +const _runShellCommandPayload: AssertWire< + typeof runShellCommandPayloadSchema, + RunShellCommandPayload +> = true; +const _shellCommandResult: AssertWire<typeof shellCommandResultSchema, ShellCommandResult> = true; +const _cancelShellCommandPayload: AssertWire< + typeof cancelShellCommandPayloadSchema, + CancelShellCommandPayload +> = true; +const _setModelPayload: AssertWire<typeof setModelPayloadSchema, SetModelPayload> = true; +const _setModelResult: AssertWire<typeof setModelResultSchema, SetModelResult> = true; +const _setPermissionPayload: AssertWire<typeof setPermissionPayloadSchema, SetPermissionPayload> = + true; +const _tokenUsage: AssertWire<typeof tokenUsageSchema, TokenUsage> = true; +const _usageStatus: AssertWire<typeof usageStatusSchema, UsageStatus> = true; +// One-directional: `history` entries are full `ContextMessage`s (deep +// `Message`/`Tool`/`PromptOrigin` unions) mirrored as `unknown`. +const _agentContextData: AssertEngineToWire<typeof agentContextDataSchema, AgentContextData> = true; +const _agentCommandInfo: AssertWire<typeof agentCommandInfoSchema, AgentCommandInfo> = true; +const _runtimeBinding: AssertWire<typeof runtimeBindingSchema, RuntimeBinding> = true; +const _runCommandPayload: AssertWire<typeof runCommandPayloadSchema, RunCommandPayload> = true; +const _planData: AssertWire<typeof planDataSchema, PlanData> = true; +const _cancelPlanPayload: AssertWire<typeof cancelPlanPayloadSchema, CancelPlanPayload> = true; +const _getTasksPayload: AssertWire<typeof getTasksPayloadSchema, GetTasksPayload> = true; +// The wire task union mirrors the protocol `TaskInfo`; the engine's +// declaration-merged `AgentTaskInfo` is structurally identical but depends on +// tool-module augmentation, so parity is pinned to the protocol type. +const _agentTaskInfo: AssertWire<typeof agentTaskInfoSchema, TaskInfo> = true; +const _stopTaskPayload: AssertWire<typeof stopTaskPayloadSchema, StopTaskPayload> = true; +const _getTaskOutputPayload: AssertWire<typeof getTaskOutputPayloadSchema, GetTaskOutputPayload> = + true; + +// agent/services.ts (mcp / fullCompaction) +const _mcpServerEntry: AssertWire<typeof mcpServerEntrySchema, McpServerEntry> = true; +const _fullCompactionInput: AssertWire<typeof fullCompactionInputSchema, FullCompactionInput> = + true; + +// ── agent scope (events.ts) ───────────────────────────────────────────────── +// Parity against the protocol event types (the stream carries flat +// `{ type, ... }` events; schemas keep the `type` literal). One-directional +// where a field is mirrored as `unknown`. +const _turnStartedEvent: AssertEngineToWire<typeof turnStartedEventSchema, TurnStartedEvent> = true; +const _turnEndedEvent: AssertEngineToWire<typeof turnEndedEventSchema, TurnEndedEvent> = true; +const _assistantDeltaEvent: AssertWire<typeof assistantDeltaEventSchema, AssistantDeltaEvent> = + true; +const _thinkingDeltaEvent: AssertWire<typeof thinkingDeltaEventSchema, ThinkingDeltaEvent> = true; +const _toolCallStartedEvent: AssertEngineToWire< + typeof toolCallStartedEventSchema, + ToolCallStartedEvent +> = true; +const _toolCallDeltaEvent: AssertWire<typeof toolCallDeltaEventSchema, ToolCallDeltaEvent> = true; +const _toolProgressEvent: AssertWire<typeof toolProgressEventSchema, ToolProgressEvent> = true; +const _toolResultEvent: AssertWire<typeof toolResultEventSchema, ToolResultEvent> = true; +const _promptCompletedEvent: AssertWire<typeof promptCompletedEventSchema, PromptCompletedEvent> = + true; +const _promptAbortedEvent: AssertWire<typeof promptAbortedEventSchema, PromptAbortedEvent> = true; +const _compactionStartedEvent: AssertWire< + typeof compactionStartedEventSchema, + CompactionStartedEvent +> = true; +const _compactionBlockedEvent: AssertWire< + typeof compactionBlockedEventSchema, + CompactionBlockedEvent +> = true; +const _compactionCancelledEvent: AssertWire< + typeof compactionCancelledEventSchema, + CompactionCancelledEvent +> = true; +const _compactionCompletedEvent: AssertWire< + typeof compactionCompletedEventSchema, + CompactionCompletedEvent +> = true; +const _warningEvent: AssertWire<typeof warningEventSchema, WarningEvent> = true; +// No parity assertions for `errorEventSchema`, `permissionApproval*Schema`, +// and `agentStatusUpdatedEventSchema`: they are deliberately `z.looseObject`s +// (index signature breaks both-ways assignability) — `permission.approval.*` +// is not part of the protocol event union at all. diff --git a/packages/klient/test/contract.test.ts b/packages/klient/test/contract.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3181e53afc0eff4b973635857a84cf86602dbbde --- /dev/null +++ b/packages/klient/test/contract.test.ts @@ -0,0 +1,116 @@ +/** + * Scenario: runtime validation at Klient wire-contract boundaries. + * + * Exercises the session-creation and plugin-manifest schemas directly with no + * external collaborators. Run with `pnpm --filter @moonshot-ai/klient exec + * vitest run test/contract.test.ts`. + */ + +import { describe, expect, it } from 'vitest'; + +import { pluginManifestSchema } from '../src/contract/global/plugins.js'; +import { mcpServerAuthFlowHandleSchema } from '../src/contract/global/mcpManagement.js'; +import { createSessionOptionsSchema } from '../src/contract/session/lifecycle.js'; +import { promptPayloadSchema } from '../src/contract/agent/schemas.js'; + +type McpTimeoutField = 'startupTimeoutMs' | 'toolTimeoutMs'; + +const timeoutCases = [ + { + surface: 'plugin manifests', + parse: (field: McpTimeoutField, value: number) => + pluginManifestSchema.safeParse({ + name: 'example', + mcpServers: { + example: { transport: 'stdio', command: 'node', [field]: value }, + }, + }), + }, +].flatMap(({ surface, parse }) => [ + { surface, field: 'startupTimeoutMs' as const, parse }, + { surface, field: 'toolTimeoutMs' as const, parse }, +]); + +describe('MCP timeout contract validation', () => { + it.each(timeoutCases)('accepts the maximum $field for $surface', ({ field, parse }) => { + expect(parse(field, 2_147_483_647).success).toBe(true); + }); + + it.each(timeoutCases)('rejects an above-maximum $field for $surface', ({ field, parse }) => { + expect(parse(field, 2_147_483_648).success).toBe(false); + }); + + it('session creation options accept ephemeral mcpServers', () => { + const parsed = createSessionOptionsSchema.safeParse({ + workDir: '/tmp/example', + mcpServers: { + stdioExample: { transport: 'stdio', command: 'node', args: ['server.mjs'] }, + httpExample: { transport: 'http', url: 'https://example.com/mcp', headers: { a: 'b' } }, + sseExample: { transport: 'sse', url: 'https://example.com/sse' }, + }, + }); + expect(parsed.success).toBe(true); + expect(parsed.data?.mcpServers?.['stdioExample']).toEqual({ + transport: 'stdio', + command: 'node', + args: ['server.mjs'], + }); + }); + + it('session creation options preserve the per-server deferred field', () => { + const parsed = createSessionOptionsSchema.safeParse({ + workDir: '/tmp/example', + mcpServers: { + stdioExample: { transport: 'stdio', command: 'node', deferred: false }, + httpExample: { transport: 'http', url: 'https://example.com/mcp', deferred: true }, + sseExample: { transport: 'sse', url: 'https://example.com/sse' }, + }, + }); + expect(parsed.success).toBe(true); + expect(parsed.data?.mcpServers?.['stdioExample']).toEqual({ + transport: 'stdio', + command: 'node', + deferred: false, + }); + expect(parsed.data?.mcpServers?.['httpExample']).toEqual({ + transport: 'http', + url: 'https://example.com/mcp', + deferred: true, + }); + expect(parsed.data?.mcpServers?.['sseExample']).toEqual({ + transport: 'sse', + url: 'https://example.com/sse', + }); + }); + + it('session creation options reject malformed mcpServers entries', () => { + const parsed = createSessionOptionsSchema.safeParse({ + workDir: '/tmp/example', + mcpServers: { + example: { transport: 'http', url: 'not-a-url' }, + }, + }); + expect(parsed.success).toBe(false); + }); + + it('completeAuth timeoutMs accepts the setTimeout maximum and rejects above it', () => { + expect( + mcpServerAuthFlowHandleSchema.safeParse({ flowId: 'flow-1', timeoutMs: 2_147_483_647 }) + .success, + ).toBe(true); + expect( + mcpServerAuthFlowHandleSchema.safeParse({ flowId: 'flow-1', timeoutMs: 2_147_483_648 }) + .success, + ).toBe(false); + }); +}); + +describe('prompt contract validation', () => { + it('rejects an empty caller-chosen promptId', () => { + expect(promptPayloadSchema.safeParse({ input: [], promptId: '' }).success).toBe(false); + }); + + it('accepts a non-empty caller-chosen promptId', () => { + expect(promptPayloadSchema.safeParse({ input: [], promptId: 'submission-1' }).success).toBe(true); + }); +}); diff --git a/packages/klient/test/e2e/harness/client.ts b/packages/klient/test/e2e/harness/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..b18664d01f265777283dd130b481741379f9cc6e --- /dev/null +++ b/packages/klient/test/e2e/harness/client.ts @@ -0,0 +1,706 @@ +/** + * `DaemonClient` — wire-level test client for the kimi-code server. + * + * Wraps the server's HTTP REST + WS surfaces (`/api/v1/...` + `/api/v1/ws`) + * into a single, typed object that scenarios can drive. Handles: + * - Envelope unwrap + typed REST helpers + * - WS `server_hello` → `client_hello` → ack handshake + * - `subscribe` / `unsubscribe` ack correlation + * - Approval + question reverse-RPC auto-resolve via per-event handlers + * - `waitForFrame` / `waitForSessionBusy` convenience waits + * + * **What it is NOT**: a server bootstrap helper. Connect to a server process + * that's already running at `baseUrl` (default `http://127.0.0.1:58627`). + */ +import type { + FsBrowseResponse, + FsHomeResponse, +} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +import type { AuthSummary } from '@moonshot-ai/agent-core-v2/app/authLegacy/authLegacy'; +import type { FileMeta } from '@moonshot-ai/agent-core-v2/app/file/fileService'; +import type { UpdateSessionProfileRequest as SessionUpdate } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; +import type { + ProviderCatalogItem, + SetDefaultModelResponse, +} from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; +import type { Terminal } from '@moonshot-ai/agent-core-v2/os/interface/terminal'; +import type { + ApprovalRequest, + ApprovalResponse, +} from '@moonshot-ai/kap-server/protocol/approval'; +import type { Message } from '@moonshot-ai/kap-server/protocol/message'; +import type { + QuestionRequest, + QuestionResponse, +} from '@moonshot-ai/kap-server/protocol/question'; +import type { + ApprovalResolveResult, + ListPendingApprovalsResponse, +} from '@moonshot-ai/kap-server/protocol/rest-approval'; +import type { + ListModelsResponse, + ListProvidersResponse, +} from '@moonshot-ai/kap-server/protocol/rest-modelCatalog'; +import type { + PromptAbortResponse, + PromptListResponse, + PromptPermissionMode, + PromptSubmission, + PromptSteerResult, + PromptSubmitResult, + PromptThinking, +} from '@moonshot-ai/kap-server/protocol/rest-prompt'; +import type { + ListPendingQuestionsResponse, + QuestionResolveResult, +} from '@moonshot-ai/kap-server/protocol/rest-question'; +import type { + CompactSessionRequest, + CompactSessionResponse, + ForkSessionRequest, + SessionAbortResponse, + UndoSessionRequest, + UndoSessionResponse, +} from '@moonshot-ai/kap-server/protocol/rest-session'; +import type { + CloseTerminalResponse, + CreateTerminalRequest, + ListTerminalsResponse, +} from '@moonshot-ai/kap-server/protocol/rest-terminal'; +import type { + Session, + SessionChildCreate, + SessionCreate, +} from '@moonshot-ai/kap-server/protocol/session'; +import type { + Workspace, + WorkspaceCreate, + WorkspaceUpdate, +} from '@moonshot-ai/kap-server/protocol/workspace'; +import type { ServerHelloMessage } from '@moonshot-ai/kap-server/protocol/ws-control'; +import { ulid } from 'ulid'; +import { WebSocket as WsWebSocket } from 'ws'; + +import { HttpClient } from './http.js'; +import { installReverseRpcHandler } from './reverse-rpc.js'; +import { DEFAULT_FRAME_TIMEOUT_MS, waitForSessionBusy } from './wait.js'; +import { type AnyFrame, WsClient } from './ws.js'; + +export interface DaemonClientOptions { + /** Default `http://127.0.0.1:58627`. */ + baseUrl?: string; + /** Default `/api/v1`. WS endpoint is `${apiPrefix}/ws`. */ + apiPrefix?: string; + /** Default `server-e2e-<ulid>` — used as the `client_hello.client_id`. */ + clientId?: string; + fetchImpl?: typeof fetch; + wsImpl?: typeof WsWebSocket; + logger?: (level: 'info' | 'warn' | 'error' | 'debug', msg: string, meta?: unknown) => void; + /** Directory for JSONL trace events and generated HTML reports. */ + reportDir?: string; + /** Default 5s. Applies to handshake + subscribe acks. */ + controlAckTimeoutMs?: number; +} + +export interface SubmitAndWaitOptions { + /** Default `prompt.completed`. */ + waitFor?: 'prompt.completed' | 'turn.ended'; + /** Default 60s. */ + timeoutMs?: number; +} + +type UploadFileData = Blob | ArrayBuffer | Uint8Array | string; + +const DEFAULT_BASE_URL = 'http://127.0.0.1:58627'; +const DEFAULT_API_PREFIX = '/api/v1'; +const DEFAULT_CONTROL_ACK_TIMEOUT_MS = 5_000; + +/** + * Per-request stateless session controls that the server REST surface + * requires on every prompt submission. Scenarios that don't care about + * these can leave them at the defaults; tests that exercise switching + * model / thinking / permission / plan mode override only the field + * they need. + * + * `model` matches what the existing server-e2e scenarios assume (the + * default provider exposes `kimi-code/kimi-for-coding`). + */ +export const DEFAULT_PROMPT_CONTROLS = { + model: 'kimi-code/kimi-for-coding', + thinking: 'off' as PromptThinking, + permission_mode: 'manual' as PromptPermissionMode, + plan_mode: false, +} as const; + +/** + * Looser input shape for `submitPrompt` / `submitAndWait`. `content` is + * required; the four stateless controls fall back to + * `DEFAULT_PROMPT_CONTROLS` when omitted. `metadata` carries through + * verbatim. + */ +export type PromptSubmitInput = + Pick<PromptSubmission, 'content'> + & Partial<Pick<PromptSubmission, 'metadata' | 'model' | 'thinking' | 'permission_mode' | 'plan_mode'>>; + +export interface TerminalAttachOptions { + sinceSeq?: number; + timeoutMs?: number; +} + +export interface TerminalControlOptions { + timeoutMs?: number; +} + +export interface TerminalAttachResult { + attached: true; + replayed: number; +} + +export interface TerminalDetachResult { + detached: true; +} + +export interface TerminalInputResult { + accepted: true; +} + +export interface TerminalResizeResult { + resized: true; +} + +export interface TerminalCloseResult { + closed: true; +} + +function fillPromptDefaults(input: PromptSubmitInput): PromptSubmission { + return { ...DEFAULT_PROMPT_CONTROLS, ...input }; +} + +export class DaemonClient { + readonly baseUrl: string; + readonly apiPrefix: string; + readonly clientId: string; + readonly http: HttpClient; + + private readonly _wsImpl: typeof WsWebSocket; + private readonly _logger: ( + level: 'info' | 'warn' | 'error' | 'debug', + msg: string, + meta?: unknown, + ) => void; + private readonly _reportDir: string | undefined; + private readonly _controlAckTimeoutMs: number; + private _ws: WsClient | null = null; + private _serverHello: ServerHelloMessage['payload'] | null = null; + private readonly _subscribed = new Set<string>(); + private readonly _disposers: Array<() => void> = []; + + constructor(opts: DaemonClientOptions = {}) { + this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); + this.apiPrefix = opts.apiPrefix ?? DEFAULT_API_PREFIX; + this.clientId = opts.clientId ?? `server-e2e-${ulid()}`; + this._wsImpl = opts.wsImpl ?? WsWebSocket; + this._logger = opts.logger ?? noopLogger; + this._reportDir = opts.reportDir; + this._controlAckTimeoutMs = opts.controlAckTimeoutMs ?? DEFAULT_CONTROL_ACK_TIMEOUT_MS; + this.http = new HttpClient({ + baseUrl: this.baseUrl, + apiPrefix: this.apiPrefix, + fetchImpl: opts.fetchImpl ?? fetch, + reportDir: this._reportDir, + }); + } + + // ── Probes + model catalog ───────────────────────────────────────────── + getAuth(): Promise<AuthSummary> { + return this.http.getAuth(); + } + listModels(): Promise<ListModelsResponse> { + return this.http.listModels(); + } + setDefaultModel(modelId: string): Promise<SetDefaultModelResponse> { + return this.http.setDefaultModel(modelId); + } + listProviders(): Promise<ListProvidersResponse> { + return this.http.listProviders(); + } + getProvider(providerId: string): Promise<ProviderCatalogItem> { + return this.http.getProvider(providerId); + } + + // ── HTTP convenience surface ──────────────────────────────────────────── + createSession(body: SessionCreate): Promise<Session> { + return this.http.createSession(body); + } + getSession(sid: string): Promise<Session> { + return this.http.getSession(sid); + } + listSessions( + query?: { page_size?: number; before_id?: string; after_id?: string; workspace_id?: string }, + ): Promise<{ items: Session[]; has_more: boolean }> { + return this.http.listSessions(query); + } + updateSession(sid: string, body: SessionUpdate): Promise<Session> { + return this.http.updateSession(sid, body); + } + forkSession(sid: string, body: ForkSessionRequest = {}): Promise<Session> { + return this.http.forkSession(sid, body); + } + compactSession( + sid: string, + body: CompactSessionRequest = {}, + ): Promise<CompactSessionResponse> { + return this.http.compactSession(sid, body); + } + undoSession( + sid: string, + body: UndoSessionRequest = { count: 1 }, + ): Promise<UndoSessionResponse> { + return this.http.undoSession(sid, body); + } + archiveSession(sid: string): Promise<{ archived: true }> { + return this.http.archiveSession(sid); + } + listChildren( + sid: string, + query?: { page_size?: number; before_id?: string; after_id?: string; busy?: boolean }, + ): Promise<{ items: Session[]; has_more: boolean }> { + return this.http.listChildren(sid, query); + } + createChild(sid: string, body: SessionChildCreate = {}): Promise<Session> { + return this.http.createChild(sid, body); + } + + // ── Terminals ────────────────────────────────────────────────────────── + listTerminals(sid: string): Promise<ListTerminalsResponse> { + return this.http.listTerminals(sid); + } + createTerminal( + sid: string, + body: CreateTerminalRequest = {}, + ): Promise<Terminal> { + return this.http.createTerminal(sid, body); + } + getTerminal(sid: string, terminalId: string): Promise<Terminal> { + return this.http.getTerminal(sid, terminalId); + } + closeTerminal( + sid: string, + terminalId: string, + ): Promise<CloseTerminalResponse> { + return this.http.closeTerminal(sid, terminalId); + } + + // ── Workspaces + folder picker ────────────────────────────────────────── + listWorkspaces(): Promise<{ items: Workspace[] }> { + return this.http.listWorkspaces(); + } + createWorkspace(body: WorkspaceCreate): Promise<Workspace> { + return this.http.createWorkspace(body); + } + updateWorkspace(workspaceId: string, body: WorkspaceUpdate): Promise<Workspace> { + return this.http.updateWorkspace(workspaceId, body); + } + deleteWorkspace(workspaceId: string): Promise<{ deleted: true }> { + return this.http.deleteWorkspace(workspaceId); + } + fsBrowse(path?: string): Promise<FsBrowseResponse> { + return this.http.fsBrowse(path); + } + fsHome(): Promise<FsHomeResponse> { + return this.http.fsHome(); + } + uploadFile(input: { + name: string; + data: UploadFileData; + mediaType?: string; + expiresInSec?: number; + }): Promise<FileMeta> { + return this.http.uploadFile(input); + } + deleteFile(fileId: string): Promise<{ deleted: true }> { + return this.http.deleteFile(fileId); + } + listMessages( + sid: string, + query?: { page_size?: number; before_id?: string; after_id?: string; role?: string }, + ): Promise<{ items: Message[]; has_more: boolean }> { + return this.http.listMessages(sid, query); + } + submitPrompt(sid: string, input: PromptSubmitInput): Promise<PromptSubmitResult> { + return this.http.submitPrompt(sid, fillPromptDefaults(input)); + } + /** + * Stateful-session submit — sends `body` to `POST /sessions/{sid}/prompts` + * verbatim, with NO default controls injected. Pair with + * `updateSession(sid, {agent_config: {...}})` (or `submitPrompt` with the + * legacy default-filled path) to first establish session state, then + * exercise the "content-only prompt inherits session state" contract. + */ + submitPromptStateful( + sid: string, + body: PromptSubmission, + ): Promise<PromptSubmitResult> { + return this.http.submitPrompt(sid, body); + } + listPrompts(sid: string): Promise<PromptListResponse> { + return this.http.listPrompts(sid); + } + steerPrompt(sid: string, pid: string): Promise<PromptSteerResult> { + return this.http.steerPrompt(sid, pid); + } + steerPrompts(sid: string, promptIds: readonly string[]): Promise<PromptSteerResult> { + return this.http.steerPrompts(sid, promptIds); + } + abortPrompt(sid: string, pid: string): Promise<PromptAbortResponse> { + return this.http.abortPrompt(sid, pid); + } + abortSession(sid: string): Promise<SessionAbortResponse> { + return this.http.abortSession(sid); + } + resolveApproval( + sid: string, + aid: string, + body: ApprovalResponse, + ): Promise<ApprovalResolveResult> { + return this.http.resolveApproval(sid, aid, body); + } + listPendingApprovals(sid: string): Promise<ListPendingApprovalsResponse> { + return this.http.listPendingApprovals(sid); + } + resolveQuestion( + sid: string, + qid: string, + body: QuestionResponse, + ): Promise<QuestionResolveResult> { + return this.http.resolveQuestion(sid, qid, body); + } + listPendingQuestions(sid: string): Promise<ListPendingQuestionsResponse> { + return this.http.listPendingQuestions(sid); + } + dismissQuestion( + sid: string, + qid: string, + ): Promise<{ dismissed: true; dismissed_at: string }> { + return this.http.dismissQuestion(sid, qid); + } + + // ── WS lifecycle ──────────────────────────────────────────────────────── + /** + * Open the WS socket, wait for `server_hello`, send `client_hello`, await + * the ack. Returns the server's hello payload (buffer sizes, capabilities, + * etc.). + */ + async connect(): Promise<ServerHelloMessage['payload']> { + if (this._serverHello) return this._serverHello; + const wsUrl = `${this.baseUrl.replace(/^http/, 'ws')}${this.apiPrefix}/ws`; + const ws = new WsClient({ + url: wsUrl, + wsImpl: this._wsImpl, + logger: this._logger, + reportDir: this._reportDir, + }); + this._ws = ws; + await ws.open(); + + const helloFrame = await ws.waitForFrame( + (f) => f.type === 'server_hello', + this._controlAckTimeoutMs, + ); + const helloPayload = helloFrame.payload as ServerHelloMessage['payload']; + this._serverHello = helloPayload; + + const helloId = `hello-${ulid()}`; + const ack = await ws.sendAndAwaitAck( + { + type: 'client_hello', + id: helloId, + payload: { client_id: this.clientId, subscriptions: [] }, + }, + this._controlAckTimeoutMs, + ); + if (ack.code !== 0) { + throw new Error(`client_hello rejected (code=${ack.code}): ${ack.msg ?? 'no message'}`); + } + this._logger('debug', 'ws: handshake complete', { + wsConnectionId: helloPayload.ws_connection_id, + clientId: this.clientId, + }); + return helloPayload; + } + + /** Send `subscribe` and await its ack. Tracks the session for `close()`. */ + async subscribe(sid: string): Promise<void> { + const ws = this._requireWs(); + if (this._subscribed.has(sid)) return; + const id = `sub-${ulid()}`; + const ack = await ws.sendAndAwaitAck( + { type: 'subscribe', id, payload: { session_ids: [sid] } }, + this._controlAckTimeoutMs, + ); + if (ack.code !== 0) { + throw new Error(`subscribe rejected (code=${ack.code}): ${ack.msg ?? 'no message'}`); + } + this._subscribed.add(sid); + } + + /** Send `unsubscribe` and await its ack. */ + async unsubscribe(sid: string): Promise<void> { + const ws = this._requireWs(); + if (!this._subscribed.has(sid)) return; + const id = `unsub-${ulid()}`; + const ack = await ws.sendAndAwaitAck( + { type: 'unsubscribe', id, payload: { session_ids: [sid] } }, + this._controlAckTimeoutMs, + ); + if (ack.code !== 0) { + throw new Error(`unsubscribe rejected (code=${ack.code}): ${ack.msg ?? 'no message'}`); + } + this._subscribed.delete(sid); + } + + /** Close the socket. Idempotent. */ + async close(): Promise<void> { + for (const dispose of this._disposers.splice(0)) { + try { + dispose(); + } catch { + // ignore + } + } + if (this._ws) { + await this._ws.close(); + this._ws = null; + } + this._serverHello = null; + this._subscribed.clear(); + } + + // ── WS observation ────────────────────────────────────────────────────── + /** Subscribe to ALL incoming frames. Returns an unsubscribe handle. */ + onFrame(handler: (frame: AnyFrame) => void): () => void { + return this._requireWs().onFrame(handler); + } + + /** Wait for the next frame satisfying `predicate`. */ + waitForFrame( + predicate: (frame: AnyFrame) => boolean, + opts?: { timeoutMs?: number }, + ): Promise<AnyFrame> { + return this._requireWs().waitForFrame( + predicate, + opts?.timeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS, + ); + } + + /** Poll `/sessions/{sid}` until its aggregate work flag reaches `busy`. */ + waitForSessionBusy( + sid: string, + busy: boolean, + opts?: { timeoutMs?: number; pollMs?: number }, + ): Promise<Session> { + return waitForSessionBusy(this.http, sid, busy, opts); + } + + // ── Terminal WS controls ─────────────────────────────────────────────── + attachTerminal( + sid: string, + terminalId: string, + options: TerminalAttachOptions = {}, + ): Promise<TerminalAttachResult> { + return this._sendWsControl<TerminalAttachResult>( + 'terminal_attach', + { + session_id: sid, + terminal_id: terminalId, + since_seq: options.sinceSeq, + }, + options.timeoutMs, + ); + } + + detachTerminal( + sid: string, + terminalId: string, + options: TerminalControlOptions = {}, + ): Promise<TerminalDetachResult> { + return this._sendWsControl<TerminalDetachResult>( + 'terminal_detach', + { session_id: sid, terminal_id: terminalId }, + options.timeoutMs, + ); + } + + writeTerminalInput( + sid: string, + terminalId: string, + data: string, + options: TerminalControlOptions = {}, + ): Promise<TerminalInputResult> { + return this._sendWsControl<TerminalInputResult>( + 'terminal_input', + { session_id: sid, terminal_id: terminalId, data }, + options.timeoutMs, + ); + } + + resizeTerminal( + sid: string, + terminalId: string, + cols: number, + rows: number, + options: TerminalControlOptions = {}, + ): Promise<TerminalResizeResult> { + return this._sendWsControl<TerminalResizeResult>( + 'terminal_resize', + { session_id: sid, terminal_id: terminalId, cols, rows }, + options.timeoutMs, + ); + } + + closeTerminalControl( + sid: string, + terminalId: string, + options: TerminalControlOptions = {}, + ): Promise<TerminalCloseResult> { + return this._sendWsControl<TerminalCloseResult>( + 'terminal_close', + { session_id: sid, terminal_id: terminalId }, + options.timeoutMs, + ); + } + + // ── Reverse RPC (approval + question) ─────────────────────────────────── + /** + * Install a handler invoked on every `event.approval.requested` frame. + * The handler's return value is POSTed to `/sessions/{sid}/approvals/{aid}`. + * Returns an unsubscribe handle (also auto-disposed by `close()`). + */ + onApprovalRequested( + handler: (req: ApprovalRequest) => Promise<ApprovalResponse> | ApprovalResponse, + ): () => void { + const ws = this._requireWs(); + const unsubscribe = installReverseRpcHandler<ApprovalRequest, ApprovalResponse>(ws, { + requestEventType: 'event.approval.requested', + idField: 'approval_id', + buildPath: (sid, aid) => `/sessions/${sid}/approvals/${aid}`, + handler, + postResolve: (sid, aid, body) => this.http.resolveApproval(sid, aid, body), + logger: this._logger, + }); + this._disposers.push(unsubscribe); + return () => { + const idx = this._disposers.indexOf(unsubscribe); + if (idx >= 0) this._disposers.splice(idx, 1); + unsubscribe(); + }; + } + + /** + * Install a handler invoked on every `event.question.requested` frame. + * Returns an unsubscribe handle (also auto-disposed by `close()`). + */ + onQuestionAsked( + handler: (req: QuestionRequest) => Promise<QuestionResponse> | QuestionResponse, + ): () => void { + const ws = this._requireWs(); + const unsubscribe = installReverseRpcHandler<QuestionRequest, QuestionResponse>(ws, { + requestEventType: 'event.question.requested', + idField: 'question_id', + buildPath: (sid, qid) => `/sessions/${sid}/questions/${qid}`, + handler, + postResolve: (sid, qid, body) => this.http.resolveQuestion(sid, qid, body), + logger: this._logger, + }); + this._disposers.push(unsubscribe); + return () => { + const idx = this._disposers.indexOf(unsubscribe); + if (idx >= 0) this._disposers.splice(idx, 1); + unsubscribe(); + }; + } + + // ── High-level convenience ────────────────────────────────────────────── + /** + * Submit a prompt and wait for its terminal event. `waitFor` defaults to + * the synthesized `prompt.completed` event (broadcast after `turn.ended` + * lands for the same prompt). Returns `prompt_id` and the matching frame. + */ + async submitAndWait( + sid: string, + input: PromptSubmitInput, + opts: SubmitAndWaitOptions = {}, + ): Promise<{ prompt_id: string; user_message_id: string; finalFrame: AnyFrame }> { + const ws = this._requireWs(); + const waitFor = opts.waitFor ?? 'prompt.completed'; + const timeoutMs = opts.timeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS; + + // POST the prompt FIRST — without `prompt_id` we have nothing to match on. + // The WS layer queues every frame from the moment we open, so any events + // that arrive between this POST and the `waitForFrame` below are still + // there to be matched (they're drained from the queue, not dropped). + const submit = await this.http.submitPrompt(sid, fillPromptDefaults(input)); + + const finalFrame = await ws.waitForFrame((f) => { + if (f.type !== waitFor) return false; + const payload = (f.payload as { promptId?: string; prompt_id?: string } | undefined) ?? {}; + const pid = payload.promptId ?? payload.prompt_id; + return pid === submit.prompt_id; + }, timeoutMs); + + return { prompt_id: submit.prompt_id, user_message_id: submit.user_message_id, finalFrame }; + } + + /** + * Stateful-session companion to `submitAndWait` — POSTs `body` verbatim + * (NO default controls injected), then waits for the terminal event for + * the resulting `prompt_id`. Use after `updateSession(sid, {agent_config: + * {...}})` to verify the session's shadow drives the next prompt without + * the body needing to redeclare any controls. + */ + async submitAndWaitStateful( + sid: string, + body: PromptSubmission, + opts: SubmitAndWaitOptions = {}, + ): Promise<{ prompt_id: string; user_message_id: string; finalFrame: AnyFrame }> { + const ws = this._requireWs(); + const waitFor = opts.waitFor ?? 'prompt.completed'; + const timeoutMs = opts.timeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS; + const submit = await this.http.submitPrompt(sid, body); + const finalFrame = await ws.waitForFrame((f) => { + if (f.type !== waitFor) return false; + const payload = (f.payload as { promptId?: string; prompt_id?: string } | undefined) ?? {}; + const pid = payload.promptId ?? payload.prompt_id; + return pid === submit.prompt_id; + }, timeoutMs); + return { prompt_id: submit.prompt_id, user_message_id: submit.user_message_id, finalFrame }; + } + + // ── internals ─────────────────────────────────────────────────────────── + private _requireWs(): WsClient { + if (!this._ws) { + throw new Error('ws not connected — call `await client.connect()` first'); + } + return this._ws; + } + + private async _sendWsControl<T>( + type: string, + payload: Record<string, unknown>, + timeoutMs?: number, + ): Promise<T> { + const id = `${type}-${ulid()}`; + const ack = await this._requireWs().sendAndAwaitAck( + { type, id, payload }, + timeoutMs ?? this._controlAckTimeoutMs, + ); + if (ack.code !== 0) { + throw new Error(`${type} rejected (code=${ack.code ?? 'unknown'}): ${ack.msg ?? 'no message'}`); + } + return (ack.payload ?? {}) as T; + } +} + +function noopLogger(): void { + // intentionally blank +} diff --git a/packages/klient/test/e2e/harness/envelope.ts b/packages/klient/test/e2e/harness/envelope.ts new file mode 100644 index 0000000000000000000000000000000000000000..489b01a9599430cffc3732f292cf344c9d13eed0 --- /dev/null +++ b/packages/klient/test/e2e/harness/envelope.ts @@ -0,0 +1,52 @@ +/** + * REST envelope helpers — unwrap `{ code, msg, data, request_id }` into either + * a typed `data` or an `EnvelopeError` thrown by the caller. + * + * Mirrors `packages/kap-server/src/protocol/envelope.ts` so the server's wire + * shape and this client's parsing stay in lockstep. + */ +import { type Envelope } from '@moonshot-ai/kap-server/protocol/envelope'; +import { ErrorCode } from '@moonshot-ai/kap-server/protocol/error-codes'; + +/** + * Thrown when an HTTP call lands but `envelope.code !== 0`. + * + * `data` is preserved separately because several server endpoints return + * non-zero envelopes with a non-null `data` payload (REST §3.6 idempotent + * re-resolve: `code: 40902 + data: { resolved: false }`). + */ +export class EnvelopeError<T = unknown> extends Error { + readonly code: number; + readonly reason: string; + readonly requestId: string; + readonly data: T | null; + + constructor(envelope: Envelope<T>) { + const reason = + Object.entries(ErrorCode) + .find(([, value]) => value === envelope.code)?.[0] + ?.toLowerCase() + .replaceAll('_', '.') ?? 'unknown'; + super(`server returned code=${envelope.code} (${reason}): ${envelope.msg}`); + this.name = 'EnvelopeError'; + this.code = envelope.code; + this.reason = reason; + this.requestId = envelope.request_id; + this.data = envelope.data; + } +} + +/** + * Unwrap a parsed envelope. On `code === 0` returns `data` (which may be + * `null` — callers asking for a non-nullable type should narrow). + */ +export function unwrap<T>(envelope: Envelope<T>): T { + if (envelope.code !== 0) throw new EnvelopeError(envelope); + if (envelope.data === null) { + // `code: 0 + data: null` is reserved for "no body" success envelopes; the + // current server surface always returns a non-null data on success, so + // surface this as a hard error rather than silently returning `null`. + throw new EnvelopeError({ ...envelope, code: 50001, msg: 'success envelope had null data' }); + } + return envelope.data; +} diff --git a/packages/klient/test/e2e/harness/http.ts b/packages/klient/test/e2e/harness/http.ts new file mode 100644 index 0000000000000000000000000000000000000000..37cde367422d068975ff841deab091a2a1bb30ab --- /dev/null +++ b/packages/klient/test/e2e/harness/http.ts @@ -0,0 +1,501 @@ +/** + * HTTP layer for `DaemonClient` — typed wrappers around fetch + envelope + * unwrap. All paths concatenate `baseUrl + apiPrefix + route`. + */ +import type { + FsBrowseResponse, + FsHomeResponse, +} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +import type { AuthSummary } from '@moonshot-ai/agent-core-v2/app/authLegacy/authLegacy'; +import type { FileMeta } from '@moonshot-ai/agent-core-v2/app/file/fileService'; +import type { UpdateSessionProfileRequest as SessionUpdate } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; +import type { + ProviderCatalogItem, + SetDefaultModelResponse, +} from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; +import type { Terminal } from '@moonshot-ai/agent-core-v2/os/interface/terminal'; +import type { ApprovalResponse } from '@moonshot-ai/kap-server/protocol/approval'; +import type { Envelope } from '@moonshot-ai/kap-server/protocol/envelope'; +import type { Message } from '@moonshot-ai/kap-server/protocol/message'; +import type { QuestionResponse } from '@moonshot-ai/kap-server/protocol/question'; +import type { + ApprovalResolveResult, + ListPendingApprovalsResponse, +} from '@moonshot-ai/kap-server/protocol/rest-approval'; +import type { + ListModelsResponse, + ListProvidersResponse, +} from '@moonshot-ai/kap-server/protocol/rest-modelCatalog'; +import type { + PromptAbortResponse, + PromptListResponse, + PromptSubmission, + PromptSteerResult, + PromptSubmitResult, +} from '@moonshot-ai/kap-server/protocol/rest-prompt'; +import type { + ListPendingQuestionsResponse, + QuestionResolveResult, +} from '@moonshot-ai/kap-server/protocol/rest-question'; +import type { + CompactSessionRequest, + CompactSessionResponse, + ForkSessionRequest, + SessionAbortResponse, + UndoSessionRequest, + UndoSessionResponse, +} from '@moonshot-ai/kap-server/protocol/rest-session'; +import type { + CloseTerminalResponse, + CreateTerminalRequest, + ListTerminalsResponse, +} from '@moonshot-ai/kap-server/protocol/rest-terminal'; +import type { + Session, + SessionChildCreate, + SessionCreate, +} from '@moonshot-ai/kap-server/protocol/session'; +import type { + Workspace, + WorkspaceCreate, + WorkspaceUpdate, +} from '@moonshot-ai/kap-server/protocol/workspace'; + +import { unwrap } from './envelope.js'; +import { fetchWithReport, recordReportEvent } from './report.js'; + +export interface HttpClientOptions { + baseUrl: string; + apiPrefix: string; + fetchImpl: typeof fetch; + reportDir?: string; + /** Optional bearer token — sent as `Authorization: Bearer <token>` when set. */ + token?: string; +} + +type UploadFileData = Blob | ArrayBuffer | Uint8Array | string; + +export class HttpClient { + constructor(private readonly opts: HttpClientOptions) {} + + private url(path: string): string { + return `${this.opts.baseUrl}${this.opts.apiPrefix}${path}`; + } + + private async request<T>( + method: string, + path: string, + body: unknown, + ): Promise<T> { + const startedAt = Date.now(); + const headers: Record<string, string> = { accept: 'application/json' }; + if (this.opts.token !== undefined) { + headers['authorization'] = `Bearer ${this.opts.token}`; + } + let init: RequestInit; + if (body !== undefined) { + headers['content-type'] = 'application/json'; + init = { method, headers, body: JSON.stringify(body) }; + } else { + init = { method, headers }; + } + const url = this.url(path); + let res: Response; + let text = ''; + try { + res = await this.opts.fetchImpl(url, init); + text = await res.text(); + } catch (error) { + recordReportEvent( + { + kind: 'http', + method, + path, + url, + durationMs: Date.now() - startedAt, + request: requestForReport(body), + error: errorForReport(error), + }, + { reportDir: this.opts.reportDir }, + ); + throw error; + } + let envelope: Envelope<T>; + try { + envelope = JSON.parse(text) as Envelope<T>; + } catch (error) { + recordReportEvent( + { + kind: 'http', + method, + path, + url, + status: res.status, + durationMs: Date.now() - startedAt, + request: requestForReport(body), + response: { raw: text.slice(0, 2_000) }, + error: errorForReport(error), + }, + { reportDir: this.opts.reportDir }, + ); + throw new Error( + `server ${method} ${path} returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, + { cause: error }, + ); + } + recordReportEvent( + { + kind: 'http', + method, + path, + url, + status: res.status, + durationMs: Date.now() - startedAt, + request: requestForReport(body), + response: { envelope }, + }, + { reportDir: this.opts.reportDir }, + ); + return unwrap(envelope); + } + + private async formRequest<T>( + method: 'POST', + path: string, + body: FormData, + ): Promise<T> { + const url = this.url(path); + const res = await fetchWithReport( + url, + { + method, + headers: { accept: 'application/json' }, + body, + }, + { + fetchImpl: this.opts.fetchImpl, + reportDir: this.opts.reportDir, + path, + }, + ); + const text = await res.text(); + let envelope: Envelope<T>; + try { + envelope = JSON.parse(text) as Envelope<T>; + } catch (error) { + throw new Error( + `server ${method} ${path} returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, + { cause: error }, + ); + } + return unwrap(envelope); + } + + // ── Probes + model catalog ───────────────────────────────────────────── + getAuth(): Promise<AuthSummary> { + return this.request<AuthSummary>('GET', '/auth', undefined); + } + listModels(): Promise<ListModelsResponse> { + return this.request('GET', '/models', undefined); + } + setDefaultModel(modelId: string): Promise<SetDefaultModelResponse> { + return this.request( + 'POST', + `/models/${encodeURIComponent(modelId)}:set_default`, + {}, + ); + } + listProviders(): Promise<ListProvidersResponse> { + return this.request('GET', '/providers', undefined); + } + getProvider(providerId: string): Promise<ProviderCatalogItem> { + return this.request( + 'GET', + `/providers/${encodeURIComponent(providerId)}`, + undefined, + ); + } + + // ── Sessions ──────────────────────────────────────────────────────────── + createSession(body: SessionCreate): Promise<Session> { + return this.request<Session>('POST', '/sessions', body); + } + getSession(sid: string): Promise<Session> { + return this.request<Session>('GET', `/sessions/${encodeURIComponent(sid)}`, undefined); + } + listSessions(query?: { + page_size?: number; + before_id?: string; + after_id?: string; + workspace_id?: string; + }): Promise<{ items: Session[]; has_more: boolean }> { + return this.request('GET', `/sessions${qs(query)}`, undefined); + } + updateSession(sid: string, body: SessionUpdate): Promise<Session> { + // Daemon canonical route: `POST /v1/sessions/{sid}/profile` (REST.md §3.3). + // Earlier scaffolding spoke `PATCH /v1/sessions/{sid}`, which the server + // never wired — keep the helper name (used by existing fixtures) and just + // dispatch to the right URL. + return this.request<Session>( + 'POST', + `/sessions/${encodeURIComponent(sid)}/profile`, + body, + ); + } + forkSession(sid: string, body: ForkSessionRequest = {}): Promise<Session> { + return this.request('POST', `/sessions/${encodeURIComponent(sid)}:fork`, body); + } + compactSession( + sid: string, + body: CompactSessionRequest = {}, + ): Promise<CompactSessionResponse> { + return this.request( + 'POST', + `/sessions/${encodeURIComponent(sid)}:compact`, + body, + ); + } + undoSession( + sid: string, + body: UndoSessionRequest = { count: 1 }, + ): Promise<UndoSessionResponse> { + return this.request( + 'POST', + `/sessions/${encodeURIComponent(sid)}:undo`, + body, + ); + } + archiveSession(sid: string): Promise<{ archived: true }> { + return this.request('POST', `/sessions/${encodeURIComponent(sid)}:archive`, {}); + } + listChildren( + sid: string, + query?: { page_size?: number; before_id?: string; after_id?: string; busy?: boolean }, + ): Promise<{ items: Session[]; has_more: boolean }> { + return this.request( + 'GET', + `/sessions/${encodeURIComponent(sid)}/children${qs(query)}`, + undefined, + ); + } + createChild(sid: string, body: SessionChildCreate = {}): Promise<Session> { + return this.request( + 'POST', + `/sessions/${encodeURIComponent(sid)}/children`, + body, + ); + } + + // ── Terminals ────────────────────────────────────────────────────────── + listTerminals(sid: string): Promise<ListTerminalsResponse> { + return this.request( + 'GET', + `/sessions/${encodeURIComponent(sid)}/terminals`, + undefined, + ); + } + createTerminal( + sid: string, + body: CreateTerminalRequest = {}, + ): Promise<Terminal> { + return this.request( + 'POST', + `/sessions/${encodeURIComponent(sid)}/terminals`, + body, + ); + } + getTerminal(sid: string, terminalId: string): Promise<Terminal> { + return this.request( + 'GET', + `/sessions/${encodeURIComponent(sid)}/terminals/${encodeURIComponent(terminalId)}`, + undefined, + ); + } + closeTerminal( + sid: string, + terminalId: string, + ): Promise<CloseTerminalResponse> { + return this.request( + 'POST', + `/sessions/${encodeURIComponent(sid)}/terminals/${encodeURIComponent(terminalId)}:close`, + {}, + ); + } + + // ── Workspaces ────────────────────────────────────────────────────────── + listWorkspaces(): Promise<{ items: Workspace[] }> { + return this.request('GET', '/workspaces', undefined); + } + createWorkspace(body: WorkspaceCreate): Promise<Workspace> { + return this.request<Workspace>('POST', '/workspaces', body); + } + updateWorkspace(workspaceId: string, body: WorkspaceUpdate): Promise<Workspace> { + return this.request<Workspace>( + 'PATCH', + `/workspaces/${encodeURIComponent(workspaceId)}`, + body, + ); + } + deleteWorkspace(workspaceId: string): Promise<{ deleted: true }> { + return this.request( + 'DELETE', + `/workspaces/${encodeURIComponent(workspaceId)}`, + undefined, + ); + } + + // ── Folder picker (fs:browse + fs:home) ───────────────────────────────── + fsBrowse(path?: string): Promise<FsBrowseResponse> { + return this.request('GET', `/fs:browse${qs({ path })}`, undefined); + } + fsHome(): Promise<FsHomeResponse> { + return this.request('GET', '/fs:home', undefined); + } + + // ── Uploads ───────────────────────────────────────────────────────────── + uploadFile(input: { + name: string; + data: UploadFileData; + mediaType?: string; + expiresInSec?: number; + }): Promise<FileMeta> { + const form = new FormData(); + form.append('name', input.name); + if (input.expiresInSec !== undefined) { + form.append('expires_in_sec', String(input.expiresInSec)); + } + form.append('file', blobFromInput(input), input.name); + return this.formRequest<FileMeta>('POST', '/files', form); + } + deleteFile(fileId: string): Promise<{ deleted: true }> { + return this.request('DELETE', `/files/${encodeURIComponent(fileId)}`, undefined); + } + + // ── Messages ──────────────────────────────────────────────────────────── + listMessages( + sid: string, + query?: { page_size?: number; before_id?: string; after_id?: string; role?: string }, + ): Promise<{ items: Message[]; has_more: boolean }> { + return this.request('GET', `/sessions/${encodeURIComponent(sid)}/messages${qs(query)}`, undefined); + } + + // ── Prompts ───────────────────────────────────────────────────────────── + listPrompts(sid: string): Promise<PromptListResponse> { + return this.request('GET', `/sessions/${encodeURIComponent(sid)}/prompts`, undefined); + } + submitPrompt(sid: string, body: PromptSubmission): Promise<PromptSubmitResult> { + return this.request('POST', `/sessions/${encodeURIComponent(sid)}/prompts`, body); + } + steerPrompt(sid: string, pid: string): Promise<PromptSteerResult> { + return this.request( + 'POST', + `/sessions/${encodeURIComponent(sid)}/prompts/${encodeURIComponent(pid)}:steer`, + {}, + ); + } + steerPrompts(sid: string, promptIds: readonly string[]): Promise<PromptSteerResult> { + return this.request( + 'POST', + `/sessions/${encodeURIComponent(sid)}/prompts:steer`, + { prompt_ids: [...promptIds] }, + ); + } + abortPrompt(sid: string, pid: string): Promise<PromptAbortResponse> { + return this.request( + 'POST', + `/sessions/${encodeURIComponent(sid)}/prompts/${encodeURIComponent(pid)}:abort`, + {}, + ); + } + abortSession(sid: string): Promise<SessionAbortResponse> { + return this.request('POST', `/sessions/${encodeURIComponent(sid)}:abort`, {}); + } + + // ── Approvals / Questions (reverse-RPC resolves) ──────────────────────── + resolveApproval( + sid: string, + aid: string, + body: ApprovalResponse, + ): Promise<ApprovalResolveResult> { + return this.request( + 'POST', + `/sessions/${encodeURIComponent(sid)}/approvals/${encodeURIComponent(aid)}`, + body, + ); + } + listPendingApprovals(sid: string): Promise<ListPendingApprovalsResponse> { + return this.request( + 'GET', + `/sessions/${encodeURIComponent(sid)}/approvals?status=pending`, + undefined, + ); + } + resolveQuestion( + sid: string, + qid: string, + body: QuestionResponse, + ): Promise<QuestionResolveResult> { + return this.request( + 'POST', + `/sessions/${encodeURIComponent(sid)}/questions/${encodeURIComponent(qid)}`, + body, + ); + } + listPendingQuestions(sid: string): Promise<ListPendingQuestionsResponse> { + return this.request( + 'GET', + `/sessions/${encodeURIComponent(sid)}/questions?status=pending`, + undefined, + ); + } + dismissQuestion( + sid: string, + qid: string, + ): Promise<{ dismissed: true; dismissed_at: string }> { + return this.request( + 'POST', + `/sessions/${encodeURIComponent(sid)}/questions/${encodeURIComponent(qid)}:dismiss`, + {}, + ); + } +} + +function requestForReport(body: unknown): { body?: unknown } { + return body === undefined ? {} : { body }; +} + +function errorForReport(error: unknown): unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + }; + } + return error; +} + +function qs(query: Record<string, string | number | boolean | undefined> | undefined): string { + if (!query) return ''; + const parts: string[] = []; + for (const [k, v] of Object.entries(query)) { + if (v === undefined) continue; + parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(serializedQueryValue(v))}`); + } + return parts.length > 0 ? `?${parts.join('&')}` : ''; +} + +function serializedQueryValue(value: string | number | boolean): string { + if (typeof value === 'string') return value; + if (typeof value === 'number') return value.toString(); + return value ? 'true' : 'false'; +} + +function blobFromInput(input: { + data: UploadFileData; + mediaType?: string; +}): Blob { + if (input.data instanceof Blob) return input.data; + return new Blob([input.data], { + type: input.mediaType ?? 'application/octet-stream', + }); +} diff --git a/packages/klient/test/e2e/harness/index.ts b/packages/klient/test/e2e/harness/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9a9094033cbf5d1ffba2b7ea4ba124974d6e8c0 --- /dev/null +++ b/packages/klient/test/e2e/harness/index.ts @@ -0,0 +1,70 @@ +/** + * `@moonshot-ai/server-e2e` — wire-level test client for the kimi-code server. + * + * Use this package from scenarios (`scenarios/*.ts`) and vitest e2e tests + * to drive a real server process at `http://127.0.0.1:58627` (or any baseUrl + * you pass via `DaemonClientOptions.baseUrl`). + * + * Public surface: + * - `DaemonClient` — main facade (HTTP + WS lifecycle) + * - `HttpClient` — REST helpers only (typed, envelope-unwrap) + * - `WsClient` — raw WS wrapper (queue + waiters + acks) + * - `EnvelopeError` — thrown on `code !== 0` + * - `fetchWithReport` / `writeHtmlReport` — report capture + rendering + * - `installReverseRpcHandler` — uniform helper for approval/question + * - `waitForFrame` / `waitForSessionBusy` — standalone wait helpers + * + * Wire DTO types are NOT re-exported here — scenarios import them from + * `@moonshot-ai/kap-server/protocol/*` or agent-core-v2 directly. + */ +export { DaemonClient } from './client.js'; +export type { + DaemonClientOptions, + SubmitAndWaitOptions, + TerminalAttachOptions, + TerminalAttachResult, + TerminalCloseResult, + TerminalControlOptions, + TerminalDetachResult, + TerminalInputResult, + TerminalResizeResult, +} from './client.js'; + +export { HttpClient } from './http.js'; +export type { HttpClientOptions } from './http.js'; + +export { WsClient } from './ws.js'; +export type { AnyFrame, WsClientOptions } from './ws.js'; + +export { EnvelopeError, unwrap } from './envelope.js'; + +export { + defaultReportDir, + fetchWithReport, + getActiveReportCase, + readReportEvents, + recordReportEvent, + resetReportDir, + setActiveReportCase, + writeHtmlReport, +} from './report.js'; +export type { + FetchWithReportOptions, + HtmlReportOptions, + HttpReportEvent, + LogReportEvent, + ReportEvent, + ReportEventBase, + ReportEventKind, + ReportOptions, + StoredReportEvent, + TestResultReportEvent, + WsDirection, + WsReportEvent, +} from './report.js'; + +export { installReverseRpcHandler } from './reverse-rpc.js'; +export type { ReverseRpcOptions } from './reverse-rpc.js'; + +export { DEFAULT_FRAME_TIMEOUT_MS, waitForFrame, waitForSessionBusy } from './wait.js'; + diff --git a/packages/klient/test/e2e/harness/report.ts b/packages/klient/test/e2e/harness/report.ts new file mode 100644 index 0000000000000000000000000000000000000000..c522e2b964accad2d47dea2a3345ddeeea5a3f0f --- /dev/null +++ b/packages/klient/test/e2e/harness/report.ts @@ -0,0 +1,662 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { join, resolve } from 'node:path'; + +export type ReportEventKind = 'log' | 'http' | 'ws' | 'test-result'; +export type WsDirection = 'in' | 'out' | 'lifecycle'; + +export interface ReportEventBase { + kind: ReportEventKind; + caseName?: string; + at?: string; +} + +export interface LogReportEvent extends ReportEventBase { + kind: 'log'; + label: string; + value?: unknown; +} + +export interface HttpReportEvent extends ReportEventBase { + kind: 'http'; + method: string; + path: string; + url?: string; + status?: number; + durationMs?: number; + request?: unknown; + response?: unknown; + error?: unknown; +} + +export interface WsReportEvent extends ReportEventBase { + kind: 'ws'; + direction: WsDirection; + url?: string; + frame?: unknown; + message?: string; + error?: unknown; +} + +export interface TestResultReportEvent extends ReportEventBase { + kind: 'test-result'; + state: 'passed' | 'failed' | 'skipped'; + durationMs?: number; + error?: unknown; +} + +export type ReportEvent = + | LogReportEvent + | HttpReportEvent + | WsReportEvent + | TestResultReportEvent; + +export interface StoredReportEvent extends ReportEventBase { + kind: ReportEventKind; + pid: number; + ordinal: number; + label?: string; + value?: unknown; + method?: string; + path?: string; + url?: string; + status?: number; + durationMs?: number; + request?: unknown; + response?: unknown; + direction?: WsDirection; + frame?: unknown; + message?: string; + state?: 'passed' | 'failed' | 'skipped'; + error?: unknown; +} + +export interface ReportOptions { + reportDir?: string; +} + +export interface HtmlReportOptions extends ReportOptions { + title?: string; +} + +export interface FetchWithReportOptions extends ReportOptions { + fetchImpl?: typeof fetch; + path?: string; +} + +let activeCaseName: string | undefined; +const activeCaseStorage = new AsyncLocalStorage<string>(); +let ordinal = 0; + +export function setActiveReportCase(caseName: string): void { + activeCaseName = caseName; + activeCaseStorage.enterWith(caseName); +} + +export function getActiveReportCase(): string | undefined { + return activeCaseStorage.getStore() ?? activeCaseName; +} + +export function resetReportDir(reportDir = defaultReportDir()): void { + rmSync(reportDir, { recursive: true, force: true }); + mkdirSync(reportDir, { recursive: true }); + writeFileSync(join(reportDir, '.gitignore'), '*\n!.gitignore\n'); +} + +export function recordReportEvent(event: ReportEvent, options?: ReportOptions): void { + const reportDir = options?.reportDir ?? defaultReportDir(); + mkdirSync(reportDir, { recursive: true }); + const stored = normalizeEvent(event); + appendFileSync(reportEventsPath(reportDir), `${JSON.stringify(stored)}\n`); +} + +export function readReportEvents(reportDir = defaultReportDir()): StoredReportEvent[] { + if (!existsSync(reportDir)) return []; + const files = readdirSync(reportDir) + .filter((file) => file.startsWith('events-') && file.endsWith('.jsonl')) + .toSorted(); + const events: StoredReportEvent[] = []; + for (const file of files) { + const text = readFileSync(join(reportDir, file), 'utf8'); + for (const line of text.split('\n')) { + if (line.trim().length === 0) continue; + events.push(JSON.parse(line) as StoredReportEvent); + } + } + return events.toSorted((a, b) => { + const byTime = Date.parse(a.at ?? '') - Date.parse(b.at ?? ''); + if (byTime !== 0) return byTime; + if (a.pid !== b.pid) return a.pid - b.pid; + return a.ordinal - b.ordinal; + }); +} + +export function writeHtmlReport(options?: HtmlReportOptions): string { + const reportDir = options?.reportDir ?? defaultReportDir(); + mkdirSync(reportDir, { recursive: true }); + const title = options?.title ?? 'server-e2e report'; + const events = readReportEvents(reportDir); + const htmlPath = join(reportDir, 'index.html'); + writeFileSync(htmlPath, renderHtml(title, events)); + return htmlPath; +} + +export async function fetchWithReport( + input: Parameters<typeof fetch>[0], + init?: Parameters<typeof fetch>[1], + options?: FetchWithReportOptions, +): Promise<Response> { + const fetchImpl = options?.fetchImpl ?? fetch; + const method = fetchMethod(input, init); + const url = fetchUrl(input); + const path = options?.path ?? pathFromUrl(url); + const startedAt = Date.now(); + let response: Response; + try { + response = await fetchImpl(input, init); + } catch (error) { + recordReportEvent( + { + kind: 'http', + method, + path, + url, + durationMs: Date.now() - startedAt, + request: requestForFetchReport(input, init), + error: errorForReport(error), + }, + { reportDir: options?.reportDir }, + ); + throw error; + } + + const text = await response.clone().text(); + recordReportEvent( + { + kind: 'http', + method, + path, + url, + status: response.status, + durationMs: Date.now() - startedAt, + request: requestForFetchReport(input, init), + response: responseForReport(text), + }, + { reportDir: options?.reportDir }, + ); + return response; +} + +export function defaultReportDir(): string { + return resolve(process.env['KIMI_SERVER_E2E_REPORT_DIR'] ?? join(process.cwd(), 'reports', 'latest')); +} + +function normalizeEvent(event: ReportEvent): StoredReportEvent { + const stored = event as StoredReportEvent; + return { + ...stored, + at: event.at ?? new Date().toISOString(), + caseName: event.caseName ?? getActiveReportCase() ?? process.env['KIMI_SERVER_E2E_CASE_NAME'] ?? 'unassigned', + pid: process.pid, + ordinal: ordinal++, + }; +} + +function reportEventsPath(reportDir: string): string { + return join(reportDir, `events-${process.pid}.jsonl`); +} + +function renderHtml(title: string, events: StoredReportEvent[]): string { + const cases = renderCases(groupByCase(events)); + return `<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>${escapeHtml(title)} + + + +
+ +
+
+
+ Step + Client -> Server + + Server -> Client +
+
+ ${cases.map(renderCase).join('\n')} +
+
+ +
+
+ + + +`; +} + +interface RenderCase { + id: string; + name: string; + events: RenderEvent[]; +} + +interface RenderEvent { + id: string; + caseId: string; + stepIndex: number; + event: StoredReportEvent; +} + +function groupByCase(events: StoredReportEvent[]): Map { + const cases = new Map(); + for (const event of events) { + const caseName = event.caseName ?? 'unassigned'; + const group = cases.get(caseName); + if (group) { + group.push(event); + } else { + cases.set(caseName, [event]); + } + } + return cases; +} + +function renderCases(cases: Map): RenderCase[] { + let eventIndex = 0; + return [...cases.entries()].map(([name, events], caseIndex) => { + const id = `case-${caseIndex}`; + return { + id, + name, + events: events.map((event, stepIndex) => ({ + id: `event-${eventIndex++}`, + caseId: id, + stepIndex: stepIndex + 1, + event, + })), + }; + }); +} + +function renderCaseLink(testCase: RenderCase): string { + return ` + ${escapeHtml(testCase.name)} + ${testCase.events.length} +`; +} + +function renderCase(testCase: RenderCase): string { + return `
+

${escapeHtml(testCase.name)}

+
    + ${testCase.events.map(renderEvent).join('\n')} +
+
`; +} + +function renderEvent(rendered: RenderEvent): string { + const left = eventLaneContent(rendered.event, 'left'); + const right = eventLaneContent(rendered.event, 'right'); + const center = lifecycleLaneContent(rendered.event); + const rowClass = rendered.event.kind === 'ws' && rendered.event.direction === 'lifecycle' + ? `${rendered.event.kind} lifecycle` + : rendered.event.kind; + return `
  • +
    #${rendered.stepIndex}
    +
    ${left}
    +
    +
    ${right}
    + ${center} +
  • `; +} + +function renderDetailCase(testCase: RenderCase): string { + return `
    +

    ${escapeHtml(testCase.name)}

    + ${testCase.events.map(renderDetailCard).join('\n')} +
    `; +} + +function renderDetailCard(rendered: RenderEvent): string { + const detail = eventDetail(rendered.event); + return `
    +
    + ${escapeHtml(rendered.event.kind)} + ${escapeHtml(eventSummary(rendered.event))} +
    +
    ${escapeHtml(JSON.stringify(detail, null, 2))}
    +
    `; +} + +function eventLaneContent(event: StoredReportEvent, lane: 'left' | 'right'): string { + const content = lane === 'left' ? leftLaneSummary(event) : rightLaneSummary(event); + if (!content) return ''; + return ``; +} + +function lifecycleLaneContent(event: StoredReportEvent): string { + if (event.kind !== 'ws' || event.direction !== 'lifecycle') return ''; + return ``; +} + +function leftLaneSummary(event: StoredReportEvent): { title: string } | undefined { + if (event.kind === 'http') { + return { + title: `${event.method ?? 'HTTP'} ${event.path ?? event.url ?? ''}`.trim(), + }; + } + if (event.kind === 'ws' && event.direction === 'out') { + return { title: eventSummary(event) }; + } + if (event.kind === 'log') { + return { title: event.label ?? 'log' }; + } + return undefined; +} + +function rightLaneSummary(event: StoredReportEvent): { title: string } | undefined { + if (event.kind === 'http') { + return { + title: event.status === undefined ? 'HTTP response' : `HTTP ${event.status}`, + }; + } + if (event.kind === 'ws' && event.direction === 'in') { + return { title: eventSummary(event) }; + } + if (event.kind === 'test-result') { + return { title: eventSummary(event) }; + } + return undefined; +} + +function eventSummary(event: StoredReportEvent): string { + if (event.kind === 'http') { + return `${event.method ?? 'HTTP'} ${event.path ?? event.url ?? ''}`.trim(); + } + if (event.kind === 'ws') { + const label = frameType(event.frame) ?? event.message ?? 'frame'; + if (event.direction === 'lifecycle') return `WS ${label}`; + const arrow = event.direction === 'out' ? '->' : '<-'; + return `WS ${arrow} ${label}`; + } + if (event.kind === 'test-result') { + return `test ${event.state ?? 'unknown'}`; + } + return event.label ?? 'log'; +} + +function eventDetail(event: StoredReportEvent): Record { + const { pid: _pid, ordinal: _ordinal, ...detail } = event; + return detail; +} + +function frameType(frame: unknown): string | undefined { + if (!frame || typeof frame !== 'object') return undefined; + const value = (frame as { type?: unknown }).type; + return typeof value === 'string' ? value : undefined; +} + +function escapeHtml(value: unknown): string { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function fetchMethod(input: Parameters[0], init: Parameters[1]): string { + if (init?.method) return init.method.toUpperCase(); + if (input instanceof Request) return input.method.toUpperCase(); + return 'GET'; +} + +function fetchUrl(input: Parameters[0]): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +function pathFromUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.pathname}${parsed.search}`; + } catch { + return url; + } +} + +function requestForFetchReport( + input: Parameters[0], + init: Parameters[1], +): { body?: unknown } { + if (init?.body !== undefined && init.body !== null) { + return { body: parseBodyForReport(init.body) }; + } + if (input instanceof Request) return {}; + return {}; +} + +function parseBodyForReport(body: NonNullable): unknown { + if (typeof body === 'string') { + try { + return JSON.parse(body) as unknown; + } catch { + return body; + } + } + if (body instanceof URLSearchParams) { + return body.toString(); + } + if (body instanceof FormData) { + return '[FormData]'; + } + if (body instanceof Blob) { + return `[Blob ${body.type || 'application/octet-stream'} ${body.size} bytes]`; + } + if (body instanceof ArrayBuffer) { + return `[ArrayBuffer ${body.byteLength} bytes]`; + } + if (ArrayBuffer.isView(body)) { + return `[${body.constructor.name} ${body.byteLength} bytes]`; + } + return '[ReadableStream]'; +} + +function responseForReport(text: string): { envelope?: unknown; raw?: string } { + try { + return { envelope: JSON.parse(text) as unknown }; + } catch { + return { raw: text.slice(0, 2_000) }; + } +} + +function errorForReport(error: unknown): unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + }; + } + return error; +} diff --git a/packages/klient/test/e2e/harness/reverse-rpc.ts b/packages/klient/test/e2e/harness/reverse-rpc.ts new file mode 100644 index 0000000000000000000000000000000000000000..25779323ab301918a911a1f596d610878fdd9a7f --- /dev/null +++ b/packages/klient/test/e2e/harness/reverse-rpc.ts @@ -0,0 +1,66 @@ +/** + * Reverse-RPC handler installer — uniform pattern shared by approval and + * question. The two flows are structurally identical: + * + * 1. WS broadcasts `event.{kind}.requested` with the request payload at + * the top level of `envelope.payload`. + * 2. Test installs `onXxxRequested(handler)`. + * 3. On each request frame, we call `handler(request)` → POST the + * decision/answer to `/sessions/{sid}/{kind}s/{id}`. + * + * Errors from the user handler (or the REST POST) are swallowed into a + * logger.warn — failing reverse-RPC silently means the server will time out + * the approval/question after 60s, which the scenario will observe as a + * timeout in `waitForFrame`. Surfacing those errors here would break the + * "framework auto-responds" contract. + */ +import type { AnyFrame, WsClient } from './ws.js'; + +export interface ReverseRpcOptions { + requestEventType: string; + idField: keyof Req & string; + /** REST path under the server API prefix. */ + buildPath: (sessionId: string, id: string) => string; + handler: (req: Req) => Promise | Res; + /** POST helper bound to the right path. */ + postResolve: (sessionId: string, id: string, body: Res) => Promise; + logger: (level: 'info' | 'warn' | 'error' | 'debug', msg: string, meta?: unknown) => void; +} + +/** + * Subscribe to `requestEventType` frames on `ws` and POST the user-supplied + * response. Returns an unsubscribe handle. + */ +export function installReverseRpcHandler( + ws: WsClient, + opts: ReverseRpcOptions, +): () => void { + const unsubscribe = ws.onFrame((frame: AnyFrame) => { + if (frame.type !== opts.requestEventType) return; + const payload = frame.payload as Req | undefined; + if (!payload) return; + const sessionId = (payload as { session_id?: string }).session_id; + const id = (payload as Record)[opts.idField] as string | undefined; + if (!sessionId || !id) { + opts.logger('warn', `reverse-rpc: ${opts.requestEventType} missing session_id/${opts.idField}`, { + payload, + }); + return; + } + // Fire-and-forget: the WS handler is sync; we kick off the resolve and + // log async failures. + Promise.resolve() + .then(async () => { + const response = await opts.handler(payload); + await opts.postResolve(sessionId, id, response); + }) + .catch((err) => { + opts.logger('warn', `reverse-rpc: ${opts.requestEventType} resolve failed`, { + err: String(err), + sessionId, + id, + }); + }); + }); + return unsubscribe; +} diff --git a/packages/klient/test/e2e/harness/wait.ts b/packages/klient/test/e2e/harness/wait.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a8ac89aa40f7db391a90d6e803a96ffb2f9e6b0 --- /dev/null +++ b/packages/klient/test/e2e/harness/wait.ts @@ -0,0 +1,56 @@ +/** + * Higher-level wait helpers built on top of `WsClient.waitForFrame` and + * `HttpClient.getSession`. Kept separate from `client.ts` so scenarios can + * import them directly without dragging the whole `DaemonClient` class. + */ +import type { Session } from '@moonshot-ai/kap-server/protocol/session'; + +import type { HttpClient } from './http.js'; +import type { AnyFrame, WsClient } from './ws.js'; + +/** Default 60s wait for a single event frame — matches approval/question TTL. */ +export const DEFAULT_FRAME_TIMEOUT_MS = 60_000; + +/** + * Wait for the first WS frame matching `predicate`. Thin wrapper that fills + * the default timeout — most scenarios shouldn't have to think about it. + */ +export function waitForFrame( + ws: WsClient, + predicate: (frame: AnyFrame) => boolean, + opts?: { timeoutMs?: number }, +): Promise { + return ws.waitForFrame(predicate, opts?.timeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS); +} + +/** + * Poll `GET /sessions/{sid}` until aggregate `busy` matches. Useful as a + * final synchronization point — the server's `turn.ended` arrives before + * the session work projection flips to false, so scenarios that want a + * quiescent session must poll. + */ +export async function waitForSessionBusy( + http: HttpClient, + sid: string, + busy: boolean, + opts?: { timeoutMs?: number; pollMs?: number }, +): Promise { + const timeoutMs = opts?.timeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS; + const pollMs = opts?.pollMs ?? 250; + const deadline = Date.now() + timeoutMs; + let last: Session | null = null; + while (Date.now() < deadline) { + const session = await http.getSession(sid); + last = session; + if (session.busy === busy) return session; + await sleep(pollMs); + } + throw new Error( + `session ${sid} did not reach busy=${busy} within ${timeoutMs}ms ` + + `(last busy=${last?.busy ?? 'unknown'})`, + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/klient/test/e2e/harness/ws.ts b/packages/klient/test/e2e/harness/ws.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5538b07c7aa22d4189e668b708bdd9027d7f4d7 --- /dev/null +++ b/packages/klient/test/e2e/harness/ws.ts @@ -0,0 +1,275 @@ +/** + * WS layer for `DaemonClient` — owns the socket, queues incoming frames so + * fast tests don't race the first `server_hello`, exposes a `waitForFrame` + * with timeouts, and routes control-message acks back to the original sender + * via `id` correlation. + * + * Frame shape is the union of WS.md §2 envelopes: + * - `event` envelope : `{type, seq, session_id, timestamp, payload}` + * - `ack` : `{type:'ack', id, code, msg, payload}` + * - `server_hello`/`ping`/`resync_required`/`error`: each carries `timestamp` + * + * We don't Zod-validate frames here — preserving forward-compat ("unknown + * fields pass through") and avoiding double-work since the server emits the + * shapes already. + */ +import { WebSocket as WsWebSocket } from 'ws'; + +import { recordReportEvent } from './report.js'; + +/** Wire frame shape — kept loose because the server adds new event types. */ +export interface AnyFrame { + readonly type: string; + readonly seq?: number; + readonly session_id?: string; + readonly timestamp?: string; + readonly id?: string; + readonly code?: number; + readonly msg?: string; + readonly payload?: unknown; +} + +export interface WsClientOptions { + url: string; + wsImpl: typeof WsWebSocket; + logger: (level: 'info' | 'warn' | 'error' | 'debug', msg: string, meta?: unknown) => void; + reportDir?: string; +} + +type FrameWaiter = (frame: AnyFrame) => boolean; + +interface PendingWaiter { + match: FrameWaiter; + resolve: (frame: AnyFrame) => void; + reject: (err: Error) => void; + timer?: NodeJS.Timeout; +} + +/** + * Thin WS wrapper. Two-tier delivery: + * - All frames also fan out to subscribers added with `onFrame()`. + * - `waitForFrame(predicate)` consumes the *first matching* frame; matching + * frames already in `_queue` are dispatched immediately. + * + * Both queue and waiters are needed because the server's first `server_hello` + * can land in the same tick as `open`, before the test has a chance to + * register its first waiter (see `server/test/ws-handshake.e2e.test.ts:88-117` + * for the pattern this is ported from). + */ +export class WsClient { + private ws: WsWebSocket | null = null; + private readonly _queue: AnyFrame[] = []; + private readonly _waiters: PendingWaiter[] = []; + private readonly _subscribers = new Set<(f: AnyFrame) => void>(); + private _closed = false; + private _closeReason: { code: number; reason: string } | null = null; + private _closeWaiters: Array<(v: { code: number; reason: string }) => void> = []; + + constructor(private readonly opts: WsClientOptions) {} + + /** Open the socket; resolves once `open` fires. */ + async open(): Promise { + if (this.ws) return; + await new Promise((resolve, reject) => { + const ws = new this.opts.wsImpl(this.opts.url); + this.ws = ws; + ws.once('open', () => { + recordReportEvent( + { kind: 'ws', direction: 'lifecycle', url: this.opts.url, message: 'open' }, + { reportDir: this.opts.reportDir }, + ); + resolve(); + }); + ws.once('error', (err) => { + if (this._closed) return; + recordReportEvent( + { + kind: 'ws', + direction: 'lifecycle', + url: this.opts.url, + message: 'error', + error: errorForReport(err), + }, + { reportDir: this.opts.reportDir }, + ); + reject(err as Error); + }); + ws.on('message', (data) => this._onMessage(data)); + ws.on('close', (code, reason) => this._onClose(code, String(reason ?? ''))); + }); + } + + /** JSON-stringifies and sends a frame. */ + send(frame: object): void { + if (!this.ws) throw new Error('ws not open'); + this.ws.send(JSON.stringify(frame)); + recordReportEvent( + { kind: 'ws', direction: 'out', url: this.opts.url, frame }, + { reportDir: this.opts.reportDir }, + ); + } + + /** Register a frame subscriber. Returns an unsubscribe handle. */ + onFrame(handler: (f: AnyFrame) => void): () => void { + this._subscribers.add(handler); + return () => { + this._subscribers.delete(handler); + }; + } + + /** + * Wait for the next frame matching `predicate`. Drains queued frames first; + * the first matching frame is consumed and returned. Times out cleanly. + */ + waitForFrame(predicate: FrameWaiter, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + // Drain queue. + for (let i = 0; i < this._queue.length; i++) { + const frame = this._queue[i]; + if (frame === undefined) continue; + if (predicate(frame)) { + this._queue.splice(i, 1); + resolve(frame); + return; + } + } + if (this._closed) { + reject(new Error(`ws closed before matching frame arrived (code=${this._closeReason?.code})`)); + return; + } + const waiter: PendingWaiter = { + match: predicate, + resolve: (f) => { + if (waiter.timer) clearTimeout(waiter.timer); + resolve(f); + }, + reject: (e) => { + if (waiter.timer) clearTimeout(waiter.timer); + reject(e); + }, + }; + waiter.timer = setTimeout(() => { + const idx = this._waiters.indexOf(waiter); + if (idx >= 0) this._waiters.splice(idx, 1); + reject(new Error(`waitForFrame timed out after ${timeoutMs}ms`)); + }, timeoutMs); + waiter.timer.unref?.(); + this._waiters.push(waiter); + }); + } + + /** Send a control message and wait for its `ack` (matched by `id`). */ + async sendAndAwaitAck(frame: { type: string; id: string; payload: unknown }, timeoutMs: number): Promise { + this.send(frame); + return this.waitForFrame( + (f) => f.type === 'ack' && f.id === frame.id, + timeoutMs, + ); + } + + /** Resolves when the socket closes (or immediately if already closed). */ + closed(): Promise<{ code: number; reason: string }> { + if (this._closeReason) return Promise.resolve(this._closeReason); + return new Promise((resolve) => { + this._closeWaiters.push(resolve); + }); + } + + /** Initiate close from the client side. */ + async close(): Promise { + if (!this.ws || this._closed) return; + this.ws.close(); + await this.closed(); + } + + private _onMessage(data: unknown): void { + let frame: AnyFrame; + try { + const raw = typeof data === 'string' ? data : String(data); + frame = JSON.parse(raw) as AnyFrame; + } catch (err) { + this.opts.logger('warn', 'ws: dropped non-JSON frame', { err: String(err) }); + recordReportEvent( + { + kind: 'ws', + direction: 'in', + url: this.opts.url, + message: 'dropped non-JSON frame', + error: errorForReport(err), + }, + { reportDir: this.opts.reportDir }, + ); + return; + } + recordReportEvent( + { kind: 'ws', direction: 'in', url: this.opts.url, frame }, + { reportDir: this.opts.reportDir }, + ); + + if (frame.type === 'ping') { + this.send({ type: 'pong', payload: { nonce: pingNonce(frame) } }); + } + + // Dispatch to subscribers first — they observe every frame, regardless of + // whether a `waitForFrame` consumed it. + for (const sub of this._subscribers) { + try { + sub(frame); + } catch (err) { + this.opts.logger('warn', 'ws: subscriber threw', { err: String(err) }); + } + } + + // Find the FIRST waiter whose predicate matches. A waiter is single-shot. + for (let i = 0; i < this._waiters.length; i++) { + const w = this._waiters[i]; + if (w === undefined) continue; + let matches = false; + try { + matches = w.match(frame); + } catch (err) { + this.opts.logger('warn', 'ws: waiter predicate threw', { err: String(err) }); + } + if (matches) { + this._waiters.splice(i, 1); + w.resolve(frame); + return; + } + } + this._queue.push(frame); + } + + private _onClose(code: number, reason: string): void { + this._closed = true; + this._closeReason = { code, reason }; + recordReportEvent( + { + kind: 'ws', + direction: 'lifecycle', + url: this.opts.url, + message: 'close', + frame: { code, reason }, + }, + { reportDir: this.opts.reportDir }, + ); + for (const w of this._waiters.splice(0)) { + w.reject(new Error(`ws closed (code=${code}) before matching frame arrived`)); + } + for (const w of this._closeWaiters.splice(0)) w(this._closeReason); + } +} + +function pingNonce(frame: AnyFrame): string { + const payload = frame.payload as { nonce?: unknown } | undefined; + return typeof payload?.nonce === 'string' ? payload.nonce : ''; +} + +function errorForReport(error: unknown): unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + }; + } + return error; +} diff --git a/packages/klient/test/e2e/invalid-input-matrix.test.ts b/packages/klient/test/e2e/invalid-input-matrix.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a2f7b0341b65e0c48f681eb9794903760bab47d --- /dev/null +++ b/packages/klient/test/e2e/invalid-input-matrix.test.ts @@ -0,0 +1,1185 @@ +/** + * Invalid-input × provider behavior matrix, pinned end-to-end through the + * klient in-memory transport and a local mock model endpoint. + * + * Every case runs the REAL pipeline: klient facade → engine session/loop → + * llmRequester → ModelRequesterImpl → composed ChatProvider.generate → the + * provider's message-conversion layer → HTTP against a local `node:http` + * server. The mock server captures request bodies (so fallbacks can be + * asserted on their wire shape) and replies with scripted SSE streams or + * HTTP errors. No real network is touched. + * + * Error layers are labeled in each case (and in the report): + * - l1: klient-side zod contract rejection (`KlientValidationError`, + * promise rejection from the facade call — never reaches the engine). + * - l2: engine-native error (provider conversion layer or engine services — + * surfaces as a failed turn whose error payload carries a v2 `Error2` + * domain code such as `provider.api_error`). + * - l3: scripted provider (mock server) error — an HTTP status error that + * enters through the provider SDK's error path, gets normalized into the + * `API*Error` family, and is translated by `translateProviderError`. + * + * Provider columns: plain OpenAI (protocol `openai`, no vendor), composed + * Kimi (protocol `openai` + provider `type: kimi` — the trait-composition + * path), Anthropic, and Google GenAI. Rows are chosen for representativeness + * rather than a full cartesian product. + */ + +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; + +import { TEST_CLIENT_IDENTITY } from '../helpers/engine.js'; +import type { ContentPart } from '@moonshot-ai/agent-core-v2/human/llm/message'; +import { IModelService } from '@moonshot-ai/agent-core-v2/llm-adapter/model/model'; + +import type { Klient } from '../../src/index.js'; +import type { AgentHandle } from '../../src/core/klient.js'; +import type { KlientEvents } from '../../src/core/events/hub.js'; +import { KlientValidationError } from '../../src/core/validation.js'; +import { createKlient as createMemoryKlient } from '../../src/transports/memory/index.js'; + +// The dual/http e2e suites (and their `helpers/dual.ts`) were dropped with the +// http transport; the two wait primitives they exported are re-declared here. +async function waitFor( + predicate: () => Promise | boolean, + timeoutMs: number, + intervalMs = 100, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await predicate()) return; + await new Promise((resolve) => { + setTimeout(resolve, intervalMs); + }); + } + throw new Error(`waitFor timed out after ${timeoutMs}ms`); +} + +/** Resolve with the first payload of `name` (or reject on timeout). */ +function onceEvent( + events: KlientEvents, + name: E, + timeoutMs = 60_000, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + sub.dispose(); + reject(new Error(`timed out waiting for event ${name}`)); + }, timeoutMs); + const sub = events.on(name, (payload) => { + clearTimeout(timer); + sub.dispose(); + resolve(payload); + }); + }); +} + +// --------------------------------------------------------------------------- +// Model ids registered in the engine for this suite. +// --------------------------------------------------------------------------- + +const M_OPENAI = 'matrix-openai'; +const M_OPENAI_VISION = 'matrix-openai-vision'; +const M_KIMI = 'matrix-kimi'; +const M_ANTHROPIC = 'matrix-anthropic'; +const M_GOOGLE = 'matrix-google'; + +const KIMI_PROVIDER = 'matrix-kimi-provider'; + +const IMAGE_BAD_MIME_URL = 'data:image/tiff;base64,QUJD'; // tiff is outside every provider's accepted set +const IMAGE_KIMI_ONLY_MIME_URL = 'data:image/bmp;base64,QUJD'; // bmp is accepted by Kimi alone +const IMAGE_BAD_BASE64_URL = 'data:image/png;base64,%%%not-base64%%%'; +const VIDEO_HTTP_URL = 'https://example.com/clip.mp4'; +const VIDEO_BAD_MIME_URL = 'data:video/x-ms-wmv;base64,QUJD'; + +/** 1x1 transparent PNG — real magic bytes so the engine's sniffer accepts it. */ +const PNG_1X1_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; +/** Minimal ftyp box so the video sniffer reports video/mp4. */ +const MP4_FTYP_HEX = '00000020667479706d703432000000006d70343269736f6d'; + +// --------------------------------------------------------------------------- +// Captured requests + scripted replies. +// --------------------------------------------------------------------------- + +interface CapturedRequest { + readonly method: string; + readonly url: string; + readonly contentType: string; + /** Parsed JSON body when the content type is JSON, otherwise undefined. */ + readonly json: unknown; + /** Raw body (kept for multipart inspection). */ + readonly raw: Buffer; +} + +type MockReply = + | { readonly kind: 'sse'; readonly lines: readonly string[] } + | { readonly kind: 'json'; readonly status: number; readonly body: unknown } + | { readonly kind: 'hang' }; + +type MockHandler = (request: CapturedRequest, callIndex: number) => MockReply; + +const requests: CapturedRequest[] = []; +let handler: MockHandler = () => { + throw new Error('mock handler not installed for this case'); +}; + +function resetMock(next: MockHandler): void { + requests.length = 0; + handler = next; +} + +/** A reply queue: each call shifts; once empty, `fallback` serves the rest. */ +function queueScript(...replies: readonly (MockReply | undefined)[]): MockHandler { + const queue = [...replies]; + return () => { + const next = queue.shift(); + if (next === undefined) throw new Error('mock script exhausted'); + return next; + }; +} + +function jsonError(status: number, message: string): MockReply { + return { kind: 'json', status, body: { error: { message } } }; +} + +const OK_OPENAI: MockReply = { kind: 'sse', lines: openAiSse('OK') }; +const OK_ANTHROPIC: MockReply = { kind: 'sse', lines: anthropicSse('OK') }; +const OK_GOOGLE: MockReply = { kind: 'sse', lines: googleSse('OK') }; + +// --------------------------------------------------------------------------- +// SSE fixtures per wire protocol. +// --------------------------------------------------------------------------- + +function sseLines(...events: readonly string[]): string[] { + const lines: string[] = []; + for (const event of events) { + lines.push(`data: ${event}`, ''); + } + return lines; +} + +function openAiSse(text: string): string[] { + return [ + ...sseLines( + JSON.stringify({ + id: 'chatcmpl-mock', + object: 'chat.completion.chunk', + created: 1, + model: 'mock', + choices: [{ index: 0, delta: { role: 'assistant', content: text }, finish_reason: null }], + }), + JSON.stringify({ + id: 'chatcmpl-mock', + object: 'chat.completion.chunk', + created: 1, + model: 'mock', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 }, + }), + ), + 'data: [DONE]', + '', + ]; +} + +function openAiToolCallSse(id: string, name: string, args: string): string[] { + return [ + ...sseLines( + JSON.stringify({ + id: 'chatcmpl-mock', + object: 'chat.completion.chunk', + created: 1, + model: 'mock', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [ + { index: 0, id, type: 'function', function: { name, arguments: args } }, + ], + }, + finish_reason: null, + }, + ], + }), + JSON.stringify({ + id: 'chatcmpl-mock', + object: 'chat.completion.chunk', + created: 1, + model: 'mock', + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 }, + }), + ), + 'data: [DONE]', + '', + ]; +} + +function anthropicSse(text: string): string[] { + return [ + 'event: message_start', + `data: ${JSON.stringify({ + type: 'message_start', + message: { + id: 'msg_mock', + type: 'message', + role: 'assistant', + model: 'mock', + content: [], + stop_reason: null, + usage: { input_tokens: 10, output_tokens: 1 }, + }, + })}`, + '', + 'event: content_block_start', + `data: ${JSON.stringify({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + })}`, + '', + 'event: content_block_delta', + `data: ${JSON.stringify({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text }, + })}`, + '', + 'event: content_block_stop', + `data: ${JSON.stringify({ type: 'content_block_stop', index: 0 })}`, + '', + 'event: message_delta', + `data: ${JSON.stringify({ + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 2 }, + })}`, + '', + 'event: message_stop', + `data: ${JSON.stringify({ type: 'message_stop' })}`, + '', + ]; +} + +function googleSse(text: string): string[] { + return sseLines( + JSON.stringify({ + candidates: [ + { content: { role: 'model', parts: [{ text }] }, finishReason: 'STOP' }, + ], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 2 }, + responseId: 'resp-mock', + }), + ); +} + +// --------------------------------------------------------------------------- +// Engine + klient + mock server lifecycle. +// --------------------------------------------------------------------------- + +let klient: Klient; +let app: ReturnType['app'] | undefined; +let server: Server; +let baseUrl: string; +let homeDir: string; +let workRoot: string; +const sockets = new Set(); + +beforeAll(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'klient-matrix-home-')); + workRoot = await mkdtemp(join(tmpdir(), 'klient-matrix-work-')); + ({ app } = bootstrap({ homeDir, clientIdentity: TEST_CLIENT_IDENTITY }, [ + ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), + ])); + klient = createMemoryKlient({ scope: app }); + + server = createServer((req: IncomingMessage, res: ServerResponse) => { + void (async () => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks); + const contentType = String(req.headers['content-type'] ?? ''); + let json: unknown; + if (contentType.includes('json')) { + try { + json = JSON.parse(raw.toString('utf8')); + } catch { + json = undefined; + } + } + const captured: CapturedRequest = { + method: req.method ?? '', + url: req.url ?? '', + contentType, + json, + raw, + }; + requests.push(captured); + let reply: MockReply; + try { + reply = handler(captured, requests.length - 1); + } catch (error) { + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: { message: `mock handler: ${String(error)}` } })); + return; + } + if (reply.kind === 'hang') return; // never answered; the client aborts it + if (reply.kind === 'json') { + res.writeHead(reply.status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(reply.body)); + return; + } + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }); + // Trailing '\n' closes the final event — some SDK SSE parsers (Google + // GenAI) only dispatch an event on a blank-line terminator. + res.end(`${reply.lines.join('\n')}\n`); + })().catch(() => { + res.destroy(); + }); + }); + server.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + await klient.global.kosong.addProvider(KIMI_PROVIDER, { + type: 'kimi', + auth: { method: 'api-key', apiKey: 'test-key' }, + baseUrl: `${baseUrl}/v1`, + }); + await klient.global.kosong.addProvider({ + id: M_OPENAI, + model: 'gpt-4o-mini', + protocol: 'openai', + baseUrl: `${baseUrl}/v1`, + auth: { method: 'api-key', apiKey: 'test-key' }, + maxContextSize: 262_144, + }); + await klient.global.kosong.addProvider({ + id: M_OPENAI_VISION, + model: 'gpt-4o-mini', + protocol: 'openai', + baseUrl: `${baseUrl}/v1`, + auth: { method: 'api-key', apiKey: 'test-key' }, + maxContextSize: 262_144, + capabilities: { image_in: true, video_in: true }, + }); + // M_KIMI needs a `provider` reference to KIMI_PROVIDER so the engine + // resolves kimi provider traits (uploadVideo). The facade's addProvider() + // doesn't support provider-linkage, so call modelService directly. + await app!.accessor.get(IModelService).set(M_KIMI, { + model: 'kimi-k2-matrix', + provider: KIMI_PROVIDER, + protocol: 'openai', + maxContextSize: 262_144, + capabilities: ['image_in', 'video_in'], + }); + await klient.global.kosong.addProvider({ + id: M_ANTHROPIC, + model: 'claude-sonnet-4-5', + protocol: 'anthropic', + baseUrl: `${baseUrl}/v1`, + auth: { method: 'api-key', apiKey: 'test-key' }, + maxContextSize: 262_144, + }); + await klient.global.kosong.addProvider({ + id: M_GOOGLE, + model: 'gemini-2.5-flash', + protocol: 'google-genai', + baseUrl, + auth: { method: 'api-key', apiKey: 'test-key' }, + maxContextSize: 262_144, + }); +}, 60_000); + +afterAll(async () => { + await klient.close(); + app?.dispose(); + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + await rm(homeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); + await rm(workRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); +}); + +// --------------------------------------------------------------------------- +// Case helpers. +// --------------------------------------------------------------------------- + +interface CollectedEvent { + readonly name: string; + readonly payload: Record; +} + +interface CaseContext { + readonly agent: AgentHandle; + readonly events: CollectedEvent[]; + readonly workDir: string; + eventNames(): readonly string[]; + payloads(name: string): Record[]; +} + +async function newCase(modelId: string, label: string): Promise { + const workDir = join(workRoot, label); + await mkdir(workDir, { recursive: true }); + const session = await klient.global.sessions.create({ workDir }); + const agent = klient.session(session.id).agent('main'); + await agent.setModel(modelId); + + const events: CollectedEvent[] = []; + const record = + (name: string) => + (payload: Record): void => { + events.push({ name, payload }); + }; + agent.events.on('turn.started', record('turn.started')); + agent.events.on('turn.ended', record('turn.ended')); + agent.events.on('error', record('error')); + agent.events.on('prompt.completed', record('prompt.completed')); + agent.events.on('prompt.aborted', record('prompt.aborted')); + + return { + agent, + events, + workDir, + eventNames: () => events.map((event) => event.name), + payloads: (name) => events.filter((event) => event.name === name).map((event) => event.payload), + }; +} + +async function promptAndWait(ctx: CaseContext, input: readonly ContentPart[]): Promise { + const settled = Promise.race([ + onceEvent(ctx.agent.events, 'prompt.completed', 60_000), + onceEvent(ctx.agent.events, 'prompt.aborted', 60_000), + ]); + await ctx.agent.prompt({ input }); + await settled; +} + +/** Chat-completions messages array of the n-th captured request. */ +function openAiMessages(callIndex: number): Record[] { + const body = requests[callIndex]?.json as { messages?: Record[] } | undefined; + expect(body?.messages, `request #${callIndex} should carry a messages array`).toBeDefined(); + return body!.messages!.filter((message) => !isDateReminderMessage(message)); +} + +const DATE_REMINDER_MARKERS = [ + 'The current date is restated in a reminder whenever it changes', + 'Rely on this reminder over any earlier date statement', +]; + +function isDateReminderMessage(message: Record): boolean { + const serialized = JSON.stringify(message); + return DATE_REMINDER_MARKERS.some((marker) => serialized.includes(marker)); +} + +// --------------------------------------------------------------------------- +// l1 — klient contract validation (never reaches the engine). +// --------------------------------------------------------------------------- + +describe('l1: klient input validation', () => { + it('rejects an image_url part missing `url` before any engine work (l1)', async () => { + const ctx = await newCase(M_OPENAI, 'l1-image-missing-url'); + resetMock(queueScript(OK_OPENAI)); + + const badInput = [ + { type: 'image_url', imageUrl: {} }, + ] as unknown as readonly ContentPart[]; + const failure = await ctx.agent.prompt({ input: badInput }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(KlientValidationError); + expect((failure as KlientValidationError).phase).toBe('input'); + + expect(requests).toHaveLength(0); // nothing reached the wire + expect(ctx.events).toHaveLength(0); // no engine events at all + }, 30_000); + + it('rejects an audio_url prompt part (not in the PromptPart union) (l1)', async () => { + const ctx = await newCase(M_OPENAI, 'l1-audio-part'); + resetMock(queueScript(OK_OPENAI)); + + const badInput = [ + { type: 'audio_url', audioUrl: { url: 'https://example.com/a.mp3' } }, + ] as unknown as readonly ContentPart[]; + const failure = await ctx.agent.prompt({ input: badInput }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(KlientValidationError); + expect((failure as KlientValidationError).phase).toBe('input'); + expect(requests).toHaveLength(0); + }, 30_000); + + it('accepts an empty input array; the empty user message is dropped by the engine (l2 toleration)', async () => { + const ctx = await newCase(M_OPENAI, 'l2-empty-input'); + resetMock(queueScript(OK_OPENAI)); + + await promptAndWait(ctx, []); + + // klient's zod schema allows an empty array; the engine's prompt service + // only appends non-empty user messages, so the request leaves with the + // system prompt alone. The turn still completes. + expect(requests).toHaveLength(1); + const messages = openAiMessages(0); + expect(messages.every((message) => message['role'] === 'system')).toBe(true); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('completed'); + }, 30_000); +}); + +// --------------------------------------------------------------------------- +// Image blocks: illegal mime type / corrupt payload × providers. +// --------------------------------------------------------------------------- + +describe('image blocks with invalid data', () => { + it('a data-URL image with an unaccepted mime is replaced at prompt ingestion on EVERY provider (l2)', async () => { + // PromptStepRequest gates image parts through gateImageFormatParts before + // the turn starts: image/tiff never reaches any provider's conversion + // layer — it becomes a text notice, the request goes out without the + // image, and the turn completes. This is the engine's "session + // poisoning" defense and holds for every provider. + const cases = [ + { label: 'tiff-openai', model: M_OPENAI, reply: OK_OPENAI }, + { label: 'tiff-kimi', model: M_KIMI, reply: OK_OPENAI }, + { label: 'tiff-anthropic', model: M_ANTHROPIC, reply: OK_ANTHROPIC }, + { label: 'tiff-google', model: M_GOOGLE, reply: OK_GOOGLE }, + ] as const; + for (const { label, model, reply } of cases) { + const ctx = await newCase(model, label); + resetMock(queueScript(reply)); + await promptAndWait(ctx, [ + { type: 'text', text: 'what is this?' }, + { type: 'image_url', imageUrl: { url: IMAGE_BAD_MIME_URL } }, + ]); + expect(requests, label).toHaveLength(1); + const wireText = JSON.stringify(requests[0]?.json); + expect(wireText, label).toContain('unsupported image format image/tiff'); + expect(wireText, label).not.toContain('image/tiff;base64'); + expect(ctx.payloads('prompt.completed')[0]?.['reason'], label).toBe('completed'); + } + }, 60_000); + + it('a data-URL image in a Kimi-only format reaches a Kimi model but is replaced elsewhere (l2)', async () => { + // The accepted set is keyed by the provider the agent is bound to: Kimi + // takes BMP/HEIC/HEIF on top of the baseline, the other providers do not. + const cases = [ + { label: 'bmp-kimi', model: M_KIMI, reply: OK_OPENAI, accepted: true }, + { label: 'bmp-openai', model: M_OPENAI, reply: OK_OPENAI, accepted: false }, + { label: 'bmp-anthropic', model: M_ANTHROPIC, reply: OK_ANTHROPIC, accepted: false }, + ] as const; + for (const { label, model, reply, accepted } of cases) { + const ctx = await newCase(model, label); + resetMock(queueScript(reply)); + await promptAndWait(ctx, [ + { type: 'text', text: 'what is this?' }, + { type: 'image_url', imageUrl: { url: IMAGE_KIMI_ONLY_MIME_URL } }, + ]); + expect(requests, label).toHaveLength(1); + const wireText = JSON.stringify(requests[0]?.json); + expect(wireText.includes('image/bmp;base64'), label).toBe(accepted); + expect(wireText.includes('unsupported image format image/bmp'), label).toBe(!accepted); + expect(ctx.payloads('prompt.completed')[0]?.['reason'], label).toBe('completed'); + } + }, 60_000); + + it('a malformed data URL is replaced with a notice at prompt ingestion (l2)', async () => { + const ctx = await newCase(M_OPENAI, 'malformed-data-url'); + resetMock(queueScript(OK_OPENAI)); + + await promptAndWait(ctx, [ + { type: 'text', text: 'what is this?' }, + { type: 'image_url', imageUrl: { url: 'data:definitely-not-a-data-url' } }, + ]); + + expect(requests).toHaveLength(1); + const wireText = JSON.stringify(requests[0]?.json); + expect(wireText).toContain('is not a valid data URL'); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('completed'); + }, 30_000); + + it('openai: corrupt base64 with an accepted mime passes conversion; a 400 triggers media-stripped resend (l3 + engine fallback)', async () => { + const ctx = await newCase(M_OPENAI, 'openai-image-base64'); + resetMock(queueScript(jsonError(400, 'Invalid image data'), OK_OPENAI)); + + await promptAndWait(ctx, [ + { type: 'text', text: 'what is this?' }, + { type: 'image_url', imageUrl: { url: IMAGE_BAD_BASE64_URL } }, + ]); + + expect(requests).toHaveLength(2); + // Ingestion accepts the declared mime (png) without validating the + // payload; the OpenAI base forwards the data URL verbatim. + const firstContent = openAiMessages(0).at(-1)?.['content'] as unknown[]; + expect(firstContent).toContainEqual({ + type: 'image_url', + image_url: { url: IMAGE_BAD_BASE64_URL }, + }); + // The 400 + "invalid image" body classifies as an image-format error, so + // llmRequester resends with the media stripped to a placeholder — and the + // turn succeeds. + const secondContent = openAiMessages(1).at(-1)?.['content'] as unknown[]; + expect(secondContent.some((part) => (part as { type?: string }).type === 'image_url')).toBe( + false, + ); + expect(JSON.stringify(secondContent)).toContain('image omitted for provider compatibility'); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('completed'); + }, 30_000); + + it('kimi (composed): same media-strip fallback as plain openai (l3 + engine fallback)', async () => { + const ctx = await newCase(M_KIMI, 'kimi-image-base64'); + resetMock(queueScript(jsonError(400, 'Invalid image data'), OK_OPENAI)); + + await promptAndWait(ctx, [ + { type: 'text', text: 'what is this?' }, + { type: 'image_url', imageUrl: { url: IMAGE_BAD_BASE64_URL } }, + ]); + + expect(requests).toHaveLength(2); + const firstContent = openAiMessages(0).at(-1)?.['content'] as unknown[]; + expect(firstContent).toContainEqual({ + type: 'image_url', + image_url: { url: IMAGE_BAD_BASE64_URL }, + }); + const secondContent = openAiMessages(1).at(-1)?.['content'] as unknown[]; + expect(secondContent.some((part) => (part as { type?: string }).type === 'image_url')).toBe( + false, + ); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('completed'); + }, 30_000); + + it('google-genai: corrupt base64 rides inlineData; a 400 triggers media-stripped resend (l3 + engine fallback)', async () => { + const ctx = await newCase(M_GOOGLE, 'google-image-base64'); + resetMock(queueScript(jsonError(400, 'Invalid image data'), OK_GOOGLE)); + + await promptAndWait(ctx, [ + { type: 'text', text: 'what is this?' }, + { type: 'image_url', imageUrl: { url: IMAGE_BAD_BASE64_URL } }, + ]); + + expect(requests).toHaveLength(2); + // convertMediaUrl parses the mime out of the data URL and never + // validates the payload — it lands in inlineData. + const firstParts = ( + (requests[0]?.json as { contents?: { parts?: unknown[] }[] }).contents ?? [] + ).flatMap((content) => content.parts ?? []); + expect(firstParts).toContainEqual({ + inlineData: { mimeType: 'image/png', data: '%%%not-base64%%%' }, + }); + const secondParts = ( + (requests[1]?.json as { contents?: { parts?: unknown[] }[] }).contents ?? [] + ).flatMap((content) => content.parts ?? []); + expect(secondParts.some((part) => (part as { inlineData?: unknown }).inlineData)).toBe(false); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('completed'); + }, 30_000); + + it('anthropic: corrupt base64 with a legal mime passes conversion, then gets stripped after a 400', async () => { + const ctx = await newCase(M_ANTHROPIC, 'anthropic-image-base64'); + resetMock(queueScript(jsonError(400, 'could not process the image'), OK_ANTHROPIC)); + + await promptAndWait(ctx, [ + { type: 'text', text: 'what is this?' }, + { type: 'image_url', imageUrl: { url: IMAGE_BAD_BASE64_URL } }, + ]); + + expect(requests).toHaveLength(2); + // The base validates the mime STRING only; payload bytes go out as-is. + // (The last block of the last message also carries an injected + // cache_control marker — compare on the fields that matter.) + const firstBlocks = ( + (requests[0]?.json as { messages?: { content?: unknown[] }[] }).messages ?? [] + ).flatMap((message) => (Array.isArray(message.content) ? message.content : [])); + const imageBlock = firstBlocks.find( + (block): block is { type: 'image'; source: Record } => + (block as { type?: string }).type === 'image', + ); + expect(imageBlock?.source).toMatchObject({ + type: 'base64', + data: '%%%not-base64%%%', + media_type: 'image/png', + }); + const secondBlocks = ( + (requests[1]?.json as { messages?: { content?: unknown[] }[] }).messages ?? [] + ).flatMap((message) => (Array.isArray(message.content) ? message.content : [])); + expect(secondBlocks.some((block) => (block as { type?: string }).type === 'image')).toBe(false); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('completed'); + }, 30_000); +}); + +// --------------------------------------------------------------------------- +// Daemon file references (kimi-file://): engine-side resolution before the +// provider wire. +// --------------------------------------------------------------------------- + +describe('daemon file references (kimi-file://)', () => { + // Regression for the duplicated resolver-token shadowing: the legacy + // video-only resolver won the shared DI token on the production import + // order, so image kimi-file refs leaked to the provider unchanged and + // gateways rejected the unknown scheme with a 400 ("unsupported image + // url"), which the media-strip fallback then mistook for a bad image. + // Kimi models now upload the image via the files API and reference it as + // ms://; other providers keep the inline data URL. + it('a kimi-file image reference reaches a non-kimi provider as a data URL, never verbatim', async () => { + const meta = await klient.global.files.save({ + data: new Uint8Array(Buffer.from(PNG_1X1_BASE64, 'base64')), + filename: 'pasted-image.png', + mimeType: 'image/png', + expiresInSec: 3600, + }); + const ctx = await newCase(M_OPENAI_VISION, 'kimifile-image-openai'); + resetMock(queueScript(OK_OPENAI)); + await promptAndWait(ctx, [ + { type: 'image_url', imageUrl: { url: `kimi-file://${meta.id}` } }, + { type: 'text', text: 'what is this?' }, + ]); + expect(requests).toHaveLength(1); + expect(JSON.stringify(requests[0]?.json)).not.toContain(`kimi-file://${meta.id}`); + const content = openAiMessages(0).at(-1)?.['content'] as unknown[]; + const imagePart = content.find( + (part) => (part as { type?: string }).type === 'image_url', + ) as { image_url?: { url?: string } } | undefined; + expect(imagePart?.image_url?.url ?? '').toMatch(/^data:image\/png;base64,/); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('completed'); + }, 60_000); + + it('a kimi-file image reference reaches kimi as an uploaded ms:// reference, never verbatim', async () => { + const meta = await klient.global.files.save({ + data: new Uint8Array(Buffer.from(PNG_1X1_BASE64, 'base64')), + filename: 'pasted-image.png', + mimeType: 'image/png', + expiresInSec: 3600, + }); + const ctx = await newCase(M_KIMI, 'kimifile-image-kimi'); + resetMock((req) => { + if (req.url === '/v1/files') { + return { + kind: 'json', + status: 200, + body: { + id: 'file-mock-image', + object: 'file', + bytes: 68, + created_at: 1, + filename: 'pasted-image.png', + purpose: 'image', + }, + }; + } + return OK_OPENAI; + }); + await promptAndWait(ctx, [ + { type: 'image_url', imageUrl: { url: `kimi-file://${meta.id}` } }, + { type: 'text', text: 'what is this?' }, + ]); + + const fileUpload = requests.find((request) => request.url === '/v1/files'); + expect(fileUpload).toBeDefined(); + expect(fileUpload?.contentType).toContain('multipart/form-data'); + const formText = fileUpload!.raw.toString('latin1'); + expect(formText).toContain('name="purpose"'); + expect(formText).toContain('image'); + + const chatCalls = requests.filter((request) => request.url === '/v1/chat/completions'); + expect(chatCalls).toHaveLength(1); + expect(JSON.stringify(chatCalls[0]?.json)).not.toContain(`kimi-file://${meta.id}`); + const content = (chatCalls[0]?.json as { messages?: Record[] }) + .messages?.filter((message) => !isDateReminderMessage(message)) + .at(-1)?.['content'] as unknown[]; + const imagePart = content.find( + (part) => (part as { type?: string }).type === 'image_url', + ) as { image_url?: { url?: string } } | undefined; + expect(imagePart?.image_url?.url ?? '').toBe('ms://file-mock-image'); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('completed'); + }, 60_000); +}); + +// --------------------------------------------------------------------------- +// Video blocks: URL pass-through, upload capability, illegal video data. +// --------------------------------------------------------------------------- + +describe('video blocks', () => { + it('video_url with an http URL passes through verbatim on every provider', async () => { + const cases: Array<{ + label: string; + model: string; + reply: MockReply; + assertBody: (body: unknown) => void; + }> = [ + { + label: 'video-url-openai', + model: M_OPENAI, + reply: OK_OPENAI, + assertBody: (body) => { + const parts = (body as { messages: { content?: unknown }[] }).messages.flatMap( + (message) => (Array.isArray(message.content) ? message.content : []), + ); + expect(parts).toContainEqual({ + type: 'video_url', + video_url: { url: VIDEO_HTTP_URL }, + }); + }, + }, + { + label: 'video-url-kimi', + model: M_KIMI, + reply: OK_OPENAI, + assertBody: (body) => { + const parts = (body as { messages: { content?: unknown }[] }).messages.flatMap( + (message) => (Array.isArray(message.content) ? message.content : []), + ); + expect(parts).toContainEqual({ + type: 'video_url', + video_url: { url: VIDEO_HTTP_URL }, + }); + }, + }, + { + label: 'video-url-anthropic', + model: M_ANTHROPIC, + reply: OK_ANTHROPIC, + assertBody: (body) => { + const blocks = (body as { messages: { content?: unknown }[] }).messages.flatMap( + (message) => (Array.isArray(message.content) ? message.content : []), + ); + expect(blocks).toContainEqual({ + type: 'video', + source: { type: 'url', url: VIDEO_HTTP_URL }, + }); + }, + }, + { + label: 'video-url-google', + model: M_GOOGLE, + reply: OK_GOOGLE, + assertBody: (body) => { + const parts = (body as { contents: { parts?: unknown[] }[] }).contents.flatMap( + (content) => content.parts ?? [], + ); + expect(parts).toContainEqual({ + fileData: { fileUri: VIDEO_HTTP_URL, mimeType: 'video/mp4' }, + }); + }, + }, + ]; + + for (const { label, model, reply, assertBody } of cases) { + const ctx = await newCase(model, label); + resetMock(queueScript(reply)); + await promptAndWait(ctx, [ + { type: 'text', text: 'describe this clip' }, + { type: 'video_url', videoUrl: { url: VIDEO_HTTP_URL } }, + ]); + expect(requests, label).toHaveLength(1); + assertBody(requests[0]?.json); + expect(ctx.payloads('prompt.completed')[0]?.['reason'], label).toBe('completed'); + } + }, 60_000); + + it('kimi (composed): ReadMediaFile on a video uploads via the files API (uploadVideo trait)', async () => { + const ctx = await newCase(M_KIMI, 'kimi-video-upload'); + await writeFile(join(ctx.workDir, 'clip.mp4'), Buffer.from(MP4_FTYP_HEX, 'hex')); + + let chatCallCount = 0; + resetMock((req) => { + if (req.url === '/v1/files') { + return { + kind: 'json', + status: 200, + body: { + id: 'file-mock-video', + object: 'file', + bytes: 32, + created_at: 1, + filename: 'clip.mp4', + purpose: 'video', + }, + }; + } + chatCallCount += 1; + return chatCallCount === 1 + ? { + kind: 'sse', + lines: openAiToolCallSse('call_video_1', 'ReadMediaFile', '{"path":"clip.mp4"}'), + } + : OK_OPENAI; + }); + await promptAndWait(ctx, [{ type: 'text', text: 'watch clip.mp4' }]); + + // The KimiFiles client POSTs multipart form data to {baseUrl}/files. + const fileUpload = requests.find((request) => request.url === '/v1/files'); + expect(fileUpload).toBeDefined(); + expect(fileUpload?.contentType).toContain('multipart/form-data'); + const formText = fileUpload!.raw.toString('latin1'); + expect(formText).toContain('name="purpose"'); + expect(formText).toContain('video'); + expect(formText).toContain('clip.mp4'); + + // Second chat call: the tool result carries the ms:// file reference in + // the tool message content (trait mode keeps media parts in place). + const chatCalls = requests.filter((request) => request.url === '/v1/chat/completions'); + expect(chatCalls).toHaveLength(2); + const secondBody = chatCalls[1]?.json as { messages: Record[] }; + const toolMessage = secondBody.messages.find((message) => message['role'] === 'tool'); + expect(JSON.stringify(toolMessage)).toContain('ms://file-mock-video'); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('completed'); + }, 30_000); + + it('openai: no uploadVideo capability — the tool result is an error text, no files call', async () => { + const ctx = await newCase(M_OPENAI_VISION, 'openai-video-upload'); + await writeFile(join(ctx.workDir, 'clip.mp4'), Buffer.from(MP4_FTYP_HEX, 'hex')); + + resetMock( + queueScript( + { kind: 'sse', lines: openAiToolCallSse('call_video_1', 'ReadMediaFile', '{"path":"clip.mp4"}') }, + OK_OPENAI, + ), + ); + await promptAndWait(ctx, [{ type: 'text', text: 'watch clip.mp4' }]); + + expect(requests.find((request) => request.url === '/v1/files')).toBeUndefined(); + const chatCalls = requests.filter((request) => request.url === '/v1/chat/completions'); + expect(chatCalls).toHaveLength(2); + const secondBody = chatCalls[1]?.json as { messages: Record[] }; + const toolMessage = secondBody.messages.find((message) => message['role'] === 'tool'); + // ModelRequesterImpl.uploadVideo throws for providers without the hook; + // ReadMediaFile converts that into an error tool result (engine fallback). + expect(String(toolMessage?.['content'])).toContain('does not support video upload'); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('completed'); + }, 30_000); + + it('anthropic: illegal base64 video mime throws in conversion; retryable, so stepRetry re-runs (l2)', async () => { + // "Unsupported media type for base64 video" does NOT match the + // image-format non-retryable patterns, so stepRetry claims it. Cap the + // retries at 2 attempts (1 re-run, ~500ms backoff) for the suite's sake. + await klient.global.config.set({ domain: 'loopControl', patch: { maxAttemptsPerStep: 2 } }); + try { + const ctx = await newCase(M_ANTHROPIC, 'anthropic-video-mime'); + resetMock(queueScript(OK_ANTHROPIC)); + + await promptAndWait(ctx, [ + { type: 'text', text: 'watch this' }, + { type: 'video_url', videoUrl: { url: VIDEO_BAD_MIME_URL } }, + ]); + + // The conversion throws before any HTTP on every attempt — zero requests + // even though stepRetry re-ran the step once (retry is invisible on the + // klient event surface; only the final failure is). + expect(requests).toHaveLength(0); + const turnEnded = ctx.payloads('turn.ended'); + expect(turnEnded).toHaveLength(1); + expect(turnEnded[0]?.['reason']).toBe('failed'); + const wireError = turnEnded[0]?.['error'] as Record | undefined; + expect(wireError?.['code']).toBe('provider.api_error'); + expect(wireError?.['name']).toBe('ChatProviderError'); + expect(String(wireError?.['message'])).toContain('Unsupported media type for base64 video'); + + // The same v2-native payload is recorded on the klient error event, in + // order: turn.started → turn.ended → error → prompt.completed. + const errorEvents = ctx.payloads('error'); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]?.['code']).toBe('provider.api_error'); + expect(ctx.eventNames()).toEqual(['turn.started', 'turn.ended', 'error', 'prompt.completed']); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('failed'); + } finally { + await klient.global.config.set({ domain: 'loopControl', patch: { maxAttemptsPerStep: 10 } }); + } + }, 30_000); +}); + +// --------------------------------------------------------------------------- +// Tool-call / tool-result structure × providers. +// --------------------------------------------------------------------------- + +describe('tool exchange structure', () => { + it('openai: an unknown tool call still gets a paired (error) result on the wire', async () => { + const ctx = await newCase(M_OPENAI, 'openai-unknown-tool'); + resetMock( + queueScript( + { + kind: 'sse', + lines: openAiToolCallSse('call_unknown_1', 'definitely_not_a_real_tool', '{}'), + }, + OK_OPENAI, + ), + ); + await promptAndWait(ctx, [{ type: 'text', text: 'use the tool' }]); + + expect(requests).toHaveLength(2); + const messages = openAiMessages(1); + const assistant = messages.find( + (message) => message['role'] === 'assistant' && message['tool_calls'] !== undefined, + ); + expect( + (assistant?.['tool_calls'] as { id: string }[]).map((call) => call.id), + ).toContain('call_unknown_1'); + const toolMessage = messages.find( + (message) => message['role'] === 'tool' && message['tool_call_id'] === 'call_unknown_1', + ); + expect(toolMessage).toBeDefined(); + // toolExecutor's fallback for a missing tool: an error result, so the + // exchange stays paired (engine-level toleration). + expect(String(toolMessage?.['content'])).toContain('definitely_not_a_real_tool'); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('completed'); + }, 30_000); + + it('media tool result: plain openai extracts text + appends a media user message; kimi keeps parts in place', async () => { + const runMediaToolResultCase = async ( + model: string, + label: string, + ): Promise[]> => { + const ctx = await newCase(model, label); + await writeFile(join(ctx.workDir, 'pixel.png'), Buffer.from(PNG_1X1_BASE64, 'base64')); + resetMock( + queueScript( + { + kind: 'sse', + lines: openAiToolCallSse('call_media_1', 'ReadMediaFile', '{"path":"pixel.png"}'), + }, + OK_OPENAI, + ), + ); + await promptAndWait(ctx, [{ type: 'text', text: 'look at pixel.png' }]); + expect(ctx.payloads('prompt.completed')[0]?.['reason'], label).toBe('completed'); + return openAiMessages(1); + }; + + // Plain openai: the base's extract_text fallback flattens the tool result + // to text, and the image re-attaches as a trailing user message. + const openAiWire = await runMediaToolResultCase(M_OPENAI_VISION, 'media-result-openai'); + const openAiTool = openAiWire.find((message) => message['role'] === 'tool'); + expect(typeof openAiTool?.['content']).toBe('string'); + expect(String(openAiTool?.['content'])).toContain(' part.type === 'image_url')).toBe(true); + + // Composed kimi: trait mode hands shaping to the trait — the image part + // stays inside the tool message content; no extra user message appears. + const kimiWire = await runMediaToolResultCase(M_KIMI, 'media-result-kimi'); + const kimiTool = kimiWire.find((message) => message['role'] === 'tool'); + const kimiContent = kimiTool?.['content'] as { type: string }[]; + expect(Array.isArray(kimiContent)).toBe(true); + expect(kimiContent.some((part) => part.type === 'image_url')).toBe(true); + expect(kimiWire.at(-1)?.['role']).toBe('tool'); + }, 60_000); + + it('tool call ids are sanitized (64-char, safe charset) consistently across call and result', async () => { + const nastyId = `call/bad id#${'x'.repeat(80)}`; + const runIdCase = async (model: string, label: string): Promise[]> => { + const ctx = await newCase(model, label); + resetMock( + queueScript( + { + kind: 'sse', + lines: openAiToolCallSse(nastyId, 'definitely_not_a_real_tool', '{}'), + }, + OK_OPENAI, + ), + ); + await promptAndWait(ctx, [{ type: 'text', text: 'use the tool' }]); + expect(ctx.payloads('prompt.completed')[0]?.['reason'], label).toBe('completed'); + return openAiMessages(1); + }; + + for (const [model, label] of [ + [M_OPENAI, 'tool-id-openai'], + [M_KIMI, 'tool-id-kimi'], + ] as const) { + const messages = await runIdCase(model, label); + const assistant = messages.find((message) => message['tool_calls'] !== undefined); + const wireId = (assistant?.['tool_calls'] as { id: string }[])[0]?.id; + expect(wireId, label).toBeDefined(); + expect(wireId!.length, label).toBeLessThanOrEqual(64); + expect(wireId, label).toMatch(/^[a-zA-Z0-9_-]+$/); + const toolMessage = messages.find((message) => message['role'] === 'tool'); + // Normalization rewrites call and result with the SAME mapping. + expect(toolMessage?.['tool_call_id'], label).toBe(wireId); + } + }, 60_000); + + it('after a user abort, an interruption reminder separates the next user message on the wire', async () => { + const ctx = await newCase(M_OPENAI, 'abort-merge'); + resetMock((_req, callIndex) => (callIndex === 0 ? { kind: 'hang' } : OK_OPENAI)); + + const firstSettled = onceEvent(ctx.agent.events, 'prompt.aborted', 30_000); + await ctx.agent.prompt({ input: [{ type: 'text', text: 'first message' }] }); + await waitFor(() => requests.length === 1, 10_000); + await ctx.agent.cancel(); + await firstSettled; + + await promptAndWait(ctx, [{ type: 'text', text: 'second message' }]); + + expect(requests).toHaveLength(2); + const userMessages = openAiMessages(1).filter((message) => message['role'] === 'user'); + // A deliberate user cancel injects an interruption reminder between the + // aborted turn's prompt and the next user message, so the two prompts no + // longer merge into one wire message. + expect(userMessages).toHaveLength(3); + expect(String(userMessages[0]?.['content'])).toContain('first message'); + expect(String(userMessages[1]?.['content'])).toContain(''); + expect(String(userMessages[1]?.['content'])).toContain('interrupted by the user'); + expect(String(userMessages[2]?.['content'])).toContain('second message'); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('completed'); + }, 60_000); +}); + +// --------------------------------------------------------------------------- +// Scripted provider (mock server) errors — the l3 layer. +// --------------------------------------------------------------------------- + +describe('provider HTTP errors', () => { + it('a non-recoverable 422 fails the turn with provider.api_error + statusCode on the wire (l3)', async () => { + const ctx = await newCase(M_OPENAI, 'openai-422'); + resetMock(queueScript(jsonError(422, 'validation exploded on field messages'))); + + await promptAndWait(ctx, [{ type: 'text', text: 'hello' }]); + + expect(requests).toHaveLength(1); // 422 is not retried, not reprojected + const turnEnded = ctx.payloads('turn.ended'); + expect(turnEnded).toHaveLength(1); + expect(turnEnded[0]?.['reason']).toBe('failed'); + const wireError = turnEnded[0]?.['error'] as Record | undefined; + expect(wireError?.['code']).toBe('provider.api_error'); + expect(wireError?.['name']).toBe('APIStatusError'); + expect((wireError?.['details'] as Record | undefined)?.['statusCode']).toBe( + 422, + ); + expect(wireError?.['retryable']).toBe(false); + + const errorEvents = ctx.payloads('error'); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]?.['code']).toBe('provider.api_error'); + expect(ctx.eventNames()).toEqual(['turn.started', 'turn.ended', 'error', 'prompt.completed']); + }, 30_000); + + it('a 400 structure error is retried once with the strict projection, then succeeds (l3 + engine fallback)', async () => { + const ctx = await newCase(M_OPENAI, 'openai-400-strict'); + resetMock( + queueScript(jsonError(400, "tool_call_id 'call_x' not found"), OK_OPENAI), + ); + + await promptAndWait(ctx, [{ type: 'text', text: 'hello' }]); + + // llmRequester recognizes the adjacency rejection and resends with the + // strict projection before the loop ever sees an error. + expect(requests).toHaveLength(2); + expect(ctx.payloads('prompt.completed')[0]?.['reason']).toBe('completed'); + expect(ctx.payloads('error')).toHaveLength(0); + }, 30_000); +}); diff --git a/packages/klient/test/e2e/legacy/client.test.ts b/packages/klient/test/e2e/legacy/client.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8739d1eeec687dfe277c9395239963916a1b303a --- /dev/null +++ b/packages/klient/test/e2e/legacy/client.test.ts @@ -0,0 +1,726 @@ +/** + * Self-tests for `DaemonClient` against a live server at + * `process.env.KIMI_SERVER_URL ?? http://127.0.0.1:58627`. + * + * Every test gates on a `daemonReachable()` check so CI / dev machines + * without a running server stay green. Run a server (`pnpm dev:server` from + * repo root) to exercise these locally. + * + * Coverage: + * 1. HTTP envelope unwrap throws on `code !== 0`. + * 2. WS handshake completes (server_hello + client_hello ack). + * 3. Subscribe ack succeeds for a real session id. + * 4. `waitForFrame` times out cleanly (no zombie waiters). + * 5. Created session is observable via `getSession`. + */ +import { afterEach, describe, expect, it } from 'vitest'; + +import type { FileMeta } from '@moonshot-ai/agent-core-v2/app/file/fileService'; +import type { + ModelCatalogItem, + ProviderCatalogItem, +} from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; +import { ErrorCode } from '@moonshot-ai/kap-server/protocol/error-codes'; +import type { Message } from '@moonshot-ai/kap-server/protocol/message'; +import type { SessionStatusResponse } from '@moonshot-ai/kap-server/protocol/rest-session'; +import type { Session } from '@moonshot-ai/kap-server/protocol/session'; + +import { DaemonClient, EnvelopeError } from '../harness/index.js'; +import { fetchWithReport } from '../harness/report.js'; +import { createCaseLogger, errorForLog } from './log.js'; + +const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; +const PROMPT_TIMEOUT_MS = 120_000; + +async function daemonReachable(): Promise { + try { + const res = await fetchWithReport(`${BASE_URL}/api/v1/meta`, { + signal: AbortSignal.timeout(500), + }); + return res.ok; + } catch { + return false; + } +} + +const reachable = await daemonReachable(); +const describeLive = reachable ? describe : describe.skip; + +let created: { client: DaemonClient; sid: string }[] = []; + +afterEach(async () => { + // Best-effort cleanup so reruns don't accumulate phantom sessions. + for (const { client, sid } of created.splice(0)) { + try { + await client.archiveSession(sid); + } catch { + // ignore + } + try { + await client.close(); + } catch { + // ignore + } + } +}); + +describeLive('DaemonClient (live server required)', () => { + it('throws EnvelopeError on code !== 0', async () => { + const log = createCaseLogger('client: missing session envelope'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + const sid = 'sess_does_not_exist_xxxxxxxx'; + log('request', { method: 'GET', path: `/api/v1/sessions/${sid}` }); + + let caughtError: unknown; + try { + await client.getSession(sid); + } catch (error) { + caughtError = error; + } + if (caughtError === undefined) { + throw new Error('expected getSession to reject for a missing session'); + } + log('error response', errorForLog(caughtError)); + expect(caughtError).toBeInstanceOf(EnvelopeError); + }); + + it('completes handshake (server_hello + client_hello ack)', async () => { + const log = createCaseLogger('client: ws handshake'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + log('connect request', { url: `${BASE_URL.replace(/^http/, 'ws')}/api/v1/ws` }); + const hello = await client.connect(); + log('server hello', hello); + // heartbeat_ms is optional — kap-server omits it (no server heartbeat). + expect(hello.heartbeat_ms === undefined || hello.heartbeat_ms > 0).toBe(true); + expect(typeof hello.ws_connection_id).toBe('string'); + await client.close(); + log('closed'); + }); + + it('subscribes to a real session id', async () => { + const log = createCaseLogger('client: subscribe real session'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + const session = await client.createSession({ metadata: { cwd: process.cwd() } }); + created.push({ client, sid: session.id }); + log('created session', session); + await client.connect(); + log('subscribe request', { type: 'subscribe', session_ids: [session.id] }); + await expect(client.subscribe(session.id)).resolves.toBeUndefined(); + log('subscribe accepted', { session_id: session.id }); + }); + + it('waitForFrame times out cleanly', async () => { + const log = createCaseLogger('client: waitForFrame timeout'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + await client.connect(); + log('wait request', { frame_type: 'event.does.not.exist', timeout_ms: 100 }); + let caughtError: unknown; + try { + await client.waitForFrame((f) => f.type === 'event.does.not.exist', { timeoutMs: 100 }); + } catch (error) { + caughtError = error; + } + if (caughtError === undefined) { + throw new Error('expected waitForFrame to time out'); + } + log('timeout error', errorForLog(caughtError)); + expect(caughtError).toBeInstanceOf(Error); + expect((caughtError as Error).message).toMatch(/waitForFrame timed out/); + await client.close(); + log('closed'); + }); + + it('created session is readable via getSession', async () => { + const log = createCaseLogger('client: getSession round trip'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + const session = await client.createSession({ metadata: { cwd: process.cwd() } }); + created.push({ client, sid: session.id }); + log('created session', session); + const fetched = await client.getSession(session.id); + log('fetched session', fetched); + expect(fetched.id).toBe(session.id); + expect(fetched.metadata.cwd).toBe(process.cwd()); + }); + + it('forks a session through the action-suffix route', async () => { + const log = createCaseLogger('client: fork action'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + const source = await client.createSession({ + title: 'Source session', + metadata: { cwd: process.cwd(), source: true }, + }); + created.push({ client, sid: source.id }); + log('source session', source); + + const forkRequest = { + metadata: { child: true }, + }; + log('request', { + method: 'POST', + path: `/api/v1/sessions/${source.id}:fork`, + body: forkRequest, + }); + + const fork = await client.forkSession(source.id, forkRequest); + created.push({ client, sid: fork.id }); + log('response', fork); + + expect(fork.id).not.toBe(source.id); + expect(fork.title).toBe('Fork: Source session'); + expect(fork.metadata).toMatchObject({ + cwd: process.cwd(), + source: true, + child: true, + }); + + const fetched = await client.getSession(fork.id); + log('fetched fork session', fetched); + expect(fetched.id).toBe(fork.id); + }); + + it( + 'compactSession prints empty-history errors and compacted history content', + async () => { + const log = createCaseLogger('client: compact empty history'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + const session = await client.createSession({ metadata: { cwd: process.cwd() } }); + created.push({ client, sid: session.id }); + log('source session', session); + + const compactRequest = { instruction: ' focus on decisions ' }; + log('request', { + method: 'POST', + path: `/api/v1/sessions/${session.id}:compact`, + body: compactRequest, + }); + + let compactError: unknown; + try { + const result = await client.compactSession(session.id, compactRequest); + log('response', result); + } catch (error) { + compactError = error; + } + if (compactError === undefined) { + throw new Error('expected compactSession to reject for an empty-history session'); + } + log('error response', errorForLog(compactError)); + + expect(compactError).toMatchObject({ + code: ErrorCode.COMPACTION_UNABLE, + reason: 'compaction.unable', + data: null, + }); + + const successLog = createCaseLogger('client: compact populated history'); + const populated = await client.createSession({ metadata: { cwd: process.cwd() } }); + created.push({ client, sid: populated.id }); + successLog('source session', populated); + + await client.connect(); + await client.subscribe(populated.id); + successLog('subscribe accepted', { session_id: populated.id }); + + const promptResult = await client.submitAndWait( + populated.id, + { + content: [ + { + type: 'text', + text: 'Remember this compact-test fact: the code word is BLUE. Reply with "OK".', + }, + ], + }, + { waitFor: 'prompt.completed', timeoutMs: PROMPT_TIMEOUT_MS }, + ); + successLog('seed prompt completed', { + prompt_id: promptResult.prompt_id, + user_message_id: promptResult.user_message_id, + final_frame: frameForLog(promptResult.finalFrame), + }); + + const beforeCompact = await client.listMessages(populated.id, { page_size: 100 }); + successLog('messages before compact', beforeCompact); + expect(beforeCompact.items.some((m) => m.role === 'user')).toBe(true); + expect(beforeCompact.items.some((m) => m.role === 'assistant')).toBe(true); + + const populatedCompactRequest = { + instruction: 'Preserve the compact-test code word and the fact that the assistant replied OK.', + }; + successLog('request', { + method: 'POST', + path: `/api/v1/sessions/${populated.id}:compact`, + body: populatedCompactRequest, + }); + + const completedPromise = client.waitForFrame( + (f) => f.type === 'compaction.completed' && f.session_id === populated.id, + { timeoutMs: PROMPT_TIMEOUT_MS }, + ); + const compactResponse = await client.compactSession(populated.id, populatedCompactRequest); + successLog('rest response', compactResponse); + const completedFrame = await completedPromise; + successLog('compaction completed frame', frameForLog(completedFrame)); + + const afterCompact = await client.listMessages(populated.id, { page_size: 100 }); + successLog('messages after compact', afterCompact); + + const compactedText = afterCompact.items + .flatMap((m) => m.content) + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join('\n'); + successLog('compacted text content', { text: compactedText }); + + expect(afterCompact.items.length).toBeGreaterThan(0); + expect(compactedText.length).toBeGreaterThan(0); + }, + PROMPT_TIMEOUT_MS + 30_000, + ); + + it( + 'undoSession removes the latest prompt and returns refreshed messages plus status', + async () => { + const log = createCaseLogger('client: undo action'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + const session = await client.createSession({ metadata: { cwd: process.cwd() } }); + created.push({ client, sid: session.id }); + log('source session', session); + + await client.connect(); + await client.subscribe(session.id); + log('subscribe accepted', { session_id: session.id }); + + const keepPrompt = await client.submitAndWait( + session.id, + { content: [{ type: 'text', text: 'Remember KEEP. Reply with "OK".' }] }, + { waitFor: 'prompt.completed', timeoutMs: PROMPT_TIMEOUT_MS }, + ); + log('keep prompt completed', { + prompt_id: keepPrompt.prompt_id, + user_message_id: keepPrompt.user_message_id, + final_frame: frameForLog(keepPrompt.finalFrame), + }); + + const undoPrompt = await client.submitAndWait( + session.id, + { content: [{ type: 'text', text: 'Remember UNDO-ME. Reply with "OK".' }] }, + { waitFor: 'prompt.completed', timeoutMs: PROMPT_TIMEOUT_MS }, + ); + log('undo prompt completed', { + prompt_id: undoPrompt.prompt_id, + user_message_id: undoPrompt.user_message_id, + final_frame: frameForLog(undoPrompt.finalFrame), + }); + + const beforeUndo = await client.listMessages(session.id, { page_size: 100 }); + log('messages before undo', beforeUndo); + expect(textFromMessages(beforeUndo.items)).toContain('UNDO-ME'); + + const result = await client.undoSession(session.id, { count: 1, page_size: 100 }); + log('undo response', result); + + const afterText = textFromMessages(result.messages.items); + expect(afterText).toContain('KEEP'); + expect(afterText).not.toContain('UNDO-ME'); + expect(result.messages.has_more).toBe(false); + expect(result.status.context_tokens).toBeGreaterThanOrEqual(0); + }, + PROMPT_TIMEOUT_MS * 2 + 30_000, + ); +}); + +describe('DaemonClient session action helpers', () => { + it('forkSession posts the action-suffix route and unwraps the returned session', async () => { + const log = createCaseLogger('client helper: forkSession'); + const calls: FetchCall[] = []; + const fork = testSession({ id: 'sess_fork', title: 'Fork: Source session' }); + const client = new DaemonClient({ + baseUrl: 'http://server.example.test', + fetchImpl: recordingFetch(okEnvelope(fork), calls), + }); + + const result = await client.forkSession('sess_source', { + title: 'Custom fork', + metadata: { child: true }, + }); + + log('fetch calls', calls); + log('unwrapped result', result); + expect(result).toEqual(fork); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe('http://server.example.test/api/v1/sessions/sess_source:fork'); + expect(calls[0]?.init.method).toBe('POST'); + expect(parseRecordedJsonBody(calls[0])).toEqual({ + title: 'Custom fork', + metadata: { child: true }, + }); + }); + + it('compactSession posts the action-suffix route and preserves error envelope details', async () => { + const log = createCaseLogger('client helper: compactSession'); + const calls: FetchCall[] = []; + const client = new DaemonClient({ + baseUrl: 'http://server.example.test', + fetchImpl: recordingFetch( + { + code: ErrorCode.COMPACTION_UNABLE, + msg: 'No prefix can be compacted.', + data: null, + request_id: 'req_test', + }, + calls, + ), + }); + + let caughtError: unknown; + try { + await client.compactSession('sess_source', { instruction: ' focus on decisions ' }); + } catch (error) { + caughtError = error; + } + if (caughtError === undefined) { + throw new Error('expected compactSession to reject for a non-zero envelope'); + } + + log('fetch calls', calls); + log('error response', errorForLog(caughtError)); + expect(caughtError).toMatchObject({ + code: ErrorCode.COMPACTION_UNABLE, + reason: 'compaction.unable', + requestId: 'req_test', + data: null, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe('http://server.example.test/api/v1/sessions/sess_source:compact'); + expect(calls[0]?.init.method).toBe('POST'); + expect(parseRecordedJsonBody(calls[0])).toEqual({ + instruction: ' focus on decisions ', + }); + }); + + it('undoSession posts the action-suffix route and unwraps messages plus status', async () => { + const log = createCaseLogger('client helper: undoSession'); + const calls: FetchCall[] = []; + const message = testMessage({ id: 'msg_kept', session_id: 'sess_source' }); + const undoResponse = { + messages: { items: [message], has_more: false }, + status: testSessionStatus(), + }; + const client = new DaemonClient({ + baseUrl: 'http://server.example.test', + fetchImpl: recordingFetch(okEnvelope(undoResponse), calls), + }); + + const result = await client.undoSession('sess_source', { count: 2, page_size: 25 }); + + log('fetch calls', calls); + log('unwrapped result', result); + expect(result).toEqual(undoResponse); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe('http://server.example.test/api/v1/sessions/sess_source:undo'); + expect(calls[0]?.init.method).toBe('POST'); + expect(parseRecordedJsonBody(calls[0])).toEqual({ + count: 2, + page_size: 25, + }); + }); + + it('model catalog helpers call the catalog and action-suffix routes', async () => { + const log = createCaseLogger('client helper: model catalog'); + const calls: FetchCall[] = []; + const model = testModel({ model: 'kimi-code/kimi-for-coding' }); + const provider = testProvider({ id: 'kimi', models: [model.model] }); + const client = new DaemonClient({ + baseUrl: 'http://server.example.test', + fetchImpl: recordingFetchSequence( + [ + okEnvelope({ + models_ready: true, + providers_count: 1, + managed_provider: null, + }), + okEnvelope({ items: [model] }), + okEnvelope({ default_model: model.model, model }), + okEnvelope({ items: [provider] }), + okEnvelope(provider), + ], + calls, + ), + }); + + await expect(client.getAuth()).resolves.toMatchObject({ models_ready: true }); + await expect(client.listModels()).resolves.toEqual({ items: [model] }); + await expect(client.setDefaultModel(model.model)).resolves.toEqual({ + default_model: model.model, + model, + }); + await expect(client.listProviders()).resolves.toEqual({ items: [provider] }); + await expect(client.getProvider('kimi')).resolves.toEqual(provider); + + log('fetch calls', calls); + expect(calls.map((call) => [call.init.method, call.url])).toEqual([ + ['GET', 'http://server.example.test/api/v1/auth'], + ['GET', 'http://server.example.test/api/v1/models'], + ['POST', 'http://server.example.test/api/v1/models/kimi-code%2Fkimi-for-coding:set_default'], + ['GET', 'http://server.example.test/api/v1/providers'], + ['GET', 'http://server.example.test/api/v1/providers/kimi'], + ]); + expect(parseRecordedJsonBody(calls[2])).toEqual({}); + }); + + it('child-session and pending reverse-RPC helpers call recovery routes', async () => { + const log = createCaseLogger('client helper: children + pending'); + const calls: FetchCall[] = []; + const child = testSession({ id: 'sess_child', title: 'Child session' }); + const client = new DaemonClient({ + baseUrl: 'http://server.example.test', + fetchImpl: recordingFetchSequence( + [ + okEnvelope(child), + okEnvelope({ items: [child], has_more: false }), + okEnvelope({ items: [] }), + okEnvelope({ items: [] }), + okEnvelope({ dismissed: true, dismissed_at: '2026-06-09T00:00:00.000Z' }), + ], + calls, + ), + }); + + await expect( + client.createChild('sess_parent', { + title: 'Child session', + metadata: { topic: 'side-question' }, + }), + ).resolves.toEqual(child); + await expect( + client.listChildren('sess_parent', { page_size: 5, busy: false }), + ).resolves.toEqual({ items: [child], has_more: false }); + await expect(client.listPendingApprovals('sess_parent')).resolves.toEqual({ items: [] }); + await expect(client.listPendingQuestions('sess_parent')).resolves.toEqual({ items: [] }); + await expect(client.dismissQuestion('sess_parent', 'question_1')).resolves.toEqual({ + dismissed: true, + dismissed_at: '2026-06-09T00:00:00.000Z', + }); + + log('fetch calls', calls); + expect(calls.map((call) => [call.init.method, call.url])).toEqual([ + ['POST', 'http://server.example.test/api/v1/sessions/sess_parent/children'], + ['GET', 'http://server.example.test/api/v1/sessions/sess_parent/children?page_size=5&busy=false'], + ['GET', 'http://server.example.test/api/v1/sessions/sess_parent/approvals?status=pending'], + ['GET', 'http://server.example.test/api/v1/sessions/sess_parent/questions?status=pending'], + ['POST', 'http://server.example.test/api/v1/sessions/sess_parent/questions/question_1:dismiss'], + ]); + expect(parseRecordedJsonBody(calls[0])).toEqual({ + title: 'Child session', + metadata: { topic: 'side-question' }, + }); + expect(parseRecordedJsonBody(calls[4])).toEqual({}); + }); + + it('uploadFile posts multipart form data and deleteFile hits the file route', async () => { + const log = createCaseLogger('client helper: file upload'); + const calls: FetchCall[] = []; + const file = testFile({ id: 'file_png', name: 'tiny.png', media_type: 'image/png', size: 3 }); + const client = new DaemonClient({ + baseUrl: 'http://server.example.test', + fetchImpl: recordingFetchSequence( + [ + okEnvelope(file), + okEnvelope({ deleted: true }), + ], + calls, + ), + }); + + await expect( + client.uploadFile({ + name: 'tiny.png', + data: new Uint8Array([1, 2, 3]), + mediaType: 'image/png', + expiresInSec: 60, + }), + ).resolves.toEqual(file); + await expect(client.deleteFile(file.id)).resolves.toEqual({ deleted: true }); + + log('fetch calls', calls); + expect(calls.map((call) => [call.init.method, call.url])).toEqual([ + ['POST', 'http://server.example.test/api/v1/files'], + ['DELETE', 'http://server.example.test/api/v1/files/file_png'], + ]); + const form = calls[0]?.init.body; + expect(form).toBeInstanceOf(FormData); + const upload = form as FormData; + expect(upload.get('name')).toBe('tiny.png'); + expect(upload.get('expires_in_sec')).toBe('60'); + const filePart = upload.get('file'); + expect(filePart).toBeInstanceOf(Blob); + expect((filePart as Blob).type).toBe('image/png'); + expect((filePart as Blob).size).toBe(3); + }); +}); + +interface FetchCall { + url: string; + init: RequestInit; +} + +function recordingFetch(responseBody: unknown, calls: FetchCall[]): typeof fetch { + return (async (input: Parameters[0], init?: Parameters[1]) => { + calls.push({ url: fetchInputUrl(input), init: init ?? {} }); + return new Response(JSON.stringify(responseBody), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; +} + +function recordingFetchSequence(responseBodies: unknown[], calls: FetchCall[]): typeof fetch { + let index = 0; + return (async (input: Parameters[0], init?: Parameters[1]) => { + calls.push({ url: fetchInputUrl(input), init: init ?? {} }); + const responseBody = responseBodies[Math.min(index, responseBodies.length - 1)]; + index++; + return new Response(JSON.stringify(responseBody), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; +} + +function fetchInputUrl(input: Parameters[0]): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +function parseRecordedJsonBody(call: FetchCall | undefined): unknown { + const body = call?.init.body; + if (typeof body !== 'string') { + throw new TypeError('expected recorded fetch body to be a JSON string'); + } + return JSON.parse(body) as unknown; +} + +function okEnvelope(data: T): { code: 0; msg: string; data: T; request_id: string } { + return { code: 0, msg: 'success', data, request_id: 'req_test' }; +} + +function frameForLog(frame: { + type: string; + seq?: number; + session_id?: string; + id?: string; + code?: number; + msg?: string; + payload?: unknown; +}): Record { + return { + type: frame.type, + seq: frame.seq, + session_id: frame.session_id, + id: frame.id, + code: frame.code, + msg: frame.msg, + payload: frame.payload, + }; +} + +function testSession(overrides: Partial = {}): Session { + const base: Session = { + id: 'sess_example', + workspace_id: 'wd_example_0123456789ab', + title: 'Example session', + created_at: '2026-06-09T00:00:00.000Z', + updated_at: '2026-06-09T00:00:00.000Z', + busy: false, + metadata: { cwd: '/tmp/example-server-e2e' }, + 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, + }; + return { + ...base, + ...overrides, + metadata: { ...base.metadata, ...overrides.metadata }, + }; +} + +function testModel(overrides: Partial = {}): ModelCatalogItem { + return { + provider: 'kimi', + model: 'k2', + display_name: 'Kimi K2', + max_context_size: 131_072, + ...overrides, + }; +} + +function testProvider(overrides: Partial = {}): ProviderCatalogItem { + return { + id: 'kimi', + type: 'kimi', + base_url: 'https://api.example.test/v1', + default_model: 'k2', + has_api_key: true, + status: 'connected', + models: ['k2'], + ...overrides, + }; +} + +function testFile(overrides: Partial = {}): FileMeta { + return { + id: 'file_example', + name: 'example.txt', + media_type: 'text/plain', + size: 0, + created_at: '2026-06-09T00:00:00.000Z', + ...overrides, + }; +} + +function testMessage(overrides: Partial = {}): Message { + return { + id: 'msg_example', + session_id: 'sess_example', + role: 'user', + content: [{ type: 'text', text: 'kept' }], + created_at: '2026-06-09T00:00:00.000Z', + ...overrides, + }; +} + +function testSessionStatus(): SessionStatusResponse { + return { + busy: false, + model: 'kimi-code/kimi-for-coding', + thinking_level: 'off', + permission: 'manual', + plan_mode: false, + swarm_mode: false, + context_tokens: 0, + max_context_tokens: 100, + context_usage: 0, + }; +} + +function textFromMessages(messages: Array<{ content: Array<{ type: string; text?: string }> }>): string { + return messages + .flatMap((message) => message.content) + .filter((part) => part.type === 'text') + .map((part) => part.text ?? '') + .join('\n'); +} diff --git a/packages/klient/test/e2e/legacy/image-file-prompts.test.ts b/packages/klient/test/e2e/legacy/image-file-prompts.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..abd64526f577d87f5b7d3f5142d8e12f97d2cac0 --- /dev/null +++ b/packages/klient/test/e2e/legacy/image-file-prompts.test.ts @@ -0,0 +1,146 @@ +/** + * Live-server invariant for uploaded image files in prompt content (v1 REST + * surface only — the facade has no file-upload method): + * - a missing prompt image `file_id` returns `FILE_NOT_FOUND`; + * - a non-image uploaded file used as image content returns `VALIDATION_FAILED`; + * - an uploaded PNG can be referenced by a prompt submission, and the + * prompt can be aborted (or was already terminal). + * + * Converted from the retired scenario `09-image-file-prompts.ts`. Skips when + * no server is reachable at `KIMI_SERVER_URL`. + */ +import { describe, expect, it } from 'vitest'; + +import { ErrorCode } from '@moonshot-ai/kap-server/protocol/error-codes'; + +import { DaemonClient, EnvelopeError } from '../harness/index.js'; +import { fetchWithReport } from '../harness/report.js'; +import { createCaseLogger } from './log.js'; + +const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; +const API_PREFIX = '/api/v1'; +const SHORT_TIMEOUT_MS = 15_000; + +const ONE_BY_ONE_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +async function daemonReachable(): Promise { + try { + const res = await fetchWithReport(`${BASE_URL}${API_PREFIX}/meta`, { + signal: AbortSignal.timeout(500), + }); + return res.ok; + } catch { + return false; + } +} + +const reachable = await daemonReachable(); +const describeLive = reachable ? describe : describe.skip; + +describeLive('legacy: image file prompts', () => { + it('missing/non-image files rejected, PNG accepted, prompt abortable', async () => { + const log = createCaseLogger('legacy: image file prompts'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + const files: string[] = []; + let sid: string | undefined; + + async function expectEnvelopeCode( + action: () => Promise, + code: ErrorCode, + label: string, + ): Promise { + let caught: unknown; + try { + await action(); + } catch (error) { + caught = error; + } + expect(caught, label).toBeInstanceOf(EnvelopeError); + expect((caught as EnvelopeError).code, label).toBe(code); + log(label, { code: (caught as EnvelopeError).code }); + } + + try { + const session = await client.createSession({ + title: 'klient-e2e image file prompts', + metadata: { cwd: process.cwd(), scenario: 'image-file-prompts' }, + }); + sid = session.id; + log('session created', { session_id: sid }); + + await expectEnvelopeCode( + () => + client.submitPrompt(sid!, { + content: [{ type: 'image', source: { kind: 'file', file_id: 'file_missing_e2e' } }], + }), + ErrorCode.FILE_NOT_FOUND, + 'missing prompt image file_id', + ); + + const textFile = await client.uploadFile({ + name: 'not-an-image.txt', + data: 'not an image', + mediaType: 'text/plain', + }); + files.push(textFile.id); + await expectEnvelopeCode( + () => + client.submitPrompt(sid!, { + content: [{ type: 'image', source: { kind: 'file', file_id: textFile.id } }], + }), + ErrorCode.VALIDATION_FAILED, + 'non-image prompt file_id', + ); + + const png = await client.uploadFile({ + name: 'tiny.png', + data: ONE_BY_ONE_PNG, + mediaType: 'image/png', + }); + files.push(png.id); + expect(png.media_type).toBe('image/png'); + expect(png.size).toBe(ONE_BY_ONE_PNG.length); + + const submit = await client.submitPrompt(sid, { + content: [ + { type: 'text', text: 'Reply with the single word "OK" after reading this image.' }, + { type: 'image', source: { kind: 'file', file_id: png.id } }, + ], + }); + expect(submit.prompt_id.length).toBeGreaterThan(0); + log('prompt submitted', { file_id: png.id, prompt_id: submit.prompt_id }); + + try { + await client.abortPrompt(sid, submit.prompt_id); + log('prompt aborted', { prompt_id: submit.prompt_id }); + } catch (error) { + if ( + error instanceof EnvelopeError && + error.code === ErrorCode.PROMPT_NOT_FOUND + ) { + log('prompt already terminal before abort', { prompt_id: submit.prompt_id }); + } else { + throw error; + } + } + await client.waitForSessionBusy(sid, false, { timeoutMs: SHORT_TIMEOUT_MS }); + } finally { + for (const fileId of files.toReversed()) { + try { + await client.deleteFile(fileId); + } catch { + // ignore + } + } + try { + if (sid) await client.archiveSession(sid); + } catch { + // ignore + } + await client.close(); + } + }, 120_000); +}); diff --git a/packages/klient/test/e2e/legacy/log.ts b/packages/klient/test/e2e/legacy/log.ts new file mode 100644 index 0000000000000000000000000000000000000000..90cb2363244af664411d216d3001a1a22ea37dbd --- /dev/null +++ b/packages/klient/test/e2e/legacy/log.ts @@ -0,0 +1,59 @@ +import { onTestFailed, onTestFinished } from 'vitest'; + +import { recordReportEvent, setActiveReportCase } from '../harness/report.js'; + +export function createCaseLogger(caseName: string): (label: string, value?: unknown) => void { + setActiveReportCase(caseName); + let failed = false; + onTestFailed((error) => { + failed = true; + recordReportEvent({ + kind: 'test-result', + caseName, + state: 'failed', + error: errorForLog(error), + }); + }); + onTestFinished(() => { + if (failed) return; + recordReportEvent({ + kind: 'test-result', + caseName, + state: 'passed', + }); + }); + return (label, value) => { + recordReportEvent({ kind: 'log', caseName, label, value }); + const prefix = `[server-e2e] ${caseName} :: ${label}`; + if (value === undefined) { + writeLogLine(prefix); + return; + } + writeLogLine(`${prefix}\n${stringifyForLog(value)}`); + }; +} + +export function errorForLog(error: unknown): unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + ...objectFields(error), + }; + } + return error; +} + +function objectFields(value: object): Record { + return Object.fromEntries( + Object.entries(value).filter(([, field]) => field !== undefined), + ); +} + +function stringifyForLog(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +function writeLogLine(line: string): void { + process.stdout.write(`${line}\n`); +} diff --git a/packages/klient/test/e2e/legacy/prompt-queue-steer.test.ts b/packages/klient/test/e2e/legacy/prompt-queue-steer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ce57ab2be11faac7e798076db63ac96278f7b1b --- /dev/null +++ b/packages/klient/test/e2e/legacy/prompt-queue-steer.test.ts @@ -0,0 +1,197 @@ +/** + * Prompt queue + steer live-server invariant. + * + * Drives the TUI Ctrl-S equivalent over server REST + WS: + * 1. use the debug-only prompt test hook to mark one prompt active; + * 2. submit a second prompt and assert it is queued instead of rejected; + * 3. list prompts and assert active + queued state; + * 4. steer the queued prompt and assert `prompt.steered` is broadcast and + * the queue is drained. + * + * Requires a server launched with debug endpoints enabled. Normal production + * daemons do not expose `/debug/*`, so this file skips when that surface is + * absent. + */ +import { afterEach, describe, expect, it } from 'vitest'; + +import { DaemonClient, type AnyFrame } from '../harness/index.js'; +import { fetchWithReport } from '../harness/report.js'; +import { createCaseLogger } from './log.js'; + +const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; +const API_PREFIX = '/api/v1'; +const SHORT_TIMEOUT_MS = 15_000; + +interface PromptSteeredPayload { + type: 'prompt.steered'; + sessionId: string; + activePromptId: string; + promptIds: string[]; + content: unknown[]; + steeredAt: string; +} + +async function daemonReachable(): Promise { + try { + const res = await fetchWithReport(`${BASE_URL}${API_PREFIX}/meta`, { + signal: AbortSignal.timeout(500), + }); + return res.ok; + } catch { + return false; + } +} + +async function debugPromptsReachable(): Promise { + try { + const res = await fetchWithReport( + `${BASE_URL}${API_PREFIX}/debug/prompts/debug_probe/state`, + { signal: AbortSignal.timeout(500) }, + ); + return res.ok; + } catch { + return false; + } +} + +const reachable = await daemonReachable(); +const debugReachable = reachable && await debugPromptsReachable(); +const describeLive = debugReachable ? describe : describe.skip; + +const created: Array<{ client: DaemonClient; sid: string; promptIds: string[] }> = []; + +afterEach(async () => { + for (const { client, sid, promptIds } of created.splice(0)) { + for (const promptId of promptIds.toReversed()) { + try { + await client.abortPrompt(sid, promptId); + } catch { + // ignore + } + } + try { + await client.archiveSession(sid); + } catch { + // ignore + } + try { + await client.close(); + } catch { + // ignore + } + } +}); + +describeLive('prompt queue + steer (live server required)', () => { + it( + 'queues a busy prompt and steers it into the active turn', + async () => { + const log = createCaseLogger('prompt queue: steer'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + const session = await client.createSession({ + title: 'server-e2e prompt queue steer', + metadata: { cwd: process.cwd(), scenario: 'prompt-queue-steer' }, + }); + const cleanup: { client: DaemonClient; sid: string; promptIds: string[] } = { + client, + sid: session.id, + promptIds: [], + }; + created.push(cleanup); + log('created session', session); + + await client.connect(); + await client.subscribe(session.id); + log('subscribe accepted', { session_id: session.id }); + + const active = await injectActivePrompt(session.id, { + prompt_id: `prompt_debug_queue_steer_${process.pid}`, + }); + log('debug active prompt injected', active); + cleanup.promptIds.push(active.prompt_id); + + const queued = await client.submitPrompt(session.id, { + content: [ + { + type: 'text', + text: 'This queued prompt should be steered into the active turn.', + }, + ], + }); + log('queued prompt submitted', queued); + cleanup.promptIds.push(queued.prompt_id); + expect(queued.status).toBe('queued'); + + const listedBefore = await client.listPrompts(session.id); + log('prompt list before steer', listedBefore); + expect(listedBefore.active?.prompt_id).toBe(active.prompt_id); + expect(listedBefore.queued.map((prompt) => prompt.prompt_id)).toEqual([ + queued.prompt_id, + ]); + + const steerFramePromise = client.waitForFrame(isPromptSteeredFor(session.id, queued.prompt_id), { + timeoutMs: SHORT_TIMEOUT_MS, + }); + const steer = await client.steerPrompt(session.id, queued.prompt_id); + log('steer response', steer); + expect(steer).toEqual({ steered: true, prompt_ids: [queued.prompt_id] }); + + const steerFrame = await steerFramePromise; + const steered = payloadOf(steerFrame); + log('prompt.steered frame', { + frame: frameForLog(steerFrame), + steered, + }); + expect(steered.activePromptId).toBe(active.prompt_id); + expect(steered.promptIds).toEqual([queued.prompt_id]); + + const listedAfter = await client.listPrompts(session.id); + log('prompt list after steer', listedAfter); + expect(listedAfter.queued).toHaveLength(0); + }, + SHORT_TIMEOUT_MS + 30_000, + ); +}); + +function isPromptSteeredFor(sid: string, promptId: string): (frame: AnyFrame) => boolean { + return (frame) => { + if (frame.type !== 'prompt.steered' || frame.session_id !== sid) return false; + const payload = frame.payload as { promptIds?: string[] } | undefined; + return payload?.promptIds?.includes(promptId) === true; + }; +} + +function payloadOf(frame: AnyFrame): T { + expect(frame.payload, `${frame.type} frame should carry payload`).toBeDefined(); + return frame.payload as T; +} + +function frameForLog(frame: AnyFrame): Record { + return { + type: frame.type, + seq: frame.seq, + session_id: frame.session_id, + payload: frame.payload, + }; +} + +async function injectActivePrompt( + sid: string, + body: { prompt_id: string }, +): Promise<{ prompt_id: string }> { + const res = await fetchWithReport( + `${BASE_URL}${API_PREFIX}/debug/prompts/${encodeURIComponent(sid)}/active`, + { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify(body), + }, + ); + const envelope = await res.json() as { + code: number; + msg: string; + data: { prompt_id: string }; + }; + expect(envelope.code, envelope.msg).toBe(0); + return envelope.data; +} diff --git a/packages/klient/test/e2e/legacy/refresh-replay.test.ts b/packages/klient/test/e2e/legacy/refresh-replay.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a001114b2de0479c557bed82dac3d2a6996476c --- /dev/null +++ b/packages/klient/test/e2e/legacy/refresh-replay.test.ts @@ -0,0 +1,363 @@ +/** + * Refresh / reload wire-level invariants. + * + * Models the page-refresh path a web client takes when the server is already + * up: hit `/healthz`, `/meta`, `/auth`, then open a fresh WebSocket and replay + * any missed events via `client_hello.cursors` BEFORE pulling REST history + * (REST.md §3 + WS.md §3.2). + * + * What's asserted here (and NOT in `client.test.ts`): + * 1. `/healthz` returns `{ok: true}`. + * 2. `/meta` exposes a non-empty `server_id`. (Since the v2 sync protocol, + * cursors carry a journal `epoch` and seq is durable across restarts — + * a stale cursor is detected server-side via `epoch_changed` instead of + * clients comparing `server_id`.) + * 3. `/auth` returns the `AuthSummary` shape. + * 4. After running one prompt to populate the journal, a fresh WS that + * passes `cursors: { [sid]: { seq: currentSeq } }` is acked with + * `accepted_subscriptions: [sid]`, `resync_required: []`, and NO event + * frames arrive between `server_hello` and the ack (caught-up replay). + * 5. A fresh WS that passes `cursors: { [sid]: { seq: 0 } }` triggers + * replay of every durable event in order (seq 1..N) BEFORE the ack. + * Volatile frames (deltas/progress/status) are never replayed. + * 6. After reconnect, `GET /messages` reflects the persisted state from + * before the WS close. + * + * Live-server gated via the same `daemonReachable()` check as + * `client.test.ts`; missing server → tests skip cleanly so CI stays green. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { WebSocket as WsWebSocket } from 'ws'; + +import { DaemonClient, WsClient, type AnyFrame } from '../harness/index.js'; +import { fetchWithReport } from '../harness/report.js'; +import { createCaseLogger } from './log.js'; + +const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; +const API_PREFIX = '/api/v1'; +const HANDSHAKE_TIMEOUT_MS = 5_000; +const PROMPT_TIMEOUT_MS = 120_000; + +async function daemonReachable(): Promise { + try { + const res = await fetchWithReport(`${BASE_URL}${API_PREFIX}/meta`, { + signal: AbortSignal.timeout(500), + }); + return res.ok; + } catch { + return false; + } +} + +interface Envelope { + code: number; + msg?: string; + data: T; + request_id?: string; +} + +async function getEnvelope( + path: string, + log?: (label: string, value?: unknown) => void, +): Promise { + const res = await fetchWithReport(`${BASE_URL}${API_PREFIX}${path}`, { + headers: { accept: 'application/json' }, + }); + const body = (await res.json()) as Envelope; + log?.('http envelope', { + method: 'GET', + path: `${API_PREFIX}${path}`, + status: res.status, + body, + }); + expect(typeof body.code, `${path} missing envelope.code`).toBe('number'); + expect(body.code, `${path} returned code=${body.code} msg=${body.msg ?? ''}`).toBe(0); + return body.data; +} + +interface HelloResult { + ws: WsClient; + ack: AnyFrame; + replayed: AnyFrame[]; +} + +async function openSocketWithHello(opts: { + sid: string; + lastSeq?: number; + clientId?: string; + log?: (label: string, value?: unknown) => void; +}): Promise { + const wsUrl = `${BASE_URL.replace(/^http/, 'ws')}${API_PREFIX}/ws`; + const ws = new WsClient({ url: wsUrl, wsImpl: WsWebSocket, logger: () => {} }); + opts.log?.('refresh ws open', { url: wsUrl, sid: opts.sid, last_seq: opts.lastSeq }); + await ws.open(); + + const arrivals: AnyFrame[] = []; + ws.onFrame((f) => arrivals.push(f)); + + const serverHello = await ws.waitForFrame((f) => f.type === 'server_hello', HANDSHAKE_TIMEOUT_MS); + opts.log?.('refresh ws server_hello', frameForLog(serverHello)); + + const helloId = `hello-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const payload: Record = { + client_id: opts.clientId ?? `vitest-refresh-${process.pid}`, + subscriptions: [opts.sid], + }; + if (opts.lastSeq !== undefined) { + payload['cursors'] = { [opts.sid]: { seq: opts.lastSeq } }; + } + opts.log?.('refresh ws client_hello', { id: helloId, payload }); + ws.send({ type: 'client_hello', id: helloId, payload }); + + const ack = await ws.waitForFrame( + (f) => f.type === 'ack' && f.id === helloId, + HANDSHAKE_TIMEOUT_MS, + ); + opts.log?.('refresh ws ack', frameForLog(ack)); + + const replayed = arrivals.filter( + (f) => + f.type !== 'server_hello' && + f.type !== 'ack' && + f.type !== 'ping' && + f.type !== 'resync_required' && + f.type !== 'error' && + typeof f.seq === 'number' && + f.session_id === opts.sid && + (opts.lastSeq === undefined || f.seq > opts.lastSeq), + ); + opts.log?.('refresh ws replayed', { + count: replayed.length, + frames: replayed.map(frameForLog), + }); + + return { ws, ack, replayed }; +} + +const reachable = await daemonReachable(); +const describeLive = reachable ? describe : describe.skip; + +const created: Array<{ client: DaemonClient; sid: string }> = []; +const sockets: WsClient[] = []; + +afterEach(async () => { + for (const ws of sockets.splice(0)) { + try { + await ws.close(); + } catch { + // ignore + } + } + for (const { client, sid } of created.splice(0)) { + try { + await client.http.archiveSession(sid); + } catch { + // ignore + } + try { + await client.close(); + } catch { + // ignore + } + } +}); + +describeLive('refresh-replay (live server required)', () => { + it('phase 0: /healthz returns ok:true', async () => { + const log = createCaseLogger('refresh: healthz'); + const health = await getEnvelope<{ ok: boolean }>('/healthz', log); + log('data', health); + expect(health.ok).toBe(true); + }); + + it('phase 0: /meta exposes server_id, version, started_at', async () => { + const log = createCaseLogger('refresh: meta'); + const meta = await getEnvelope<{ + server_id: string; + server_version: string; + started_at: string; + capabilities: Record; + }>('/meta', log); + log('data', meta); + expect(meta.server_id).toMatch(/.+/); + expect(meta.server_version).toMatch(/.+/); + expect(meta.started_at).toMatch(/.+/); + expect(meta.capabilities['websocket']).toBe(true); + }); + + it('phase 0: /auth returns AuthSummary shape', async () => { + const log = createCaseLogger('refresh: auth'); + const auth = await getEnvelope<{ + models_ready: boolean; + providers_count: number; + managed_provider: { name: string; status: string } | null; + }>('/auth', log); + log('data', auth); + expect(typeof auth.models_ready).toBe('boolean'); + expect(typeof auth.providers_count).toBe('number'); + }); + + it( + 'reconnect with caught-up last_seq → ack accepts subscription, no replay events', + async () => { + const log = createCaseLogger('refresh: caught-up replay'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + const session = await client.createSession({ metadata: { cwd: process.cwd() } }); + created.push({ client, sid: session.id }); + log('created session', session); + + await client.connect(); + await client.subscribe(session.id); + log('initial subscribe accepted', { session_id: session.id }); + + let maxSeq = 0; + client.onFrame((f) => { + if ( + typeof f.seq === 'number' && + f.session_id === session.id && + f.seq > maxSeq + ) { + maxSeq = f.seq; + } + }); + + const { finalFrame } = await client.submitAndWait( + session.id, + { content: [{ type: 'text', text: 'Reply with the single word "OK" and nothing else.' }] }, + { waitFor: 'prompt.completed', timeoutMs: PROMPT_TIMEOUT_MS }, + ); + log('prompt completed frame', frameForLog(finalFrame)); + if (typeof finalFrame.seq === 'number' && finalFrame.seq > maxSeq) { + maxSeq = finalFrame.seq; + } + log('max seq before reconnect', { session_id: session.id, max_seq: maxSeq }); + expect(maxSeq, 'session must publish at least one event before reconnect').toBeGreaterThan(0); + + await client.close(); + log('closed initial socket'); + + const refreshed = await openSocketWithHello({ sid: session.id, lastSeq: maxSeq, log }); + sockets.push(refreshed.ws); + + expect(refreshed.ack.code).toBe(0); + const payload = (refreshed.ack.payload ?? {}) as { + accepted_subscriptions?: string[]; + resync_required?: string[]; + }; + expect(payload.accepted_subscriptions ?? []).toEqual([session.id]); + expect(payload.resync_required ?? []).toEqual([]); + expect( + refreshed.replayed, + `expected 0 replay events when caught up, got: ${JSON.stringify(refreshed.replayed.map((f) => `${f.type}@${f.seq}`))}`, + ).toHaveLength(0); + log('asserted caught-up replay result', { + accepted_subscriptions: payload.accepted_subscriptions ?? [], + resync_required: payload.resync_required ?? [], + replayed_count: refreshed.replayed.length, + }); + }, + PROMPT_TIMEOUT_MS + 30_000, + ); + + it( + 'reconnect with last_seq=0 → server replays buffered events in order before ack', + async () => { + const log = createCaseLogger('refresh: replay from zero'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + const session = await client.createSession({ metadata: { cwd: process.cwd() } }); + created.push({ client, sid: session.id }); + log('created session', session); + + await client.connect(); + await client.subscribe(session.id); + log('initial subscribe accepted', { session_id: session.id }); + + let maxSeq = 0; + client.onFrame((f) => { + if ( + typeof f.seq === 'number' && + f.session_id === session.id && + f.seq > maxSeq + ) { + maxSeq = f.seq; + } + }); + + const { finalFrame } = await client.submitAndWait( + session.id, + { content: [{ type: 'text', text: 'Reply with the single word "OK" and nothing else.' }] }, + { waitFor: 'prompt.completed', timeoutMs: PROMPT_TIMEOUT_MS }, + ); + log('prompt completed frame', frameForLog(finalFrame)); + if (typeof finalFrame.seq === 'number' && finalFrame.seq > maxSeq) { + maxSeq = finalFrame.seq; + } + log('max seq before reconnect', { session_id: session.id, max_seq: maxSeq }); + expect(maxSeq).toBeGreaterThan(0); + + await client.close(); + log('closed initial socket'); + + const refreshed = await openSocketWithHello({ sid: session.id, lastSeq: 0, log }); + sockets.push(refreshed.ws); + + expect(refreshed.ack.code).toBe(0); + const payload = (refreshed.ack.payload ?? {}) as { + accepted_subscriptions?: string[]; + resync_required?: string[]; + }; + expect(payload.accepted_subscriptions ?? []).toEqual([session.id]); + // Buffer cap defaults to 1000; a single prompt emits <<1000 events, so + // every event is still in the ring → no resync_required. + expect(payload.resync_required ?? []).toEqual([]); + expect(refreshed.replayed.length).toBeGreaterThan(0); + + const seqs = refreshed.replayed + .map((f) => f.seq) + .filter((n): n is number => typeof n === 'number'); + expect(Math.min(...seqs)).toBe(1); + expect(Math.max(...seqs)).toBe(maxSeq); + // Daemon must dispatch buffered events in seq order + // (eventService.getBufferedSince filters the buffer in insertion order). + const sorted = seqs.toSorted((a, b) => a - b); + expect(seqs).toEqual(sorted); + log('replay seq assertion', { + min_seq: Math.min(...seqs), + max_seq: Math.max(...seqs), + expected_max_seq: maxSeq, + replayed_count: refreshed.replayed.length, + }); + + // Phase 2: REST snapshot reflects the persisted user + assistant pair. + const { items } = await client.http.listMessages(session.id, { page_size: 100 }); + log('messages snapshot', { + count: items.length, + roles: items.map((m) => m.role), + messages: items, + }); + expect(items.some((m) => m.role === 'user')).toBe(true); + expect(items.some((m) => m.role === 'assistant')).toBe(true); + + // `GET /tasks` returns the documented `{items:[]}` envelope shape. + const tasks = await getEnvelope<{ items: unknown[] }>( + `/sessions/${encodeURIComponent(session.id)}/tasks`, + log, + ); + log('tasks snapshot', tasks); + expect(Array.isArray(tasks.items)).toBe(true); + }, + PROMPT_TIMEOUT_MS + 30_000, + ); +}); + +function frameForLog(frame: AnyFrame): Record { + return { + type: frame.type, + seq: frame.seq, + session_id: frame.session_id, + id: frame.id, + code: frame.code, + msg: frame.msg, + payload: frame.payload, + }; +} diff --git a/packages/klient/test/e2e/legacy/report.test.ts b/packages/klient/test/e2e/legacy/report.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..26d4a5764253f607fefc15cc47a7f20955b32c4c --- /dev/null +++ b/packages/klient/test/e2e/legacy/report.test.ts @@ -0,0 +1,482 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { EventEmitter } from 'node:events'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; +import { WebSocket as WsWebSocket } from 'ws'; + +import { + fetchWithReport, + readReportEvents, + recordReportEvent, + resetReportDir, + setActiveReportCase, + writeHtmlReport, +} from '../harness/report.js'; +import { DaemonClient } from '../harness/client.js'; +import { HttpClient } from '../harness/http.js'; +import { WsClient } from '../harness/ws.js'; +import { createCaseLogger } from './log'; + +const tmpDirs: string[] = []; + +afterEach(() => { + for (const dir of tmpDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function tmpReportDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'server-e2e-report-')); + tmpDirs.push(dir); + return dir; +} + +describe('server-e2e report', () => { + it('renders HTTP and WS trace events into a readable HTML timeline', () => { + const reportDir = tmpReportDir(); + resetReportDir(reportDir); + + recordReportEvent( + { + kind: 'http', + caseName: 'refresh: replay from zero', + method: 'POST', + path: '/sessions', + status: 200, + durationMs: 14, + request: { body: { metadata: { cwd: '/tmp/workspace' } } }, + response: { + envelope: { + code: 0, + msg: 'success', + request_id: 'req_1', + data: { id: 'session_1' }, + }, + }, + }, + { reportDir }, + ); + recordReportEvent( + { + kind: 'ws', + caseName: 'refresh: replay from zero', + direction: 'lifecycle', + message: 'open', + url: 'ws://server.example.test/api/v1/ws', + }, + { reportDir }, + ); + recordReportEvent( + { + kind: 'ws', + caseName: 'refresh: replay from zero', + direction: 'in', + frame: { + type: 'prompt.completed', + seq: 3, + session_id: 'session_1', + payload: { promptId: 'prompt_1' }, + }, + }, + { reportDir }, + ); + recordReportEvent( + { + kind: 'log', + caseName: 'refresh: replay from zero', + label: 'fresh listSessions snapshot', + value: { + count: 20, + sessions: [{ id: 'session_1', title: 'New Session' }], + }, + }, + { reportDir }, + ); + + const htmlPath = writeHtmlReport({ reportDir, title: 'Daemon E2E Report' }); + const html = readFileSync(htmlPath, 'utf8'); + + expect(html).toContain('Daemon E2E Report'); + expect(html).toContain('refresh: replay from zero'); + expect(html).toContain('class="case-nav"'); + expect(html).toContain('class="case-link"'); + expect(html).toContain('Client -> Server'); + expect(html).toContain('Server -> Client'); + expect(html).toContain('class="detail-pane"'); + expect(html).toContain('id="event-scroll"'); + expect(html).toContain('id="detail-scroll"'); + expect(html).toContain('data-event-id="event-0"'); + expect(html).toContain('Step'); + expect(html).toContain('
    #1
    '); + expect(html).toContain('
    #2
    '); + expect(html).not.toContain('
    06:'); + expect(html).toContain('.report { display: grid; grid-template-columns: minmax(680px, 1fr) minmax(320px, 34vw);'); + expect(html).toContain('.lane-head { display: grid; grid-template-columns: 38px minmax(0, 1fr) 22px minmax(0, 1fr);'); + expect(html).toContain('.swim-row { display: grid; grid-template-columns: 38px minmax(0, 1fr) 22px minmax(0, 1fr); align-items: start; min-height: 30px; }'); + expect(html).toContain('border-radius: 0'); + expect(html).toContain('
  • fresh listSessions snapshot'); + expect(html).not.toContain(''); + expect(html).toContain('session_1'); + expect(html).toContain('scrollIntoView'); + expect(html).toContain('function setActiveEvent(eventId, options = {})'); + expect(html).toContain('if (!options.scrollPeer) return;'); + expect(html).toContain("setActiveEvent(nearest(rows, eventScroll)?.dataset.eventId, { scrollPeer: false });"); + expect(html).toContain("setActiveEvent(nearest(details, detailScroll)?.dataset.eventId, { scrollPeer: false });"); + expect(html).toContain("row.addEventListener('click', () => setActiveEvent(row.dataset.eventId, { scrollPeer: true, peer: 'detail' }));"); + expect(html).toContain("detail.addEventListener('click', () => setActiveEvent(detail.dataset.eventId, { scrollPeer: true, peer: 'timeline' }));"); + expect(html).not.toContain('syncing'); + expect(html).not.toContain("if (source === 'timeline')"); + expect(html).not.toContain("if (source === 'detail')"); + expect(html).not.toContain('WS -- open'); + expect(html).not.toMatch(/