diff --git a/AGENTS.md b/AGENTS.md index f8cdf7c107..b9f67fc5b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## Project Map -- `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). +- `apps/kimi-code`: the CLI / TUI application. The interactive TUI runs on `@moonshot-ai/agent-core-v2` through the in-app facade `src/core/` (`CoreHarness`/`CoreSession`); the remaining paths (`kimi -p`, ACP, misc subcommands) still consume v1 through `@moonshot-ai/kimi-code-sdk`. It must not depend directly on `@moonshot-ai/agent-core` (v1). When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). - `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). See `apps/kimi-web/AGENTS.md`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. diff --git a/apps/kimi-code/AGENTS.md b/apps/kimi-code/AGENTS.md index 11184d9588..a6137a96a7 100644 --- a/apps/kimi-code/AGENTS.md +++ b/apps/kimi-code/AGENTS.md @@ -8,16 +8,17 @@ This file only contains rules local to `apps/kimi-code`. For cross-repo rules, s `apps/kimi-code` is the terminal UI / CLI app. The entry chain is: -`src/main.ts` -> `src/cli/commands.ts` -> `src/cli/run-shell.ts` -> SDK `KimiHarness` -> `src/tui/kimi-tui.ts` +`src/main.ts` -> `src/cli/commands.ts` -> `src/cli/run-shell.ts` -> `src/core` `CoreHarness` (agent-core-v2 facade) -> `src/tui/kimi-tui.ts` Main directories: - `src/constant/`: non-copy constants shared by CLI/TUI — product, protocol, paths, terminal control, updates, and so on. - `src/cli/`: command-line arguments, subcommands, and CLI startup. +- `src/core/`: the TUI engine facade over `packages/agent-core-v2` — `CoreHarness` (app scope: bootstrap, session CRUD, config, plugins, telemetry) and `CoreSession` (session scope: prompts, modes, goals, tasks, event fan-in, approval/question pending). The only path through which the interactive TUI reaches the agent engine. Print (`src/cli/v2/`), ACP, and other subcommands do not use this module — they still consume `@moonshot-ai/kimi-code-sdk`. - `src/tui/`: the interactive terminal UI. - `src/tui/kimi-tui.ts`: the `KimiTUI` coordinator — wires state, layout, editor, session, SDK events, and dialogs together, and dispatches slash-command handlers. Heavy logic is delegated to `controllers/`, not accumulated here. - `src/tui/tui-state.ts`: `TUIState`, `createTUIState`, `createInitialAppState` — the single global UI-state shape. -- `src/tui/controllers/`: independently-testable responsibilities — `session-event-handler` (SDK event routing), `streaming-ui` (streaming render), `session-replay` (resume/replay), `tasks-browser`, `editor-keyboard`, `auth-flow`. +- `src/tui/controllers/`: independently-testable responsibilities — `session-event-handler` (core event routing), `streaming-ui` (streaming render), `session-replay` (resume/replay), `tasks-browser`, `editor-keyboard`, `auth-flow`. - `src/tui/commands/`: slash command definitions, parsing, ordering, and dynamic skill command generation. - `src/tui/components/`: pi-tui components, organized by UI type. - `src/tui/constant/`: non-copy constants reused across TUI modules — symbols, terminal sequences, render sizing, streaming-arg match rules, and so on. @@ -27,7 +28,7 @@ Main directories: - `src/tui/components/media/`: image, diff, code highlight, and other media displays. - `src/tui/components/messages/`: message blocks in the transcript — assistant, user, tool call, thinking, usage, subagent, and so on. - `src/tui/components/panes/`: right-side / activity-area panes such as the activity pane and queue pane. -- `src/tui/reverse-rpc/`: the adapter layer that bridges SDK approval/question callbacks to the UI. +- `src/tui/interactions/`: consumes the core approval/question pending model — watches `CoreSession.approvals`/`questions` pending lists, drives the approval/question panels, and writes `decide`/`answer`/`dismiss` back into the kernel. - `src/tui/theme/`: themes, color tokens, style helpers, terminal-background detection, and the pi-tui markdown theme. - `src/tui/utils/`: TUI-only utility functions. - `src/utils/`: app-wide utilities — clipboard, git, history, image, process, usage, and so on. @@ -35,14 +36,14 @@ Main directories: ## Module Responsibilities - `cli` only interprets command-line input, assembles startup arguments, and invokes the TUI. Do not put TUI interaction logic into the CLI. -- `KimiTUI` coordinates; it does not accumulate complex business rules. New logic that can be tested independently should be split into `controllers`, `commands`, `components`, `reverse-rpc`, or `utils` first. +- `KimiTUI` coordinates; it does not accumulate complex business rules. New logic that can be tested independently should be split into `controllers`, `commands`, `components`, `interactions`, or `utils` first. - `controllers` own the heavy, independently-testable slices (event routing, streaming render, session replay, tasks browser, editor keyboard, auth). Event-routing and rendering logic belong here, not on the `KimiTUI` class. - `commands` only owns slash-command declaration, parsing, and the parsed-result types. The actual execution can be dispatched from `KimiTUI`, but complex logic should continue to sink downward. -- `components` only handle presentation and local interaction; they must not call the SDK directly, and must not read or write session state directly. -- `reverse-rpc` converts SDK approval/question requests into the data shape a UI panel/dialog needs, and converts the user's choice back into an SDK response. +- `components` only handle presentation and local interaction; they must not call the core facade directly, and must not read or write session state directly. +- `interactions` turns the core pending approval/question entries into the data shape a UI panel/dialog needs, and writes the user's choice back through `decide`/`answer`/`dismiss`. - `theme` is the single source of truth for colors and styles. Components must not bypass the theme system and use chalk named colors directly. - `utils` holds utility functions with no UI-state dependency. Logic that needs `TUIState` or a component instance must not live under app-level `src/utils`. -- `apps/kimi-code` may only use core capabilities through `@moonshot-ai/kimi-code-sdk`. Do not import `@moonshot-ai/agent-core` directly in app code. +- The interactive TUI reaches the agent engine only through `src/core/` (`CoreHarness`/`CoreSession`). TUI files must not import `@moonshot-ai/agent-core-v2` or `@moonshot-ai/kimi-code-sdk` directly — add types/capabilities to the `src/core` facade instead. Print (`src/cli/v2/`), ACP, and other subcommands are exempt and still consume `@moonshot-ai/kimi-code-sdk`. No app code may import `@moonshot-ai/agent-core` (v1) directly. ## TUI Coding Conventions diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index caeae907ca..292aeaa64f 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -2,21 +2,18 @@ import { execSync, spawnSync } from 'node:child_process'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { - createKimiHarness, - log, - type KimiHarness, - type TelemetryClient, -} from '@moonshot-ai/kimi-code-sdk'; +import { log } from '@moonshot-ai/kimi-code-sdk'; import { setCrashPhase, setTelemetryContext, shutdownTelemetry, track, withTelemetryContext, + type TelemetryProperties, } from '@moonshot-ai/kimi-telemetry'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; +import { createCoreHarness, type TelemetryClient } from '#/core/index'; import { detectPendingMigration } from '#/migration/index'; import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; @@ -59,7 +56,7 @@ export async function runShell( withContext: withTelemetryContext, setContext: setTelemetryContext, }; - const harness = createKimiHarness({ + const harness = createCoreHarness({ homeDir: telemetryBootstrap.homeDir, identity: createKimiCodeHostIdentity(version), skillDirs: opts.skillsDirs, @@ -96,8 +93,23 @@ export async function runShell( return; } const config = await harness.getConfig(); - for (const warning of (await harness.getConfigDiagnostics()).warnings) { - configWarning = combineStartupNotice(configWarning, warning); + const configDiagnostics = await harness.getConfigDiagnostics(); + // v1 fail-fast: an unparseable config.toml must not silently start with an + // empty config. v2 downgrades TOML parse failures to `severity: 'error'` + // diagnostics and keeps running, so surface every error and exit before the + // TUI grabs the terminal. + const configErrors = configDiagnostics.filter((diagnostic) => diagnostic.severity === 'error'); + if (configErrors.length > 0) { + for (const diagnostic of configErrors) { + const domain = diagnostic.domain ?? 'config'; + process.stderr.write(`Config error [${domain}]: ${diagnostic.message}\n`); + } + await harness.close(); + process.exit(1); + } + for (const diagnostic of configDiagnostics) { + if (diagnostic.severity !== 'warning') continue; + configWarning = combineStartupNotice(configWarning, diagnostic.message); } const configMs = Date.now() - configStartedAt; const tui = new KimiTUI(harness, { @@ -114,7 +126,12 @@ export async function runShell( initializeCliTelemetry({ harness, bootstrap: telemetryBootstrap, - config, + // The v2 config document is domain-keyed and untyped on this surface; + // project the two fields the telemetry bootstrap reads. + config: { + defaultModel: typeof config['defaultModel'] === 'string' ? config['defaultModel'] : undefined, + telemetry: typeof config['telemetry'] === 'boolean' ? config['telemetry'] : undefined, + }, version, uiMode: CLI_UI_MODE, }); @@ -123,7 +140,7 @@ export async function runShell( const trackLifecycleForSession = ( sessionId: string, event: string, - properties?: Parameters[1], + properties?: TelemetryProperties, ) => { if (sessionId.length === 0) { harness.track(event, properties); @@ -131,7 +148,7 @@ export async function runShell( } withTelemetryContext({ sessionId }).track(event, properties); }; - const trackLifecycle = (event: string, properties?: Parameters[1]) => { + const trackLifecycle = (event: string, properties?: TelemetryProperties) => { trackLifecycleForSession(tui.getCurrentSessionId(), event, properties); }; diff --git a/apps/kimi-code/src/cli/telemetry.ts b/apps/kimi-code/src/cli/telemetry.ts index 7a52cc23d2..30894d30d5 100644 --- a/apps/kimi-code/src/cli/telemetry.ts +++ b/apps/kimi-code/src/cli/telemetry.ts @@ -8,7 +8,6 @@ import { type TelemetryClient, } from '@moonshot-ai/kimi-code-sdk'; -import type { PromptHarness } from './prompt-session'; import { initializeTelemetry, setTelemetryContext, @@ -17,6 +16,7 @@ import { } from '@moonshot-ai/kimi-telemetry'; import { CLI_USER_AGENT_PRODUCT, WEB_UI_MODE } from '#/constant/app'; +import type { TelemetryProperties } from '#/core/index'; import { createKimiCodeHostIdentity } from './version'; @@ -26,8 +26,20 @@ export interface CliTelemetryBootstrap { readonly firstLaunch: boolean; } +/** + * Minimal harness surface consumed by {@link initializeCliTelemetry}. Both the + * v1 SDK `KimiHarness` (print mode, `kimi export`) and the v2-backed + * `CoreHarness` (shell mode) satisfy it structurally — `KimiAuthFacade` is the + * same class on both paths. + */ +export interface CliTelemetryHarness { + readonly homeDir: string; + readonly auth: KimiAuthFacade; + track(event: string, properties?: TelemetryProperties): void; +} + export interface InitializeCliTelemetryOptions { - readonly harness: PromptHarness; + readonly harness: CliTelemetryHarness; readonly bootstrap: CliTelemetryBootstrap; readonly config: Pick; readonly version: string; diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 456a3bcf08..efc4d533fd 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -40,6 +40,7 @@ import { type DomainEvent, type IAgentScopeHandle, type ISessionScopeHandle, + type LoopRunResult, type Scope, } from '@moonshot-ai/agent-core-v2'; import { createKimiDefaultHeaders, createKimiDeviceId } from '@moonshot-ai/kimi-code-oauth'; @@ -354,18 +355,17 @@ async function runNativeTurn( // spawned has drained (config-bounded). Flush the buffered assistant // message first so a long drain does not withhold the final message. writer.flushAssistant(); - if (result.reason === 'completed') { - try { - await drainBackgroundTasks(app, session); - } catch { - // Draining is best-effort; a wedged background task must not fail the - // (already completed) turn. Swallow and proceed to finish. - } + if (result.type !== 'completed') { writer.finish(); - return; + throw new Error(formatNativeTurnFailure(result)); + } + try { + await drainBackgroundTasks(app, session); + } catch { + // Draining is best-effort; a wedged background task must not fail the + // (already completed) turn. Swallow and proceed to finish. } writer.finish(); - throw new Error(formatNativeTurnFailure(result)); } catch (error) { writer.finish(); throw error instanceof Error ? error : new Error(String(error)); @@ -493,10 +493,10 @@ async function drainBackgroundTasks(app: Scope, session: ISessionScopeHandle): P if (allWaiters.length > 0) await Promise.all(allWaiters); } -function formatNativeTurnFailure(result: { - readonly reason: string; - readonly error?: unknown; -}): string { +function formatNativeTurnFailure(result: Exclude): string { + if (result.type === 'cancelled') { + return `Prompt turn ended with reason: ${String(result.reason)}`; + } const error = result.error as { readonly code?: string; readonly message?: string } | undefined; if (error?.code === 'provider.filtered') { return 'Provider safety policy blocked the response.'; @@ -507,8 +507,5 @@ function formatNativeTurnFailure(result: { if (result.error instanceof Error) { return result.error.message; } - if (result.reason === 'blocked') { - return 'Prompt hook blocked the request.'; - } - return `Prompt turn ended with reason: ${result.reason}`; + return 'Prompt turn failed.'; } diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index e38ea6a686..9c33fc65eb 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -1,4 +1,4 @@ -import { ErrorCodes } from '@moonshot-ai/kimi-code-sdk'; +import { CoreErrorCodes } from '#/core/index'; export const PRODUCT_NAME = 'Kimi Code'; export const CLI_COMMAND_NAME = 'kimi'; @@ -58,7 +58,7 @@ export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:kimi-code'; // SDK/core error code that tells the TUI to show a login-required startup // notice. Derived from sdk's ErrorCodes so a future rename in core // auto-propagates instead of silently breaking the startup recovery path. -export const OAUTH_LOGIN_REQUIRED_CODE = ErrorCodes.AUTH_LOGIN_REQUIRED; +export const OAUTH_LOGIN_REQUIRED_CODE = CoreErrorCodes.AUTH_LOGIN_REQUIRED; export const FEEDBACK_ISSUE_URL = 'https://github.com/MoonshotAI/kimi-code/issues'; diff --git a/apps/kimi-code/src/core/README.md b/apps/kimi-code/src/core/README.md new file mode 100644 index 0000000000..a4ea930c8f --- /dev/null +++ b/apps/kimi-code/src/core/README.md @@ -0,0 +1,261 @@ +# `src/core` — TUI 的 agent-core-v2 门面 + +本文档记录 `apps/kimi-code` 交互式 TUI 从 v1 SDK(`@moonshot-ai/kimi-code-sdk` 的 `KimiHarness`/`Session`)切换到 `packages/agent-core-v2`(DI × Scope 引擎)的架构、用法、缺口与维护要点。读者是改 core 门面或 TUI 的开发者。 + +设计推导见 `.tmp/agent-core-v2-tui-direct-design.md`,实施计划见 `.tmp/agent-core-v2-tui-direct-plan.md`(均在 `.tmp/`,不入库)。 + +## 1. 它是什么 / 为什么存在 + +TUI 现在跑在 agent-core-v2 上,但 **TUI 文件不直接 import agent-core-v2**,也不经过 v1 SDK 的 `KimiHarness`/`Session` 类。中间这层 `src/core/` 是一个**薄门面**: + +- 对内:持有 v2 的 App `Scope`,方法内部直接 `handle.accessor.get(IXxxService)` 调 v2 服务(App / Session / Agent 三层 scope)。 +- 对外:暴露 `CoreHarness` / `CoreSession` 两个类 + v2 原生类型(经 `index.ts` re-export),TUI 全部 import `#/core`。 + +**这不是协议桥接层。** 事件 payload 是 v2 原生(只补 `agentId`/`sessionId` 路由上下文),交互是 v2 pending 模型直出,方法直接调 v2 服务。门面存在的唯一理由是把 v2 的 DI/Scope 细节收敛在一个地方,给 TUI 一个稳定的、按 TUI 需求裁剪的接口面。 + +范围边界: + +- **只有交互式 TUI 走这里。** print(`src/cli/v2/`)、ACP、其他子命令继续用 `@moonshot-ai/kimi-code-sdk`(v1),不动。 +- `packages/node-sdk`、`packages/agent-core` 一行不改。`packages/agent-core-v2` 仅在 facade 需要时补 **type-only** barrel 导出(不改逻辑、不扩值导出);v2 缺失的能力仍一律降级 + `TODO(v2-gap)` 标记。 + +## 2. 架构 + +``` +run-shell.ts ── createCoreHarness(...) ──> CoreHarness ── createSession/resumeSession ──> CoreSession + │ │ + └─ App Scope (bootstrap) └─ ISessionScopeHandle + (进程唯一) + agent handle 路由 + accessor.get(IXxxService) + ├─ events.ts 事件合流 + └─ approvals/questions pending 直出 +``` + +- `bootstrap()` 产生进程唯一的 App `Scope`,持有到退出。 +- 一切能力 = `handle.accessor.get(IXxxService)`,严格按 App / Session / Agent 三层从对应 handle 取。 +- main agent 通过 `ensureMainAgent(handle)` 惰性物化;其他 agent 经 `IAgentLifecycleService.getHandle(id)` 解析。 + +## 3. 文件职责 + +| 文件 | 职责 | +|---|---| +| `index.ts` | barrel:`createCoreHarness` / `CoreHarness` / `CoreSession` + core 自有类型 + v2 类型 re-export | +| `harness.ts` | `CoreHarness`:bootstrap、session CRUD、config、插件、telemetry、auth 持有 | +| `session.ts` | `CoreSession`:对话流、模式、查询、goal/task、skills、事件、交互子对象 | +| `events.ts` | 多 agent `IEventBus` + App 级 `IEventService` 合流成 session 单一事件流 | +| `replay.ts` | resume 回放数据组装(`getResumeState()` 数据源) | +| `transcript.ts` | wire records → 渲染 transcript 的 fold(`defineDerivedModel` 形态、facade 手动 fold);条目类型 `TranscriptMessage` 与喂模型的 `ContextMessage` 隔离 | +| `errors.ts` | `CoreError` / `CoreErrorCodes` / `isCoreError`(错误码值与 v1 wire 一致) | +| `types.ts` | core 自有类型(`SessionEvent` / `SessionStatus` / `ResumedSessionState` / 投影类型等) | +| `auth.ts` | re-export SDK 的 `KimiAuthFacade`(`TODO(migrate)`,见 §7) | +| `catalog.ts` | catalog 纯函数转口(`TODO(migrate)`,见 §7) | + +## 4. TUI 如何消费 + +```ts +import { createCoreHarness, type CoreHarness, type CoreSession, type SessionEvent } from '#/core/index'; + +const harness = createCoreHarness({ homeDir, identity, telemetry, onOAuthRefresh, ... }); + +// 启动时可以不建 session:先拿 config 级默认(模型 / 上下文窗口 / 模式)渲染首屏, +// 第一条用户消息再 lazy 创建 session(TUI 的 sendNormalUserInput 路径)。 +const startup = await harness.getStartupState(); +const session = await harness.createSession({ workDir, model, permission, planMode }); + +// 对话流 +await session.prompt(parts); // 默认路由到 main agent +await session.prompt(parts, { agentId: btwId }); // 显式 agent 路由(替代 v1 的 withInteractiveAgent) +await session.steer(parts); +await session.cancel(); + +// 事件(合流后的 session 单一流,返回退订函数) +const off = session.onEvent((event: SessionEvent) => { ... }); + +// 交互(v2 pending 模型) +session.approvals.onDidChangePending(() => { + for (const p of session.approvals.list()) { /* 驱动审批面板 */ } +}); +session.approvals.decide(id, { decision: 'approved' }); +session.questions.answer(id, result); +session.questions.dismiss(id); + +// 模式 / 查询 +await session.setModel('kimi-latest'); +await session.setPermission('yolo'); +const status = await session.getStatus(); +``` + +TUI 文件**只 import `#/core/index`**。不要把 agent-core-v2 的服务 identifier 漏进 TUI 组件——需要新能力时,先在 core 门面加方法。 + +## 5. v1 → v2 迁移速查 + +从 v1 SDK 迁到 core 门面时,下列行为触点需要对应改造(已在 TUI 切换中落实,记录在此供后续参考)。 + +### 5.1 事件名 + +v2 事件 kind 与 v1 几乎一致,唯一改名: + +| v1 (`background.task.*`) | v2 (`task.*`) | +|---|---| +| `background.task.started` | `task.started` | +| `background.task.terminated` | `task.terminated` | + +事件形状:`SessionEvent = DomainEvent & { agentId, sessionId }`。core 只补路由上下文,不改 payload。`session-event-handler.ts` 按 `event.agentId` 做 subagent 路由。 + +### 5.2 交互模型 + +| v1(handler 回调) | v2(pending 模型,TUI `src/tui/interactions/` 消费) | +|---|---| +| `session.setApprovalHandler(fn)` | `session.approvals.onDidChangePending(...)` → `list()` → `decide(id, response)` | +| `session.setQuestionHandler(fn)` | `session.questions.onDidChangePending(...)` → `list()` → `answer(id, result)` / `dismiss(id)` | + +`ApprovalResponse` / `QuestionResult` 结构 v1/v2 一致。pending 队列天然支持并发审批排队。 + +### 5.3 服务挂靠跟随 v2 scope + +v2 的服务归属与 v1 不同,门面方法挂靠**跟随 v2 scope,不迁就 v1 习惯**: + +| 能力 | v1 | v2(门面挂靠) | +|---|---|---| +| 插件管理(list/install/enable/remove/reload/info/listPluginCommands) | `Session` | `CoreHarness`(`IPluginService` 是 App 级) | +| `getStatus` | client 端 6-RPC 聚合 | 各 Agent 原生服务聚合(profile / permission / plan / swarm / contextSize / usage) | +| `reloadSession` | `Session` 方法 | `CoreHarness.reloadSession()`(需重建 CoreSession 实例) | + +### 5.4 agent 路由显式化 + +| v1 | v2 | +|---|---| +| `harness.withInteractiveAgent(agentId, () => session.prompt(parts))` | `session.prompt(parts, { agentId })` | +| `harness.interactiveAgentId`(AsyncLocalStorage) | TUI 自持 `interactiveAgentId` 字段,默认 `'main'` | + +### 5.5 其他 + +| v1 | v2 | +|---|---| +| `session.init()`(`/init`) | `session.generateAgentsMd()` | +| `SessionSummary.workDir`(来自 v1 index) | `CoreSessionSummary.workDir`(来自 v2 index 的 `cwd` 字段,投影命名保持 `workDir`) | +| `getConfigDiagnostics()` 返回 `{ warnings }` | 返回 `readonly ConfigDiagnostic[]`(按 `severity` 过滤 warning/error) | +| `onEvent` 返回 `IDisposable` | 返回退订函数 `() => void` | + +## 6. 缺口清单(`TODO(v2-gap)`) + +v2 一行不改,缺失能力降级 + 标记。完整清单与设计文档 §7 对账: + +| 编号 | 缺口 | 降级行为 | +|---|---|---| +| G-3 | bootstrap 无 `skillDirs` 输入 | 参数接受并忽略(`harness.ts:94,160`) | +| G-4 | 无退出 drain API | close 时 best-effort(`harness.ts:477`) | +| G-5 | `forcePluginSessionStartReminder` 无 API | reload 接受并忽略(`harness.ts:131,268`) | +| G-6 | `generateAgentsMd` 无实现 | 未实现,直接抛 `CoreErrorCodes.NOT_IMPLEMENTED`(不在客户端编排) | +| G-8 | create/resume 无 additionalDirs 输入、注入不持久 | 物化后立即注入,仅存活于 session scope(`harness.ts:223,500`) | +| G-9/G-12 | 无 warnings 聚合 API;AGENTS.md warning 仅缓存 | 仅回 profile 缓存的 AGENTS.md warning(`session.ts:341`) | +| G-11 | config 无 raw 文本投影 | `raw` 省略(`harness.ts:384`) | +| G-20 | question origin 无 agentId | 子 agent 提问归因 main(`session.ts:592`) | +| G-30 | resume 无 warning 通道 | `warning: undefined`(`harness.ts:511`) | + +实现期新增未编号 gap(设计 §7 之后发现,方向合理): + +- v2 无 per-agent `ProviderConfig` DTO → resume 回放的 `provider?.model` 恒 undefined(`replay.ts:66`、`types.ts:173`)。 +- `/export-md` 的 `token_count` best-effort 填 0(`commands/session.ts:119`)。 + +## 7. 临时桥接(`TODO(migrate)`) + +下列依赖项计划在后续 follow-up 中搬离或替换为 v2/protocol 正式导出: + +| 位置 | 内容 | 去向 | +|---|---|---| +| `auth.ts` | `KimiAuthFacade`(与 RPC 解耦,直接操作 config 文件 + oauth 包) | 搬进 `src/core/`,直接依赖 `@moonshot-ai/kimi-code-oauth` | +| `catalog.ts` | catalog 纯函数(`effectiveModelAlias` / `fetchCatalog` 等) | v2 `IModelCatalogService` / `IModelService` 或本地化 | +| `types.ts` | `BackgroundTaskInfo` / `SessionUsage` 别名 | v2 原生类型 | +| `types.ts` | `McpServerStatusEvent` / `McpServerStatusPayload` / `McpOAuthAuthorizationUrlUpdateData` / `MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE` | `@moonshot-ai/protocol` 正式导出 | +| `src/utils/image-model.ts` | 图片压缩纯函数(`compressImageForModel` 等) | 已在 app 内,长期保留 | + +入口链残留 SDK(`run-shell.ts` 的 `log`、`telemetry.ts`、`startup-error.ts`)属入口/server 共用区,随 auth migrate 一并处理。 + +## 8. 维护指南 + +### 8.1 加一个新的 session 方法 + +1. 在 v2 找到对应服务(`packages/agent-core-v2/src/...`),确认它属于 App / Session / Agent 哪一层 scope。 +2. 在 `session.ts` 的 `CoreSession` 类加方法,内部 `this.agent(agentId).then(a => a.accessor.get(IXxxService).method(...))`。 +3. 若返回类型是 v2 原生且 TUI 需要,在 `types.ts` 追加类型别名或在 `index.ts` re-export。 +4. 在 `test/core/session.test.ts` 加用例(token 分发假件模式参考既有用例)。 +5. TUI 调用点 import 类型自 `#/core/index`。 + +### 8.2 加一个新的 App 级方法 + +同上,但加在 `harness.ts` 的 `CoreHarness`,从 `this.deps.app.accessor.get(IXxxService)` 取服务。 + +### 8.3 加一个新事件 + +v2 事件 kind 自动经 `events.ts` 合流透传。若 TUI 需要处理新 kind: + +1. 在 `session-event-handler.ts` 的 `handleEvent` switch 加 case。 +2. 若事件 payload 需要补字段或来自 App 级总线(如 `session.meta.updated`),在 `events.ts` 加投影逻辑 + 单测。 +3. 事件类型从 `#/core/index` re-export(v2 barrel 有就直接转口)。 + +### 8.4 加一种新的交互 kind + +v2 的 `ISessionInteractionService` 当前有 `approval` / `question` / `user_tool` 三种 kind。门面只透传前两种。若需要支持 `user_tool` 或新增 kind,在 `session.ts` 的 `buildApprovals`/`buildQuestions` 旁加对应的子对象 + `src/tui/interactions/` 控制器。 + +### 8.5 排错路径 + +TUI 行为异常时按数据流反查: + +- **交互问题**(审批/提问不弹、卡死)→ `interactions/` 控制器(in-flight 去重 / decide 写回 / 异常 settle)→ `session.ts` 的 `approvals`/`questions` 投影 → v2 `ISessionInteractionService`。 +- **事件问题**(流式不更新、subagent 事件丢失)→ `session-event-handler.ts` case → `events.ts` 合流(订阅覆盖 / flush 队列 / App 级总线)→ v2 各服务的 `IEventBus.publish`。 +- **方法行为偏差** → `session.ts`/`harness.ts` 对应域方法 → v2 服务实现。 +- **resume 回放缺失** → `replay.ts` 的 `buildResumedAgents` → 各 v2 服务的数据形状。 + +## 9. 验证命令 + +切换涉及的所有验证(一律用干净 env,避免本机 `KIMI_CODE_EXPERIMENTAL_FLAG` 把 print 测试切到 v2 撞 minidb 锁): + +```bash +# 全量测试 +env -u KIMI_CODE_EXPERIMENTAL_FLAG pnpm -C apps/kimi-code exec vitest run + +# typecheck(仅 v2 包 sessionMetadata.ts 的 6 条预存错误为基线) +pnpm -C apps/kimi-code exec tsc -p tsconfig.json --noEmit + +# 构建 + 冒烟 +pnpm -C apps/kimi-code run build && node apps/kimi-code/scripts/smoke.mjs + +# 不动区零 diff +git diff --stat packages/ apps/kimi-code/src/cli/v2 + +# 缺口对账 +grep -rn "TODO(v2-gap)\|TODO(migrate)" apps/kimi-code/src/core apps/kimi-code/src/tui + +# SDK 残留审计(src/tui 应零命中) +grep -rn "@moonshot-ai/kimi-code-sdk" apps/kimi-code/src/tui +``` + +## 10. 端到端 dogfood 清单 + +单元测试用 fake scope / broker,以下是**真实 v2 引擎**端到端验证前的高风险点(按风险排序): + +1. **审批/提问面板 + 排队**:manual 模式触发审批;连发多个需审批工具验证排队;`Approve for session` 后同 action 自动通过;`AskUserQuestion` 触发提问 + Esc dismiss;policy 自动批准某工具确认不弹面板且不卡 turn。 +2. **流式渲染**:长文本 + thinking + 工具调用的 prompt,确认 live 文本、工具卡片、thinking 实时更新。 +3. **resume 回放保真**:含工具调用 + thinking + `!` shell + subagent + goal 的会话 `kimi -r` resume,确认回放渲染消息且不崩。 +4. **`/btw`**:开 /btw 提问,确认子 agent 响应渲染在 btw 面板;关闭后 agentId 回 main。 +5. **`!` shell + compaction 回放**:`!ls` 看实时输出;`!sleep 30` 取消;压缩后 resume 确认压缩前的 user/assistant/tool 消息全量在、shell 输出渲染为 `$ cmd` + 输出块、compaction 显示为带 token 数的折叠卡片。 +6. **后台任务面板**:spawn 长任务,开 tasks browser,stop/detach。 +7. **图片粘贴**:向 image-capable 模型粘贴图片,确认模型看到;测压缩 caption。 +8. **坏 config fail-fast**:写坏 TOML 到 config.toml,启动应打印 `Config error [domain]: …` 并 `exit 1`;warning-only config 应启动并带 startupNotice。 +9. **Goal**:`/goal start/pause/resume/cancel`,验证 goal 面板与 queued-goal 提升流程。 +10. **插件命令 / fork / rename / export**:install/enable/disable/remove 插件 + `/reload`;idle fork 成功、活跃 turn fork 报 `SESSION_FORK_ACTIVE_TURN`;rename 后 picker 标题更新;export markdown + debug zip。 + +## 11. 回滚 + +本切换是纯工作区改动(未 commit)。如需回滚: + +```bash +git checkout -- apps/kimi-code AGENTS.md +rm -rf apps/kimi-code/src/core apps/kimi-code/test/core +rm -rf apps/kimi-code/src/tui/interactions +rm -f apps/kimi-code/src/utils/image-model.ts +# reverse-rpc 目录经 git 恢复 +git checkout -- apps/kimi-code/src/tui/reverse-rpc +``` + +回滚后 TUI 回到 v1 SDK 路径。print / ACP / 其他子命令始终未动,无需回滚。 diff --git a/apps/kimi-code/src/core/auth.ts b/apps/kimi-code/src/core/auth.ts new file mode 100644 index 0000000000..ae7c55b2d8 --- /dev/null +++ b/apps/kimi-code/src/core/auth.ts @@ -0,0 +1,34 @@ +/** + * Auth surface of the v2 facade (`#/core`). + * + * TODO(migrate): KimiAuthFacade lives in node-sdk but is RPC-free (config file + * + oauth toolkit only). Re-exported for now; move the implementation into + * this module (depending on @moonshot-ai/kimi-code-oauth directly) in a + * follow-up so the TUI path fully drops its node-sdk dependency. + */ + +import type { OAuthRefreshOutcome } from '@moonshot-ai/kimi-code-oauth'; + +export { KimiAuthFacade } from '@moonshot-ai/kimi-code-sdk'; +export type { + KimiAuthCompleteFeedbackUploadInput, + KimiAuthCompleteFeedbackUploadPart, + KimiAuthCreateFeedbackUploadUrlInput, + KimiAuthCreateFeedbackUploadUrlOk, + KimiAuthCreateFeedbackUploadUrlResult, + KimiAuthFeedbackUploadPart, + KimiAuthLoginResult, + KimiAuthLogoutResult, + KimiAuthSubmitFeedbackInput, +} from '@moonshot-ai/kimi-code-sdk'; +export { + assertKimiHostIdentity, + type KimiHostIdentity, + type OAuthRefreshOutcome, +} from '@moonshot-ai/kimi-code-oauth'; + +/** + * Callback invoked after a managed OAuth token refresh. The v1 SDK typed this + * inline on `KimiHarnessOptions.onOAuthRefresh`; named here for the facade. + */ +export type OAuthRefreshHandler = (outcome: OAuthRefreshOutcome) => void; diff --git a/apps/kimi-code/src/core/catalog.ts b/apps/kimi-code/src/core/catalog.ts new file mode 100644 index 0000000000..5da853b40a --- /dev/null +++ b/apps/kimi-code/src/core/catalog.ts @@ -0,0 +1,22 @@ +/** + * Model-catalog helpers, re-exported through the core facade so TUI files do + * not import the SDK directly. + * + * TODO(migrate): these are still the SDK / agent-core implementations. v2 + * covers part of this surface via `IModelCatalogService` / `IModelService` + * (reached through `CoreHarness`); the remaining pure helpers should move to a + * shared home. Until then the TUI consumes them from `#/core/index` only. + */ +export { + applyCatalogProvider, + catalogBaseUrl, + catalogModelToAlias, + catalogProviderModels, + CatalogFetchError, + DEFAULT_CATALOG_URL, + effectiveModelAlias, + fetchCatalog, + inferWireType, + type Catalog, + type CatalogModel, +} from '@moonshot-ai/kimi-code-sdk'; diff --git a/apps/kimi-code/src/core/errors.ts b/apps/kimi-code/src/core/errors.ts new file mode 100644 index 0000000000..bb3fe1eb12 --- /dev/null +++ b/apps/kimi-code/src/core/errors.ts @@ -0,0 +1,66 @@ +/** + * Core error model for the v2 facade. Code values mirror the v1 wire codes + * the TUI already matches on (see `packages/agent-core/src/errors/codes.ts`); + * TODO(migrate): unify with a v2-native error registry once agent-core-v2 + * exposes one. + */ + +import { isKimiError } from '@moonshot-ai/agent-core-v2'; + +export const CoreErrorCodes = { + SESSION_NOT_FOUND: 'session.not_found', + SESSION_ID_REQUIRED: 'session.id_required', + SESSION_ID_EMPTY: 'session.id_empty', + AGENT_NOT_FOUND: 'agent.not_found', + TURN_AGENT_BUSY: 'turn.agent_busy', + PLUGIN_NOT_FOUND: 'plugin.not_found', + SESSION_INIT_FAILED: 'session.init_failed', + // Facade-local: raised for capabilities the v2 surface declares but has not + // implemented yet; not a v1 wire code. + NOT_IMPLEMENTED: 'not_implemented', + AUTH_LOGIN_REQUIRED: 'auth.login_required', + CONFIG_INVALID: 'config.invalid', + GOAL_ALREADY_EXISTS: 'goal.already_exists', + GOAL_NOT_FOUND: 'goal.not_found', + GOAL_OBJECTIVE_EMPTY: 'goal.objective_empty', + GOAL_OBJECTIVE_TOO_LONG: 'goal.objective_too_long', +} as const; + +export class CoreError extends Error { + readonly code: string; + readonly details: Record | undefined; + + constructor( + code: string, + message: string, + options?: { details?: Record; cause?: unknown }, + ) { + super(message, { cause: options?.cause }); + this.name = 'CoreError'; + this.code = code; + this.details = options?.details; + } +} + +/** + * Recognizes errors that carry a v1-style string `code`: the facade's own + * `CoreError` plus the `KimiError` thrown by agent-core-v2 services. v2 reuses + * the v1 code values verbatim (e.g. `session.not_found`), and the TUI matches + * on those codes, so the guard must see through service errors instead of + * forcing every call site into a separate fallback branch. + * + * v2 errors are identified via `isKimiError` (an `instanceof` guard), so this + * does not depend on v2's error `name`. Errors thrown through the node-sdk + * auth/catalog bridge are a different class reference (same `name`, different + * `instanceof`), so a string-`code` + `name` fallback keeps recognizing them + * until that bridge is migrated (TODO(migrate)). + */ +export function isCoreError(value: unknown): value is CoreError { + if (value instanceof CoreError) return true; + if (isKimiError(value)) return true; + return ( + value instanceof Error && + typeof (value as { code?: unknown }).code === 'string' && + (value.name === 'KimiError' || value.name === 'CoreError') + ); +} diff --git a/apps/kimi-code/src/core/event-types.ts b/apps/kimi-code/src/core/event-types.ts new file mode 100644 index 0000000000..adfff7b473 --- /dev/null +++ b/apps/kimi-code/src/core/event-types.ts @@ -0,0 +1,35 @@ +/** + * Named event types for the TUI/CLI event handlers. The v2 barrel exports only + * the `DomainEvent` union, so the v1 SDK event names consumers import are + * derived from the session stream union: each alias is the matching + * `SessionEvent` variant (v2-native payload plus `agentId`/`sessionId`). + */ + +import type { SessionEventOf } from './types'; + +export type TurnStartedEvent = SessionEventOf<'turn.started'>; +export type TurnEndedEvent = SessionEventOf<'turn.ended'>; +export type TurnStepStartedEvent = SessionEventOf<'turn.step.started'>; +export type TurnStepCompletedEvent = SessionEventOf<'turn.step.completed'>; +export type TurnStepInterruptedEvent = SessionEventOf<'turn.step.interrupted'>; +export type AssistantDeltaEvent = SessionEventOf<'assistant.delta'>; +export type ThinkingDeltaEvent = SessionEventOf<'thinking.delta'>; +export type ToolCallStartedEvent = SessionEventOf<'tool.call.started'>; +export type ToolCallDeltaEvent = SessionEventOf<'tool.call.delta'>; +export type ToolProgressEvent = SessionEventOf<'tool.progress'>; +export type ToolResultEvent = SessionEventOf<'tool.result'>; +export type AgentStatusUpdatedEvent = SessionEventOf<'agent.status.updated'>; +export type SessionMetaUpdatedEvent = SessionEventOf<'session.meta.updated'>; +export type GoalUpdatedEvent = SessionEventOf<'goal.updated'>; +export type SkillActivatedEvent = SessionEventOf<'skill.activated'>; +export type PluginCommandActivatedEvent = SessionEventOf<'plugin_command.activated'>; +export type ErrorEvent = SessionEventOf<'error'>; +export type WarningEvent = SessionEventOf<'warning'>; +export type HookResultEvent = SessionEventOf<'hook.result'>; +export type CompactionStartedEvent = SessionEventOf<'compaction.started'>; +export type CompactionCompletedEvent = SessionEventOf<'compaction.completed'>; +export type CompactionCancelledEvent = SessionEventOf<'compaction.cancelled'>; +/** v2 renamed `background.task.*` to `task.*`; payload is `{ info: AgentTaskInfo }`. */ +export type TaskStartedEvent = SessionEventOf<'task.started'>; +export type TaskTerminatedEvent = SessionEventOf<'task.terminated'>; +export type CronFiredEvent = SessionEventOf<'cron.fired'>; diff --git a/apps/kimi-code/src/core/events.ts b/apps/kimi-code/src/core/events.ts new file mode 100644 index 0000000000..15b5a6661e --- /dev/null +++ b/apps/kimi-code/src/core/events.ts @@ -0,0 +1,100 @@ +/** + * Multi-agent event merging for the v2 facade (`#/core`). + * + * `attachSessionEvents` fans in every agent's `IEventBus` (Agent scope) plus + * the App-scope `IEventService` projection of `session.meta.updated` into a + * single ordered `SessionEvent` stream, tracking agent creation/disposal via + * the Session-scope `IAgentLifecycleService`. + */ + +import { + IAgentLifecycleService, + IEventBus, + IEventService, + MAIN_AGENT_ID, + type DomainEvent, + type GlobalEvent, + type IAgentScopeHandle, + type IDisposable, + type ISessionScopeHandle, + type Scope, +} from '@moonshot-ai/agent-core-v2'; + +import type { SessionEvent } from './types'; + +/** Stamp routing context; payload stays verbatim (event types are not rewritten, D5). */ +export function toSessionEvent(event: DomainEvent, sessionId: string, agentId: string): SessionEvent { + // Controlled boundary cast: a stamped DomainEvent is the DomainEvent arm of SessionEvent. + return { ...event, agentId, sessionId } as SessionEvent; +} + +/** `session.meta.updated` on the app bus → this session's SessionEvent; anything else → undefined. */ +export function projectSessionMetaEvent(event: GlobalEvent, sessionId: string): SessionEvent | undefined { + if (event.type !== 'session.meta.updated') return undefined; + const payload = event.payload; + if (typeof payload !== 'object' || payload === null) return undefined; + const candidate = payload as { sessionId?: unknown; agentId?: unknown; title?: unknown; patch?: unknown }; + if (candidate.sessionId !== sessionId) return undefined; + const title = typeof candidate.title === 'string' ? candidate.title : undefined; + const patch = typeof candidate.patch === 'object' && candidate.patch !== null && !Array.isArray(candidate.patch) + ? (candidate.patch as Record) : undefined; + if (title === undefined && patch === undefined) return undefined; + const agentId = typeof candidate.agentId === 'string' ? candidate.agentId : MAIN_AGENT_ID; + // Controlled boundary cast: the narrowed literal is the meta arm of SessionEvent. + return { type: 'session.meta.updated', title, patch, agentId, sessionId } as SessionEvent; +} + +export function attachSessionEvents(args: { + session: ISessionScopeHandle; + sessionId: string; + app: Scope; + emit: (event: SessionEvent) => void; +}): () => void { + const { session, sessionId, app, emit } = args; + const queue: SessionEvent[] = []; + let flushing = false; + let detached = false; + const deliver = (event: SessionEvent): void => { + if (detached) return; + queue.push(event); + if (flushing) return; + flushing = true; + try { + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + try { emit(next); } catch { /* a listener error must not break the stream */ } + } + } finally { flushing = false; } + }; + + const agents = session.accessor.get(IAgentLifecycleService); + const agentSubscriptions = new Map(); + const subscribeAgent = (handle: IAgentScopeHandle): void => { + if (agentSubscriptions.has(handle.id)) return; + const bus = handle.accessor.get(IEventBus); + agentSubscriptions.set(handle.id, bus.subscribe((event: DomainEvent) => { + deliver(toSessionEvent(event, sessionId, handle.id)); + })); + }; + for (const handle of agents.list()) subscribeAgent(handle); + const lifecycleSubscriptions: IDisposable[] = [ + agents.onDidCreate((handle) => { subscribeAgent(handle); }), + agents.onDidDispose((agentId) => { + agentSubscriptions.get(agentId)?.dispose(); + agentSubscriptions.delete(agentId); + }), + ]; + const globalSubscription = app.accessor.get(IEventService).subscribe((event) => { + const adapted = projectSessionMetaEvent(event, sessionId); + if (adapted !== undefined) deliver(adapted); + }); + + return () => { + if (detached) return; + detached = true; + globalSubscription.dispose(); + for (const d of lifecycleSubscriptions) d.dispose(); + for (const d of agentSubscriptions.values()) d.dispose(); + agentSubscriptions.clear(); + queue.length = 0; + }; +} diff --git a/apps/kimi-code/src/core/harness.ts b/apps/kimi-code/src/core/harness.ts new file mode 100644 index 0000000000..52d9da7533 --- /dev/null +++ b/apps/kimi-code/src/core/harness.ts @@ -0,0 +1,641 @@ +/** + * App-level facade over the v2 engine (`#/core`). + * + * `CoreHarness` is the TUI's single entry object: it owns the App scope + * produced by `bootstrap()`, the map of live `CoreSession`s, the auth facade, + * and the client-side telemetry semantics (verbatim from the v1 SDK + * `KimiHarness`). Every method resolves an App-scope service through the DI + * accessor and forwards with at most a light projection; session-scoped work + * lives in `CoreSession`. Construction is split so tests can inject a fake + * scope: `new CoreHarness(deps)` takes the ready-made pieces, while + * `createCoreHarness(options)` performs the real bootstrap. + */ + +import { randomUUID } from 'node:crypto'; +import { mkdir, open } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { + bootstrap, + ensureMainAgent, + IAgentPermissionModeService, + IAgentProfileService, + IBootstrapService, + IConfigService, + IFlagService, + IModelResolver, + IPluginService, + IProviderService, + ISessionActivity, + ISessionContext, + ISessionExportService, + ISessionIndex, + ISessionLifecycleService, + ISessionMetadata, + ISessionWorkspaceContext, + IWorkspaceRegistry, + logSeed, + MAIN_AGENT_ID, + resolveConfigPath, + resolveKimiHome, + resolveLoggingConfig, + resolveThinkingEffortForModel, + type GetPluginInfoInput, + type InstallPluginInput, + type ISessionScopeHandle, + type PluginCommandDef, + type PluginInfo, + type PluginSummary, + type ReloadSummary, + type RemovePluginInput, + type Scope, + type SetPluginEnabledInput, + type SetPluginMcpServerEnabledInput, + type Model, + type ThinkingDefaults, +} from '@moonshot-ai/agent-core-v2'; +import { assertKimiHostIdentity, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; + +import { KimiAuthFacade, type OAuthRefreshHandler } from './auth'; +import { CoreError, CoreErrorCodes } from './errors'; +import { buildResumedSessionState } from './replay'; +import { CoreSession } from './session'; +import type { + ConfigDiagnostic, + CoreConfig, + CoreConfigPatch, + CoreSessionSummary, + CoreStartupState, + ExportSessionInput, + ExportSessionResult, + FlagExplanation, + PermissionMode, + ResumedSessionState, + SessionEvent, + TelemetryClient, + TelemetryContextPatch, + TelemetryProperties, +} from './types'; + +/** Verbatim v1 stub written by `ensureConfigFile` (agent-core `config/toml.ts`). */ +const DEFAULT_CONFIG_FILE_TEXT = `# ~/.kimi-code/config.toml +# Runtime settings for Kimi Code. +# This file starts empty so built-in defaults can apply. +# Login will populate managed Kimi provider and model entries. +`; + +const DEFAULT_SESSION_STARTED_UI_MODE = 'shell'; + +/** Telemetry sink used when the host does not supply a client. */ +export const noopTelemetry: TelemetryClient = { track: () => {} }; + +export interface CoreHarnessOptions { + readonly homeDir?: string; + readonly configPath?: string; + readonly identity?: KimiHostIdentity; + readonly uiMode?: string; + readonly telemetry?: TelemetryClient; + readonly onOAuthRefresh?: OAuthRefreshHandler; + /** TODO(v2-gap): G-3 — v2 bootstrap has no skillDirs input; accepted and ignored. */ + readonly skillDirs?: readonly string[]; + readonly sessionStartedProperties?: TelemetryProperties; +} + +/** Ready-made pieces for `new CoreHarness(...)`; built by `createCoreHarness`. */ +export interface CoreHarnessDeps { + readonly app: Scope; + readonly homeDir: string; + readonly configPath: string; + readonly identity?: KimiHostIdentity; + readonly uiMode: string; + readonly telemetry: TelemetryClient; + readonly auth: KimiAuthFacade; + readonly sessionStartedProperties: TelemetryProperties; +} + +export interface CreateSessionOptions { + readonly id?: string; + readonly workDir: string; + readonly model?: string; + readonly thinking?: string; + readonly permission?: PermissionMode; + readonly planMode?: boolean; + readonly metadata?: Record; + readonly additionalDirs?: readonly string[]; + readonly sessionStartedProperties?: TelemetryProperties; +} + +export interface ResumeSessionInput { + readonly id: string; + readonly additionalDirs?: readonly string[]; + readonly sessionStartedProperties?: TelemetryProperties; +} + +export interface ReloadSessionInput { + readonly id: string; + /** TODO(v2-gap): G-5 — no v2 plugin session-start reminder replay; accepted and ignored. */ + readonly forcePluginSessionStartReminder?: boolean; +} + +export interface ForkSessionInput { + readonly id: string; + readonly forkId?: string; + readonly title?: string; +} + +export interface RenameSessionInput { + readonly id: string; + readonly title: string; +} + +export interface ListSessionsOptions { + readonly workDir?: string; + readonly sessionId?: string; +} + +export interface GetConfigOptions { + readonly reload?: boolean; +} + +/** Bootstrap the real v2 engine and wrap it in a `CoreHarness`. */ +export function createCoreHarness(options: CoreHarnessOptions = {}): CoreHarness { + const identity = options.identity === undefined ? undefined : assertKimiHostIdentity(options.identity); + const homeDir = resolveKimiHome(options.homeDir); + const configPath = resolveConfigPath({ homeDir, configPath: options.configPath }); + // TODO(v2-gap): G-3 — v2 bootstrap has no skillDirs input; options.skillDirs is dropped. + const logging = resolveLoggingConfig({ homeDir, env: process.env }); + const { app } = bootstrap({ homeDir, configPath }, logSeed(logging)); + const auth = new KimiAuthFacade({ homeDir, configPath, identity, onRefresh: options.onOAuthRefresh }); + return new CoreHarness({ + app, + homeDir, + configPath, + identity, + uiMode: options.uiMode ?? DEFAULT_SESSION_STARTED_UI_MODE, + telemetry: options.telemetry ?? noopTelemetry, + auth, + sessionStartedProperties: options.sessionStartedProperties ?? {}, + }); +} + +interface ActiveSession { + readonly session: CoreSession; + readonly handle: ISessionScopeHandle; +} + +export class CoreHarness { + readonly homeDir: string; + readonly configPath: string; + readonly auth: KimiAuthFacade; + + private readonly activeSessions = new Map(); + + constructor(private readonly deps: CoreHarnessDeps) { + this.homeDir = deps.homeDir; + this.configPath = deps.configPath; + this.auth = deps.auth; + } + + /** Snapshot of live sessions (a fresh copy; mutating it does not affect the harness). */ + get sessions(): ReadonlyMap { + return new Map([...this.activeSessions].map(([id, { session }]) => [id, session])); + } + + // -- Session lifecycle ------------------------------------------------------ + + /** + * Pre-session defaults for the TUI's initial render. Resolved from + * App-scope services only — never creates a session. The context-window + * fallback mirrors `CoreSession.getStatus()` and the thinking resolution + * reuses the profile service's own helper, so the footer shows the same + * values before and after the first session is created lazily. + */ + async getStartupState(): Promise { + const app = this.deps.app.accessor; + const config = app.get(IConfigService); + await config.ready; + const rawModel = config.get('defaultModel'); + const model = typeof rawModel === 'string' ? rawModel : ''; + let resolved: Model | undefined; + if (model.length > 0) { + try { + resolved = app.get(IModelResolver).resolve(model); + } catch { + // Provider/auth not ready (e.g. logged out): degrade like getStatus. + resolved = undefined; + } + } + const rawPermission = config.get('defaultPermissionMode'); + const rawPlanMode = config.get('defaultPlanMode'); + const thinking = config.get('thinking'); + return { + model, + maxContextTokens: resolved?.capabilities.max_context_tokens ?? 0, + permissionMode: + rawPermission === 'manual' || rawPermission === 'auto' || rawPermission === 'yolo' + ? rawPermission + : 'manual', + planMode: typeof rawPlanMode === 'boolean' ? rawPlanMode : false, + thinkingEffort: resolveThinkingEffortForModel(undefined, thinking, resolved), + }; + } + + async createSession(options: CreateSessionOptions): Promise { + const id = options.id ?? randomUUID(); + const app = this.deps.app.accessor; + // The workspace must be registered before the session is created — + // `ISessionLifecycleService.resume` refuses sessions whose workspace is + // unknown to the registry, so skipping this would make the session + // impossible to resume later. + await app.get(IWorkspaceRegistry).createOrTouch(options.workDir); + const handle = await app.get(ISessionLifecycleService).create({ sessionId: id, workDir: options.workDir }); + try { + const main = await ensureMainAgent(handle); + if (options.model !== undefined) { + await main.accessor.get(IAgentProfileService).setModel(options.model); + } + if (options.thinking !== undefined) { + main.accessor.get(IAgentProfileService).setThinking(options.thinking); + } + if (options.permission !== undefined) { + main.accessor.get(IAgentPermissionModeService).setMode(options.permission); + } + if (options.metadata !== undefined) { + await handle.accessor.get(ISessionMetadata).update({ custom: { ...options.metadata } }); + } + // TODO(v2-gap): G-8 — additional dirs only live on the session-scope + // workspace context; they are not persisted across resumes. + for (const dir of options.additionalDirs ?? []) { + handle.accessor.get(ISessionWorkspaceContext).addAdditionalDir(dir); + } + const summary = await this.projectLiveSummary(handle); + const session = this.registerSession(handle, summary, undefined); + if (options.planMode === true) { + await session.setPlanMode(true); + } + this.trackSessionStarted(id, false, options.sessionStartedProperties); + this.trackSessionEvent(id, 'session_new'); + return session; + } catch (error) { + // A session registered before the failure (e.g. `setPlanMode` or a + // throwing telemetry client) already owns live event subscriptions — + // including the App-scope IEventService one — so close it through + // CoreSession.close() to release them; its onClose handles the + // registry removal and the v2 scope close. Before registration a + // bare lifecycle close is all there is to unwind. + const registered = this.activeSessions.get(id); + if (registered !== undefined) { + await registered.session.close().catch(() => {}); + } else { + await app.get(ISessionLifecycleService).close(id).catch(() => {}); + } + this.activeSessions.delete(id); + throw error; + } + } + + async resumeSession(input: ResumeSessionInput): Promise { + const id = normalizeSessionId(input.id); + // v1 semantics: a live session is returned as-is and does not re-track + // session_started / session_resume. + const active = this.activeSessions.get(id); + if (active !== undefined) return active.session; + const session = await this.resumeInternal(id, { additionalDirs: input.additionalDirs }); + this.trackSessionStarted(id, true, input.sessionStartedProperties); + this.trackSessionEvent(id, 'session_resume'); + return session; + } + + async reloadSession(input: ReloadSessionInput): Promise { + const id = normalizeSessionId(input.id); + // TODO(v2-gap): G-5 — v2 cannot replay plugin session-start reminders; + // `input.forcePluginSessionStartReminder` is accepted and ignored. + const active = this.activeSessions.get(id); + if (active !== undefined && active.handle.accessor.get(ISessionActivity).status() !== 'idle') { + throw new CoreError( + CoreErrorCodes.TURN_AGENT_BUSY, + `Session "${id}" is busy; wait for the current turn to finish before reloading.`, + ); + } + await this.deps.app.accessor.get(IPluginService).reloadPlugins(); + if (active !== undefined) { + await active.session.close(); + } + const session = await this.resumeInternal(id, {}); + this.trackSessionEvent(id, 'session_reload'); + return session; + } + + async forkSession(input: ForkSessionInput): Promise { + const sourceId = normalizeSessionId(input.id); + // v1 `forkId` maps onto the v2 `ForkSessionOptions.newSessionId`. + const handle = await this.deps.app.accessor.get(ISessionLifecycleService).fork({ + sourceSessionId: sourceId, + newSessionId: input.forkId, + title: input.title, + }); + const session = await this.hydrateSession(handle); + this.trackSessionStarted(session.id, true); + this.trackSessionEvent(session.id, 'session_fork'); + return session; + } + + async renameSession(input: RenameSessionInput): Promise { + const id = normalizeSessionId(input.id); + const active = this.activeSessions.get(id); + if (active !== undefined) { + await active.handle.accessor.get(ISessionMetadata).setTitle(input.title); + // v2's `setTitle` persists the rename but publishes no global + // `session.meta.updated` event (verified against sessionMetadataService), + // so re-emit one locally for the session's own listeners. + active.session.emitEvent({ + type: 'session.meta.updated', + title: input.title, + agentId: MAIN_AGENT_ID, + sessionId: id, + }); + return; + } + // Cold rename: load, retitle, and put the session back to rest. + const lifecycle = this.deps.app.accessor.get(ISessionLifecycleService); + const handle = await lifecycle.resume(id); + if (handle === undefined) { + throw new CoreError(CoreErrorCodes.SESSION_NOT_FOUND, `Session "${id}" was not found.`); + } + try { + await handle.accessor.get(ISessionMetadata).setTitle(input.title); + } finally { + await lifecycle.close(id).catch(() => {}); + } + } + + async exportSession(input: ExportSessionInput): Promise { + const id = normalizeSessionId(input.id); + const result = await this.deps.app.accessor.get(ISessionExportService).export({ + sessionId: id, + outputPath: input.outputPath, + includeGlobalLog: input.includeGlobalLog, + version: input.version, + installSource: input.installSource, + shellEnv: input.shellEnv, + }); + this.trackSessionEvent(id, 'export'); + return result; + } + + async listSessions(options: ListSessionsOptions = {}): Promise { + const app = this.deps.app.accessor; + const page = await app.get(ISessionIndex).list({}); + const bootstrapService = app.get(IBootstrapService); + let items = page.items; + if (options.sessionId !== undefined) { + items = items.filter((summary) => summary.id === options.sessionId); + } + if (options.workDir !== undefined) { + items = items.filter((summary) => summary.cwd === options.workDir); + } + return items.map((summary) => ({ + id: summary.id, + title: summary.title, + lastPrompt: summary.lastPrompt, + // `cwd` is optional only for sessions persisted before v2 recorded it. + workDir: summary.cwd ?? '', + sessionDir: bootstrapService.sessionDir(summary.workspaceId, summary.id), + createdAt: summary.createdAt, + updatedAt: summary.updatedAt, + archived: summary.archived, + metadata: summary.custom, + })); + } + + getSession(id: string): CoreSession | undefined { + return this.activeSessions.get(id)?.session; + } + + async closeSession(id: string): Promise { + await this.activeSessions.get(id)?.session.close(); + } + + // -- Config ------------------------------------------------------------------- + + async getConfig(options: GetConfigOptions = {}): Promise { + const config = this.deps.app.accessor.get(IConfigService); + await config.ready; + if (options.reload === true) { + await config.reload(); + } + // TODO(v2-gap): G-11 — v2 exposes only the resolved config; there is no + // raw config-text projection on this surface. + return config.getAll(); + } + + async setConfig(patch: CoreConfigPatch): Promise { + const config = this.deps.app.accessor.get(IConfigService); + await config.ready; + for (const [domain, value] of Object.entries(patch)) { + await config.set(domain, value); + } + return config.getAll(); + } + + async getConfigDiagnostics(): Promise { + const config = this.deps.app.accessor.get(IConfigService); + await config.ready; + return config.diagnostics(); + } + + async removeProvider(providerId: string): Promise { + const app = this.deps.app.accessor; + await app.get(IProviderService).delete(providerId); + return app.get(IConfigService).getAll(); + } + + async getExperimentalFeatures(): Promise { + return this.deps.app.accessor.get(IFlagService).explainAll(); + } + + async ensureConfigFile(): Promise { + await mkdir(dirname(this.configPath), { recursive: true, mode: 0o700 }); + let handle: Awaited> | undefined; + try { + handle = await open(this.configPath, 'wx', 0o600); + await handle.writeFile(DEFAULT_CONFIG_FILE_TEXT, 'utf-8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') return; + throw error; + } finally { + await handle?.close(); + } + } + + // -- Plugins -------------------------------------------------------------------- + + async listPlugins(): Promise { + return this.deps.app.accessor.get(IPluginService).listPlugins(); + } + + async installPlugin(input: InstallPluginInput): Promise { + return this.deps.app.accessor.get(IPluginService).installPlugin(input); + } + + async setPluginEnabled(input: SetPluginEnabledInput): Promise { + await this.deps.app.accessor.get(IPluginService).setPluginEnabled(input); + } + + async setPluginMcpServerEnabled(input: SetPluginMcpServerEnabledInput): Promise { + await this.deps.app.accessor.get(IPluginService).setPluginMcpServerEnabled(input); + } + + async removePlugin(input: RemovePluginInput): Promise { + await this.deps.app.accessor.get(IPluginService).removePlugin(input); + } + + async reloadPlugins(): Promise { + return this.deps.app.accessor.get(IPluginService).reloadPlugins(); + } + + async getPluginInfo(input: GetPluginInfoInput): Promise { + const info = await this.deps.app.accessor.get(IPluginService).getPluginInfo(input); + if (info === undefined) { + throw new CoreError(CoreErrorCodes.PLUGIN_NOT_FOUND, `Plugin "${input.id}" was not found.`); + } + return info; + } + + async listPluginCommands(): Promise { + return this.deps.app.accessor.get(IPluginService).listPluginCommands(); + } + + // -- Telemetry / shutdown ---------------------------------------------------------- + + track(event: string, properties?: TelemetryProperties): void { + this.deps.telemetry.track(event, properties); + } + + setTelemetryContext(patch: TelemetryContextPatch): void { + this.deps.telemetry.setContext?.(patch); + } + + async close(): Promise { + // TODO(v2-gap): G-4 — v2 has no exit-drain API. When pending background + // work must finish before exit, the TUI layer waits before calling + // close(); the facade does not implement its own wait loop. + const active = [...this.activeSessions.values()]; + await Promise.all(active.map(({ session }) => session.close().catch(() => {}))); + try { + this.deps.app.dispose(); + } catch { + // The exit path must not throw. + } + } + + // -- Internals ----------------------------------------------------------------- + + /** Cold-load a session and register it; shared by resume and reload. */ + private async resumeInternal( + id: string, + input: { additionalDirs?: readonly string[] }, + ): Promise { + const handle = await this.deps.app.accessor.get(ISessionLifecycleService).resume(id); + if (handle === undefined) { + throw new CoreError(CoreErrorCodes.SESSION_NOT_FOUND, `Session "${id}" was not found.`); + } + // TODO(v2-gap): G-8 — additional dirs only live on the session-scope + // workspace context; they are not persisted across resumes. + for (const dir of input.additionalDirs ?? []) { + handle.accessor.get(ISessionWorkspaceContext).addAdditionalDir(dir); + } + return this.hydrateSession(handle); + } + + /** Ensure main, rebuild the resume snapshot, and register a `CoreSession`. */ + private async hydrateSession(handle: ISessionScopeHandle): Promise { + const main = await ensureMainAgent(handle); + // TODO(v2-gap): G-30 — v2 resume has no warning channel; + // `resumeState.warning` stays undefined. + const resumeState = await buildResumedSessionState(handle, main); + const summary = await this.projectLiveSummary(handle); + return this.registerSession(handle, summary, resumeState); + } + + private registerSession( + handle: ISessionScopeHandle, + summary: CoreSessionSummary, + resumeState: ResumedSessionState | undefined, + ): CoreSession { + const id = handle.id; + const session = new CoreSession({ + id, + handle, + app: this.deps.app, + summary, + resumeState, + onClose: async () => { + this.activeSessions.delete(id); + await this.deps.app.accessor.get(ISessionLifecycleService).close(id); + }, + }); + this.activeSessions.set(id, { session, handle }); + return session; + } + + /** Project a live session's metadata + context into `CoreSessionSummary`. */ + private async projectLiveSummary(handle: ISessionScopeHandle): Promise { + const meta = await handle.accessor.get(ISessionMetadata).read(); + const context = handle.accessor.get(ISessionContext); + const workspace = handle.accessor.get(ISessionWorkspaceContext); + return { + id: meta.id, + title: meta.title, + lastPrompt: meta.lastPrompt, + workDir: meta.cwd ?? context.cwd, + sessionDir: context.sessionDir, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + archived: meta.archived, + metadata: meta.custom, + additionalDirs: workspace.additionalDirs, + }; + } + + private scoped(sessionId: string): TelemetryClient { + return this.deps.telemetry.withContext?.({ sessionId }) ?? this.deps.telemetry; + } + + private trackSessionEvent(sessionId: string, event: string): void { + this.scoped(sessionId).track(event); + } + + private trackSessionStarted( + sessionId: string, + resumed: boolean, + sessionScoped?: TelemetryProperties, + ): void { + this.scoped(sessionId).track('session_started', { + ...this.deps.sessionStartedProperties, + ...sessionScoped, + // Canonical fields are owned by the harness and must win over any + // caller-supplied sessionStartedProperties that happen to share a key. + // `client_id` is always null here: a single-process host has no + // per-connection client id (that concept only exists for daemon + // clients). Kept as an explicit key so both producers share the same + // session_started schema. + client_id: null, + client_name: this.deps.identity?.userAgentProduct ?? null, + client_version: this.deps.identity?.version ?? null, + ui_mode: this.deps.uiMode, + resumed, + }); + } +} + +function normalizeSessionId(value: string): string { + if (typeof value !== 'string') { + throw new CoreError(CoreErrorCodes.SESSION_ID_REQUIRED, 'Session id is required.'); + } + const normalized = value.trim(); + if (normalized.length === 0) { + throw new CoreError(CoreErrorCodes.SESSION_ID_EMPTY, 'Session id cannot be empty.'); + } + return normalized; +} diff --git a/apps/kimi-code/src/core/index.ts b/apps/kimi-code/src/core/index.ts new file mode 100644 index 0000000000..e094555a0f --- /dev/null +++ b/apps/kimi-code/src/core/index.ts @@ -0,0 +1,27 @@ +/** + * `#/core` — the TUI's consumption surface over `@moonshot-ai/agent-core-v2`. + * + * Everything the TUI/entry chain needs is exported from here: the harness + * (App level), the session facade, the error model, the event merging + * helpers, the resume-replay builders, and the core-owned types (which also + * re-export the v2 names used in public signatures). Additional v2 type + * re-exports are added as Task 6-8 compile feedback demands them, not + * preemptively. + */ + +export * from './auth'; +export * from './catalog'; +export * from './errors'; +export * from './event-types'; +export * from './events'; +export * from './harness'; +export * from './replay'; +export * from './session'; +export * from './types'; +export { + buildImageCompressionCaption, + COMPACTION_SUMMARY_PREFIX, + compressImageForModel, + persistOriginalImage, + sessionMediaOriginalsDir, +} from '@moonshot-ai/agent-core-v2'; diff --git a/apps/kimi-code/src/core/replay.ts b/apps/kimi-code/src/core/replay.ts new file mode 100644 index 0000000000..103db80144 --- /dev/null +++ b/apps/kimi-code/src/core/replay.ts @@ -0,0 +1,198 @@ +/** + * Resume-replay assembly for the v2 facade (`#/core`). + * + * Projects the live v2 Session/Agent scope services into the v1-shaped + * `ResumedSessionState` the TUI hydrates from on resume. Every value is read + * through the scope handles' accessors; nothing here touches the engine + * internals directly. + * + * `replay` is folded from the agent's persisted wire records through + * `#/core/transcript` (full history, compaction cards included); records + * carry no envelope timestamps on the read path, so `time` stays `0` — the + * TUI renders records by position, not timestamp, so the zero time is safe. + */ + +import { + IAgentBlobService, + IAgentContextMemoryService, + IAgentContextSizeService, + IAgentPermissionModeService, + IAgentPermissionRulesService, + IAgentPlanService, + IAgentProfileService, + IAgentSwarmService, + IAgentTaskService, + IAgentToolRegistryService, + IAgentUsageService, + IAgentWireRecordService, + ISessionMetadata, + ISessionTodoService, + MAIN_AGENT_ID, + type AgentMeta, + type IAgentScopeHandle, + type ISessionScopeHandle, + type SessionMeta, + type ToolInfo, +} from '@moonshot-ai/agent-core-v2'; + +import { + reduceTranscript, + rehydrateTranscript, + type TranscriptEntry, + type TranscriptMessage, +} from './transcript'; +import type { + AgentReplayRecord, + ResumedAgentMeta, + ResumedAgentState, + ResumedSessionMetadata, + ResumedSessionState, +} from './types'; + +/** + * Assemble the per-agent resume snapshots. v2 resume restores only the main + * agent (subagents are lazily re-created on their next prompt), so the map + * carries a single `main` entry. + */ +export async function buildResumedAgents( + session: ISessionScopeHandle, + mainAgent: IAgentScopeHandle, +): Promise> { + const { accessor } = mainAgent; + const profile = accessor.get(IAgentProfileService); + const data = profile.data(); + const history = accessor.get(IAgentContextMemoryService).get(); + const replay = await buildReplayFromWireRecords(accessor); + const tools: Array = accessor + .get(IAgentToolRegistryService) + .list() + .map((tool) => ({ ...tool, active: profile.isToolActive(tool.name, tool.source) })); + const state: ResumedAgentState = { + type: 'main', + config: { + cwd: data.cwd, + // TODO(v2-gap): v2 has no per-agent provider config DTO; always undefined. + provider: undefined, + modelAlias: data.modelAlias, + modelCapabilities: data.modelCapabilities, + profileName: data.profileName, + // v2 names the field `thinkingLevel`; the v1 wire shape says `thinkingEffort`. + thinkingEffort: data.thinkingLevel, + systemPrompt: data.systemPrompt, + }, + context: { history, tokenCount: accessor.get(IAgentContextSizeService).get().size }, + replay, + permission: { + mode: accessor.get(IAgentPermissionModeService).mode, + rules: [...accessor.get(IAgentPermissionRulesService).rules], + }, + plan: await accessor.get(IAgentPlanService).status(), + swarmMode: accessor.get(IAgentSwarmService).isActive, + usage: accessor.get(IAgentUsageService).status(), + tools, + // The todo list is session-shared state, not per-agent. + toolStore: { todo: session.accessor.get(ISessionTodoService).getTodos() }, + // Include finished tasks so the TUI can replay their terminal status. + background: accessor.get(IAgentTaskService).list(false), + }; + return { [MAIN_AGENT_ID]: state }; +} + +/** + * Fold the agent's persisted wire records into v1 replay records. The wire + * log is the full event-sourced history (compaction included), so unlike the + * context view this survives a resume intact. Blob references in message + * content are rehydrated back to inline parts for display. + */ +async function buildReplayFromWireRecords( + accessor: IAgentScopeHandle['accessor'], +): Promise { + const records = accessor.get(IAgentWireRecordService).getRecords(); + const { entries } = reduceTranscript(records); + const rehydrated = await rehydrateTranscript(entries, accessor.get(IAgentBlobService)); + return rehydrated.map(projectTranscriptEntry); +} + +function projectTranscriptEntry(entry: TranscriptEntry): AgentReplayRecord { + switch (entry.type) { + case 'message': + return { time: 0, type: 'message', message: toReplayMessage(entry.message) }; + case 'compaction': + return { + time: 0, + type: 'compaction', + result: { + summary: entry.summary, + compactedCount: entry.compactedCount ?? 0, + tokensBefore: entry.tokensBefore ?? 0, + tokensAfter: entry.tokensAfter ?? 0, + }, + instruction: undefined, + }; + case 'goal_updated': + return { time: 0, type: 'goal_updated', snapshot: entry.snapshot, change: entry.change }; + case 'plan_updated': + return { time: 0, type: 'plan_updated', enabled: entry.enabled }; + case 'config_updated': + return { time: 0, type: 'config_updated', config: entry.config }; + case 'permission_updated': + return { time: 0, type: 'permission_updated', mode: entry.mode }; + case 'approval_result': + return { time: 0, type: 'approval_result', record: entry.record }; + } +} + +/** Display message → the v2 `ContextMessage` the replay record carries. */ +function toReplayMessage(message: TranscriptMessage) { + return { + role: message.role, + content: [...message.content], + toolCalls: [...message.toolCalls], + toolCallId: message.toolCallId, + isError: message.isError, + origin: message.origin, + }; +} + +/** Full resume snapshot: agents plus the projected session metadata. */ +export async function buildResumedSessionState( + session: ISessionScopeHandle, + mainAgent: IAgentScopeHandle, +): Promise { + const agents = await buildResumedAgents(session, mainAgent); + const meta = await session.accessor.get(ISessionMetadata).read(); + return { sessionMetadata: projectSessionMetadata(meta), agents }; +} + +/** v2 `SessionMeta` (epoch-ms timestamps) → v1 shape (ISO strings, defaults). */ +function projectSessionMetadata(meta: SessionMeta): ResumedSessionMetadata { + const agents: Record = {}; + for (const [id, entry] of Object.entries(meta.agents ?? {})) { + agents[id] = projectAgentMeta(id, entry); + } + return { + createdAt: new Date(meta.createdAt).toISOString(), + updatedAt: new Date(meta.updatedAt).toISOString(), + title: meta.title ?? '', + isCustomTitle: meta.isCustomTitle ?? false, + agents, + }; +} + +/** + * `labels` is the canonical v2 store for recorded values; the bare + * `type`/`parentAgentId`/`swarmItem` fields are legacy read-compat duplicates + * (their conflicting declarations are the known sessionMetadata.ts baseline + * tsc errors), so prefer the labels path and only fall back to the bare + * fields. A non-main/sub `type` (v2 also knows `independent`) maps through + * the id-based fallback, since the v1 shape has no such value. + */ +function projectAgentMeta(id: string, meta: AgentMeta): ResumedAgentMeta { + const type = meta.type === 'main' || meta.type === 'sub' ? meta.type : id === MAIN_AGENT_ID ? 'main' : 'sub'; + return { + homedir: meta.homedir, + type, + parentAgentId: meta.labels?.['parentAgentId'] ?? meta.parentAgentId ?? null, + swarmItem: meta.labels?.['swarmItem'] ?? meta.swarmItem, + }; +} diff --git a/apps/kimi-code/src/core/session.ts b/apps/kimi-code/src/core/session.ts new file mode 100644 index 0000000000..c6c0f049c0 --- /dev/null +++ b/apps/kimi-code/src/core/session.ts @@ -0,0 +1,560 @@ +/** + * Session-level facade for the v2 engine (`#/core`). + * + * `CoreSession` is the TUI's single session object. Every method resolves the + * target Agent/Session-scope service through the DI accessors + * (`handle.accessor.get(IXxxService)`) and forwards with at most a light + * projection. It owns the merged `SessionEvent` stream (`attachSessionEvents`), + * the approval/question sub-objects layered over the interaction kernel, and a + * synchronous `summary` snapshot kept fresh from `session.meta.updated` + * events. Session re-creation (reload/resume/fork) is a harness concern and is + * intentionally not part of this class. + */ + +import { + ensureMainAgent, + IAgentContextSizeService, + IAgentFullCompactionService, + IAgentGoalService, + IAgentLifecycleService, + IAgentMcpService, + IAgentPermissionModeService, + IAgentPlanService, + IAgentProfileService, + IAgentPromptLegacyService, + IAgentRPCService, + IAgentSwarmService, + IAgentTaskService, + IAgentUsageService, + IConfigService, + IModelResolver, + ISessionApprovalService, + ISessionBtwService, + ISessionContext, + ISessionInteractionService, + ISessionMetadata, + ISessionQuestionService, + ISessionSkillCatalog, + ISessionWorkspaceCommandService, + MAIN_AGENT_ID, + summarizeSkill, + type ContentPart, + type ContextMessage, + type IAgentScopeHandle, + type Interaction, + type ISessionScopeHandle, + type Scope, +} from '@moonshot-ai/agent-core-v2'; + +import { CoreError, CoreErrorCodes } from './errors'; +import { attachSessionEvents } from './events'; +import type { + AgentTaskInfo, + ApprovalResponse, + CoreApprovalRequest, + CoreQuestionRequest, + CoreSessionSummary, + CreateGoalInput, + GoalToolResult, + McpServerEntry, + PendingApproval, + PendingQuestion, + PermissionMode, + PlanData, + PromptPart, + QuestionResult, + ResumedSessionState, + SessionEvent, + SessionMeta, + SessionStatus, + SessionWarning, + ShellCommandResult, + SkillSummary, + SwarmModeTrigger, + UsageStatus, + WorkspaceAdditionalDirsResult, +} from './types'; + +export interface CoreSessionInit { + readonly id: string; + readonly handle: ISessionScopeHandle; + readonly app: Scope; + /** Initial summary snapshot projected by the harness at create/resume time. */ + readonly summary: CoreSessionSummary; + /** Injected by the harness on resume/fork; `undefined` for fresh sessions. */ + readonly resumeState?: ResumedSessionState; + /** Harness deregistration callback (includes the lifecycle close). */ + readonly onClose: () => Promise; +} + +export interface CoreApprovals { + list(): readonly PendingApproval[]; + onDidChangePending(listener: () => void): () => void; + onDidResolve(listener: (id: string) => void): () => void; + decide(id: string, response: ApprovalResponse): void; +} + +export interface CoreQuestions { + list(): readonly PendingQuestion[]; + onDidChangePending(listener: () => void): () => void; + onDidResolve(listener: (id: string) => void): () => void; + answer(id: string, result: Exclude): void; + dismiss(id: string): void; +} + +export class CoreSession { + readonly id: string; + readonly approvals: CoreApprovals; + readonly questions: CoreQuestions; + + private readonly listeners = new Set<(event: SessionEvent) => void>(); + private detachEvents: (() => void) | undefined; + private closed = false; + private summarySnapshot: CoreSessionSummary; + + constructor(private readonly init: CoreSessionInit) { + this.id = init.id; + this.summarySnapshot = init.summary; + this.approvals = this.buildApprovals(); + this.questions = this.buildQuestions(); + this.detachEvents = attachSessionEvents({ + session: init.handle, + sessionId: init.id, + app: init.app, + emit: (event) => this.deliver(event), + }); + } + + /** Working directory frozen at session creation (`ISessionContext.cwd`). */ + get workDir(): string { + return this.init.handle.accessor.get(ISessionContext).cwd; + } + + /** + * Synchronous summary snapshot. `ISessionMetadata.read()` is async, but the + * TUI reads `session.summary` synchronously, so the harness injects the + * initial projection and `session.meta.updated` events keep it fresh. + */ + get summary(): CoreSessionSummary { + return this.summarySnapshot; + } + + // -- Conversation flow ---------------------------------------------------- + + // `prompt` stays on the v1 Legacy service for its FIFO queue: a prompt sent + // while a turn is running is queued and drained in order. The native + // `IAgentRPCService.prompt` returns `undefined` (does not queue) when the + // agent is busy or a hook blocks it, which would drop back-to-back input. + // Everything else that mutates a turn (`steer`/`cancel`/`runShellCommand`/ + // `undoHistory`/`activateSkill`/`setPermission`/`getContext`/`cancelCompaction`) + // goes through the native `IAgentRPCService`. The split is intentional. + async prompt(parts: readonly PromptPart[], options?: { agentId?: string }): Promise { + const agent = await this.agent(options?.agentId); + await agent.accessor.get(IAgentPromptLegacyService).submit({ content: [...parts] }); + } + + async steer(parts: readonly PromptPart[], options?: { agentId?: string }): Promise { + const agent = await this.agent(options?.agentId); + // The RPC facade takes v2-native `ContentPart`s while the public surface + // uses the v1 protocol parts (same `text` shape the TUI steers with); + // media parts would need the promptLegacy conversion, which v2 keeps + // module-private. + await agent.accessor + .get(IAgentRPCService) + .steer({ input: parts as unknown as readonly ContentPart[] }); + } + + async cancel(options?: { agentId?: string }): Promise { + const agent = await this.agent(options?.agentId); + await agent.accessor.get(IAgentRPCService).cancel({}); + } + + async runShellCommand( + command: string, + options: { commandId: string; agentId?: string }, + ): Promise { + const agent = await this.agent(options.agentId); + return await agent.accessor + .get(IAgentRPCService) + .runShellCommand({ command, commandId: options.commandId }); + } + + async cancelShellCommand(commandId: string, options?: { agentId?: string }): Promise { + const agent = await this.agent(options?.agentId); + await agent.accessor.get(IAgentRPCService).cancelShellCommand({ commandId }); + } + + /** Returns the number of history entries actually undone. */ + async undoHistory(count: number, options?: { agentId?: string }): Promise { + const agent = await this.agent(options?.agentId); + return await agent.accessor.get(IAgentRPCService).undoHistory({ count }); + } + + async activateSkill(input: { name: string; args?: string; agentId?: string }): Promise { + const agent = await this.agent(input.agentId); + await agent.accessor.get(IAgentRPCService).activateSkill({ name: input.name, args: input.args }); + } + + async activatePluginCommand(input: { + pluginId: string; + commandName: string; + args?: string; + agentId?: string; + }): Promise { + const agent = await this.agent(input.agentId); + await agent.accessor.get(IAgentRPCService).activatePluginCommand({ + pluginId: input.pluginId, + commandName: input.commandName, + args: input.args, + }); + } + + // -- Modes ------------------------------------------------------------------ + + async setModel( + model: string, + options?: { agentId?: string }, + ): Promise<{ model: string; providerName?: string }> { + const agent = await this.agent(options?.agentId); + return await agent.accessor.get(IAgentProfileService).setModel(model); + } + + async setThinking(level: string, options?: { agentId?: string }): Promise { + const agent = await this.agent(options?.agentId); + agent.accessor.get(IAgentProfileService).setThinking(level); + } + + async setPermission(mode: PermissionMode, options?: { agentId?: string }): Promise { + const agent = await this.agent(options?.agentId); + await agent.accessor.get(IAgentRPCService).setPermission({ mode }); + } + + async setPlanMode(on: boolean, options?: { agentId?: string }): Promise { + const agent = await this.agent(options?.agentId); + const plan = agent.accessor.get(IAgentPlanService); + if (on) await plan.enter(); + else plan.cancel(); + } + + async setSwarmMode( + on: boolean, + options?: { agentId?: string; trigger?: SwarmModeTrigger }, + ): Promise { + const agent = await this.agent(options?.agentId); + const swarm = agent.accessor.get(IAgentSwarmService); + if (on) swarm.enter(options?.trigger ?? 'manual'); + else swarm.exit(); + } + + /** Returns `false` when a compaction is already in flight. */ + async compact(instruction?: string): Promise { + const agent = await this.agent(); + return agent.accessor.get(IAgentFullCompactionService).begin({ source: 'manual', instruction }); + } + + async cancelCompaction(): Promise { + const agent = await this.agent(); + await agent.accessor.get(IAgentRPCService).cancelCompaction({}); + } + + async clearPlan(): Promise { + const agent = await this.agent(); + await agent.accessor.get(IAgentPlanService).clear(); + } + + async getPlan(): Promise { + const agent = await this.agent(); + return await agent.accessor.get(IAgentPlanService).status(); + } + + // -- Queries ---------------------------------------------------------------- + + async getStatus(): Promise { + const main = await this.agent(); + const { accessor } = main; + const profile = accessor.get(IAgentProfileService); + const model = profile.getModel(); + const contextTokens = accessor.get(IAgentContextSizeService).get().size; + // Aggregate from the main agent's native services instead of the v1 + // `ISessionLegacyService` wire projection. Mirror v1's + // `resolveDefaultModelContextTokens`: when no model is bound yet (fresh + // session), report the configured default model's context window; fall back + // to 0 when none is configured or it cannot be resolved (e.g. auth not ready). + let maxContextTokens = profile.getModelCapabilities().max_context_tokens ?? 0; + if (model === '') { + const defaultModel = accessor.get(IConfigService).get('defaultModel'); + if (typeof defaultModel !== 'string' || defaultModel.length === 0) { + maxContextTokens = 0; + } else { + try { + maxContextTokens = accessor.get(IModelResolver).resolve(defaultModel).capabilities.max_context_tokens; + } catch { + maxContextTokens = 0; + } + } + } + return { + model: model === '' ? undefined : model, + thinkingEffort: profile.data().thinkingLevel, + permission: accessor.get(IAgentPermissionModeService).mode, + planMode: (await accessor.get(IAgentPlanService).status()) !== null, + swarmMode: accessor.get(IAgentSwarmService).isActive, + contextTokens, + maxContextTokens, + contextUsage: maxContextTokens > 0 ? contextTokens / maxContextTokens : 0, + usage: accessor.get(IAgentUsageService).status(), + }; + } + + async getContext(options?: { agentId?: string }): Promise { + const agent = await this.agent(options?.agentId); + const data = await agent.accessor.get(IAgentRPCService).getContext({}); + return data.history; + } + + async getUsage(options?: { agentId?: string }): Promise { + const agent = await this.agent(options?.agentId); + return agent.accessor.get(IAgentUsageService).status(); + } + + async getGoal(): Promise { + const agent = await this.agent(); + return agent.accessor.get(IAgentGoalService).getGoal(); + } + + async getSessionWarnings(): Promise { + // TODO(v2-gap): G-9/G-12 — v2 only tracks the AGENTS.md size warning + // (profile-cached); other v1 warning sources have no v2 equivalent yet. + const agent = await this.agent(); + const agentsMdWarning = agent.accessor.get(IAgentProfileService).getAgentsMdWarning(); + if (agentsMdWarning === undefined) return []; + return [{ code: 'agents-md-oversized', message: agentsMdWarning, severity: 'warning' }]; + } + + async getMcpStartupMetrics(): Promise<{ durationMs: number | undefined }> { + const agent = await this.agent(); + return { durationMs: agent.accessor.get(IAgentMcpService).initialLoadDurationMs() }; + } + + async listMcpServers(): Promise { + const agent = await this.agent(); + return agent.accessor.get(IAgentMcpService).list(); + } + + async listSkills(): Promise { + const skills = this.init.handle.accessor.get(ISessionSkillCatalog); + await skills.ready; + return skills.catalog.listSkills().map((skill) => summarizeSkill(skill)); + } + + getResumeState(): ResumedSessionState | undefined { + return this.init.resumeState; + } + + async getSessionMetadata(): Promise { + return await this.init.handle.accessor.get(ISessionMetadata).read(); + } + + // -- Goal / background tasks ------------------------------------------------- + + async createGoal(input: CreateGoalInput): Promise { + const agent = await this.agent(); + return { goal: await agent.accessor.get(IAgentGoalService).createGoal(input) }; + } + + async pauseGoal(): Promise { + const agent = await this.agent(); + return { goal: await agent.accessor.get(IAgentGoalService).pauseGoal() }; + } + + async resumeGoal(): Promise { + const agent = await this.agent(); + return { goal: await agent.accessor.get(IAgentGoalService).resumeGoal() }; + } + + async cancelGoal(): Promise { + const agent = await this.agent(); + return { goal: await agent.accessor.get(IAgentGoalService).cancelGoal() }; + } + + async listBackgroundTasks(options?: { + activeOnly?: boolean; + limit?: number; + agentId?: string; + }): Promise { + const agent = await this.agent(options?.agentId); + return agent.accessor.get(IAgentTaskService).list(options?.activeOnly, options?.limit); + } + + async getBackgroundTaskOutput( + taskId: string, + options?: { tail?: number; agentId?: string }, + ): Promise { + const agent = await this.agent(options?.agentId); + return await agent.accessor.get(IAgentTaskService).readOutput(taskId, options?.tail); + } + + /** Resolves with the terminal task info, or `undefined` for an unknown task. */ + async stopBackgroundTask(taskId: string, reason?: string): Promise { + const agent = await this.agent(); + return await agent.accessor.get(IAgentTaskService).stop(taskId, reason); + } + + /** Resolves with the detached task info, or `undefined` when it already finished. */ + async detachBackgroundTask(taskId: string): Promise { + const agent = await this.agent(); + return agent.accessor.get(IAgentTaskService).detach(taskId); + } + + // -- Orchestration ----------------------------------------------------------- + + /** Fork the main agent into a side-question child; returns the child agent id. */ + async startBtw(): Promise { + return await this.init.handle.accessor.get(ISessionBtwService).start(); + } + + async addAdditionalDir(input: { + path: string; + persist?: boolean; + }): Promise { + return await this.init.handle.accessor + .get(ISessionWorkspaceCommandService) + .addAdditionalDir({ path: input.path, persist: input.persist }); + } + + async generateAgentsMd(): Promise { + // TODO(v2-gap): G-6 — v2 declares `generateAgentsMd` on its RPC surface but + // ships no native implementation. The facade must not orchestrate the v1 + // swarm flow in the client; surface as not-implemented until v2 lands it. + throw new CoreError( + CoreErrorCodes.NOT_IMPLEMENTED, + '`/init` (AGENTS.md generation) is not implemented on the v2 engine yet.', + ); + } + + // -- Events / interactions / lifecycle ---------------------------------------- + + onEvent(listener: (event: SessionEvent) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * @internal Inject an event into this session's stream. Used by the harness + * to re-emit `session.meta.updated` after a rename — v2's `setTitle` does + * not publish a global event. Goes through the same delivery path as engine + * events so the summary snapshot stays in sync. + */ + emitEvent(event: SessionEvent): void { + this.deliver(event); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.detachEvents?.(); + this.detachEvents = undefined; + this.listeners.clear(); + await this.init.onClose(); + } + + // -- Internals ----------------------------------------------------------------- + + /** Single delivery path for engine and harness-injected events. */ + private deliver(event: SessionEvent): void { + this.applySummaryEvent(event); + // Snapshot so listeners unsubscribing/subscribing mid-dispatch do not + // affect this delivery round. + const snapshot = [...this.listeners]; + for (const listener of snapshot) listener(event); + } + + /** Resolve the target agent handle; the main agent is created lazily. */ + private async agent(agentId?: string): Promise { + const id = agentId ?? MAIN_AGENT_ID; + if (id === MAIN_AGENT_ID) return await ensureMainAgent(this.init.handle); + const handle = this.init.handle.accessor.get(IAgentLifecycleService).getHandle(id); + if (handle === undefined) { + throw new CoreError( + CoreErrorCodes.AGENT_NOT_FOUND, + `Agent "${id}" was not found in session "${this.id}"`, + ); + } + return handle; + } + + /** Keep the synchronous summary snapshot in step with `session.meta.updated`. */ + private applySummaryEvent(event: SessionEvent): void { + // `session.meta.updated` is an app-bus projection (not part of the agent-bus + // DomainEvent union); after the type guard it narrows to the meta arm, whose + // optional `title`/`patch` are readable directly. + if (event.type !== 'session.meta.updated') return; + const patchTitle = event.patch?.['title']; + const patchLastPrompt = event.patch?.['lastPrompt']; + const nextTitle = + typeof event.title === 'string' + ? event.title + : typeof patchTitle === 'string' + ? patchTitle + : undefined; + const lastPrompt = typeof patchLastPrompt === 'string' ? patchLastPrompt : undefined; + if (nextTitle === undefined && lastPrompt === undefined) return; + this.summarySnapshot = { + ...this.summarySnapshot, + title: nextTitle ?? this.summarySnapshot.title, + lastPrompt: lastPrompt ?? this.summarySnapshot.lastPrompt, + }; + } + + private buildApprovals(): CoreApprovals { + const kernel = this.init.handle.accessor.get(ISessionInteractionService); + const approvals = this.init.handle.accessor.get(ISessionApprovalService); + const project = (i: Interaction): PendingApproval => { + const payload = i.payload as CoreApprovalRequest; + return { + id: i.id, + agentId: i.origin.agentId ?? payload.agentId ?? MAIN_AGENT_ID, + request: payload, + }; + }; + return { + list: () => kernel.listPending('approval').map(project), + onDidChangePending: (listener) => { + const d = kernel.onDidChangePending(() => listener()); + return () => d.dispose(); + }, + onDidResolve: (listener) => { + const d = kernel.onDidResolve(({ id }) => listener(id)); + return () => d.dispose(); + }, + decide: (id, response) => approvals.decide(id, response), + }; + } + + private buildQuestions(): CoreQuestions { + const kernel = this.init.handle.accessor.get(ISessionInteractionService); + const questions = this.init.handle.accessor.get(ISessionQuestionService); + const project = (i: Interaction): PendingQuestion => ({ + id: i.id, + // TODO(v2-gap): G-20 — `QuestionRequest` carries no agentId; fall back to + // the interaction origin, then main. + agentId: i.origin.agentId ?? MAIN_AGENT_ID, + request: i.payload as CoreQuestionRequest, + }); + return { + list: () => kernel.listPending('question').map(project), + onDidChangePending: (listener) => { + const d = kernel.onDidChangePending(() => listener()); + return () => d.dispose(); + }, + onDidResolve: (listener) => { + const d = kernel.onDidResolve(({ id }) => listener(id)); + return () => d.dispose(); + }, + answer: (id, result) => questions.answer(id, result), + dismiss: (id) => questions.dismiss(id), + }; + } +} diff --git a/apps/kimi-code/src/core/transcript.ts b/apps/kimi-code/src/core/transcript.ts new file mode 100644 index 0000000000..0c46fb0f84 --- /dev/null +++ b/apps/kimi-code/src/core/transcript.ts @@ -0,0 +1,609 @@ +/** + * TUI transcript model (`#/core/transcript`) — folds an agent's wire records + * into RENDERING transcript entries. + * + * Why this exists, and why it is not `ContextMessage`: + * the context history is the model-facing view — `context.apply_compaction` + * rewrites it into `[...keptUserMessages, summary]`, tool results are shaped + * for the LLM, and injections/reminders come and go. The UI needs the opposite + * projection: the full history, compaction cards with token counts, and display + * content. The wire log keeps every record, so this module re-reduces the + * records into a dedicated `TranscriptEntry` stream. `TranscriptMessage` + * deliberately mirrors but does NOT alias `ContextMessage`: the two must not + * be assignable to each other, so a transcript can never be fed to the model + * by accident (and vice versa). + * + * Form: the reducer set is declared through v2's public `defineDerivedModel` + * (the same shape a wire-attached derived model would use), but the TUI folds + * it manually over `IAgentWireRecordService.getRecords()` — resume's + * `wire.replay` runs inside v2 before the facade sees the session, so a + * facade-side `wire.attach` would always miss the restore fold. Replay is + * read exactly once per resume, so a one-shot reduce loses nothing. + * + * Record → entry mapping (op types not exported by the v2 barrel are keyed by + * their literal wire names; payloads are declared locally): + * context.append_message → message entry (deferred while a tool exchange is open) + * context.append_loop_event → folds into assistant / tool entries + * context.apply_compaction → compaction card entry (full history kept; + * carries summary + token counts from the record) + * context.undo / context.clear → truncate tail / floor (mirrors contextTranscript) + * goal.create / goal.update → goal_updated (clear/forked: no entry, no card UI) + * plan_mode.enter / exit / cancel → plan_updated + * permission.set_mode → permission_updated + * permission.record_approval_result → approval_result + * config.update → config_updated + * turn.prompt, full_compaction.* → no entries (turn counter op; compaction + * cards come only from context.apply_compaction + * to avoid double-rendering) + * + * Mirrors v2's `reduceContextTranscript` + * (`agent-core-v2/src/agent/contextMemory/contextTranscript.ts`) semantics for + * the context ops; diverges only where the transcript is not the context: + * compaction keeps the full history, and the undo boundary is the compaction + * CARD entry (the summary message never becomes an entry here). + */ + +import { + COMPACTION_SUMMARY_PREFIX, + contextAppendLoopEvent, + contextAppendMessage, + contextApplyCompaction, + contextClear, + contextUndo, + defineDerivedModel, + isRealUserInput, + planModeCancel, + planModeEnter, + planModeExit, + readContextCompactionSummary, + type ContentPart, + type ContextMessage, + type GoalBudgetLimits, + type GoalChange, + type GoalSnapshot, + type GoalStatus, + type LoopRecordedEvent, + type PermissionApprovalResultRecord, + type PermissionMode, + type PromptOrigin, + type ToolCall, +} from '@moonshot-ai/agent-core-v2'; + +const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = + 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.'; + +/** + * Display-side message. Field set intentionally mirrors `ContextMessage` + * minus the model-facing machinery; the distinct type name is the isolation + * boundary that keeps transcripts out of LLM input. `role: 'system'` is kept + * (not folded into 'user') so the renderer can skip it like the live path. + */ +export interface TranscriptMessage { + readonly role: 'user' | 'assistant' | 'tool' | 'system'; + readonly content: readonly ContentPart[]; + readonly toolCalls: readonly ToolCall[]; + readonly toolCallId?: string; + readonly isError?: boolean; + readonly origin?: PromptOrigin; +} + +export interface TranscriptCompactionEntry { + readonly type: 'compaction'; + readonly summary: string; + readonly tokensBefore?: number; + readonly tokensAfter?: number; + readonly compactedCount?: number; +} + +export interface TranscriptGoalEntry { + readonly type: 'goal_updated'; + readonly snapshot: GoalSnapshot; + readonly change: GoalChange | { readonly kind: 'created' }; +} + +/** `config.update` wire payload (v2 `ConfigUpdatePayload`; not barrel-exported). */ +export interface ConfigUpdateWirePayload { + readonly cwd?: string; + readonly modelAlias?: string; + readonly profileName?: string; + readonly thinkingEffort?: string; + readonly systemPrompt?: string; +} + +/** + * Persistent goal fields the fold tracks (v2 `GoalState`; not barrel-exported). + * Budget remaining/reached fields are derived live by the goal service, so the + * replay snapshot approximates them — see {@link snapshotFromGoalStateLike}. + */ +interface GoalStateLike { + readonly goalId: string; + readonly objective: string; + readonly completionCriterion?: string; + readonly status: GoalStatus; + readonly turnsUsed: number; + readonly tokensUsed: number; + readonly wallClockMs: number; + readonly budgetLimits: GoalBudgetLimits; + readonly terminalReason?: string; +} + +export type TranscriptEntry = + | { readonly type: 'message'; readonly message: TranscriptMessage } + | TranscriptCompactionEntry + | TranscriptGoalEntry + | { readonly type: 'plan_updated'; readonly enabled: boolean } + | { readonly type: 'config_updated'; readonly config: ConfigUpdateWirePayload } + | { readonly type: 'permission_updated'; readonly mode: PermissionMode } + | { readonly type: 'approval_result'; readonly record: PermissionApprovalResultRecord }; + +interface MutableMessage { + role: TranscriptMessage['role']; + content: ContentPart[]; + toolCalls: ToolCall[]; + toolCallId?: string; + isError?: boolean; + origin?: PromptOrigin; +} + +/** + * Fold working set. Must live inside the state (not a closure): the same + * reducers serve a live fold and a silent replay fold, and closure state + * would be corrupted by replay. Internal — not part of the public contract. + */ +interface TranscriptWorking { + readonly openSteps: ReadonlyMap; + readonly pendingToolResultIds: ReadonlySet; + readonly deferred: readonly MutableMessage[]; + readonly clearFloor: number; + readonly goal: GoalStateLike | null; +} + +export interface TranscriptModelState { + readonly entries: readonly TranscriptEntry[]; + /** @internal fold working set; consumers must not read this. */ + readonly working: TranscriptWorking; +} + +const INITIAL_WORKING: TranscriptWorking = { + openSteps: new Map(), + pendingToolResultIds: new Set(), + deferred: [], + clearFloor: 0, + goal: null, +}; + +/** Blob loader subset of `IAgentBlobService` used by {@link rehydrateTranscript}. */ +export interface TranscriptBlobLoader { + loadParts(parts: readonly ContentPart[]): Promise; +} + +// -- payload shapes for ops the v2 barrel does not export ------------------ + +interface GoalCreateWirePayload { + readonly goalId: string; + readonly objective: string; + readonly completionCriterion?: string; +} + +interface GoalUpdateWirePayload { + readonly status?: GoalStatus; + readonly reason?: string; + readonly turnsUsed?: number; + readonly tokensUsed?: number; + readonly wallClockMs?: number; + readonly budgetLimits?: GoalStateLike['budgetLimits']; + readonly actor?: GoalChange['actor']; +} + +/** Minimal ContextMessage shape the fold consumes (wire payload side). */ +interface ContextMessageLike { + readonly role: TranscriptMessage['role']; + readonly content: readonly ContentPart[]; + readonly toolCalls: readonly ToolCall[]; + readonly toolCallId?: string; + readonly isError?: boolean; + readonly origin?: PromptOrigin; +} + +// -- fold implementation ---------------------------------------------------- + +function toMutableMessage(message: ContextMessageLike): MutableMessage { + return { + role: message.role, + content: [...message.content], + toolCalls: [...message.toolCalls], + ...(message.toolCallId !== undefined ? { toolCallId: message.toolCallId } : {}), + ...(message.isError !== undefined ? { isError: message.isError } : {}), + ...(message.origin !== undefined ? { origin: message.origin } : {}), + }; +} + +function pushMessageEntry( + entries: readonly TranscriptEntry[], + message: MutableMessage, +): readonly TranscriptEntry[] { + return [...entries, { type: 'message', message: { ...message } }]; +} + +function flushDeferred(working: TranscriptWorking): { + entries: readonly TranscriptEntry[]; + working: TranscriptWorking; +} { + if (working.pendingToolResultIds.size > 0 || working.deferred.length === 0) { + return { entries: [], working }; + } + return { + entries: working.deferred.map((message) => ({ type: 'message' as const, message: { ...message } })), + working: { ...working, deferred: [] }, + }; +} + +function closeInterruptedTools( + entries: readonly TranscriptEntry[], + working: TranscriptWorking, +): { entries: readonly TranscriptEntry[]; working: TranscriptWorking } { + if (working.pendingToolResultIds.size === 0) { + return { entries, working }; + } + let out = entries; + for (const toolCallId of working.pendingToolResultIds) { + out = pushMessageEntry(out, { + role: 'tool', + content: [{ type: 'text', text: TOOL_INTERRUPTED_ON_RESUME_OUTPUT }], + toolCalls: [], + toolCallId, + isError: true, + }); + } + const flushed = flushDeferred({ ...working, pendingToolResultIds: new Set() }); + return { entries: [...out, ...flushed.entries], working: flushed.working }; +} + +function applyLoopEvent( + state: TranscriptModelState, + event: LoopRecordedEvent, +): TranscriptModelState { + const { entries, working } = state; + switch (event.type) { + case 'step.begin': { + const closed = closeInterruptedTools(entries, working); + const step: MutableMessage = { role: 'assistant', content: [], toolCalls: [] }; + return { + entries: pushMessageEntry(closed.entries, step), + working: { + ...closed.working, + openSteps: new Map([...closed.working.openSteps, [event.uuid, step]]), + }, + }; + } + case 'step.end': { + const openSteps = new Map(working.openSteps); + openSteps.delete(event.uuid); + const flushed = flushDeferred({ ...working, openSteps }); + return { entries: [...entries, ...flushed.entries], working: flushed.working }; + } + case 'content.part': { + const step = working.openSteps.get(event.stepUuid); + if (step === undefined) return state; + // Lenient where the live reducer throws: a dangling part in a damaged + // file should not take the whole transcript down. + step.content.push(event.part as ContentPart); + return state; + } + case 'tool.call': { + const step = working.openSteps.get(event.stepUuid); + if (step === undefined) return state; + const call: ToolCall = { + type: 'function', + id: event.toolCallId, + name: event.name, + arguments: event.args === undefined ? null : JSON.stringify(event.args), + ...(event.extras !== undefined ? { extras: event.extras } : {}), + }; + step.toolCalls.push(call); + return { + entries, + working: { + ...working, + pendingToolResultIds: new Set([...working.pendingToolResultIds, event.toolCallId]), + }, + }; + } + case 'tool.result': { + if (!working.pendingToolResultIds.has(event.toolCallId)) return state; + const pendingToolResultIds = new Set(working.pendingToolResultIds); + pendingToolResultIds.delete(event.toolCallId); + const appended = pushMessageEntry(entries, { + role: 'tool', + content: rawToolResultContent(event.result.output), + toolCalls: [], + toolCallId: event.toolCallId, + isError: event.result.isError, + }); + const flushed = flushDeferred({ ...working, pendingToolResultIds }); + return { entries: [...appended, ...flushed.entries], working: flushed.working }; + } + } +} + +function applyAppendMessage( + state: TranscriptModelState, + message: MutableMessage, +): TranscriptModelState { + const { entries, working } = state; + if (working.pendingToolResultIds.size > 0) { + return { entries, working: { ...working, deferred: [...working.deferred, message] } }; + } + return { entries: pushMessageEntry(entries, message), working }; +} + +function applyTranscriptUndo(state: TranscriptModelState, count: number): TranscriptModelState { + if (count <= 0) return state; + let removedUserCount = 0; + const entries = [...state.entries]; + for (let i = entries.length - 1; i >= state.working.clearFloor; i--) { + const entry = entries[i]!; + // The undo boundary is the compaction card: the summary never becomes a + // message entry here, so the card itself marks where the folded past begins. + if (entry.type === 'compaction') break; + // Non-message entries (goal/plan/permission/...) survive an undo, exactly + // like v1's replayBuilder.removeLastMessages only drops message records. + if (entry.type !== 'message') continue; + const { message } = entry; + if (message.origin?.kind === 'injection') continue; + entries.splice(i, 1); + if (isRealUserInput(toContextMessageView(message))) { + removedUserCount++; + if (removedUserCount >= count) break; + } + } + return { + entries, + working: { ...state.working, openSteps: new Map(), pendingToolResultIds: new Set(), deferred: [] }, + }; +} + +function applyTranscriptClear(state: TranscriptModelState): TranscriptModelState { + // Keep prior transcript entries but reset the fold floor, exactly like the + // contextTranscript clear semantics. + return { + entries: state.entries, + working: { ...state.working, clearFloor: state.entries.length, openSteps: new Map(), pendingToolResultIds: new Set(), deferred: [] }, + }; +} + +function applyCompactionEntry( + state: TranscriptModelState, + payload: unknown, +): TranscriptModelState { + const record = payload as Record; + // Every record shape funnels through the prefixed injection text (either + // `contextSummary` directly, or `summary` / a legacy summary message which + // also carries the prefix), so strip the marker back off for display. + const summary = stripSummaryPrefix(textOfParts(readContextCompactionSummary(record).content)); + const entry: TranscriptCompactionEntry = { + type: 'compaction', + summary, + tokensBefore: readNumber(record, 'tokensBefore'), + tokensAfter: readNumber(record, 'tokensAfter'), + compactedCount: readNumber(record, 'compactedCount'), + }; + return { + entries: [...state.entries, entry], + working: { ...state.working, openSteps: new Map(), pendingToolResultIds: new Set(), deferred: [] }, + }; +} + +function applyGoalCreate( + state: TranscriptModelState, + payload: GoalCreateWirePayload, +): TranscriptModelState { + const goal: GoalStateLike = { + goalId: payload.goalId, + objective: payload.objective, + completionCriterion: payload.completionCriterion, + status: 'active', + turnsUsed: 0, + tokensUsed: 0, + wallClockMs: 0, + budgetLimits: {}, + }; + const entry: TranscriptGoalEntry = { + type: 'goal_updated', + snapshot: snapshotFromGoalStateLike(goal), + change: { kind: 'created' }, + }; + return { entries: [...state.entries, entry], working: { ...state.working, goal } }; +} + +function applyGoalUpdate( + state: TranscriptModelState, + payload: GoalUpdateWirePayload, +): TranscriptModelState { + const current = state.working.goal; + if (current === null) return state; + let next: GoalStateLike = current; + if (payload.status !== undefined && payload.status !== current.status) { + next = { + ...next, + status: payload.status, + terminalReason: payload.status === 'active' ? undefined : payload.reason, + }; + } + if (payload.turnsUsed !== undefined) next = { ...next, turnsUsed: payload.turnsUsed }; + if (payload.tokensUsed !== undefined) next = { ...next, tokensUsed: payload.tokensUsed }; + if (payload.wallClockMs !== undefined) next = { ...next, wallClockMs: payload.wallClockMs }; + if (payload.budgetLimits !== undefined) next = { ...next, budgetLimits: payload.budgetLimits }; + + const stats = + payload.turnsUsed !== undefined && payload.tokensUsed !== undefined && payload.wallClockMs !== undefined + ? { turnsUsed: payload.turnsUsed, tokensUsed: payload.tokensUsed, wallClockMs: payload.wallClockMs } + : undefined; + const change: GoalChange = + next.status === 'complete' + ? { kind: 'completion', status: next.status, reason: payload.reason, stats, actor: payload.actor } + : { kind: 'lifecycle', status: payload.status, reason: payload.reason, stats, actor: payload.actor }; + const entry: TranscriptGoalEntry = { + type: 'goal_updated', + snapshot: snapshotFromGoalStateLike(next), + change, + }; + return { entries: [...state.entries, entry], working: { ...state.working, goal: next } }; +} + +function applyGoalClear(state: TranscriptModelState): TranscriptModelState { + // No entry: there is no goal-cleared card in the TUI, so a cleared goal only + // updates the fold working set (same rule as compaction cancel). + return { entries: state.entries, working: { ...state.working, goal: null } }; +} + +/** Replay-approximate snapshot: remaining/reached budget fields are a live-service computation, so the transcript reports limits only. */ +function snapshotFromGoalStateLike(goal: GoalStateLike): GoalSnapshot { + return { + goalId: goal.goalId, + objective: goal.objective, + completionCriterion: goal.completionCriterion, + status: goal.status, + turnsUsed: goal.turnsUsed, + tokensUsed: goal.tokensUsed, + wallClockMs: goal.wallClockMs, + budget: { + tokenBudget: goal.budgetLimits.tokenBudget ?? null, + turnBudget: goal.budgetLimits.turnBudget ?? null, + wallClockBudgetMs: goal.budgetLimits.wallClockBudgetMs ?? null, + remainingTokens: null, + remainingTurns: null, + remainingWallClockMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + overBudget: false, + }, + terminalReason: goal.terminalReason, + }; +} + +// -- public model + one-shot reduce ----------------------------------------- + +export const TranscriptModel = defineDerivedModel( + 'kimi.tui.transcript', + () => ({ entries: [], working: INITIAL_WORKING }), + { + [contextAppendMessage.type]: (state, payload: unknown) => + applyAppendMessage(state, toMutableMessage((payload as { message: ContextMessageLike }).message)), + [contextAppendLoopEvent.type]: (state, payload: unknown) => + applyLoopEvent(state, (payload as { event: LoopRecordedEvent }).event), + [contextApplyCompaction.type]: (state, payload: unknown) => applyCompactionEntry(state, payload), + [contextUndo.type]: (state, payload: unknown) => + applyTranscriptUndo(state, (payload as { count: number }).count), + [contextClear.type]: (state) => applyTranscriptClear(state), + 'goal.create': (state, payload: unknown) => applyGoalCreate(state, payload as GoalCreateWirePayload), + 'goal.update': (state, payload: unknown) => applyGoalUpdate(state, payload as GoalUpdateWirePayload), + 'goal.clear': (state) => applyGoalClear(state), + forked: (state) => applyGoalClear(state), + [planModeEnter.type]: (state) => ({ + entries: [...state.entries, { type: 'plan_updated', enabled: true }], + working: state.working, + }), + [planModeExit.type]: (state) => ({ + entries: [...state.entries, { type: 'plan_updated', enabled: false }], + working: state.working, + }), + [planModeCancel.type]: (state) => ({ + entries: [...state.entries, { type: 'plan_updated', enabled: false }], + working: state.working, + }), + 'permission.set_mode': (state, payload: unknown) => ({ + entries: [...state.entries, { type: 'permission_updated', mode: (payload as { mode: PermissionMode }).mode }], + working: state.working, + }), + 'permission.record_approval_result': (state, payload: unknown) => ({ + entries: [...state.entries, { type: 'approval_result', record: payload as PermissionApprovalResultRecord }], + working: state.working, + }), + 'config.update': (state, payload: unknown) => ({ + entries: [...state.entries, { type: 'config_updated', config: payload as ConfigUpdateWirePayload }], + working: state.working, + }), + }, +); + +function stripSummaryPrefix(text: string): string { + return text.startsWith(COMPACTION_SUMMARY_PREFIX) + ? text.slice(COMPACTION_SUMMARY_PREFIX.length).trimStart() + : text; +} + +function rawToolResultContent(output: string | readonly ContentPart[]): ContentPart[] { + return typeof output === 'string' ? [{ type: 'text', text: output }] : [...output]; +} + +/** Widen a display message to the mutable `ContextMessage` shape v2 helpers expect. */ +function toContextMessageView(message: TranscriptMessage): ContextMessage { + return { ...message, content: [...message.content], toolCalls: [...message.toolCalls] }; +} + +function textOfParts(content: readonly ContentPart[]): string { + let text = ''; + for (const part of content) { + if (part.type === 'text') text += part.text; + } + return text; +} + +function readNumber(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === 'number' ? value : undefined; +} + +/** + * Fold an agent's persisted wire records into transcript entries. Pure: + * no I/O, no DI — the same function a wire-attached fold would produce. + * Records are typed loosely (the persisted `WireRecord` union has no index + * signature); the reduce reads only `type`/`time` envelope fields plus + * per-op payload fields. + */ +export function reduceTranscript(records: Iterable): TranscriptModelState { + let state = TranscriptModel.initial(); + for (const record of records) { + const fields = record as Record; + const type = fields['type']; + if (typeof type !== 'string') continue; + const reducer = TranscriptModel.reducers[type]; + if (reducer === undefined) continue; + state = reducer(state, recordToPayload(fields)); + } + return state; +} + +/** Facade-side mirror of wire `recordToPayload`: envelope fields out. */ +function recordToPayload(record: Record): unknown { + const payload: Record = {}; + for (const key of Object.keys(record)) { + if (key === 'type' || key === 'time') continue; + payload[key] = record[key]; + } + return payload; +} + +/** + * Rehydrate blob references in transcript message content back to inline + * parts (the counterpart of the wire's derived-model `rehydrate`, which the + * facade cannot reach since it folds manually). Entries are returned + * unchanged when nothing references blob storage. + */ +export async function rehydrateTranscript( + entries: readonly TranscriptEntry[], + blobs: TranscriptBlobLoader, +): Promise { + const out: TranscriptEntry[] = []; + for (const entry of entries) { + if (entry.type !== 'message') { + out.push(entry); + continue; + } + const content = await blobs.loadParts(entry.message.content); + out.push(content === entry.message.content ? entry : { ...entry, message: { ...entry.message, content } }); + } + return out; +} diff --git a/apps/kimi-code/src/core/types.ts b/apps/kimi-code/src/core/types.ts new file mode 100644 index 0000000000..20095ad544 --- /dev/null +++ b/apps/kimi-code/src/core/types.ts @@ -0,0 +1,445 @@ +/** + * Core-owned types for the v2 facade (`#/core`). + * + * These replace the `@moonshot-ai/kimi-code-sdk` types the TUI used to + * consume, now that it talks to `@moonshot-ai/agent-core-v2` directly. Shapes + * follow two bounds: the fields the TUI actually reads are the lower bound, + * and the v1 SDK types of the same name are the upper bound; only fields the + * core facade will actually assign are kept. Fields v2 cannot provide yet stay + * for shape parity and are marked `TODO(v2-gap)`. + */ + +import type { + AgentContextData, + AgentReplayRecord, + AgentTaskInfo, + AgentTaskStatus, + ConfigDiagnostic, + ContentPart, + ContextMessage, + CreateGoalInput, + DomainEvent, + ExperimentalFeatureState, + ExperimentalFlagMap, + ExportSessionResult, + FlagId, + GoalChange, + GoalSnapshot, + GoalStatus, + GoalToolResult, + IAgentPromptLegacyService, + IAgentRPCService, + ModelCapability, + PermissionData, + PermissionMode, + PlanData, + PluginCommandDef, + PluginInfo, + PluginMcpServerInfo, + PluginSummary, + PromptOrigin, + QuestionAnswers, + ResolvedConfig, + SessionMeta, + ShellEnvironment, + SkillSummary, + SwarmModeTrigger, + TokenUsage, + ToolCall, + ToolInfo, + ToolUpdate, + UsageStatus, + WorkspaceAdditionalDirsResult, +} from '@moonshot-ai/agent-core-v2'; +// These were deep-imported from v2 internal paths; the v2 barrel now re-exports +// them (`SessionApprovalRequest`/`SessionApprovalResponse` are barrel aliases +// that dodge the `permissionPolicy` name collision, question types are exported +// directly, and the two MCP types are type-only re-exports). The local +// `Core*`/`ApprovalResponse` names are kept for TUI compatibility. +import type { + McpServerEntry, + McpServerInfo, + QuestionAnswerMethod, + QuestionRequest as CoreQuestionRequest, + QuestionResult, + SessionApprovalRequest as CoreApprovalRequest, + SessionApprovalResponse as ApprovalResponse, +} from '@moonshot-ai/agent-core-v2'; + +// v1 config-file schema shapes the TUI reads. Sourced from the node-sdk (which +// re-exports the agent-core v1 schema verbatim) instead of hand-copied here. +import type { + ModelAlias, + OAuthRef, + ProviderConfig, + ProviderType, +} from '@moonshot-ai/kimi-code-sdk'; + +export type { + AgentReplayRecord, + ApprovalResponse, + CoreApprovalRequest, + CoreQuestionRequest, + QuestionAnswerMethod, + QuestionResult, +}; + +/** + * App-bus `session.meta.updated` projection injected into the session stream + * by `attachSessionEvents` / the harness rename re-emit. It is not part of the + * v2 agent-bus `DomainEvent` union, so it is declared here to keep + * `SessionEvent` truthful about what the stream actually delivers. + */ +export interface SessionMetaUpdatedPayload { + readonly type: 'session.meta.updated'; + readonly title?: string; + readonly patch?: Record; +} + +/** Merged event stream: native v2 payload plus routing context. */ +export type SessionEvent = (DomainEvent | SessionMetaUpdatedPayload) & { + readonly agentId: string; + readonly sessionId: string; +}; + +/** Narrow the session stream union to a single event kind. */ +export type SessionEventOf = Extract< + SessionEvent, + { readonly type: K } +>; + +export interface PendingApproval { + readonly id: string; + readonly agentId: string; + readonly request: CoreApprovalRequest; +} + +export interface PendingQuestion { + readonly id: string; + readonly agentId: string; + readonly request: CoreQuestionRequest; +} + +export interface TelemetryClient { + track(event: string, properties?: TelemetryProperties): void; + setContext?(patch: TelemetryContextPatch): void; + withContext?(patch: TelemetryContextPatch): TelemetryClient; +} +export type TelemetryProperties = Record; +export type TelemetryContextPatch = Record; + +/** + * Runtime status snapshot consumed by `/status` and `syncRuntimeState`. + * + * Field set copied verbatim from the v1 SDK `SessionStatus` + * (`packages/node-sdk/src/types.ts`). Verified against the real engine in the + * Task 5 smoke run: a fresh session reports + * `{ thinkingEffort: 'off', permission: 'manual', planMode: false, + * swarmMode: false, contextTokens: 0, maxContextTokens: 0, contextUsage: 0, + * usage: {} }` — `model` is absent until one is bound (hence optional), and + * every other field is always present. The v2 wire response also carries a + * `status` activity field, which this projection intentionally drops (v1 + * parity). `swarmMode`/`usage` stay optional only for v1 shape parity; the + * `getStatus` projection always assigns them. + */ +export interface SessionStatus { + readonly model?: string; + readonly thinkingEffort: string; + readonly permission: PermissionMode; + readonly planMode: boolean; + readonly swarmMode?: boolean; + readonly contextTokens: number; + readonly maxContextTokens: number; + readonly contextUsage: number; + /** Merged from the main agent's `IAgentUsageService.status()` by `getStatus`. */ + readonly usage?: UsageStatus; +} + +/** + * Pre-session startup snapshot consumed by the TUI's initial render when no + * session is created at startup. Every field comes from App-scope services + * (config + model resolver), so `CoreHarness.getStartupState()` never has to + * create a session to produce it. Semantics mirror `SessionStatus`: the first + * lazily-created session reports the same values these defaults imply, unless + * the user changed them in between. + */ +export interface CoreStartupState { + /** Configured `defaultModel` alias; '' when none is set. */ + readonly model: string; + /** Context window of `model`; 0 when unset or unresolvable (e.g. auth not ready). */ + readonly maxContextTokens: number; + /** Configured `defaultPermissionMode`; 'manual' when unset or invalid. */ + readonly permissionMode: PermissionMode; + /** Configured `defaultPlanMode`; false when unset. */ + readonly planMode: boolean; + /** + * Thinking level the first session would start at: the `thinking` config + * default resolved through `resolveThinkingEffortForModel` with no requested + * effort, clamped by the resolved model's metadata. 'off' when disabled or + * no model resolves. + */ + readonly thinkingEffort: ThinkingEffort; +} + +/** Session list/summary projection consumed by the picker and `/export`. */ +export interface CoreSessionSummary { + readonly id: string; + readonly title?: string; + readonly lastPrompt?: string; + readonly workDir: string; + readonly sessionDir: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly archived: boolean; + readonly metadata?: Record; + readonly additionalDirs?: readonly string[]; +} + +/** + * Agent config snapshot for replay hydration. Field set mirrors the v1 wire + * `AgentConfigData`; sourced from v2 `IAgentProfileService.data()` (whose + * `thinkingLevel` maps to `thinkingEffort`). + */ +export interface ResumedAgentConfig { + readonly cwd: string; + /** + * TODO(v2-gap): v2 has no per-agent provider config DTO; always undefined. + * Kept so the TUI's `config.provider?.model` fallback keeps compiling. + */ + readonly provider?: { readonly model?: string }; + readonly modelAlias?: string; + readonly modelCapabilities: ModelCapability; + readonly profileName?: string; + readonly thinkingEffort: string; + readonly systemPrompt: string; +} + +/** + * Per-agent resume snapshot, hydrated by `session-replay.ts` / + * `message-replay.ts`. Shape mirrors the v1 wire `ResumedAgentState` + * (`packages/agent-core/src/rpc/resumed.ts`) with the payload types swapped + * to their v2 equivalents; `replay` reuses the v2 `AgentReplayRecord` union, + * whose `message` variant carries the v2 `ContextMessage`. + */ +export interface ResumedAgentState { + readonly type: 'main' | 'sub'; + readonly config: ResumedAgentConfig; + readonly context: AgentContextData; + readonly replay: readonly AgentReplayRecord[]; + readonly permission: PermissionData; + readonly plan: PlanData; + readonly swarmMode?: boolean; + readonly usage: UsageStatus; + readonly tools: readonly ToolInfo[]; + readonly toolStore?: Readonly>; + readonly background: readonly AgentTaskInfo[]; +} + +/** Per-agent entry of the session metadata projection (v1 `AgentMeta` shape). */ +export interface ResumedAgentMeta { + readonly homedir: string; + readonly type: 'main' | 'sub'; + readonly parentAgentId: string | null; + readonly swarmItem?: string; +} + +/** + * Projection of v2 `ISessionMetadata.read()` into the v1 `SessionMeta` shape + * (ISO timestamps, defaulted title). Limited to the fields the core replay + * builder assigns; the TUI does not read it directly. + */ +export interface ResumedSessionMetadata { + readonly createdAt: string; + readonly updatedAt: string; + readonly title: string; + readonly isCustomTitle: boolean; + readonly agents: Readonly>; +} + +/** Resume snapshot returned by `CoreSession.getResumeState()`. */ +export interface ResumedSessionState { + readonly sessionMetadata: ResumedSessionMetadata; + readonly agents: Readonly>; + readonly warning?: string; +} + +// --------------------------------------------------------------------------- +// Types appended for the `CoreSession` facade (session.ts). + +/** + * v1-protocol content parts accepted by `CoreSession.prompt` / `steer` — + * derived from `IAgentPromptLegacyService.submit` so the facade cannot drift + * from the service it forwards to. + */ +export type PromptContent = Parameters[0]['content']; +export type PromptPart = PromptContent[number]; + +/** Result of `CoreSession.runShellCommand` (the v2 RPC facade's shape). */ +export type ShellCommandResult = Awaited>; + +/** + * Session warning surfaced by `getSessionWarnings`. Mirrors the v1 wire + * `SessionWarning` (`@moonshot-ai/protocol`), which the v2 barrel does not + * re-export. + */ +export interface SessionWarning { + readonly code: string; + readonly message: string; + readonly severity: 'info' | 'warning' | 'error'; +} + +/** v2 names used in `CoreSession` public signatures, re-exported for consumers. */ +export type { + AgentTaskInfo, + ContentPart, + ContextMessage, + CreateGoalInput, + DomainEvent, + GoalChange, + GoalSnapshot, + GoalToolResult, + McpServerEntry, + PermissionMode, + PlanData, + PromptOrigin, + QuestionAnswers, + SessionMeta, + SkillSummary, + SwarmModeTrigger, + ToolCall, + ToolUpdate, + UsageStatus, + WorkspaceAdditionalDirsResult, +}; + +/** + * TODO(migrate): v1 SDK aliases kept so TUI components/utils that still read + * the v1 names compile against the v2 shapes (which are structurally + * compatible at runtime). New code should use the v2 names directly. + */ +export type BackgroundTaskInfo = AgentTaskInfo; +export type BackgroundTaskStatus = AgentTaskStatus; +/** v1 `SessionUsage` ≡ v2 `UsageStatus` (main-agent usage snapshot). */ +export type SessionUsage = UsageStatus; + +/** + * TODO(migrate): Custom `tool.progress` update kind the v2 MCP auth tool emits + * when an OAuth authorization URL is available. Value mirrors the protocol + * constant (`@moonshot-ai/protocol` `events.ts`) so the TUI matches the + * runtime update without importing the protocol package directly; drop once + * agent-core-v2 or the protocol package exposes it through a supported export. + */ +export const MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE = 'mcp.oauth.authorization_url'; + +/** + * TODO(migrate): Payload of {@link MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE} + * custom progress updates; mirrored from the protocol package pending a + * supported export. + */ +export interface McpOAuthAuthorizationUrlUpdateData { + readonly serverName: string; + readonly authorizationUrl: string; +} + +/** + * TODO(migrate): `mcp.server.status` event payload, mirrored from + * `@moonshot-ai/protocol` (`events.ts`) so the TUI's MCP status util can read + * the `server` snapshot without importing the protocol package directly; drop + * once a supported export exists. + */ +export interface McpServerStatusPayload { + readonly name: string; + readonly transport: 'stdio' | 'http' | 'sse'; + readonly status: 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth'; + readonly toolCount: number; + readonly error?: string; +} + +/** TODO(migrate): Mirrored protocol event; drop with {@link McpServerStatusPayload}. */ +export interface McpServerStatusEvent { + readonly type: 'mcp.server.status'; + readonly server: McpServerStatusPayload; +} + +// --------------------------------------------------------------------------- +// Types appended for the `CoreHarness` facade (harness.ts). + +/** + * Resolved config document, keyed by config domain — the v2 + * `IConfigService.getAll()` shape (v1 called this `KimiConfig`). + */ +export type CoreConfig = ResolvedConfig; + +/** Config write patch: each entry is handed to `IConfigService.set(domain, value)`. */ +export type CoreConfigPatch = Readonly>; + +/** + * v2 flag state entry (`IFlagService.explainAll()` element); named after the + * "flag explanation" the TUI renders in `/config`. + */ +export type FlagExplanation = ExperimentalFeatureState; + +/** + * Input of `CoreHarness.exportSession`. Mirrors the v1 SDK shape (`id` instead + * of the v2 payload's `sessionId`); the harness maps it onto the v2 + * `ISessionExportService.export` payload. + */ +export interface ExportSessionInput { + readonly id: string; + readonly outputPath?: string; + /** Bundle the global diagnostic log into the zip (off by default). */ + readonly includeGlobalLog?: boolean; + /** Host version to record in the export manifest. */ + readonly version: string; + /** How the CLI was installed (e.g. 'npm-global', 'native'). */ + readonly installSource?: string; + readonly shellEnv?: ShellEnvironment; +} + +/** v2 names used in `CoreHarness` public signatures, re-exported for consumers. */ +export type { + AgentTaskStatus, + ConfigDiagnostic, + ExperimentalFeatureState, + ExperimentalFlagMap, + ExportSessionResult, + FlagId, + GoalStatus, + McpServerInfo, + PluginCommandDef, + PluginInfo, + PluginMcpServerInfo, + PluginSummary, + ResolvedConfig, + ShellEnvironment, + TokenUsage, +}; + +// --------------------------------------------------------------------------- +// Types appended for the TUI switchover (Task 6). These fill v1 SDK names the +// TUI still references but v2 does not export from its barrel. + +/** + * The `@moonshot-ai/protocol` `ToolInputDisplay`, extracted from the v2 + * approval request instead of imported directly — the protocol package is not + * a dependency of this app. + */ +export type ToolInputDisplay = CoreApprovalRequest['display']; + +/** + * TODO(migrate): mirrors the v1 `ThinkingEffort` (kosong `provider.ts`). The + * v2 profile service accepts plain strings; the alias keeps the TUI's literal + * comparisons ('off' / 'on') type-safe. + */ +export type ThinkingEffort = 'off' | 'on' | (string & {}); + +// Re-exported from the node-sdk (the v1 config schema's home) so the TUI keeps +// reading the v1 shapes it is coupled to. TODO(migrate): drop once the TUI +// consumes v2-native config types. +export type { ModelAlias, OAuthRef, ProviderConfig, ProviderType }; + +/** TODO(migrate): derived from the v1 `ModelAlias.overrides` node (same shape as + * the old hand-copied `ModelAliasOverrides`); drop with the re-exports above. */ +export type ModelAliasOverrides = NonNullable; + + diff --git a/apps/kimi-code/src/tui/commands/add-dir.ts b/apps/kimi-code/src/tui/commands/add-dir.ts index 90636cea55..a497359443 100644 --- a/apps/kimi-code/src/tui/commands/add-dir.ts +++ b/apps/kimi-code/src/tui/commands/add-dir.ts @@ -76,7 +76,7 @@ async function handleAddDirChoice( } try { - const result = await session.addAdditionalDir(path, { persist: choice === 'remember' }); + const result = await session.addAdditionalDir({ path, persist: choice === 'remember' }); host.setAppState({ additionalDirs: result.additionalDirs }); host.refreshSlashCommandAutocomplete(); host.showStatus( diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts index 773638485f..9c7cac3d71 100644 --- a/apps/kimi-code/src/tui/commands/auth.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -8,10 +8,13 @@ import { type ManagedKimiConfigShape, type OpenPlatformDefinition, } from '@moonshot-ai/kimi-code-oauth'; -import { log } from '@moonshot-ai/kimi-code-sdk'; import type { ChoiceOption } from '../components/dialogs/choice-picker'; import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '../constant/kimi-tui'; +import { + modelsView, + providersView, +} from '../utils/core-config-view'; import { formatErrorMessage } from '../utils/event-payload'; import type { LoginProgressSpinnerHandle } from '../types'; import { @@ -84,12 +87,6 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { }); spinner = undefined; if (cancelled) return; - log.warn('login failed', { - providerName: DEFAULT_OAUTH_PROVIDER_NAME, - alreadyLoggedIn, - sessionId: host.session?.id, - error, - }); const message = formatErrorMessage(error); host.showError(`Login failed: ${message}`); } finally { @@ -150,12 +147,15 @@ async function handleOpenPlatformLogin( if (selection === undefined) return; const existingConfig = await host.harness.getConfig(); - if (existingConfig.providers[platform.id] !== undefined) { + if (providersView(existingConfig)[platform.id] !== undefined) { await host.harness.removeProvider(platform.id); } const config = await host.harness.getConfig(); - applyOpenPlatformConfig(config as ManagedKimiConfigShape, { + // `applyOpenPlatformConfig` mutates the config document in place (oauth + // package boundary), so view it as the oauth shape for the apply + read-back. + const managed = config as unknown as ManagedKimiConfigShape; + applyOpenPlatformConfig(managed, { platform, models, selectedModel: selection.model, @@ -168,10 +168,10 @@ async function handleOpenPlatformLogin( }); await host.harness.setConfig({ - providers: config.providers, - models: config.models, - defaultModel: config.defaultModel, - thinking: config.thinking, + providers: managed.providers, + models: managed.models, + defaultModel: managed.defaultModel, + thinking: managed.thinking, }); await host.authFlow.refreshConfigAfterLogin(); @@ -185,9 +185,10 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise (p) => p.providerName === DEFAULT_OAUTH_PROVIDER_NAME && p.hasToken, ); const config = await host.harness.getConfig(); + const providers = providersView(config); const hasManagedRemnant = - hasOAuthToken || config.providers[DEFAULT_OAUTH_PROVIDER_NAME] !== undefined; - const apiKeyProviderIds = Object.keys(config.providers ?? {}) + hasOAuthToken || providers[DEFAULT_OAUTH_PROVIDER_NAME] !== undefined; + const apiKeyProviderIds = Object.keys(providers) .filter((id) => id !== DEFAULT_OAUTH_PROVIDER_NAME) .toSorted(); @@ -200,7 +201,7 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise }); } for (const id of apiKeyProviderIds) { - const baseUrl = config.providers[id]?.baseUrl; + const baseUrl = providers[id]?.baseUrl; options.push({ value: id, label: id, @@ -231,8 +232,8 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise } else { const updated = await host.harness.getConfig({ reload: true }); host.setAppState({ - availableModels: updated.models ?? {}, - availableProviders: updated.providers ?? {}, + availableModels: modelsView(updated), + availableProviders: providersView(updated), }); } diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 9d95974193..d287a292c7 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -1,11 +1,11 @@ import { effectiveModelAlias, + type CoreSession, type ExperimentalFeatureState, type ModelAlias, type PermissionMode, - type Session, type ThinkingEffort, -} from '@moonshot-ai/kimi-code-sdk'; +} from '#/core/index'; import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; import { EffortSelectorComponent } from '../components/dialogs/effort-selector'; @@ -24,6 +24,7 @@ import type { ThemeName } from '#/tui/theme'; import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; +import { defaultModelView, thinkingView } from '../utils/core-config-view'; import { thinkingEffortToConfig } from '../utils/thinking-config'; import { showUsage } from './info'; import { setExperimentalFeatures } from './experimental-flags'; @@ -59,19 +60,36 @@ export async function handlePlanCommand(host: SlashCommandHost, args: string): P return; } - let enabled: boolean; - if (subcmd.length === 0) enabled = !host.state.appState.planMode; - else if (subcmd === 'on') enabled = true; - else if (subcmd === 'off') enabled = false; - else { + const currentPlanMode = host.state.appState.planMode; + + if (subcmd === 'on') { + if (currentPlanMode) { + host.showNotice('Plan mode is already on'); + return; + } + await applyPlanMode(host, session, true); + return; + } + + if (subcmd === 'off') { + if (!currentPlanMode) { + host.showNotice('Plan mode is already off'); + return; + } + await applyPlanMode(host, session, false); + return; + } + + if (subcmd.length > 0) { host.showError(`Unknown plan subcommand: ${subcmd}`); return; } - await applyPlanMode(host, session, enabled); + // no-arg toggle + await applyPlanMode(host, session, !currentPlanMode); } -async function applyPlanMode(host: SlashCommandHost, session: Session, enabled: boolean): Promise { +async function applyPlanMode(host: SlashCommandHost, session: CoreSession, enabled: boolean): Promise { try { await session.setPlanMode(enabled); host.setAppState({ planMode: enabled }); @@ -185,7 +203,7 @@ export async function handleCompactCommand(host: SlashCommandHost, args: string) return; } const customInstruction = args.trim() || undefined; - await session.compact({ instruction: customInstruction }); + await session.compact(customInstruction); } export async function handleEditorCommand(host: SlashCommandHost, args: string): Promise { @@ -408,7 +426,7 @@ async function performModelSwitch( const session = host.session; try { if (session === undefined && runtimeChanged) { - await host.authFlow.activateModelAfterLogin(alias, effort); + await host.authFlow.activateModelSelection(alias, effort); } else if (session !== undefined) { if (alias !== prevModel) { await session.setModel(alias); @@ -472,10 +490,11 @@ async function persistModelSelection( ): Promise { const config = await host.harness.getConfig({ reload: true }); const patch = thinkingEffortToConfig(effort); + const thinking = thinkingView(config); if ( - config.defaultModel === alias && - config.thinking?.enabled === patch.enabled && - config.thinking?.effort === patch.effort + defaultModelView(config) === alias && + thinking?.enabled === patch.enabled && + thinking?.effort === patch.effort ) { return false; } @@ -607,9 +626,11 @@ export async function applyExperimentalFeatureChanges( host.refreshSlashCommandAutocomplete(); host.restoreEditor(); if (host.session !== undefined) { - await host.session.reloadSession(); + // `reloadSession` returns a fresh `CoreSession`; swap the TUI's held + // reference via `reloadCurrentSessionView`. + const reloaded = await host.harness.reloadSession({ id: host.session.id }); await host.reloadCurrentSessionView( - host.session, + reloaded, 'Experimental features updated. Session reloaded.', ); } else { diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 7508bc06a5..767bf596e5 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -1,7 +1,7 @@ import type { Component, Focusable } from '@moonshot-ai/pi-tui'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; -import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { CoreHarness, CoreSession } from '#/core/index'; import type { ColorToken, ThemeName } from '#/tui/theme'; import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; @@ -97,8 +97,8 @@ export { handleWebCommand } from './web'; export interface SlashCommandHost { state: TUIState; - session: Session | undefined; - readonly harness: KimiHarness; + session: CoreSession | undefined; + readonly harness: CoreHarness; cancelInFlight: (() => void) | undefined; deferUserMessages: boolean; @@ -115,12 +115,17 @@ export interface SlashCommandHost { refreshSlashCommandAutocomplete(): void; // Session - requireSession(): Session; - switchToSession(session: Session, message: string): Promise; - reloadCurrentSessionView(session: Session, message: string): Promise; + requireSession(): CoreSession; + switchToSession(session: CoreSession, message: string): Promise; + reloadCurrentSessionView(session: CoreSession, message: string): Promise; beginSessionRequest(): void; failSessionRequest(message: string): void; - sendQueuedMessage(session: Session, item: QueuedMessage): void; + sendQueuedMessage(session: CoreSession, item: QueuedMessage): void; + sendQueuedGoalMessage( + session: CoreSession, + item: QueuedMessage, + confirmation: Component, + ): Promise; requestQueuedGoalPromotion?(): void; // UI @@ -139,9 +144,9 @@ export interface SlashCommandHost { createNewSession(): Promise; showSessionPicker(): Promise; sendNormalUserInput(text: string): void; - sendSkillActivation(session: Session, skillName: string, skillArgs: string): void; + sendSkillActivation(session: CoreSession, skillName: string, skillArgs: string): void; activatePluginCommand( - session: Session, + session: CoreSession, pluginId: string, commandName: string, args: string, diff --git a/apps/kimi-code/src/tui/commands/experimental-flags.ts b/apps/kimi-code/src/tui/commands/experimental-flags.ts index 0800e7b363..979f1df409 100644 --- a/apps/kimi-code/src/tui/commands/experimental-flags.ts +++ b/apps/kimi-code/src/tui/commands/experimental-flags.ts @@ -1,4 +1,4 @@ -import type { ExperimentalFeatureState, ExperimentalFlagMap } from '@moonshot-ai/kimi-code-sdk'; +import type { ExperimentalFeatureState, ExperimentalFlagMap } from '#/core/index'; import { experimentalFeatureMap } from '#/utils/experimental-features'; diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index de790b9060..b7fbc184a0 100644 --- a/apps/kimi-code/src/tui/commands/goal.ts +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -1,4 +1,4 @@ -import { ErrorCodes, isKimiError, type PermissionMode } from '@moonshot-ai/kimi-code-sdk'; +import { CoreErrorCodes, isCoreError, type PermissionMode } from '#/core/index'; import { GoalStartPermissionPromptComponent, @@ -48,7 +48,12 @@ type GoalCommandHost = Pick< export interface GoalStartOptions { readonly beforeSend?: () => boolean | Promise; - readonly sendInput?: (objective: string) => void; + readonly sendInput?: (objective: string) => void | Promise; + readonly onSendError?: () => void | Promise; + readonly sendConfirmationWithInput?: ( + objective: string, + confirmation: GoalSetMessageComponent, + ) => Promise; } export type ParsedGoalCommand = @@ -146,7 +151,16 @@ export async function handleGoalCommand(host: SlashCommandHost, args: string): P await showGoalQueueManager(host); return; case 'create': - await createGoal(host, parsed, args); + if ( + host.state.appState.permissionMode === 'manual' || + host.state.appState.permissionMode === 'yolo' + ) { + void createGoal(host, parsed, args).catch((error: unknown) => { + host.showError(formatErrorMessage(error)); + }); + } else { + await createGoal(host, parsed, args); + } return; } } @@ -189,11 +203,21 @@ async function queueNextGoal( if (!hasCurrentGoal && !isBusy(host)) { host.showStatus(START_NEXT_GOAL_NOW_MESSAGE); - await createGoal( + const start = createGoal( host, { kind: 'create', objective: parsed.objective, replace: false }, `next ${parsed.objective}`, ); + if ( + host.state.appState.permissionMode !== 'manual' && + host.state.appState.permissionMode !== 'yolo' + ) { + await start; + } else { + void start.catch((error: unknown) => { + host.showError(formatErrorMessage(error)); + }); + } return; } @@ -328,88 +352,156 @@ export async function createGoal( return false; } + const session = host.session; if ( host.state.appState.permissionMode === 'manual' || host.state.appState.permissionMode === 'yolo' ) { - showGoalStartPermissionPrompt(host, parsed, rawArgs ?? parsed.objective, options); - return false; + return showGoalStartPermissionPrompt( + host, + session, + parsed, + rawArgs ?? parsed.objective, + options, + ); } - return startGoal(host, parsed, options); + return startGoal(host, session, parsed, options); } function showGoalStartPermissionPrompt( host: GoalCommandHost, + session: NonNullable, parsed: Extract, rawArgs: string, options: GoalStartOptions, -): void { - const commandText = `/goal ${rawArgs.trim()}`; - const cancelStart = (): void => { - host.restoreInputText(commandText); - host.showStatus('Goal not started.'); - }; - host.mountEditorReplacement( - new GoalStartPermissionPromptComponent({ - mode: host.state.appState.permissionMode === 'yolo' ? 'yolo' : 'manual', - onSelect: (choice) => { - if (choice === 'cancel') { - cancelStart(); - return; - } - host.restoreEditor(); - void startGoalWithPermission(host, parsed, choice, options); - }, - onCancel: cancelStart, - }), - ); +): Promise { + return new Promise((resolve) => { + let handled = false; + const claim = (): boolean => { + if (handled) return false; + handled = true; + return true; + }; + const commandText = `/goal ${rawArgs.trim()}`; + const cancelStart = (): void => { + if (!claim()) return; + try { + host.restoreInputText(commandText); + host.showStatus('Goal not started.'); + } catch (error) { + host.showError(formatErrorMessage(error)); + } + resolve(false); + }; + const disposeStart = (): void => { + if (!claim()) return; + resolve(false); + }; + try { + host.mountEditorReplacement( + new GoalStartPermissionPromptComponent({ + mode: host.state.appState.permissionMode === 'yolo' ? 'yolo' : 'manual', + onSelect: (choice) => { + if (choice === 'cancel') { + cancelStart(); + return; + } + if (!claim()) return; + try { + host.restoreEditor(); + } catch (error) { + host.showError(formatErrorMessage(error)); + resolve(false); + return; + } + void startGoalWithPermission(host, session, parsed, choice, options).then( + resolve, + (error) => { + host.showError(formatErrorMessage(error)); + resolve(false); + }, + ); + }, + onCancel: cancelStart, + onDispose: disposeStart, + }), + ); + } catch (error) { + if (!claim()) return; + host.showError(formatErrorMessage(error)); + resolve(false); + } + }); } async function startGoalWithPermission( host: GoalCommandHost, + session: NonNullable, parsed: Extract, choice: GoalStartPermissionChoice, options: GoalStartOptions, -): Promise { +): Promise { + if (host.session !== session) return false; const previousMode = host.state.appState.permissionMode; const switched = choice !== previousMode && (choice === 'auto' || choice === 'yolo'); if (switched) { - if (!(await setPermissionForGoal(host, choice))) return; + const permissionSet = await setPermissionForGoal(host, session, choice); + if (permissionSet === 'failed') return false; + if (permissionSet === 'session-changed') { + try { + await session.setPermission(previousMode); + } catch { + // Best effort: never surface an old-session rollback failure in the new UI. + } + return false; + } } - const started = await startGoal(host, parsed, options); + + const started = await startGoal(host, session, parsed, options); // The permission switch only exists to run this goal. If creation fails // (e.g. a goal already exists and `replace` was not given), restore the // previous mode so the session is not left more permissive than before. - if (!started && switched) { - await setPermissionForGoal(host, previousMode); + if (!started && switched && host.session === session) { + await setPermissionForGoal(host, session, previousMode); } + return started; } -async function setPermissionForGoal(host: GoalCommandHost, mode: PermissionMode): Promise { +async function setPermissionForGoal( + host: GoalCommandHost, + session: NonNullable, + mode: PermissionMode, +): Promise<'set' | 'failed' | 'session-changed'> { try { - await host.requireSession().setPermission(mode); + await session.setPermission(mode); } catch (error) { - host.showError(`Failed to set permission mode: ${formatErrorMessage(error)}`); - return false; + if (host.session === session) { + host.showError(`Failed to set permission mode: ${formatErrorMessage(error)}`); + } + return 'failed'; } + if (host.session !== session) return 'session-changed'; host.setAppState({ permissionMode: mode }); - return true; + return 'set'; } async function startGoal( host: GoalCommandHost, + session: NonNullable, parsed: Extract, options: GoalStartOptions, ): Promise { + if (host.session !== session) return false; try { - await host.requireSession().createGoal({ + await session.createGoal({ objective: parsed.objective, replace: parsed.replace, }); } catch (error) { - if (isKimiError(error) && error.code === ErrorCodes.GOAL_ALREADY_EXISTS) { + if (host.session !== session) return false; + if (isCoreError(error) && error.code === CoreErrorCodes.GOAL_ALREADY_EXISTS) { host.showError( 'A goal is already active. Use `/goal replace ` to replace it, or `/goal status` to inspect it.', ); @@ -418,17 +510,63 @@ async function startGoal( host.showError(formatErrorMessage(error)); return false; } - if (options.beforeSend !== undefined && !(await options.beforeSend())) { + if (host.session !== session) { + try { + await session.cancelGoal(); + } catch { + // The originating session is no longer current; do not surface stale-session errors. + } return false; } - host.state.transcriptContainer.addChild(new GoalSetMessageComponent()); - host.state.ui.requestRender(); - if (options.sendInput !== undefined) { - options.sendInput(parsed.objective); - } else { - host.sendNormalUserInput(parsed.objective); + if (options.beforeSend !== undefined) { + try { + if (!(await options.beforeSend())) return false; + } catch (error) { + if (host.session === session) host.showError(formatErrorMessage(error)); + return false; + } + } + if (host.session !== session) { + try { + await session.cancelGoal(); + } catch { + // The originating session is no longer current; do not surface stale-session errors. + } + return false; + } + try { + if (options.sendConfirmationWithInput !== undefined) { + await options.sendConfirmationWithInput(parsed.objective, new GoalSetMessageComponent()); + } else { + host.state.transcriptContainer.addChild(new GoalSetMessageComponent()); + host.state.ui.requestRender(); + if (options.sendInput !== undefined) { + await options.sendInput(parsed.objective); + } else { + host.sendNormalUserInput(parsed.objective); + } + } + if (host.session !== session) { + try { + if (options.onSendError !== undefined) await options.onSendError(); + else await session.cancelGoal(); + } catch { + // The originating session is no longer current; do not surface stale-session errors. + } + return false; + } + return true; + } catch (error) { + if (host.session === session) host.showError(formatErrorMessage(error)); + if (options.onSendError !== undefined) { + try { + await options.onSendError(); + } catch (rollbackError) { + host.showError(`Failed to roll back goal start: ${formatErrorMessage(rollbackError)}`); + } + } + return false; } - return true; } async function pauseGoal(host: SlashCommandHost): Promise { @@ -437,7 +575,7 @@ async function pauseGoal(host: SlashCommandHost): Promise { await session.pauseGoal(); if (isStreaming(host)) await session.cancel(); } catch (error) { - if (isKimiError(error) && error.code === ErrorCodes.GOAL_NOT_FOUND) { + if (isCoreError(error) && error.code === CoreErrorCodes.GOAL_NOT_FOUND) { host.showStatus('No goal to pause.'); return; } @@ -457,7 +595,7 @@ async function resumeGoal(host: SlashCommandHost): Promise { try { await host.requireSession().resumeGoal(); } catch (error) { - if (isKimiError(error) && error.code === ErrorCodes.GOAL_NOT_FOUND) { + if (isCoreError(error) && error.code === CoreErrorCodes.GOAL_NOT_FOUND) { host.showStatus('No goal to resume.'); return; } @@ -474,7 +612,7 @@ async function cancelGoal(host: SlashCommandHost): Promise { await session.cancelGoal(); if (isStreaming(host)) await session.cancel(); } catch (error) { - if (isKimiError(error) && error.code === ErrorCodes.GOAL_NOT_FOUND) { + if (isCoreError(error) && error.code === CoreErrorCodes.GOAL_NOT_FOUND) { host.showStatus('No goal to cancel.'); return; } diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index fd5d397f4b..76d99a3173 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -1,6 +1,6 @@ import { release as osRelease, type as osType } from 'node:os'; -import type { McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/kimi-code-sdk'; +import type { McpServerInfo, SessionStatus, SessionUsage } from '#/core/index'; import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel'; import { buildStatusReportLines } from '../components/messages/status-panel'; diff --git a/apps/kimi-code/src/tui/commands/plugin-commands.ts b/apps/kimi-code/src/tui/commands/plugin-commands.ts index a26e6fdb55..dc759c66c6 100644 --- a/apps/kimi-code/src/tui/commands/plugin-commands.ts +++ b/apps/kimi-code/src/tui/commands/plugin-commands.ts @@ -1,4 +1,4 @@ -import type { PluginCommandDef } from '@moonshot-ai/kimi-code-sdk'; +import type { PluginCommandDef } from '#/core/index'; import type { KimiSlashCommand } from './types'; diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index b69f0724a9..77a1bef675 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -1,7 +1,7 @@ import { homedir as osHomedir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; -import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { PluginInfo, PluginSummary } from '#/core/index'; import { PluginInstallTrustConfirmComponent, @@ -49,7 +49,6 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri const args = rawArgs.trim().split(/\s+/).filter((part) => part.length > 0); const sub = args[0]; const rest = args.slice(1); - const session = host.requireSession(); try { if (sub === undefined) { @@ -109,7 +108,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri host.showError('Usage: /plugins mcp enable|disable '); return; } - await session.setPluginMcpServerEnabled(id, server, action === 'enable'); + await host.harness.setPluginMcpServerEnabled({ id, server, enabled: action === 'enable' }); host.showStatus( `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /reload or /new to apply.`, ); @@ -141,7 +140,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri await reloadPlugins(host); return; } - const plugins = await session.listPlugins(); + const plugins = await host.harness.listPlugins(); if (plugins.some((plugin) => plugin.id === sub)) { await renderPluginInfo(host, sub); return; @@ -158,7 +157,7 @@ async function showPluginsPicker( ): Promise { let plugins: readonly PluginSummary[]; try { - plugins = await host.requireSession().listPlugins(); + plugins = await host.harness.listPlugins(); } catch (error) { host.showError(`Failed to load plugins: ${formatErrorMessage(error)}`); return; @@ -225,7 +224,7 @@ async function showPluginMcpPicker( ): Promise { let info: PluginInfo; try { - info = await host.requireSession().getPluginInfo(id); + info = await host.harness.getPluginInfo({ id }); } catch (error) { host.showError(`Failed to load plugin MCP servers: ${formatErrorMessage(error)}`); return; @@ -254,7 +253,7 @@ async function showPluginMcpPicker( async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise { let displayName = id; try { - displayName = (await host.requireSession().getPluginInfo(id)).displayName; + displayName = (await host.harness.getPluginInfo({ id })).displayName; } catch { // Keep the confirmation available even when plugin details cannot be loaded. } @@ -340,11 +339,10 @@ async function applyPluginEnabled( enabled: boolean, showStatus = true, ): Promise { - const session = host.requireSession(); - await session.setPluginEnabled(id, enabled); + await host.harness.setPluginEnabled({ id, enabled }); let info: PluginInfo | undefined; try { - info = await session.getPluginInfo(id); + info = await host.harness.getPluginInfo({ id }); } catch { info = undefined; } @@ -427,11 +425,11 @@ async function handlePluginMcpSelection( ): Promise { switch (selection.kind) { case 'toggle': - await host.requireSession().setPluginMcpServerEnabled( - selection.pluginId, - selection.server, - selection.enabled, - ); + await host.harness.setPluginMcpServerEnabled({ + id: selection.pluginId, + server: selection.server, + enabled: selection.enabled, + }); await showPluginMcpPicker(host, selection.pluginId, { selectedServer: selection.server, serverHint: { @@ -447,7 +445,7 @@ async function handlePluginMcpSelection( } async function removePlugin(host: SlashCommandHost, id: string): Promise { - await host.requireSession().removePlugin(id); + await host.harness.removePlugin({ id }); host.showStatus(`Removed ${id}.`); host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } @@ -456,7 +454,7 @@ async function renderPluginsList( host: SlashCommandHost, plugins?: readonly PluginSummary[], ): Promise { - const currentPlugins = plugins ?? (await host.requireSession().listPlugins()); + const currentPlugins = plugins ?? (await host.harness.listPlugins()); const title = ` Plugins (${currentPlugins.length}) `; const panel = new UsagePanelComponent( () => buildPluginsListLines({ plugins: currentPlugins }), @@ -468,7 +466,7 @@ async function renderPluginsList( } async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { - const info = await host.requireSession().getPluginInfo(id); + const info = await host.harness.getPluginInfo({ id }); const panel = new UsagePanelComponent( () => buildPluginsInfoLines({ info }), 'primary', @@ -482,11 +480,10 @@ async function installPluginFromSource( host: SlashCommandHost, source: string, ): Promise { - const session = host.requireSession(); - const beforeList = await session.listPlugins(); - const summary = await session.installPlugin( - resolvePluginInstallSource(source, host.state.appState.workDir), - ); + const beforeList = await host.harness.listPlugins(); + const summary = await host.harness.installPlugin({ + source: resolvePluginInstallSource(source, host.state.appState.workDir), + }); showPluginInstallResult(host, beforeList, summary); } @@ -546,7 +543,7 @@ function truncateForStatus(input: string): string { } async function reloadPlugins(host: SlashCommandHost): Promise { - const summary = await host.requireSession().reloadPlugins(); + const summary = await host.harness.reloadPlugins(); const line = `Reload: +${summary.added.length} -${summary.removed.length}` + (summary.errors.length > 0 ? ` (${summary.errors.length} errors)` : ''); host.showStatus(line); diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index 5b0fd55339..ac84dff1cb 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -5,7 +5,7 @@ import { type CatalogModel, type ModelAlias, type ThinkingEffort, -} from '@moonshot-ai/kimi-code-sdk'; +} from '#/core/index'; import { capabilitiesForModel } from '@moonshot-ai/kimi-code-oauth'; import type { ManagedKimiCodeModelInfo, diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index eb416a54e6..d5be4a555d 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -14,7 +14,7 @@ import { inferWireType, type Catalog, type ThinkingEffort, -} from '@moonshot-ai/kimi-code-sdk'; +} from '#/core/index'; import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import { @@ -27,6 +27,7 @@ import { } from '../components/dialogs/provider-manager'; import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/kimi-tui'; +import { modelsView, providersView } from '../utils/core-config-view'; import { formatErrorMessage } from '../utils/event-payload'; import { thinkingEffortToConfig } from '../utils/thinking-config'; import { @@ -98,8 +99,8 @@ async function handleProviderDelete(host: SlashCommandHost, providerId: string): await host.authFlow.clearActiveSessionAfterLogout(); } else { host.setAppState({ - availableProviders: config.providers ?? {}, - availableModels: config.models ?? {}, + availableProviders: providersView(config), + availableModels: modelsView(config), }); } } @@ -201,12 +202,15 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { // entered. The model selector that follows is just a convenience to pick the // default model; ESC leaves the provider in place without a default selection. const existingConfig = await host.harness.getConfig(); - if (existingConfig.providers[providerId] !== undefined) { + if (providersView(existingConfig)[providerId] !== undefined) { await host.harness.removeProvider(providerId); } const config = await host.harness.getConfig(); - applyCatalogProvider(config, { + // `applyCatalogProvider` mutates the config document in place and is typed + // against the SDK's `KimiConfig`; the v2 resolved config is the same TOML + // projection at runtime, so bridge the type at this catalog boundary. + applyCatalogProvider(config as unknown as Parameters[0], { providerId, wire, baseUrl, @@ -217,8 +221,8 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { }); await host.harness.setConfig({ - providers: config.providers, - models: config.models, + providers: providersView(config), + models: modelsView(config), }); await host.authFlow.refreshConfigAfterLogin(); @@ -228,7 +232,7 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { // Build a merged model dictionary that includes existing models plus the // newly-persisted provider's models, so the tabbed selector shows every // provider's tab (the new provider's tab starts active via initialTabId). - const stateModels = await host.harness.getConfig().then((c) => c.models ?? {}); + const stateModels = await host.harness.getConfig().then((c) => modelsView(c)); const mergedModels = { ...stateModels }; const selector = new TabbedModelSelectorComponent({ @@ -291,8 +295,8 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise source, ); await host.harness.setConfig({ - providers: config.providers, - models: config.models, + providers: providersView(config), + models: modelsView(config), }); await host.authFlow.refreshConfigAfterLogin(); } catch (error) { @@ -314,7 +318,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise // Offer the model selector so the user can pick a default, just like the // catalog (known-provider) flow. - const stateModels = await host.harness.getConfig().then((c) => c.models ?? {}); + const stateModels = await host.harness.getConfig().then((c) => modelsView(c)); const firstNewAlias = Object.keys(stateModels).find((a) => addedProviderIds.some((pid) => a.startsWith(`${pid}/`)), ); diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 6f28a16da5..ddce5d443f 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -1,6 +1,7 @@ -import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; +import type { CoreConfig } from '#/core/index'; import { currentTheme, lightColors } from '#/tui/theme'; +import { modelsView, providersView } from '#/tui/utils/core-config-view'; import { loadTuiConfig, type TuiConfig } from '../config'; import type { SlashCommandHost } from './dispatch'; import { setExperimentalFeatures } from './experimental-flags'; @@ -16,8 +17,14 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise const session = host.session; if (session !== undefined) { - await session.reloadSession({ forcePluginSessionStartReminder: true }); - await host.reloadCurrentSessionView(session, 'Session reloaded.'); + // `reloadSession` returns a fresh `CoreSession` instance; the TUI must swap + // its held reference via `reloadCurrentSessionView` (the old instance is + // closed by the harness during reload). + const reloaded = await host.harness.reloadSession({ + id: session.id, + forcePluginSessionStartReminder: true, + }); + await host.reloadCurrentSessionView(reloaded, 'Session reloaded.'); } const config = await host.harness.getConfig({ reload: true }); @@ -52,9 +59,9 @@ export async function applyReloadedTuiConfig( host.state.editor.setDisablePasteBurst(config.disablePasteBurst); } -function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void { +function applyRuntimeConfig(host: SlashCommandHost, config: CoreConfig): void { host.setAppState({ - availableModels: config.models ?? {}, - availableProviders: config.providers ?? {}, + availableModels: modelsView(config), + availableProviders: providersView(config), }); } diff --git a/apps/kimi-code/src/tui/commands/session.ts b/apps/kimi-code/src/tui/commands/session.ts index 2f0870db0d..1f55db41da 100644 --- a/apps/kimi-code/src/tui/commands/session.ts +++ b/apps/kimi-code/src/tui/commands/session.ts @@ -2,7 +2,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { Session } from '@moonshot-ai/kimi-code-sdk'; +import type { CoreSession } from '#/core/index'; import { detectInstallSource } from '#/cli/update/source'; import { detectShellEnvironment } from '#/utils/process/shell-env'; @@ -55,7 +55,7 @@ export async function handleForkCommand(host: SlashCommandHost, args: string): P } const sourceTitle = forkSourceTitle(host, session); - let forked: Session; + let forked: CoreSession; try { forked = await host.harness.forkSession({ id: session.id, @@ -78,7 +78,7 @@ export async function handleForkCommand(host: SlashCommandHost, args: string): P } } -function forkSourceTitle(host: SlashCommandHost, session: Session): string { +function forkSourceTitle(host: SlashCommandHost, session: CoreSession): string { const currentTitle = host.state.appState.sessionTitle?.trim(); if (currentTitle !== undefined && currentTitle.length > 0) return currentTitle; @@ -97,7 +97,7 @@ export async function handleExportMdCommand(host: SlashCommandHost, args: string host.showStatus('Exporting session as Markdown…'); try { const context = await session.getContext(); - if (context.history.length === 0) { + if (context.length === 0) { host.showError('No messages to export.'); return; } @@ -115,8 +115,10 @@ export async function handleExportMdCommand(host: SlashCommandHost, args: string const md = buildExportMarkdown({ sessionId: session.id, workDir: host.state.appState.workDir, - history: context.history, - tokenCount: context.tokenCount, + history: context, + // TODO(v2-gap): v2 `getContext` returns the bare history array (no token + // count); the export `token_count` metadata field is best-effort. + tokenCount: 0, now, }); @@ -124,7 +126,7 @@ export async function handleExportMdCommand(host: SlashCommandHost, args: string await writeFile(outputPath, md, 'utf-8'); const linked = toTerminalHyperlink(outputPath, pathToFileURL(outputPath).href); - host.showNotice(`Exported ${String(context.history.length)} messages`, linked); + host.showNotice(`Exported ${String(context.length)} messages`, linked); } catch (error) { const msg = formatErrorMessage(error); host.showError(`Failed to export session: ${msg}`); @@ -167,7 +169,7 @@ export async function handleInitCommand(host: SlashCommandHost): Promise { host.deferUserMessages = true; host.beginSessionRequest(); try { - await session.init(); + await session.generateAgentsMd(); host.track('init_complete'); host.streamingUI.finalizeTurn((item) => { host.sendQueuedMessage(session, item); diff --git a/apps/kimi-code/src/tui/commands/skills.ts b/apps/kimi-code/src/tui/commands/skills.ts index 2997a8b151..cc197940f9 100644 --- a/apps/kimi-code/src/tui/commands/skills.ts +++ b/apps/kimi-code/src/tui/commands/skills.ts @@ -1,8 +1,8 @@ -import type { Session, SkillSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { CoreSession, SkillSummary } from '#/core/index'; import type { KimiSlashCommand } from './types'; -export type SkillListSession = Pick; +export type SkillListSession = Pick; export interface SkillSlashCommands { readonly commands: readonly KimiSlashCommand[]; diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts index 540aa58604..774be91258 100644 --- a/apps/kimi-code/src/tui/commands/swarm.ts +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -1,4 +1,4 @@ -import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; +import type { PermissionMode } from '#/core/index'; import { SwarmStartPermissionPromptComponent, @@ -129,7 +129,7 @@ async function setSwarmMode( trigger: 'manual' | 'task', ): Promise { try { - await host.requireSession().setSwarmMode(enabled, trigger); + await host.requireSession().setSwarmMode(enabled, { trigger }); } catch (error) { host.showError( `Failed to ${enabled ? 'enable' : 'disable'} swarm mode: ${formatErrorMessage(error)}`, diff --git a/apps/kimi-code/src/tui/commands/types.ts b/apps/kimi-code/src/tui/commands/types.ts index 1ec3c68352..fa87ec0a14 100644 --- a/apps/kimi-code/src/tui/commands/types.ts +++ b/apps/kimi-code/src/tui/commands/types.ts @@ -1,5 +1,5 @@ import type { AutocompleteItem, SlashCommand } from '@moonshot-ai/pi-tui'; -import type { FlagId } from '@moonshot-ai/kimi-code-sdk'; +import type { FlagId } from '#/core/index'; export type SlashCommandAvailability = 'always' | 'idle-only'; diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index bd427277d4..84e8156b40 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -1,6 +1,6 @@ import type { Component } from '@moonshot-ai/pi-tui'; -import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk'; -import { isKimiError } from '@moonshot-ai/kimi-code-sdk'; +import type { ContextMessage } from '#/core/index'; +import { isCoreError } from '#/core/index'; import { WelcomeComponent } from '../components/chrome/welcome'; import { CompactionComponent } from '../components/dialogs/compaction'; @@ -178,7 +178,7 @@ async function resolveUndoAvailability( const context = await getSessionContext(host.session); if (context === undefined) return local; - const activeContext = undoAvailabilityFromContext(context.history); + const activeContext = undoAvailabilityFromContext(context); return { maxCount: Math.min(local.maxCount, activeContext.maxCount), stoppedAtCompaction: @@ -372,7 +372,7 @@ function showUndoLimitStatus(host: SlashCommandHost, message: string): void { function undoLimitFromError( error: unknown, ): (UndoAvailability & { readonly requestedCount: number }) | undefined { - if (!isKimiError(error)) return undefined; + if (!isCoreError(error)) return undefined; const details = error.details; if (details?.['reason'] !== 'undo_limit') return undefined; const requestedCount = details['requestedCount']; diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index b91193b530..0d3d95ffbc 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -9,7 +9,7 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; -import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { effectiveModelAlias } from '#/core/index'; import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips'; import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance'; diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index 10cdecbdb2..663b47d379 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -7,7 +7,7 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; -import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import { effectiveModelAlias } from '#/core/index'; import { isRainbowDancing, renderDanceWelcomeHeader } from '#/tui/easter-eggs/dance'; import type { AppState } from '#/tui/types'; diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts index e18f5709b3..01dd98c9b0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts @@ -24,7 +24,7 @@ import type { DisplayBlock, FileContentDisplayBlock, PendingApproval, -} from '#/tui/reverse-rpc/types'; +} from '#/tui/interactions/types'; export interface ApprovalPanelResponse { readonly response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts index 044884792e..560f552f16 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts @@ -28,7 +28,7 @@ import { import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; -import type { DiffDisplayBlock, FileContentDisplayBlock } from '#/tui/reverse-rpc/types'; +import type { DiffDisplayBlock, FileContentDisplayBlock } from '#/tui/interactions/types'; import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; diff --git a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts index 2678899ad9..13df75d0e3 100644 --- a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts @@ -6,7 +6,7 @@ import { type Focusable, } from '@moonshot-ai/pi-tui'; -import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; +import type { ThinkingEffort } from '#/core/index'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts index 1e466f8739..b74dab3812 100644 --- a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts @@ -6,7 +6,7 @@ import { visibleWidth, type Focusable, } from '@moonshot-ai/pi-tui'; -import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk'; +import type { ExperimentalFeatureState } from '#/core/index'; import { SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts index e60d85ce02..e8a5fcee43 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts @@ -9,6 +9,7 @@ export interface GoalStartPermissionPromptOptions { readonly mode: 'manual' | 'yolo'; readonly onSelect: (choice: GoalStartPermissionChoice) => void; readonly onCancel: () => void; + readonly onDispose: () => void; } export const GOAL_START_MANUAL_OPTIONS: readonly StartPermissionOption[] = [ @@ -78,6 +79,8 @@ const YOLO_NOTICE_LINES = [ ] as const; export class GoalStartPermissionPromptComponent extends StartPermissionPromptComponent { + private readonly onDispose: () => void; + constructor(opts: GoalStartPermissionPromptOptions) { super({ title: @@ -89,5 +92,10 @@ export class GoalStartPermissionPromptComponent extends StartPermissionPromptCom onSelect: opts.onSelect, onCancel: opts.onCancel, }); + this.onDispose = opts.onDispose; + } + + dispose(): void { + this.onDispose(); } } diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 82906d1d50..6cc23f0ee8 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -1,4 +1,4 @@ -import { effectiveModelAlias, type ModelAlias, type ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; +import { effectiveModelAlias, type ModelAlias, type ThinkingEffort } from '#/core/index'; import { Container, Key, diff --git a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts index 4b2db673d3..00c338275d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts @@ -1,4 +1,4 @@ -import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; +import type { PermissionMode } from '#/core/index'; import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index d7066c77ca..1953c66a0f 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -7,7 +7,7 @@ import { visibleWidth, type Focusable, } from '@moonshot-ai/pi-tui'; -import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '#/core/index'; import chalk from 'chalk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts index 7ae0da03d2..c7ed0b0777 100644 --- a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts @@ -29,7 +29,7 @@ * `setOptions`. */ -import type { ProviderConfig } from '@moonshot-ai/kimi-code-sdk'; +import type { ProviderConfig } from '#/core/index'; import { getOpenPlatformById, isOpenPlatformId, diff --git a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts index 6764b88d8f..ff2f53c7c3 100644 --- a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts @@ -22,7 +22,7 @@ import type { PendingQuestion, QuestionPanelResponse, QuestionSubmissionMethod, -} from '#/tui/reverse-rpc/types'; +} from '#/tui/interactions/types'; const NUMBER_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; const MAX_BODY_LINES = 12; diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 9a986b0962..5666a9b2bd 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -13,7 +13,7 @@ * AskUserQuestion dialog's tab strip) — see .agents/skills/write-tui/DESIGN.md. */ -import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; +import type { ModelAlias } from '#/core/index'; import { Container, Key, diff --git a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts index c0f647f67c..64e286d962 100644 --- a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -18,7 +18,7 @@ import { visibleWidth, type Focusable, } from '@moonshot-ai/pi-tui'; -import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; +import type { BackgroundTaskInfo, BackgroundTaskStatus } from '#/core/index'; import { currentTheme } from '#/tui/theme'; import { printableChar } from '@/tui/utils/printable-key'; diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index 7902e81e1a..393d9749ae 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -22,7 +22,7 @@ import { visibleWidth, type Focusable, } from '@moonshot-ai/pi-tui'; -import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; +import type { BackgroundTaskInfo, BackgroundTaskStatus } from '#/core/index'; import { SELECT_POINTER } from '@/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/goal-markers.ts b/apps/kimi-code/src/tui/components/messages/goal-markers.ts index f4482941f5..17b5abb179 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-markers.ts @@ -8,7 +8,7 @@ */ import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; -import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; +import type { GoalChange } from '#/core/index'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/goal-panel.ts b/apps/kimi-code/src/tui/components/messages/goal-panel.ts index d01f580faa..1e868e4c64 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -20,7 +20,7 @@ import { wrapTextWithAnsi, type Component, } from '@moonshot-ai/pi-tui'; -import type { GoalSnapshot, GoalStatus } from '@moonshot-ai/kimi-code-sdk'; +import type { GoalSnapshot, GoalStatus } from '#/core/index'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts index 416cf6a331..eb0309703b 100644 --- a/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/mcp-status-panel.ts @@ -1,4 +1,4 @@ -import type { McpServerInfo } from '@moonshot-ai/kimi-code-sdk'; +import type { McpServerInfo } from '#/core/index'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts index 4654ee82df..3ae15ac53a 100644 --- a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts @@ -1,4 +1,4 @@ -import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { PluginInfo, PluginSummary } from '#/core/index'; import { currentTheme } from '#/tui/theme'; import { diff --git a/apps/kimi-code/src/tui/components/messages/status-panel.ts b/apps/kimi-code/src/tui/components/messages/status-panel.ts index 1d788d53b5..907401ef6f 100644 --- a/apps/kimi-code/src/tui/components/messages/status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -11,7 +11,7 @@ import { type PermissionMode, type SessionStatus, type ThinkingEffort, -} from '@moonshot-ai/kimi-code-sdk'; +} from '#/core/index'; import { PRODUCT_NAME } from '#/constant/app'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index a25c88e643..8414d0e3b8 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -24,7 +24,7 @@ import { FAILURE_MARK, STATUS_BULLET, SUCCESS_MARK } from '#/tui/constant/symbol import { currentTheme } from '#/tui/theme'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; -import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk'; +import type { TokenUsage } from '#/core/index'; import { appendStreamingArgsPreview } from '#/tui/utils/event-payload'; import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 195860bc39..4d8a1e657d 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -6,7 +6,7 @@ import type { Component } from '@moonshot-ai/pi-tui'; import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; -import type { SessionUsage, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; +import type { SessionUsage, TokenUsage } from '#/core/index'; import { formatTokenCount, diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index ae70c0cb8f..6b1ceffc77 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -1,9 +1,16 @@ -import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; import type { SkillListSession } from '../commands'; +import type { CoreHarness, CoreSession } from '#/core/index'; import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; +import { + defaultModelView, + modelsView, + providersView, + thinkingView, +} from '../utils/core-config-view'; import { refreshAllProviderModels, + type RefreshProviderHost, type RefreshProviderScope, type RefreshResult, } from '../utils/refresh-providers'; @@ -12,28 +19,24 @@ import type { SessionEventHandler } from './session-event-handler'; import type { AppState, KimiTUIOptions } from '../types'; import type { TUIState } from '../tui-state'; -type MutableCreateSessionOptions = { - -readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P]; -}; - export interface AuthFlowHost { state: TUIState; - session: Session | undefined; - readonly harness: KimiHarness; + session: CoreSession | undefined; + readonly harness: CoreHarness; readonly options: KimiTUIOptions; setAppState(patch: Partial): void; setStartupReady(): void; resetSessionRuntime(): void; - setSession(session: Session): Promise; - syncRuntimeState(session?: Session): Promise; + setSession(session: CoreSession): Promise; + syncRuntimeState(session?: CoreSession): Promise; closeSession(reason: string): Promise; appendStartupNotice(extra: string): void; readonly sessionEventHandler: SessionEventHandler; fetchSessions(): Promise; updateTerminalTitle(): void; refreshSkillCommands(session?: SkillListSession): Promise; - refreshPluginCommands(session?: Session): Promise; + refreshPluginCommands(session?: CoreSession): Promise; } export class AuthFlowController { @@ -42,8 +45,8 @@ export class AuthFlowController { async refreshAvailableModels(): Promise { const config = await this.host.harness.getConfig({ reload: true }); this.host.setAppState({ - availableModels: config.models ?? {}, - availableProviders: config.providers ?? {}, + availableModels: modelsView(config), + availableProviders: providersView(config), }); } @@ -62,7 +65,13 @@ export class AuthFlowController { this.host.setStartupReady(); } - async activateModelAfterLogin(model: string, effort?: string): Promise { + /** + * Apply a model choice. With a live session this switches the session's + * model; without one (session-less startup, or login completing before the + * first message) it only records the choice in appState — the lazy session + * creation on the first message picks it up. + */ + async activateModelSelection(model: string, effort?: string): Promise { const { host } = this; if (host.session !== undefined) { await host.session.setModel(model); @@ -72,32 +81,15 @@ export class AuthFlowController { return; } - const options: MutableCreateSessionOptions = { - workDir: host.state.appState.workDir, - model, - thinking: effort, - permission: host.options.startup.auto - ? 'auto' - : host.options.startup.yolo - ? 'yolo' - : undefined, - planMode: host.state.appState.planMode ? true : undefined, - }; - if (host.state.appState.additionalDirs.length > 0) { - options.additionalDirs = [...host.state.appState.additionalDirs]; + const patch: Partial = { model }; + if (effort !== undefined) { + patch.thinkingEffort = effort; } - const session = await host.harness.createSession(options); - await host.setSession(session); - host.setAppState({ - sessionId: session.id, - sessionTitle: session.summary?.title ?? null, - }); - await host.syncRuntimeState(session); - host.sessionEventHandler.startSubscription(); - void host.fetchSessions(); - host.updateTerminalTitle(); - void host.refreshSkillCommands(host.session); - void host.refreshPluginCommands(host.session); + const selected = host.state.appState.availableModels[model]; + if (selected !== undefined) { + patch.maxContextTokens = selected.maxContextSize; + } + host.setAppState(patch); } async clearActiveSessionAfterLogout(): Promise { @@ -115,9 +107,9 @@ export class AuthFlowController { async refreshConfigAfterLogin(): Promise { const { host } = this; const config = await host.harness.getConfig({ reload: true }); - const availableModels = config.models ?? {}; - const availableProviders = config.providers ?? {}; - const defaultModel = host.options.startup.model ?? config.defaultModel; + const availableModels = modelsView(config); + const availableProviders = providersView(config); + const defaultModel = host.options.startup.model ?? defaultModelView(config); const selected = defaultModel !== undefined ? availableModels[defaultModel] : undefined; if (defaultModel === undefined || selected === undefined) { @@ -125,7 +117,7 @@ export class AuthFlowController { return; } - await this.activateModelAfterLogin(defaultModel, thinkingEffortFromConfig(config.thinking)); + await this.activateModelSelection(defaultModel, thinkingEffortFromConfig(thinkingView(config))); const appStatePatch: Partial = { availableModels, availableProviders, @@ -138,8 +130,8 @@ export class AuthFlowController { async refreshConfigAfterLogout(): Promise { const config = await this.host.harness.getConfig({ reload: true }); this.host.setAppState({ - availableModels: config.models ?? {}, - availableProviders: config.providers ?? {}, + availableModels: modelsView(config), + availableProviders: providersView(config), model: '', thinkingEffort: 'off', maxContextTokens: 0, @@ -164,18 +156,16 @@ export class AuthFlowController { private async refreshProviderModelsWithScope(scope: RefreshProviderScope): Promise { const { host } = this; - const result = await refreshAllProviderModels( - { - getConfig: () => host.harness.getConfig({ reload: true }), - removeProvider: (id) => host.harness.removeProvider(id), - setConfig: (patch) => host.harness.setConfig(patch), - resolveOAuthToken: async (providerName, oauthRef) => { - const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); - return tokenProvider.getAccessToken(); - }, + const hostAdapter: RefreshProviderHost = { + getConfig: () => host.harness.getConfig({ reload: true }), + removeProvider: (id) => host.harness.removeProvider(id), + setConfig: (patch) => host.harness.setConfig(patch), + resolveOAuthToken: async (providerName, oauthRef) => { + const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); + return tokenProvider.getAccessToken(); }, - { scope }, - ); + }; + const result = await refreshAllProviderModels(hostAdapter, { scope }); if (result.changed.length > 0) { await this.refreshAvailableModels(); } diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index a8ee45e61b..7ca5d95e22 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -1,12 +1,7 @@ import { Spacer } from '@moonshot-ai/pi-tui'; -import type { - Event, - KimiHarness, - Session, - TurnEndedEvent, -} from '@moonshot-ai/kimi-code-sdk'; - -import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; + +import type { CoreSession, SessionEvent, TurnEndedEvent } from '#/core/index'; +import { MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { BtwPanelComponent } from '../components/panes/btw-panel'; import { formatErrorMessage } from '../utils/event-payload'; import { formatHookResultPlain } from '../utils/hook-result-format'; @@ -17,10 +12,15 @@ const BTW_BUSY_NOTICE = 'Wait for /btw to finish before sending another question export interface BtwPanelHost { state: TUIState; - session: Session | undefined; - readonly harness: KimiHarness; + session: CoreSession | undefined; showError(msg: string): void; + /** + * Point the TUI's interactive prompt routing at the given agent. The btw + * panel targets its side-question agent while active and restores 'main' + * on exit (the v2 facade has no ambient `withInteractiveAgent` scope). + */ + setInteractiveAgentId(agentId: string): void; } export class BtwPanelController { @@ -46,6 +46,7 @@ export class BtwPanelController { }); this.active = { agentId, panel }; this.panelsByAgentId.set(agentId, panel); + this.host.setInteractiveAgentId(agentId); this.mount(panel); panel.submit(initialPrompt); } @@ -56,6 +57,7 @@ export class BtwPanelController { void this.cancelAgent(active.agentId); } this.active = undefined; + this.host.setInteractiveAgentId(MAIN_AGENT_ID); this.panelsByAgentId.clear(); this.host.state.btwPanelContainer.clear(); this.host.state.editor.connectedAbove = false; @@ -99,7 +101,7 @@ export class BtwPanelController { return true; } - routeEvent(event: Event): boolean { + routeEvent(event: SessionEvent): boolean { const panel = this.panelsByAgentId.get(event.agentId); if (panel === undefined) return false; @@ -153,7 +155,10 @@ export class BtwPanelController { this.panelsByAgentId.delete(agentId); } } - if (this.active?.panel === panel) this.active = undefined; + if (this.active?.panel === panel) { + this.active = undefined; + this.host.setInteractiveAgentId(MAIN_AGENT_ID); + } } private showBusyNotice( @@ -172,16 +177,18 @@ export class BtwPanelController { this.host.state.ui.requestRender(); return; } - void this.withInteractiveAgent(agentId, () => session.prompt(prompt)).catch((error: unknown) => { - panel.markFailed(`Failed to send /btw prompt: ${formatErrorMessage(error)}`); - this.host.state.ui.requestRender(); - }); + void session + .prompt([{ type: 'text', text: prompt }], { agentId }) + .catch((error: unknown) => { + panel.markFailed(`Failed to send /btw prompt: ${formatErrorMessage(error)}`); + this.host.state.ui.requestRender(); + }); } private async cancelAgent(agentId: string): Promise { const session = this.host.session; if (session === undefined) return; - await this.withInteractiveAgent(agentId, () => session.cancel()).catch((error: unknown) => { + await session.cancel({ agentId }).catch((error: unknown) => { this.host.showError(`Failed to cancel /btw: ${formatErrorMessage(error)}`); }); } @@ -189,10 +196,6 @@ export class BtwPanelController { private shouldCancelOnUnmount(panel: BtwPanelComponent): boolean { return panel.isRunning() || panel.isEmpty(); } - - private withInteractiveAgent(agentId: string, fn: () => Promise): Promise { - return this.host.harness.withInteractiveAgent(agentId, fn); - } } function formatBtwTurnEnd(event: TurnEndedEvent): string { diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index e76492f7b9..6bfc88918b 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -1,6 +1,9 @@ -import type { Session } from '@moonshot-ai/kimi-code-sdk'; -import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk'; - +import { + compressImageForModel, + persistOriginalImage, + sessionMediaOriginalsDir, + type CoreSession, +} from '#/core/index'; import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; import { parseImageMeta } from '#/utils/image/image-mime'; import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor'; @@ -21,12 +24,12 @@ import type { BtwPanelController } from './btw-panel'; export interface EditorKeyboardHost { state: TUIState; - session: Session | undefined; + session: CoreSession | undefined; cancelInFlight: (() => void) | undefined; handleUserInput(text: string): void; readonly btwPanelController: BtwPanelController; - steerMessage(session: Session, input: string[]): void; + steerMessage(session: CoreSession, input: string[]): void; recallLastQueued(): QueuedMessage | undefined; showError(msg: string): void; track(event: string, props?: Record): void; @@ -410,8 +413,9 @@ export class EditorKeyboardController { const compressed = await compressImageForModel(media.bytes, meta.mime, { telemetry: { client: { - track: (event, properties) => - this.host.track(event, properties === undefined ? undefined : { ...properties }), + track: (event, properties) => { + this.host.track(event, properties === undefined ? undefined : { ...properties }); + }, }, source: 'tui_paste', }, diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 815759a4b3..6f9b1f420c 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -1,23 +1,24 @@ import type { Component, Focusable } from '@moonshot-ai/pi-tui'; + import type { AgentStatusUpdatedEvent, + AgentTaskInfo, AssistantDeltaEvent, - BackgroundTaskInfo, - BackgroundTaskStartedEvent, - BackgroundTaskTerminatedEvent, CompactionCancelledEvent, CompactionCompletedEvent, CompactionStartedEvent, + CoreSession, CronFiredEvent, ErrorEvent, - Event, GoalChange, GoalUpdatedEvent, HookResultEvent, - Session, + PluginCommandActivatedEvent, + SessionEvent, SessionMetaUpdatedEvent, SkillActivatedEvent, - PluginCommandActivatedEvent, + TaskStartedEvent, + TaskTerminatedEvent, ThinkingDeltaEvent, ToolCallDeltaEvent, ToolCallStartedEvent, @@ -29,7 +30,7 @@ import type { TurnStepInterruptedEvent, TurnStepStartedEvent, WarningEvent, -} from '@moonshot-ai/kimi-code-sdk'; +} from '#/core/index'; import { MoonLoader } from '../components/chrome/moon-loader'; import { buildGoalMarker } from '../components/messages/goal-markers'; @@ -46,17 +47,10 @@ import { buildGoalCompletionMessage } from '../utils/goal-completion'; import { argsRecord, formatErrorPayload, - formatErrorMessage, isTodoItemShape, serializeToolResultOutput, stringValue, } from '../utils/event-payload'; -import { - readGoalQueue, - removeGoalQueueItem, - restoreGoalQueueItem, - type UpcomingGoal, -} from '../goal-queue-store'; import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; import { formatHookResultMarkdown } from '../utils/hook-result-format'; import { McpOAuthAuthorizationUrlOpener } from '../utils/mcp-oauth'; @@ -80,21 +74,22 @@ import type { AppState, LivePaneState, QueuedMessage, + SkillActivationTrigger, ToolCallBlockData, ToolResultBlockData, TranscriptEntry, } from '../types'; import type { TUIState } from '../tui-state'; -import { createGoal as startGoalCommand } from '../commands/goal'; +import { GoalQueuePromoter } from '../goal-queue-promoter'; export interface SessionEventHost { state: TUIState; - session: Session | undefined; + session: CoreSession | undefined; aborted: boolean; sessionEventUnsubscribe: (() => void) | undefined; readonly streamingUI: StreamingUIController; - requireSession(): Session; + requireSession(): CoreSession; setAppState(patch: Partial): void; patchLivePane(patch: Partial): void; resetLivePane(): void; @@ -111,7 +106,12 @@ export interface SessionEventHost { handleShellStarted(event: { commandId: string; taskId: string }): void; sendNormalUserInput(text: string): void; updateTerminalTitle(): void; - sendQueuedMessage(session: Session, item: QueuedMessage): void; + sendQueuedMessage(session: CoreSession, item: QueuedMessage): void; + sendQueuedGoalMessage( + session: CoreSession, + item: QueuedMessage, + confirmation: Component, + ): Promise; shiftQueuedMessage(): QueuedMessage | undefined; readonly btwPanelController: BtwPanelController; readonly tasksBrowserController: TasksBrowserController; @@ -120,7 +120,10 @@ export interface SessionEventHost { export class SessionEventHandler { readonly subAgentEventHandler: SubAgentEventHandler; + private readonly promoter: GoalQueuePromoter; + constructor(private readonly host: SessionEventHost) { + this.promoter = new GoalQueuePromoter(host); this.subAgentEventHandler = new SubAgentEventHandler(host, { backgroundTasks: this.backgroundTasks, backgroundTaskTranscriptedTerminal: this.backgroundTaskTranscriptedTerminal, @@ -131,7 +134,7 @@ export class SessionEventHandler { } // Runtime state – owned by this handler, reset between sessions. - backgroundTasks: Map = new Map(); + backgroundTasks: Map = new Map(); backgroundTaskTranscriptedTerminal: Set = new Set(); renderedSkillActivationIds: Set = new Set(); @@ -139,13 +142,8 @@ export class SessionEventHandler { renderedMcpServerStatusKeys: Map = new Map(); mcpServerStatusSpinners: Map = new Map(); mcpServers: Map = new Map(); - private goalCompletionAwaitingClear = false; - private goalCompletionTurnEnded = false; private currentTurnHasAssistantText = false; private pendingModelBlockedFallback: GoalChange | undefined; - private queuedGoalPromotionPending = false; - private queuedGoalPromotionInFlight = false; - private queuedGoalPromotionTimer: ReturnType | undefined; resetRuntimeState(): void { this.backgroundTasks.clear(); @@ -155,13 +153,9 @@ export class SessionEventHandler { this.renderedPluginCommandActivationIds.clear(); this.renderedMcpServerStatusKeys.clear(); this.mcpServers.clear(); - this.goalCompletionAwaitingClear = false; - this.goalCompletionTurnEnded = false; this.currentTurnHasAssistantText = false; this.pendingModelBlockedFallback = undefined; - this.queuedGoalPromotionPending = false; - this.queuedGoalPromotionInFlight = false; - this.clearQueuedGoalPromotionTimer(); + this.promoter.reset(); this.stopAllMcpServerStatusSpinners(); } @@ -181,6 +175,9 @@ export class SessionEventHandler { const { host } = this; const session = host.requireSession(); const sendQueued = (item: QueuedMessage): void => { + if (host.aborted) return; + if (host.session !== session) return; + if (host.state.appState.sessionId !== session.id) return; host.sendQueuedMessage(session, item); }; host.sessionEventUnsubscribe?.(); @@ -197,7 +194,7 @@ export class SessionEventHandler { void this.syncMcpServerStatusSnapshot(session); } - async syncMcpServerStatusSnapshot(session: Session): Promise { + async syncMcpServerStatusSnapshot(session: CoreSession): Promise { const { host } = this; let servers: readonly McpServerStatusSnapshot[]; try { @@ -232,7 +229,7 @@ export class SessionEventHandler { host.setAppState({ mcpServersSummary: summary || null }); } - handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void { + handleEvent(event: SessionEvent, sendQueued: (item: QueuedMessage) => void): void { if (this.subAgentEventHandler.routeChildAgentEvent(event)) return; if ('turnId' in event && event.turnId !== undefined) { @@ -272,8 +269,9 @@ export class SessionEventHandler { case 'subagent.completed': case 'subagent.failed': this.subAgentEventHandler.handleLifecycleEvent(event); break; - case 'background.task.started': - case 'background.task.terminated': + // v2 renamed the background task lifecycle events to `task.*`. + case 'task.started': + case 'task.terminated': this.handleBackgroundTaskEvent(event); break; case 'cron.fired': this.handleCronFired(event); break; case 'mcp.server.status': this.renderMcpServerStatus(event.server); break; @@ -347,8 +345,7 @@ export class SessionEventHandler { this.host.streamingUI.finalizeTurn(sendQueued); this.renderPendingModelBlockedFallback(); this.currentTurnHasAssistantText = false; - this.goalCompletionTurnEnded = true; - this.scheduleQueuedGoalPromotion(); + this.promoter.onTurnEnded(); } private handleStepBegin(event: TurnStepStartedEvent): void { @@ -602,14 +599,20 @@ export class SessionEventHandler { this.host.state.appState.swarmMode && this.host.state.swarmModeEntry === 'task'; const patch: Partial = {}; - if (event.contextUsage !== undefined) patch.contextUsage = event.contextUsage; if (event.contextTokens !== undefined) patch.contextTokens = event.contextTokens; if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens; + // The v2 `agent.status.updated` payload carries no `contextUsage` (or + // `permission`) field, unlike the v1 protocol event: derive the usage + // ratio from the freshest token counts so the footer gauge stays live; + // permission changes flow through explicit setPermission + status sync. + if (event.contextTokens !== undefined || event.maxContextTokens !== undefined) { + const contextTokens = event.contextTokens ?? this.host.state.appState.contextTokens; + const maxContextTokens = + event.maxContextTokens ?? this.host.state.appState.maxContextTokens; + patch.contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0; + } if (event.planMode !== undefined) patch.planMode = event.planMode; if (event.swarmMode !== undefined) patch.swarmMode = event.swarmMode; - if (event.permission !== undefined) { - patch.permissionMode = event.permission; - } if (event.model !== undefined) patch.model = event.model; if (Object.keys(patch).length > 0) this.host.setAppState(patch); if (event.swarmMode === false) { @@ -629,12 +632,8 @@ export class SessionEventHandler { private handleGoalUpdated(event: GoalUpdatedEvent): void { this.host.setAppState({ goal: event.snapshot }); - if (event.snapshot === null && this.goalCompletionAwaitingClear) { - this.goalCompletionAwaitingClear = false; - this.queuedGoalPromotionPending = true; - this.scheduleQueuedGoalPromotion(); - } if (event.snapshot === null) { + this.promoter.onSnapshotCleared(); this.pendingModelBlockedFallback = undefined; } const change = event.change; @@ -647,8 +646,7 @@ export class SessionEventHandler { // record, so live and replayed completion cards stay identical. if (change.kind === 'completion' && event.snapshot !== null) { this.pendingModelBlockedFallback = undefined; - this.goalCompletionAwaitingClear = true; - this.goalCompletionTurnEnded = false; + this.promoter.onGoalCompletion(); this.host.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'assistant', @@ -662,7 +660,7 @@ export class SessionEventHandler { // Lifecycle change (pause / resume / blocked) -> a low-profile, // ctrl+o-expandable marker. if (change.kind === 'lifecycle' && change.status === 'blocked') { - void this.notifyQueuedGoalWaitingOnBlocked(); + void this.promoter.notifyBlocked(); if (change.actor === 'model' || change.reason === undefined) { this.pendingModelBlockedFallback = this.currentTurnHasAssistantText ? undefined @@ -692,150 +690,12 @@ export class SessionEventHandler { } } - private scheduleQueuedGoalPromotion(): void { - if (!this.queuedGoalPromotionPending || !this.goalCompletionTurnEnded) return; - if (this.queuedGoalPromotionInFlight) return; - if (this.queuedGoalPromotionTimer !== undefined) return; - this.queuedGoalPromotionTimer = setTimeout(() => { - this.queuedGoalPromotionTimer = undefined; - if (!this.queuedGoalPromotionPending || !this.goalCompletionTurnEnded) return; - if (this.queuedGoalPromotionInFlight) return; - if (!this.isReadyForQueuedGoalPromotion()) { - return; - } - this.queuedGoalPromotionInFlight = true; - void this.promoteNextQueuedGoal() - .then((complete) => { - if (complete) { - this.queuedGoalPromotionPending = false; - this.goalCompletionTurnEnded = false; - return; - } - this.goalCompletionTurnEnded = false; - }) - .finally(() => { - this.queuedGoalPromotionInFlight = false; - this.scheduleQueuedGoalPromotion(); - }); - }, 0); - } - - private clearQueuedGoalPromotionTimer(): void { - if (this.queuedGoalPromotionTimer === undefined) return; - clearTimeout(this.queuedGoalPromotionTimer); - this.queuedGoalPromotionTimer = undefined; - } - requestQueuedGoalPromotion(): void { - this.queuedGoalPromotionPending = true; - this.goalCompletionTurnEnded = true; - this.scheduleQueuedGoalPromotion(); + this.promoter.request(); } retryQueuedGoalPromotion(): void { - this.scheduleQueuedGoalPromotion(); - } - - private isReadyForQueuedGoalPromotion(session?: Session): boolean { - return ( - (session === undefined || this.host.session === session) && - !this.host.aborted && - this.host.state.appState.streamingPhase === 'idle' && - this.host.state.queuedMessages.length === 0 && - !this.host.state.queuedMessageDispatchPending - ); - } - - private async promoteNextQueuedGoal(): Promise { - const { host } = this; - const session = host.session; - if (session === undefined || host.aborted) return true; - - let queue; - try { - queue = await readGoalQueue(session); - } catch (error) { - host.showError(`Failed to read upcoming goals: ${formatErrorMessage(error)}`); - return false; - } - if (host.session !== session || host.aborted) return true; - - const next = queue.goals[0]; - if (next === undefined) return true; - - if (!this.isReadyForQueuedGoalPromotion(session)) return false; - - const started = await startGoalCommand( - host, - { kind: 'create', objective: next.objective, replace: false }, - next.objective, - { - beforeSend: async () => { - if (!this.isReadyForQueuedGoalPromotion(session)) { - await this.cancelStartedQueuedGoal(session); - return false; - } - try { - await removeGoalQueueItem(session, { goalId: next.id }); - } catch (error) { - host.showError( - `Queued goal started, but could not be removed from the queue: ${formatErrorMessage(error)}`, - ); - await this.cancelStartedQueuedGoal(session); - return false; - } - if (this.isReadyForQueuedGoalPromotion(session)) { - return true; - } - await this.restoreAndCancelStartedQueuedGoal(session, next); - return false; - }, - sendInput: (objective) => { - host.sendQueuedMessage(session, { text: objective }); - }, - }, - ); - return started || host.session !== session || host.aborted; - } - - private async restoreAndCancelStartedQueuedGoal( - session: Session, - goal: UpcomingGoal, - ): Promise { - try { - await restoreGoalQueueItem(session, goal); - } catch (error) { - this.host.showError(`Queued goal could not be restored: ${formatErrorMessage(error)}`); - } - await this.cancelStartedQueuedGoal(session); - } - - private async cancelStartedQueuedGoal(session: Session): Promise { - try { - await session.cancelGoal(); - } catch (error) { - this.host.showError(`Queued goal could not be cancelled: ${formatErrorMessage(error)}`); - } - } - - private async notifyQueuedGoalWaitingOnBlocked(): Promise { - const { host } = this; - const session = host.session; - if (session === undefined || host.aborted) return; - - let hasQueuedGoal = false; - try { - const queue = await readGoalQueue(session); - hasQueuedGoal = queue.goals.length > 0; - } catch { - return; - } - if (!hasQueuedGoal || host.session !== session || host.aborted) return; - - host.showNotice( - 'Goal blocked.', - 'The next queued goal will start only after this goal is complete.', - ); + this.promoter.retry(); } private handleSessionMetaChanged(event: SessionMetaUpdatedEvent): void { @@ -951,7 +811,9 @@ export class SessionEventHandler { skillActivationId: event.activationId, skillName: event.skillName, skillArgs: event.skillArgs, - skillTrigger: event.trigger, + // v2 declares `trigger` as a plain string; the runtime source + // (SkillActivationOrigin) is exactly this literal union. + skillTrigger: event.trigger as SkillActivationTrigger, }); } @@ -1008,16 +870,19 @@ export class SessionEventHandler { const hasActiveTurn = this.host.streamingUI.hasActiveTurn(); if (!hasActiveTurn) { const next = this.host.shiftQueuedMessage(); + let dispatchGeneration: number | undefined; if (next !== undefined) { this.host.state.queuedMessageDispatchPending = true; + dispatchGeneration = ++this.host.state.queuedMessageDispatchGeneration; } this.host.setAppState({ isCompacting: false, streamingPhase: 'idle', }); this.host.resetLivePane(); - if (next !== undefined) { + if (next !== undefined && dispatchGeneration !== undefined) { setTimeout(() => { + if (this.host.state.queuedMessageDispatchGeneration !== dispatchGeneration) return; this.host.state.queuedMessageDispatchPending = false; sendQueued(next); }, 0); @@ -1032,7 +897,7 @@ export class SessionEventHandler { // --------------------------------------------------------------------------- private handleBackgroundTaskEvent( - event: BackgroundTaskStartedEvent | BackgroundTaskTerminatedEvent, + event: TaskStartedEvent | TaskTerminatedEvent, ): void { const { state } = this.host; const { info } = event; @@ -1051,7 +916,7 @@ export class SessionEventHandler { info.status === 'killed' || info.status === 'lost'; - if (event.type === 'background.task.started') { + if (event.type === 'task.started') { if (info.kind === 'agent') { // A foreground subagent detached via Ctrl+B: flip its card to // `◐ backgrounded` so it doesn't look like it completed. @@ -1066,7 +931,7 @@ export class SessionEventHandler { return; } - if (event.type === 'background.task.terminated' && isTerminal) { + if (event.type === 'task.terminated' && isTerminal) { if (info.kind === 'agent') { // The Agent tool's spawn-success ToolResult is not an error, so the // parent toolCall card would otherwise render `✓ Completed` for any @@ -1095,7 +960,7 @@ export class SessionEventHandler { this.host.tasksBrowserController.repaint(); } - private appendBackgroundTaskEntry(info: BackgroundTaskInfo): void { + private appendBackgroundTaskEntry(info: AgentTaskInfo): void { const status = formatBackgroundTaskTranscript(info); const entry: TranscriptEntry = { id: nextTranscriptId(), diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 00ee5a64b5..11573f746f 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -1,13 +1,14 @@ +import { COMPACTION_SUMMARY_PREFIX } from '#/core/index'; import type { AgentReplayRecord, ContextMessage, + CoreSession, GoalChange, PermissionMode, PromptOrigin, ResumedAgentState, - Session, ToolCall, -} from '@moonshot-ai/kimi-code-sdk'; +} from '#/core/index'; import { ToolCallComponent } from '../components/messages/tool-call'; import { currentTheme } from '../theme'; @@ -81,7 +82,7 @@ function unescapeBashXml(text: string): string { export class SessionReplayRenderer { constructor(private readonly host: SessionReplayHost) {} - async hydrateFromReplay(session: Session): Promise { + async hydrateFromReplay(session: CoreSession): Promise { this.host.setAppState({ isReplaying: true }); try { const main = session.getResumeState()?.agents['main']; @@ -293,6 +294,23 @@ export class SessionReplayRenderer { } return; } + if (message.origin?.kind === 'compaction_summary') { + // Defensive fallback: the summary normally arrives as a `compaction` + // replay record (rendered by renderCompaction with token counts) since + // the facade folds wire records. Only a summary that shows up as a bare + // context message lands here; render the same collapsible card instead + // of dumping the text as a plain user message. + this.flushAssistant(context); + const text = contentPartsToText(message.content); + const summary = text.startsWith(COMPACTION_SUMMARY_PREFIX) + ? text.slice(COMPACTION_SUMMARY_PREFIX.length).trimStart() + : text; + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'status', 'Compaction complete', 'plain'), + compactionData: { summary }, + }); + return; + } if (message.origin?.kind === 'cron_job') { this.renderCronJob(context, message); return; @@ -628,7 +646,7 @@ export class SessionReplayRenderer { switch (result.decision) { case 'rejected': content = - result.selectedLabel === 'Revise' ? 'Plan sent back for revision' : 'Plan review rejected'; + result.selected_label === 'Revise' ? 'Plan sent back for revision' : 'Plan review rejected'; break; case 'cancelled': content = 'Plan review cancelled'; @@ -661,7 +679,7 @@ export class SessionReplayRenderer { private renderBackgroundTaskNotification( context: ReplayRenderContext, - origin: Extract, + origin: Extract, ): void { const { sessionEventHandler } = this.host; const task = sessionEventHandler.backgroundTasks.get(origin.taskId); diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index ae5eac7681..d816aca92c 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -1,4 +1,4 @@ -import type { Session } from '@moonshot-ai/kimi-code-sdk'; +import type { CoreSession } from '#/core/index'; import { AgentGroupComponent } from '../components/messages/agent-group'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; @@ -25,13 +25,13 @@ import type { TUIState } from '../tui-state'; export interface StreamingUIHost { state: TUIState; - session: Session | undefined; + session: CoreSession | undefined; setAppState(patch: Partial): void; patchLivePane(patch: Partial): void; resetLivePane(): void; updateActivityPane(): void; updateQueueDisplay(): void; - requireSession(): Session; + requireSession(): CoreSession; deferUserMessages: boolean; shiftQueuedMessage(): QueuedMessage | undefined; pushTranscriptEntry(entry: TranscriptEntry): void; @@ -565,9 +565,11 @@ export class StreamingUIController { // queued-goal promotion, which would otherwise see an empty queue and an // idle phase and start a goal ahead of this message. state.queuedMessageDispatchPending = true; + const dispatchGeneration = ++state.queuedMessageDispatchGeneration; this.host.setAppState({ streamingPhase: 'idle' }); this.host.resetLivePane(); setTimeout(() => { + if (state.queuedMessageDispatchGeneration !== dispatchGeneration) return; state.queuedMessageDispatchPending = false; sendQueued(next); }, 0); diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index deb407bfc2..080e94117e 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -1,9 +1,7 @@ -import type { - BackgroundTaskInfo, - Event, -} from '@moonshot-ai/kimi-code-sdk'; import type { Component } from '@moonshot-ai/pi-tui'; +import type { AgentTaskInfo, SessionEvent } from '#/core/index'; + import { AgentSwarmProgressComponent, agentSwarmDescriptionFromArgs, @@ -29,12 +27,12 @@ export interface SubagentInfo { readonly swarmIndex?: number; } -export type SubagentLifecycleEvent = Event & { type: `subagent.${string}` }; +export type SubagentLifecycleEvent = SessionEvent & { type: `subagent.${string}` }; type SubagentLifecycleEventOf = SubagentLifecycleEvent & { type: Type }; export interface SubAgentEventHandlerDependencies { - readonly backgroundTasks: ReadonlyMap; + readonly backgroundTasks: ReadonlyMap; readonly backgroundTaskTranscriptedTerminal: Set; readonly syncBackgroundAgentBadge: () => void; } @@ -67,8 +65,9 @@ export class SubAgentEventHandler { this.clearAgentSwarmProgress(); } - routeChildAgentEvent(event: Event): boolean { + routeChildAgentEvent(event: SessionEvent): boolean { if (isSubagentLifecycleEvent(event)) return false; + if (event.type === 'task.started' || event.type === 'task.terminated') return false; const childAgentId = event.agentId; if (childAgentId === MAIN_AGENT_ID) return false; @@ -339,7 +338,7 @@ export class SubAgentEventHandler { private findAgentTaskId( subagentId: string, meta: BackgroundAgentMetadata, - backgroundTasks: ReadonlyMap, + backgroundTasks: ReadonlyMap, ): string | undefined { for (const info of backgroundTasks.values()) { if (info.kind !== 'agent') continue; @@ -495,7 +494,7 @@ export class SubAgentEventHandler { private applySubagentEventToSwarmProgress( progress: AgentSwarmProgressComponent, - event: Event, + event: SessionEvent, subagentId: string, ): void { if (event.type === 'assistant.delta' || event.type === 'thinking.delta') { @@ -621,7 +620,7 @@ export class SubAgentEventHandler { } } -function isSubagentLifecycleEvent(event: Event): event is SubagentLifecycleEvent { +function isSubagentLifecycleEvent(event: SessionEvent): event is SubagentLifecycleEvent { return ( event.type === 'subagent.spawned' || event.type === 'subagent.started' || diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index 6994b13b86..4b6ef441cd 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -1,6 +1,6 @@ -import type { BackgroundTaskInfo, Session } from '@moonshot-ai/kimi-code-sdk'; import type { Component, ProcessTerminal, TUI } from '@moonshot-ai/pi-tui'; +import type { AgentTaskInfo, CoreSession } from '#/core/index'; import { TaskOutputViewer } from '../components/dialogs/task-output-viewer'; import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser'; import type { Theme } from '#/tui/theme'; @@ -14,8 +14,8 @@ export interface TasksBrowserHost { readonly ui: TUI; readonly editor: CustomEditor; }; - readonly backgroundTasks: ReadonlyMap; - readonly session: Session | undefined; + readonly backgroundTasks: ReadonlyMap; + readonly session: CoreSession | undefined; showError(msg: string): void; setTasksBrowser(value: TasksBrowserState | undefined): void; } @@ -56,7 +56,7 @@ export class TasksBrowserController { return; } - let tasks: readonly BackgroundTaskInfo[] = []; + let tasks: readonly AgentTaskInfo[] = []; try { tasks = await session.listBackgroundTasks({ activeOnly: false }); } catch (error) { @@ -176,7 +176,7 @@ export class TasksBrowserController { // --------------------------------------------------------------------------- private pickInitialSelection( - tasks: readonly BackgroundTaskInfo[], + tasks: readonly AgentTaskInfo[], filter: TasksFilter, ): string | undefined { const candidates = @@ -202,7 +202,7 @@ export class TasksBrowserController { const session = this.host.session; if (session === undefined) return; - let tasks: readonly BackgroundTaskInfo[]; + let tasks: readonly AgentTaskInfo[]; try { tasks = await session.listBackgroundTasks({ activeOnly: false }); } catch (error) { @@ -217,7 +217,7 @@ export class TasksBrowserController { this.pushProps(tasks); } - private pushProps(tasks: readonly BackgroundTaskInfo[]): void { + private pushProps(tasks: readonly AgentTaskInfo[]): void { const browser = this.host.state.tasksBrowser; if (browser === undefined) return; browser.component.setProps({ @@ -303,7 +303,7 @@ export class TasksBrowserController { this.flash(`Stopping ${taskId}…`, 1500); try { - await session.stopBackgroundTask(taskId, { reason: 'User initiated stop' }); + await session.stopBackgroundTask(taskId, 'User initiated stop'); await this.refresh({ silent: true }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/apps/kimi-code/src/tui/goal-queue-promoter.ts b/apps/kimi-code/src/tui/goal-queue-promoter.ts new file mode 100644 index 0000000000..c0a423767c --- /dev/null +++ b/apps/kimi-code/src/tui/goal-queue-promoter.ts @@ -0,0 +1,215 @@ +/** + * Queued-goal promotion for the TUI. + * + * When the active goal completes (and the turn has ended), start the next + * queued goal, if any. This module owns the promotion state machine — the + * pending / in-flight / timer gates plus the completion and turn-ended gates — + * and the queue read → create → remove → restore/cancel dance. The session + * event handler observes the underlying events and forwards the promotion + * signals here (`onGoalCompletion` / `onSnapshotCleared` / `onTurnEnded`), so + * goal orchestration stays out of the event-routing controller. + */ + +import { createGoal as startGoalCommand } from './commands/goal'; +import { + readGoalQueue, + removeGoalQueueItem, + restoreGoalQueueItem, + type UpcomingGoal, +} from './goal-queue-store'; +import { formatErrorMessage } from './utils/event-payload'; +import type { CoreSession } from '#/core/index'; +import type { SessionEventHost } from './controllers/session-event-handler'; + +export class GoalQueuePromoter { + private awaitingClear = false; + private turnEnded = false; + private pending = false; + private inFlight = false; + private timer: ReturnType | undefined; + private generation = 0; + + constructor(private readonly host: SessionEventHost) {} + + reset(): void { + this.generation += 1; + this.awaitingClear = false; + this.turnEnded = false; + this.pending = false; + this.inFlight = false; + this.clearTimer(); + } + + /** A goal just completed (snapshot still live); arm the clear gate. */ + onGoalCompletion(): void { + this.awaitingClear = true; + this.turnEnded = false; + } + + /** The completed goal's snapshot was cleared; queue a promotion if armed. */ + onSnapshotCleared(): void { + if (!this.awaitingClear) return; + this.awaitingClear = false; + this.pending = true; + this.schedule(); + } + + /** A turn ended; a queued promotion is allowed to fire. */ + onTurnEnded(): void { + this.turnEnded = true; + this.schedule(); + } + + /** External request (e.g. `/goal next` queued the first goal). */ + request(): void { + this.pending = true; + this.turnEnded = true; + this.schedule(); + } + + /** Re-arm the scheduler after a busy state clears. */ + retry(): void { + this.schedule(); + } + + /** Surface a low-profile notice when the active goal is blocked with a queue. */ + async notifyBlocked(): Promise { + const { host } = this; + const session = host.session; + if (session === undefined || host.aborted) return; + + let hasQueuedGoal = false; + try { + const queue = await readGoalQueue(session); + hasQueuedGoal = queue.goals.length > 0; + } catch { + return; + } + if (!hasQueuedGoal || host.session !== session || host.aborted) return; + + host.showNotice( + 'Goal blocked.', + 'The next queued goal will start only after this goal is complete.', + ); + } + + private schedule(): void { + if (!this.pending || !this.turnEnded) return; + if (this.inFlight) return; + if (this.timer !== undefined) return; + const generation = this.generation; + this.timer = setTimeout(() => { + if (generation !== this.generation) return; + this.timer = undefined; + if (!this.pending || !this.turnEnded) return; + if (this.inFlight) return; + if (!this.isReady()) { + return; + } + this.inFlight = true; + void this.promote() + .then((complete) => { + if (generation !== this.generation) return; + if (complete) this.pending = false; + this.turnEnded = false; + }) + .catch((error: unknown) => { + if (generation !== this.generation) return; + this.turnEnded = false; + this.host.showError(`Failed to promote queued goal: ${formatErrorMessage(error)}`); + }) + .finally(() => { + if (generation === this.generation) this.inFlight = false; + }); + }, 0); + } + + private clearTimer(): void { + if (this.timer === undefined) return; + clearTimeout(this.timer); + this.timer = undefined; + } + + private isReady(session?: CoreSession): boolean { + return ( + (session === undefined || this.host.session === session) && + !this.host.aborted && + this.host.state.appState.streamingPhase === 'idle' && + this.host.state.queuedMessages.length === 0 && + !this.host.state.queuedMessageDispatchPending + ); + } + + private async promote(): Promise { + const { host } = this; + const session = host.session; + if (session === undefined || host.aborted) return true; + + let queue; + try { + queue = await readGoalQueue(session); + } catch (error) { + host.showError(`Failed to read upcoming goals: ${formatErrorMessage(error)}`); + return false; + } + if (host.session !== session || host.aborted) return true; + + const next = queue.goals[0]; + if (next === undefined) return true; + + if (!this.isReady(session)) return false; + + const started = await startGoalCommand( + host, + { kind: 'create', objective: next.objective, replace: false }, + next.objective, + { + beforeSend: async () => { + if (!this.isReady(session)) { + await this.cancelStarted(session); + return false; + } + try { + await removeGoalQueueItem(session, { goalId: next.id }); + } catch (error) { + host.showError( + `Queued goal started, but could not be removed from the queue: ${formatErrorMessage(error)}`, + ); + await this.cancelStarted(session); + return false; + } + if (this.isReady(session)) { + return true; + } + await this.restoreAndCancel(session, next); + return false; + }, + sendConfirmationWithInput: (objective, confirmation) => + host.sendQueuedGoalMessage(session, { text: objective }, confirmation), + onSendError: () => this.restoreAndCancel(session, next), + }, + ); + return started || host.session !== session || host.aborted; + } + + private async restoreAndCancel(session: CoreSession, goal: UpcomingGoal): Promise { + try { + await restoreGoalQueueItem(session, goal); + } catch (error) { + if (this.host.session === session) { + this.host.showError(`Queued goal could not be restored: ${formatErrorMessage(error)}`); + } + } + await this.cancelStarted(session); + } + + private async cancelStarted(session: CoreSession): Promise { + try { + await session.cancelGoal(); + } catch (error) { + if (this.host.session === session) { + this.host.showError(`Queued goal could not be cancelled: ${formatErrorMessage(error)}`); + } + } + } +} diff --git a/apps/kimi-code/src/tui/goal-queue-store.ts b/apps/kimi-code/src/tui/goal-queue-store.ts index 0b98eda676..3b63f798cb 100644 --- a/apps/kimi-code/src/tui/goal-queue-store.ts +++ b/apps/kimi-code/src/tui/goal-queue-store.ts @@ -1,10 +1,7 @@ import { randomUUID } from 'node:crypto'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import { - ErrorCodes, - KimiError, -} from '@moonshot-ai/kimi-code-sdk'; +import { CoreError, CoreErrorCodes } from '#/core/index'; const GOAL_QUEUE_FILE = 'upcoming-goals.json'; const GOAL_QUEUE_VERSION = 1; @@ -152,8 +149,8 @@ async function readQueueFile(session: GoalQueueSession): Promise try { parsed = JSON.parse(raw); } catch (error) { - throw new KimiError( - ErrorCodes.CONFIG_INVALID, + throw new CoreError( + CoreErrorCodes.CONFIG_INVALID, `Invalid JSON in goal queue: ${describeError(error)}`, ); } @@ -205,11 +202,11 @@ function toSnapshot(file: GoalQueueFile): GoalQueueSnapshot { function normalizeObjective(value: string): string { const objective = value.trim(); if (objective.length === 0) { - throw new KimiError(ErrorCodes.GOAL_OBJECTIVE_EMPTY, 'Goal objective cannot be empty'); + throw new CoreError(CoreErrorCodes.GOAL_OBJECTIVE_EMPTY, 'Goal objective cannot be empty'); } if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { - throw new KimiError( - ErrorCodes.GOAL_OBJECTIVE_TOO_LONG, + throw new CoreError( + CoreErrorCodes.GOAL_OBJECTIVE_TOO_LONG, `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters`, ); } @@ -219,7 +216,7 @@ function normalizeObjective(value: string): string { function findGoalIndex(file: GoalQueueFile, goalId: string): number { const index = file.goals.findIndex((goal) => goal.id === goalId); if (index === -1) { - throw new KimiError(ErrorCodes.GOAL_NOT_FOUND, 'No queued goal found'); + throw new CoreError(CoreErrorCodes.GOAL_NOT_FOUND, 'No queued goal found'); } return index; } diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts b/apps/kimi-code/src/tui/interactions/approval-adapter.ts similarity index 93% rename from apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts rename to apps/kimi-code/src/tui/interactions/approval-adapter.ts index 373690592d..3d60ef12f3 100644 --- a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts +++ b/apps/kimi-code/src/tui/interactions/approval-adapter.ts @@ -1,8 +1,8 @@ -import type { ApprovalRequest, ApprovalResponse, ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk'; +import type { ApprovalResponse, CoreApprovalRequest, ToolInputDisplay } from '#/core/index'; import type { ApprovalPanelResponse } from '#/tui/components/dialogs/approval-panel'; import { goalStartOptions } from '#/tui/components/dialogs/goal-start-permission-prompt'; -import type { ApprovalPanelChoice, ApprovalPanelData, DisplayBlock } from '#/tui/reverse-rpc/types'; +import type { ApprovalPanelChoice, ApprovalPanelData, DisplayBlock } from '#/tui/interactions/types'; const DEFAULT_APPROVAL_CHOICES: ApprovalPanelChoice[] = [ { label: 'Approve once', response: 'approved' }, @@ -16,16 +16,23 @@ const PLAN_REJECT_CHOICES: ApprovalPanelChoice[] = [ { label: 'Revise', response: 'rejected', selected_label: 'Revise', requires_feedback: true }, ]; -export function adaptApprovalRequest(event: ApprovalRequest): ApprovalPanelData { - const resolved = resolveDisplay(event.toolName, event.display, event.action); +/** + * Project a core approval request into the panel view payload. `id` is the + * pending-interaction id used to correlate the panel with the session broker; + * it falls back to the request's own id / tool-call id (v2 marks both + * optional). + */ +export function adaptApprovalRequest(request: CoreApprovalRequest, id?: string): ApprovalPanelData { + const panelId = id ?? request.id ?? request.toolCallId ?? 'approval'; + const resolved = resolveDisplay(request.toolName, request.display, request.action); return { - id: event.toolCallId, - tool_call_id: event.toolCallId, - tool_name: event.toolName, - action: event.action, + id: panelId, + tool_call_id: request.toolCallId ?? panelId, + tool_name: request.toolName, + action: request.action, description: resolved.description, display: resolved.blocks, - choices: adaptChoices(event.toolName, event.display), + choices: adaptChoices(request.toolName, request.display), }; } diff --git a/apps/kimi-code/src/tui/interactions/approval-controller.ts b/apps/kimi-code/src/tui/interactions/approval-controller.ts new file mode 100644 index 0000000000..48a27d7d54 --- /dev/null +++ b/apps/kimi-code/src/tui/interactions/approval-controller.ts @@ -0,0 +1,123 @@ +/** + * Pending-model approval controller. + * + * Consumes the session's approval broker (`session.approvals`): every pending + * approval is projected into the panel view and presented through the modal + * `PanelQueue` (one panel at a time, arrival order, approve-for-session + * auto-resolution for queued same-action pendings). The user's choice is + * written back with `approvals.decide`; pendings resolved outside the panel + * (another client, policy) are retracted from the queue via `onDidResolve`. + * Detach retracts the UI without deciding — pendings stay parked in the + * engine and are re-presented on the next attach. + */ + +import type { ApprovalResponse, CoreSession, PendingApproval } from '#/core/index'; + +import { adaptApprovalRequest } from './approval-adapter'; +import { PanelQueue, type PanelQueueUIHooks } from './panel-queue'; +import type { ApprovalPanelData } from './types'; + +class ApprovalPanelQueue extends PanelQueue { + protected override autoResolveFor( + resolvedPayload: ApprovalPanelData, + response: ApprovalResponse, + queuedPayload: ApprovalPanelData, + ): ApprovalResponse | undefined { + if (response.decision !== 'approved') return undefined; + if (response.scope !== 'session') return undefined; + if (resolvedPayload.action !== queuedPayload.action) return undefined; + // Inherit the session-scoped approval. Drop `feedback` and + // `selectedLabel` — those described the user's interaction with the + // first request only and would be misleading on auto-resolved ones. + return { decision: 'approved', scope: 'session' }; + } +} + +export interface ApprovalControllerEvents { + /** Fired after a decision made through the panel flow is written back. */ + readonly onDecided?: (pending: PendingApproval, response: ApprovalResponse) => void; +} + +export class ApprovalController { + private readonly queue = new ApprovalPanelQueue(); + /** Pending ids currently owned by a `present()` loop (dedupes re-scans). */ + private readonly inFlight = new Set(); + private teardowns: Array<() => void> = []; + + setUIHooks(hooks: PanelQueueUIHooks): void { + this.queue.setUIHooks(hooks); + } + + /** Called by the UI after the user makes a panel choice. */ + respond(response: ApprovalResponse): void { + this.queue.respond(response); + } + + hasPending(): boolean { + return this.queue.hasPending(); + } + + /** Subscribe to the session's approval broker; returns the teardown. */ + attach(session: CoreSession, events: ApprovalControllerEvents = {}): () => void { + this.teardowns = [ + session.approvals.onDidChangePending(() => { + this.scan(session, events); + }), + session.approvals.onDidResolve((id) => { + this.inFlight.delete(id); + // Resolved outside the panel flow: fold the panel/queue entry away. + this.queue.retract((payload) => payload.id === id); + }), + ]; + // Pendings parked before the TUI attached (resume, reload) present now. + this.scan(session, events); + return () => { + this.detach(); + }; + } + + /** Drop broker subscriptions and retract panels without deciding. */ + detach(): void { + const teardowns = this.teardowns; + this.teardowns = []; + for (const teardown of teardowns) teardown(); + this.inFlight.clear(); + this.queue.retractAll(); + } + + private scan(session: CoreSession, events: ApprovalControllerEvents): void { + for (const pending of session.approvals.list()) { + if (this.inFlight.has(pending.id)) continue; + this.inFlight.add(pending.id); + void this.present(session, pending, events); + } + } + + private async present( + session: CoreSession, + pending: PendingApproval, + events: ApprovalControllerEvents, + ): Promise { + try { + const response = await this.queue.show(adaptApprovalRequest(pending.request, pending.id)); + // Retracted: resolved externally or the session detached — never decide. + if (response === undefined) return; + session.approvals.decide(pending.id, response); + events.onDecided?.(pending, response); + } catch (error) { + // The panel path must settle the pending; otherwise the turn hangs. + const feedback = `Approval UI failed: ${ + error instanceof Error ? error.message : String(error) + }`; + try { + const response: ApprovalResponse = { decision: 'cancelled', feedback }; + session.approvals.decide(pending.id, response); + events.onDecided?.(pending, response); + } catch { + /* best-effort: the pending may have been resolved concurrently */ + } + } finally { + this.inFlight.delete(pending.id); + } + } +} diff --git a/apps/kimi-code/src/tui/interactions/index.ts b/apps/kimi-code/src/tui/interactions/index.ts new file mode 100644 index 0000000000..c8fe891081 --- /dev/null +++ b/apps/kimi-code/src/tui/interactions/index.ts @@ -0,0 +1,42 @@ +import type { ApprovalController } from './approval-controller'; +import { InteractionModalCoordinator, type InteractionModalUIHooks } from './modal-coordinator'; +import type { QuestionController } from './question-controller'; + +export type { InteractionModalUIHooks }; + +/** + * Wire the approval/question controllers' panel queues into the shared modal + * coordinator so only one interaction dialog occupies the editor area at a + * time. Returns disposers that clear the coordinator. + */ +export function registerInteractionPanels( + approvalController: ApprovalController, + questionController: QuestionController, + uiHooks: InteractionModalUIHooks, +): Array<() => void> { + const modalCoordinator = new InteractionModalCoordinator(uiHooks); + + approvalController.setUIHooks({ + showPanel: (payload) => { + modalCoordinator.showApproval(payload); + }, + hidePanel: () => { + modalCoordinator.hide('approval'); + }, + }); + + questionController.setUIHooks({ + showPanel: (payload) => { + modalCoordinator.showQuestion(payload); + }, + hidePanel: () => { + modalCoordinator.hide('question'); + }, + }); + + return [ + () => { + modalCoordinator.clear(); + }, + ]; +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/modal-coordinator.ts b/apps/kimi-code/src/tui/interactions/modal-coordinator.ts similarity index 79% rename from apps/kimi-code/src/tui/reverse-rpc/modal-coordinator.ts rename to apps/kimi-code/src/tui/interactions/modal-coordinator.ts index 623ec761a8..cd0ecb7d08 100644 --- a/apps/kimi-code/src/tui/reverse-rpc/modal-coordinator.ts +++ b/apps/kimi-code/src/tui/interactions/modal-coordinator.ts @@ -1,25 +1,25 @@ import type { ApprovalPanelData, QuestionPanelData } from './types'; -export type ReverseRpcModalOwner = 'approval' | 'question'; +export type InteractionModalOwner = 'approval' | 'question'; -export interface ReverseRpcModalUIHooks { +export interface InteractionModalUIHooks { readonly showApprovalPanel: (payload: ApprovalPanelData) => void; readonly hideApprovalPanel: () => void; readonly showQuestionDialog: (payload: QuestionPanelData) => void; readonly hideQuestionDialog: () => void; } -interface ReverseRpcModalEntry { - readonly owner: ReverseRpcModalOwner; +interface InteractionModalEntry { + readonly owner: InteractionModalOwner; readonly show: () => void; readonly hide: () => void; } -export class ReverseRpcModalCoordinator { - private active: ReverseRpcModalEntry | null = null; - private readonly queued: ReverseRpcModalEntry[] = []; +export class InteractionModalCoordinator { + private active: InteractionModalEntry | null = null; + private readonly queued: InteractionModalEntry[] = []; - constructor(private readonly hooks: ReverseRpcModalUIHooks) {} + constructor(private readonly hooks: InteractionModalUIHooks) {} showApproval(payload: ApprovalPanelData): void { this.show({ @@ -45,7 +45,7 @@ export class ReverseRpcModalCoordinator { }); } - hide(owner: ReverseRpcModalOwner): void { + hide(owner: InteractionModalOwner): void { if (this.active?.owner === owner) { const active = this.active; this.active = null; @@ -65,7 +65,7 @@ export class ReverseRpcModalCoordinator { active?.hide(); } - private show(entry: ReverseRpcModalEntry): void { + private show(entry: InteractionModalEntry): void { const active = this.active; if (active === null) { this.active = entry; diff --git a/apps/kimi-code/src/tui/interactions/panel-queue.ts b/apps/kimi-code/src/tui/interactions/panel-queue.ts new file mode 100644 index 0000000000..9535cba2f9 --- /dev/null +++ b/apps/kimi-code/src/tui/interactions/panel-queue.ts @@ -0,0 +1,134 @@ +/** + * Promise-based modal panel queue for interaction dialogs. + * + * Approval and question flows wait for a UI action before writing the + * decision back to the session broker. When concurrent pendings arrive (e.g. + * multiple parallel subagents each needing approval), only one panel is shown + * at a time; additional payloads are queued in arrival order and advance after + * the current one resolves. + * + * A queued payload can settle two ways: + * - `respond(data)` — the user made a choice; `show()` resolves with it. + * - retraction (`retract` / `retractAll`) — the pending was resolved outside + * the panel (another client, session detach); `show()` resolves with + * `undefined` and the caller must NOT write a decision back. + */ + +export interface PanelQueueUIHooks { + showPanel(payload: TPayload): void; + hidePanel(): void; +} + +interface PendingEntry { + readonly payload: TPayload; + readonly resolve: (data: TResponse | undefined) => void; +} + +export class PanelQueue { + private uiHooks: PanelQueueUIHooks | null = null; + private current: PendingEntry | null = null; + private queue: Array> = []; + + setUIHooks(hooks: PanelQueueUIHooks): void { + this.uiHooks = hooks; + } + + /** + * Present a payload (immediately, or once earlier ones settle). The returned + * promise resolves with the user's response, or `undefined` when the entry + * was retracted before the user answered. + */ + show(payload: TPayload): Promise { + return new Promise((resolve) => { + const entry: PendingEntry = { payload, resolve }; + if (this.current === null) { + this.current = entry; + this.uiHooks?.showPanel(payload); + } else { + this.queue.push(entry); + } + }); + } + + /** Called by the UI after the user makes a panel choice. */ + respond(data: TResponse): void { + const pending = this.current; + this.current = null; + pending?.resolve(data); + if (pending !== null) { + this.drainAutoResolved(pending.payload, data); + } + this.advanceOrHide(); + } + + /** + * Drop entries whose payload matches, resolving their `show()` promise with + * `undefined`. Used when a pending is resolved outside the panel. + */ + retract(matches: (payload: TPayload) => boolean): void { + this.queue = this.queue.filter((entry) => { + if (!matches(entry.payload)) return true; + entry.resolve(undefined); + return false; + }); + if (this.current !== null && matches(this.current.payload)) { + const current = this.current; + this.current = null; + current.resolve(undefined); + this.advanceOrHide(); + } + } + + /** Retract everything (session detach / shutdown) and hide the panel. */ + retractAll(): void { + const all = [...(this.current === null ? [] : [this.current]), ...this.queue]; + const hadCurrent = this.current !== null; + this.current = null; + this.queue = []; + if (hadCurrent) this.uiHooks?.hidePanel(); + for (const entry of all) { + entry.resolve(undefined); + } + } + + hasPending(): boolean { + return this.current !== null || this.queue.length > 0; + } + + private advanceOrHide(): void { + const next = this.queue.shift(); + if (next === undefined) { + this.uiHooks?.hidePanel(); + return; + } + this.current = next; + this.uiHooks?.showPanel(next.payload); + } + + private drainAutoResolved(resolvedPayload: TPayload, response: TResponse): void { + const remaining: Array> = []; + for (const entry of this.queue) { + const auto = this.autoResolveFor(resolvedPayload, response, entry.payload); + if (auto === undefined) { + remaining.push(entry); + } else { + entry.resolve(auto); + } + } + this.queue = remaining; + } + + /** + * Subclasses override to short-circuit queued payloads when an answer to the + * just-resolved one (e.g. an approve-for-session) implies the same answer + * for matching queued payloads. Return `undefined` to leave the queued + * payload waiting for its own panel turn. + */ + protected autoResolveFor( + _resolvedPayload: TPayload, + _response: TResponse, + _queuedPayload: TPayload, + ): TResponse | undefined { + return undefined; + } +} diff --git a/apps/kimi-code/src/tui/interactions/question-adapter.ts b/apps/kimi-code/src/tui/interactions/question-adapter.ts new file mode 100644 index 0000000000..88f87e3674 --- /dev/null +++ b/apps/kimi-code/src/tui/interactions/question-adapter.ts @@ -0,0 +1,53 @@ +import type { CoreQuestionRequest, QuestionResult } from '#/core/index'; + +import type { QuestionPanelData, QuestionPanelResponse } from '#/tui/interactions/types'; + +/** + * Project a core question request into the dialog view payload. `id` is the + * pending-interaction id used to correlate the dialog with the session broker; + * it falls back to the request's own correlation fields. + */ +export function adaptQuestionRequest(request: CoreQuestionRequest, id?: string): QuestionPanelData { + const panelId = + id ?? + request.id ?? + request.toolCallId ?? + (request.turnId === undefined ? 'question' : `question-${String(request.turnId)}`); + return { + id: panelId, + tool_call_id: request.toolCallId ?? panelId, + questions: request.questions.map((question) => ({ + question: question.question, + header: question.header, + body: question.body, + multi_select: question.multiSelect ?? false, + other_label: question.otherLabel, + other_description: question.otherDescription, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + })), + }; +} + +/** + * Map the dialog answers back to the core result. Returns `null` when nothing + * was answered (Esc / empty submit), which the controller turns into a + * `dismiss`. + */ +export function adaptQuestionAnswers( + request: CoreQuestionRequest, + response: QuestionPanelResponse, +): QuestionResult { + const result: Record = {}; + for (let i = 0; i < request.questions.length; i++) { + const question = request.questions[i]; + const answer = response.answers[i]; + if (question === undefined || typeof answer !== 'string' || answer.length === 0) continue; + result[question.question] = answer; + } + return Object.keys(result).length > 0 + ? { answers: result, method: response.method } + : null; +} diff --git a/apps/kimi-code/src/tui/interactions/question-controller.ts b/apps/kimi-code/src/tui/interactions/question-controller.ts new file mode 100644 index 0000000000..df46c48d3e --- /dev/null +++ b/apps/kimi-code/src/tui/interactions/question-controller.ts @@ -0,0 +1,94 @@ +/** + * Pending-model question controller. + * + * Consumes the session's question broker (`session.questions`): pendings are + * projected into the dialog view and presented through the modal `PanelQueue`. + * A non-empty answer set is written back with `questions.answer`; an empty + * one (Esc / empty submit) maps to `questions.dismiss` — the core `null` + * result semantics. Externally resolved pendings are retracted via + * `onDidResolve`; detach retracts the UI without settling anything. + */ + +import type { CoreSession, PendingQuestion } from '#/core/index'; + +import { PanelQueue, type PanelQueueUIHooks } from './panel-queue'; +import { adaptQuestionAnswers, adaptQuestionRequest } from './question-adapter'; +import type { QuestionPanelData, QuestionPanelResponse } from './types'; + +class QuestionPanelQueue extends PanelQueue {} + +export class QuestionController { + private readonly queue = new QuestionPanelQueue(); + /** Pending ids currently owned by a `present()` loop (dedupes re-scans). */ + private readonly inFlight = new Set(); + private teardowns: Array<() => void> = []; + + setUIHooks(hooks: PanelQueueUIHooks): void { + this.queue.setUIHooks(hooks); + } + + /** Called by the UI after the user submits or dismisses the dialog. */ + respond(response: QuestionPanelResponse): void { + this.queue.respond(response); + } + + hasPending(): boolean { + return this.queue.hasPending(); + } + + /** Subscribe to the session's question broker; returns the teardown. */ + attach(session: CoreSession): () => void { + this.teardowns = [ + session.questions.onDidChangePending(() => { + this.scan(session); + }), + session.questions.onDidResolve((id) => { + this.inFlight.delete(id); + // Resolved outside the dialog flow: fold the dialog/queue entry away. + this.queue.retract((payload) => payload.id === id); + }), + ]; + // Pendings parked before the TUI attached (resume, reload) present now. + this.scan(session); + return () => { + this.detach(); + }; + } + + /** Drop broker subscriptions and retract dialogs without settling. */ + detach(): void { + const teardowns = this.teardowns; + this.teardowns = []; + for (const teardown of teardowns) teardown(); + this.inFlight.clear(); + this.queue.retractAll(); + } + + private scan(session: CoreSession): void { + for (const pending of session.questions.list()) { + if (this.inFlight.has(pending.id)) continue; + this.inFlight.add(pending.id); + void this.present(session, pending); + } + } + + private async present(session: CoreSession, pending: PendingQuestion): Promise { + try { + const response = await this.queue.show(adaptQuestionRequest(pending.request, pending.id)); + // Retracted: resolved externally or the session detached — never settle. + if (response === undefined) return; + const result = adaptQuestionAnswers(pending.request, response); + if (result === null) session.questions.dismiss(pending.id); + else session.questions.answer(pending.id, result); + } catch { + // The dialog path must settle the pending; otherwise the turn hangs. + try { + session.questions.dismiss(pending.id); + } catch { + /* best-effort: the pending may have been resolved concurrently */ + } + } finally { + this.inFlight.delete(pending.id); + } + } +} diff --git a/apps/kimi-code/src/tui/reverse-rpc/types.ts b/apps/kimi-code/src/tui/interactions/types.ts similarity index 93% rename from apps/kimi-code/src/tui/reverse-rpc/types.ts rename to apps/kimi-code/src/tui/interactions/types.ts index 2a41f0df20..2e914e0cd4 100644 --- a/apps/kimi-code/src/tui/reverse-rpc/types.ts +++ b/apps/kimi-code/src/tui/interactions/types.ts @@ -1,12 +1,12 @@ /** - * Reverse RPC view-layer types. + * Interaction view-layer types. * - * These types are the contract between the UI layer and reverse RPC - * controllers, not SDK event payloads. Approval and question adapters convert - * core payloads into these shapes for panel components. + * These types are the contract between the UI layer and the interaction + * controllers, not core payloads. Approval and question adapters convert + * core pending-interaction payloads into these shapes for panel components. */ -import type { QuestionAnswerMethod } from '@moonshot-ai/kimi-code-sdk'; +import type { QuestionAnswerMethod } from '#/core/index'; // ── Display blocks (approval panel) ────────────────────────────────── diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 62d1b2370b..7af3a40cd6 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2,16 +2,6 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; -import type { - ApprovalRequest, - ApprovalResponse, - BackgroundTaskInfo, - CreateSessionOptions, - KimiHarness, - PermissionMode, - PromptPart, - Session, -} from '@moonshot-ai/kimi-code-sdk'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; import { deleteAllKittyImages, @@ -23,6 +13,17 @@ import { import { resolve } from 'pathe'; import type { CLIOptions } from '#/cli/options'; +import type { + AgentTaskInfo, + ApprovalResponse, + CoreApprovalRequest, + CoreHarness, + CoreSession, + CreateSessionOptions, + PermissionMode, + PromptPart, + TelemetryProperties, +} from '#/core/index'; import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history'; @@ -106,13 +107,11 @@ import { SessionReplayRenderer } from './controllers/session-replay'; import { StreamingUIController } from './controllers/streaming-ui'; import { TasksBrowserController } from './controllers/tasks-browser'; import { installRainbowDance } from './easter-eggs/dance'; -import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; -import { ApprovalController } from './reverse-rpc/approval/controller'; -import { createApprovalRequestHandler } from './reverse-rpc/approval/handler'; -import { registerReverseRPCHandlers } from './reverse-rpc/index'; -import { QuestionController } from './reverse-rpc/question/controller'; -import { createQuestionAskHandler } from './reverse-rpc/question/handler'; -import type { ApprovalPanelData, QuestionPanelData } from './reverse-rpc/types'; +import { adaptPanelResponse } from './interactions/approval-adapter'; +import { ApprovalController } from './interactions/approval-controller'; +import { registerInteractionPanels } from './interactions/index'; +import { QuestionController } from './interactions/question-controller'; +import type { ApprovalPanelData, QuestionPanelData } from './interactions/types'; import { currentTheme, getColorPalette, getBuiltInPalette, isBuiltInTheme } from './theme'; import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; import { createTUIState, type TUIState } from './tui-state'; @@ -236,19 +235,30 @@ interface SendMessageOptions { readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; readonly hasMedia?: boolean; + /** Target agent for the prompt; defaults to the main agent. */ + readonly agentId?: string; } /** How long the one-shot "moved to background" footer hint stays visible. */ const DETACH_HINT_DISPLAY_MS = 4_000; export class KimiTUI { - readonly harness: KimiHarness; + readonly harness: CoreHarness; readonly options: KimiTUIOptions; - session: Session | undefined; + session: CoreSession | undefined; state: TUIState; private readonly approvalController = new ApprovalController(); private readonly questionController = new QuestionController(); - private readonly reverseRpcDisposers: Array<() => void> = []; + private readonly interactionPanelDisposers: Array<() => void> = []; + /** Live subscriptions onto the active session's approval/question brokers. */ + private interactionTeardowns: Array<() => void> = []; + /** + * Agent the next interactive prompt targets ('main' unless a /btw panel is + * active). Owned by the TUI now that the v2 facade has no + * `withInteractiveAgent` ambient state; the btw controller updates it + * through `setInteractiveAgentId` on panel activation/exit. + */ + private interactiveAgentId: string = MAIN_AGENT_ID; private skillCommands: readonly KimiSlashCommand[] = []; readonly skillCommandMap = new Map(); private pluginCommands: readonly KimiSlashCommand[] = []; @@ -260,6 +270,7 @@ export class KimiTUI { cancelInFlight: (() => void) | undefined; deferUserMessages = false; aborted = false; + private sessionRuntimeGeneration = 0; private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; private clipboardImageHintController: ClipboardImageHintController | undefined; @@ -311,11 +322,11 @@ export class KimiTUI { /** URL opened in the browser just before exit (e.g. by `/web`); printed by onExit. */ public exitOpenUrl: string | undefined; - track(event: string, properties?: Parameters[1]): void { + track(event: string, properties?: TelemetryProperties): void { this.harness.track(event, properties); } - constructor(harness: KimiHarness, startupInput: KimiTUIStartupInput) { + constructor(harness: CoreHarness, startupInput: KimiTUIStartupInput) { this.harness = harness; const tuiOptions: KimiTUIOptions = { initialAppState: createInitialAppState(startupInput), @@ -338,8 +349,8 @@ export class KimiTUI { this.state.ui.requestRender(); }); - this.reverseRpcDisposers.push( - ...registerReverseRPCHandlers(this.approvalController, this.questionController, { + this.interactionPanelDisposers.push( + ...registerInteractionPanels(this.approvalController, this.questionController, { showApprovalPanel: (payload) => { this.showApprovalPanel(payload); }, @@ -383,10 +394,9 @@ export class KimiTUI { name: cmd.name, aliases: cmd.aliases, description: cmd.description, - ...(cmd.argumentHint !== undefined ? { argumentHint: cmd.argumentHint } : {}), - ...(completer !== undefined - ? { getArgumentCompletions: (prefix: string) => completer(prefix) } - : {}), + argumentHint: cmd.argumentHint, + getArgumentCompletions: + completer !== undefined ? (prefix: string) => completer(prefix) : undefined, }; }); const provider = new FileMentionProvider( @@ -436,7 +446,7 @@ export class KimiTUI { this.setupAutocomplete(); } - async refreshPluginCommands(session?: Session): Promise { + async refreshPluginCommands(session?: CoreSession): Promise { if (session === undefined) { this.pluginCommands = []; this.pluginCommandMap.clear(); @@ -446,7 +456,9 @@ export class KimiTUI { let defs; try { - defs = await session.listPluginCommands(); + // v2 moves the plugin-command list to the App scope (harness); the + // session argument still gates "no session → clear the commands". + defs = await this.harness.listPluginCommands(); } catch { return; } @@ -651,7 +663,7 @@ export class KimiTUI { void this.refreshPluginCommands(this.session); } - private async showSessionWarnings(session: Session): Promise { + private async showSessionWarnings(session: CoreSession): Promise { try { const warnings = await session.getSessionWarnings(); if (this.session !== session) return; @@ -677,7 +689,7 @@ export class KimiTUI { const { startup } = this.options; const { workDir } = this.state.appState; - let session: Session | undefined; + let session: CoreSession | undefined; let shouldReplayHistory = false; const isResumeStartup = startup.sessionFlag !== undefined || startup.continueLast; const createSessionOptions: MutableCreateSessionOptions = { @@ -742,7 +754,17 @@ export class KimiTUI { } } } else { - session = await this.harness.createSession(createSessionOptions); + // Session-less startup: the first user message creates the session + // lazily (sendNormalUserInput). Until then, the initial render reads + // config-level defaults from the harness instead of a session status. + const startupState = await this.harness.getStartupState(); + this.setAppState({ + model: startup.model ?? startupState.model, + thinkingEffort: startupState.thinkingEffort, + permissionMode: startupState.permissionMode, + planMode: startupState.planMode, + maxContextTokens: startupState.maxContextTokens, + }); } if (session !== undefined && shouldReplayHistory) { await this.applyStartupModesToResumedSession(session); @@ -756,11 +778,10 @@ export class KimiTUI { return false; } - if (session === undefined) { - throw new Error('Startup session was not initialized.'); + if (session !== undefined) { + await this.setSession(session); + await this.syncRuntimeState(session); } - await this.setSession(session); - await this.syncRuntimeState(session); this.applyStartupPermissionAndPlanToAppState(); this.state.startupState = 'ready'; return shouldReplayHistory; @@ -783,10 +804,7 @@ export class KimiTUI { this.disposeTranscriptChildren(); this.editorKeyboard.dispose(); this.state.footer.dispose(); - for (const dispose of this.reverseRpcDisposers) { - dispose(); - } - this.reverseRpcDisposers.length = 0; + this.clearInteractionPanels(); this.disposeTerminalTracking(); // Restore the terminal even if closing the session / harness throws — a // SIGTERM during a network or MCP shutdown must not leave the user stuck in @@ -1079,9 +1097,70 @@ export class KimiTUI { if (!this.validateMediaCapabilities(extraction)) return; const session = this.session; if (session === undefined) { - this.showError(LLM_NOT_SET_MESSAGE); + // Session-less startup: create the session lazily, then deliver this + // input as its first turn. + void this.sendAfterLazySessionStart(text, extraction); return; } + if (extraction.hasMedia) { + this.sendMessage(session, text, { + hasMedia: true, + // `extractMediaAttachments` now emits protocol `image` parts (base64 + // `source`) directly, so the parts flow straight into the v2 prompt + // surface without a shape cast. + parts: extraction.parts, + imageAttachmentIds: extraction.imageAttachmentIds, + }); + } else { + this.sendMessage(session, text); + } + this.updateQueueDisplay(); + this.state.ui.requestRender(); + } + + /** + * First user message after a session-less startup: create the session from + * the current appState (model / permission / plan / additionalDirs), wire + * the runtime like `createNewSession` does, then deliver the pending input + * as the session's first turn. + */ + private async sendAfterLazySessionStart( + text: string, + extraction: ReturnType, + ): Promise { + let session: CoreSession; + try { + session = await this.createSessionFromCurrentState(); + } catch (error) { + if (isOAuthLoginRequiredError(error)) { + this.authFlow.enterLoginRequiredStartupState(); + return; + } + this.showError(`Failed to start a session: ${formatErrorMessage(error)}`); + return; + } + + this.resetSessionRuntime(); + await this.setSession(session); + this.setAppState({ sessionId: session.id }); + try { + await this.activateRuntime(); + await this.syncRuntimeState(session); + } catch (error) { + // The session is live; keep it so the user can retry the message. + this.sessionEventHandler.startSubscription(); + this.showError(`Post-create setup failed: ${formatErrorMessage(error)}`); + return; + } + try { + await this.refreshSkillCommands(this.session); + await this.refreshPluginCommands(this.session); + } catch { + /* keep the new session usable even if dynamic skills fail */ + } + this.sessionEventHandler.startSubscription(); + void this.showSessionWarnings(session); + if (extraction.hasMedia) { this.sendMessage(session, text, { hasMedia: true, @@ -1168,7 +1247,7 @@ export class KimiTUI { ): void { this.state.queuedMessages.push({ text, - agentId: this.harness.interactiveAgentId, + agentId: options?.agentId ?? this.interactiveAgentId, parts: options?.parts, imageAttachmentIds: options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 @@ -1202,24 +1281,80 @@ export class KimiTUI { this.showError(message); } - sendQueuedMessage(session: Session, item: QueuedMessage): void { + sendQueuedMessage(session: CoreSession, item: QueuedMessage): void { if (item.mode === 'bash') { this.runShellCommandFromInput(item.text); return; } - this.harness.withInteractiveAgent(item.agentId ?? MAIN_AGENT_ID, () => { - this.sendMessageInternal(session, item.text, { - parts: item.parts, - imageAttachmentIds: item.imageAttachmentIds, - }); + this.sendMessageInternal(session, item.text, { + parts: item.parts, + imageAttachmentIds: item.imageAttachmentIds, + agentId: item.agentId ?? MAIN_AGENT_ID, }); } + async sendQueuedGoalMessage( + session: CoreSession, + item: QueuedMessage, + confirmation: Component, + ): Promise { + const generation = this.sessionRuntimeGeneration; + const imageAttachmentIds = + item.imageAttachmentIds !== undefined && item.imageAttachmentIds.length > 0 + ? item.imageAttachmentIds + : undefined; + const userEntry: TranscriptEntry = { + id: nextTranscriptId(), + kind: 'user', + turnId: undefined, + renderMode: 'plain', + content: item.text, + imageAttachmentIds, + }; + this.state.transcriptContainer.addChild(confirmation); + this.appendTranscriptEntry(userEntry); + const rollbackTranscript = (): void => { + this.state.transcriptEntries = this.state.transcriptEntries.filter( + (entry) => entry !== userEntry, + ); + const childrenToRemove = this.state.transcriptContainer.children.filter( + (child) => child === confirmation || getTranscriptComponentEntry(child) === userEntry, + ); + for (const child of childrenToRemove) { + // pi-tui Container.removeChild (not a DOM node); `child.remove()` does not exist. + // oxlint-disable-next-line unicorn/prefer-dom-node-remove + this.state.transcriptContainer.removeChild(child); + if (hasDispose(child)) child.dispose(); + } + this.state.ui.requestRender(); + }; + this.beginSessionRequest(); + const parts: readonly PromptPart[] = item.parts ?? [{ type: 'text', text: item.text }]; + try { + await session.prompt(parts, { agentId: item.agentId ?? MAIN_AGENT_ID }); + } catch (error) { + rollbackTranscript(); + if (this.session === session && this.sessionRuntimeGeneration === generation) { + this.setAppState({ streamingPhase: 'idle' }); + this.resetLivePane(); + } + throw error; + } + if (this.session !== session || this.sessionRuntimeGeneration !== generation) { + rollbackTranscript(); + throw new Error('Queued goal send completed after the session runtime changed.'); + } + } + requestQueuedGoalPromotion(): void { this.sessionEventHandler.requestQueuedGoalPromotion(); } - private sendMessageInternal(session: Session, input: string, options?: SendMessageOptions): void { + private sendMessageInternal( + session: CoreSession, + input: string, + options?: SendMessageOptions, + ): void { const imageAttachmentIds = options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 ? options.imageAttachmentIds @@ -1235,35 +1370,35 @@ export class KimiTUI { this.beginSessionRequest(); - const sdkInput = options?.parts ?? input; - void session.prompt(sdkInput).catch((error: unknown) => { + const parts: readonly PromptPart[] = options?.parts ?? [{ type: 'text', text: input }]; + void session.prompt(parts, { agentId: options?.agentId }).catch((error: unknown) => { const message = formatErrorMessage(error); this.failSessionRequest(`Failed to send: ${message}`); }); } - sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { + sendSkillActivation(session: CoreSession, skillName: string, skillArgs: string): void { this.beginSessionRequest(); - void session.activateSkill(skillName, skillArgs).catch((error: unknown) => { + void session.activateSkill({ name: skillName, args: skillArgs }).catch((error: unknown) => { const message = formatErrorMessage(error); this.failSessionRequest(`Skill "${skillName}" failed: ${message}`); }); } activatePluginCommand( - session: Session, + session: CoreSession, pluginId: string, commandName: string, args: string, ): void { this.beginSessionRequest(); - void session.activatePluginCommand(pluginId, commandName, args).catch((error: unknown) => { + void session.activatePluginCommand({ pluginId, commandName, args }).catch((error: unknown) => { const message = formatErrorMessage(error); this.failSessionRequest(`Command "${pluginId}:${commandName}" failed: ${message}`); }); } - private sendMessage(session: Session, input: string, options?: SendMessageOptions): void { + private sendMessage(session: CoreSession, input: string, options?: SendMessageOptions): void { if ( this.deferUserMessages || this.state.appState.streamingPhase !== 'idle' || @@ -1275,7 +1410,7 @@ export class KimiTUI { this.sendMessageInternal(session, input, options); } - steerMessage(session: Session, input: string[]): void { + steerMessage(session: CoreSession, input: string[]): void { if (this.deferUserMessages || this.state.appState.isCompacting) { for (const part of input) { this.enqueueMessage(part); @@ -1299,7 +1434,7 @@ export class KimiTUI { }); } - void session.steer(input.join('\n\n')).catch((error: unknown) => { + void session.steer([{ type: 'text', text: input.join('\n\n') }]).catch((error: unknown) => { const message = formatErrorMessage(error); this.showError(`Failed to steer: ${message}`); }); @@ -1313,6 +1448,15 @@ export class KimiTUI { this.state.startupState = 'ready'; } + /** + * Route subsequent interactive prompts (queued sends included) to the given + * agent. The /btw panel controller points this at the btw side-question + * agent while its panel is active and restores 'main' on exit. + */ + setInteractiveAgentId(agentId: string): void { + this.interactiveAgentId = agentId; + } + clearQueuedMessages(): void { this.state.queuedMessages = []; } @@ -1340,7 +1484,7 @@ export class KimiTUI { this.startupNotice = combineStartupNotice(this.startupNotice, extra); } - get backgroundTasks(): ReadonlyMap { + get backgroundTasks(): ReadonlyMap { return this.sessionEventHandler.backgroundTasks; } @@ -1361,7 +1505,7 @@ export class KimiTUI { if (session === undefined) return 0; try { const metrics = await session.getMcpStartupMetrics(); - return metrics.durationMs; + return metrics.durationMs ?? 0; } catch { return 0; } @@ -1398,7 +1542,7 @@ export class KimiTUI { this.state.ui.requestRender(); } - private syncAdditionalDirs(session: Session): void { + private syncAdditionalDirs(session: CoreSession): void { const additionalDirs = session.summary?.additionalDirs ?? []; if (sameStringArrays(this.state.appState.additionalDirs, additionalDirs)) return; this.setAppState({ additionalDirs: [...additionalDirs] }); @@ -1408,22 +1552,26 @@ export class KimiTUI { // Session Runtime // ========================================================================= - requireSession(): Session { + requireSession(): CoreSession { if (this.session === undefined) { throw new Error(NO_ACTIVE_SESSION_MESSAGE); } return this.session; } - private async createSessionFromCurrentState(): Promise { + private async createSessionFromCurrentState(): Promise { const model = this.state.appState.model.trim(); if (model.length === 0) { throw new Error(LLM_NOT_SET_MESSAGE); } + // `thinkingEffort` mirrors the config default from the first render + // (getStartupState resolves it exactly like the profile service), so it is + // safe to pass on the first creation too — and it preserves session-only + // effort changes made before the session existed. const options: MutableCreateSessionOptions = { workDir: this.state.appState.workDir, model, - thinking: this.session === undefined ? undefined : this.state.appState.thinkingEffort, + thinking: this.state.appState.thinkingEffort, permission: this.state.appState.permissionMode, planMode: this.state.appState.planMode ? true : undefined, }; @@ -1433,16 +1581,16 @@ export class KimiTUI { return this.harness.createSession(options); } - async setSession(session: Session): Promise { + async setSession(session: CoreSession): Promise { const previous = this.unloadCurrentSession('switching session'); await previous?.close(); this.session = session; this.harness.setTelemetryContext({ sessionId: session.id }); - this.registerSessionHandlers(session); + this.attachInteractionBridges(session); this.syncAdditionalDirs(session); } - async syncRuntimeState(session: Session = this.requireSession()): Promise { + async syncRuntimeState(session: CoreSession = this.requireSession()): Promise { const [status, goalResult] = await Promise.all([session.getStatus(), session.getGoal()]); this.setAppState({ sessionId: session.id, @@ -1464,7 +1612,7 @@ export class KimiTUI { // session may already be in plan mode from its persisted records, and // re-entering plan mode throws, so only enable it when it is not active yet. // setPermission is idempotent and needs no such guard. - private async applyStartupModesToResumedSession(session: Session): Promise { + private async applyStartupModesToResumedSession(session: CoreSession): Promise { const { startup } = this.options; if (startup.auto) { await session.setPermission('auto'); @@ -1506,15 +1654,16 @@ export class KimiTUI { await previous?.close(); } - private unloadCurrentSession(reason: string): Session | undefined { + private unloadCurrentSession(reason: string): CoreSession | undefined { + // `reason` documents the unload trigger at call sites; the pending-model + // controllers retract their panels on detach without settling, so no + // cancel feedback is written back to the (possibly closing) session. + void reason; const previous = this.session; this.sessionEventUnsubscribe?.(); this.sessionEventUnsubscribe = undefined; - this.clearReverseRpcPanels(); - previous?.setApprovalHandler(undefined); - previous?.setQuestionHandler(undefined); - this.approvalController.cancelAll(reason); - this.questionController.cancelAll(reason); + this.clearInteractionPanels(); + this.detachInteractionBridges(); this.session = undefined; this.state.swarmModeEntry = undefined; this.harness.setTelemetryContext({ sessionId: null }); @@ -1522,20 +1671,40 @@ export class KimiTUI { return previous; } - private clearReverseRpcPanels(): void { - for (const dispose of this.reverseRpcDisposers) { + private clearInteractionPanels(): void { + for (const dispose of this.interactionPanelDisposers) { dispose(); } - this.reverseRpcDisposers.length = 0; - } - - private registerSessionHandlers(session: Session): void { - session.setApprovalHandler( - createApprovalRequestHandler(this.approvalController, (request, response) => { - this.appendApprovalTranscriptEntry(request, response); + this.interactionPanelDisposers.length = 0; + } + + /** + * Subscribe the TUI to the session's pending approval/question brokers. + * The controllers scan parked pendings immediately, present them through + * the modal panel queue, and write decisions back via + * `approvals.decide` / `questions.answer|dismiss`; approval decisions made + * through the panel are rendered with `appendApprovalTranscriptEntry`. + * Every subscription pushes its teardown into `interactionTeardowns`. + */ + private attachInteractionBridges(session: CoreSession): void { + this.interactionTeardowns.push( + this.approvalController.attach(session, { + onDecided: (pending, response) => { + this.appendApprovalTranscriptEntry(pending.request, response); + this.syncPermissionModeFromGoalApproval(pending.request, response); + }, }), + this.questionController.attach(session), ); - session.setQuestionHandler(createQuestionAskHandler(this.questionController)); + } + + /** Drop every live broker subscription installed by `attachInteractionBridges`. */ + private detachInteractionBridges(): void { + const teardowns = this.interactionTeardowns; + this.interactionTeardowns = []; + for (const teardown of teardowns) { + teardown(); + } } async fetchSessions(scope: 'cwd' | 'all' = this.state.sessionsScope): Promise { @@ -1565,9 +1734,13 @@ export class KimiTUI { } resetSessionRuntime(): void { + this.sessionRuntimeGeneration += 1; this.aborted = false; + this.teardownEditorReplacement(); this.streamingUI.discardPending(); this.state.queuedMessages = []; + this.state.queuedMessageDispatchGeneration += 1; + this.state.queuedMessageDispatchPending = false; this.state.swarmModeEntry = undefined; this.streamingUI.resetToolCallState(); this.streamingUI.resetToolUi(); @@ -1609,7 +1782,7 @@ export class KimiTUI { return false; } - let session: Session; + let session: CoreSession; try { session = await this.harness.resumeSession({ id: targetSessionId }); } catch (error) { @@ -1622,7 +1795,7 @@ export class KimiTUI { return true; } - async switchToSession(session: Session, statusMessage: string): Promise { + async switchToSession(session: CoreSession, statusMessage: string): Promise { this.resetSessionRuntime(); await this.setSession(session); await this.syncRuntimeState(session); @@ -1650,19 +1823,16 @@ export class KimiTUI { void this.showSessionWarnings(session); } - async reloadCurrentSessionView(session: Session, statusMessage: string): Promise { + async reloadCurrentSessionView(session: CoreSession, statusMessage: string): Promise { this.sessionEventUnsubscribe?.(); this.sessionEventUnsubscribe = undefined; - this.clearReverseRpcPanels(); - session.setApprovalHandler(undefined); - session.setQuestionHandler(undefined); - this.approvalController.cancelAll('reloading session'); - this.questionController.cancelAll('reloading session'); + this.clearInteractionPanels(); + this.detachInteractionBridges(); this.resetSessionRuntime(); this.session = session; this.harness.setTelemetryContext({ sessionId: session.id }); - this.registerSessionHandlers(session); + this.attachInteractionBridges(session); await this.syncRuntimeState(session); this.updateTerminalTitle(); try { @@ -1686,7 +1856,7 @@ export class KimiTUI { return; } - let session: Session; + let session: CoreSession; try { session = await this.createSessionFromCurrentState(); } catch (error) { @@ -1720,12 +1890,20 @@ export class KimiTUI { void this.showConfigWarningsIfAny(); } - /** Surface config.toml load warnings (degraded or kept-previous config) in the status bar. */ + /** Surface config.toml load warnings/errors (degraded or kept-previous config) in the status bar. */ private async showConfigWarningsIfAny(): Promise { try { - const { warnings } = await this.harness.getConfigDiagnostics(); - for (const warning of warnings) { - this.showStatus(warning, 'warning'); + const diagnostics = await this.harness.getConfigDiagnostics(); + for (const diagnostic of diagnostics) { + if (diagnostic.severity === 'error') { + // An `error` diagnostic means the config is broken (e.g. TOML parse + // failure); surface it explicitly so it is not swallowed the way a + // warning-only filter would. + this.showStatus(`Config error: ${diagnostic.message}`, 'error'); + continue; + } + if (diagnostic.severity !== 'warning') continue; + this.showStatus(diagnostic.message, 'warning'); } } catch { /* diagnostics are best-effort */ @@ -1837,8 +2015,14 @@ export class KimiTUI { } } + /** + * Render a resolved approval as a transcript notice. Fed by the approval + * controller's `onDecided` callback for decisions made through the panel + * flow (external resolutions carry no response payload and are not + * transcribed). + */ private appendApprovalTranscriptEntry( - request: ApprovalRequest, + request: CoreApprovalRequest, response: ApprovalResponse, ): void { if ( @@ -1872,6 +2056,26 @@ export class KimiTUI { }); } + /** + * A goal-start approval can flip the session's permission mode: the engine's + * goal-start policy reads the chosen option's label and calls `setMode`. + * Because v2 dropped `permission` from `agent.status.updated`, that change + * no longer reaches the footer on its own. Reflect it directly from the + * decision — `selectedLabel` is exactly the mode the engine applies — so the + * footer stays in sync without a status round-trip (and races with it). + */ + private syncPermissionModeFromGoalApproval( + request: CoreApprovalRequest, + response: ApprovalResponse, + ): void { + if (request.display.kind !== 'goal_start') return; + if (response.decision !== 'approved') return; + const mode = response.selectedLabel; + if (mode !== 'auto' && mode !== 'yolo' && mode !== 'manual') return; + if (this.state.appState.permissionMode === mode) return; + this.setAppState({ permissionMode: mode }); + } + private renderWelcome(): void { if ( this.state.transcriptContainer.children.some((child) => child instanceof WelcomeComponent) @@ -2424,7 +2628,7 @@ export class KimiTUI { return; } - let tasks: readonly BackgroundTaskInfo[]; + let tasks: readonly AgentTaskInfo[]; try { // activeOnly defaults to true; foreground running tasks are non-terminal // and therefore included. We filter to `detached === false` ourselves. @@ -2595,16 +2799,26 @@ export class KimiTUI { // ========================================================================= mountEditorReplacement(panel: Component & Focusable): void { + for (const child of this.state.editorContainer.children) { + if (child !== this.state.editor && hasDispose(child)) child.dispose(); + } this.state.editorContainer.clear(); this.state.editorContainer.addChild(panel); this.state.ui.setFocus(panel); this.state.ui.requestRender(); } - restoreEditor(): void { + private teardownEditorReplacement(): void { + for (const child of this.state.editorContainer.children) { + if (child !== this.state.editor && hasDispose(child)) child.dispose(); + } this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); + } + + restoreEditor(): void { + this.teardownEditorReplacement(); // Measure overflow against the restored tree (editor mounted), not the tall // panel just removed — otherwise a short session with a tall panel looks like // it overflows and we take a full clear/home that yanks the editor to the top. diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/controller.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/controller.ts deleted file mode 100644 index 5578a5189f..0000000000 --- a/apps/kimi-code/src/tui/reverse-rpc/approval/controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { ApprovalResponse } from '@moonshot-ai/kimi-code-sdk'; - -import { ReverseRpcController } from '#/tui/reverse-rpc/base-controller'; -import type { ApprovalPanelData } from '#/tui/reverse-rpc/types'; - -export class ApprovalController extends ReverseRpcController< - ApprovalPanelData, - ApprovalResponse -> { - protected createCancelResponse(reason: string): ApprovalResponse { - return { decision: 'cancelled', feedback: reason }; - } - - protected override autoResolveFor( - resolvedPayload: ApprovalPanelData, - response: ApprovalResponse, - queuedPayload: ApprovalPanelData, - ): ApprovalResponse | undefined { - if (response.decision !== 'approved') return undefined; - if (response.scope !== 'session') return undefined; - if (resolvedPayload.action !== queuedPayload.action) return undefined; - // Inherit the session-scoped approval. Drop `feedback` and - // `selectedLabel` — those described the user's interaction with the - // first request only and would be misleading on auto-resolved ones. - return { decision: 'approved', scope: 'session' }; - } -} diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/handler.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/handler.ts deleted file mode 100644 index 8640dd5d97..0000000000 --- a/apps/kimi-code/src/tui/reverse-rpc/approval/handler.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { ApprovalHandler, ApprovalRequest, ApprovalResponse } from '@moonshot-ai/kimi-code-sdk'; - -import { adaptApprovalRequest } from './adapter'; -import type { ApprovalController } from './controller'; - -export function createApprovalRequestHandler( - controller: ApprovalController, - onResponse?: (request: ApprovalRequest, response: ApprovalResponse) => void, -): ApprovalHandler { - return async (event): Promise => { - try { - const response = await controller.show(adaptApprovalRequest(event)); - onResponse?.(event, response); - return response; - } catch { - const response: ApprovalResponse = { - decision: 'cancelled', - feedback: 'approval handler failed', - }; - onResponse?.(event, response); - return response; - } - }; -} diff --git a/apps/kimi-code/src/tui/reverse-rpc/base-controller.ts b/apps/kimi-code/src/tui/reverse-rpc/base-controller.ts deleted file mode 100644 index a32dcb047b..0000000000 --- a/apps/kimi-code/src/tui/reverse-rpc/base-controller.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Base class for promise-based reverse RPC dialog controllers. - * - * Approval and question flows wait for a UI action before returning a response. - * Subclasses only need to define the default cancellation response. - * - * When concurrent requests arrive (e.g. multiple parallel subagents each - * needing approval), only one panel is shown at a time; additional requests - * are queued in arrival order and advance after the current one resolves. - */ - -export interface ReverseRpcUIHooks { - showPanel(payload: TPayload): void; - hidePanel(): void; -} - -interface Pending { - readonly payload: TPayload; - readonly resolve: (data: TResponse) => void; -} - -export abstract class ReverseRpcController { - private uiHooks: ReverseRpcUIHooks | null = null; - private current: Pending | null = null; - private queue: Array> = []; - - setUIHooks(hooks: ReverseRpcUIHooks): void { - this.uiHooks = hooks; - } - - /** - * Called when a reverse RPC request arrives from core. The returned promise - * resolves after the user responds or `cancelAll` forces cancellation. - */ - show(payload: TPayload): Promise { - return new Promise((resolve) => { - const entry: Pending = { payload, resolve }; - if (this.current === null) { - this.current = entry; - this.uiHooks?.showPanel(payload); - } else { - this.queue.push(entry); - } - }); - } - - /** Called by the UI after the user makes a panel choice. */ - respond(data: TResponse): void { - const pending = this.current; - this.current = null; - pending?.resolve(data); - if (pending !== null) { - this.drainAutoResolved(pending.payload, data); - } - this.advanceOrHide(); - } - - /** Cancels all pending requests during shutdown or session switches. */ - cancelAll(reason: string): void { - const all = [...(this.current === null ? [] : [this.current]), ...this.queue]; - this.current = null; - this.queue = []; - this.uiHooks?.hidePanel(); - for (const entry of all) { - entry.resolve(this.createCancelResponse(reason)); - } - } - - hasPending(): boolean { - return this.current !== null || this.queue.length > 0; - } - - private advanceOrHide(): void { - const next = this.queue.shift(); - if (next === undefined) { - this.uiHooks?.hidePanel(); - return; - } - this.current = next; - this.uiHooks?.showPanel(next.payload); - } - - private drainAutoResolved(resolvedPayload: TPayload, response: TResponse): void { - const remaining: Array> = []; - for (const entry of this.queue) { - const auto = this.autoResolveFor(resolvedPayload, response, entry.payload); - if (auto === undefined) { - remaining.push(entry); - } else { - entry.resolve(auto); - } - } - this.queue = remaining; - } - - /** - * Subclasses override to short-circuit queued requests when an answer to the - * just-resolved one (e.g. an approve-for-session) implies the same answer - * for matching queued requests. Return `undefined` to leave the queued - * request waiting for its own panel turn. - */ - protected autoResolveFor( - _resolvedPayload: TPayload, - _response: TResponse, - _queuedPayload: TPayload, - ): TResponse | undefined { - return undefined; - } - - protected abstract createCancelResponse(reason: string): TResponse; -} diff --git a/apps/kimi-code/src/tui/reverse-rpc/index.ts b/apps/kimi-code/src/tui/reverse-rpc/index.ts deleted file mode 100644 index 3a00ae8899..0000000000 --- a/apps/kimi-code/src/tui/reverse-rpc/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { ApprovalController } from './approval/controller'; -import type { QuestionController } from './question/controller'; -import { ReverseRpcModalCoordinator } from './modal-coordinator'; -import type { ApprovalPanelData, QuestionPanelData } from './types'; - -export interface ReverseRPCUIHooks { - readonly showApprovalPanel: (payload: ApprovalPanelData) => void; - readonly hideApprovalPanel: () => void; - readonly showQuestionDialog: (payload: QuestionPanelData) => void; - readonly hideQuestionDialog: () => void; -} - -export function registerReverseRPCHandlers( - approvalController: ApprovalController, - questionController: QuestionController, - uiHooks: ReverseRPCUIHooks, -): Array<() => void> { - const modalCoordinator = new ReverseRpcModalCoordinator(uiHooks); - - // Setup UI hooks for controllers - approvalController.setUIHooks({ - showPanel: (payload) => { - modalCoordinator.showApproval(payload); - }, - hidePanel: () => { - modalCoordinator.hide('approval'); - }, - }); - - questionController.setUIHooks({ - showPanel: (payload) => { - modalCoordinator.showQuestion(payload); - }, - hidePanel: () => { - modalCoordinator.hide('question'); - }, - }); - - return [ - () => { - modalCoordinator.clear(); - }, - ]; -} diff --git a/apps/kimi-code/src/tui/reverse-rpc/question/controller.ts b/apps/kimi-code/src/tui/reverse-rpc/question/controller.ts deleted file mode 100644 index a87c24daf7..0000000000 --- a/apps/kimi-code/src/tui/reverse-rpc/question/controller.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ReverseRpcController } from '#/tui/reverse-rpc/base-controller'; -import type { QuestionPanelData, QuestionPanelResponse } from '#/tui/reverse-rpc/types'; - -export class QuestionController extends ReverseRpcController< - QuestionPanelData, - QuestionPanelResponse -> { - protected createCancelResponse(_reason: string): QuestionPanelResponse { - return { answers: [] }; - } -} diff --git a/apps/kimi-code/src/tui/reverse-rpc/question/handler.ts b/apps/kimi-code/src/tui/reverse-rpc/question/handler.ts deleted file mode 100644 index 3bf564b027..0000000000 --- a/apps/kimi-code/src/tui/reverse-rpc/question/handler.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { QuestionHandler, QuestionRequest, QuestionResult } from '@moonshot-ai/kimi-code-sdk'; - -import type { - QuestionPanelData, - QuestionPanelResponse, -} from '#/tui/reverse-rpc/types'; - -import type { QuestionController } from './controller'; - -export function createQuestionAskHandler(controller: QuestionController): QuestionHandler { - return async (event): Promise => { - try { - const answers = await controller.show(adaptQuestionRequest(event)); - return adaptQuestionAnswers(event, answers); - } catch { - return null; - } - }; -} - -export function adaptQuestionRequest(event: QuestionRequest): QuestionPanelData { - const id = - event.toolCallId ?? - (event.turnId === undefined ? 'question' : `question-${String(event.turnId)}`); - return { - id, - tool_call_id: id, - questions: event.questions.map((question) => ({ - question: question.question, - header: question.header, - body: question.body, - multi_select: question.multiSelect ?? false, - other_label: question.otherLabel, - other_description: question.otherDescription, - options: question.options.map((option) => ({ - label: option.label, - description: option.description, - })), - })), - }; -} - -export function adaptQuestionAnswers( - event: QuestionRequest, - response: QuestionPanelResponse, -): QuestionResult { - const result: Record = {}; - for (let i = 0; i < event.questions.length; i++) { - const question = event.questions[i]; - const answer = response.answers[i]; - if (question === undefined || typeof answer !== 'string' || answer.length === 0) continue; - result[question.question] = answer; - } - return Object.keys(result).length > 0 - ? { answers: result, method: response.method } - : null; -} diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index d9665c9e3d..480e98cb16 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -59,6 +59,8 @@ export interface TUIState { * this flag to avoid starting a goal ahead of the user's earlier message. */ queuedMessageDispatchPending: boolean; + /** Invalidates deferred queued-message callbacks from older runtimes. */ + queuedMessageDispatchGeneration: number; swarmModeEntry: 'manual' | 'task' | undefined; } @@ -111,6 +113,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { externalEditorRunning: false, queuedMessages: [], queuedMessageDispatchPending: false, + queuedMessageDispatchGeneration: 0, swarmModeEntry: undefined, }; } diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 6dcdccdd1a..fd34aada54 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -7,10 +7,10 @@ import type { PromptPart, ThinkingEffort, ToolInputDisplay, -} from '@moonshot-ai/kimi-code-sdk'; +} from '#/core/index'; import type { NotificationsConfig, UpgradePreferences } from './config'; -import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; +import type { PendingApproval, PendingQuestion } from './interactions/types'; import type { ColorToken, ThemeName } from './theme'; export type BannerDisplay = 'always' | 'once' | 'cooldown'; diff --git a/apps/kimi-code/src/tui/utils/background-task-status.ts b/apps/kimi-code/src/tui/utils/background-task-status.ts index 10f579ad9f..21bb7f975b 100644 --- a/apps/kimi-code/src/tui/utils/background-task-status.ts +++ b/apps/kimi-code/src/tui/utils/background-task-status.ts @@ -9,7 +9,7 @@ * — into the dim detail line so the user still sees it. */ -import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; +import type { BackgroundTaskInfo, BackgroundTaskStatus } from '#/core/index'; import type { BackgroundAgentStatusData, BackgroundAgentStatusPhase } from '@/tui/types'; diff --git a/apps/kimi-code/src/tui/utils/core-config-view.ts b/apps/kimi-code/src/tui/utils/core-config-view.ts new file mode 100644 index 0000000000..6cc302447a --- /dev/null +++ b/apps/kimi-code/src/tui/utils/core-config-view.ts @@ -0,0 +1,71 @@ +/** + * Typed structural views over the v2 resolved config (`CoreConfig`). + * + * The v2 config surface (`IConfigService.getAll()`) is domain-keyed and untyped + * (`Record`). The TUI reads a handful of domains (`models`, + * `providers`, `defaultModel`, `thinking`) whose shapes are validated at load + * time by the section schemas the engine registers (e.g. `ThinkingConfigSchema` + * in `agent-core-v2/src/agent/profile/configSection.ts`). These helpers narrow + * those domains to the v1 structural shapes the TUI (and the oauth refresh + * adapter) still consume, so call sites stay free of `as unknown as` casts and + * index-signature access. + */ + +import type { CoreConfig, ModelAlias, ProviderConfig } from '#/core/index'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** `models` domain: alias-name → `ModelAlias`. */ +export function modelsView(config: CoreConfig): Record { + const value = config['models']; + return isRecord(value) ? (value as Record) : {}; +} + +/** `providers` domain: provider-name → `ProviderConfig`. */ +export function providersView(config: CoreConfig): Record { + const value = config['providers']; + return isRecord(value) ? (value as Record) : {}; +} + +/** `defaultModel` domain: the selected alias name, when configured. */ +export function defaultModelView(config: CoreConfig): string | undefined { + const value = config['defaultModel']; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * `thinking` domain view, projected onto the v1 `{ enabled?, effort? }` shape + * that {@link import('./thinking-config').thinkingEffortFromConfig} consumes. + * + * The v2 section schema keys the toggle as `mode: 'auto' | 'on' | 'off'` + * (`ThinkingConfigSchema`), while v1 used `enabled: boolean`. + * `thinkingEffortFromConfig` treats `enabled === false` as the "off" signal, + * so this view maps `mode === 'off'` → `enabled: false` (and any other + * present mode → `enabled: true`) to preserve that helper's semantics. The + * concrete `effort` string is passed through unchanged. + */ +export function thinkingView( + config: CoreConfig, +): { enabled?: boolean; effort?: string } | undefined { + const value = config['thinking']; + if (!isRecord(value)) return undefined; + const mode = value['mode']; + const effort = value['effort']; + // Prefer the v2 `mode` toggle; fall back to a v1-style `enabled` boolean so + // configs that have not been rewritten yet (and older test fixtures) still + // honor the "off" signal. + const enabled = + mode === 'off' + ? false + : mode === 'on' || mode === 'auto' + ? true + : typeof value['enabled'] === 'boolean' + ? (value['enabled'] as boolean) + : undefined; + return { + enabled, + effort: typeof effort === 'string' && effort.length > 0 ? effort : undefined, + }; +} diff --git a/apps/kimi-code/src/tui/utils/event-payload.ts b/apps/kimi-code/src/tui/utils/event-payload.ts index 088409ffdd..0c47c68e7f 100644 --- a/apps/kimi-code/src/tui/utils/event-payload.ts +++ b/apps/kimi-code/src/tui/utils/event-payload.ts @@ -1,4 +1,4 @@ -import { isKimiError } from '@moonshot-ai/kimi-code-sdk'; +import { isCoreError } from '#/core/index'; import { STREAMING_ARGS_FIELD_RE, @@ -90,7 +90,7 @@ export function isTodoItemShape( } export function formatErrorMessage(error: unknown): string { - if (isKimiError(error)) { + if (isCoreError(error)) { return formatErrorPayload({ code: error.code, message: error.message, diff --git a/apps/kimi-code/src/tui/utils/export-markdown.ts b/apps/kimi-code/src/tui/utils/export-markdown.ts index 9531efa104..d4d8a7d1e9 100644 --- a/apps/kimi-code/src/tui/utils/export-markdown.ts +++ b/apps/kimi-code/src/tui/utils/export-markdown.ts @@ -1,4 +1,4 @@ -import type { ContentPart, ContextMessage, PromptOrigin, ToolCall } from '@moonshot-ai/kimi-code-sdk'; +import type { ContentPart, ContextMessage, PromptOrigin, ToolCall } from '#/core/index'; const HINT_KEYS = ['path', 'file_path', 'command', 'query', 'url', 'name', 'pattern'] as const; diff --git a/apps/kimi-code/src/tui/utils/foreground-task.ts b/apps/kimi-code/src/tui/utils/foreground-task.ts index ba2ac811b2..219667f770 100644 --- a/apps/kimi-code/src/tui/utils/foreground-task.ts +++ b/apps/kimi-code/src/tui/utils/foreground-task.ts @@ -1,4 +1,4 @@ -import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; +import type { BackgroundTaskInfo } from '#/core/index'; function isDetachableForegroundTask(t: BackgroundTaskInfo): boolean { return ( diff --git a/apps/kimi-code/src/tui/utils/goal-completion.ts b/apps/kimi-code/src/tui/utils/goal-completion.ts index f4c802b4a8..7217eb97da 100644 --- a/apps/kimi-code/src/tui/utils/goal-completion.ts +++ b/apps/kimi-code/src/tui/utils/goal-completion.ts @@ -1,4 +1,4 @@ -import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; +import type { GoalSnapshot } from '#/core/index'; interface GoalCompletionStats { readonly terminalReason?: string | undefined; diff --git a/apps/kimi-code/src/tui/utils/hook-result-format.ts b/apps/kimi-code/src/tui/utils/hook-result-format.ts index 758e3e9127..47ff22b614 100644 --- a/apps/kimi-code/src/tui/utils/hook-result-format.ts +++ b/apps/kimi-code/src/tui/utils/hook-result-format.ts @@ -1,4 +1,4 @@ -import type { HookResultEvent } from '@moonshot-ai/kimi-code-sdk'; +import type { HookResultEvent } from '#/core/index'; export function formatHookResultMarkdown(event: HookResultEvent): string { return `*${formatHookResultTitle(event)}*\n\n${formatHookResultBody(event)}`; diff --git a/apps/kimi-code/src/tui/utils/image-placeholder.ts b/apps/kimi-code/src/tui/utils/image-placeholder.ts index 4017c4b2d1..81e5032b8b 100644 --- a/apps/kimi-code/src/tui/utils/image-placeholder.ts +++ b/apps/kimi-code/src/tui/utils/image-placeholder.ts @@ -7,11 +7,12 @@ * A literal `[image #999 ...]` the user typed themselves stays in * the text (we can't hallucinate files for it). * - Order is preserved for text/image/video segments. Image placeholders - * expand to image content parts so the prompt reaches the provider - * without relying on a model tool call. Video placeholders are copied - * into the shared cache (`getCacheDir()`) and expand to file-path tags, - * so `ReadMediaFile` — and the provider's `VideoUploader` — own video - * upload behavior instead of base64-inlining here. + * expand to protocol `image` content parts (base64 `source`) so the + * prompt reaches the provider without relying on a model tool call. + * Video placeholders are copied into the shared cache (`getCacheDir()`) + * and expand to file-path tags, so `ReadMediaFile` — and the provider's + * `VideoUploader` — own video upload behavior instead of base64-inlining + * here. * - Adjacent text segments are flattened — empty / whitespace-only * segments drop out so we never emit `{type:'text', text:' '}` * noise between two media parts. @@ -21,8 +22,7 @@ import { randomUUID } from 'node:crypto'; import { copyFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; -import type { PromptPart } from '@moonshot-ai/kimi-code-sdk'; -import { buildImageCompressionCaption } from '@moonshot-ai/kimi-code-sdk'; +import { buildImageCompressionCaption, type PromptPart } from '#/core/index'; import { getCacheDir } from '#/utils/paths'; @@ -116,10 +116,14 @@ function pushText(parts: PromptPart[], segment: string): void { } function imagePartForAttachment(att: ImageAttachment): PromptPart { - const base64 = Buffer.from(att.bytes).toString('base64'); + // The v2 prompt surface consumes protocol `image` parts: a base64 `source` + // carrying the raw MIME + data, not the v1 kosong `image_url` data-URL + // shape. Split the MIME out of the attachment directly — `att.mime` is + // already the sniffed content type, so there is no data-URL to parse. + const data = Buffer.from(att.bytes).toString('base64'); return { - type: 'image_url', - imageUrl: { url: `data:${att.mime};base64,${base64}` }, + type: 'image', + source: { kind: 'base64', media_type: att.mime, data }, }; } diff --git a/apps/kimi-code/src/tui/utils/mcp-oauth.ts b/apps/kimi-code/src/tui/utils/mcp-oauth.ts index e0c17b043c..3f2a30a20d 100644 --- a/apps/kimi-code/src/tui/utils/mcp-oauth.ts +++ b/apps/kimi-code/src/tui/utils/mcp-oauth.ts @@ -3,7 +3,7 @@ import { type McpOAuthAuthorizationUrlUpdateData, type ToolProgressEvent, type ToolUpdate, -} from '@moonshot-ai/kimi-code-sdk'; +} from '#/core/index'; export type OpenUrl = (url: string) => void; diff --git a/apps/kimi-code/src/tui/utils/mcp-server-status.ts b/apps/kimi-code/src/tui/utils/mcp-server-status.ts index 53e4694885..66d5dee7e7 100644 --- a/apps/kimi-code/src/tui/utils/mcp-server-status.ts +++ b/apps/kimi-code/src/tui/utils/mcp-server-status.ts @@ -1,4 +1,4 @@ -import type { McpServerInfo, McpServerStatusEvent } from '@moonshot-ai/kimi-code-sdk'; +import type { McpServerInfo, McpServerStatusEvent } from '#/core/index'; export type McpServerStatusSnapshot = McpServerInfo | McpServerStatusEvent['server']; diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index 99441472b9..f0e76e0d32 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -1,12 +1,12 @@ import type { AgentReplayRecord, - BackgroundTaskInfo, + AgentTaskInfo, ContentPart, ContextMessage, PromptOrigin, ResumedAgentState, ToolCall, -} from '@moonshot-ai/kimi-code-sdk'; +} from '#/core/index'; import type { AppState, @@ -70,7 +70,7 @@ export function appStateFromResumeAgent(agent: ResumedAgentState): Partial): { +export function countActiveBackgroundTasks(tasks: ReadonlyMap): { bashTasks: number; agentTasks: number; } { @@ -98,7 +98,7 @@ export function countActiveBackgroundTasks(tasks: ReadonlyMap(); for (const info of background) { @@ -207,8 +207,8 @@ export function contentPartsToText(content: readonly ContentPart[]): string { export function backgroundOrigin( message: ContextMessage, -): Extract | undefined { - return message.origin?.kind === 'background_task' ? message.origin : undefined; +): Extract | undefined { + return message.origin?.kind === 'task' ? message.origin : undefined; } export function skillActivationFromOrigin( @@ -276,10 +276,8 @@ function isReplayUserTurnRecord(record: AgentReplayRecord): boolean { return message.origin.trigger === 'user-slash'; case 'plugin_command': return message.origin.trigger === 'user-slash'; + case 'task': case 'shell_command': - // A `!` command's input is a user-turn anchor; its output is not. - return message.origin.phase === 'input'; - case 'background_task': case 'compaction_summary': case 'cron_job': case 'cron_missed': diff --git a/apps/kimi-code/src/tui/utils/plugin-source-label.ts b/apps/kimi-code/src/tui/utils/plugin-source-label.ts index d475313ae8..7a489bbb5b 100644 --- a/apps/kimi-code/src/tui/utils/plugin-source-label.ts +++ b/apps/kimi-code/src/tui/utils/plugin-source-label.ts @@ -1,4 +1,4 @@ -import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { PluginSummary } from '#/core/index'; export const OFFICIAL_BADGE = 'official'; export const CURATED_BADGE = 'curated'; diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index 926b458e1e..63c430c204 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -5,17 +5,20 @@ import { type RefreshProviderScope, type RefreshResult, } from '@moonshot-ai/kimi-code-oauth'; -import type { KimiConfig, KimiConfigPatch, OAuthRef } from '@moonshot-ai/kimi-code-sdk'; +import type { ManagedKimiConfigShape } from '@moonshot-ai/kimi-code-oauth'; + +import type { CoreConfig, CoreConfigPatch, OAuthRef } from '#/core/index'; /** - * CLI-side host for provider-model refresh. Kept on the SDK's full config types - * so existing TUI callers (and tests) don't change; the daemon uses the oauth - * package's `ManagedKimiConfigShape`-typed host directly. + * CLI-side host for provider-model refresh. Typed against the v2 resolved + * config (`CoreConfig`) so TUI callers stay on the core facade; the adapter + * below bridges to the oauth orchestrator's `ManagedKimiConfigShape` at the + * single package boundary. */ export interface RefreshProviderHost { - getConfig(): Promise; - removeProvider(providerId: string): Promise; - setConfig(patch: KimiConfigPatch): Promise; + getConfig(): Promise; + removeProvider(providerId: string): Promise; + setConfig(patch: CoreConfigPatch): Promise; resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise; } @@ -25,6 +28,12 @@ export type { ProviderChange, RefreshProviderOptions, RefreshProviderScope, Refr * Refresh remote model metadata for the configured providers. Thin adapter over * the shared `refreshProviderModels` orchestrator in `@moonshot-ai/kimi-code-oauth` * (which is also what the daemon's scheduled/manual refresh uses). + * + * The v2 resolved config is the same TOML projection the oauth orchestrator + * reads at runtime, but it is untyped (`CoreConfig` is `Record`) while the orchestrator requires a present `providers` key. The + * `as unknown as ManagedKimiConfigShape` casts bridge that type gap at this + * boundary only; no TUI caller needs to know about the oauth shape. */ export async function refreshAllProviderModels( host: RefreshProviderHost, @@ -32,9 +41,11 @@ export async function refreshAllProviderModels( ): Promise { return refreshProviderModels( { - getConfig: () => host.getConfig(), - removeProvider: (providerId) => host.removeProvider(providerId), - setConfig: (patch) => host.setConfig(patch as unknown as KimiConfigPatch), + getConfig: async () => (await host.getConfig()) as unknown as ManagedKimiConfigShape, + removeProvider: async (providerId) => + (await host.removeProvider(providerId)) as unknown as ManagedKimiConfigShape, + setConfig: async (patch) => + (await host.setConfig(patch as CoreConfigPatch)) as unknown as ManagedKimiConfigShape, resolveOAuthToken: (providerName, oauthRef) => host.resolveOAuthToken(providerName, oauthRef as unknown as OAuthRef), }, diff --git a/apps/kimi-code/src/tui/utils/session-picker-rows.ts b/apps/kimi-code/src/tui/utils/session-picker-rows.ts index 55e063246a..728b36264d 100644 --- a/apps/kimi-code/src/tui/utils/session-picker-rows.ts +++ b/apps/kimi-code/src/tui/utils/session-picker-rows.ts @@ -1,9 +1,9 @@ -import type { SessionSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { CoreSessionSummary } from '#/core/index'; import type { SessionRow } from '#/tui/components/dialogs/session-picker'; export function sessionRowsForPicker( - sessions: readonly SessionSummary[], + sessions: readonly CoreSessionSummary[], currentSessionId: string, currentSessionHasContent: boolean, ): SessionRow[] { diff --git a/apps/kimi-code/src/tui/utils/thinking-config.ts b/apps/kimi-code/src/tui/utils/thinking-config.ts index 2e8411ef9c..f0411d974b 100644 --- a/apps/kimi-code/src/tui/utils/thinking-config.ts +++ b/apps/kimi-code/src/tui/utils/thinking-config.ts @@ -1,4 +1,4 @@ -import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; +import type { ThinkingEffort } from '#/core/index'; /** Whether a thinking effort represents "thinking enabled" (anything but 'off'). */ export function isThinkingOn(effort: ThinkingEffort): boolean { diff --git a/apps/kimi-code/src/utils/experimental-features.ts b/apps/kimi-code/src/utils/experimental-features.ts index b14e083fb1..3d37771f80 100644 --- a/apps/kimi-code/src/utils/experimental-features.ts +++ b/apps/kimi-code/src/utils/experimental-features.ts @@ -1,7 +1,7 @@ import type { ExperimentalFeatureState, ExperimentalFlagMap, -} from '@moonshot-ai/kimi-code-sdk'; +} from '#/core/index'; export function experimentalFeatureMap( features: readonly Pick[], diff --git a/apps/kimi-code/src/utils/process/shell-env.ts b/apps/kimi-code/src/utils/process/shell-env.ts index 3e63ac5607..2d06a596bc 100644 --- a/apps/kimi-code/src/utils/process/shell-env.ts +++ b/apps/kimi-code/src/utils/process/shell-env.ts @@ -1,4 +1,4 @@ -import type { ShellEnvironment } from '@moonshot-ai/kimi-code-sdk'; +import type { ShellEnvironment } from '#/core/index'; function detectMultiplexer(): string | undefined { if (process.env['TMUX']) return 'tmux'; diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index eafc4a9e0e..5d2302d750 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -37,7 +37,10 @@ const mocks = vi.hoisted(() => { defaultModel: 'k2', telemetry: true, })), - harnessGetConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })), + harnessGetConfigDiagnostics: vi.fn( + async () => + [] as readonly { severity: 'warning' | 'error'; domain?: string; message: string }[], + ), harnessGetCachedAccessToken: vi.fn(), harnessClose: vi.fn(), detectPendingMigration: vi.fn<() => Promise>(async () => null), @@ -69,7 +72,14 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { return { ...actual, resolveKimiHome: mocks.resolveKimiHome, - createKimiHarness: (...args: unknown[]) => { + }; +}); + +vi.mock('../../src/core/index', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createCoreHarness: (...args: unknown[]) => { const options = args[0] as { readonly homeDir?: string } | undefined; const homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home'; if (mocks.harnessCreatesDeviceIdOnConstruction) { @@ -482,25 +492,32 @@ describe('runShell', () => { editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, }); - mocks.harnessGetConfigDiagnostics.mockResolvedValue({ - warnings: ['Ignored invalid config in config.toml: loop_control.'], - }); + mocks.harnessGetConfigDiagnostics.mockResolvedValue([ + { severity: 'warning', message: 'Ignored invalid config in config.toml: loop_control.' }, + ]); mocks.tuiStart.mockResolvedValue(undefined); - await runShell( - { - session: '', - continue: false, - yolo: false, - auto: false, - plan: false, - model: undefined, - outputFormat: undefined, - prompt: undefined, - skillsDirs: [], - }, - '1.2.3-test', - ); + try { + await runShell( + { + session: '', + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + } finally { + // `clearAllMocks` (beforeEach) resets call history but not implementations, + // so restore the default empty-diagnostics implementation to avoid leaking + // the warning into subsequent tests. + mocks.harnessGetConfigDiagnostics.mockResolvedValue([]); + } const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; expect(startupInput).toMatchObject({ @@ -508,6 +525,48 @@ describe('runShell', () => { }); }); + it('fails fast on error-severity config diagnostics', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.harnessGetConfigDiagnostics.mockResolvedValue([ + { severity: 'error', domain: 'providers', message: 'Unexpected end of TOML input.' }, + { severity: 'warning', message: 'Ignored invalid config in config.toml: loop_control.' }, + ]); + + const stderr = captureProcessWrite('stderr'); + const exitSpy = mockProcessExit(); + try { + await expect( + runShell( + { + session: '', + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ), + ).rejects.toMatchObject({ code: 1 }); + } finally { + mocks.harnessGetConfigDiagnostics.mockResolvedValue([]); + exitSpy.mockRestore(); + stderr.restore(); + } + + expect(stderr.text()).toContain('Config error [providers]: Unexpected end of TOML input.'); + expect(stderr.text()).not.toContain('loop_control'); + expect(mocks.harnessClose).toHaveBeenCalledOnce(); + expect(mocks.kimiTuiConstructor).not.toHaveBeenCalled(); + }); + it('closes the harness when TUI startup fails', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/apps/kimi-code/test/cli/telemetry.test.ts b/apps/kimi-code/test/cli/telemetry.test.ts index 8b06558629..97e0c49f7c 100644 --- a/apps/kimi-code/test/cli/telemetry.test.ts +++ b/apps/kimi-code/test/cli/telemetry.test.ts @@ -29,10 +29,14 @@ vi.mock('@moonshot-ai/kimi-telemetry', () => ({ withTelemetryContext: vi.fn(), })); -vi.mock('@moonshot-ai/kimi-code-oauth', () => ({ - createKimiDeviceId: mocks.createKimiDeviceId, - KIMI_CODE_PROVIDER_NAME: 'managed:kimi-code', -})); +vi.mock('@moonshot-ai/kimi-code-oauth', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createKimiDeviceId: mocks.createKimiDeviceId, + KIMI_CODE_PROVIDER_NAME: 'managed:kimi-code', + }; +}); vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { const actual = await importOriginal(); diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index 016ed7cb3a..a761985278 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -153,7 +153,7 @@ describe('runV2Print', () => { } return { id: 1, - result: Promise.resolve({ reason: 'completed' }), + result: Promise.resolve({ type: 'completed', steps: 1, truncated: false }), }; }), }, diff --git a/apps/kimi-code/test/core/errors.test.ts b/apps/kimi-code/test/core/errors.test.ts new file mode 100644 index 0000000000..ef6956ffae --- /dev/null +++ b/apps/kimi-code/test/core/errors.test.ts @@ -0,0 +1,25 @@ +import { KimiError } from '@moonshot-ai/agent-core-v2'; +import { describe, expect, it } from 'vitest'; +import { CoreError, CoreErrorCodes, isCoreError } from '../../src/core/errors'; + +describe('CoreError', () => { + it('carries code/details and passes the guard', () => { + const err = new CoreError(CoreErrorCodes.SESSION_NOT_FOUND, 'missing', { details: { id: 's1' } }); + expect(err.code).toBe(CoreErrorCodes.SESSION_NOT_FOUND); + expect(err.details).toEqual({ id: 's1' }); + expect(isCoreError(err)).toBe(true); + }); + it('rejects foreign errors', () => { + expect(isCoreError(new Error('x'))).toBe(false); + expect(isCoreError(undefined)).toBe(false); + }); + it('recognizes KimiError thrown by agent-core-v2 services', () => { + const err = new KimiError(CoreErrorCodes.SESSION_NOT_FOUND, 'session missing'); + expect(isCoreError(err)).toBe(true); + }); + it('rejects a plain Error that only carries a code', () => { + const err = Object.assign(new Error('boom'), { code: 'session.not_found' }); + expect(err.name).toBe('Error'); + expect(isCoreError(err)).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/core/events.test.ts b/apps/kimi-code/test/core/events.test.ts new file mode 100644 index 0000000000..cdcc8cf312 --- /dev/null +++ b/apps/kimi-code/test/core/events.test.ts @@ -0,0 +1,115 @@ +// Uses fake scope handles shaped like the minimal v2 interface subset; +// does not bootstrap the real engine. +import { describe, expect, it } from 'vitest'; +import { attachSessionEvents } from '../../src/core/events'; +import type { SessionEvent } from '../../src/core/types'; + +// -- Minimal fakes: shapes aligned to the smallest subset of the v2 interfaces -- +function makeFakeBus() { + const listeners = new Set<(e: unknown) => void>(); + return { + subscribe: (h: (e: unknown) => void) => { listeners.add(h); return { dispose: () => listeners.delete(h) }; }, + publish: (e: unknown) => { for (const l of [...listeners]) l(e); }, + }; +} +function makeFakeAgent(id: string) { const bus = makeFakeBus(); return { id, bus, accessor: { get: () => bus } }; } +function makeFakeSession(agents: ReturnType[]) { + const created = new Set<(h: unknown) => void>(); + const disposed = new Set<(id: string) => void>(); + const lifecycle = { + list: () => agents, + onDidCreate: (h: (a: unknown) => void) => { created.add(h); return { dispose: () => created.delete(h) }; }, + onDidDispose: (h: (id: string) => void) => { disposed.add(h); return { dispose: () => disposed.delete(h) }; }, + _create: (a: ReturnType) => { agents.push(a); for (const h of [...created]) h(a); }, + _dispose: (id: string) => { for (const h of [...disposed]) h(id); }, + }; + return { accessor: { get: () => lifecycle }, lifecycle }; +} +function makeFakeApp() { + const bus = makeFakeBus(); + return { accessor: { get: () => bus }, bus }; +} + +describe('attachSessionEvents', () => { + it('stamps agentId/sessionId and keeps v2 event kinds verbatim', () => { + const a = makeFakeAgent('main'); + const session = makeFakeSession([a]); + const app = makeFakeApp(); + const seen: SessionEvent[] = []; + attachSessionEvents({ session: session as never, sessionId: 's1', app: app as never, emit: (e) => seen.push(e) }); + a.bus.publish({ type: 'task.started', info: { taskId: 't1' } }); + expect(seen).toEqual([{ type: 'task.started', info: { taskId: 't1' }, agentId: 'main', sessionId: 's1' }]); + }); + + it('subscribes late-created agents and drops disposed ones', () => { + const main = makeFakeAgent('main'); + const session = makeFakeSession([main]); + const app = makeFakeApp(); + const seen: SessionEvent[] = []; + attachSessionEvents({ session: session as never, sessionId: 's1', app: app as never, emit: (e) => seen.push(e) }); + + const sub = makeFakeAgent('sub'); + session.lifecycle._create(sub); + sub.bus.publish({ type: 'task.started', info: { taskId: 't2' } }); + expect(seen).toEqual([{ type: 'task.started', info: { taskId: 't2' }, agentId: 'sub', sessionId: 's1' }]); + + session.lifecycle._dispose('sub'); + sub.bus.publish({ type: 'task.started', info: { taskId: 't3' } }); + expect(seen).toHaveLength(1); + }); + + it('serializes nested publishes through the flush queue', () => { + const a = makeFakeAgent('main'); + const session = makeFakeSession([a]); + const app = makeFakeApp(); + const seen: string[] = []; + attachSessionEvents({ + session: session as never, + sessionId: 's1', + app: app as never, + emit: (e) => { + const type = (e as { type: string }).type; + // Publish the nested event before recording the outer one: without the + // flush queue the nested emit would re-enter and land first. + if (type === 'outer') a.bus.publish({ type: 'nested' }); + seen.push(type); + }, + }); + a.bus.publish({ type: 'outer' }); + expect(seen).toEqual(['outer', 'nested']); + }); + + it('projects session.meta.updated from the app bus filtered by sessionId', () => { + const a = makeFakeAgent('main'); + const session = makeFakeSession([a]); + const app = makeFakeApp(); + const seen: SessionEvent[] = []; + attachSessionEvents({ session: session as never, sessionId: 's1', app: app as never, emit: (e) => seen.push(e) }); + + app.bus.publish({ type: 'session.meta.updated', payload: { sessionId: 's1', title: 'T' } }); + // `patch` is undefined here; toEqual treats undefined-valued keys as absent. + expect(seen).toEqual([{ type: 'session.meta.updated', title: 'T', agentId: 'main', sessionId: 's1' }]); + + app.bus.publish({ type: 'session.meta.updated', payload: { sessionId: 's2', title: 'X' } }); + expect(seen).toHaveLength(1); + }); + + it('teardown unsubscribes every source', () => { + const a = makeFakeAgent('main'); + const session = makeFakeSession([a]); + const app = makeFakeApp(); + const seen: SessionEvent[] = []; + const teardown = attachSessionEvents({ session: session as never, sessionId: 's1', app: app as never, emit: (e) => seen.push(e) }); + + a.bus.publish({ type: 'task.started', info: { taskId: 't1' } }); + expect(seen).toHaveLength(1); + + teardown(); + a.bus.publish({ type: 'task.started', info: { taskId: 't2' } }); + app.bus.publish({ type: 'session.meta.updated', payload: { sessionId: 's1', title: 'T' } }); + const late = makeFakeAgent('late'); + session.lifecycle._create(late); + late.bus.publish({ type: 'task.started', info: { taskId: 't3' } }); + expect(seen).toHaveLength(1); + }); +}); diff --git a/apps/kimi-code/test/core/harness.test.ts b/apps/kimi-code/test/core/harness.test.ts new file mode 100644 index 0000000000..334fe4beb3 --- /dev/null +++ b/apps/kimi-code/test/core/harness.test.ts @@ -0,0 +1,875 @@ +// Uses a fake App scope shaped like the minimal v2 interface subset; does not +// bootstrap the real engine (`createCoreHarness` is covered by the Task 5 +// smoke run instead). Services are dispatched by the real service identifier +// objects (same accessor pattern as session.test.ts), so the harness must ask +// for the exact tokens it documents. `ensureMainAgent` is exercised through +// its "already exists" branch: each fake session lifecycle's `getHandle('main')` +// returns that session's fake main handle. +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; +import { + IAgentBlobService, + IAgentContextMemoryService, + IAgentContextSizeService, + IAgentLifecycleService, + IAgentPermissionModeService, + IAgentPermissionRulesService, + IAgentPlanService, + IAgentProfileService, + IAgentSwarmService, + IAgentTaskService, + IAgentToolRegistryService, + IAgentUsageService, + IAgentWireRecordService, + IBootstrapService, + IConfigService, + IEventBus, + IEventService, + IFlagService, + IModelResolver, + IPluginService, + IProviderService, + ISessionActivity, + ISessionApprovalService, + ISessionContext, + ISessionCronService, + ISessionExportService, + ISessionIndex, + ISessionInteractionService, + ISessionLifecycleService, + ISessionMetadata, + ISessionQuestionService, + ISessionTodoService, + ISessionWorkspaceContext, + IWorkspaceRegistry, +} from '@moonshot-ai/agent-core-v2'; +import { CoreErrorCodes, isCoreError } from '../../src/core/errors'; +import { CoreHarness } from '../../src/core/harness'; +import type { SessionEvent, TelemetryProperties } from '../../src/core/types'; + +// -- Minimal fakes: token-dispatching accessor keyed by real identifiers -- + +function makeAccessor(entries: ReadonlyArray) { + const services = new Map(entries); + return { + get: (token: unknown) => { + if (!services.has(token)) throw new Error(`fake accessor: unexpected service ${String(token)}`); + return services.get(token); + }, + }; +} + +function makeFakeBus() { + const listeners = new Set<(e: unknown) => void>(); + return { + subscribe: (h: (e: unknown) => void) => { listeners.add(h); return { dispose: () => listeners.delete(h) }; }, + publish: (e: unknown) => { for (const l of [...listeners]) l(e); }, + count: () => listeners.size, + }; +} + +function makeFakeInteractionKernel() { + return { + listPending: () => [], + onDidChangePending: () => ({ dispose: () => {} }), + onDidResolve: () => ({ dispose: () => {} }), + }; +} + +interface TrackedEvent { + readonly event: string; + readonly properties: TelemetryProperties | undefined; + readonly context: Record | undefined; +} + +function makeFixture(options?: { + configPath?: string; + activityStatus?: 'idle' | 'running'; + setModelError?: Error; + planEnterError?: Error; + closeSessionError?: Error; + disposeError?: Error; + resolverError?: Error; +}) { + const calls: Record = {}; + const order: string[] = []; + const record = (name: string) => (...args: unknown[]) => { + (calls[name] ??= []).push(args); + order.push(name); + }; + const recordReturning = (name: string, value: T) => (...args: unknown[]) => { + (calls[name] ??= []).push(args); + order.push(name); + return value; + }; + + const telemetryEvents: TrackedEvent[] = []; + const contextPatches: Array> = []; + const telemetry = { + track: (event: string, properties?: TelemetryProperties) => { + telemetryEvents.push({ event, properties, context: undefined }); + }, + setContext: (patch: Record) => { + contextPatches.push(patch); + }, + withContext: (patch: Record) => ({ + track: (event: string, properties?: TelemetryProperties) => { + telemetryEvents.push({ event, properties, context: patch }); + }, + }), + }; + + const usage = { total: { inputTokens: 1, outputTokens: 2 } }; + const planData = { id: 'plan-1', content: '# plan' }; + + const makeAgentServices = (sid: string) => { + const bus = makeFakeBus(); + const setModel = (...args: unknown[]) => { + (calls[`${sid}.setModel`] ??= []).push(args); + order.push(`${sid}.setModel`); + if (options?.setModelError !== undefined) return Promise.reject(options.setModelError); + return Promise.resolve({ model: 'kimi-latest', providerName: 'kimi' }); + }; + return { + bus, + entries: [ + [IEventBus, bus], + [ + IAgentProfileService, + { + setModel, + setThinking: record(`${sid}.setThinking`), + data: () => ({ + cwd: '/work', + modelAlias: 'kimi-latest', + modelCapabilities: {}, + thinkingLevel: 'high', + systemPrompt: 'sp', + }), + isToolActive: () => true, + }, + ], + [IAgentPermissionModeService, { mode: 'auto', setMode: record(`${sid}.setMode`) }], + [ + IAgentPlanService, + { + enter: (...args: unknown[]) => { + (calls[`${sid}.plan.enter`] ??= []).push(args); + order.push(`${sid}.plan.enter`); + if (options?.planEnterError !== undefined) return Promise.reject(options.planEnterError); + return Promise.resolve(); + }, + cancel: record(`${sid}.plan.cancel`), + status: () => Promise.resolve(planData), + }, + ], + [IAgentContextMemoryService, { get: () => [] }], + [IAgentContextSizeService, { get: () => ({ size: 42 }) }], + [IAgentPermissionRulesService, { rules: [] }], + [IAgentSwarmService, { isActive: false }], + [IAgentUsageService, { status: () => usage }], + [IAgentToolRegistryService, { list: () => [] }], + [IAgentTaskService, { list: () => [] }], + [IAgentWireRecordService, { getRecords: () => [] }], + [IAgentBlobService, { loadParts: async (parts: readonly unknown[]) => parts }], + ] as ReadonlyArray, + }; + }; + + const makeSessionHandle = (sid: string, workDir: string) => { + const agent = makeAgentServices(sid); + const main = { id: 'main', kind: 'agent', accessor: makeAccessor(agent.entries) }; + const lifecycleAgents = { + list: () => [main], + getHandle: (id: string) => (id === 'main' ? main : undefined), + onDidCreate: () => ({ dispose: () => {} }), + onDidDispose: () => ({ dispose: () => {} }), + }; + const meta = { + id: sid, + version: 2, + title: 'Old Title', + lastPrompt: 'last words', + createdAt: 100, + updatedAt: 200, + archived: false, + cwd: workDir, + custom: { origin: 'test' }, + agents: { main: { homedir: `/homes/${sid}/main` } }, + }; + return { + id: sid, + kind: 'session', + accessor: makeAccessor([ + [IAgentLifecycleService, lifecycleAgents], + [ISessionCronService, { _serviceBrand: undefined }], + [ISessionInteractionService, makeFakeInteractionKernel()], + [ISessionApprovalService, { decide: () => {} }], + [ISessionQuestionService, { answer: () => {}, dismiss: () => {} }], + [ISessionContext, { sessionId: sid, workspaceId: 'ws-1', sessionDir: `/sessions/ws-1/${sid}`, cwd: workDir }], + [ + ISessionMetadata, + { + read: () => Promise.resolve(meta), + setTitle: record(`${sid}.setTitle`), + update: record(`${sid}.meta.update`), + }, + ], + [ + ISessionWorkspaceContext, + { workDir, additionalDirs: ['/extra'], addAdditionalDir: record(`${sid}.addAdditionalDir`) }, + ], + [ISessionActivity, { status: () => options?.activityStatus ?? 'idle' }], + [ISessionTodoService, { getTodos: () => [] }], + ]), + }; + }; + + // App scope: lifecycle owns fake session handles; `resume` only knows the + // ids this fixture created (mirrors the persisted index). + const persisted = new Set(); + const lifecycle = { + create: (opts: { sessionId: string; workDir: string }) => { + (calls['lifecycle.create'] ??= []).push([opts]); + order.push('lifecycle.create'); + persisted.add(opts.sessionId); + return Promise.resolve(makeSessionHandle(opts.sessionId, opts.workDir)); + }, + resume: (id: string) => { + (calls['lifecycle.resume'] ??= []).push([id]); + order.push('lifecycle.resume'); + if (!persisted.has(id)) return Promise.resolve(undefined); + return Promise.resolve(makeSessionHandle(id, '/work')); + }, + fork: (opts: { sourceSessionId: string; newSessionId?: string; title?: string }) => { + (calls['lifecycle.fork'] ??= []).push([opts]); + order.push('lifecycle.fork'); + const id = opts.newSessionId ?? 'fork-generated'; + persisted.add(id); + return Promise.resolve(makeSessionHandle(id, '/work')); + }, + close: (id: string) => { + (calls['lifecycle.close'] ??= []).push([id]); + order.push('lifecycle.close'); + if (options?.closeSessionError !== undefined) return Promise.reject(options.closeSessionError); + return Promise.resolve(); + }, + }; + + const configAll = { defaultModel: 'kimi-latest', providers: { kimi: {} } }; + const configValues: Record = { + defaultModel: 'kimi-latest', + defaultPermissionMode: 'auto', + defaultPlanMode: true, + thinking: { enabled: true, effort: 'high' }, + }; + const resolverModel = { + capabilities: { max_context_tokens: 200_000 }, + alwaysThinking: false, + supportEfforts: ['low', 'medium', 'high'], + defaultEffort: 'medium', + }; + const diagnostics = [{ severity: 'warning' as const, message: 'unknown key' }]; + const flags = [{ id: 'my-flag', enabled: true }]; + const pluginInfo = { id: 'known', name: 'Known Plugin', enabled: true }; + const pluginSummaries = [{ id: 'known', name: 'Known Plugin', enabled: true }]; + const pluginCommands = [{ pluginId: 'known', name: 'cmd' }]; + const reloadSummary = { loaded: 1, failed: 0 }; + const exportResult = { + zipPath: '/tmp/out.zip', + entries: ['manifest.json'], + sessionDir: '/sessions/ws-1/sess-1', + manifest: { sessionId: 'sess-1' }, + }; + const indexItems = [ + { + id: 'sess-a', + workspaceId: 'ws-1', + cwd: '/work', + title: 'A', + lastPrompt: 'p', + createdAt: 1, + updatedAt: 2, + archived: false, + custom: { k: 'v' }, + }, + { + id: 'sess-b', + workspaceId: 'ws-2', + createdAt: 3, + updatedAt: 4, + archived: true, + }, + ]; + + let disposeCalls = 0; + const appEventBus = makeFakeBus(); + const app = { + accessor: makeAccessor([ + [IEventService, appEventBus], + [IWorkspaceRegistry, { + createOrTouch: recordReturning( + 'registry.createOrTouch', + Promise.resolve({ id: 'ws-1', root: '/work', name: 'work', createdAt: 1, lastOpenedAt: 2 }), + ), + }], + [ISessionLifecycleService, lifecycle], + [ISessionIndex, { list: recordReturning('index.list', Promise.resolve({ items: indexItems })) }], + [IBootstrapService, { sessionDir: (ws: string, id: string) => `/sessions/${ws}/${id}` }], + [ + IConfigService, + { + ready: Promise.resolve(), + reload: recordReturning('config.reload', Promise.resolve()), + getAll: recordReturning('config.getAll', configAll), + get: (domain: string) => configValues[domain], + set: recordReturning('config.set', Promise.resolve()), + diagnostics: () => diagnostics, + }, + ], + [ + IModelResolver, + { + resolve: (...args: unknown[]) => { + (calls['resolver.resolve'] ??= []).push(args); + if (options?.resolverError !== undefined) throw options.resolverError; + return resolverModel; + }, + }, + ], + [IProviderService, { delete: recordReturning('provider.delete', Promise.resolve()) }], + [IFlagService, { explainAll: () => flags }], + [ISessionExportService, { export: recordReturning('export.export', Promise.resolve(exportResult)) }], + [ + IPluginService, + { + listPlugins: recordReturning('plugins.list', Promise.resolve(pluginSummaries)), + installPlugin: recordReturning('plugins.install', Promise.resolve(pluginSummaries[0])), + setPluginEnabled: recordReturning('plugins.setEnabled', Promise.resolve()), + setPluginMcpServerEnabled: recordReturning('plugins.setMcpServerEnabled', Promise.resolve()), + removePlugin: recordReturning('plugins.remove', Promise.resolve()), + reloadPlugins: recordReturning('plugins.reload', Promise.resolve(reloadSummary)), + getPluginInfo: (input: { id: string }) => { + (calls['plugins.getInfo'] ??= []).push([input]); + order.push('plugins.getInfo'); + return Promise.resolve(input.id === 'known' ? pluginInfo : undefined); + }, + listPluginCommands: recordReturning('plugins.listCommands', Promise.resolve(pluginCommands)), + }, + ], + ]), + dispose: () => { + disposeCalls += 1; + if (options?.disposeError !== undefined) throw options.disposeError; + }, + }; + + const harness = new CoreHarness({ + app: app as never, + homeDir: '/home/.kimi-code', + configPath: options?.configPath ?? '/home/.kimi-code/config.toml', + identity: { userAgentProduct: 'KimiCodeTest', version: '1.2.3' }, + uiMode: 'test-ui', + telemetry: telemetry as never, + auth: { marker: 'auth' } as never, + sessionStartedProperties: { base: 'prop' }, + }); + + return { + harness, + calls, + order, + telemetryEvents, + contextPatches, + configAll, + configValues, + diagnostics, + flags, + pluginInfo, + pluginSummaries, + pluginCommands, + reloadSummary, + exportResult, + indexItems, + getDisposeCalls: () => disposeCalls, + appEventListenerCount: () => appEventBus.count(), + }; +} + +const rejected = async (promise: Promise): Promise => + promise.then( + () => undefined, + (error: unknown) => error, + ); + +describe('CoreHarness createSession', () => { + it('registers the workspace before creating, then binds main and applies options in order', async () => { + const fx = makeFixture(); + const session = await fx.harness.createSession({ + id: 'sess-1', + workDir: '/work', + model: 'kimi-latest', + thinking: 'high', + permission: 'auto', + metadata: { source: 'test' }, + additionalDirs: ['/extra'], + }); + + expect(session.id).toBe('sess-1'); + expect(fx.calls['registry.createOrTouch']).toEqual([['/work']]); + expect(fx.calls['lifecycle.create']).toEqual([[{ sessionId: 'sess-1', workDir: '/work' }]]); + expect(fx.order.indexOf('registry.createOrTouch')).toBeLessThan(fx.order.indexOf('lifecycle.create')); + expect(fx.order.indexOf('lifecycle.create')).toBeLessThan(fx.order.indexOf('sess-1.setModel')); + expect(fx.calls['sess-1.setModel']).toEqual([['kimi-latest']]); + expect(fx.calls['sess-1.setThinking']).toEqual([['high']]); + expect(fx.calls['sess-1.setMode']).toEqual([['auto']]); + expect(fx.calls['sess-1.meta.update']).toEqual([[{ custom: { source: 'test' } }]]); + expect(fx.calls['sess-1.addAdditionalDir']).toEqual([['/extra']]); + // Live summary snapshot is projected from session metadata + context. + expect(session.summary).toEqual({ + id: 'sess-1', + title: 'Old Title', + lastPrompt: 'last words', + workDir: '/work', + sessionDir: '/sessions/ws-1/sess-1', + createdAt: 100, + updatedAt: 200, + archived: false, + metadata: { origin: 'test' }, + additionalDirs: ['/extra'], + }); + }); + + it('enters plan mode on the main agent when planMode is set', async () => { + const fx = makeFixture(); + await fx.harness.createSession({ id: 'sess-1', workDir: '/work', planMode: true }); + expect(fx.calls['sess-1.plan.enter']).toEqual([[]]); + }); + + it('tracks session_started (resumed:false, canonical fields win) and session_new', async () => { + const fx = makeFixture(); + await fx.harness.createSession({ + id: 'sess-1', + workDir: '/work', + sessionStartedProperties: { extra: 'scoped', client_name: 'hijack' }, + }); + + expect(fx.telemetryEvents).toEqual([ + { + event: 'session_started', + context: { sessionId: 'sess-1' }, + properties: { + base: 'prop', + extra: 'scoped', + client_id: null, + client_name: 'KimiCodeTest', + client_version: '1.2.3', + ui_mode: 'test-ui', + resumed: false, + }, + }, + { event: 'session_new', context: { sessionId: 'sess-1' }, properties: undefined }, + ]); + }); + + it('rolls the lifecycle back when a post-create step fails', async () => { + const boom = new Error('setModel exploded'); + const fx = makeFixture({ setModelError: boom }); + const error = await rejected( + fx.harness.createSession({ id: 'sess-1', workDir: '/work', model: 'kimi-latest' }), + ); + expect(error).toBe(boom); + expect(fx.calls['lifecycle.close']).toEqual([['sess-1']]); + expect(fx.telemetryEvents).toEqual([]); + }); + + it('closes the registered session when a post-registration step fails', async () => { + const boom = new Error('plan enter exploded'); + const fx = makeFixture({ planEnterError: boom }); + const error = await rejected( + fx.harness.createSession({ id: 'sess-1', workDir: '/work', planMode: true }), + ); + expect(error).toBe(boom); + // The registered CoreSession owned an App-scope IEventService subscription; + // the rollback must release it through CoreSession.close(), not just drop + // the registry entry. + expect(fx.appEventListenerCount()).toBe(0); + expect(fx.calls['lifecycle.close']).toEqual([['sess-1']]); + expect(fx.telemetryEvents).toEqual([]); + // The registry entry is gone: a subsequent resume takes the cold path + // instead of returning a cached live instance. + await fx.harness.resumeSession({ id: 'sess-1' }).catch(() => {}); + expect(fx.calls['lifecycle.resume']).toHaveLength(1); + }); +}); + +describe('CoreHarness resumeSession', () => { + it('returns the live instance on cache hit without re-tracking session_started', async () => { + const fx = makeFixture(); + const created = await fx.harness.createSession({ id: 'sess-1', workDir: '/work' }); + fx.telemetryEvents.length = 0; + + const resumed = await fx.harness.resumeSession({ id: 'sess-1' }); + expect(resumed).toBe(created); + expect(fx.calls['lifecycle.resume']).toBeUndefined(); + expect(fx.telemetryEvents).toEqual([]); + }); + + it('cold-resumes through the lifecycle and injects the rebuilt resume state', async () => { + const fx = makeFixture(); + await fx.harness.createSession({ id: 'sess-1', workDir: '/work' }); + await fx.harness.closeSession('sess-1'); + fx.telemetryEvents.length = 0; + + const session = await fx.harness.resumeSession({ id: 'sess-1', additionalDirs: ['/more'] }); + expect(fx.calls['lifecycle.resume']).toEqual([['sess-1']]); + expect(fx.calls['sess-1.addAdditionalDir']).toEqual([['/more']]); + const resumeState = session.getResumeState(); + expect(resumeState).toBeDefined(); + expect(Object.keys(resumeState!.agents)).toEqual(['main']); + expect(fx.telemetryEvents).toEqual([ + { + event: 'session_started', + context: { sessionId: 'sess-1' }, + properties: { + base: 'prop', + client_id: null, + client_name: 'KimiCodeTest', + client_version: '1.2.3', + ui_mode: 'test-ui', + resumed: true, + }, + }, + { event: 'session_resume', context: { sessionId: 'sess-1' }, properties: undefined }, + ]); + }); + + it('rejects an unknown id with SESSION_NOT_FOUND', async () => { + const fx = makeFixture(); + const error = await rejected(fx.harness.resumeSession({ id: 'ghost' })); + expect(isCoreError(error)).toBe(true); + expect((error as { code: string }).code).toBe(CoreErrorCodes.SESSION_NOT_FOUND); + }); + + it('normalizes the session id (empty and non-string rejected)', async () => { + const fx = makeFixture(); + const empty = await rejected(fx.harness.resumeSession({ id: ' ' })); + expect((empty as { code: string }).code).toBe(CoreErrorCodes.SESSION_ID_EMPTY); + const missing = await rejected(fx.harness.resumeSession({ id: 123 as never })); + expect((missing as { code: string }).code).toBe(CoreErrorCodes.SESSION_ID_REQUIRED); + }); +}); + +describe('CoreHarness reloadSession', () => { + it('rejects with TURN_AGENT_BUSY while the live session is not idle', async () => { + const fx = makeFixture({ activityStatus: 'running' }); + await fx.harness.createSession({ id: 'sess-1', workDir: '/work' }); + const error = await rejected(fx.harness.reloadSession({ id: 'sess-1' })); + expect(isCoreError(error)).toBe(true); + expect((error as { code: string }).code).toBe(CoreErrorCodes.TURN_AGENT_BUSY); + expect(fx.calls['plugins.reload']).toBeUndefined(); + }); + + it('reloads plugins, closes and resumes the session, then tracks session_reload', async () => { + const fx = makeFixture(); + const original = await fx.harness.createSession({ id: 'sess-1', workDir: '/work' }); + fx.telemetryEvents.length = 0; + + const reloaded = await fx.harness.reloadSession({ + id: 'sess-1', + forcePluginSessionStartReminder: true, // TODO(v2-gap): G-5 — accepted and ignored. + }); + expect(reloaded).not.toBe(original); + expect(reloaded.id).toBe('sess-1'); + expect(fx.calls['plugins.reload']).toHaveLength(1); + expect(fx.order.indexOf('plugins.reload')).toBeLessThan(fx.order.indexOf('lifecycle.close')); + expect(fx.order.indexOf('lifecycle.close')).toBeLessThan(fx.order.indexOf('lifecycle.resume')); + expect(fx.telemetryEvents).toEqual([ + { event: 'session_reload', context: { sessionId: 'sess-1' }, properties: undefined }, + ]); + }); +}); + +describe('CoreHarness forkSession', () => { + it('forwards forkId as newSessionId and tracks session_fork on the fork product', async () => { + const fx = makeFixture(); + await fx.harness.createSession({ id: 'sess-1', workDir: '/work' }); + fx.telemetryEvents.length = 0; + + const fork = await fx.harness.forkSession({ id: 'sess-1', forkId: 'fork-1', title: 'Fork!' }); + expect(fork.id).toBe('fork-1'); + expect(fx.calls['lifecycle.fork']).toEqual([ + [{ sourceSessionId: 'sess-1', newSessionId: 'fork-1', title: 'Fork!' }], + ]); + expect(fork.getResumeState()).toBeDefined(); + expect(fx.telemetryEvents).toEqual([ + { + event: 'session_started', + context: { sessionId: 'fork-1' }, + properties: { + base: 'prop', + client_id: null, + client_name: 'KimiCodeTest', + client_version: '1.2.3', + ui_mode: 'test-ui', + resumed: true, + }, + }, + { event: 'session_fork', context: { sessionId: 'fork-1' }, properties: undefined }, + ]); + }); +}); + +describe('CoreHarness renameSession', () => { + it('renames a live session via setTitle and re-emits session.meta.updated locally', async () => { + const fx = makeFixture(); + const session = await fx.harness.createSession({ id: 'sess-1', workDir: '/work' }); + const events: SessionEvent[] = []; + session.onEvent((event) => events.push(event)); + + await fx.harness.renameSession({ id: 'sess-1', title: 'New Title' }); + expect(fx.calls['sess-1.setTitle']).toEqual([['New Title']]); + expect(events).toEqual([ + { type: 'session.meta.updated', title: 'New Title', agentId: 'main', sessionId: 'sess-1' }, + ]); + // The injected event flows through the same delivery path, so the + // synchronous summary snapshot follows. + expect(session.summary.title).toBe('New Title'); + }); + + it('renames a cold session by resuming, retitling and closing it again', async () => { + const fx = makeFixture(); + await fx.harness.createSession({ id: 'sess-1', workDir: '/work' }); + await fx.harness.closeSession('sess-1'); + fx.calls['lifecycle.close'] = []; + + await fx.harness.renameSession({ id: 'sess-1', title: 'Cold Title' }); + expect(fx.calls['lifecycle.resume']).toEqual([['sess-1']]); + expect(fx.calls['sess-1.setTitle']).toEqual([['Cold Title']]); + expect(fx.calls['lifecycle.close']).toEqual([['sess-1']]); + }); + + it('rejects an unknown cold id with SESSION_NOT_FOUND', async () => { + const fx = makeFixture(); + const error = await rejected(fx.harness.renameSession({ id: 'ghost', title: 'X' })); + expect((error as { code: string }).code).toBe(CoreErrorCodes.SESSION_NOT_FOUND); + }); +}); + +describe('CoreHarness listSessions and exportSession', () => { + it('lists via the session index, filters by workDir, and projects summaries', async () => { + const fx = makeFixture(); + const all = await fx.harness.listSessions(); + expect(all).toEqual([ + { + id: 'sess-a', + title: 'A', + lastPrompt: 'p', + workDir: '/work', + sessionDir: '/sessions/ws-1/sess-a', + createdAt: 1, + updatedAt: 2, + archived: false, + metadata: { k: 'v' }, + }, + { + id: 'sess-b', + title: undefined, + lastPrompt: undefined, + // TODO note: sessions predating the persisted cwd project an empty workDir. + workDir: '', + sessionDir: '/sessions/ws-2/sess-b', + createdAt: 3, + updatedAt: 4, + archived: true, + metadata: undefined, + }, + ]); + + const filtered = await fx.harness.listSessions({ workDir: '/work' }); + expect(filtered.map((s) => s.id)).toEqual(['sess-a']); + const byId = await fx.harness.listSessions({ sessionId: 'sess-b' }); + expect(byId.map((s) => s.id)).toEqual(['sess-b']); + }); + + it('exports through the session export service and tracks export', async () => { + const fx = makeFixture(); + const result = await fx.harness.exportSession({ + id: 'sess-1', + outputPath: '/tmp/out.zip', + includeGlobalLog: true, + version: '9.9.9', + installSource: 'npm-global', + }); + expect(result).toBe(fx.exportResult); + expect(fx.calls['export.export']).toEqual([ + [ + { + sessionId: 'sess-1', + outputPath: '/tmp/out.zip', + includeGlobalLog: true, + version: '9.9.9', + installSource: 'npm-global', + shellEnv: undefined, + }, + ], + ]); + expect(fx.telemetryEvents).toEqual([ + { event: 'export', context: { sessionId: 'sess-1' }, properties: undefined }, + ]); + }); +}); + +describe('CoreHarness getStartupState', () => { + it('resolves config defaults and the default model without creating a session', async () => { + const fx = makeFixture(); + const state = await fx.harness.getStartupState(); + expect(state).toEqual({ + model: 'kimi-latest', + maxContextTokens: 200_000, + permissionMode: 'auto', + planMode: true, + thinkingEffort: 'high', + }); + expect(fx.calls['resolver.resolve']).toEqual([['kimi-latest']]); + expect(fx.calls['lifecycle.create']).toBeUndefined(); + }); + + it('keeps the alias but degrades to a zero context window when resolution fails', async () => { + const fx = makeFixture({ resolverError: new Error('auth not ready') }); + const state = await fx.harness.getStartupState(); + expect(state.model).toBe('kimi-latest'); + expect(state.maxContextTokens).toBe(0); + // The thinking default comes from config and survives a failed resolve. + expect(state.thinkingEffort).toBe('high'); + }); + + it('reports empty defaults when nothing is configured', async () => { + const fx = makeFixture(); + for (const key of Object.keys(fx.configValues)) delete fx.configValues[key]; + const state = await fx.harness.getStartupState(); + expect(state).toEqual({ + model: '', + maxContextTokens: 0, + permissionMode: 'manual', + planMode: false, + thinkingEffort: 'off', + }); + expect(fx.calls['resolver.resolve']).toBeUndefined(); + }); + + it('falls back to manual for an invalid permission mode', async () => { + const fx = makeFixture(); + fx.configValues['defaultPermissionMode'] = 'bogus'; + expect((await fx.harness.getStartupState()).permissionMode).toBe('manual'); + }); +}); + +describe('CoreHarness config domain', () => { + it('getConfig awaits readiness and only reloads when asked', async () => { + const fx = makeFixture(); + expect(await fx.harness.getConfig()).toBe(fx.configAll); + expect(fx.calls['config.reload']).toBeUndefined(); + expect(await fx.harness.getConfig({ reload: true })).toBe(fx.configAll); + expect(fx.calls['config.reload']).toHaveLength(1); + }); + + it('setConfig writes each domain then returns the resolved config', async () => { + const fx = makeFixture(); + const result = await fx.harness.setConfig({ defaultModel: 'other', telemetry: { enabled: false } }); + expect(fx.calls['config.set']).toEqual([ + ['defaultModel', 'other'], + ['telemetry', { enabled: false }], + ]); + expect(result).toBe(fx.configAll); + }); + + it('exposes diagnostics, provider removal and experimental flags', async () => { + const fx = makeFixture(); + expect(await fx.harness.getConfigDiagnostics()).toBe(fx.diagnostics); + expect(await fx.harness.removeProvider('kimi')).toBe(fx.configAll); + expect(fx.calls['provider.delete']).toEqual([['kimi']]); + expect(await fx.harness.getExperimentalFeatures()).toBe(fx.flags); + }); +}); + +describe('CoreHarness plugins', () => { + it('forwards the plugin surface to IPluginService', async () => { + const fx = makeFixture(); + expect(await fx.harness.listPlugins()).toBe(fx.pluginSummaries); + expect(await fx.harness.installPlugin({ source: 'github:me/plugin' })).toBe(fx.pluginSummaries[0]); + await fx.harness.setPluginEnabled({ id: 'known', enabled: false }); + await fx.harness.setPluginMcpServerEnabled({ id: 'known', server: 'srv', enabled: true }); + await fx.harness.removePlugin({ id: 'known' }); + expect(await fx.harness.reloadPlugins()).toBe(fx.reloadSummary); + expect(await fx.harness.listPluginCommands()).toBe(fx.pluginCommands); + expect(fx.calls['plugins.install']).toEqual([[{ source: 'github:me/plugin' }]]); + expect(fx.calls['plugins.setEnabled']).toEqual([[{ id: 'known', enabled: false }]]); + expect(fx.calls['plugins.setMcpServerEnabled']).toEqual([[{ id: 'known', server: 'srv', enabled: true }]]); + expect(fx.calls['plugins.remove']).toEqual([[{ id: 'known' }]]); + }); + + it('getPluginInfo returns the info and rejects unknown ids with PLUGIN_NOT_FOUND', async () => { + const fx = makeFixture(); + expect(await fx.harness.getPluginInfo({ id: 'known' })).toBe(fx.pluginInfo); + const error = await rejected(fx.harness.getPluginInfo({ id: 'ghost' })); + expect(isCoreError(error)).toBe(true); + expect((error as { code: string }).code).toBe(CoreErrorCodes.PLUGIN_NOT_FOUND); + }); +}); + +describe('CoreHarness telemetry passthrough and close', () => { + it('track and setTelemetryContext forward to the client', () => { + const fx = makeFixture(); + fx.harness.track('custom_event', { a: 1 }); + fx.harness.setTelemetryContext({ sessionId: 'sess-1' }); + expect(fx.telemetryEvents).toEqual([{ event: 'custom_event', properties: { a: 1 }, context: undefined }]); + expect(fx.contextPatches).toEqual([{ sessionId: 'sess-1' }]); + }); + + it('closes every live session and disposes the app scope', async () => { + const fx = makeFixture(); + await fx.harness.createSession({ id: 'sess-1', workDir: '/work' }); + await fx.harness.createSession({ id: 'sess-2', workDir: '/work' }); + await fx.harness.close(); + expect(fx.calls['lifecycle.close']).toEqual(expect.arrayContaining([['sess-1'], ['sess-2']])); + expect(fx.getDisposeCalls()).toBe(1); + }); + + it('swallows session-close and dispose failures on the exit path', async () => { + const fx = makeFixture({ closeSessionError: new Error('close boom'), disposeError: new Error('dispose boom') }); + await fx.harness.createSession({ id: 'sess-1', workDir: '/work' }); + await expect(fx.harness.close()).resolves.toBeUndefined(); + expect(fx.getDisposeCalls()).toBe(1); + }); +}); + +describe('CoreHarness ensureConfigFile', () => { + const tempDirs: string[] = []; + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + it('creates the default stub once and leaves an existing file untouched', async () => { + const dir = await mkdtemp(join(tmpdir(), 'core-harness-')); + tempDirs.push(dir); + const configPath = join(dir, 'nested', 'config.toml'); + const fx = makeFixture({ configPath }); + + await fx.harness.ensureConfigFile(); + const created = await readFile(configPath, 'utf-8'); + expect(created).toBe( + '# ~/.kimi-code/config.toml\n' + + '# Runtime settings for Kimi Code.\n' + + '# This file starts empty so built-in defaults can apply.\n' + + '# Login will populate managed Kimi provider and model entries.\n', + ); + + await writeFile(configPath, 'user content', 'utf-8'); + await fx.harness.ensureConfigFile(); + expect(await readFile(configPath, 'utf-8')).toBe('user content'); + }); +}); diff --git a/apps/kimi-code/test/core/replay.test.ts b/apps/kimi-code/test/core/replay.test.ts new file mode 100644 index 0000000000..ee09430b6d --- /dev/null +++ b/apps/kimi-code/test/core/replay.test.ts @@ -0,0 +1,346 @@ +// Uses fake scope handles shaped like the minimal v2 interface subset; +// does not bootstrap the real engine. Services are dispatched by the real +// service identifier objects so the implementation must ask for the exact +// tokens it documents. +import { describe, expect, it } from 'vitest'; +import { + IAgentBlobService, + IAgentContextMemoryService, + IAgentContextSizeService, + IAgentPermissionModeService, + IAgentPermissionRulesService, + IAgentPlanService, + IAgentProfileService, + IAgentSwarmService, + IAgentTaskService, + IAgentToolRegistryService, + IAgentUsageService, + IAgentWireRecordService, + ISessionMetadata, + ISessionTodoService, +} from '@moonshot-ai/agent-core-v2'; +import { buildResumedAgents, buildResumedSessionState } from '../../src/core/replay'; + +// -- Minimal fakes: token-dispatching accessor keyed by real identifiers -- + +function makeAccessor(entries: ReadonlyArray) { + const services = new Map(entries); + return { + get: (token: unknown) => { + if (!services.has(token)) throw new Error(`fake accessor: unexpected service ${String(token)}`); + return services.get(token); + }, + }; +} + +function makeFixture(metaOverrides?: Record) { + const history = [ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'hi there' }] }, + ]; + // Wire log: the two live messages plus one pre-compaction exchange that the + // folded context no longer carries. The transcript fold must recover all + // of it, with a compaction card in between. + const wireRecords = [ + { + type: 'context.append_message', + message: { role: 'user', content: [{ type: 'text', text: 'before compaction' }], toolCalls: [] }, + time: 1, + }, + { + type: 'context.apply_compaction', + summary: 'Compacted summary.', + compactedCount: 1, + tokensBefore: 500, + tokensAfter: 120, + time: 2, + }, + { + type: 'context.append_message', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, + time: 3, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 's1' }, + time: 4, + }, + { + type: 'context.append_loop_event', + event: { type: 'content.part', stepUuid: 's1', part: { type: 'text', text: 'hi there' } }, + time: 5, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.end', uuid: 's1' }, + time: 6, + }, + ]; + const profileData = { + cwd: '/work/dir', + modelAlias: 'kimi-latest', + modelCapabilities: { imageInput: true }, + profileName: 'agent', + thinkingLevel: 'high', + systemPrompt: 'be helpful', + }; + const rules = [{ decision: 'allow', scope: 'session-runtime', pattern: 'Bash(ls *)' }]; + const plan = { id: 'plan-1', content: '# plan', path: '/tmp/plan.md' }; + const usage = { total: { inputTokens: 12, outputTokens: 34 } }; + const toolInfos = [ + { name: 'Bash', description: 'run commands', source: 'builtin' }, + { name: 'WebSearch', description: 'search the web', source: 'mcp' }, + ]; + const tasks = [{ taskId: 'task-1', status: 'running' }]; + const todos = [{ title: 'write tests', status: 'in_progress' }]; + const meta = { + id: 'sess-1', + version: 2, + createdAt: Date.UTC(2024, 0, 2, 3, 4, 5), // 2024-01-02T03:04:05.000Z + updatedAt: Date.UTC(2024, 0, 2, 3, 4, 6, 789), // 2024-01-02T03:04:06.789Z + archived: false, + agents: { + main: { homedir: '/homes/main' }, + 'sub-legacy': { + homedir: '/homes/sub-legacy', + type: 'sub', + parentAgentId: 'main', + swarmItem: 'legacy-item', + }, + 'sub-labels': { + homedir: '/homes/sub-labels', + labels: { parentAgentId: 'labels-parent', swarmItem: 'labels-item' }, + parentAgentId: 'legacy-parent', + swarmItem: 'legacy-item', + }, + }, + ...metaOverrides, + }; + + const isToolActiveCalls: Array = []; + const taskListCalls: unknown[] = []; + + const mainAgent = { + id: 'main', + accessor: makeAccessor([ + [IAgentContextMemoryService, { get: () => history }], + [IAgentContextSizeService, { get: () => ({ size: 1234, measured: 1000, estimated: 234 }) }], + [ + IAgentProfileService, + { + data: () => profileData, + isToolActive: (name: string, source?: unknown) => { + isToolActiveCalls.push([name, source]); + return name === 'Bash'; + }, + }, + ], + [IAgentPermissionModeService, { mode: 'manual' }], + [IAgentPermissionRulesService, { rules }], + [IAgentPlanService, { status: async () => plan }], + [IAgentSwarmService, { isActive: true }], + [IAgentUsageService, { status: () => usage }], + [IAgentToolRegistryService, { list: () => toolInfos }], + [IAgentWireRecordService, { getRecords: () => wireRecords }], + [IAgentBlobService, { loadParts: async (parts: readonly unknown[]) => parts }], + [ + IAgentTaskService, + { + list: (activeOnly?: boolean) => { + taskListCalls.push(activeOnly); + return tasks; + }, + }, + ], + ]), + }; + const session = { + id: 'sess-1', + accessor: makeAccessor([ + [ISessionTodoService, { getTodos: () => todos }], + [ISessionMetadata, { read: async () => meta }], + ]), + }; + return { + history, + wireRecords, + profileData, + rules, + plan, + usage, + toolInfos, + tasks, + todos, + meta, + mainAgent, + session, + isToolActiveCalls, + taskListCalls, + }; +} + +describe('buildResumedAgents', () => { + it('returns a single main entry whose replay folds the full wire log', async () => { + const fx = makeFixture(); + const agents = await buildResumedAgents(fx.session as never, fx.mainAgent as never); + + expect(Object.keys(agents)).toEqual(['main']); + const main = agents['main']!; + expect(main.type).toBe('main'); + // The replay recovers the pre-compaction message and the compaction card + // from the wire log; `time` stays 0 (records carry no readable envelope + // timestamp) and the TUI renders by position. + expect(main.replay).toEqual([ + { + time: 0, + type: 'message', + message: { + role: 'user', + content: [{ type: 'text', text: 'before compaction' }], + toolCalls: [], + toolCallId: undefined, + isError: undefined, + origin: undefined, + }, + }, + { + time: 0, + type: 'compaction', + result: { + summary: 'Compacted summary.', + compactedCount: 1, + tokensBefore: 500, + tokensAfter: 120, + }, + instruction: undefined, + }, + { + time: 0, + type: 'message', + message: { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + toolCalls: [], + toolCallId: undefined, + isError: undefined, + origin: undefined, + }, + }, + { + time: 0, + type: 'message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'hi there' }], + toolCalls: [], + toolCallId: undefined, + isError: undefined, + origin: undefined, + }, + }, + ]); + // The model-facing context view is unaffected: still the folded history. + expect(main.context).toEqual({ history: fx.history, tokenCount: 1234 }); + }); + + it('maps profile data to config, renaming thinkingLevel and pinning provider undefined', async () => { + const fx = makeFixture(); + const agents = await buildResumedAgents(fx.session as never, fx.mainAgent as never); + + const config = agents['main']!.config; + expect(config.thinkingEffort).toBe('high'); + expect('thinkingLevel' in config).toBe(false); + // v2 has no per-agent provider config DTO. + expect(config.provider).toBeUndefined(); + expect(config).toEqual({ + cwd: '/work/dir', + provider: undefined, + modelAlias: 'kimi-latest', + modelCapabilities: { imageInput: true }, + profileName: 'agent', + thinkingEffort: 'high', + systemPrompt: 'be helpful', + }); + }); + + it('reads permission, plan, swarm, usage and background through their services', async () => { + const fx = makeFixture(); + const agents = await buildResumedAgents(fx.session as never, fx.mainAgent as never); + + const main = agents['main']!; + expect(main.permission).toEqual({ mode: 'manual', rules: fx.rules }); + expect(main.plan).toEqual(fx.plan); + expect(main.swarmMode).toBe(true); + expect(main.usage).toEqual(fx.usage); + expect(main.background).toEqual(fx.tasks); + // Background must include finished tasks: list(activeOnly = false). + expect(fx.taskListCalls).toEqual([false]); + }); + + it('marks each registry tool active via profile.isToolActive(name, source)', async () => { + const fx = makeFixture(); + const agents = await buildResumedAgents(fx.session as never, fx.mainAgent as never); + + expect(agents['main']!.tools).toEqual([ + { name: 'Bash', description: 'run commands', source: 'builtin', active: true }, + { name: 'WebSearch', description: 'search the web', source: 'mcp', active: false }, + ]); + expect(fx.isToolActiveCalls).toEqual([ + ['Bash', 'builtin'], + ['WebSearch', 'mcp'], + ]); + }); + + it('fills toolStore.todo from the session todo service', async () => { + const fx = makeFixture(); + const agents = await buildResumedAgents(fx.session as never, fx.mainAgent as never); + + expect(agents['main']!.toolStore).toEqual({ todo: fx.todos }); + }); +}); + +describe('buildResumedSessionState', () => { + it('projects session metadata with ISO timestamps, defaults, and agent fallbacks', async () => { + const fx = makeFixture(); + const state = await buildResumedSessionState(fx.session as never, fx.mainAgent as never); + + expect(Object.keys(state.agents)).toEqual(['main']); + expect(state.agents['main']!.type).toBe('main'); + expect(state.warning).toBeUndefined(); + + const metadata = state.sessionMetadata; + expect(metadata.createdAt).toBe('2024-01-02T03:04:05.000Z'); + expect(metadata.updatedAt).toBe('2024-01-02T03:04:06.789Z'); + // Absent in the v2 document -> projected defaults. + expect(metadata.title).toBe(''); + expect(metadata.isCustomTitle).toBe(false); + + expect(metadata.agents).toEqual({ + // No type/parent recorded: main falls back by id, parent to null. + main: { homedir: '/homes/main', type: 'main', parentAgentId: null, swarmItem: undefined }, + // Legacy bare fields are used when labels are absent. + 'sub-legacy': { + homedir: '/homes/sub-legacy', + type: 'sub', + parentAgentId: 'main', + swarmItem: 'legacy-item', + }, + // labels take precedence over the legacy bare fields; missing type on a + // non-main id falls back to 'sub'. + 'sub-labels': { + homedir: '/homes/sub-labels', + type: 'sub', + parentAgentId: 'labels-parent', + swarmItem: 'labels-item', + }, + }); + }); + + it('passes an explicit title and isCustomTitle through unchanged', async () => { + const fx = makeFixture({ title: 'My Session', isCustomTitle: true }); + const state = await buildResumedSessionState(fx.session as never, fx.mainAgent as never); + + expect(state.sessionMetadata.title).toBe('My Session'); + expect(state.sessionMetadata.isCustomTitle).toBe(true); + }); +}); diff --git a/apps/kimi-code/test/core/session.test.ts b/apps/kimi-code/test/core/session.test.ts new file mode 100644 index 0000000000..b175a009fb --- /dev/null +++ b/apps/kimi-code/test/core/session.test.ts @@ -0,0 +1,661 @@ +// Uses fake scope handles shaped like the minimal v2 interface subset; +// does not bootstrap the real engine. Services are dispatched by the real +// service identifier objects (same pattern as replay.test.ts), so the +// implementation must ask for the exact tokens it documents. `ensureMainAgent` +// is exercised through its "already exists" branch: the fake lifecycle's +// `getHandle('main')` returns the fake main handle. +import { describe, expect, it } from 'vitest'; +import { + IAgentContextSizeService, + IAgentFullCompactionService, + IAgentGoalService, + IAgentLifecycleService, + IAgentMcpService, + IAgentPermissionModeService, + IAgentPlanService, + IAgentProfileService, + IAgentPromptLegacyService, + IAgentRPCService, + IAgentSwarmService, + IAgentSystemReminderService, + IAgentTaskService, + IAgentUsageService, + IBootstrapService, + IEventBus, + IEventService, + IHostEnvironment, + IHostFileSystem, + ISessionApprovalService, + ISessionBtwService, + ISessionContext, + ISessionCronService, + ISessionInteractionService, + ISessionMetadata, + ISessionQuestionService, + ISessionSkillCatalog, + ISessionSwarmService, + ISessionWorkspaceCommandService, +} from '@moonshot-ai/agent-core-v2'; +import { CoreErrorCodes, isCoreError } from '../../src/core/errors'; +import { CoreSession } from '../../src/core/session'; +import type { CoreSessionSummary, ResumedSessionState, SessionEvent } from '../../src/core/types'; + +// -- Minimal fakes: token-dispatching accessor keyed by real identifiers -- + +function makeAccessor(entries: ReadonlyArray) { + const services = new Map(entries); + return { + get: (token: unknown) => { + if (!services.has(token)) throw new Error(`fake accessor: unexpected service ${String(token)}`); + return services.get(token); + }, + }; +} + +function makeFakeBus() { + const listeners = new Set<(e: unknown) => void>(); + return { + subscribe: (h: (e: unknown) => void) => { listeners.add(h); return { dispose: () => listeners.delete(h) }; }, + publish: (e: unknown) => { for (const l of [...listeners]) l(e); }, + }; +} + +interface FakeInteraction { + readonly id: string; + readonly kind: 'approval' | 'question' | 'user_tool'; + readonly payload: unknown; + readonly origin: { readonly agentId?: string }; + readonly createdAt: number; +} + +function makeFakeInteractionKernel(pending: FakeInteraction[]) { + const changeListeners = new Set<(e: { pending: readonly string[] }) => void>(); + const resolveListeners = new Set<(e: { id: string; response: unknown }) => void>(); + return { + listPending: (kind?: string) => (kind === undefined ? pending : pending.filter((i) => i.kind === kind)), + onDidChangePending: (l: (e: { pending: readonly string[] }) => void) => { + changeListeners.add(l); + return { dispose: () => changeListeners.delete(l) }; + }, + onDidResolve: (l: (e: { id: string; response: unknown }) => void) => { + resolveListeners.add(l); + return { dispose: () => resolveListeners.delete(l) }; + }, + _fireChange: () => { for (const l of [...changeListeners]) l({ pending: pending.map((i) => i.id) }); }, + _fireResolve: (id: string, response: unknown) => { for (const l of [...resolveListeners]) l({ id, response }); }, + }; +} + +function makeFixture(options?: { + resumeState?: ResumedSessionState; + pendingInteractions?: FakeInteraction[]; + agentsMdWarning?: string; + swarmRunStatus?: 'completed' | 'failed'; +}) { + const calls: Record = {}; + const record = (name: string) => (...args: unknown[]) => { (calls[name] ??= []).push(args); }; + const recordReturning = (name: string, value: T) => (...args: unknown[]) => { + (calls[name] ??= []).push(args); + return value; + }; + + const usage = { total: { inputTokens: 10, outputTokens: 20 } }; + const planData = { id: 'plan-1', content: '# plan' }; + const goalResult = { goal: { id: 'g1', objective: 'ship', status: 'active' } }; + const goalSnapshot = { id: 'g1', objective: 'ship', status: 'paused' }; + const tasks = [{ taskId: 't1', status: 'running' }]; + const taskInfo = { taskId: 't1', status: 'stopped' }; + const shellResult = { stdout: 'ok', stderr: '', isError: false }; + const mcpServers = [{ name: 'srv', transport: 'stdio', status: 'connected', toolCount: 2 }]; + const skillDefinitions = [ + { + name: 'write-tui', + description: 'TUI skill', + path: '/skills/write-tui', + source: 'project', + metadata: { type: 'general', disableModelInvocation: false, isSubSkill: false }, + }, + ]; + const sessionMeta = { id: 'sess-1', version: 2, createdAt: 1, updatedAt: 2, archived: false, agents: {} }; + const addDirResult = { projectRoot: '/work', configPath: '/work/.kimi-code/config.toml', additionalDirs: ['/extra'], persisted: true }; + + const makeAgentServices = (id: string) => { + const bus = makeFakeBus(); + return { + bus, + entries: [ + [IEventBus, bus], + [IAgentPromptLegacyService, { submit: recordReturning(`${id}.submit`, Promise.resolve({ prompt_id: 'p1' })) }], + [ + IAgentRPCService, + { + steer: record(`${id}.steer`), + cancel: record(`${id}.cancel`), + runShellCommand: recordReturning(`${id}.runShellCommand`, shellResult), + cancelShellCommand: record(`${id}.cancelShellCommand`), + undoHistory: recordReturning(`${id}.undoHistory`, 2), + activateSkill: record(`${id}.activateSkill`), + activatePluginCommand: record(`${id}.activatePluginCommand`), + setPermission: record(`${id}.setPermission`), + cancelCompaction: record(`${id}.cancelCompaction`), + getContext: recordReturning(`${id}.getContext`, { history: [{ role: 'user', content: 'hi' }], tokenCount: 42 }), + }, + ], + [ + IAgentProfileService, + { + setModel: recordReturning(`${id}.setModel`, Promise.resolve({ model: 'kimi-latest', providerName: 'kimi' })), + setThinking: record(`${id}.setThinking`), + getAgentsMdWarning: () => options?.agentsMdWarning, + getModel: () => 'kimi-latest', + getModelCapabilities: () => ({ max_context_tokens: 1000 }), + data: () => ({ cwd: '/work', thinkingLevel: 'high', systemPrompt: 'sp', modelCapabilities: {} }), + }, + ], + [ + IAgentPlanService, + { + enter: record(`${id}.plan.enter`), + cancel: record(`${id}.plan.cancel`), + clear: record(`${id}.plan.clear`), + status: recordReturning(`${id}.plan.status`, Promise.resolve(planData)), + }, + ], + [ + IAgentSwarmService, + { enter: record(`${id}.swarm.enter`), exit: record(`${id}.swarm.exit`), isActive: false }, + ], + [IAgentFullCompactionService, { begin: recordReturning(`${id}.compaction.begin`, true) }], + [ + IAgentGoalService, + { + getGoal: recordReturning(`${id}.getGoal`, goalResult), + createGoal: recordReturning(`${id}.createGoal`, Promise.resolve(goalSnapshot)), + pauseGoal: recordReturning(`${id}.pauseGoal`, Promise.resolve(goalSnapshot)), + resumeGoal: recordReturning(`${id}.resumeGoal`, Promise.resolve(goalSnapshot)), + cancelGoal: recordReturning(`${id}.cancelGoal`, Promise.resolve(goalSnapshot)), + }, + ], + [IAgentUsageService, { status: () => usage }], + [IAgentContextSizeService, { get: () => ({ size: 100 }) }], + [IAgentPermissionModeService, { mode: 'auto' }], + [ + IAgentMcpService, + { initialLoadDurationMs: () => 123, list: () => mcpServers }, + ], + [ + IAgentTaskService, + { + list: recordReturning(`${id}.tasks.list`, tasks), + readOutput: recordReturning(`${id}.tasks.readOutput`, Promise.resolve('output text')), + stop: recordReturning(`${id}.tasks.stop`, Promise.resolve(taskInfo)), + detach: recordReturning(`${id}.tasks.detach`, taskInfo), + }, + ], + [IAgentSystemReminderService, { appendSystemReminder: record(`${id}.appendSystemReminder`) }], + ] as ReadonlyArray, + }; + }; + + const mainServices = makeAgentServices('main'); + const btwServices = makeAgentServices('btw-1'); + const mainAgent = { id: 'main', kind: 'agent', accessor: makeAccessor(mainServices.entries) }; + const btwAgent = { id: 'btw-1', kind: 'agent', accessor: makeAccessor(btwServices.entries) }; + const handles = new Map([ + ['main', mainAgent], + ['btw-1', btwAgent], + ]); + + const getHandleCalls: string[] = []; + const lifecycle = { + list: () => [mainAgent], + getHandle: (id: string) => { + getHandleCalls.push(id); + return handles.get(id); + }, + onDidCreate: () => ({ dispose: () => {} }), + onDidDispose: () => ({ dispose: () => {} }), + }; + + const kernel = makeFakeInteractionKernel(options?.pendingInteractions ?? []); + const swarmRunCalls: unknown[] = []; + const sessionSwarm = { + run: (args: { tasks: readonly unknown[] }) => { + swarmRunCalls.push(args); + return Promise.resolve([ + options?.swarmRunStatus === 'failed' + ? { task: args.tasks[0], status: 'failed', error: 'boom' } + : { task: args.tasks[0], status: 'completed', result: 'done' }, + ]); + }, + }; + + const session = { + id: 'sess-1', + kind: 'session', + accessor: makeAccessor([ + [IAgentLifecycleService, lifecycle], + [ISessionCronService, { _serviceBrand: undefined }], + [ISessionInteractionService, kernel], + [ISessionApprovalService, { decide: record('approvals.decide') }], + [ISessionQuestionService, { answer: record('questions.answer'), dismiss: record('questions.dismiss') }], + [ISessionBtwService, { start: () => Promise.resolve('btw-1') }], + [ISessionSwarmService, sessionSwarm], + [ISessionSkillCatalog, { ready: Promise.resolve(), catalog: { listSkills: () => skillDefinitions } }], + [ISessionWorkspaceCommandService, { addAdditionalDir: recordReturning('addAdditionalDir', Promise.resolve(addDirResult)) }], + [ISessionContext, { cwd: '/work' }], + [ISessionMetadata, { read: () => Promise.resolve(sessionMeta) }], + ]), + }; + + // App scope: global event bus; the remaining host services are pre-wired fakes. + const appBus = makeFakeBus(); + const files = new Map([['/work/AGENTS.md', 'PROJECT RULES']]); + const fakeFs = { + stat: (path: string) => { + if (!files.has(path)) return Promise.reject(new Error(`ENOENT: ${path}`)); + return Promise.resolve({ isFile: true, isDirectory: false }); + }, + readText: (path: string) => Promise.resolve(files.get(path) ?? ''), + }; + const app = { + accessor: makeAccessor([ + [IEventService, appBus], + [IHostFileSystem, fakeFs], + [IHostEnvironment, { ready: Promise.resolve(), homeDir: '/os-home' }], + [IBootstrapService, { homeDir: '/kimi-home' }], + ]), + }; + + const summary: CoreSessionSummary = { + id: 'sess-1', + title: 'Old Title', + workDir: '/work', + sessionDir: '/sessions/sess-1', + createdAt: 1, + updatedAt: 2, + archived: false, + additionalDirs: ['/extra'], + }; + + let onCloseCalls = 0; + const core = new CoreSession({ + id: 'sess-1', + handle: session as never, + app: app as never, + summary, + resumeState: options?.resumeState, + onClose: () => { + onCloseCalls += 1; + return Promise.resolve(); + }, + }); + + return { + core, + calls, + summary, + usage, + planData, + goalResult, + goalSnapshot, + tasks, + taskInfo, + shellResult, + mcpServers, + sessionMeta, + addDirResult, + mainBus: mainServices.bus, + btwBus: btwServices.bus, + appBus, + kernel, + swarmRunCalls, + getHandleCalls, + getOnCloseCalls: () => onCloseCalls, + }; +} + +const parts = [{ type: 'text' as const, text: 'hello' }]; + +describe('CoreSession conversation flow and agent routing', () => { + it('prompt routes to the main agent prompt service by default', async () => { + const fx = makeFixture(); + await fx.core.prompt(parts); + expect(fx.calls['main.submit']).toEqual([[{ content: parts }]]); + // ensureMainAgent resolves main through the lifecycle's existing handle. + expect(fx.getHandleCalls).toContain('main'); + }); + + it('prompt with an explicit agentId routes to that agent', async () => { + const fx = makeFixture(); + await fx.core.prompt(parts, { agentId: 'btw-1' }); + expect(fx.calls['btw-1.submit']).toEqual([[{ content: parts }]]); + expect(fx.calls['main.submit']).toBeUndefined(); + }); + + it('prompt with an unknown agentId rejects with AGENT_NOT_FOUND', async () => { + const fx = makeFixture(); + const error = await fx.core.prompt(parts, { agentId: 'ghost' }).then( + () => undefined, + (error: unknown) => error, + ); + expect(isCoreError(error)).toBe(true); + expect((error as { code: string }).code).toBe(CoreErrorCodes.AGENT_NOT_FOUND); + }); + + it('steer/cancel/shell/undo/skill/plugin-command/permission/compaction-cancel forward to the RPC facade', async () => { + const fx = makeFixture(); + await fx.core.steer(parts); + await fx.core.cancel(); + const shell = await fx.core.runShellCommand('ls', { commandId: 'c1' }); + await fx.core.cancelShellCommand('c1'); + const undone = await fx.core.undoHistory(2); + await fx.core.activateSkill({ name: 'write-tui', args: 'now' }); + await fx.core.activatePluginCommand({ pluginId: 'p1', commandName: 'cmd', args: 'a' }); + await fx.core.setPermission('auto'); + await fx.core.cancelCompaction(); + + expect(fx.calls['main.steer']).toEqual([[{ input: parts }]]); + expect(fx.calls['main.cancel']).toEqual([[{}]]); + expect(fx.calls['main.runShellCommand']).toEqual([[{ command: 'ls', commandId: 'c1' }]]); + expect(shell).toEqual(fx.shellResult); + expect(fx.calls['main.cancelShellCommand']).toEqual([[{ commandId: 'c1' }]]); + expect(fx.calls['main.undoHistory']).toEqual([[{ count: 2 }]]); + expect(undone).toBe(2); + expect(fx.calls['main.activateSkill']).toEqual([[{ name: 'write-tui', args: 'now' }]]); + expect(fx.calls['main.activatePluginCommand']).toEqual([[{ pluginId: 'p1', commandName: 'cmd', args: 'a' }]]); + expect(fx.calls['main.setPermission']).toEqual([[{ mode: 'auto' }]]); + expect(fx.calls['main.cancelCompaction']).toEqual([[{}]]); + }); +}); + +describe('CoreSession modes', () => { + it('setModel/setThinking forward to the profile service', async () => { + const fx = makeFixture(); + const result = await fx.core.setModel('kimi-latest'); + await fx.core.setThinking('high'); + expect(fx.calls['main.setModel']).toEqual([['kimi-latest']]); + expect(result).toEqual({ model: 'kimi-latest', providerName: 'kimi' }); + expect(fx.calls['main.setThinking']).toEqual([['high']]); + }); + + it('setPlanMode enters/cancels plan mode and plan queries forward', async () => { + const fx = makeFixture(); + await fx.core.setPlanMode(true); + await fx.core.setPlanMode(false); + const plan = await fx.core.getPlan(); + await fx.core.clearPlan(); + expect(fx.calls['main.plan.enter']).toEqual([[]]); + expect(fx.calls['main.plan.cancel']).toEqual([[]]); + expect(plan).toEqual(fx.planData); + expect(fx.calls['main.plan.clear']).toEqual([[]]); + }); + + it('setSwarmMode enters with the trigger (default manual) and exits', async () => { + const fx = makeFixture(); + await fx.core.setSwarmMode(true); + await fx.core.setSwarmMode(true, { trigger: 'task' }); + await fx.core.setSwarmMode(false); + expect(fx.calls['main.swarm.enter']).toEqual([['manual'], ['task']]); + expect(fx.calls['main.swarm.exit']).toEqual([[]]); + }); + + it('compact begins a manual full compaction with the instruction', async () => { + const fx = makeFixture(); + await fx.core.compact('keep decisions'); + expect(fx.calls['main.compaction.begin']).toEqual([[{ source: 'manual', instruction: 'keep decisions' }]]); + }); +}); + +describe('CoreSession queries', () => { + it('getStatus aggregates the main agent native services with usage', async () => { + const fx = makeFixture(); + const status = await fx.core.getStatus(); + expect(status).toEqual({ + model: 'kimi-latest', + thinkingEffort: 'high', + permission: 'auto', + planMode: true, + swarmMode: false, + contextTokens: 100, + maxContextTokens: 1000, + contextUsage: 0.1, + usage: fx.usage, + }); + }); + + it('getContext returns the agent history and getUsage the usage snapshot', async () => { + const fx = makeFixture(); + expect(await fx.core.getContext()).toEqual([{ role: 'user', content: 'hi' }]); + expect(await fx.core.getUsage()).toEqual(fx.usage); + }); + + it('getSessionWarnings projects the AGENTS.md size warning when present', async () => { + const warned = makeFixture({ agentsMdWarning: 'AGENTS.md too large' }); + expect(await warned.core.getSessionWarnings()).toEqual([ + { code: 'agents-md-oversized', message: 'AGENTS.md too large', severity: 'warning' }, + ]); + const clean = makeFixture(); + expect(await clean.core.getSessionWarnings()).toEqual([]); + }); + + it('exposes MCP metrics/list, skills, metadata and workDir/summary', async () => { + const fx = makeFixture(); + expect(await fx.core.getMcpStartupMetrics()).toEqual({ durationMs: 123 }); + expect(await fx.core.listMcpServers()).toEqual(fx.mcpServers); + expect(await fx.core.listSkills()).toEqual([ + { + name: 'write-tui', + description: 'TUI skill', + path: '/skills/write-tui', + source: 'project', + type: 'general', + disableModelInvocation: false, + isSubSkill: false, + }, + ]); + expect(await fx.core.getSessionMetadata()).toEqual(fx.sessionMeta); + expect(fx.core.workDir).toBe('/work'); + expect(fx.core.summary).toEqual(fx.summary); + }); + + it('getResumeState returns the harness-injected snapshot', () => { + const resumeState = { sessionMetadata: {}, agents: {} } as never; + expect(makeFixture({ resumeState }).core.getResumeState()).toBe(resumeState); + expect(makeFixture().core.getResumeState()).toBeUndefined(); + }); + + it('summary follows session.meta.updated events for this session', () => { + const fx = makeFixture(); + fx.appBus.publish({ + type: 'session.meta.updated', + payload: { sessionId: 'sess-1', agentId: 'main', title: 'New Title', patch: { title: 'New Title', lastPrompt: 'hi' } }, + }); + expect(fx.core.summary.title).toBe('New Title'); + expect(fx.core.summary.lastPrompt).toBe('hi'); + // Unrelated sessions leave the snapshot untouched. + fx.appBus.publish({ + type: 'session.meta.updated', + payload: { sessionId: 'other', title: 'X', patch: { title: 'X' } }, + }); + expect(fx.core.summary.title).toBe('New Title'); + }); +}); + +describe('CoreSession goal and background tasks', () => { + it('getGoal forwards and mutators wrap snapshots into GoalToolResult', async () => { + const fx = makeFixture(); + expect(await fx.core.getGoal()).toEqual(fx.goalResult); + expect(await fx.core.createGoal({ objective: 'ship' })).toEqual({ goal: fx.goalSnapshot }); + expect(await fx.core.pauseGoal()).toEqual({ goal: fx.goalSnapshot }); + expect(await fx.core.resumeGoal()).toEqual({ goal: fx.goalSnapshot }); + expect(await fx.core.cancelGoal()).toEqual({ goal: fx.goalSnapshot }); + expect(fx.calls['main.createGoal']).toEqual([[{ objective: 'ship' }]]); + expect(fx.calls['main.pauseGoal']).toEqual([[]]); + }); + + it('task list/output/stop/detach forward to the agent task service', async () => { + const fx = makeFixture(); + expect(await fx.core.listBackgroundTasks()).toEqual(fx.tasks); + expect(await fx.core.listBackgroundTasks({ activeOnly: false, limit: 5 })).toEqual(fx.tasks); + expect(await fx.core.getBackgroundTaskOutput('t1', { tail: 100 })).toBe('output text'); + expect(await fx.core.stopBackgroundTask('t1', 'user stop')).toEqual(fx.taskInfo); + expect(await fx.core.detachBackgroundTask('t1')).toEqual(fx.taskInfo); + expect(fx.calls['main.tasks.list']).toEqual([ + [undefined, undefined], + [false, 5], + ]); + expect(fx.calls['main.tasks.readOutput']).toEqual([['t1', 100]]); + expect(fx.calls['main.tasks.stop']).toEqual([['t1', 'user stop']]); + expect(fx.calls['main.tasks.detach']).toEqual([['t1']]); + }); + + it('routes task queries to an explicit agent', async () => { + const fx = makeFixture(); + await fx.core.listBackgroundTasks({ agentId: 'btw-1' }); + expect(fx.calls['btw-1.tasks.list']).toEqual([[undefined, undefined]]); + expect(fx.calls['main.tasks.list']).toBeUndefined(); + }); +}); + +describe('CoreSession orchestration', () => { + it('startBtw returns the side-question agent id', async () => { + const fx = makeFixture(); + expect(await fx.core.startBtw()).toBe('btw-1'); + }); + + it('addAdditionalDir forwards to the workspace command service', async () => { + const fx = makeFixture(); + expect(await fx.core.addAdditionalDir({ path: '/extra', persist: true })).toEqual(fx.addDirResult); + expect(fx.calls['addAdditionalDir']).toEqual([[{ path: '/extra', persist: true }]]); + }); + + it('generateAgentsMd throws NOT_IMPLEMENTED (v2 has no native method)', async () => { + const fx = makeFixture(); + const error = await fx.core.generateAgentsMd().then( + () => undefined, + (error: unknown) => error, + ); + expect(isCoreError(error)).toBe(true); + expect((error as { code: string }).code).toBe(CoreErrorCodes.NOT_IMPLEMENTED); + }); +}); + +describe('CoreSession events and close', () => { + it('fans events out to every listener and honors unsubscribe', () => { + const fx = makeFixture(); + const first: SessionEvent[] = []; + const second: SessionEvent[] = []; + const offFirst = fx.core.onEvent((e) => first.push(e)); + fx.core.onEvent((e) => second.push(e)); + + fx.mainBus.publish({ type: 'turn.started', turnId: 1 }); + expect(first).toEqual([{ type: 'turn.started', turnId: 1, agentId: 'main', sessionId: 'sess-1' }]); + expect(second).toEqual(first); + + offFirst(); + fx.mainBus.publish({ type: 'turn.ended', turnId: 1 }); + expect(first).toHaveLength(1); + expect(second).toHaveLength(2); + }); + + it('close is idempotent and detaches the event pipeline', async () => { + const fx = makeFixture(); + const seen: SessionEvent[] = []; + fx.core.onEvent((e) => seen.push(e)); + + await fx.core.close(); + await fx.core.close(); + expect(fx.getOnCloseCalls()).toBe(1); + + fx.mainBus.publish({ type: 'turn.started', turnId: 2 }); + expect(seen).toHaveLength(0); + }); +}); + +describe('CoreSession interactions', () => { + const pendingInteractions: FakeInteraction[] = [ + { + id: 'a1', + kind: 'approval', + payload: { toolName: 'Bash', action: 'run', display: {}, agentId: 'payload-agent' }, + origin: { agentId: 'origin-agent' }, + createdAt: 1, + }, + { + id: 'a2', + kind: 'approval', + payload: { toolName: 'Edit', action: 'edit', display: {}, agentId: 'payload-agent' }, + origin: {}, + createdAt: 2, + }, + { + id: 'a3', + kind: 'approval', + payload: { toolName: 'Read', action: 'read', display: {} }, + origin: {}, + createdAt: 3, + }, + { + id: 'q1', + kind: 'question', + payload: { questions: [{ question: 'Which?', options: [] }] }, + origin: { agentId: 'asker' }, + createdAt: 4, + }, + { + id: 'q2', + kind: 'question', + payload: { questions: [{ question: 'Sure?', options: [] }] }, + origin: {}, + createdAt: 5, + }, + ]; + + it('approvals.list projects id, agentId fallback chain and the raw request', () => { + const fx = makeFixture({ pendingInteractions }); + expect(fx.core.approvals.list()).toEqual([ + { id: 'a1', agentId: 'origin-agent', request: pendingInteractions[0]!.payload }, + { id: 'a2', agentId: 'payload-agent', request: pendingInteractions[1]!.payload }, + { id: 'a3', agentId: 'main', request: pendingInteractions[2]!.payload }, + ]); + }); + + it('questions.list projects only question interactions with the origin fallback', () => { + const fx = makeFixture({ pendingInteractions }); + expect(fx.core.questions.list()).toEqual([ + { id: 'q1', agentId: 'asker', request: pendingInteractions[3]!.payload }, + { id: 'q2', agentId: 'main', request: pendingInteractions[4]!.payload }, + ]); + }); + + it('decide/answer/dismiss write through to the session brokers', () => { + const fx = makeFixture({ pendingInteractions }); + fx.core.approvals.decide('a1', { decision: 'approved' }); + fx.core.questions.answer('q1', { item0: 'yes' }); + fx.core.questions.dismiss('q2'); + expect(fx.calls['approvals.decide']).toEqual([['a1', { decision: 'approved' }]]); + expect(fx.calls['questions.answer']).toEqual([['q1', { item0: 'yes' }]]); + expect(fx.calls['questions.dismiss']).toEqual([['q2']]); + }); + + it('onDidChangePending and onDidResolve subscribe to the kernel and unsubscribe cleanly', () => { + const fx = makeFixture({ pendingInteractions }); + let changes = 0; + const resolved: string[] = []; + const offChange = fx.core.approvals.onDidChangePending(() => { changes += 1; }); + const offResolve = fx.core.questions.onDidResolve((id) => resolved.push(id)); + + fx.kernel._fireChange(); + fx.kernel._fireResolve('q1', { item0: 'yes' }); + expect(changes).toBe(1); + expect(resolved).toEqual(['q1']); + + offChange(); + offResolve(); + fx.kernel._fireChange(); + fx.kernel._fireResolve('q2', null); + expect(changes).toBe(1); + expect(resolved).toEqual(['q1']); + }); +}); diff --git a/apps/kimi-code/test/core/transcript.test.ts b/apps/kimi-code/test/core/transcript.test.ts new file mode 100644 index 0000000000..5f45da083e --- /dev/null +++ b/apps/kimi-code/test/core/transcript.test.ts @@ -0,0 +1,374 @@ +// Tests the facade transcript fold (wire records → rendering entries) as a +// pure reduce: no DI, no engine bootstrap. Records are shaped like the +// persisted wire log (`{ type, ...payload, time }`); fixtures mirror +// agent-core-v2's contextTranscript tests but assert the rendering +// projection (full history, compaction cards, replay records) instead of +// the model-facing context view. +import { describe, expect, it } from 'vitest'; + +import { COMPACTION_SUMMARY_PREFIX } from '#/core/index'; +import { + reduceTranscript, + rehydrateTranscript, + type TranscriptEntry, +} from '../../src/core/transcript'; + +function appendMessage( + message: Record, + time = 1, +): Record { + return { type: 'context.append_message', message, time }; +} + +function userMessage(text: string, origin?: Record): Record { + return { + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + ...(origin !== undefined ? { origin } : {}), + }; +} + +function loopEvent(event: Record, time = 1): Record { + return { type: 'context.append_loop_event', event, time }; +} + +function entriesOf(records: readonly Record[]): readonly TranscriptEntry[] { + return reduceTranscript(records).entries; +} + +describe('reduceTranscript context append', () => { + it('maps append_message records into message entries verbatim', () => { + const entries = entriesOf([ + appendMessage(userMessage('hello')), + appendMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'hi there' }], + toolCalls: [], + }), + ]); + + expect(entries).toEqual([ + { type: 'message', message: userMessage('hello') }, + { + type: 'message', + message: { role: 'assistant', content: [{ type: 'text', text: 'hi there' }], toolCalls: [] }, + }, + ]); + }); + + it('ignores envelope fields and unknown record types', () => { + const entries = entriesOf([ + { type: 'turn.prompt', input: [{ type: 'text', text: 'hello' }], origin: { kind: 'user' }, time: 5 }, + { type: 'metadata', protocol_version: '1.0' }, + { nope: true }, + appendMessage(userMessage('hello')), + ]); + + expect(entries).toEqual([{ type: 'message', message: userMessage('hello') }]); + }); +}); + +describe('reduceTranscript loop fold', () => { + it('folds loop events into assistant and tool entries', () => { + const entries = entriesOf([ + appendMessage(userMessage('run ls')), + loopEvent({ type: 'step.begin', uuid: 's1' }), + loopEvent({ type: 'content.part', stepUuid: 's1', part: { type: 'text', text: 'Running ' } }), + loopEvent({ type: 'content.part', stepUuid: 's1', part: { type: 'text', text: 'ls.' } }), + loopEvent({ type: 'tool.call', stepUuid: 's1', toolCallId: 'tc1', name: 'Bash', args: { command: 'ls' } }), + loopEvent({ type: 'step.end', uuid: 's1' }), + loopEvent({ type: 'tool.result', toolCallId: 'tc1', result: { output: 'file.ts' } }), + ]); + + expect(entries).toEqual([ + { type: 'message', message: userMessage('run ls') }, + { + type: 'message', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'Running ' }, + { type: 'text', text: 'ls.' }, + ], + toolCalls: [ + { type: 'function', id: 'tc1', name: 'Bash', arguments: JSON.stringify({ command: 'ls' }) }, + ], + }, + }, + { + type: 'message', + message: { + role: 'tool', + content: [{ type: 'text', text: 'file.ts' }], + toolCalls: [], + toolCallId: 'tc1', + isError: undefined, + }, + }, + ]); + }); + + it('closes an interrupted tool exchange with a placeholder result on the next step', () => { + const entries = entriesOf([ + loopEvent({ type: 'step.begin', uuid: 's1' }), + loopEvent({ type: 'tool.call', stepUuid: 's1', toolCallId: 'tc1', name: 'Bash', args: {} }), + loopEvent({ type: 'step.end', uuid: 's1' }), + loopEvent({ type: 'step.begin', uuid: 's2' }), + ]); + + const toolEntry = entries[1]; + expect(toolEntry).toMatchObject({ + type: 'message', + message: { + role: 'tool', + toolCallId: 'tc1', + isError: true, + }, + }); + const content = toolEntry?.type === 'message' ? toolEntry.message.content : []; + expect(content[0]).toMatchObject({ type: 'text', text: expect.stringContaining('interrupted') }); + }); + + it('defers messages appended while a tool exchange is open', () => { + const entries = entriesOf([ + loopEvent({ type: 'step.begin', uuid: 's1' }), + loopEvent({ type: 'tool.call', stepUuid: 's1', toolCallId: 'tc1', name: 'Bash', args: {} }), + appendMessage(userMessage('meanwhile')), + loopEvent({ type: 'tool.result', toolCallId: 'tc1', result: { output: 'ok' } }), + ]); + + expect(entries.map((e) => e.type)).toEqual(['message', 'message', 'message']); + const roles = entries.map((e) => (e.type === 'message' ? e.message.role : e.type)); + expect(roles).toEqual(['assistant', 'tool', 'user']); + }); +}); + +describe('reduceTranscript compaction', () => { + const compactionRecord = { + type: 'context.apply_compaction', + summary: 'Compacted history summary.', + contextSummary: `${COMPACTION_SUMMARY_PREFIX}\nCompacted history summary.`, + compactedCount: 2, + tokensBefore: 1000, + tokensAfter: 250, + time: 3, + }; + + it('keeps the full history and appends a compaction card with token counts', () => { + const entries = entriesOf([ + appendMessage(userMessage('before')), + appendMessage({ role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] }), + compactionRecord, + appendMessage(userMessage('after')), + ]); + + expect(entries).toEqual([ + { type: 'message', message: userMessage('before') }, + { + type: 'message', + message: { role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] }, + }, + { + type: 'compaction', + summary: 'Compacted history summary.', + tokensBefore: 1000, + tokensAfter: 250, + compactedCount: 2, + }, + { type: 'message', message: userMessage('after') }, + ]); + }); + + it('strips the summary prefix from legacy contextSummary-only records', () => { + const entries = entriesOf([ + { + type: 'context.apply_compaction', + contextSummary: `${COMPACTION_SUMMARY_PREFIX}\nLegacy summary.`, + compactedCount: 1, + tokensBefore: 10, + tokensAfter: 5, + }, + ]); + + expect(entries).toEqual([ + { + type: 'compaction', + summary: 'Legacy summary.', + tokensBefore: 10, + tokensAfter: 5, + compactedCount: 1, + }, + ]); + }); + + it('does not render full_compaction lifecycle ops (the card comes from apply_compaction)', () => { + const entries = entriesOf([ + { type: 'full_compaction.begin', instruction: undefined }, + { type: 'full_compaction.complete', summary: 'done', tokensBefore: 10, tokensAfter: 5 }, + { type: 'full_compaction.cancel' }, + ]); + + expect(entries).toEqual([]); + }); +}); + +describe('reduceTranscript undo and clear', () => { + it('removes trailing messages up to N real user prompts, skipping injections', () => { + const entries = entriesOf([ + appendMessage(userMessage('one')), + appendMessage({ role: 'assistant', content: [{ type: 'text', text: 'a1' }], toolCalls: [] }), + appendMessage(userMessage('injected', { kind: 'injection', variant: 'x' })), + appendMessage(userMessage('two')), + appendMessage({ role: 'assistant', content: [{ type: 'text', text: 'a2' }], toolCalls: [] }), + { type: 'context.undo', count: 1, time: 9 }, + ]); + + expect(entries).toEqual([ + { type: 'message', message: userMessage('one') }, + { + type: 'message', + message: { role: 'assistant', content: [{ type: 'text', text: 'a1' }], toolCalls: [] }, + }, + { type: 'message', message: userMessage('injected', { kind: 'injection', variant: 'x' }) }, + ]); + }); + + it('keeps non-message entries across an undo', () => { + const entries = entriesOf([ + { type: 'permission.set_mode', mode: 'yolo', time: 2 }, + appendMessage(userMessage('one')), + { type: 'context.undo', count: 1, time: 3 }, + ]); + + expect(entries).toEqual([{ type: 'permission_updated', mode: 'yolo' }]); + }); + + it('stops the undo at a compaction boundary', () => { + const entries = entriesOf([ + appendMessage(userMessage('before')), + { + type: 'context.apply_compaction', + summary: 's', + compactedCount: 1, + tokensBefore: 10, + tokensAfter: 5, + }, + appendMessage(userMessage('after')), + { type: 'context.undo', count: 2, time: 9 }, + ]); + + // Only one real user prompt exists after the boundary; the compaction + // card and everything before it survive. + expect(entries).toEqual([ + { type: 'message', message: userMessage('before') }, + { + type: 'compaction', + summary: 's', + tokensBefore: 10, + tokensAfter: 5, + compactedCount: 1, + }, + ]); + }); + + it('clear keeps prior entries but fences off later undos', () => { + const entries = entriesOf([ + appendMessage(userMessage('old')), + { type: 'context.clear', time: 2 }, + appendMessage(userMessage('new')), + { type: 'context.undo', count: 2, time: 3 }, + ]); + + // The undo may not cross the clear floor, so only the post-clear prompt + // is removed. + expect(entries).toEqual([{ type: 'message', message: userMessage('old') }]); + }); +}); + +describe('reduceTranscript domain ops', () => { + it('maps goal lifecycle ops to goal_updated entries', () => { + const entries = entriesOf([ + { type: 'goal.create', goalId: 'g1', objective: 'Ship it', completionCriterion: 'tests pass' }, + { type: 'goal.update', status: 'paused', reason: 'needs input', turnsUsed: 2, tokensUsed: 100, wallClockMs: 5000, actor: 'model' }, + { type: 'goal.update', status: 'complete', reason: 'done', turnsUsed: 3, tokensUsed: 150, wallClockMs: 7000, actor: 'model' }, + { type: 'goal.clear' }, + ]); + + expect(entries).toHaveLength(3); + expect(entries[0]).toMatchObject({ + type: 'goal_updated', + change: { kind: 'created' }, + snapshot: { goalId: 'g1', objective: 'Ship it', status: 'active' }, + }); + expect(entries[1]).toMatchObject({ + type: 'goal_updated', + change: { kind: 'lifecycle', status: 'paused', reason: 'needs input', actor: 'model' }, + snapshot: { status: 'paused', turnsUsed: 2 }, + }); + expect(entries[2]).toMatchObject({ + type: 'goal_updated', + change: { kind: 'completion', status: 'complete', actor: 'model' }, + snapshot: { status: 'complete', turnsUsed: 3 }, + }); + // goal.clear produces no entry (no card UI for it). + }); + + it('maps plan, permission, approval and config ops', () => { + const approval = { + request: { toolName: 'Bash' }, + result: { decision: 'approved', scope: 'session' }, + }; + const entries = entriesOf([ + { type: 'plan_mode.enter', planId: 'p1' }, + { type: 'plan_mode.exit', planId: 'p1' }, + { type: 'plan_mode.enter', planId: 'p2' }, + { type: 'plan_mode.cancel', planId: 'p2' }, + { type: 'permission.set_mode', mode: 'yolo' }, + { type: 'permission.record_approval_result', ...approval }, + { type: 'config.update', modelAlias: 'kimi-latest', thinkingEffort: 'high' }, + ]); + + expect(entries).toEqual([ + { type: 'plan_updated', enabled: true }, + { type: 'plan_updated', enabled: false }, + { type: 'plan_updated', enabled: true }, + { type: 'plan_updated', enabled: false }, + { type: 'permission_updated', mode: 'yolo' }, + { type: 'approval_result', record: approval }, + { + type: 'config_updated', + config: { modelAlias: 'kimi-latest', thinkingEffort: 'high' }, + }, + ]); + }); +}); + +describe('rehydrateTranscript', () => { + it('loads blob-referenced parts back to inline content', async () => { + const blobRef = { type: 'text', text: 'blobref:abc123' }; + const entries = entriesOf([ + appendMessage({ role: 'user', content: [blobRef], toolCalls: [] }), + { type: 'permission.set_mode', mode: 'yolo' }, + ]); + + const loader = { + async loadParts(parts: readonly unknown[]) { + return parts.map((part) => + part === blobRef ? { type: 'text', text: 'inline-content' } : part, + ); + }, + }; + const rehydrated = await rehydrateTranscript(entries, loader as never); + + expect(rehydrated).toHaveLength(2); + const first = rehydrated[0]; + expect(first?.type).toBe('message'); + expect(first?.type === 'message' && first.message.content[0]).toEqual({ + type: 'text', + text: 'inline-content', + }); + // Non-message entries pass through untouched. + expect(rehydrated[1]).toBe(entries[1]); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/add-dir.test.ts b/apps/kimi-code/test/tui/commands/add-dir.test.ts index 0c3381a870..55b01b5954 100644 --- a/apps/kimi-code/test/tui/commands/add-dir.test.ts +++ b/apps/kimi-code/test/tui/commands/add-dir.test.ts @@ -28,11 +28,11 @@ function makeHost(additionalDirs: readonly string[] = []) { summary: { additionalDirs, }, - addAdditionalDir: vi.fn(async (path: string, options: { persist: boolean }) => ({ + addAdditionalDir: vi.fn(async ({ path, persist }: { path: string; persist?: boolean }) => ({ additionalDirs: [...additionalDirs, path], projectRoot: '/repo', configPath: '/repo/.kimi-code/local.toml', - persisted: options.persist, + persisted: persist === true, })), }; const host = { @@ -120,7 +120,7 @@ describe('handleAddDirCommand', () => { getMountedPanel()?.handleInput(' '); await vi.waitFor(() => { - expect(session.addAdditionalDir).toHaveBeenCalledWith('../shared', { persist: false }); + expect(session.addAdditionalDir).toHaveBeenCalledWith({ path: '../shared', persist: false }); }); expect(host.restoreEditor).toHaveBeenCalledOnce(); expect(host.setAppState).toHaveBeenCalledWith({ @@ -144,7 +144,7 @@ describe('handleAddDirCommand', () => { getMountedPanel()?.handleInput(' '); await vi.waitFor(() => { - expect(session.addAdditionalDir).toHaveBeenCalledWith('../shared', { persist: true }); + expect(session.addAdditionalDir).toHaveBeenCalledWith({ path: '../shared', persist: true }); }); await vi.waitFor(() => { expect(host.showStatus).toHaveBeenCalledWith( diff --git a/apps/kimi-code/test/tui/commands/config.test.ts b/apps/kimi-code/test/tui/commands/config.test.ts new file mode 100644 index 0000000000..3d9ff90553 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/config.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { handlePlanCommand } from '#/tui/commands/config'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; + +function makeHost(opts: { planMode?: boolean; hasSession?: boolean } = {}) { + const session = { + setPlanMode: vi.fn(async () => {}), + clearPlan: vi.fn(async () => {}), + getPlan: vi.fn(async () => null), + }; + const hasSession = opts.hasSession ?? true; + const host = { + state: { appState: { planMode: opts.planMode ?? false } }, + session: hasSession ? session : undefined, + setAppState: vi.fn((patch: Record) => + Object.assign(host.state.appState, patch), + ), + showNotice: vi.fn(), + showError: vi.fn(), + } as unknown as SlashCommandHost; + return { host, session }; +} + +describe('handlePlanCommand', () => { + it('does not re-enter plan mode when already on', async () => { + const { host, session } = makeHost({ planMode: true }); + + await handlePlanCommand(host, 'on'); + + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.showNotice).toHaveBeenCalledWith('Plan mode is already on'); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('does not leave plan mode when already off', async () => { + const { host, session } = makeHost({ planMode: false }); + + await handlePlanCommand(host, 'off'); + + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.showNotice).toHaveBeenCalledWith('Plan mode is already off'); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('turns plan mode on when off', async () => { + const { host, session } = makeHost({ planMode: false }); + + await handlePlanCommand(host, 'on'); + + expect(session.setPlanMode).toHaveBeenCalledWith(true); + expect(host.setAppState).toHaveBeenCalledWith({ planMode: true }); + expect(host.showNotice).toHaveBeenCalledWith('Plan mode: ON', undefined); + }); + + it('turns plan mode off when on', async () => { + const { host, session } = makeHost({ planMode: true }); + + await handlePlanCommand(host, 'off'); + + expect(session.setPlanMode).toHaveBeenCalledWith(false); + expect(host.setAppState).toHaveBeenCalledWith({ planMode: false }); + expect(host.showNotice).toHaveBeenCalledWith('Plan mode: OFF'); + }); + + it('toggles plan mode on with no args', async () => { + const { host, session } = makeHost({ planMode: false }); + + await handlePlanCommand(host, ''); + + expect(session.setPlanMode).toHaveBeenCalledWith(true); + }); + + it('toggles plan mode off with no args', async () => { + const { host, session } = makeHost({ planMode: true }); + + await handlePlanCommand(host, ''); + + expect(session.setPlanMode).toHaveBeenCalledWith(false); + }); + + it('clears the plan', async () => { + const { host, session } = makeHost({ planMode: true }); + + await handlePlanCommand(host, 'clear'); + + expect(session.clearPlan).toHaveBeenCalledOnce(); + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(host.showNotice).toHaveBeenCalledWith('Plan cleared'); + }); + + it('rejects an unknown subcommand', async () => { + const { host, session } = makeHost(); + + await handlePlanCommand(host, 'bogus'); + + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(host.showError).toHaveBeenCalledWith('Unknown plan subcommand: bogus'); + }); + + it('shows an error when there is no active session', async () => { + const { host, session } = makeHost({ hasSession: false }); + + await handlePlanCommand(host, 'on'); + + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(host.showError).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/experiments.test.ts b/apps/kimi-code/test/tui/commands/experiments.test.ts index 89bf62da6c..c173ca20d7 100644 --- a/apps/kimi-code/test/tui/commands/experiments.test.ts +++ b/apps/kimi-code/test/tui/commands/experiments.test.ts @@ -30,7 +30,6 @@ function feature( function makeHost() { const session = { id: 'ses-experiments', - reloadSession: vi.fn(async () => ({})), }; const host = { state: { @@ -42,6 +41,7 @@ function makeHost() { getExperimentalFeatures: vi.fn(async () => [ feature({ enabled: false, source: 'config', configValue: false }), ]), + reloadSession: vi.fn(async () => session), }, session, refreshSlashCommandAutocomplete: vi.fn(), @@ -87,7 +87,7 @@ describe('experimental feature command handlers', () => { expect(isExperimentalFlagEnabled('micro_compaction')).toBe(false); expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalled(); expect(host.restoreEditor).toHaveBeenCalled(); - expect(host.session.reloadSession).toHaveBeenCalledOnce(); + expect(host.harness.reloadSession).toHaveBeenCalledOnce(); expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( host.session, 'Experimental features updated. Session reloaded.', diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index ea59d4fae2..672a308cb7 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -16,6 +16,7 @@ import { updateGoalQueueItem, } from '#/tui/goal-queue-store'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { createGoal } from '#/tui/commands/goal'; import { getBuiltInPalette } from '#/tui/theme'; vi.mock('#/tui/goal-queue-store', () => ({ @@ -266,6 +267,138 @@ describe('handleGoalCommand', () => { expect(calls).toEqual([{ receiver: host, text: 'Ship feature X' }]); }); + it.each(['manual', 'yolo'] as const)( + 'waits for the %s permission choice before resolving goal creation', + async (permissionMode) => { + const { host: promptHost, session: s } = makeHost({ permissionMode }); + const result = createGoal( + promptHost, + { kind: 'create', objective: 'Ship queued goal', replace: false }, + 'Ship queued goal', + ); + let settled = false; + void result.finally(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + expect(s.createGoal).not.toHaveBeenCalled(); + + mountedPicker(promptHost).handleInput(ENTER); + + await expect(result).resolves.toBe(true); + expect(s.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + }, + ); + + it('resolves false when mounting the permission prompt throws', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + vi.mocked(manualHost.mountEditorReplacement).mockImplementationOnce(() => { + throw new Error('mount failed'); + }); + + await expect( + createGoal( + manualHost, + { kind: 'create', objective: 'Ship queued goal', replace: false }, + 'Ship queued goal', + ), + ).resolves.toBe(false); + + expect(manualHost.showError).toHaveBeenCalledWith('mount failed'); + expect(s.createGoal).not.toHaveBeenCalled(); + }); + + it('resolves false when another editor replacement disposes the permission prompt', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + let mounted: { dispose?: () => void } | undefined; + vi.mocked(manualHost.mountEditorReplacement).mockImplementation((panel) => { + mounted?.dispose?.(); + mounted = panel as { dispose?: () => void }; + }); + const result = createGoal( + manualHost, + { kind: 'create', objective: 'Ship queued goal', replace: false }, + 'Ship queued goal', + ); + + manualHost.mountEditorReplacement({} as never); + + await expect(result).resolves.toBe(false); + expect(s.createGoal).not.toHaveBeenCalled(); + }); + + it('does not continue a permission-selected goal on a replacement session', async () => { + const { host: manualHost, session: oldSession } = makeHost({ permissionMode: 'manual' }); + let releasePermission!: () => void; + oldSession.setPermission.mockImplementationOnce( + () => new Promise((resolve) => (releasePermission = resolve)), + ); + const result = createGoal( + manualHost, + { kind: 'create', objective: 'Ship queued goal', replace: false }, + 'Ship queued goal', + ); + mountedPicker(manualHost).handleInput(ENTER); + await vi.waitFor(() => { + expect(oldSession.setPermission).toHaveBeenCalledWith('auto'); + }); + + const newSession = { + ...oldSession, + setPermission: vi.fn(async () => {}), + createGoal: vi.fn(async () => fakeSnapshot()), + cancelGoal: vi.fn(async () => fakeSnapshot()), + }; + manualHost.session = newSession as never; + releasePermission(); + + await expect(result).resolves.toBe(false); + expect(oldSession.setPermission).toHaveBeenNthCalledWith(1, 'auto'); + expect(oldSession.setPermission).toHaveBeenNthCalledWith(2, 'manual'); + expect(oldSession.createGoal).not.toHaveBeenCalled(); + expect(newSession.setPermission).not.toHaveBeenCalled(); + expect(newSession.createGoal).not.toHaveBeenCalled(); + expect(newSession.cancelGoal).not.toHaveBeenCalled(); + expect(manualHost.setAppState).not.toHaveBeenCalledWith({ permissionMode: 'auto' }); + }); + + it('resolves false when sending goal input throws after creation', async () => { + const { host: promptHost, session: s } = makeHost(); + vi.mocked(promptHost.sendNormalUserInput).mockImplementationOnce(() => { + throw new Error('send failed'); + }); + + await expect( + createGoal( + promptHost, + { kind: 'create', objective: 'Ship queued goal', replace: false }, + 'Ship queued goal', + ), + ).resolves.toBe(false); + + expect(s.createGoal).toHaveBeenCalledOnce(); + expect(promptHost.showError).toHaveBeenCalledWith('send failed'); + }); + + it('resolves false when the queued-goal permission prompt is cancelled', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + const result = createGoal( + manualHost, + { kind: 'create', objective: 'Ship queued goal', replace: false }, + 'Ship queued goal', + ); + + mountedPicker(manualHost).handleInput(ESCAPE); + + await expect(result).resolves.toBe(false); + expect(s.createGoal).not.toHaveBeenCalled(); + }); + it('asks before starting a goal in Manual mode', async () => { const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index 77f582a1b9..23dcae7a8e 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -45,14 +45,14 @@ notification_condition = "always" [upgrade] auto_install = false `); - const session = { reloadSession: vi.fn() }; + const session = { id: 'ses-1' }; const host = makeHost({ session }); await handleReloadTuiCommand(host); expect(host.harness.getConfig).not.toHaveBeenCalled(); expect(host.harness.getExperimentalFeatures).not.toHaveBeenCalled(); - expect(session.reloadSession).not.toHaveBeenCalled(); + expect(host.harness.reloadSession).not.toHaveBeenCalled(); expect(host.state.appState).toMatchObject({ theme: 'light', editorCommand: 'vim', @@ -67,12 +67,13 @@ auto_install = false it('reloads the active session, refreshes runtime config, and applies tui.toml', async () => { await writeTuiConfig('theme = "light"\n'); - const session = { id: 'ses-1', reloadSession: vi.fn(async () => ({})) }; + const session = { id: 'ses-1' }; const host = makeHost({ session }); await handleReloadCommand(host); - expect(session.reloadSession).toHaveBeenCalledWith({ + expect(host.harness.reloadSession).toHaveBeenCalledWith({ + id: 'ses-1', forcePluginSessionStartReminder: true, }); expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( @@ -159,6 +160,7 @@ function makeHost({ }, })), getExperimentalFeatures: vi.fn(async () => [{ id: 'micro_compaction', enabled: true }]), + reloadSession: vi.fn(async () => session), }, setAppState: vi.fn((patch: Record) => { Object.assign(state.appState, patch); diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index 3e3c9b11a3..00baf4920b 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -80,7 +80,7 @@ describe('handleSwarmCommand', () => { await handleSwarmCommand(host, 'Ship feature X'); expect(session.setPermission).not.toHaveBeenCalled(); - expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, { trigger: 'task' }); expect(host.state.swarmModeEntry).toBe('task'); expectSwarmMarker(host, 'Swarm activated'); expect(host.mountEditorReplacement).not.toHaveBeenCalled(); @@ -103,7 +103,7 @@ describe('handleSwarmCommand', () => { await handleSwarmCommand(host, 'on'); - expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'manual'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, { trigger: 'manual' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); expect(host.state.swarmModeEntry).toBe('manual'); expectSwarmMarker(host, 'Swarm activated'); @@ -126,7 +126,7 @@ describe('handleSwarmCommand', () => { mountedPicker(host).handleInput(ENTER); await vi.waitFor(() => { - expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'manual'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, { trigger: 'manual' }); }); expect(session.setPermission).toHaveBeenCalledWith('auto'); expect(session.setSwarmMode).toHaveBeenCalledTimes(1); @@ -142,7 +142,7 @@ describe('handleSwarmCommand', () => { await handleSwarmCommand(host, ''); - expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'manual'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, { trigger: 'manual' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); expect(host.state.swarmModeEntry).toBe('manual'); expectSwarmMarker(host, 'Swarm activated'); @@ -168,7 +168,7 @@ describe('handleSwarmCommand', () => { await handleSwarmCommand(host, 'off'); - expect(session.setSwarmMode).toHaveBeenCalledWith(false, 'manual'); + expect(session.setSwarmMode).toHaveBeenCalledWith(false, { trigger: 'manual' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: false }); expect(host.state.swarmModeEntry).toBeUndefined(); expectSwarmMarker(host, 'Swarm deactivated'); @@ -181,7 +181,7 @@ describe('handleSwarmCommand', () => { await handleSwarmCommand(host, ''); - expect(session.setSwarmMode).toHaveBeenCalledWith(false, 'manual'); + expect(session.setSwarmMode).toHaveBeenCalledWith(false, { trigger: 'manual' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: false }); expect(host.state.swarmModeEntry).toBeUndefined(); expectSwarmMarker(host, 'Swarm deactivated'); @@ -228,7 +228,7 @@ describe('handleSwarmCommand', () => { expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); }); expect(session.setPermission).toHaveBeenCalledWith('auto'); - expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, { trigger: 'task' }); expect(session.setSwarmMode).toHaveBeenCalledTimes(1); expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); @@ -249,7 +249,7 @@ describe('handleSwarmCommand', () => { expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); }); expect(session.setPermission).not.toHaveBeenCalled(); - expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, { trigger: 'task' }); expect(session.setSwarmMode).toHaveBeenCalledTimes(1); expect(host.state.swarmModeEntry).toBe('task'); expectSwarmMarker(host, 'Swarm activated'); @@ -267,7 +267,7 @@ describe('handleSwarmCommand', () => { expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); }); expect(session.setPermission).toHaveBeenCalledWith('yolo'); - expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, { trigger: 'task' }); expect(session.setSwarmMode).toHaveBeenCalledTimes(1); expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); @@ -319,7 +319,7 @@ describe('handleSwarmCommand', () => { ); }); expect(session.setPermission).toHaveBeenCalledWith('auto'); - expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, { trigger: 'task' }); expect(markerAddChild(host)).not.toHaveBeenCalled(); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts index d02df500eb..e0bba10302 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts @@ -6,7 +6,7 @@ import type { DiffDisplayBlock, FileContentDisplayBlock, PendingApproval, -} from '#/tui/reverse-rpc/types'; +} from '#/tui/interactions/types'; import { captureProcessWrite } from '../../../helpers/process'; diff --git a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts index 812ac0d94b..58394cd302 100644 --- a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts @@ -3,7 +3,7 @@ import chalk from 'chalk'; import { beforeAll, describe, expect, it } from 'vitest'; import { QuestionDialogComponent } from '#/tui/components/dialogs/question-dialog'; -import type { PendingQuestion } from '#/tui/reverse-rpc/types'; +import type { PendingQuestion } from '#/tui/interactions/types'; import { currentTheme } from '#/tui/theme'; function strip(text: string): string { diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 8b9d5fbdfc..e1127f362d 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, beforeEach, vi } from 'vitest'; import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { GoalQueuePromoter } from '#/tui/goal-queue-promoter'; import { getBuiltInPalette } from '#/tui/theme'; import { readGoalQueue, removeGoalQueueItem, restoreGoalQueueItem } from '#/tui/goal-queue-store'; @@ -37,8 +38,15 @@ function fakeGoalSnapshot(objective: string, status: 'active' | 'blocked' | 'pau }; } -function makeHost(options: { createGoalRejects?: boolean } = {}) { +function makeHost( + options: { + createGoalRejects?: boolean; + permissionMode?: 'auto' | 'manual' | 'yolo'; + } = {}, +) { + const transcriptContainer = { addChild: vi.fn() }; const session = { + setPermission: vi.fn(async () => {}), createGoal: vi.fn(async () => { if (options.createGoalRejects === true) throw new Error('create failed'); return fakeGoalSnapshot('Ship queued goal', 'active'); @@ -51,14 +59,15 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { sessionId: 's1', streamingPhase: 'waiting', model: 'kimi-model', - permissionMode: 'auto', + permissionMode: options.permissionMode ?? 'auto', }, queuedMessages: [], queuedMessageDispatchPending: false, + queuedMessageDispatchGeneration: 0, theme: { palette: getBuiltInPalette('dark') }, toolOutputExpanded: false, todoPanel: { getTodos: vi.fn(() => []) }, - transcriptContainer: { addChild: vi.fn() }, + transcriptContainer, ui: { requestRender: vi.fn() }, }, session, @@ -92,6 +101,9 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { appendTranscriptEntry: vi.fn(), sendNormalUserInput: vi.fn(), sendQueuedMessage: vi.fn(), + sendQueuedGoalMessage: vi.fn(async (_session, _item, confirmation) => { + transcriptContainer.addChild(confirmation); + }), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, tasksBrowserController: {}, @@ -149,6 +161,7 @@ function compactionCompletedEvent() { type: 'compaction.completed', sessionId: 's1', agentId: 'main', + trigger: 'manual', result: { summary: 'summary', tokensBefore: 100, @@ -175,9 +188,13 @@ function addedTranscriptText(host: ReturnType['host']): string describe('SessionEventHandler goal queue promotion', () => { beforeEach(() => { - vi.mocked(readGoalQueue).mockClear(); - vi.mocked(removeGoalQueueItem).mockClear(); - vi.mocked(restoreGoalQueueItem).mockClear(); + vi.mocked(readGoalQueue).mockReset().mockResolvedValue({ + goals: [{ id: 'q1', objective: 'Ship queued goal', createdAt: '', updatedAt: '' }], + }); + vi.mocked(removeGoalQueueItem).mockReset().mockResolvedValue({ goals: [] }); + vi.mocked(restoreGoalQueueItem).mockReset().mockResolvedValue({ + goals: [{ id: 'q1', objective: 'Ship queued goal', createdAt: '', updatedAt: '' }], + }); }); it('starts the next queued goal after the completion turn ends', async () => { @@ -200,12 +217,97 @@ describe('SessionEventHandler goal queue promotion', () => { }); }); expect(removeGoalQueueItem).toHaveBeenCalledWith(session, { goalId: 'q1' }); - expect(host.sendQueuedMessage).toHaveBeenCalledWith(session, { - text: 'Ship queued goal', - }); + expect(host.sendQueuedGoalMessage).toHaveBeenCalledWith( + session, + { text: 'Ship queued goal' }, + expect.anything(), + ); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); + it.each(['manual', 'yolo'] as const)( + 'promotes only the first of two queued goals after the %s permission choice', + async (permissionMode) => { + const secondGoal = { + id: 'q2', + objective: 'Second queued goal', + createdAt: '', + updatedAt: '', + }; + vi.mocked(readGoalQueue) + .mockResolvedValueOnce({ + goals: [ + { id: 'q1', objective: 'First queued goal', createdAt: '', updatedAt: '' }, + secondGoal, + ], + }) + .mockResolvedValue({ goals: [secondGoal] }); + vi.mocked(removeGoalQueueItem).mockResolvedValue({ goals: [secondGoal] }); + const { host, session } = makeHost({ permissionMode }); + const handler = new SessionEventHandler(host); + const sendQueued = sendQueuedViaHost(host, session); + + handler.handleEvent(completionEvent(), sendQueued); + handler.handleEvent(clearedEvent(), sendQueued); + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + + const panel = host.mountEditorReplacement.mock.calls[0]?.[0] as { + handleInput(data: string): void; + }; + panel.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'First queued goal', + replace: false, + }); + }); + expect(removeGoalQueueItem).toHaveBeenCalledWith(session, { goalId: 'q1' }); + expect(host.sendQueuedGoalMessage).toHaveBeenCalledWith( + session, + { text: 'First queued goal' }, + expect.anything(), + ); + + host.setAppState({ streamingPhase: 'idle' }); + handler.handleEvent(turnEndedEvent(), sendQueued); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(session.createGoal).toHaveBeenCalledOnce(); + expect(removeGoalQueueItem).toHaveBeenCalledOnce(); + expect(host.sendQueuedGoalMessage).toHaveBeenCalledOnce(); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + }, + ); + + it('keeps the queued goal when its permission prompt is cancelled', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + const handler = new SessionEventHandler(host); + const sendQueued = sendQueuedViaHost(host, session); + + handler.handleEvent(completionEvent(), sendQueued); + handler.handleEvent(clearedEvent(), sendQueued); + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + }); + const panel = host.mountEditorReplacement.mock.calls[0]?.[0] as { + handleInput(data: string): void; + }; + panel.handleInput('\u001B'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(session.createGoal).not.toHaveBeenCalled(); + expect(removeGoalQueueItem).not.toHaveBeenCalled(); + expect(host.sendQueuedMessage).not.toHaveBeenCalled(); + }); + it('waits for queued user input to drain before promoting the next queued goal', async () => { const { host, session } = makeHost(); host.state.queuedMessages = [{ text: 'queued user turn' }]; @@ -250,7 +352,11 @@ describe('SessionEventHandler goal queue promotion', () => { replace: false, }); }); - expect(host.sendQueuedMessage).toHaveBeenLastCalledWith(session, { text: 'Ship queued goal' }); + expect(host.sendQueuedGoalMessage).toHaveBeenCalledWith( + session, + { text: 'Ship queued goal' }, + expect.anything(), + ); }); it('defers queued-goal promotion while a queued message is mid-dispatch', async () => { @@ -317,7 +423,11 @@ describe('SessionEventHandler goal queue promotion', () => { ([, item]) => item.text === 'queued user turn', ); expect(userMessageIndex).toBeGreaterThanOrEqual(0); - expect(host.sendQueuedMessage).toHaveBeenLastCalledWith(session, { text: 'Ship queued goal' }); + expect(host.sendQueuedGoalMessage).toHaveBeenCalledWith( + session, + { text: 'Ship queued goal' }, + expect.anything(), + ); const userMessageOrder = host.sendQueuedMessage.mock.invocationCallOrder[userMessageIndex]!; const goalCreateOrder = session.createGoal.mock.invocationCallOrder[0]!; expect(userMessageOrder).toBeLessThan(goalCreateOrder); @@ -362,7 +472,11 @@ describe('SessionEventHandler goal queue promotion', () => { expect(session.createGoal).toHaveBeenCalledTimes(2); }); expect(removeGoalQueueItem).toHaveBeenCalledWith(session, { goalId: 'q1' }); - expect(host.sendQueuedMessage).toHaveBeenCalledWith(session, { text: 'Ship queued goal' }); + expect(host.sendQueuedGoalMessage).toHaveBeenCalledWith( + session, + { text: 'Ship queued goal' }, + expect.anything(), + ); }); it('does not send the queued objective when removal fails after goal creation', async () => { @@ -387,6 +501,97 @@ describe('SessionEventHandler goal queue promotion', () => { expect(host.sendQueuedMessage).not.toHaveBeenCalled(); }); + it('restores and cancels a queued goal when its awaited send rejects', async () => { + const { host, session } = makeHost(); + host.sendQueuedGoalMessage.mockRejectedValueOnce(new Error('send failed')); + const handler = new SessionEventHandler(host); + + handler.handleEvent(completionEvent(), vi.fn()); + handler.handleEvent(clearedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), sendQueuedViaHost(host, session)); + + await vi.waitFor(() => { + expect(restoreGoalQueueItem).toHaveBeenCalledWith(session, { + id: 'q1', + objective: 'Ship queued goal', + createdAt: '', + updatedAt: '', + }); + }); + expect(session.cancelGoal).toHaveBeenCalledOnce(); + expect(host.showError).toHaveBeenCalledWith('send failed'); + + handler.handleEvent(turnEndedEvent(), sendQueuedViaHost(host, session)); + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledTimes(2); + }); + expect(host.sendQueuedGoalMessage).toHaveBeenCalledTimes(2); + expect(host.state.transcriptContainer.addChild).toHaveBeenCalledTimes(1); + }); + + it('keeps a new in-flight promotion gated when an old generation settles', async () => { + vi.useFakeTimers(); + try { + const { host } = makeHost(); + host.state.appState.streamingPhase = 'idle'; + const promoter = new GoalQueuePromoter(host); + let settleOld!: (complete: boolean) => void; + let settleNew!: (complete: boolean) => void; + const oldPromotion = (): Promise => + new Promise((resolve) => (settleOld = resolve)); + const newPromotion = (): Promise => + new Promise((resolve) => (settleNew = resolve)); + const promoterWithPrivate = promoter as unknown as { + promote: () => Promise; + }; + const promote = vi + .spyOn(promoterWithPrivate, 'promote') + .mockImplementationOnce(oldPromotion) + .mockImplementationOnce(newPromotion); + + promoter.request(); + await vi.runAllTimersAsync(); + promoter.reset(); + promoter.request(); + await vi.runAllTimersAsync(); + expect(promote).toHaveBeenCalledTimes(2); + + settleOld(true); + await Promise.resolve(); + promoter.request(); + await vi.runAllTimersAsync(); + expect(promote).toHaveBeenCalledTimes(2); + + settleNew(true); + await Promise.resolve(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not busy-loop when an unexpected promotion error escapes', async () => { + vi.useFakeTimers(); + try { + const { host, session } = makeHost(); + host.state.appState.streamingPhase = 'idle'; + const promoter = new GoalQueuePromoter(host); + vi.spyOn(promoter as any, 'promote').mockRejectedValueOnce( + new Error('unexpected promotion failure'), + ); + + promoter.request(); + await vi.runAllTimersAsync(); + + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('unexpected promotion failure'), + ); + expect((promoter as any).promote).toHaveBeenCalledOnce(); + expect(session.createGoal).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it('restores the queued goal and cancels the started goal when the session changes before send', async () => { const { host, session } = makeHost(); vi.mocked(removeGoalQueueItem).mockImplementationOnce(async () => { diff --git a/apps/kimi-code/test/tui/export-markdown.test.ts b/apps/kimi-code/test/tui/export-markdown.test.ts index 05a6eb8adc..b2b56a54cc 100644 --- a/apps/kimi-code/test/tui/export-markdown.test.ts +++ b/apps/kimi-code/test/tui/export-markdown.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { ContentPart, ToolCall } from '@moonshot-ai/kimi-code-sdk'; -import type { ContextMessage, PromptOrigin } from '@moonshot-ai/kimi-code-sdk'; +import type { ContentPart, ContextMessage, PromptOrigin, ToolCall } from '#/core/index'; import { buildExportMarkdown, diff --git a/apps/kimi-code/test/tui/goal-queue-store.test.ts b/apps/kimi-code/test/tui/goal-queue-store.test.ts index d1efba07b3..1ff3106660 100644 --- a/apps/kimi-code/test/tui/goal-queue-store.test.ts +++ b/apps/kimi-code/test/tui/goal-queue-store.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ErrorCodes, KimiError } from '@moonshot-ai/kimi-code-sdk'; +import { CoreError, CoreErrorCodes } from '#/core/index'; import { appendGoalQueueItem, @@ -130,10 +130,10 @@ describe('goal queue store', () => { it('rejects empty and over-long objectives', async () => { await expect(appendGoalQueueItem(session(), { objective: ' ' })).rejects.toMatchObject({ - code: ErrorCodes.GOAL_OBJECTIVE_EMPTY, + code: CoreErrorCodes.GOAL_OBJECTIVE_EMPTY, }); await expect(appendGoalQueueItem(session(), { objective: 'x'.repeat(4001) })).rejects.toMatchObject({ - code: ErrorCodes.GOAL_OBJECTIVE_TOO_LONG, + code: CoreErrorCodes.GOAL_OBJECTIVE_TOO_LONG, }); }); @@ -162,10 +162,10 @@ describe('goal queue store', () => { it('throws a goal-not-found error when the target item is missing', async () => { await expect(removeGoalQueueItem(session(), { goalId: 'missing' })).rejects.toBeInstanceOf( - KimiError, + CoreError, ); await expect(removeGoalQueueItem(session(), { goalId: 'missing' })).rejects.toMatchObject({ - code: ErrorCodes.GOAL_NOT_FOUND, + code: CoreErrorCodes.GOAL_NOT_FOUND, }); }); }); diff --git a/apps/kimi-code/test/tui/input/image-placeholder.test.ts b/apps/kimi-code/test/tui/input/image-placeholder.test.ts index e157cbf8c8..b0e719b9a3 100644 --- a/apps/kimi-code/test/tui/input/image-placeholder.test.ts +++ b/apps/kimi-code/test/tui/input/image-placeholder.test.ts @@ -66,7 +66,7 @@ describe('extractMediaAttachments', () => { expect(r.imageAttachmentIds).toEqual([1]); expect(r.parts).toEqual([ { type: 'text', text: 'describe ' }, - { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + { type: 'image', source: { kind: 'base64', media_type: 'image/png', data: 'qrs=' } }, { type: 'text', text: ' please' }, ]); }); @@ -80,9 +80,9 @@ describe('extractMediaAttachments', () => { expect(r.imageAttachmentIds).toEqual([1, 2]); expect(r.parts).toEqual([ { type: 'text', text: 'first ' }, - { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AQ==' } }, + { type: 'image', source: { kind: 'base64', media_type: 'image/png', data: 'AQ==' } }, { type: 'text', text: ' then ' }, - { type: 'image_url', imageUrl: { url: 'data:image/png;base64,Ag==' } }, + { type: 'image', source: { kind: 'base64', media_type: 'image/png', data: 'Ag==' } }, { type: 'text', text: ' end' }, ]); }); @@ -102,8 +102,8 @@ describe('extractMediaAttachments', () => { expect(r.videoAttachmentIds).toEqual([2]); expect(r.parts[0]).toEqual({ type: 'text', text: 'first ' }); expect(r.parts[1]).toEqual({ - type: 'image_url', - imageUrl: { url: 'data:image/png;base64,AQ==' }, + type: 'image', + source: { kind: 'base64', media_type: 'image/png', data: 'AQ==' }, }); const cachePath = videoPathFromParts(r.parts); expect(cachePath.startsWith(getCacheDir())).toBe(true); @@ -127,8 +127,8 @@ describe('extractMediaAttachments', () => { const r = extractMediaAttachments(placeholder, store); expect(r.parts).toHaveLength(1); expect(r.parts[0]).toEqual({ - type: 'image_url', - imageUrl: { url: 'data:image/png;base64,iVBORw==' }, + type: 'image', + source: { kind: 'base64', media_type: 'image/png', data: 'iVBORw==' }, }); }); @@ -192,8 +192,8 @@ describe('extractMediaAttachments', () => { expect(caption.text).toContain('2600x2600'); expect(caption.text).toContain('/tmp/kimi-code-original-images/abc.png'); expect(r.parts[1]).toEqual({ - type: 'image_url', - imageUrl: { url: 'data:image/png;base64,AQID' }, + type: 'image', + source: { kind: 'base64', media_type: 'image/png', data: 'AQID' }, }); }); @@ -218,6 +218,6 @@ describe('extractMediaAttachments', () => { const { store, placeholder } = storeWith(new Uint8Array([0xaa])); const r = extractMediaAttachments(placeholder, store); expect(r.parts).toHaveLength(1); - expect(r.parts[0]?.type).toBe('image_url'); + expect(r.parts[0]?.type).toBe('image'); }); }); diff --git a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts b/apps/kimi-code/test/tui/interactions/approval-adapter.test.ts similarity index 96% rename from apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts rename to apps/kimi-code/test/tui/interactions/approval-adapter.test.ts index cdc8709c36..4f6a2080b0 100644 --- a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts +++ b/apps/kimi-code/test/tui/interactions/approval-adapter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { adaptApprovalRequest, adaptPanelResponse } from '#/tui/reverse-rpc/approval/adapter'; +import { adaptApprovalRequest, adaptPanelResponse } from '#/tui/interactions/approval-adapter'; describe('approval adapter', () => { it('adapts generic command displays into shell blocks with approval choices', () => { @@ -316,4 +316,17 @@ describe('approval adapter', () => { selectedLabel: 'Approve for this session', }); }); + + it('uses an explicit pending id when one is supplied', () => { + const adapted = adaptApprovalRequest( + { + toolCallId: 'tc-1', + toolName: 'Bash', + action: 'run', + display: { kind: 'generic', summary: 'run' }, + }, + 'interaction-1', + ); + expect(adapted.id).toBe('interaction-1'); + }); }); diff --git a/apps/kimi-code/test/tui/interactions/approval-controller.test.ts b/apps/kimi-code/test/tui/interactions/approval-controller.test.ts new file mode 100644 index 0000000000..294b3b6e5a --- /dev/null +++ b/apps/kimi-code/test/tui/interactions/approval-controller.test.ts @@ -0,0 +1,178 @@ +import type { ApprovalResponse, CoreSession, PendingApproval } from '#/core/index'; +import { describe, expect, it, vi } from 'vitest'; + +import { ApprovalController } from '#/tui/interactions/approval-controller'; + +function pending(id: string, action: string): PendingApproval { + return { + id, + agentId: 'main', + request: { + toolCallId: id, + toolName: 'Bash', + action, + display: { kind: 'generic', summary: action }, + }, + }; +} + +function makeApprovalBroker(initial: readonly PendingApproval[] = []) { + const pendings = [...initial]; + const changeListeners = new Set<() => void>(); + const resolveListeners = new Set<(id: string) => void>(); + const remove = (id: string): void => { + const idx = pendings.findIndex((p) => p.id === id); + if (idx >= 0) pendings.splice(idx, 1); + }; + const decide = vi.fn((id: string, _response: ApprovalResponse) => { + remove(id); + for (const listener of resolveListeners) listener(id); + }); + return { + list: () => pendings, + onDidChangePending: (listener: () => void) => { + changeListeners.add(listener); + return () => changeListeners.delete(listener); + }, + onDidResolve: (listener: (id: string) => void) => { + resolveListeners.add(listener); + return () => resolveListeners.delete(listener); + }, + decide, + /** Simulate an out-of-band resolution (another client, policy). */ + externalResolve(id: string): void { + remove(id); + for (const listener of resolveListeners) listener(id); + }, + }; +} + +function sessionOf(broker: ReturnType): CoreSession { + return { approvals: broker } as unknown as CoreSession; +} + +describe('ApprovalController', () => { + it('presents a parked pending and writes the decision back', async () => { + const broker = makeApprovalBroker([pending('id-1', 'run command: ls')]); + const controller = new ApprovalController(); + const showPanel = vi.fn(); + const hidePanel = vi.fn(); + const onDecided = vi.fn(); + controller.setUIHooks({ showPanel, hidePanel }); + + controller.attach(sessionOf(broker), { onDecided }); + + expect(showPanel).toHaveBeenCalledWith(expect.objectContaining({ id: 'id-1' })); + + controller.respond({ decision: 'approved' }); + await vi.waitFor(() => { + expect(broker.decide).toHaveBeenCalledWith('id-1', { decision: 'approved' }); + }); + expect(onDecided).toHaveBeenCalledWith( + expect.objectContaining({ id: 'id-1' }), + expect.objectContaining({ decision: 'approved' }), + ); + expect(hidePanel).toHaveBeenCalledOnce(); + }); + + it('auto-approves queued same-action pendings when the current is approved for session', async () => { + const broker = makeApprovalBroker([ + pending('id-1', 'run command: ls'), + pending('id-2', 'run command: ls'), + pending('id-3', 'edit src/x.ts'), + pending('id-4', 'run command: ls'), + ]); + const controller = new ApprovalController(); + const showPanel = vi.fn(); + controller.setUIHooks({ showPanel, hidePanel: vi.fn() }); + + controller.attach(sessionOf(broker)); + expect(showPanel).toHaveBeenCalledWith(expect.objectContaining({ id: 'id-1' })); + + controller.respond({ decision: 'approved', scope: 'session', feedback: 'ok' }); + + await vi.waitFor(() => { + expect(broker.decide).toHaveBeenCalledWith('id-1', { + decision: 'approved', + scope: 'session', + feedback: 'ok', + }); + }); + // Queued same-action pendings inherit a session-scoped approval without + // surfacing another panel. The user's feedback is not carried over. + expect(broker.decide).toHaveBeenCalledWith('id-2', { decision: 'approved', scope: 'session' }); + expect(broker.decide).toHaveBeenCalledWith('id-4', { decision: 'approved', scope: 'session' }); + // A different-action pending still waits for an explicit decision. + expect(showPanel).toHaveBeenCalledWith(expect.objectContaining({ id: 'id-3' })); + expect(broker.decide).not.toHaveBeenCalledWith('id-3', expect.anything()); + }); + + it('does not auto-approve queued pendings when only approved-once is chosen', async () => { + const broker = makeApprovalBroker([pending('id-1', 'run'), pending('id-2', 'run')]); + const controller = new ApprovalController(); + const showPanel = vi.fn(); + controller.setUIHooks({ showPanel, hidePanel: vi.fn() }); + + controller.attach(sessionOf(broker)); + controller.respond({ decision: 'approved' }); + + await vi.waitFor(() => { + expect(broker.decide).toHaveBeenCalledWith('id-1', { decision: 'approved' }); + }); + // Approve-once is a one-shot decision; the second pending advances to its + // own panel turn. + expect(broker.decide).not.toHaveBeenCalledWith('id-2', expect.anything()); + expect(showPanel).toHaveBeenCalledWith(expect.objectContaining({ id: 'id-2' })); + }); + + it('retracts the panel when a pending is resolved externally', async () => { + const broker = makeApprovalBroker([pending('id-1', 'run'), pending('id-2', 'edit')]); + const controller = new ApprovalController(); + const showPanel = vi.fn(); + const hidePanel = vi.fn(); + controller.setUIHooks({ showPanel, hidePanel }); + + controller.attach(sessionOf(broker)); + expect(showPanel).toHaveBeenCalledWith(expect.objectContaining({ id: 'id-1' })); + + broker.externalResolve('id-1'); + // The active panel is replaced by the next pending without an + // intervening hide (the modal coordinator swaps owners in place). + expect(hidePanel).not.toHaveBeenCalled(); + expect(showPanel).toHaveBeenCalledWith(expect.objectContaining({ id: 'id-2' })); + expect(broker.decide).not.toHaveBeenCalled(); + }); + + it('detach retracts the panel without deciding', () => { + const broker = makeApprovalBroker([pending('id-1', 'run')]); + const controller = new ApprovalController(); + const hidePanel = vi.fn(); + controller.setUIHooks({ showPanel: vi.fn(), hidePanel }); + + const teardown = controller.attach(sessionOf(broker)); + teardown(); + + expect(hidePanel).toHaveBeenCalledOnce(); + expect(broker.decide).not.toHaveBeenCalled(); + }); + + it('settles the pending as cancelled when the panel path throws', async () => { + const broker = makeApprovalBroker([pending('id-1', 'run')]); + const controller = new ApprovalController(); + controller.setUIHooks({ + showPanel: () => { + throw new Error('render boom'); + }, + hidePanel: vi.fn(), + }); + + controller.attach(sessionOf(broker)); + + await vi.waitFor(() => { + expect(broker.decide).toHaveBeenCalledWith('id-1', { + decision: 'cancelled', + feedback: 'Approval UI failed: render boom', + }); + }); + }); +}); diff --git a/apps/kimi-code/test/tui/interactions/index.test.ts b/apps/kimi-code/test/tui/interactions/index.test.ts new file mode 100644 index 0000000000..7f85c2e753 --- /dev/null +++ b/apps/kimi-code/test/tui/interactions/index.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { registerInteractionPanels, type InteractionModalUIHooks } from '#/tui/interactions/index'; +import type { PanelQueueUIHooks } from '#/tui/interactions/panel-queue'; +import type { ApprovalPanelData, QuestionPanelData } from '#/tui/interactions/types'; + +function approvalPanel(id: string): ApprovalPanelData { + return { + id, + tool_call_id: id, + tool_name: 'Bash', + action: 'run', + description: '', + display: [], + choices: [], + }; +} + +function questionPanel(id: string): QuestionPanelData { + return { id, tool_call_id: id, questions: [] }; +} + +interface FakePanelController { + setUIHooks: ReturnType; + show(payload: TPayload): void; + hide(): void; +} + +function fakeController(): FakePanelController { + let hooks: PanelQueueUIHooks | null = null; + return { + setUIHooks: vi.fn((next: PanelQueueUIHooks) => { + hooks = next; + }), + show(payload: TPayload) { + hooks?.showPanel(payload); + }, + hide() { + hooks?.hidePanel(); + }, + }; +} + +function makeUIHooks(): InteractionModalUIHooks { + return { + showApprovalPanel: vi.fn(), + hideApprovalPanel: vi.fn(), + showQuestionDialog: vi.fn(), + hideQuestionDialog: vi.fn(), + }; +} + +describe('registerInteractionPanels', () => { + it('wires controller UI hooks into the modal coordinator', () => { + const approvalController = fakeController(); + const questionController = fakeController(); + const uiHooks = makeUIHooks(); + + registerInteractionPanels(approvalController as never, questionController as never, uiHooks); + + approvalController.show(approvalPanel('approval-1')); + expect(uiHooks.showApprovalPanel).toHaveBeenCalledWith( + expect.objectContaining({ id: 'approval-1' }), + ); + + approvalController.hide(); + expect(uiHooks.hideApprovalPanel).toHaveBeenCalledOnce(); + + questionController.show(questionPanel('question-1')); + expect(uiHooks.showQuestionDialog).toHaveBeenCalledWith( + expect.objectContaining({ id: 'question-1' }), + ); + questionController.hide(); + expect(uiHooks.hideQuestionDialog).toHaveBeenCalledOnce(); + }); + + it('queues question dialogs behind active approval panels', () => { + const approvalController = fakeController(); + const questionController = fakeController(); + const uiHooks = makeUIHooks(); + + registerInteractionPanels(approvalController as never, questionController as never, uiHooks); + + approvalController.show(approvalPanel('approval-1')); + questionController.show(questionPanel('question-1')); + + expect(uiHooks.showApprovalPanel).toHaveBeenCalledWith( + expect.objectContaining({ id: 'approval-1' }), + ); + expect(uiHooks.showQuestionDialog).not.toHaveBeenCalled(); + + approvalController.hide(); + expect(uiHooks.hideApprovalPanel).toHaveBeenCalledOnce(); + expect(uiHooks.showQuestionDialog).toHaveBeenCalledWith( + expect.objectContaining({ id: 'question-1' }), + ); + }); + + it('queues approval panels behind active question dialogs', () => { + const approvalController = fakeController(); + const questionController = fakeController(); + const uiHooks = makeUIHooks(); + + registerInteractionPanels(approvalController as never, questionController as never, uiHooks); + + questionController.show(questionPanel('question-1')); + approvalController.show(approvalPanel('approval-1')); + + expect(uiHooks.showQuestionDialog).toHaveBeenCalledWith( + expect.objectContaining({ id: 'question-1' }), + ); + expect(uiHooks.showApprovalPanel).not.toHaveBeenCalled(); + + questionController.hide(); + expect(uiHooks.hideQuestionDialog).toHaveBeenCalledOnce(); + expect(uiHooks.showApprovalPanel).toHaveBeenCalledWith( + expect.objectContaining({ id: 'approval-1' }), + ); + }); + + it('clears active and queued modals without showing queued entries', () => { + const approvalController = fakeController(); + const questionController = fakeController(); + const uiHooks = makeUIHooks(); + + const disposers = registerInteractionPanels( + approvalController as never, + questionController as never, + uiHooks, + ); + + approvalController.show(approvalPanel('approval-1')); + questionController.show(questionPanel('question-1')); + + for (const dispose of disposers) dispose(); + expect(uiHooks.hideApprovalPanel).toHaveBeenCalledOnce(); + // The queued question is dropped — it must not be shown after clear. + expect(uiHooks.showQuestionDialog).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/interactions/panel-queue.test.ts b/apps/kimi-code/test/tui/interactions/panel-queue.test.ts new file mode 100644 index 0000000000..891bae5d22 --- /dev/null +++ b/apps/kimi-code/test/tui/interactions/panel-queue.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { PanelQueue } from '#/tui/interactions/panel-queue'; + +class TestQueue extends PanelQueue {} + +class AutoQueue extends PanelQueue<{ action: string; id: string }, string> { + protected override autoResolveFor( + resolved: { action: string; id: string }, + response: string, + queued: { action: string; id: string }, + ): string | undefined { + if (response === 'approve_all_same' && resolved.action === queued.action) { + return `auto:${queued.id}`; + } + return undefined; + } +} + +describe('PanelQueue', () => { + it('shows a payload, resolves the pending promise on respond, and hides the panel', async () => { + const queue = new TestQueue(); + const showPanel = vi.fn(); + const hidePanel = vi.fn(); + queue.setUIHooks({ showPanel, hidePanel }); + + const pending = queue.show('payload'); + expect(queue.hasPending()).toBe(true); + expect(showPanel).toHaveBeenCalledWith('payload'); + + queue.respond('approved'); + + await expect(pending).resolves.toBe('approved'); + expect(queue.hasPending()).toBe(false); + expect(hidePanel).toHaveBeenCalledOnce(); + }); + + it('queues concurrent show() requests and presents them one at a time', async () => { + const queue = new TestQueue(); + const showPanel = vi.fn(); + const hidePanel = vi.fn(); + queue.setUIHooks({ showPanel, hidePanel }); + + const first = queue.show('first'); + const second = queue.show('second'); + const third = queue.show('third'); + + // Only the first is presented; the rest stay queued. + expect(showPanel).toHaveBeenCalledTimes(1); + expect(showPanel).toHaveBeenLastCalledWith('first'); + expect(queue.hasPending()).toBe(true); + + queue.respond('answer-first'); + await expect(first).resolves.toBe('answer-first'); + // Advancing to the next queued request reuses the same panel without + // hiding it in between. + expect(hidePanel).not.toHaveBeenCalled(); + expect(showPanel).toHaveBeenCalledTimes(2); + expect(showPanel).toHaveBeenLastCalledWith('second'); + + queue.respond('answer-second'); + await expect(second).resolves.toBe('answer-second'); + expect(showPanel).toHaveBeenCalledTimes(3); + expect(showPanel).toHaveBeenLastCalledWith('third'); + + queue.respond('answer-third'); + await expect(third).resolves.toBe('answer-third'); + expect(queue.hasPending()).toBe(false); + expect(hidePanel).toHaveBeenCalledTimes(1); + }); + + it('auto-resolves matching queued requests via the autoResolveFor hook', async () => { + const queue = new AutoQueue(); + const showPanel = vi.fn(); + const hidePanel = vi.fn(); + queue.setUIHooks({ showPanel, hidePanel }); + + const first = queue.show({ action: 'run', id: 'a' }); + const second = queue.show({ action: 'run', id: 'b' }); + const third = queue.show({ action: 'edit', id: 'c' }); + const fourth = queue.show({ action: 'run', id: 'd' }); + + queue.respond('approve_all_same'); + + await expect(first).resolves.toBe('approve_all_same'); + await expect(second).resolves.toBe('auto:b'); + await expect(fourth).resolves.toBe('auto:d'); + // The non-matching request advances to the panel and stays pending. + expect(showPanel).toHaveBeenLastCalledWith({ action: 'edit', id: 'c' }); + expect(queue.hasPending()).toBe(true); + + queue.respond('approve_all_same'); + await expect(third).resolves.toBe('approve_all_same'); + expect(queue.hasPending()).toBe(false); + expect(hidePanel).toHaveBeenCalledTimes(1); + }); + + it('retractAll resolves every pending with undefined and hides the panel', async () => { + const queue = new TestQueue(); + const hidePanel = vi.fn(); + queue.setUIHooks({ showPanel: vi.fn(), hidePanel }); + + const first = queue.show('first'); + const second = queue.show('second'); + const third = queue.show('third'); + + queue.retractAll(); + + await expect(first).resolves.toBeUndefined(); + await expect(second).resolves.toBeUndefined(); + await expect(third).resolves.toBeUndefined(); + expect(queue.hasPending()).toBe(false); + expect(hidePanel).toHaveBeenCalledTimes(1); + }); + + it('retract resolves the matching current entry with undefined and advances', async () => { + const queue = new PanelQueue<{ id: string }, string>(); + const showPanel = vi.fn(); + const hidePanel = vi.fn(); + queue.setUIHooks({ showPanel, hidePanel }); + + const first = queue.show({ id: 'a' }); + const second = queue.show({ id: 'b' }); + + queue.retract((payload) => payload.id === 'a'); + await expect(first).resolves.toBeUndefined(); + // 'b' advances into the active panel without an intervening hide. + expect(hidePanel).not.toHaveBeenCalled(); + expect(showPanel).toHaveBeenLastCalledWith({ id: 'b' }); + + queue.respond('answer-b'); + await expect(second).resolves.toBe('answer-b'); + }); + + it('retract drops matching queued entries without hiding the active panel', async () => { + const queue = new PanelQueue<{ id: string }, string>(); + const showPanel = vi.fn(); + const hidePanel = vi.fn(); + queue.setUIHooks({ showPanel, hidePanel }); + + const first = queue.show({ id: 'a' }); + const second = queue.show({ id: 'b' }); + const third = queue.show({ id: 'c' }); + + queue.retract((payload) => payload.id === 'b'); + await expect(second).resolves.toBeUndefined(); + // The current panel ('a') is untouched. + expect(hidePanel).not.toHaveBeenCalled(); + expect(showPanel).toHaveBeenCalledTimes(1); + + queue.respond('answer-a'); + await expect(first).resolves.toBe('answer-a'); + // 'c' advances — 'b' was removed from the queue. + expect(showPanel).toHaveBeenLastCalledWith({ id: 'c' }); + queue.respond('answer-c'); + await expect(third).resolves.toBe('answer-c'); + }); +}); diff --git a/apps/kimi-code/test/tui/interactions/question-adapter.test.ts b/apps/kimi-code/test/tui/interactions/question-adapter.test.ts new file mode 100644 index 0000000000..61f62447e2 --- /dev/null +++ b/apps/kimi-code/test/tui/interactions/question-adapter.test.ts @@ -0,0 +1,100 @@ +import type { CoreQuestionRequest } from '#/core/index'; +import { describe, expect, it } from 'vitest'; + +import { adaptQuestionAnswers, adaptQuestionRequest } from '#/tui/interactions/question-adapter'; + +function questionRequest(overrides: Partial = {}): CoreQuestionRequest { + return { + toolCallId: 'q-1', + questions: [{ question: 'Q1?', options: [{ label: 'Alpha' }] }], + ...overrides, + }; +} + +describe('question adapter', () => { + it('normalizes question payloads', () => { + const adapted = adaptQuestionRequest( + questionRequest({ + questions: [ + { + question: 'Q1?', + header: 'Pick', + body: 'Choose one', + multiSelect: true, + otherLabel: 'Other', + otherDescription: 'Type a custom answer', + options: [{ label: 'Alpha', description: 'First option' }], + }, + ], + }), + ); + + expect(adapted).toEqual({ + id: 'q-1', + tool_call_id: 'q-1', + questions: [ + { + question: 'Q1?', + header: 'Pick', + body: 'Choose one', + multi_select: true, + other_label: 'Other', + other_description: 'Type a custom answer', + options: [{ label: 'Alpha', description: 'First option' }], + }, + ], + }); + }); + + it('maps multiple answers by question text', () => { + const request = questionRequest({ + toolCallId: 'call_question', + questions: [ + { question: 'Q1?', options: [{ label: 'Alpha' }] }, + { question: 'Storage?', header: 'Store', options: [{ label: 'SQLite' }] }, + ], + }); + const adapted = adaptQuestionRequest(request); + expect(adapted).toEqual({ + id: 'call_question', + tool_call_id: 'call_question', + questions: [ + { + question: 'Q1?', + header: undefined, + body: undefined, + multi_select: false, + other_label: undefined, + other_description: undefined, + options: [{ label: 'Alpha', description: undefined }], + }, + { + question: 'Storage?', + header: 'Store', + body: undefined, + multi_select: false, + other_label: undefined, + other_description: undefined, + options: [{ label: 'SQLite', description: undefined }], + }, + ], + }); + + expect( + adaptQuestionAnswers(request, { answers: ['Alpha', 'SQLite'], method: 'enter' }), + ).toEqual({ + answers: { 'Q1?': 'Alpha', 'Storage?': 'SQLite' }, + method: 'enter', + }); + }); + + it('returns null when no answers are provided', () => { + const request = questionRequest(); + expect(adaptQuestionAnswers(request, { answers: [''] })).toBeNull(); + }); + + it('uses an explicit pending id when one is supplied', () => { + const adapted = adaptQuestionRequest(questionRequest(), 'interaction-1'); + expect(adapted.id).toBe('interaction-1'); + }); +}); diff --git a/apps/kimi-code/test/tui/interactions/question-controller.test.ts b/apps/kimi-code/test/tui/interactions/question-controller.test.ts new file mode 100644 index 0000000000..1223bb240b --- /dev/null +++ b/apps/kimi-code/test/tui/interactions/question-controller.test.ts @@ -0,0 +1,139 @@ +import type { CoreSession, PendingQuestion, QuestionResult } from '#/core/index'; +import { describe, expect, it, vi } from 'vitest'; + +import { QuestionController } from '#/tui/interactions/question-controller'; + +function pending(id: string, text = 'Q1?'): PendingQuestion { + return { + id, + agentId: 'main', + request: { + toolCallId: id, + questions: [{ question: text, options: [{ label: 'Alpha' }] }], + }, + }; +} + +function makeQuestionBroker(initial: readonly PendingQuestion[] = []) { + const pendings = [...initial]; + const changeListeners = new Set<() => void>(); + const resolveListeners = new Set<(id: string) => void>(); + const remove = (id: string): void => { + const idx = pendings.findIndex((p) => p.id === id); + if (idx >= 0) pendings.splice(idx, 1); + }; + const answer = vi.fn((id: string, _result: Exclude) => { + remove(id); + for (const listener of resolveListeners) listener(id); + }); + const dismiss = vi.fn((id: string) => { + remove(id); + for (const listener of resolveListeners) listener(id); + }); + return { + list: () => pendings, + onDidChangePending: (listener: () => void) => { + changeListeners.add(listener); + return () => changeListeners.delete(listener); + }, + onDidResolve: (listener: (id: string) => void) => { + resolveListeners.add(listener); + return () => resolveListeners.delete(listener); + }, + answer, + dismiss, + externalResolve(id: string): void { + remove(id); + for (const listener of resolveListeners) listener(id); + }, + }; +} + +function sessionOf(broker: ReturnType): CoreSession { + return { questions: broker } as unknown as CoreSession; +} + +describe('QuestionController', () => { + it('presents a parked pending and writes back the answer', async () => { + const broker = makeQuestionBroker([pending('q-1')]); + const controller = new QuestionController(); + const showPanel = vi.fn(); + const hidePanel = vi.fn(); + controller.setUIHooks({ showPanel, hidePanel }); + + controller.attach(sessionOf(broker)); + expect(showPanel).toHaveBeenCalledWith(expect.objectContaining({ id: 'q-1' })); + + controller.respond({ answers: ['Alpha'], method: 'number_key' }); + await vi.waitFor(() => { + expect(broker.answer).toHaveBeenCalledWith('q-1', { + answers: { 'Q1?': 'Alpha' }, + method: 'number_key', + }); + }); + expect(hidePanel).toHaveBeenCalledOnce(); + }); + + it('dismisses the pending on an empty answer set', async () => { + const broker = makeQuestionBroker([pending('q-1')]); + const controller = new QuestionController(); + controller.setUIHooks({ showPanel: vi.fn(), hidePanel: vi.fn() }); + + controller.attach(sessionOf(broker)); + controller.respond({ answers: [''] }); + + await vi.waitFor(() => { + expect(broker.dismiss).toHaveBeenCalledWith('q-1'); + }); + expect(broker.answer).not.toHaveBeenCalled(); + }); + + it('retracts the dialog when a pending is resolved externally', async () => { + const broker = makeQuestionBroker([pending('q-1'), pending('q-2', 'Q2?')]); + const controller = new QuestionController(); + const showPanel = vi.fn(); + const hidePanel = vi.fn(); + controller.setUIHooks({ showPanel, hidePanel }); + + controller.attach(sessionOf(broker)); + expect(showPanel).toHaveBeenCalledWith(expect.objectContaining({ id: 'q-1' })); + + broker.externalResolve('q-1'); + expect(hidePanel).not.toHaveBeenCalled(); + expect(showPanel).toHaveBeenCalledWith(expect.objectContaining({ id: 'q-2' })); + expect(broker.answer).not.toHaveBeenCalled(); + expect(broker.dismiss).not.toHaveBeenCalled(); + }); + + it('detach retracts the dialog without answering or dismissing', () => { + const broker = makeQuestionBroker([pending('q-1')]); + const controller = new QuestionController(); + const hidePanel = vi.fn(); + controller.setUIHooks({ showPanel: vi.fn(), hidePanel }); + + const teardown = controller.attach(sessionOf(broker)); + teardown(); + + expect(hidePanel).toHaveBeenCalledOnce(); + expect(broker.answer).not.toHaveBeenCalled(); + expect(broker.dismiss).not.toHaveBeenCalled(); + }); + + it('dismisses the pending when the dialog path throws', async () => { + const broker = makeQuestionBroker([pending('q-1')]); + const controller = new QuestionController(); + controller.setUIHooks({ + showPanel: () => { + throw new Error('render boom'); + }, + hidePanel: vi.fn(), + }); + + controller.attach(sessionOf(broker)); + + await vi.waitFor(() => { + expect(broker.dismiss).toHaveBeenCalledWith('q-1'); + }); + expect(broker.answer).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index a014a45860..22e323346c 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -3,12 +3,20 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import type { + ApprovalResponse, + PendingApproval, + PendingQuestion, + QuestionResult, + SessionEvent, + SessionEventOf, +} from '#/core/index'; import { deleteAllKittyImages, resetCapabilitiesCache, setCapabilities, } from '@moonshot-ai/pi-tui'; -import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi-code-sdk'; +import type { Event } from '@moonshot-ai/kimi-code-sdk'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; @@ -20,7 +28,9 @@ import { } from '#/tui/components/messages/agent-swarm-progress'; import { BtwPanelComponent } from '#/tui/components/panes/btw-panel'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; +import { GoalSetMessageComponent } from '#/tui/components/messages/goal-panel'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; +import { GoalStartPermissionPromptComponent } from '#/tui/components/dialogs/goal-start-permission-prompt'; import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; import { UndoSelectorComponent } from '#/tui/components/dialogs/undo-selector'; import { @@ -81,6 +91,7 @@ function stripSgr(text: string): string { interface MessageDriver { state: TUIState; + session: unknown; streamingUI: StreamingUIController; sessionEventHandler: { startSubscription(): void; @@ -90,6 +101,13 @@ interface MessageDriver { handleUserInput(text: string): void; persistInputHistory(text: string): Promise; sendQueuedMessage(session: unknown, item: QueuedMessage): void; + sendQueuedGoalMessage( + session: unknown, + item: QueuedMessage, + confirmation: GoalSetMessageComponent, + ): Promise; + resetSessionRuntime(): void; + createNewSession(): Promise; getCurrentSessionId(): string; } @@ -145,7 +163,7 @@ function makeSession(overrides: Record = {}) { summary: { title: null }, prompt: vi.fn(async () => {}), steer: vi.fn(async () => {}), - init: vi.fn(async () => {}), + generateAgentsMd: vi.fn(async () => {}), startBtw: vi.fn(async () => 'agent-btw'), undoHistory: vi.fn(async () => {}), cancel: vi.fn(async () => {}), @@ -160,8 +178,8 @@ function makeSession(overrides: Record = {}) { contextUsage: 0, })), getGoal: vi.fn(async () => ({ goal: null })), - setApprovalHandler: vi.fn(), - setQuestionHandler: vi.fn(), + approvals: makeApprovalsBroker(), + questions: makeQuestionsBroker(), setModel: vi.fn(async () => {}), setThinking: vi.fn(async () => {}), setPermission: vi.fn(async () => {}), @@ -189,6 +207,105 @@ function makeSession(overrides: Record = {}) { }, })), close: vi.fn(async () => {}), + activateSkill: vi.fn(async () => {}), + ...overrides, + }; +} + +function makeApprovalsBroker(initial: readonly PendingApproval[] = []) { + const pendings: PendingApproval[] = [...initial]; + const changeListeners = new Set<() => void>(); + const resolveListeners = new Set<(id: string) => void>(); + const remove = (id: string): void => { + const idx = pendings.findIndex((p) => p.id === id); + if (idx >= 0) pendings.splice(idx, 1); + }; + const decide = vi.fn((id: string, _response: ApprovalResponse) => { + remove(id); + for (const listener of resolveListeners) listener(id); + }); + return { + list: vi.fn(() => pendings), + onDidChangePending: vi.fn((listener: () => void) => { + changeListeners.add(listener); + return () => changeListeners.delete(listener); + }), + onDidResolve: vi.fn((listener: (id: string) => void) => { + resolveListeners.add(listener); + return () => resolveListeners.delete(listener); + }), + decide, + /** Test helper: park a pending and notify the controller. */ + add(pending: PendingApproval): void { + pendings.push(pending); + for (const listener of changeListeners) listener(); + }, + /** Test helper: simulate an out-of-band resolution. */ + resolve(id: string): void { + remove(id); + for (const listener of resolveListeners) listener(id); + }, + }; +} + +function makeQuestionsBroker(initial: readonly PendingQuestion[] = []) { + const pendings: PendingQuestion[] = [...initial]; + const changeListeners = new Set<() => void>(); + const resolveListeners = new Set<(id: string) => void>(); + const remove = (id: string): void => { + const idx = pendings.findIndex((p) => p.id === id); + if (idx >= 0) pendings.splice(idx, 1); + }; + const answer = vi.fn((id: string, _result: Exclude) => { + remove(id); + for (const listener of resolveListeners) listener(id); + }); + const dismiss = vi.fn((id: string) => { + remove(id); + for (const listener of resolveListeners) listener(id); + }); + return { + list: vi.fn(() => pendings), + onDidChangePending: vi.fn((listener: () => void) => { + changeListeners.add(listener); + return () => changeListeners.delete(listener); + }), + onDidResolve: vi.fn((listener: (id: string) => void) => { + resolveListeners.add(listener); + return () => resolveListeners.delete(listener); + }), + answer, + dismiss, + }; +} + +function makeHarness(session = makeSession(), overrides: Record = {}) { + const interactiveAgentScope = new AsyncLocalStorage(); + return { + getConfig: vi.fn(async () => ({ + models: { + k2: { model: 'moonshot-v1', maxContextSize: 100 }, + }, + })), + setConfig: vi.fn(async () => ({ providers: {} })), + getStartupState: vi.fn(async () => ({ + model: 'k2', + maxContextTokens: 100, + permissionMode: 'manual', + planMode: false, + thinkingEffort: 'off', + })), + createSession: vi.fn(async () => session), + resumeSession: vi.fn(async () => session), + forkSession: vi.fn(async () => session), + listSessions: vi.fn(async () => []), + exportSession: vi.fn(async () => ({ + zipPath: '/tmp/fake-session.zip', + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + })), + close: vi.fn(async () => {}), listPlugins: vi.fn(async () => []), installPlugin: vi.fn(async () => ({ id: 'demo', @@ -206,11 +323,10 @@ function makeSession(overrides: Record = {}) { setPluginMcpServerEnabled: vi.fn(async () => {}), removePlugin: vi.fn(async () => {}), reloadPlugins: vi.fn(async () => ({ added: [], removed: [], errors: [] })), - reloadSession: vi.fn(async () => ({})), - activateSkill: vi.fn(async () => {}), - getPluginInfo: vi.fn(async (id: string) => ({ - id, - displayName: id, + reloadSession: vi.fn(async () => session), + getPluginInfo: vi.fn(async (input: { id: string }) => ({ + id: input.id, + displayName: input.id, version: '1.0.0', enabled: true, state: 'ok', @@ -219,35 +335,11 @@ function makeSession(overrides: Record = {}) { enabledMcpServerCount: 0, hasErrors: false, source: 'local-path', - root: `/plugins/${id}`, + root: `/plugins/${input.id}`, manifest: undefined, mcpServers: [], diagnostics: [], })), - ...overrides, - }; -} - -function makeHarness(session = makeSession(), overrides: Record = {}) { - const interactiveAgentScope = new AsyncLocalStorage(); - return { - getConfig: vi.fn(async () => ({ - models: { - k2: { model: 'moonshot-v1', maxContextSize: 100 }, - }, - })), - setConfig: vi.fn(async () => ({ providers: {} })), - createSession: vi.fn(async () => session), - resumeSession: vi.fn(async () => session), - forkSession: vi.fn(async () => session), - listSessions: vi.fn(async () => []), - exportSession: vi.fn(async () => ({ - zipPath: '/tmp/fake-session.zip', - entries: ['manifest.json', 'state.json'], - sessionDir: '/tmp/session-a', - manifest: {}, - })), - close: vi.fn(async () => {}), track: vi.fn(), setTelemetryContext: vi.fn(), get interactiveAgentId() { @@ -275,9 +367,15 @@ function makeHarness(session = makeSession(), overrides: Record }; } +/** Flush the async lazy-session-creation chain triggered by the first input. */ +async function flushLazySessionStart(times = 50): Promise { + for (let i = 0; i < times; i++) await Promise.resolve(); +} + async function makeDriver( session = makeSession(), harnessOverrides: Record = {}, + options?: { sessionless?: boolean }, ): Promise<{ driver: MessageDriver; session: ReturnType; @@ -289,6 +387,19 @@ async function makeDriver( vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); driver.persistInputHistory = vi.fn(async () => {}); await driver.init(); + if (options?.sessionless !== true) { + // Startup no longer creates a session. Most tests here exercise behavior + // with a live session, so enter that state through the real /new path; + // tests for the session-less startup itself pass `sessionless: true`. + await driver.createNewSession(); + } + // When the driver is session-less, the first user input creates the session + // lazily and asynchronously, so input submission must be awaited. + const rawHandleUserInput = driver.handleUserInput.bind(driver); + driver.handleUserInput = ((text: string) => { + rawHandleUserInput(text); + return flushLazySessionStart(); + }) as MessageDriver['handleUserInput']; return { driver, session, harness }; } @@ -324,7 +435,7 @@ async function openBtwPanel( session: ReturnType, prompt = 'side question', ): Promise { - driver.handleUserInput(`/btw ${prompt}`); + await driver.handleUserInput(`/btw ${prompt}`); await vi.waitFor(() => { expect(session.startBtw).toHaveBeenCalled(); expect(driver.state.btwPanelContainer.children).toHaveLength(2); @@ -420,7 +531,7 @@ describe('KimiTUI message flow', () => { harness.createSession.mockResolvedValueOnce(nextSession); harness.track.mockClear(); - driver.handleUserInput('/clear'); + await driver.handleUserInput('/clear'); await vi.waitFor(() => { expect(driver.getCurrentSessionId()).toBe('ses-2'); @@ -434,7 +545,7 @@ describe('KimiTUI message flow', () => { const { driver, harness } = await makeDriver(); harness.track.mockClear(); - driver.handleUserInput('/theme light'); + await driver.handleUserInput('/theme light'); await vi.waitFor(() => { expect(driver.state.appState.theme).toBe('light'); @@ -458,15 +569,15 @@ command = "vim" ); const { driver, session, harness } = await makeDriver(); harness.track.mockClear(); - session.reloadSession.mockClear(); + harness.reloadSession.mockClear(); - driver.handleUserInput('/reload-tui'); + await driver.handleUserInput('/reload-tui'); await vi.waitFor(() => { expect(driver.state.appState.theme).toBe('light'); }); expect(driver.state.appState.editorCommand).toBe('vim'); - expect(session.reloadSession).not.toHaveBeenCalled(); + expect(harness.reloadSession).not.toHaveBeenCalled(); expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'reload-tui' }); }); @@ -476,14 +587,14 @@ command = "vim" await writeFile(join(homeDir, 'tui.toml'), 'theme = "light"\n', 'utf-8'); const { driver, session, harness } = await makeDriver(); harness.track.mockClear(); - session.reloadSession.mockClear(); - driver.handleUserInput('hello before reload'); + harness.reloadSession.mockClear(); + await driver.handleUserInput('hello before reload'); driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/reload'); + await driver.handleUserInput('/reload'); await vi.waitFor(() => { - expect(session.reloadSession).toHaveBeenCalledOnce(); + expect(harness.reloadSession).toHaveBeenCalledOnce(); }); await vi.waitFor(() => { expect(driver.state.appState.theme).toBe('light'); @@ -895,7 +1006,7 @@ command = "vim" for (const command of ['/new', '/sessions']) { harness.track.mockClear(); - driver.handleUserInput(command); + await driver.handleUserInput(command); await Promise.resolve(); expect(harness.track).toHaveBeenCalledWith('input_command_invalid', { @@ -928,7 +1039,7 @@ command = "vim" session.setPlanMode.mockClear(); driver.state.appState.planMode = true; - driver.handleUserInput('/new'); + await driver.handleUserInput('/new'); await vi.waitFor(() => { expect(harness.createSession).toHaveBeenCalledWith({ @@ -958,7 +1069,7 @@ command = "vim" const { driver } = await makeDriver(initialSession, { createSession }); vi.mocked(failedSession.onEvent).mockClear(); - driver.handleUserInput('/new'); + await driver.handleUserInput('/new'); await vi.waitFor(() => { expect(stripSgr(renderTranscript(driver))).toContain( @@ -985,7 +1096,7 @@ command = "vim" const { driver, session, harness } = await makeDriver(); harness.track.mockClear(); - driver.handleUserInput('/yolo on'); + await driver.handleUserInput('/yolo on'); await vi.waitFor(() => { expect(session.setPermission).toHaveBeenCalledWith('yolo'); @@ -1017,6 +1128,10 @@ command = "vim" }); const { driver } = await makeDriver(session); + // makeDriver's session setup already subscribed once; reset the spies so + // the assertions cover only this explicit (re)subscription. + session.onEvent.mockClear(); + session.listMcpServers.mockClear(); driver.sessionEventHandler.startSubscription(); await Promise.resolve(); @@ -1140,9 +1255,12 @@ command = "vim" it('sends normal editor input to the active session and marks the turn as waiting', async () => { const { driver, session } = await makeDriver(); - driver.handleUserInput('hello'); + await driver.handleUserInput('hello'); - expect(session.prompt).toHaveBeenCalledWith('hello'); + expect(session.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'hello' }], + { agentId: undefined }, + ); expect(driver.state.appState.streamingPhase).not.toBe('idle'); expect(driver.state.appState.streamingPhase).toBe('waiting'); expect(driver.state.livePane.mode).toBe('waiting'); @@ -1162,10 +1280,10 @@ command = "vim" }); const { driver } = await makeDriver(session); - driver.handleUserInput('hello'); + await driver.handleUserInput('hello'); driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); + await driver.handleUserInput('/undo'); await confirmUndoSelection(driver); await vi.waitFor(() => { @@ -1190,10 +1308,10 @@ command = "vim" it('does not duplicate welcome after undoing the only turn', async () => { const { driver } = await makeDriver(); - driver.handleUserInput('hello'); + await driver.handleUserInput('hello'); driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); + await driver.handleUserInput('/undo'); await confirmUndoSelection(driver); await vi.waitFor(() => { @@ -1210,22 +1328,22 @@ command = "vim" it('keeps command notices that are not part of the undone context', async () => { const { driver, session } = await makeDriver(); - driver.handleUserInput('hello'); + await driver.handleUserInput('hello'); driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/auto on'); + await driver.handleUserInput('/auto on'); await vi.waitFor(() => { expect(stripSgr(renderTranscript(driver))).toContain('Auto mode: ON'); }); - driver.handleUserInput('/undo 10'); + await driver.handleUserInput('/undo 10'); await vi.waitFor(() => { expect(stripSgr(renderTranscript(driver))).toContain( 'Cannot undo 10 prompts; only 1 prompt can be undone in the active context.', ); }); - driver.handleUserInput('/undo'); + await driver.handleUserInput('/undo'); await confirmUndoSelection(driver); await vi.waitFor(() => { @@ -1239,14 +1357,224 @@ command = "vim" expect(driver.state.appState.permissionMode).toBe('auto'); }); + it('routes child-agent process task lifecycle events through the background task UI', async () => { + const { driver } = await makeDriver(); + const started = { + type: 'task.started', + agentId: 'agent-child', + sessionId: 'ses-1', + info: { + kind: 'process', + taskId: 'bash-child1234', + command: 'pnpm test', + description: 'Run child tests', + status: 'running', + pid: 1234, + exitCode: null, + startedAt: Date.now(), + endedAt: null, + }, + } satisfies SessionEventOf<'task.started'>; + + driver.sessionEventHandler.handleEvent(started, () => {}); + + let transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('bash task started in background'); + expect(transcript).toContain('Run child tests'); + expect(stripSgr(driver.state.footer.render(120).join('\n'))).toContain('[1 task running]'); + + driver.sessionEventHandler.handleEvent( + { + ...started, + type: 'task.terminated', + info: { + ...started.info, + status: 'completed', + exitCode: 0, + endedAt: Date.now(), + }, + } satisfies SessionEventOf<'task.terminated'>, + () => {}, + ); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('bash task completed in background'); + expect(transcript).toContain('Run child tests · exit 0'); + expect(stripSgr(driver.state.footer.render(120).join('\n'))).not.toContain('task running'); + }); + + it('routes child-agent question task lifecycle events through the background task UI', async () => { + const { driver } = await makeDriver(); + const started = { + type: 'task.started', + agentId: 'agent-child', + sessionId: 'ses-1', + info: { + kind: 'question', + taskId: 'question-child1234', + description: 'Choose a child database', + status: 'running', + detached: true, + questionCount: 1, + toolCallId: 'call_question_child', + startedAt: Date.now(), + endedAt: null, + }, + } satisfies SessionEventOf<'task.started'>; + + driver.sessionEventHandler.handleEvent(started, () => {}); + + let transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('question task started in background'); + expect(transcript).toContain('Choose a child database'); + expect(stripSgr(driver.state.footer.render(120).join('\n'))).toContain('[1 task running]'); + + driver.sessionEventHandler.handleEvent( + { + ...started, + type: 'task.terminated', + info: { ...started.info, status: 'completed', endedAt: Date.now() }, + } satisfies SessionEventOf<'task.terminated'>, + () => {}, + ); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('question task completed in background'); + expect(stripSgr(driver.state.footer.render(120).join('\n'))).not.toContain('task running'); + }); + + it('deduplicates child-agent completed task and subagent lifecycle terminal UI', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_agent_child_completed', + subagentId: 'agent-child-completed', + subagentName: 'coder', + description: 'Implement child result', + runInBackground: true, + } as Event, + sendQueued, + ); + const started = { + type: 'task.started', + agentId: 'agent-child-completed', + sessionId: 'ses-1', + info: { + kind: 'agent', + taskId: 'agent-task-completed', + agentId: 'agent-child-completed', + subagentType: 'coder', + description: 'Implement child result', + status: 'running', + detached: true, + startedAt: Date.now(), + endedAt: null, + }, + } satisfies SessionEventOf<'task.started'>; + driver.sessionEventHandler.handleEvent(started, sendQueued); + expect(stripSgr(driver.state.footer.render(120).join('\n'))).toContain('[1 agent running]'); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.completed', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-child-completed', + resultSummary: 'Child implementation is ready', + } as Event, + sendQueued, + ); + driver.sessionEventHandler.handleEvent( + { + ...started, + type: 'task.terminated', + info: { ...started.info, status: 'completed', endedAt: Date.now() }, + } satisfies SessionEventOf<'task.terminated'>, + sendQueued, + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'coder agent completed in background')).toBe(1); + expect(stripSgr(driver.state.footer.render(120).join('\n'))).not.toContain('agent running'); + }); + + it('deduplicates child-agent failed task and subagent lifecycle terminal UI', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_agent_child_failed', + subagentId: 'agent-child-failed', + subagentName: 'reviewer', + description: 'Review child implementation', + runInBackground: true, + } as Event, + sendQueued, + ); + const started = { + type: 'task.started', + agentId: 'agent-child-failed', + sessionId: 'ses-1', + info: { + kind: 'agent', + taskId: 'agent-task-failed', + agentId: 'agent-child-failed', + subagentType: 'reviewer', + description: 'Review child implementation', + status: 'running', + detached: true, + startedAt: Date.now(), + endedAt: null, + }, + } satisfies SessionEventOf<'task.started'>; + driver.sessionEventHandler.handleEvent(started, sendQueued); + expect(stripSgr(driver.state.footer.render(120).join('\n'))).toContain('[1 agent running]'); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.failed', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-child-failed', + error: 'Child review crashed', + } as Event, + sendQueued, + ); + driver.sessionEventHandler.handleEvent( + { + ...started, + type: 'task.terminated', + info: { + ...started.info, + status: 'failed', + stopReason: 'Child review crashed', + endedAt: Date.now(), + }, + } satisfies SessionEventOf<'task.terminated'>, + sendQueued, + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'reviewer agent failed in background')).toBe(1); + expect(countOccurrences(transcript, 'Child review crashed')).toBe(1); + expect(stripSgr(driver.state.footer.render(120).join('\n'))).not.toContain('agent running'); + }); + it('removes turn-scoped background status entries and restores welcome', async () => { const { driver, session } = await makeDriver(); - driver.handleUserInput('hello'); + await driver.handleUserInput('hello'); driver.state.appState.streamingPhase = 'idle'; driver.sessionEventHandler.handleEvent( { - type: 'background.task.started', + type: 'task.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, @@ -1271,7 +1599,7 @@ command = "vim" expect(transcript).toContain('Run tests in background'); }); - driver.handleUserInput('/undo'); + await driver.handleUserInput('/undo'); await confirmUndoSelection(driver); await vi.waitFor(() => { @@ -1294,7 +1622,7 @@ command = "vim" const { driver, session } = await makeDriver(); const sendQueued = vi.fn(); - driver.handleUserInput('launch swarm'); + await driver.handleUserInput('launch swarm'); driver.sessionEventHandler.handleEvent( { type: 'tool.call.started', @@ -1318,7 +1646,7 @@ command = "vim" expect(transcript).toContain('Review changed files'); driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); + await driver.handleUserInput('/undo'); await confirmUndoSelection(driver); await vi.waitFor(() => { @@ -1333,22 +1661,23 @@ command = "vim" it('removes approval notices from undone turns', async () => { const { driver, session } = await makeDriver(); - const approvalHandler = vi.mocked(session.setApprovalHandler).mock.calls[0]?.[0] as - | ((request: ApprovalRequest) => Promise) - | undefined; - if (approvalHandler === undefined) throw new Error('expected approval handler'); - driver.handleUserInput('hello'); + await driver.handleUserInput('hello'); driver.state.appState.streamingPhase = 'idle'; - const response = approvalHandler({ - turnId: 1, - toolCallId: 'call_bash', - toolName: 'Bash', - action: 'Run shell command', - display: { - kind: 'generic', - summary: 'Run shell command', - detail: { command: 'echo ok', description: 'Run a shell command' }, + + session.approvals.add({ + id: 'approval-1', + agentId: 'main', + request: { + turnId: 1, + toolCallId: 'call_bash', + toolName: 'Bash', + action: 'Run shell command', + display: { + kind: 'generic', + summary: 'Run shell command', + detail: { command: 'echo ok', description: 'Run a shell command' }, + }, }, }); @@ -1356,13 +1685,19 @@ command = "vim" expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ApprovalPanelComponent); }); (driver.state.editorContainer.children[0] as ApprovalPanelComponent).handleInput('1'); - await expect(response).resolves.toMatchObject({ decision: 'approved' }); + + await vi.waitFor(() => { + expect(session.approvals.decide).toHaveBeenCalledWith( + 'approval-1', + expect.objectContaining({ decision: 'approved' }), + ); + }); await vi.waitFor(() => { expect(stripSgr(renderTranscript(driver))).toContain('Approved: Run shell command'); }); - driver.handleUserInput('/undo'); + await driver.handleUserInput('/undo'); await confirmUndoSelection(driver); await vi.waitFor(() => { @@ -1374,12 +1709,67 @@ command = "vim" expect(transcript).not.toContain('Approved: Run shell command'); }); + it('reflects a goal-start approval mode choice in the footer permission mode', async () => { + const { driver, session } = await makeDriver(); + driver.state.appState.permissionMode = 'manual'; + driver.state.appState.streamingPhase = 'idle'; + + session.approvals.add({ + id: 'approval-goal', + agentId: 'main', + request: { + turnId: 1, + toolCallId: 'call_goal', + toolName: 'CreateGoal', + action: 'start goal', + display: { + kind: 'goal_start', + mode: 'manual', + objective: 'Ship the feature', + }, + }, + }); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ApprovalPanelComponent); + }); + // The first goal-start option for a Manual session is "Switch to Auto and start". + (driver.state.editorContainer.children[0] as ApprovalPanelComponent).handleInput('1'); + + await vi.waitFor(() => { + expect(session.approvals.decide).toHaveBeenCalledWith( + 'approval-goal', + expect.objectContaining({ decision: 'approved', selectedLabel: 'auto' }), + ); + }); + await vi.waitFor(() => { + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + }); + + it('restores the editor when session runtime resets a goal permission prompt', async () => { + const { driver } = await makeDriver(); + driver.state.appState.permissionMode = 'manual'; + await driver.handleUserInput('/goal Ship the feature'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + GoalStartPermissionPromptComponent, + ); + }); + + driver.resetSessionRuntime(); + + expect(driver.state.editorContainer.children).toEqual([driver.state.editor]); + expect(driver.state.editor.focused).toBe(true); + }); + it('removes debug timing status from undone turns', async () => { const { driver, session } = await makeDriver(); const previousDebug = process.env['KIMI_CODE_DEBUG']; process.env['KIMI_CODE_DEBUG'] = '1'; try { - driver.handleUserInput('hello'); + await driver.handleUserInput('hello'); driver.sessionEventHandler.handleEvent( { type: 'turn.step.completed', @@ -1398,7 +1788,7 @@ command = "vim" }); driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); + await driver.handleUserInput('/undo'); await confirmUndoSelection(driver); await vi.waitFor(() => { @@ -1420,14 +1810,14 @@ command = "vim" it('undoes multiple turns when a count is provided', async () => { const { driver, session } = await makeDriver(); - driver.handleUserInput('first'); + await driver.handleUserInput('first'); driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('second'); + await driver.handleUserInput('second'); driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('third'); + await driver.handleUserInput('third'); driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo 2'); + await driver.handleUserInput('/undo 2'); await vi.waitFor(() => { expect(session.undoHistory).toHaveBeenCalledWith(2); @@ -1448,10 +1838,10 @@ command = "vim" it('rejects invalid undo counts without changing context', async () => { const { driver, session } = await makeDriver(); - driver.handleUserInput('hello'); + await driver.handleUserInput('hello'); driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo 0'); + await driver.handleUserInput('/undo 0'); await vi.waitFor(() => { expect(stripSgr(renderTranscript(driver))).toContain( @@ -1471,7 +1861,7 @@ command = "vim" it('undoes from the real user turn when the last skill activation came from the model', async () => { const { driver } = await makeDriver(); - driver.handleUserInput('hello'); + await driver.handleUserInput('hello'); driver.sessionEventHandler.handleEvent( { type: 'skill.activated', @@ -1484,7 +1874,7 @@ command = "vim" ); driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); + await driver.handleUserInput('/undo'); await confirmUndoSelection(driver); await vi.waitFor(() => { @@ -1500,7 +1890,7 @@ command = "vim" it('keeps user-slash skill activations as undo anchors', async () => { const { driver } = await makeDriver(); - driver.handleUserInput('hello'); + await driver.handleUserInput('hello'); driver.sessionEventHandler.handleEvent( { type: 'skill.activated', @@ -1513,7 +1903,7 @@ command = "vim" ); driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); + await driver.handleUserInput('/undo'); await confirmUndoSelection(driver); await vi.waitFor(() => { @@ -1541,12 +1931,15 @@ command = "vim" const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); - driver.handleUserInput(`describe ${attachment.placeholder}`); + await driver.handleUserInput(`describe ${attachment.placeholder}`); - expect(session.prompt).toHaveBeenCalledWith([ - { type: 'text', text: 'describe ' }, - { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, - ]); + expect(session.prompt).toHaveBeenCalledWith( + [ + { type: 'text', text: 'describe ' }, + { type: 'image', source: { kind: 'base64', media_type: 'image/png', data: 'qrs=' } }, + ], + { agentId: undefined }, + ); expect(driver.state.transcriptEntries).toEqual([ expect.objectContaining({ kind: 'user', @@ -1561,7 +1954,7 @@ command = "vim" driver.state.appState.streamingPhase = 'waiting'; harness.track.mockClear(); - driver.handleUserInput('queued message'); + await driver.handleUserInput('queued message'); expect(session.prompt).not.toHaveBeenCalled(); expect(driver.state.queuedMessages).toEqual([{ text: 'queued message', agentId: 'main' }]); @@ -1634,13 +2027,189 @@ command = "vim" } }); + it('drops a turn-end queued message when the session changes before deferred dispatch', async () => { + vi.useFakeTimers(); + try { + const eventListeners: Array<(event: SessionEvent) => void> = []; + const initialSession = makeSession({ + id: 'ses-1', + onEvent: vi.fn((listener: (event: SessionEvent) => void) => { + eventListeners.push(listener); + return vi.fn(); + }), + }); + const nextSession = makeSession({ id: 'ses-next' }); + const { driver, harness } = await makeDriver(initialSession); + harness.createSession.mockResolvedValueOnce(nextSession); + driver.sessionEventHandler.startSubscription(); + const onEvent = eventListeners.at(-1); + if (onEvent === undefined) throw new Error('Expected the initial session subscription.'); + + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.streamingStartTime = 1; + driver.streamingUI.setTurnId('1'); + driver.state.queuedMessages = [{ text: 'old queued message', agentId: 'main' }]; + onEvent({ + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + reason: 'completed', + }); + + await driver.createNewSession(); + await vi.advanceTimersByTimeAsync(0); + + expect(initialSession.prompt).not.toHaveBeenCalled(); + expect(nextSession.prompt).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).not.toContain('old queued message'); + expect(driver.state.queuedMessageDispatchPending).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('does not let an old turn timer clear a new session queued-message dispatch', async () => { + vi.useFakeTimers(); + try { + const initialEventListeners: Array<(event: SessionEvent) => void> = []; + const nextEventListeners: Array<(event: SessionEvent) => void> = []; + const initialSession = makeSession({ + id: 'ses-1', + onEvent: vi.fn((listener: (event: SessionEvent) => void) => { + initialEventListeners.push(listener); + return vi.fn(); + }), + }); + const nextSession = makeSession({ + id: 'ses-next', + onEvent: vi.fn((listener: (event: SessionEvent) => void) => { + nextEventListeners.push(listener); + return vi.fn(); + }), + }); + const { driver, harness } = await makeDriver(initialSession); + harness.createSession.mockResolvedValueOnce(nextSession); + driver.sessionEventHandler.startSubscription(); + const initialOnEvent = initialEventListeners.at(-1); + if (initialOnEvent === undefined) throw new Error('Expected the initial session subscription.'); + + const scheduledDispatches: Array<() => void> = []; + const scheduleTimer = globalThis.setTimeout; + const captureTimer = ( + callback: (...args: any[]) => void, + delay?: number, + ...args: any[] + ): ReturnType => { + if (delay === 0) { + scheduledDispatches.push(() => { + callback(...args); + }); + return 0 as unknown as ReturnType; + } + return scheduleTimer(callback, delay, ...args); + }; + vi.spyOn(globalThis, 'setTimeout').mockImplementation(captureTimer); + + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.streamingStartTime = 1; + driver.streamingUI.setTurnId('1'); + driver.state.queuedMessages = [{ text: 'old queued message', agentId: 'main' }]; + initialOnEvent({ + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + reason: 'completed', + }); + + await driver.createNewSession(); + const nextOnEvent = nextEventListeners.at(-1); + if (nextOnEvent === undefined) throw new Error('Expected the next session subscription.'); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.streamingStartTime = 2; + driver.streamingUI.setTurnId('2'); + driver.state.queuedMessages = [{ text: 'new queued message', agentId: 'main' }]; + nextOnEvent({ + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-next', + turnId: 2, + reason: 'completed', + }); + expect(scheduledDispatches).toHaveLength(2); + + scheduledDispatches[0]!(); + + expect(driver.state.queuedMessageDispatchPending).toBe(true); + expect(initialSession.prompt).not.toHaveBeenCalled(); + expect(nextSession.prompt).not.toHaveBeenCalled(); + + scheduledDispatches[1]!(); + await Promise.resolve(); + + expect(driver.state.queuedMessageDispatchPending).toBe(false); + expect(nextSession.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'new queued message' }], + { agentId: 'main' }, + ); + } finally { + vi.useRealTimers(); + } + }); + + it('drops a compaction queued message when the session changes before deferred dispatch', async () => { + vi.useFakeTimers(); + try { + const eventListeners: Array<(event: SessionEvent) => void> = []; + const initialSession = makeSession({ + id: 'ses-1', + onEvent: vi.fn((listener: (event: SessionEvent) => void) => { + eventListeners.push(listener); + return vi.fn(); + }), + }); + const nextSession = makeSession({ id: 'ses-next' }); + const { driver, harness } = await makeDriver(initialSession); + harness.createSession.mockResolvedValueOnce(nextSession); + driver.sessionEventHandler.startSubscription(); + const onEvent = eventListeners.at(-1); + if (onEvent === undefined) throw new Error('Expected the initial session subscription.'); + + driver.state.appState.isCompacting = true; + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [{ text: 'old compacted message', agentId: 'main' }]; + onEvent({ + type: 'compaction.completed', + agentId: 'main', + sessionId: 'ses-1', + result: { + summary: 'Compacted old session.', + compactedCount: 1, + tokensBefore: 100, + tokensAfter: 20, + }, + }); + + await driver.createNewSession(); + await vi.advanceTimersByTimeAsync(0); + + expect(initialSession.prompt).not.toHaveBeenCalled(); + expect(nextSession.prompt).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).not.toContain('old compacted message'); + expect(driver.state.queuedMessageDispatchPending).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it('queues bash input with mode bash while a turn is streaming', async () => { const { driver, session } = await makeDriver(); driver.state.appState.streamingPhase = 'waiting'; driver.state.appState.inputMode = 'bash'; driver.state.editor.inputMode = 'bash'; - driver.handleUserInput('ls'); + await driver.handleUserInput('ls'); expect(session.prompt).not.toHaveBeenCalled(); expect(driver.state.queuedMessages).toEqual([ @@ -1663,13 +2232,130 @@ command = "vim" expect(session.prompt).not.toHaveBeenCalled(); }); + it('rejects an awaited queued-goal send when the production prompt rejects', async () => { + const session = makeSession({ + prompt: vi.fn(async () => { + throw new Error('prompt failed'); + }), + }); + const { driver } = await makeDriver(session); + + await expect( + driver.sendQueuedGoalMessage( + session, + { text: 'Ship queued goal' }, + new GoalSetMessageComponent(), + ), + ).rejects.toThrow('prompt failed'); + + expect(session.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'Ship queued goal' }], + { agentId: 'main' }, + ); + expect(driver.state.appState.streamingPhase).toBe('idle'); + expect(stripSgr(renderTranscript(driver))).not.toContain('Ship queued goal'); + }); + + it('keeps queued-goal marker and user input ahead of synchronous turn events', async () => { + let emit: ((event: SessionEvent) => void) | undefined; + const session = makeSession({ + onEvent: vi.fn((listener: (event: SessionEvent) => void) => { + emit = listener; + return vi.fn(); + }), + prompt: vi.fn(async () => { + emit?.({ + type: 'turn.started', + sessionId: 'ses-1', + agentId: 'main', + turnId: 1, + origin: { kind: 'user' }, + }); + emit?.({ + type: 'assistant.delta', + sessionId: 'ses-1', + agentId: 'main', + turnId: 1, + delta: 'assistant reply', + }); + emit?.({ + type: 'turn.ended', + sessionId: 'ses-1', + agentId: 'main', + turnId: 1, + reason: 'completed', + }); + }), + }); + const { driver } = await makeDriver(session); + driver.sessionEventHandler.startSubscription(); + + await driver.sendQueuedGoalMessage( + session, + { text: 'Ship queued goal' }, + new GoalSetMessageComponent(), + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript.indexOf('Goal set')).toBeLessThan(transcript.indexOf('Ship queued goal')); + expect(transcript.indexOf('Ship queued goal')).toBeLessThan( + transcript.indexOf('assistant reply'), + ); + }); + + it('does not let an old queued-goal rejection reset the new runtime UI', async () => { + let rejectPrompt!: (error: Error) => void; + const oldSession = makeSession({ + prompt: vi.fn(() => new Promise((_, reject) => (rejectPrompt = reject))), + }); + const { driver } = await makeDriver(oldSession); + const send = driver.sendQueuedGoalMessage( + oldSession, + { text: 'Old queued goal' }, + new GoalSetMessageComponent(), + ); + driver.resetSessionRuntime(); + driver.session = makeSession({ id: 'ses-new' }); + driver.state.appState.streamingPhase = 'thinking'; + driver.state.livePane.mode = 'thinking'; + + rejectPrompt(new Error('old prompt failed')); + + await expect(send).rejects.toThrow('old prompt failed'); + expect(driver.state.appState.streamingPhase).toBe('thinking'); + expect(driver.state.livePane.mode).toBe('thinking'); + expect(stripSgr(renderTranscript(driver))).not.toContain('Old queued goal'); + }); + + it('does not append an old queued goal after the runtime resets before resolve', async () => { + let resolvePrompt!: () => void; + const oldSession = makeSession({ + prompt: vi.fn(() => new Promise((resolve) => (resolvePrompt = resolve))), + }); + const { driver } = await makeDriver(oldSession); + const send = driver.sendQueuedGoalMessage( + oldSession, + { text: 'Old queued goal' }, + new GoalSetMessageComponent(), + ); + driver.resetSessionRuntime(); + driver.session = makeSession({ id: 'ses-new' }); + driver.state.appState.streamingPhase = 'waiting'; + + resolvePrompt(); + + await expect(send).rejects.toThrow('session runtime changed'); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(stripSgr(renderTranscript(driver))).not.toContain('Old queued goal'); + }); + it('persists bash input to input history with a leading !', async () => { const { driver } = await makeDriver(); driver.state.appState.streamingPhase = 'waiting'; driver.state.appState.inputMode = 'bash'; driver.state.editor.inputMode = 'bash'; - driver.handleUserInput('ls'); + await driver.handleUserInput('ls'); expect(driver.persistInputHistory).toHaveBeenCalledWith('!ls'); }); @@ -1677,7 +2363,7 @@ command = "vim" it('persists normal input to input history', async () => { const { driver } = await makeDriver(); - driver.handleUserInput('hello'); + await driver.handleUserInput('hello'); expect(driver.persistInputHistory).toHaveBeenCalledWith('hello'); }); @@ -1694,7 +2380,7 @@ command = "vim" driver.state.editor.onCtrlS?.(); - expect(session.steer).toHaveBeenCalledWith('focus on tests'); + expect(session.steer).toHaveBeenCalledWith([{ type: 'text', text: 'focus on tests' }]); expect(driver.state.queuedMessages).toEqual([ { text: 'ls', agentId: 'main', mode: 'bash' }, ]); @@ -1769,7 +2455,7 @@ command = "vim" driver.state.appState.inputMode = 'bash'; driver.state.editor.inputMode = 'bash'; - driver.handleUserInput('ls'); + await driver.handleUserInput('ls'); await Promise.resolve(); expect(harness.track).toHaveBeenCalledWith('shell_command', undefined); @@ -2104,7 +2790,7 @@ command = "vim" const { driver, session } = await makeDriver(); driver.state.appState.model = ''; - driver.handleUserInput('hello'); + await driver.handleUserInput('hello'); expect(session.prompt).not.toHaveBeenCalled(); expect(driver.state.transcriptContainer.render(120).join('\n')).toContain('LLM not set'); @@ -2113,7 +2799,7 @@ command = "vim" it('dispatches /init to the active session and clears busy state after completion', async () => { let resolveInit: (() => void) | undefined; const session = makeSession({ - init: vi.fn( + generateAgentsMd: vi.fn( () => new Promise((resolve) => { resolveInit = resolve; @@ -2123,10 +2809,10 @@ command = "vim" const { driver, harness } = await makeDriver(session); harness.track.mockClear(); - driver.handleUserInput('/init'); + await driver.handleUserInput('/init'); await vi.waitFor(() => { - expect(session.init).toHaveBeenCalledTimes(1); + expect(session.generateAgentsMd).toHaveBeenCalledTimes(1); }); expect(session.prompt).not.toHaveBeenCalled(); expect(driver.state.appState.streamingPhase).not.toBe('idle'); @@ -2148,13 +2834,16 @@ command = "vim" driver.state.appState.streamingPhase = 'composing'; driver.state.livePane.mode = 'thinking'; - driver.handleUserInput('/btw What are you working on right now?'); + await driver.handleUserInput('/btw What are you working on right now?'); await vi.waitFor(() => { expect(session.startBtw).toHaveBeenCalledWith(); }); await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith('What are you working on right now?'); + expect(session.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'What are you working on right now?' }], + { agentId: 'agent-btw' }, + ); }); expect(session.steer).not.toHaveBeenCalled(); expect(driver.state.appState.streamingPhase).toBe('composing'); @@ -2166,7 +2855,7 @@ command = "vim" const session = makeSession(); const { driver } = await makeDriver(session); - driver.handleUserInput('/btw'); + await driver.handleUserInput('/btw'); await vi.waitFor(() => { expect(session.startBtw).toHaveBeenCalledWith(); @@ -2174,10 +2863,13 @@ command = "vim" expect(session.prompt).not.toHaveBeenCalled(); expect(stripSgr(renderBtwPanel(driver))).toContain('Ready for a side question...'); - driver.handleUserInput('What are you working on right now?'); + await driver.handleUserInput('What are you working on right now?'); await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith('What are you working on right now?'); + expect(session.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'What are you working on right now?' }], + { agentId: 'agent-btw' }, + ); }); expect(session.steer).not.toHaveBeenCalled(); expect(stripSgr(renderBtwPanel(driver))).toContain('Q: What are you working on right now?'); @@ -2187,7 +2879,7 @@ command = "vim" const session = makeSession(); const { driver } = await makeDriver(session); - driver.handleUserInput('/btw'); + await driver.handleUserInput('/btw'); await vi.waitFor(() => { expect(session.startBtw).toHaveBeenCalledWith(); @@ -2490,8 +3182,8 @@ command = "vim" const session = makeSession(); const { driver, harness } = await makeDriver(session); const cancelledAgentIds: string[] = []; - session.cancel.mockImplementation(async () => { - cancelledAgentIds.push(harness.interactiveAgentId); + session.cancel.mockImplementation(async (options?: { agentId?: string }) => { + cancelledAgentIds.push(options?.agentId ?? 'main'); }); await openBtwPanel(driver, session); driver.state.appState.streamingPhase = 'waiting'; @@ -2552,12 +3244,12 @@ command = "vim" .mockResolvedValueOnce(nextSession); const { driver, harness } = await makeDriver(initialSession, { createSession }); const cancelledAgentIds: string[] = []; - initialSession.cancel.mockImplementation(async () => { - cancelledAgentIds.push(harness.interactiveAgentId); + initialSession.cancel.mockImplementation(async (options?: { agentId?: string }) => { + cancelledAgentIds.push(options?.agentId ?? 'main'); }); await openBtwPanel(driver, initialSession); - driver.handleUserInput('/new'); + await driver.handleUserInput('/new'); await vi.waitFor(() => { expect(driver.getCurrentSessionId()).toBe('ses-next'); @@ -2642,10 +3334,13 @@ command = "vim" const panel = getMountedBtwPanel(driver); expect(panel.isRunning()).toBe(false); - driver.handleUserInput('follow up'); + await driver.handleUserInput('follow up'); await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith('follow up'); + expect(session.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'follow up' }], + { agentId: 'agent-btw' }, + ); }); expect(session.prompt).toHaveBeenCalledTimes(2); expect(driver.state.btwPanelContainer.children).toHaveLength(2); @@ -2667,8 +3362,8 @@ command = "vim" await openBtwPanel(driver, session, 'slow side question'); expect(harness.interactiveAgentId).toBe('main'); - driver.handleUserInput('follow-up while btw prompt is pending'); - driver.handleUserInput('another follow-up while btw prompt is pending'); + await driver.handleUserInput('follow-up while btw prompt is pending'); + await driver.handleUserInput('another follow-up while btw prompt is pending'); expect(session.prompt).toHaveBeenCalledTimes(1); expect(driver.state.queuedMessages).toEqual([]); @@ -2713,13 +3408,16 @@ command = "vim" const firstPanel = getMountedBtwPanel(driver); expect(firstPanel.isRunning()).toBe(true); - driver.handleUserInput('/btw second question'); + await driver.handleUserInput('/btw second question'); await vi.waitFor(() => { expect(session.startBtw).toHaveBeenCalledTimes(2); }); await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith('second question'); + expect(session.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'second question' }], + { agentId: 'agent-btw-2' }, + ); }); const secondPanel = getMountedBtwPanel(driver); @@ -2757,12 +3455,12 @@ command = "vim" const { driver, session } = await makeDriver(); driver.state.appState.model = ''; - driver.handleUserInput('/btw'); + await driver.handleUserInput('/btw'); expect(session.startBtw).not.toHaveBeenCalled(); expect(driver.state.btwPanelContainer.children).toHaveLength(0); expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); - driver.handleUserInput('/btw What are you doing now?'); + await driver.handleUserInput('/btw What are you doing now?'); expect(session.startBtw).not.toHaveBeenCalled(); expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); @@ -2811,10 +3509,10 @@ command = "vim" const { driver, session } = await makeDriver(undefined); driver.state.appState.permissionMode = 'auto'; - driver.handleUserInput('/swarm Ship feature X'); + await driver.handleUserInput('/swarm Ship feature X'); await vi.waitFor(() => { - expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, { trigger: 'task' }); }); await vi.waitFor(() => { expect(countOccurrences(stripSgr(renderTranscript(driver)), 'Swarm activated')).toBe(1); @@ -2843,7 +3541,7 @@ command = "vim" it('queues Ctrl-S input instead of steering while /init is running', async () => { let resolveInit: (() => void) | undefined; const session = makeSession({ - init: vi.fn( + generateAgentsMd: vi.fn( () => new Promise((resolve) => { resolveInit = resolve; @@ -2852,9 +3550,9 @@ command = "vim" }); const { driver } = await makeDriver(session); - driver.handleUserInput('/init'); + await driver.handleUserInput('/init'); await vi.waitFor(() => { - expect(session.init).toHaveBeenCalledTimes(1); + expect(session.generateAgentsMd).toHaveBeenCalledTimes(1); }); driver.state.editor.setText('apply after init'); @@ -2869,7 +3567,10 @@ command = "vim" resolveInit?.(); await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith('apply after init'); + expect(session.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'apply after init' }], + { agentId: 'main' }, + ); }); expect(driver.state.queuedMessages).toEqual([]); }); @@ -2877,7 +3578,7 @@ command = "vim" it('cancels the active /init request through the session', async () => { let resolveInit: (() => void) | undefined; const session = makeSession({ - init: vi.fn( + generateAgentsMd: vi.fn( () => new Promise((resolve) => { resolveInit = resolve; @@ -2886,9 +3587,9 @@ command = "vim" }); const { driver } = await makeDriver(session); - driver.handleUserInput('/init'); + await driver.handleUserInput('/init'); await vi.waitFor(() => { - expect(session.init).toHaveBeenCalledTimes(1); + expect(session.generateAgentsMd).toHaveBeenCalledTimes(1); }); driver.state.editor.onEscape?.(); @@ -2904,9 +3605,9 @@ command = "vim" const { driver, session } = await makeDriver(); driver.state.appState.model = ''; - driver.handleUserInput('/init'); + await driver.handleUserInput('/init'); - expect(session.init).not.toHaveBeenCalled(); + expect(session.generateAgentsMd).not.toHaveBeenCalled(); expect(driver.state.transcriptContainer.render(120).join('\n')).toContain('LLM not set'); }); @@ -3039,19 +3740,19 @@ command = "vim" expect(countOccurrences(transcript, 'non-duplicated plan work')).toBe(1); }); - const approvalHandler = vi.mocked(session.setApprovalHandler).mock.calls[0]?.[0] as - | ((request: ApprovalRequest) => Promise) - | undefined; - if (approvalHandler === undefined) throw new Error('expected approval handler'); - void approvalHandler({ - turnId: 1, - toolCallId: 'call_exit_plan', - toolName: 'ExitPlanMode', - action: 'Review plan', - display: { - kind: 'plan_review', - plan: planContent, - path: '/tmp/no-duplicate-plan.md', + session.approvals.add({ + id: 'approval-plan', + agentId: 'main', + request: { + turnId: 1, + toolCallId: 'call_exit_plan', + toolName: 'ExitPlanMode', + action: 'Review plan', + display: { + kind: 'plan_review', + plan: planContent, + path: '/tmp/no-duplicate-plan.md', + }, }, }); @@ -3513,19 +4214,19 @@ command = "vim" expect(countOccurrences(transcript, 'keep this plan visible after reject')).toBe(1); }); - const approvalHandler = vi.mocked(session.setApprovalHandler).mock.calls[0]?.[0] as - | ((request: ApprovalRequest) => Promise) - | undefined; - if (approvalHandler === undefined) throw new Error('expected approval handler'); - const response = approvalHandler({ - turnId: 1, - toolCallId: 'call_exit_reject_plan', - toolName: 'ExitPlanMode', - action: 'Review plan', - display: { - kind: 'plan_review', - plan: planContent, - path: '/tmp/reject-plan.md', + session.approvals.add({ + id: 'approval-plan-reject', + agentId: 'main', + request: { + turnId: 1, + toolCallId: 'call_exit_reject_plan', + toolName: 'ExitPlanMode', + action: 'Review plan', + display: { + kind: 'plan_review', + plan: planContent, + path: '/tmp/reject-plan.md', + }, }, }); @@ -3533,7 +4234,12 @@ command = "vim" expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ApprovalPanelComponent); }); (driver.state.editorContainer.children[0] as ApprovalPanelComponent).handleInput('2'); - await expect(response).resolves.toMatchObject({ decision: 'rejected' }); + await vi.waitFor(() => { + expect(session.approvals.decide).toHaveBeenCalledWith( + 'approval-plan-reject', + expect.objectContaining({ decision: 'rejected' }), + ); + }); driver.sessionEventHandler.handleEvent( { @@ -3575,7 +4281,7 @@ command = "vim" const getStatus = vi.mocked(session.getStatus); const previousStatusCalls = getStatus.mock.calls.length; - driver.handleUserInput('/status'); + await driver.handleUserInput('/status'); await vi.waitFor(() => { expect(getStatus).toHaveBeenCalledTimes(previousStatusCalls + 1); @@ -3625,7 +4331,7 @@ command = "vim" const listMcpServers = vi.mocked(session.listMcpServers); const previousCalls = listMcpServers.mock.calls.length; - driver.handleUserInput('/mcp'); + await driver.handleUserInput('/mcp'); await vi.waitFor(() => { expect(listMcpServers).toHaveBeenCalledTimes(previousCalls + 1); @@ -3654,7 +4360,7 @@ command = "vim" }); const { driver } = await makeDriver(session); - driver.handleUserInput('/mcp'); + await driver.handleUserInput('/mcp'); await vi.waitFor(() => { const output = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); @@ -3670,7 +4376,7 @@ command = "vim" }); const { driver } = await makeDriver(session); - driver.handleUserInput('/mcp'); + await driver.handleUserInput('/mcp'); await vi.waitFor(() => { const output = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); @@ -3680,38 +4386,38 @@ command = "vim" it('toggles plugin MCP servers from the text command', async () => { const session = makeSession(); - const { driver } = await makeDriver(session); + const { driver, harness } = await makeDriver(session); - driver.handleUserInput('/plugins mcp enable kimi-datasource data'); + await driver.handleUserInput('/plugins mcp enable kimi-datasource data'); await vi.waitFor(() => { - expect(session.setPluginMcpServerEnabled).toHaveBeenCalledWith( - 'kimi-datasource', - 'data', - true, - ); + expect(harness.setPluginMcpServerEnabled).toHaveBeenCalledWith({ + id: 'kimi-datasource', + server: 'data', + enabled: true, + }); }); }); it('errors when /plugins install has no argument', async () => { const session = makeSession(); - const { driver } = await makeDriver(session); + const { driver, harness } = await makeDriver(session); - driver.handleUserInput('/plugins install'); + await driver.handleUserInput('/plugins install'); await vi.waitFor(() => { expect(stripSgr(renderTranscript(driver))).toContain( 'Usage: /plugins install ', ); }); - expect(session.installPlugin).not.toHaveBeenCalled(); + expect(harness.installPlugin).not.toHaveBeenCalled(); }); it('installs from a positional source on /plugins install after trusting it', async () => { const session = makeSession(); - const { driver } = await makeDriver(session); + const { driver, harness } = await makeDriver(session); - driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); + await driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf( @@ -3723,17 +4429,17 @@ command = "vim" confirm.handleInput('\r'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith( - resolve('/tmp/proj-a', './plugins/kimi-datasource'), - ); + expect(harness.installPlugin).toHaveBeenCalledWith({ + source: resolve('/tmp/proj-a', './plugins/kimi-datasource'), + }); }); }); it('does not install when the third-party trust prompt is dismissed', async () => { const session = makeSession(); - const { driver } = await makeDriver(session); + const { driver, harness } = await makeDriver(session); - driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); + await driver.handleUserInput('/plugins install ./plugins/kimi-datasource'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf( @@ -3746,7 +4452,7 @@ command = "vim" await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); }); - expect(session.installPlugin).not.toHaveBeenCalled(); + expect(harness.installPlugin).not.toHaveBeenCalled(); }); it('loads a local plugin marketplace file and installs from it', async () => { @@ -3769,9 +4475,9 @@ command = "vim" ); process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = marketplacePath; const session = makeSession(); - const { driver } = await makeDriver(session); + const { driver, harness } = await makeDriver(session); - driver.handleUserInput('/plugins marketplace'); + await driver.handleUserInput('/plugins marketplace'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); @@ -3787,9 +4493,9 @@ command = "vim" panel.handleInput('\r'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith( - 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', - ); + expect(harness.installPlugin).toHaveBeenCalledWith({ + source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + }); }); await vi.waitFor(() => { const transcript = stripSgr(renderTranscript(driver)); @@ -3823,10 +4529,10 @@ command = "vim" const installPlugin = vi.fn(async () => { throw new Error('install failed'); }); - const session = makeSession({ installPlugin }); - const { driver } = await makeDriver(session); + const session = makeSession(); + const { driver } = await makeDriver(session, { installPlugin }); - driver.handleUserInput('/plugins marketplace'); + await driver.handleUserInput('/plugins marketplace'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); @@ -3865,10 +4571,10 @@ command = "vim" 'utf8', ); const session = makeSession(); - const { driver } = await makeDriver(session); + const { driver, harness } = await makeDriver(session); // Passing the marketplace path opens the panel directly on the Third-party tab. - driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); + await driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); @@ -3889,7 +4595,9 @@ command = "vim" confirm.handleInput('\r'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'superpowers')); + expect(harness.installPlugin).toHaveBeenCalledWith({ + source: join(marketplaceDir, 'superpowers'), + }); }); }); @@ -3913,10 +4621,10 @@ command = "vim" const installPlugin = vi.fn(async () => { throw new Error('install failed'); }); - const session = makeSession({ installPlugin }); - const { driver } = await makeDriver(session); + const session = makeSession(); + const { driver } = await makeDriver(session, { installPlugin }); - driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); + await driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); @@ -3945,9 +4653,9 @@ command = "vim" it('removes a plugin record without auto-running any cleanup skill', async () => { const session = makeSession(); - const { driver } = await makeDriver(session); + const { driver, harness } = await makeDriver(session); - driver.handleUserInput('/plugins remove kimi-webbridge'); + await driver.handleUserInput('/plugins remove kimi-webbridge'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf( @@ -3959,7 +4667,7 @@ command = "vim" confirm.handleInput('\r'); await vi.waitFor(() => { - expect(session.removePlugin).toHaveBeenCalledWith('kimi-webbridge'); + expect(harness.removePlugin).toHaveBeenCalledWith({ id: 'kimi-webbridge' }); }); expect(session.activateSkill).not.toHaveBeenCalled(); }); @@ -3978,10 +4686,10 @@ command = "vim" ], })))); const session = makeSession(); - const { driver } = await makeDriver(session); + const { driver, harness } = await makeDriver(session); try { - driver.handleUserInput('/plugins marketplace'); + await driver.handleUserInput('/plugins marketplace'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); @@ -3996,9 +4704,9 @@ command = "vim" panel.handleInput('\r'); await vi.waitFor(() => { - expect(session.installPlugin).toHaveBeenCalledWith( - 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', - ); + expect(harness.installPlugin).toHaveBeenCalledWith({ + source: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + }); }); expect(globalThis.fetch).toHaveBeenCalledWith(KIMI_CODE_PLUGIN_MARKETPLACE_URL); } finally { @@ -4019,7 +4727,7 @@ command = "vim" const { driver } = await makeDriver(session); try { - driver.handleUserInput('/plugins'); + await driver.handleUserInput('/plugins'); // The panel opens immediately on the Installed tab — no marketplace fetch. await vi.waitFor(() => { @@ -4042,7 +4750,11 @@ command = "vim" it('toggles plugins from the Installed tab with space', async () => { let enabled = true; - const session = makeSession({ + const session = makeSession(); + const setPluginEnabled = vi.fn(async ({ enabled: nextEnabled }: { id: string; enabled: boolean }) => { + enabled = nextEnabled; + }); + const { driver, harness } = await makeDriver(session, { listPlugins: vi.fn(async () => [ { id: 'demo', @@ -4057,13 +4769,10 @@ command = "vim" source: 'local-path', }, ]), - setPluginEnabled: vi.fn(async (_id: string, nextEnabled: boolean) => { - enabled = nextEnabled; - }), + setPluginEnabled, }); - const { driver } = await makeDriver(session); - driver.handleUserInput('/plugins'); + await driver.handleUserInput('/plugins'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); @@ -4076,7 +4785,7 @@ command = "vim" expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); await vi.waitFor(() => { - expect(session.setPluginEnabled).toHaveBeenCalledWith('demo', false); + expect(harness.setPluginEnabled).toHaveBeenCalledWith({ id: 'demo', enabled: false }); }); await vi.waitFor(() => { const refreshed = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); @@ -4092,7 +4801,13 @@ command = "vim" ['metadata', true], ['data', true], ]); - const session = makeSession({ + const session = makeSession(); + const setPluginMcpServerEnabled = vi.fn( + async ({ server, enabled }: { id: string; server: string; enabled: boolean }) => { + serverEnabled.set(server, enabled); + }, + ); + const { driver, harness } = await makeDriver(session, { listPlugins: vi.fn(async () => [ { id: 'kimi-datasource', @@ -4139,13 +4854,10 @@ command = "vim" ], diagnostics: [], })), - setPluginMcpServerEnabled: vi.fn(async (_id: string, _server: string, nextEnabled: boolean) => { - serverEnabled.set(_server, nextEnabled); - }), + setPluginMcpServerEnabled, }); - const { driver } = await makeDriver(session); - driver.handleUserInput('/plugins'); + await driver.handleUserInput('/plugins'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); @@ -4163,11 +4875,11 @@ command = "vim" mcpPicker.handleInput(' '); await vi.waitFor(() => { - expect(session.setPluginMcpServerEnabled).toHaveBeenCalledWith( - 'kimi-datasource', - 'data', - false, - ); + expect(harness.setPluginMcpServerEnabled).toHaveBeenCalledWith({ + id: 'kimi-datasource', + server: 'data', + enabled: false, + }); }); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginMcpSelectorComponent); @@ -4181,16 +4893,16 @@ command = "vim" it('requires confirmation before /plugins remove removes a plugin', async () => { const session = makeSession(); - const { driver } = await makeDriver(session); + const { driver, harness } = await makeDriver(session); - driver.handleUserInput('/plugins remove demo'); + await driver.handleUserInput('/plugins remove demo'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf( PluginRemoveConfirmComponent, ); }); - expect(session.removePlugin).not.toHaveBeenCalled(); + expect(harness.removePlugin).not.toHaveBeenCalled(); const confirm = driver.state.editorContainer.children[0] as PluginRemoveConfirmComponent; expect(stripSgr(confirm.render(120).join('\n'))).toContain('Remove demo (demo)?'); @@ -4199,11 +4911,12 @@ command = "vim" await vi.waitFor(() => { expect(stripSgr(renderTranscript(driver))).toContain('Remove cancelled: demo.'); }); - expect(session.removePlugin).not.toHaveBeenCalled(); + expect(harness.removePlugin).not.toHaveBeenCalled(); }); it('renders /plugins info to the transcript', async () => { - const session = makeSession({ + const session = makeSession(); + const { driver, harness } = await makeDriver(session, { listPlugins: vi.fn(async () => [ { id: 'demo', @@ -4218,12 +4931,11 @@ command = "vim" }, ]), }); - const { driver } = await makeDriver(session); - driver.handleUserInput('/plugins demo'); + await driver.handleUserInput('/plugins demo'); await vi.waitFor(() => { - expect(session.getPluginInfo).toHaveBeenCalledWith('demo'); + expect(harness.getPluginInfo).toHaveBeenCalledWith({ id: 'demo' }); }); }); @@ -4254,7 +4966,7 @@ command = "vim" setConfig, }); - driver.handleUserInput('/model turbo'); + await driver.handleUserInput('/model turbo'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); @@ -4312,7 +5024,7 @@ command = "vim" setConfig, }); - driver.handleUserInput('/model turbo'); + await driver.handleUserInput('/model turbo'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); @@ -4350,7 +5062,7 @@ command = "vim" setConfig, }); - driver.handleUserInput('/model k2'); + await driver.handleUserInput('/model k2'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); @@ -4407,7 +5119,7 @@ command = "vim" } ).refreshOAuthProviderModels = refreshOAuthProviderModels; - driver.handleUserInput('/model'); + await driver.handleUserInput('/model'); await vi.waitFor(() => { const picker = driver.state.editorContainer.children[0]; @@ -4444,7 +5156,7 @@ command = "vim" vi.useFakeTimers(); try { - driver.handleUserInput('/model'); + await driver.handleUserInput('/model'); await Promise.resolve(); expect(refreshOAuthProviderModels).toHaveBeenCalledOnce(); @@ -4504,7 +5216,7 @@ command = "vim" harness.createSession.mockResolvedValueOnce(nextSession); const write = vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); - driver.handleUserInput('/new'); + await driver.handleUserInput('/new'); await vi.waitFor(() => { expect(harness.createSession).toHaveBeenCalledTimes(2); @@ -4553,7 +5265,7 @@ command = "vim" try { process.title = 'kimi-test-runner'; - driver.handleUserInput('/fork ignored args'); + await driver.handleUserInput('/fork ignored args'); await vi.waitFor(() => { expect(forkSession).toHaveBeenCalledWith({ @@ -4581,7 +5293,7 @@ command = "vim" }); const { driver } = await makeDriver(makeSession({ id: 'ses-source' }), { forkSession }); - driver.handleUserInput('/fork'); + await driver.handleUserInput('/fork'); await vi.waitFor(() => { expect(forkSession).toHaveBeenCalledWith({ @@ -4798,7 +5510,7 @@ describe('/model status displayName override', () => { setConfig, }); - driver.handleUserInput('/model turbo'); + await driver.handleUserInput('/model turbo'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); @@ -4838,7 +5550,7 @@ describe('/effort support_efforts override', () => { })), }); - driver.handleUserInput('/effort max'); + await driver.handleUserInput('/effort max'); await vi.waitFor(() => { expect(renderTranscript(driver)).toContain('Unsupported thinking effort "max" for k2. Available: off, low, high'); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 79a36d70f2..91015586a2 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { log, type GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; +import type { GoalSnapshot } from '#/core/index'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; import { describe, expect, it, vi } from 'vitest'; @@ -113,8 +113,8 @@ function makeSession(overrides: Record = {}) { maxContextTokens: 100, contextUsage: 0.1, })), - setApprovalHandler: vi.fn(), - setQuestionHandler: vi.fn(), + approvals: makeApprovalsBroker(), + questions: makeQuestionsBroker(), setModel: vi.fn(async () => {}), setThinking: vi.fn(async () => {}), setPermission: vi.fn(async () => {}), @@ -128,6 +128,25 @@ function makeSession(overrides: Record = {}) { }; } +function makeApprovalsBroker() { + return { + list: vi.fn(() => []), + onDidChangePending: vi.fn(() => () => {}), + onDidResolve: vi.fn(() => () => {}), + decide: vi.fn(), + }; +} + +function makeQuestionsBroker() { + return { + list: vi.fn(() => []), + onDidChangePending: vi.fn(() => () => {}), + onDidResolve: vi.fn(() => () => {}), + answer: vi.fn(), + dismiss: vi.fn(), + }; +} + function goalSnapshot(overrides: Partial = {}): GoalSnapshot { return { goalId: 'goal-1', @@ -188,6 +207,11 @@ function loginRequiredError(): Error & { readonly code: string } { }); } +/** Flush the async lazy-session-creation chain triggered by the first input. */ +async function flushLazySessionStart(times = 50): Promise { + for (let i = 0; i < times; i++) await Promise.resolve(); +} + function makeHarness(session = makeSession(), overrides: Record = {}) { return { getConfig: vi.fn(async () => ({ @@ -195,6 +219,13 @@ function makeHarness(session = makeSession(), overrides: Record k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), + getStartupState: vi.fn(async () => ({ + model: 'k2', + maxContextTokens: 100, + permissionMode: 'manual', + planMode: false, + thinkingEffort: 'off', + })), createSession: vi.fn(async () => session), resumeSession: vi.fn(async () => session), listSessions: vi.fn(async () => []), @@ -238,7 +269,7 @@ function captureInputListeners(driver: StartupDriver) { } describe('KimiTUI startup', () => { - it('creates a fresh session from startup flags and syncs runtime state', async () => { + it('defers fresh startup session creation and seeds state from startup defaults', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', @@ -255,16 +286,36 @@ describe('KimiTUI startup', () => { await expect(driver.init()).resolves.toBe(false); + // Startup itself creates no session: the initial state comes from harness + // defaults (getStartupState) with the CLI flags applied on top. + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: 'k2', + permissionMode: 'yolo', + planMode: true, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + }); + + // The session is created later from that same state. + await (driver as any).createNewSession(); + expect(harness.createSession).toHaveBeenCalledWith({ workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', permission: 'yolo', planMode: true, }); - expect(session.setApprovalHandler).toHaveBeenCalledOnce(); - expect(session.setQuestionHandler).toHaveBeenCalledOnce(); + expect(session.approvals.onDidChangePending).toHaveBeenCalledOnce(); + expect(session.approvals.onDidResolve).toHaveBeenCalledOnce(); + expect(session.questions.onDidChangePending).toHaveBeenCalledOnce(); + expect(session.questions.onDidResolve).toHaveBeenCalledOnce(); expect(harness.setTelemetryContext).toHaveBeenCalledWith({ sessionId: null }); expect(harness.setTelemetryContext).toHaveBeenLastCalledWith({ sessionId: 'ses-1' }); - expect(driver.state.startupState).toBe('ready'); expect(driver.state.appState).toMatchObject({ sessionId: 'ses-1', model: 'k2', @@ -539,7 +590,7 @@ describe('KimiTUI startup', () => { expect(driver.state.appState.goal).toEqual(goal); }); - it('syncs goal state regardless of the goal flag', async () => { + it('syncs goal state after the deferred session creation, regardless of the goal flag', async () => { const goal = goalSnapshot(); const session = makeSession({ getGoal: vi.fn(async () => ({ goal })), @@ -549,7 +600,13 @@ describe('KimiTUI startup', () => { await expect(driver.init()).resolves.toBe(false); - expect(session.getGoal).toHaveBeenCalledOnce(); + // No session yet: nothing to read the goal from. + expect(session.getGoal).not.toHaveBeenCalled(); + expect(driver.state.appState.goal).toBeNull(); + + await (driver as any).createNewSession(); + + expect(session.getGoal).toHaveBeenCalled(); expect(driver.state.appState.goal).toEqual(goal); }); @@ -564,6 +621,7 @@ describe('KimiTUI startup', () => { const driver = makeDriver(harness, makeStartupInput()) as unknown as RuntimeStateDriver; await expect(driver.init()).resolves.toBe(false); + await (driver as any).createNewSession(); expect(driver.state.appState.goal).toEqual(goal); await driver.closeSession('test close'); @@ -571,16 +629,22 @@ describe('KimiTUI startup', () => { expect(driver.state.appState.goal).toBeNull(); }); - it('passes the CLI model override when creating a fresh startup session', async () => { + it('passes the CLI model override when the deferred startup session is created', async () => { const harness = makeHarness(); const driver = makeDriver(harness, makeStartupInput({ model: 'kimi-code/k2.5' })); await expect(driver.init()).resolves.toBe(false); + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState.model).toBe('kimi-code/k2.5'); + + await (driver as any).createNewSession(); + expect(harness.createSession).toHaveBeenCalledWith({ workDir: '/tmp/proj-a', model: 'kimi-code/k2.5', - permission: undefined, + thinking: 'off', + permission: 'manual', planMode: undefined, }); }); @@ -1115,7 +1179,7 @@ describe('KimiTUI startup', () => { expect(showStatus).toHaveBeenCalledWith("New Models · +2 models."); }); - it("starts TUI without a session when fresh startup needs OAuth login", async () => { + it("enters the login-required state when the deferred session creation needs OAuth login", async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { throw loginRequiredError(); @@ -1124,6 +1188,13 @@ describe('KimiTUI startup', () => { const driver = makeDriver(harness, makeStartupInput()); await expect(driver.init()).resolves.toBe(false); + // Startup itself succeeds without a session; the OAuth failure surfaces + // when the first message tries to create one. + expect(driver.state.appState.sessionId).toBe(''); + expect((driver as any).startupNotice).toBeUndefined(); + + (driver as any).sendNormalUserInput('hello'); + await flushLazySessionStart(); expect(driver.state.startupState).toBe('ready'); expect((driver as any).startupNotice).toContain('OAuth login expired'); @@ -1150,10 +1221,6 @@ describe('KimiTUI startup', () => { contextUsage: 0.1, })), }); - const createSession = vi - .fn() - .mockRejectedValueOnce(loginRequiredError()) - .mockResolvedValueOnce(session); const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ defaultModel: 'k2', @@ -1162,7 +1229,6 @@ describe('KimiTUI startup', () => { k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), - createSession, }); const driver = makeDriver(harness, makeStartupInput({ yolo: true, plan: true })); @@ -1170,7 +1236,7 @@ describe('KimiTUI startup', () => { expect(driver.state.appState).toMatchObject({ sessionId: '', - model: '', + model: 'k2', permissionMode: 'yolo', planMode: true, }); @@ -1178,12 +1244,20 @@ describe('KimiTUI startup', () => { vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await handleLoginCommand(driver as any); - expect(createSession).toHaveBeenNthCalledWith(1, { - workDir: '/tmp/proj-a', - permission: 'yolo', + // Login only records the model choice; the session stays deferred, and + // the CLI intents survive untouched in appState. + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: 'k2', + permissionMode: 'yolo', planMode: true, }); - expect(createSession).toHaveBeenNthCalledWith(2, { + + // When the session is finally created, every intent is applied. + await (driver as any).createNewSession(); + + expect(harness.createSession).toHaveBeenCalledWith({ workDir: '/tmp/proj-a', model: 'k2', thinking: 'off', @@ -1199,22 +1273,15 @@ describe('KimiTUI startup', () => { }); it('does not force manual permission after OAuth login without --yolo', async () => { - const session = makeSession({ - getStatus: vi.fn(async () => ({ + const session = makeSession(); + const harness = makeHarness(session, { + getStartupState: vi.fn(async () => ({ model: 'k2', - thinkingEffort: 'off', - permission: 'auto', - planMode: false, - contextTokens: 10, maxContextTokens: 100, - contextUsage: 0.1, + permissionMode: 'auto', + planMode: false, + thinkingEffort: 'off', })), - }); - const createSession = vi - .fn() - .mockRejectedValueOnce(loginRequiredError()) - .mockResolvedValueOnce(session); - const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ defaultModel: 'k2', thinking: { enabled: false }, @@ -1222,22 +1289,19 @@ describe('KimiTUI startup', () => { k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), - createSession, }); const driver = makeDriver(harness, makeStartupInput()); await expect(driver.init()).resolves.toBe(false); + // The configured default permission ('auto') seeds the footer directly. + expect(driver.state.appState.permissionMode).toBe('auto'); + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await handleLoginCommand(driver as any); - expect(createSession).toHaveBeenNthCalledWith(2, { - workDir: '/tmp/proj-a', - model: 'k2', - thinking: 'off', - permission: undefined, - planMode: undefined, - }); + expect(harness.createSession).not.toHaveBeenCalled(); expect(driver.state.appState).toMatchObject({ + model: 'k2', permissionMode: 'auto', }); }); @@ -1261,9 +1325,10 @@ describe('KimiTUI startup', () => { vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await handleLoginCommand(driver as any); - expect(session.setModel).toHaveBeenCalledWith('k2'); - // `thinking.enabled === true` means "leave the session's current thinking - // level alone" — only an explicit `enabled === false` forces `'off'`. + // No session exists to retarget — the choice is recorded in appState only. + // `thinking.enabled === true` yields no explicit effort, so the current + // appState thinking level is left alone. + expect(session.setModel).not.toHaveBeenCalled(); expect(session.setThinking).not.toHaveBeenCalled(); expect(driver.state.appState).toMatchObject({ model: 'k2', @@ -1311,8 +1376,7 @@ describe('KimiTUI startup', () => { }); }); - it('logs login failures with session context', async () => { - const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + it('surfaces login failures to the user', async () => { const session = makeSession(); const loginError = new Error('Failed to list Kimi Code models (HTTP 402).'); const harness = makeHarness(session, { @@ -1327,33 +1391,18 @@ describe('KimiTUI startup', () => { }); const driver = makeDriver(harness, makeStartupInput()); - try { - await expect(driver.init()).resolves.toBe(false); + await expect(driver.init()).resolves.toBe(false); - vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); - await handleLoginCommand(driver as any); + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); + await handleLoginCommand(driver as any); - expect(harness.auth.login).toHaveBeenCalledWith( - 'managed:kimi-code', - expect.objectContaining({ - signal: expect.any(AbortSignal), - onDeviceCode: expect.any(Function), - }), - ); - expect(warn).toHaveBeenCalledWith( - 'login failed', - expect.objectContaining({ - providerName: 'managed:kimi-code', - alreadyLoggedIn: false, - sessionId: 'ses-1', - error: expect.objectContaining({ - message: 'Failed to list Kimi Code models (HTTP 402).', - }), - }), - ); - } finally { - warn.mockRestore(); - } + expect(harness.auth.login).toHaveBeenCalledWith( + 'managed:kimi-code', + expect.objectContaining({ + signal: expect.any(AbortSignal), + onDeviceCode: expect.any(Function), + }), + ); }); it('tracks logout after managed credentials and session state are cleared', async () => { @@ -1377,6 +1426,7 @@ describe('KimiTUI startup', () => { const driver = makeDriver(harness, makeStartupInput()); await expect(driver.init()).resolves.toBe(false); + await (driver as any).createNewSession(); harness.track.mockClear(); vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); @@ -1418,6 +1468,7 @@ describe('KimiTUI startup', () => { const driver = makeDriver(harness, makeStartupInput()); await expect(driver.init()).resolves.toBe(false); + await (driver as any).createNewSession(); harness.track.mockClear(); vi.mocked(promptLogoutProviderSelection).mockResolvedValue('openai'); @@ -1543,9 +1594,9 @@ describe('KimiTUI startup', () => { expect(driver.terminalFocusTrackingDispose).toBeUndefined(); }); - it('keeps non-login startup session errors fatal', async () => { + it('keeps non-login startup state errors fatal', async () => { const harness = makeHarness(makeSession(), { - createSession: vi.fn(async () => { + getStartupState: vi.fn(async () => { throw new Error('provider config is invalid'); }), }); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 5e4e11670f..1bf0767b21 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -13,6 +13,8 @@ import type { } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; +import { COMPACTION_SUMMARY_PREFIX } from '#/core/index'; + import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; @@ -186,8 +188,8 @@ function makeSession( contextUsage: 0, })), getGoal: vi.fn(async () => ({ goal: null })), - setApprovalHandler: vi.fn(), - setQuestionHandler: vi.fn(), + approvals: makeFakeApprovalsBroker(), + questions: makeFakeQuestionsBroker(), setModel: vi.fn(async () => {}), setThinking: vi.fn(async () => {}), setPermission: vi.fn(async () => {}), @@ -203,6 +205,25 @@ function makeSession( } as unknown as Session; } +function makeFakeApprovalsBroker() { + return { + list: vi.fn(() => []), + onDidChangePending: vi.fn(() => () => {}), + onDidResolve: vi.fn(() => () => {}), + decide: vi.fn(), + }; +} + +function makeFakeQuestionsBroker() { + return { + list: vi.fn(() => []), + onDidChangePending: vi.fn(() => () => {}), + onDidResolve: vi.fn(() => () => {}), + answer: vi.fn(), + dismiss: vi.fn(), + }; +} + function makeHarness(initialSession: Session) { const interactiveAgentScope = new AsyncLocalStorage(); return { @@ -212,6 +233,13 @@ function makeHarness(initialSession: Session) { }, })), setConfig: vi.fn(async () => ({ providers: {} })), + getStartupState: vi.fn(async () => ({ + model: 'k2', + maxContextTokens: 100, + permissionMode: 'manual', + planMode: false, + thinkingEffort: 'off', + })), createSession: vi.fn(async () => initialSession), resumeSession: vi.fn(async () => initialSession), forkSession: vi.fn(async () => initialSession), @@ -308,6 +336,35 @@ describe('KimiTUI resume message replay', () => { expect(transcript).not.toContain('Goal complete'); }); + it('renders shell command input as a `$` prompt when replaying', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: '\nls -la\n' }], { + origin: { kind: 'shell_command', phase: 'input' }, + }), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('$ ls -la'); + expect(transcript).not.toContain(''); + const userEntry = driver.state.transcriptEntries.find((entry) => entry.kind === 'user'); + expect(userEntry?.bullet).toBe(''); + }); + + it('renders shell command output through the display formatter when replaying', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [{ type: 'text', text: 'out-lineerr-line' }], + { origin: { kind: 'shell_command', phase: 'output' } }, + ), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('out-line'); + expect(transcript).toContain('err-line'); + expect(transcript).not.toContain(''); + }); + it('unescapes bash tag delimiters when replaying shell output', async () => { const driver = await replayIntoDriver([ message( @@ -326,6 +383,75 @@ describe('KimiTUI resume message replay', () => { expect(transcript).toContain('prepost'); }); + it('renders a compaction record as a collapsible card with token counts', async () => { + const driver = await replayIntoDriver([ + { + time: REPLAY_TIME, + type: 'compaction', + result: { + summary: 'Compacted summary.', + compactedCount: 5, + tokensBefore: 1000, + tokensAfter: 250, + }, + instruction: undefined, + }, + ]); + + const entries = driver.state.transcriptEntries; + expect(entries).toHaveLength(1); + expect(entries[0]?.compactionData).toMatchObject({ + summary: 'Compacted summary.', + tokensBefore: 1000, + tokensAfter: 250, + }); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('Compaction complete'); + expect(transcript).toContain('1000 → 250 tokens'); + // Collapsed by default: the summary body is not dumped into the transcript. + expect(transcript).not.toContain('Compacted summary.'); + }); + + it('renders pre-compaction assistant and tool messages from the replay', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'before compaction' }]), + { + time: REPLAY_TIME, + type: 'compaction', + result: { summary: 's', compactedCount: 1, tokensBefore: 10, tokensAfter: 5 }, + instruction: undefined, + }, + message('user', [{ type: 'text', text: 'run ls' }]), + message('assistant', [{ type: 'text', text: 'Running it now.' }], { + toolCalls: [toolCall('tc1', 'Bash', { command: 'ls' })], + }), + message('tool', [{ type: 'text', text: 'file.ts' }], { toolCallId: 'tc1' }), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('before compaction'); + expect(transcript).toContain('Running it now.'); + expect(transcript).toContain('file.ts'); + }); + + it('renders a compaction summary message as a collapsible compaction card', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [{ type: 'text', text: `${COMPACTION_SUMMARY_PREFIX}\nDid things.` }], + { origin: { kind: 'compaction_summary' } }, + ), + ]); + + const entries = driver.state.transcriptEntries; + expect(entries).toHaveLength(1); + expect(entries[0]?.compactionData?.summary).toBe('Did things.'); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('Compaction complete'); + // Collapsed by default: the summary body is not dumped into the transcript. + expect(transcript).not.toContain('Did things.'); + }); + it('does not render neutral goal completion context reminders as transcript messages', async () => { const driver = await replayIntoDriver([ message( @@ -805,7 +931,7 @@ describe('KimiTUI resume message replay', () => { driver.sessionEventHandler.handleEvent( { - type: 'background.task.terminated', + type: 'task.terminated', agentId: 'main', sessionId: 'ses-replay', info: { ...info, status: 'timed_out', endedAt: 2 }, @@ -843,11 +969,11 @@ describe('KimiTUI resume message replay', () => { [ message('user', [{ type: 'text', text: 'Background task lost.' }], { origin: { - kind: 'background_task', + kind: 'task', taskId: 'bash-lost0000', status: 'lost', notificationId: 'task:bash-lost0000:lost', - }, + } as unknown as PromptOrigin, }), ], { diff --git a/apps/kimi-code/test/tui/reverse-rpc/approval.test.ts b/apps/kimi-code/test/tui/reverse-rpc/approval.test.ts deleted file mode 100644 index c2e29c6c26..0000000000 --- a/apps/kimi-code/test/tui/reverse-rpc/approval.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { ApprovalRequest } from '@moonshot-ai/kimi-code-sdk'; -import { describe, expect, it, vi } from 'vitest'; - -import { ApprovalController } from '#/tui/reverse-rpc/approval/controller'; -import { createApprovalRequestHandler } from '#/tui/reverse-rpc/approval/handler'; - -function approvalEvent(overrides: Partial = {}): ApprovalRequest { - return { - toolCallId: 'tc-1', - toolName: 'Bash', - action: 'run command', - display: { - kind: 'generic', - summary: 'run command', - detail: { - command: 'rm -rf /tmp/cache', - cwd: '/tmp', - }, - }, - ...overrides, - }; -} - -describe('approval reverse-rpc', () => { - it('auto-approves queued requests with the same action when the current is approved for session', async () => { - const controller = new ApprovalController(); - const panel = (id: string, action: string) => ({ - id, - tool_call_id: id, - tool_name: 'Bash', - action, - description: '', - display: [], - choices: [], - }); - - const first = controller.show(panel('tc-1', 'run command: ls')); - const second = controller.show(panel('tc-2', 'run command: ls')); - const third = controller.show(panel('tc-3', 'edit src/x.ts')); - const fourth = controller.show(panel('tc-4', 'run command: ls')); - - controller.respond({ decision: 'approved', scope: 'session', feedback: 'ok' }); - - await expect(first).resolves.toEqual({ - decision: 'approved', - scope: 'session', - feedback: 'ok', - }); - // Queued same-action requests inherit a session-scoped approval without - // surfacing another panel. The user's feedback is not carried over — - // it described the first request only. - await expect(second).resolves.toEqual({ decision: 'approved', scope: 'session' }); - await expect(fourth).resolves.toEqual({ decision: 'approved', scope: 'session' }); - // A different-action request still waits for an explicit decision. - expect(controller.hasPending()).toBe(true); - - controller.respond({ decision: 'rejected' }); - await expect(third).resolves.toEqual({ decision: 'rejected' }); - }); - - it('does not auto-approve queued requests when only approved-once is chosen', async () => { - const controller = new ApprovalController(); - const panel = (id: string) => ({ - id, - tool_call_id: id, - tool_name: 'Bash', - action: 'run command: ls', - description: '', - display: [], - choices: [], - }); - - const first = controller.show(panel('tc-1')); - const second = controller.show(panel('tc-2')); - - controller.respond({ decision: 'approved' }); - - await expect(first).resolves.toEqual({ decision: 'approved' }); - // The second same-action request must NOT be auto-resolved — approve-once - // is a one-shot decision, not a session rule. - expect(controller.hasPending()).toBe(true); - controller.respond({ decision: 'approved' }); - await expect(second).resolves.toEqual({ decision: 'approved' }); - }); - - it('ApprovalController cancels pending requests with a cancelled response', async () => { - const controller = new ApprovalController(); - const pending = controller.show({ - id: 'req-1', - tool_call_id: 'tc-1', - tool_name: 'Bash', - action: 'run', - description: '', - display: [], - choices: [], - }); - - controller.cancelAll('closed'); - - await expect(pending).resolves.toEqual({ - decision: 'cancelled', - feedback: 'closed', - }); - }); - - it('adapts approval payloads through the handler and falls back on failure', async () => { - const controller = new ApprovalController(); - const show = vi.spyOn(controller, 'show').mockResolvedValue({ - decision: 'approved', - scope: 'session', - feedback: 'looks good', - }); - const handler = createApprovalRequestHandler(controller); - - await expect(handler(approvalEvent())).resolves.toEqual({ - decision: 'approved', - scope: 'session', - feedback: 'looks good', - }); - expect(show).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'tc-1', - tool_call_id: 'tc-1', - tool_name: 'Bash', - display: [ - expect.objectContaining({ - type: 'shell', - command: 'rm -rf /tmp/cache', - cwd: '/tmp', - danger: 'recursive delete', - }), - ], - }), - ); - - show.mockRejectedValueOnce(new Error('boom')); - await expect(handler(approvalEvent())).resolves.toEqual({ - decision: 'cancelled', - feedback: 'approval handler failed', - }); - }); -}); diff --git a/apps/kimi-code/test/tui/reverse-rpc/base-controller.test.ts b/apps/kimi-code/test/tui/reverse-rpc/base-controller.test.ts deleted file mode 100644 index c755c4dd9d..0000000000 --- a/apps/kimi-code/test/tui/reverse-rpc/base-controller.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { ReverseRpcController } from '#/tui/reverse-rpc/base-controller'; - -class TestController extends ReverseRpcController { - protected createCancelResponse(reason: string): string { - return `cancel:${reason}`; - } -} - -describe('ReverseRpcController', () => { - it('shows a payload, resolves the pending promise on respond, and hides the panel', async () => { - const controller = new TestController(); - const showPanel = vi.fn(); - const hidePanel = vi.fn(); - controller.setUIHooks({ showPanel, hidePanel }); - - const pending = controller.show('payload'); - expect(controller.hasPending()).toBe(true); - expect(showPanel).toHaveBeenCalledWith('payload'); - - controller.respond('approved'); - - await expect(pending).resolves.toBe('approved'); - expect(controller.hasPending()).toBe(false); - expect(hidePanel).toHaveBeenCalledOnce(); - }); - - it('queues concurrent show() requests and presents them one at a time', async () => { - const controller = new TestController(); - const showPanel = vi.fn(); - const hidePanel = vi.fn(); - controller.setUIHooks({ showPanel, hidePanel }); - - const first = controller.show('first'); - const second = controller.show('second'); - const third = controller.show('third'); - - // Only the first is presented; the rest stay queued. - expect(showPanel).toHaveBeenCalledTimes(1); - expect(showPanel).toHaveBeenLastCalledWith('first'); - expect(controller.hasPending()).toBe(true); - - controller.respond('answer-first'); - await expect(first).resolves.toBe('answer-first'); - // Advancing to the next queued request reuses the same panel without - // hiding it in between. - expect(hidePanel).not.toHaveBeenCalled(); - expect(showPanel).toHaveBeenCalledTimes(2); - expect(showPanel).toHaveBeenLastCalledWith('second'); - - controller.respond('answer-second'); - await expect(second).resolves.toBe('answer-second'); - expect(showPanel).toHaveBeenCalledTimes(3); - expect(showPanel).toHaveBeenLastCalledWith('third'); - - controller.respond('answer-third'); - await expect(third).resolves.toBe('answer-third'); - expect(controller.hasPending()).toBe(false); - expect(hidePanel).toHaveBeenCalledTimes(1); - }); - - it('auto-resolves matching queued requests via the autoResolveFor hook', async () => { - class AutoController extends ReverseRpcController< - { action: string; id: string }, - string - > { - protected createCancelResponse(reason: string): string { - return `cancel:${reason}`; - } - protected override autoResolveFor( - resolved: { action: string; id: string }, - response: string, - queued: { action: string; id: string }, - ): string | undefined { - if (response === 'approve_all_same' && resolved.action === queued.action) { - return `auto:${queued.id}`; - } - return undefined; - } - } - const controller = new AutoController(); - const showPanel = vi.fn(); - const hidePanel = vi.fn(); - controller.setUIHooks({ showPanel, hidePanel }); - - const first = controller.show({ action: 'run', id: 'a' }); - const second = controller.show({ action: 'run', id: 'b' }); - const third = controller.show({ action: 'edit', id: 'c' }); - const fourth = controller.show({ action: 'run', id: 'd' }); - - controller.respond('approve_all_same'); - - await expect(first).resolves.toBe('approve_all_same'); - await expect(second).resolves.toBe('auto:b'); - await expect(fourth).resolves.toBe('auto:d'); - // The non-matching request advances to the panel and stays pending. - expect(showPanel).toHaveBeenLastCalledWith({ action: 'edit', id: 'c' }); - expect(controller.hasPending()).toBe(true); - - controller.respond('approve_all_same'); - await expect(third).resolves.toBe('approve_all_same'); - expect(controller.hasPending()).toBe(false); - expect(hidePanel).toHaveBeenCalledTimes(1); - }); - - it('cancelAll cancels the current request and every queued request', async () => { - const controller = new TestController(); - const hidePanel = vi.fn(); - controller.setUIHooks({ showPanel: vi.fn(), hidePanel }); - - const first = controller.show('first'); - const second = controller.show('second'); - const third = controller.show('third'); - - controller.cancelAll('shutdown'); - - await expect(first).resolves.toBe('cancel:shutdown'); - await expect(second).resolves.toBe('cancel:shutdown'); - await expect(third).resolves.toBe('cancel:shutdown'); - expect(controller.hasPending()).toBe(false); - expect(hidePanel).toHaveBeenCalledTimes(1); - }); -}); diff --git a/apps/kimi-code/test/tui/reverse-rpc/index.test.ts b/apps/kimi-code/test/tui/reverse-rpc/index.test.ts deleted file mode 100644 index 790fd00dc7..0000000000 --- a/apps/kimi-code/test/tui/reverse-rpc/index.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { ApprovalController } from '#/tui/reverse-rpc/approval/controller'; -import { registerReverseRPCHandlers } from '#/tui/reverse-rpc/index'; -import { QuestionController } from '#/tui/reverse-rpc/question/controller'; - -describe('registerReverseRPCHandlers', () => { - it('wires controller UI hooks without registering wire request handlers', async () => { - const approvalController = new ApprovalController(); - const questionController = new QuestionController(); - const uiHooks = { - showApprovalPanel: vi.fn(), - hideApprovalPanel: vi.fn(), - showQuestionDialog: vi.fn(), - hideQuestionDialog: vi.fn(), - }; - - registerReverseRPCHandlers(approvalController, questionController, uiHooks); - - const approvalPending = approvalController.show({ - id: 'req-1', - tool_call_id: 'tc-1', - tool_name: 'Bash', - action: 'run', - description: '', - display: [], - choices: [], - }); - expect(uiHooks.showApprovalPanel).toHaveBeenCalledWith( - expect.objectContaining({ id: 'req-1' }), - ); - approvalController.cancelAll('bye'); - await expect(approvalPending).resolves.toEqual({ - decision: 'cancelled', - feedback: 'bye', - }); - expect(uiHooks.hideApprovalPanel).toHaveBeenCalledOnce(); - - const questionPending = questionController.show({ - id: 'q-1', - tool_call_id: 'tc-1', - questions: [], - }); - expect(uiHooks.showQuestionDialog).toHaveBeenCalledWith(expect.objectContaining({ id: 'q-1' })); - questionController.cancelAll('bye'); - await expect(questionPending).resolves.toEqual({ answers: [] }); - expect(uiHooks.hideQuestionDialog).toHaveBeenCalledOnce(); - }); - - it('queues question dialogs behind active approval panels', async () => { - const approvalController = new ApprovalController(); - const questionController = new QuestionController(); - const uiHooks = { - showApprovalPanel: vi.fn(), - hideApprovalPanel: vi.fn(), - showQuestionDialog: vi.fn(), - hideQuestionDialog: vi.fn(), - }; - - registerReverseRPCHandlers(approvalController, questionController, uiHooks); - - const approvalPending = approvalController.show({ - id: 'approval-1', - tool_call_id: 'tc-1', - tool_name: 'Bash', - action: 'run', - description: '', - display: [], - choices: [], - }); - const questionPending = questionController.show({ - id: 'question-1', - tool_call_id: 'tq-1', - questions: [], - }); - - expect(uiHooks.showApprovalPanel).toHaveBeenCalledWith( - expect.objectContaining({ id: 'approval-1' }), - ); - expect(uiHooks.showQuestionDialog).not.toHaveBeenCalled(); - - approvalController.respond({ decision: 'approved' }); - await expect(approvalPending).resolves.toEqual({ decision: 'approved' }); - expect(uiHooks.hideApprovalPanel).toHaveBeenCalledOnce(); - expect(uiHooks.showQuestionDialog).toHaveBeenCalledWith( - expect.objectContaining({ id: 'question-1' }), - ); - - questionController.respond({ answers: ['answer'] }); - await expect(questionPending).resolves.toEqual({ answers: ['answer'] }); - expect(uiHooks.hideQuestionDialog).toHaveBeenCalledOnce(); - }); - - it('queues approval panels behind active question dialogs', async () => { - const approvalController = new ApprovalController(); - const questionController = new QuestionController(); - const uiHooks = { - showApprovalPanel: vi.fn(), - hideApprovalPanel: vi.fn(), - showQuestionDialog: vi.fn(), - hideQuestionDialog: vi.fn(), - }; - - registerReverseRPCHandlers(approvalController, questionController, uiHooks); - - const questionPending = questionController.show({ - id: 'question-1', - tool_call_id: 'tq-1', - questions: [], - }); - const approvalPending = approvalController.show({ - id: 'approval-1', - tool_call_id: 'tc-1', - tool_name: 'Bash', - action: 'run', - description: '', - display: [], - choices: [], - }); - - expect(uiHooks.showQuestionDialog).toHaveBeenCalledWith( - expect.objectContaining({ id: 'question-1' }), - ); - expect(uiHooks.showApprovalPanel).not.toHaveBeenCalled(); - - questionController.respond({ answers: ['answer'] }); - await expect(questionPending).resolves.toEqual({ answers: ['answer'] }); - expect(uiHooks.hideQuestionDialog).toHaveBeenCalledOnce(); - expect(uiHooks.showApprovalPanel).toHaveBeenCalledWith( - expect.objectContaining({ id: 'approval-1' }), - ); - - approvalController.respond({ decision: 'approved' }); - await expect(approvalPending).resolves.toEqual({ decision: 'approved' }); - expect(uiHooks.hideApprovalPanel).toHaveBeenCalledOnce(); - }); - - it('removes queued modals when their controller is cancelled', async () => { - const approvalController = new ApprovalController(); - const questionController = new QuestionController(); - const uiHooks = { - showApprovalPanel: vi.fn(), - hideApprovalPanel: vi.fn(), - showQuestionDialog: vi.fn(), - hideQuestionDialog: vi.fn(), - }; - - registerReverseRPCHandlers(approvalController, questionController, uiHooks); - - const approvalPending = approvalController.show({ - id: 'approval-1', - tool_call_id: 'tc-1', - tool_name: 'Bash', - action: 'run', - description: '', - display: [], - choices: [], - }); - const questionPending = questionController.show({ - id: 'question-1', - tool_call_id: 'tq-1', - questions: [], - }); - - questionController.cancelAll('closed'); - await expect(questionPending).resolves.toEqual({ answers: [] }); - expect(uiHooks.hideQuestionDialog).not.toHaveBeenCalled(); - - approvalController.respond({ decision: 'approved' }); - await expect(approvalPending).resolves.toEqual({ decision: 'approved' }); - expect(uiHooks.showQuestionDialog).not.toHaveBeenCalled(); - }); - - it('clears active and queued modals without showing queued entries', async () => { - const approvalController = new ApprovalController(); - const questionController = new QuestionController(); - const uiHooks = { - showApprovalPanel: vi.fn(), - hideApprovalPanel: vi.fn(), - showQuestionDialog: vi.fn(), - hideQuestionDialog: vi.fn(), - }; - - const disposers = registerReverseRPCHandlers(approvalController, questionController, uiHooks); - - const approvalPending = approvalController.show({ - id: 'approval-1', - tool_call_id: 'tc-1', - tool_name: 'Bash', - action: 'run', - description: '', - display: [], - choices: [], - }); - const questionPending = questionController.show({ - id: 'question-1', - tool_call_id: 'tq-1', - questions: [], - }); - - for (const dispose of disposers) dispose(); - expect(uiHooks.hideApprovalPanel).toHaveBeenCalledOnce(); - expect(uiHooks.showQuestionDialog).not.toHaveBeenCalled(); - - approvalController.cancelAll('closed'); - questionController.cancelAll('closed'); - await expect(approvalPending).resolves.toEqual({ - decision: 'cancelled', - feedback: 'closed', - }); - await expect(questionPending).resolves.toEqual({ answers: [] }); - expect(uiHooks.hideQuestionDialog).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/kimi-code/test/tui/reverse-rpc/question.test.ts b/apps/kimi-code/test/tui/reverse-rpc/question.test.ts deleted file mode 100644 index 41de65fff9..0000000000 --- a/apps/kimi-code/test/tui/reverse-rpc/question.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { QuestionRequest } from '@moonshot-ai/kimi-code-sdk'; -import { describe, expect, it, vi } from 'vitest'; - -import { QuestionController } from '#/tui/reverse-rpc/question/controller'; -import { createQuestionAskHandler } from '#/tui/reverse-rpc/question/handler'; - -function questionEvent(overrides: Partial = {}): QuestionRequest { - return { - toolCallId: 'q-1', - questions: [ - { - question: 'Q1?', - options: [{ label: 'Alpha' }], - }, - ], - ...overrides, - }; -} - -describe('question reverse-rpc', () => { - it('QuestionController cancels pending requests with an empty answer list', async () => { - const controller = new QuestionController(); - const pending = controller.show({ - id: 'req-1', - tool_call_id: 'tc-1', - questions: [], - }); - - controller.cancelAll('closed'); - - await expect(pending).resolves.toEqual({ answers: [] }); - }); - - it('normalizes question payloads and returns the selected answer', async () => { - const controller = new QuestionController(); - const show = vi - .spyOn(controller, 'show') - .mockResolvedValue({ answers: ['Alpha'], method: 'number_key' }); - const handler = createQuestionAskHandler(controller); - const event = questionEvent({ - questions: [ - { - question: 'Q1?', - header: 'Pick', - body: 'Choose one', - multiSelect: true, - otherLabel: 'Other', - otherDescription: 'Type a custom answer', - options: [{ label: 'Alpha', description: 'First option' }], - }, - ], - }); - - await expect(handler(event)).resolves.toEqual({ - answers: { 'Q1?': 'Alpha' }, - method: 'number_key', - }); - expect(show).toHaveBeenCalledWith({ - id: 'q-1', - tool_call_id: 'q-1', - questions: [ - { - question: 'Q1?', - header: 'Pick', - body: 'Choose one', - multi_select: true, - other_label: 'Other', - other_description: 'Type a custom answer', - options: [{ label: 'Alpha', description: 'First option' }], - }, - ], - }); - - show.mockResolvedValueOnce({ answers: [''] }); - await expect(handler(questionEvent())).resolves.toBeNull(); - - show.mockRejectedValueOnce(new Error('boom')); - await expect(handler(questionEvent())).resolves.toBeNull(); - }); - - it('maps multiple question answers by question text', async () => { - const controller = new QuestionController(); - const show = vi - .spyOn(controller, 'show') - .mockResolvedValue({ answers: ['Alpha', 'SQLite'], method: 'enter' }); - const handler = createQuestionAskHandler(controller); - const event = questionEvent({ - toolCallId: 'call_question', - questions: [ - { - question: 'Q1?', - options: [{ label: 'Alpha' }], - }, - { - question: 'Storage?', - header: 'Store', - options: [{ label: 'SQLite' }], - }, - ], - }); - - await expect(handler(event)).resolves.toEqual({ - answers: { - 'Q1?': 'Alpha', - 'Storage?': 'SQLite', - }, - method: 'enter', - }); - expect(show).toHaveBeenCalledWith({ - id: 'call_question', - tool_call_id: 'call_question', - questions: [ - { - question: 'Q1?', - header: undefined, - body: undefined, - multi_select: false, - other_label: undefined, - other_description: undefined, - options: [{ label: 'Alpha', description: undefined }], - }, - { - question: 'Storage?', - header: 'Store', - body: undefined, - multi_select: false, - other_label: undefined, - other_description: undefined, - options: [{ label: 'SQLite', description: undefined }], - }, - ], - }); - }); -}); diff --git a/apps/kimi-code/test/tui/utils/session-picker-rows.test.ts b/apps/kimi-code/test/tui/utils/session-picker-rows.test.ts index 30d56969c2..9585b4bd61 100644 --- a/apps/kimi-code/test/tui/utils/session-picker-rows.test.ts +++ b/apps/kimi-code/test/tui/utils/session-picker-rows.test.ts @@ -1,4 +1,4 @@ -import type { SessionSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { CoreSessionSummary } from '#/core/index'; import { describe, expect, it } from 'vitest'; import { sessionRowsForPicker } from '#/tui/utils/session-picker-rows'; @@ -7,7 +7,7 @@ function summary(input: { readonly id: string; readonly title?: string; readonly lastPrompt?: string; -}): SessionSummary { +}): CoreSessionSummary { return { id: input.id, title: input.title, @@ -16,6 +16,7 @@ function summary(input: { sessionDir: `/tmp/home/sessions/${input.id}`, createdAt: 1, updatedAt: 2, + archived: false, }; } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 90cb12939a..dff9ec7aaf 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -251,6 +251,7 @@ export * from '#/session/sessionActivity/sessionActivityService'; import '#/session/approval/approval'; import '#/session/approval/approvalService'; export { ISessionApprovalService } from '#/session/approval/approval'; +export type { ApprovalRequest as SessionApprovalRequest, ApprovalResponse as SessionApprovalResponse } from '#/session/approval/approval'; export * from '#/session/question/question'; export * from '#/session/question/questionService'; import '#/agent/questionTools/tools/ask-user'; @@ -374,6 +375,7 @@ export * from '#/agent/mcp/mcp'; export * from '#/agent/mcp/mcpService'; export * from '#/agent/mcp/mcpDiscoveryOps'; export * from '#/agent/mcp/config-schema'; +export type { McpServerEntry } from '#/agent/mcp/connection-manager'; export * from '#/agent/media/mediaTools'; export * from '#/agent/media/mediaToolsRegistrar'; export * from '#/agent/media/registerMediaTools'; @@ -408,6 +410,7 @@ export * from '#/agent/shellCommand/shellCommand'; export * from '#/agent/shellCommand/shellCommandService'; export * from '#/agent/rpc/rpc'; export * from '#/agent/rpc/rpcService'; +export type { McpServerInfo } from '#/agent/rpc/core-api'; export * from '#/agent/scopeContext/scopeContext'; export * from '#/session/btw/btw'; export * from '#/session/btw/btwService';