diff --git a/.agents/skills/agent-core-dev/errors.md b/.agents/skills/agent-core-dev/errors.md index 254cec98d..e0af433ce 100644 --- a/.agents/skills/agent-core-dev/errors.md +++ b/.agents/skills/agent-core-dev/errors.md @@ -7,7 +7,7 @@ Base classes and serialization are **centralized** in `_base/errors`; error **co ## Where things live - `src/_base/errors/errors.ts`: base classes — `Error2`, `ExpectedError`, `ErrorNoTelemetry`, `BugIndicatingError`, `NotImplementedError`, plus `isError2` and `unwrapErrorCause`. -- `src/_base/errors/codes.ts`: the `ErrorDomain` contract, the `ErrorCode` type (aliased to the protocol's `KimiErrorCode`), the registry (`registerErrorDomain` / `errorInfo` / `isErrorCode`), and `CoreErrors` (`internal`, `not_implemented`). +- `src/_base/errors/codes.ts`: the `ErrorDomain` contract, the registry (`registerErrorDomain` / `errorInfo` / `isErrorCode`), and `CoreErrors` (`internal`, `not_implemented`). The `ErrorCode` union type is derived by `#/errors` from the aggregated domain definitions. - `src/_base/errors/serialize.ts`: `ErrorPayload`, `isCodedError`, `toErrorPayload`, `fromErrorPayload`. Wire-facing names (`KimiErrorPayload`, `toKimiErrorPayload`) mirror the protocol and are kept as-is. - `src/_base/errors/unexpectedError.ts`: `onUnexpectedError` / `setUnexpectedErrorHandler` (global handler). - `src//errors.ts`: the domain's `XxxErrors` descriptor (codes + retryable list + per-code info overrides), self-registered on import. @@ -17,7 +17,7 @@ Base classes and serialization are **centralized** in `_base/errors`; error **co - **Throw a coded error, not a bare string.** `throw new Error2(ErrorCodes.X, …)`. Bare `new Error` only for unreachable guards; `BugIndicatingError` for caller bugs; `NotImplementedError('feature')` for stubs. - **Define codes in the owning domain**, in `/errors.ts` as an `XxxErrors` descriptor (`satisfies ErrorDomain` + `registerErrorDomain`), then wire it into the facade. Never add domain codes to `_base/errors`. -- **One `code` per failure mode.** Codes read `domain.reason`. The valid code strings are fixed by the protocol (`KimiErrorCode` in `packages/protocol/src/events.ts`): **add new codes to the protocol first**. Renaming/removing a code is a major. +- **One `code` per failure mode.** Codes read `domain.reason`. The valid code strings are derived from the aggregated domain definitions (`ErrorCode` in `#/errors` is computed from the `ErrorCodes` aggregate): **add new codes to the owning domain's `errors.ts`** — registration throws on cross-domain collisions. Renaming/removing a code is a major. - **Translate foreign errors at the boundary.** Provider/HTTP, fs, MCP errors are re-thrown as the owning domain's coded error. `_base/errors` never imports a business domain. - **Translation is idempotent and cause-preserving.** Translators (`toHostFsError`, `toStorageIoError`) pass through an already-translated error and always keep the original as `cause`. - **`details` is structured and JSON-serializable; `message` is a short human sentence.** Paths/errnos/scope/key go into `details`, not the message. @@ -35,6 +35,6 @@ Base classes and serialization are **centralized** in `_base/errors`; error **co ## Red lines (this topic) - Throw a coded error with a `code`, not a bare string (except unreachable guards / `BugIndicatingError` / `NotImplementedError`). -- Codes live in the owning domain's `errors.ts` and self-register; new codes land in the protocol first. +- Codes live in the owning domain's `errors.ts` and self-register; new codes land in the owning domain first. - Translate foreign errors at the owning domain's boundary, idempotently, with `cause` and structured `details`; `_base/errors` never imports a business domain. - Branch on `code` across the wire, never `instanceof`. diff --git a/.agents/skills/agent-core-dev/server-align.md b/.agents/skills/agent-core-dev/server-align.md index f347005b4..82b9188f1 100644 --- a/.agents/skills/agent-core-dev/server-align.md +++ b/.agents/skills/agent-core-dev/server-align.md @@ -38,20 +38,20 @@ Pick surface → Read the v1 route (if any) → Reuse / add the protocol schema Apply the decision above. For a v1-matched endpoint, the **spec** is the protocol schema plus the existing mirror routes: -- `packages/protocol/src/rest/.ts` — the wire schema you must match. +- `packages/kap-server/src/protocol/rest-.ts` — the wire schema you must match. - `packages/kap-server/src/routes/.ts` — the file you are writing (create it if missing); sibling route files show the conventions. The protocol schema is the source of truth. Do not re-derive the wire shape from memory or from the v2 domain model. ### 2. Reuse (or add) the protocol schema -The wire schema lives in **`@moonshot-ai/protocol`** under `packages/protocol/src/rest/.ts` (e.g. `promptSubmissionSchema`, `promptListResponseSchema`, `configResponseSchema`). Every `/api/v1` route in `packages/kap-server` imports from it — that single import is what guarantees the server speaks the same shape released clients expect. +The wire schema lives in **`packages/kap-server/src/protocol`** under `rest-.ts` (e.g. `promptSubmissionSchema`, `promptListResponseSchema`, `configResponseSchema`) — or in the owning `agent-core-v2` domain contract when the engine's service speaks the shape. Every `/api/v1` route in `packages/kap-server` imports from it — that single import is what guarantees the server speaks the same shape released clients expect. Actions: - **Schema already in protocol** → import it in the server-v2 route and use it in `defineRoute` (`body`, `success.data`, error `dataSchema` / `detailsSchema`). Do **not** re-declare the schema inline in server-v2. -- **Schema missing in protocol** → add it to `packages/protocol/src/rest/.ts` first, with a `rest-.test.ts`, then consume it from the route. The protocol package is the source of truth; server-v2 never owns a v1 wire schema locally. -- **Schema exists but only v1 uses it** → move/keep it in protocol and import it into server-v2; do not fork a copy. +- **Schema missing** → add it to `packages/kap-server/src/protocol/rest-.ts` first (or to the owning v2 domain contract if its service speaks the shape), then consume it from the route. The shared schema is the source of truth; server-v2 never re-declares a v1 wire schema inline. +- **Schema exists but only v1 uses it** → keep it in `packages/kap-server/src/protocol` and import it into server-v2; do not fork a copy. #### Schema-fidelity rule (the hard rule) @@ -59,7 +59,7 @@ For a `/api/v1` endpoint, the request and response schemas **must be the establi - ✅ **Adding** an optional field is allowed (`field: z.string().optional()`). Old clients ignore it; new clients may send it. - ❌ **Renaming** a field, **changing** its type, **tightening** its validation, or **changing its meaning** is a wire break — do not do it in a mirror route. If the v2 domain genuinely needs a different shape, that shape belongs on `/api/v2`, not on the `/api/v1` mirror. -- ❌ Re-declaring the schema inline in server-v2 (even if it "looks identical") is forbidden — it drifts. One schema, one home: `packages/protocol`. +- ❌ Re-declaring the schema inline in server-v2 (even if it "looks identical") is forbidden — it drifts. One schema, one home: the owning `agent-core-v2` domain contract or `packages/kap-server/src/protocol`. Self-check: "would a released v1 client get a byte-identical envelope from `packages/kap-server` for this request?" If you cannot answer yes from the shared schema, the route is wrong. @@ -94,8 +94,8 @@ packages/agent-core-v2/src/Legacy/ Skeleton (matches `prompt/`): ```ts -// prompt.ts — contract shaped by @moonshot-ai/protocol -import type { PromptSubmitResult, PromptSubmission } from '@moonshot-ai/protocol'; +// prompt.ts — contract shaped by the v1 wire schema (kap-server/src/protocol) +import type { PromptSubmitResult, PromptSubmission } from '../../protocol/rest-prompt'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; export interface IAgentPromptService { @@ -128,7 +128,7 @@ Conventions: - **Header comment** must say it is an `L7 edge adapter` and name both the v1 contract it implements and the native v2 Service it leaves untouched (see `prompt.ts`). - **Scope** = the lifetime of the *legacy* state it holds (the `prompt` queue is per-agent → `LifecycleScope.Agent`). Apply [orient.md](orient.md) / [design.md](design.md) normally — a LegacyService is not exempt from scope rules. - **Delegate, do not duplicate** business logic. The LegacyService translates the v1 contract into native-Service calls and translates results back; the real work stays in the native Service. -- **Contract types come from `@moonshot-ai/protocol`**, so the interface cannot drift from the wire shape. +- **Contract types come from the v1 wire schema homes** (the owning v2 domain contract or `kap-server/src/protocol`), so the interface cannot drift from the wire shape. ### 4. Wire the route / actionMap entry @@ -139,9 +139,9 @@ const route = defineRoute( { method: 'POST', path: '/sessions/{session_id}/prompts', - body: promptSubmissionSchema, // ← from @moonshot-ai/protocol + body: promptSubmissionSchema, // ← from kap-server/src/protocol params: sessionIdParamSchema, - success: { data: promptSubmitResultSchema }, // ← from @moonshot-ai/protocol + success: { data: promptSubmitResultSchema }, // ← from kap-server/src/protocol errors: { [ErrorCode.SESSION_NOT_FOUND]: {}, [ErrorCode.SESSION_BUSY]: {}, @@ -169,7 +169,7 @@ app.post(route.path, route.options, route.handler); The route translates domain `KimiError` codes into protocol `ErrorCode` numbers. Two registries must stay in sync: - **Domain code** — register in `agent-core-v2/src/errors.ts` (`ErrorCodes`) and throw from the Service (errors.md). Co-located domain errors go in `Legacy/errors.ts` (e.g. `prompt.not_found`, `session.busy`). -- **Wire code** — register the matching number in `packages/protocol/src/error-codes.ts` and reference it in the route's `errors` map and `sendMappedError`. +- **Wire code** — register the matching number in `packages/kap-server/src/protocol/error-codes.ts` and reference it in the route's `errors` map and `sendMappedError`. ```ts function sendMappedError(reply, requestId, err) { @@ -202,10 +202,10 @@ Where the route mirrors v1, the test is the regression guard for the schema-fide ### 7. Verify - `pnpm -C packages/kap-server test` — server routes green. -- `pnpm -C packages/protocol test` — schema tests green (incl. any new `rest-*.test.ts`). +- `pnpm -C packages/kap-server test` — server routes green (incl. any wire-schema guards). - `pnpm -C packages/agent-core-v2 test` — native + Legacy Service tests green. - `pnpm -C packages/agent-core-v2 run lint:domain` — a LegacyService is still inside the domain layers (edge adapter, L7); it must not pull business code into the edge or invert scope direction. -- `pnpm -C packages/server-e2e ...` when a v1 parity scenario exists. +- `pnpm -C packages/klient test` (optionally with `KIMI_SERVER_URL` for the live legacy suites) when a v1 parity scenario exists. ## Worked example — porting v1 `/sessions/:sid/prompts` @@ -218,9 +218,9 @@ This is the reference alignment (commits `feat(server-v2): port v1 /sessions/:si - `/api/v2` keeps the native shape — `prompts:submit` / `steer` / `undo` / `clear` / `cancel` map to `IAgentRPCService` (a wire facade over the v2 turn driver) in `actionMap`. The native `IAgentPromptService` is untouched. - `/api/v1` gets an `AgentPromptLegacyService` (`prompt/`, `LifecycleScope.Agent`) that re-implements the v1 scheduler — queue, `prompt_id`, steer/abort, auto-start-next — **on top of** the native `IAgentPromptService`. The `/api/v1` routes consume the LegacyService. -**The schema.** Both surfaces import `promptSubmissionSchema` / `promptSubmitResultSchema` / `promptListResponseSchema` / `promptSteerRequestSchema` / `promptSteerResultSchema` / `promptAbortResponseSchema` from `@moonshot-ai/protocol`. The `/api/v1` and `/api/v2` routes are therefore compatible with released clients by construction; the LegacyService projects v2 turn results back into those protocol shapes. +**The schema.** Both surfaces import `promptSubmissionSchema` / `promptSubmitResultSchema` / `promptListResponseSchema` / `promptSteerRequestSchema` / `promptSteerResultSchema` / `promptAbortResponseSchema` from the shared v1 wire schemas (see `packages/kap-server/src/protocol`). The `/api/v1` and `/api/v2` routes are therefore compatible with released clients by construction; the LegacyService projects v2 turn results back into those protocol shapes. -**The errors.** v1 codes (`prompt.not_found`, `session.busy`, `prompt.already_completed`) are registered in `agent-core-v2` (`prompt/errors.ts`) and in `packages/protocol` (`error-codes.ts`), then mapped in the route's `sendMappedError` — including the idempotent `prompt.already_completed` → `40903 { data: { aborted: false } }`. +**The errors.** v1 codes (`prompt.not_found`, `session.busy`, `prompt.already_completed`) are registered in `agent-core-v2` (`prompt/errors.ts`) and in `packages/kap-server/src/protocol` (`error-codes.ts`), then mapped in the route's `sendMappedError` — including the idempotent `prompt.already_completed` → `40903 { data: { aborted: false } }`. **The lesson.** When the v1 contract and the v2 domain disagree, add an adapter (LegacyService) at the edge; do not let the wire contract leak into the native domain. The two surfaces share the protocol schema but not the Service. @@ -230,21 +230,21 @@ Before submitting a server-align change: - [ ] Surface chosen deliberately: `/api/v1` mirror for a v1-matched endpoint, `/api/v2` for a new native capability (both if needed). - [ ] For a `/api/v1` mirror, the route matches the established v1 contract (protocol schema + sibling routes) path-for-path, verb-for-verb, action-for-action. -- [ ] Request and response schemas come from `@moonshot-ai/protocol` (`packages/protocol/src/rest/.ts`); no inline re-declaration in server-v2. +- [ ] Request and response schemas come from their owning home (the `agent-core-v2` domain contract or `packages/kap-server/src/protocol`); no inline re-declaration in server-v2. - [ ] Existing schema fields are unchanged in name, type, and semantics; only optional fields added (if any). - [ ] Native v2 Service left clean; v1-only behavior isolated in a `Legacy` / `ILegacyService` edge adapter when the semantics diverge. - [ ] LegacyService registered with the correct `LifecycleScope` and a header comment naming it an L7 edge adapter + the native Service it preserves. -- [ ] Domain error codes registered in `agent-core-v2`; wire codes registered in `packages/protocol`; route maps them in `sendMappedError`, matching v1's status codes and idempotent envelopes. +- [ ] Domain error codes registered in `agent-core-v2`; wire codes registered in `packages/kap-server/src/protocol`; route maps them in `sendMappedError`, matching v1's status codes and idempotent envelopes. - [ ] Route resolves the scope from the URL by `accessor.get(IX)`; no cached scope; finishes before disposal. -- [ ] Tests assert the wire envelope + protocol shape; schema tests in `packages/protocol` added/updated. +- [ ] Tests assert the wire envelope + protocol shape; wire-shape guards added/updated where the route mirrors v1. - [ ] `lint:domain` passes; the LegacyService did not invert scope or domain direction. ## Red lines (this subskill) -- One wire schema, one home: `packages/protocol`. Never re-declare a v1 wire schema inline in server-v2. +- One wire schema, one home: the owning `agent-core-v2` domain contract or `packages/kap-server/src/protocol`. Never re-declare a v1 wire schema inline in server-v2. - A `/api/v1` mirror route must keep every existing schema field's name, type, and semantics; only optional additions are allowed. A different shape belongs on `/api/v2`, not on the mirror. - Do not distort the native v2 Service to satisfy a v1 quirk — add a `Legacy` edge adapter instead. The native Service serves the v2 architecture; the LegacyService serves the wire contract. - A LegacyService is still a v2 Service: it follows scope, domain-direction, and DI rules. "Edge adapter" describes its role, not an exemption. -- The protocol schema (`packages/protocol/src/rest/.ts`) plus the existing mirror routes are the spec for a `/api/v1` route — match them; do not re-derive the wire shape from the v2 domain model or from memory. -- Register every new error code in **both** `agent-core-v2` and `packages/protocol`; an unmapped code is a wire break. +- The established wire schema (in its owning home — the `agent-core-v2` domain contract or `packages/kap-server/src/protocol`) plus the existing mirror routes are the spec for a `/api/v1` route — match them; do not re-derive the wire shape from the v2 domain model or from memory. +- Register every new error code in **both** `agent-core-v2` and `packages/kap-server/src/protocol/error-codes.ts`; an unmapped code is a wire break. - Events stream over WS (`listen`), never over the REST mirror; do not invent REST polling for something v1 pushed as an event. diff --git a/.changeset/README.md b/.changeset/README.md index d20552ded..3afeb3bdf 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -24,7 +24,6 @@ All other workspace packages are private internal packages, are not published to - `@moonshot-ai/kosong` - `@moonshot-ai/migration-legacy` - `@moonshot-ai/protocol` -- `@moonshot-ai/server-e2e` - `@moonshot-ai/vis` - `@moonshot-ai/vis-server` - `@moonshot-ai/vis-web` diff --git a/.changeset/config.json b/.changeset/config.json index 2320f45e3..0f9da8d75 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,7 +7,6 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ - "@moonshot-ai/server-e2e", "@moonshot-ai/vis", "@moonshot-ai/vis-server", "@moonshot-ai/vis-web" diff --git a/.changeset/debug-zip-timestamped-filename.md b/.changeset/debug-zip-timestamped-filename.md new file mode 100644 index 000000000..3ddb2f955 --- /dev/null +++ b/.changeset/debug-zip-timestamped-filename.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix `/export-debug-zip` and `kimi export` overwriting the previous ZIP archive when run repeatedly on the same session; the default export filename now includes a timestamp. diff --git a/.changeset/record-unexecuted-tool-calls.md b/.changeset/record-unexecuted-tool-calls.md new file mode 100644 index 000000000..b23bf49d3 --- /dev/null +++ b/.changeset/record-unexecuted-tool-calls.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix repeated request rejections after an interrupted model response by recording tool calls that never ran and closing them with an interrupted result. diff --git a/.changeset/secure-fetch-url-ssrf.md b/.changeset/secure-fetch-url-ssrf.md new file mode 100644 index 000000000..eba3c7622 --- /dev/null +++ b/.changeset/secure-fetch-url-ssrf.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the built-in URL fetch tool's network safeguards: crafted domains and redirect chains can no longer reach loopback or internal network services. diff --git a/.changeset/web-drop-workspace-git-badges.md b/.changeset/web-drop-workspace-git-badges.md new file mode 100644 index 000000000..49c42059d --- /dev/null +++ b/.changeset/web-drop-workspace-git-badges.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Remove per-workspace git repo badges and branch labels; branch, PR, and diff status remain shown for the active session. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99d74a539..3ff91c303 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,48 @@ jobs: - name: Smoke test CLI bundle run: pnpm -C apps/kimi-code run smoke + vscode-vsix-package: + name: VSIX package audit (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: all + - os: macos-latest + target: darwin-arm64 + - os: windows-latest + target: win32-x64 + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + - name: Build and audit target VSIX + run: pnpm --filter kimi-code run package:platform -- --target "${{ matrix.target }}" + - name: Run installed VSIX Extension Host smoke (Linux) + if: runner.os == 'Linux' + run: xvfb-run -a pnpm --filter kimi-code run test:extension-host -- --version 1.100.0 + - name: Run installed VSIX Extension Host smoke on stable (Linux) + if: runner.os == 'Linux' + run: xvfb-run -a pnpm --filter kimi-code run test:extension-host -- --version stable + - name: Run installed VSIX Extension Host smoke + if: runner.os != 'Linux' + run: pnpm --filter kimi-code run test:extension-host -- --version 1.100.0 + - uses: actions/upload-artifact@v4 + with: + name: vscode-vsix-${{ matrix.target }} + path: apps/vscode/artifacts/vsix/*.vsix + if-no-files-found: error + test: runs-on: ubuntu-latest strategy: @@ -125,6 +167,8 @@ jobs: echo "Typechecking ${config}" pnpm dlx --package @typescript/native-preview@beta tsgo -p "${config}" --noEmit done + - name: Typecheck VS Code extension + run: pnpm --filter kimi-code run typecheck - name: Typecheck kimi-web (vue-tsc) run: pnpm --filter @moonshot-ai/kimi-web run typecheck - name: Typecheck vis-server diff --git a/.gitignore b/.gitignore index 8d73a08af..1c3bc6f22 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ plugins/cdn/ .kimi-code/local.toml .kimi-sandbox/ .vscode/ +!apps/vscode/.vscode/ +!apps/vscode/.vscode/*.json +apps/vscode/artifacts/ Dockerfile docker-compose.yml diff --git a/AGENTS.md b/AGENTS.md index 2a822e51f..da149b954 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. - `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` and the native `/api/v2` RPC surface); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. -- `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. +- `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/http|ipc|memory`); all three return the same `Klient`. The package also hosts the e2e suites: dual-backend session/agent suites (`test/e2e/dual/`, in-memory + in-process server), `/api/v2` wire tests (`test/e2e/v2/`), the legacy `/api/v1` live suites (`test/e2e/legacy/`), and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. ## Environment Requirements diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 8361900a6..b54a7b4e0 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,151 @@ # @moonshot-ai/kimi-code +## 0.26.0 + +### Minor Changes + +- [#1776](https://github.com/MoonshotAI/kimi-code/pull/1776) [`ffaf0b9`](https://github.com/MoonshotAI/kimi-code/commit/ffaf0b98ca76bb90ba9c989256441dceb468d85f) Thanks [@sailist](https://github.com/sailist)! - Expand the coder subagent tool set to include background tasks, todo lists, plan mode, skill invocation, and nested agents, mirroring the main agent's capabilities; a subagent run also waits for its background tasks to settle before reporting completion. Applies automatically to coder subagents launched through the Agent tool. + +### Patch Changes + +- [#1771](https://github.com/MoonshotAI/kimi-code/pull/1771) [`b513975`](https://github.com/MoonshotAI/kimi-code/commit/b5139757e2df1b5b8723d4bab5137266f5eb0f01) Thanks [@liruifengv](https://github.com/liruifengv)! - Optimize the unit formatting of the context usage display. + +- [#1765](https://github.com/MoonshotAI/kimi-code/pull/1765) [`d531398`](https://github.com/MoonshotAI/kimi-code/commit/d531398d0143cd3b0a2f4a099ff537894c9245e9) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix Kimi-provider models routed through the Anthropic protocol incorrectly showing reasoning effort options. Effort choices now come only from the model's declared metadata, and the inferred fallback profile applies solely to non-Kimi Anthropic-compatible providers. + +- [#1774](https://github.com/MoonshotAI/kimi-code/pull/1774) [`3d5d630`](https://github.com/MoonshotAI/kimi-code/commit/3d5d630c12ea71fb7066e8018dfa2cb6d42da3e8) Thanks [@RealKai42](https://github.com/RealKai42)! - Honor an explicit thinking "off" on OpenAI-compatible (chat completions) providers: it used to be indistinguishable from "never configured", so the history-based auto `reasoning_effort` injection kept the model reasoning (and could leak the field to models that reject it). The provider now also reports the actual current thinking effort ("on"/"off") instead of recording "off" for both. + +- [#1766](https://github.com/MoonshotAI/kimi-code/pull/1766) [`7042af3`](https://github.com/MoonshotAI/kimi-code/commit/7042af3571dbfbf5600535a56692434b84afb4ce) Thanks [@kermanx](https://github.com/kermanx)! - web: Fix the sidebar resize handle being covered by the chat composer background. + +- [#1769](https://github.com/MoonshotAI/kimi-code/pull/1769) [`d1ca65e`](https://github.com/MoonshotAI/kimi-code/commit/d1ca65e1de189617e9edbc54010e62d472a1de3d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Keep legacy migrations idempotent across multiple Kimi homes and report damaged or unmapped sessions instead of silently skipping them. + +- [#1763](https://github.com/MoonshotAI/kimi-code/pull/1763) [`81414b6`](https://github.com/MoonshotAI/kimi-code/commit/81414b6ad5eddb64bbd959a8754ffb6c20b4f6fe) Thanks [@liruifengv](https://github.com/liruifengv)! - Warn in the /model and /effort pickers that switching invalidates the existing prompt cache, and hint to use /new to avoid extra token costs. + +- [#1773](https://github.com/MoonshotAI/kimi-code/pull/1773) [`1169a6d`](https://github.com/MoonshotAI/kimi-code/commit/1169a6d5fdafca4c1455c3ac4889586ef42f4435) Thanks [@RealKai42](https://github.com/RealKai42)! - Replay empty thinking content verbatim instead of substituting a placeholder space on Anthropic-compatible and Kimi preserved-thinking endpoints. + +- [#1781](https://github.com/MoonshotAI/kimi-code/pull/1781) [`09e8554`](https://github.com/MoonshotAI/kimi-code/commit/09e855401be62431b967dcb3b7caf1bcc9705df5) Thanks [@kermanx](https://github.com/kermanx)! - Report when users stop tasks and preserve other stop reasons in model context. + +- [#1784](https://github.com/MoonshotAI/kimi-code/pull/1784) [`d465591`](https://github.com/MoonshotAI/kimi-code/commit/d465591eb3fdb30c0c0348d6edb6f4d3d2f72698) Thanks [@sailist](https://github.com/sailist)! - Fix a resumed session being marked as just updated and jumping to the top of the session list without any new activity. + +- [#1759](https://github.com/MoonshotAI/kimi-code/pull/1759) [`9e3e670`](https://github.com/MoonshotAI/kimi-code/commit/9e3e6700f9276f4ab60219897b297fc96be2355a) Thanks [@sailist](https://github.com/sailist)! - Fix a race where resuming a background subagent right after it was manually stopped could fail with an "already running" error. + +- [#1782](https://github.com/MoonshotAI/kimi-code/pull/1782) [`072eed4`](https://github.com/MoonshotAI/kimi-code/commit/072eed476b5fe7599d994783649a21083320df58) Thanks [@sailist](https://github.com/sailist)! - Fix the context size indicator under-reporting the model's actual context usage. + +- [#1769](https://github.com/MoonshotAI/kimi-code/pull/1769) [`d1ca65e`](https://github.com/MoonshotAI/kimi-code/commit/d1ca65e1de189617e9edbc54010e62d472a1de3d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Support in-process editor hosts with session lifecycle, context, MCP configuration, and cross-platform session storage APIs. + +- [#1772](https://github.com/MoonshotAI/kimi-code/pull/1772) [`78967e2`](https://github.com/MoonshotAI/kimi-code/commit/78967e283d28238337e6437b4824f67b2c3cea7d) Thanks [@sailist](https://github.com/sailist)! - web: Refresh the model catalog for all providers when opening the model picker, so newly available models always show up. + +## 0.25.0 + +### Minor Changes + +- [#1731](https://github.com/MoonshotAI/kimi-code/pull/1731) [`0b790cd`](https://github.com/MoonshotAI/kimi-code/commit/0b790cdc056475593abd572f657d010504caf752) Thanks [@sailist](https://github.com/sailist)! - web: Allow attaching any file type in chat; files the model cannot consume inline (documents, SVG images, archives, …) are uploaded to the server and given to the model as a file path it can read on demand. + +### Patch Changes + +- [#1746](https://github.com/MoonshotAI/kimi-code/pull/1746) [`918c135`](https://github.com/MoonshotAI/kimi-code/commit/918c1354d9ff4a7dc66a02ede3a504d19e1f53d1) Thanks [@RealKai42](https://github.com/RealKai42)! - Honor adaptive_thinking = false on Anthropic-compatible models by limiting thinking efforts to the legacy budget set and omitting the effort parameter from requests. + +- [#1746](https://github.com/MoonshotAI/kimi-code/pull/1746) [`918c135`](https://github.com/MoonshotAI/kimi-code/commit/918c1354d9ff4a7dc66a02ede3a504d19e1f53d1) Thanks [@RealKai42](https://github.com/RealKai42)! - Apply official Anthropic effort profiles and a 128k output fallback for unknown models. Preserve compatible-provider thinking history across session resumes and model switches, normalize incomplete stream events, and warn on unlisted efforts. + +- [#1746](https://github.com/MoonshotAI/kimi-code/pull/1746) [`918c135`](https://github.com/MoonshotAI/kimi-code/commit/918c1354d9ff4a7dc66a02ede3a504d19e1f53d1) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix custom-named models on Anthropic-compatible providers starting new sessions with thinking effort off instead of the model default, and not showing the thinking control in ACP clients. + +- [#1757](https://github.com/MoonshotAI/kimi-code/pull/1757) [`f0c8a10`](https://github.com/MoonshotAI/kimi-code/commit/f0c8a103c620b4a66761c0f34c1a8cc7ece9b86c) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix the diagnostic log missing the actual error when the CLI exits unexpectedly. + +- [#1731](https://github.com/MoonshotAI/kimi-code/pull/1731) [`0b790cd`](https://github.com/MoonshotAI/kimi-code/commit/0b790cdc056475593abd572f657d010504caf752) Thanks [@sailist](https://github.com/sailist)! - Fix the Content-Security-Policy on non-loopback server binds blocking the web UI's theme bootstrap script and bundled fonts, and tighten the policy with explicit form-action, base-uri, and frame-ancestors directives. + +- [#1758](https://github.com/MoonshotAI/kimi-code/pull/1758) [`1d7c205`](https://github.com/MoonshotAI/kimi-code/commit/1d7c205e8397983d3d79e59704db3f67a0c72937) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix the CLI exiting unexpectedly when reading an image from the clipboard fails; it now falls back to pasting text. + +- [#1753](https://github.com/MoonshotAI/kimi-code/pull/1753) [`d8ddabb`](https://github.com/MoonshotAI/kimi-code/commit/d8ddabb605c1f6fdcfa9fade8cc09b5f8c93651f) Thanks [@sailist](https://github.com/sailist)! - Fix the web server bearer-token check being bypassed by percent-encoded API paths (e.g. `/%61pi/v1/…`), which allowed unauthenticated access to every API route. + +- [#1758](https://github.com/MoonshotAI/kimi-code/pull/1758) [`1d7c205`](https://github.com/MoonshotAI/kimi-code/commit/1d7c205e8397983d3d79e59704db3f67a0c72937) Thanks [@RealKai42](https://github.com/RealKai42)! - Report crash telemetry for unhandled promise rejections, so exits they cause are no longer invisible. + +- [#1753](https://github.com/MoonshotAI/kimi-code/pull/1753) [`d8ddabb`](https://github.com/MoonshotAI/kimi-code/commit/d8ddabb605c1f6fdcfa9fade8cc09b5f8c93651f) Thanks [@sailist](https://github.com/sailist)! - Fix the session filesystem API following symlinks that point outside the workspace, which allowed reading, listing, creating, and downloading host files beyond the session directory through a planted symlink. + +- [#1753](https://github.com/MoonshotAI/kimi-code/pull/1753) [`d8ddabb`](https://github.com/MoonshotAI/kimi-code/commit/d8ddabb605c1f6fdcfa9fade8cc09b5f8c93651f) Thanks [@sailist](https://github.com/sailist)! - Fix sessions failing to be created when the workspace directory is given through a symlink, which the v2 engine rejected as "not a directory". + +- [#1754](https://github.com/MoonshotAI/kimi-code/pull/1754) [`1186686`](https://github.com/MoonshotAI/kimi-code/commit/11866865544b8ec88330372b8582e97a35113308) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix completed background subagents losing their final output after a session reload, and retry the output backfill when a transient fetch failure occurs. + +- [#1755](https://github.com/MoonshotAI/kimi-code/pull/1755) [`4f99114`](https://github.com/MoonshotAI/kimi-code/commit/4f99114342da11ebf7a403e3af6e0cf2c8cca431) Thanks [@kermanx](https://github.com/kermanx)! - Move the server's v1 wire schema definitions into the engine domains and the server package, removing the shared schema package from the v2 server stack with no behavior change. + +- [#1731](https://github.com/MoonshotAI/kimi-code/pull/1731) [`0b790cd`](https://github.com/MoonshotAI/kimi-code/commit/0b790cdc056475593abd572f657d010504caf752) Thanks [@sailist](https://github.com/sailist)! - web: Show every attachment a user sends — files, images, and videos — as chips in the message bubble, and let files be attached by dropping them anywhere in the window. + +- [#1744](https://github.com/MoonshotAI/kimi-code/pull/1744) [`b89d385`](https://github.com/MoonshotAI/kimi-code/commit/b89d385fa56915f067d656160086bc3c3126f8a3) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix Enter not confirming modal confirmation dialogs in dev builds, and keep the dialog open with a loading state until the confirmed action (such as archiving a session) completes. + +- [#1756](https://github.com/MoonshotAI/kimi-code/pull/1756) [`e885aec`](https://github.com/MoonshotAI/kimi-code/commit/e885aec7ffa9ee62d122908b68837c5e010d5d04) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show full diagnostics for model request failures — a semantic title, the provider's raw message, and expandable details (error code, HTTP status, request ID) with copy support — instead of a bare "Connection error" toast. + +- [#1751](https://github.com/MoonshotAI/kimi-code/pull/1751) [`df75a0f`](https://github.com/MoonshotAI/kimi-code/commit/df75a0f5c2f2e2dd3291c8adaba96a832ee1f179) Thanks [@kermanx](https://github.com/kermanx)! - web: Keep session activity indicators in sync with agent work, prevent duplicate streamed content after session activation races or LLM retries, and flush durable session events promptly. + +- [#1754](https://github.com/MoonshotAI/kimi-code/pull/1754) [`1186686`](https://github.com/MoonshotAI/kimi-code/commit/11866865544b8ec88330372b8582e97a35113308) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix a background subagent showing up as two identical rows in the agents dock panel during streaming. + +## 0.24.2 + +### Patch Changes + +- [#1704](https://github.com/MoonshotAI/kimi-code/pull/1704) [`38a2363`](https://github.com/MoonshotAI/kimi-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - Align the print-mode run lifecycle across engines: `print_background_mode` and `print_max_turns` now take effect for `kimi -p` on the experimental engine, with the same exit / drain / steer semantics and defaults as the default engine, and `kimi -p "/goal ..."` now stays alive until the goal reaches a terminal state instead of exiting after the first turn. + +- [#1704](https://github.com/MoonshotAI/kimi-code/pull/1704) [`38a2363`](https://github.com/MoonshotAI/kimi-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - Align the subagent timeout across engines: a fixed 2-hour default, overridable with `[subagent] timeout_ms` in config.toml or the KIMI_SUBAGENT_TIMEOUT_MS environment variable. + +- [#1727](https://github.com/MoonshotAI/kimi-code/pull/1727) [`286d3e7`](https://github.com/MoonshotAI/kimi-code/commit/286d3e7aca40a778cc4136eb377e14f14c70141c) Thanks [@liruifengv](https://github.com/liruifengv)! - Add a builtin `check-kimi-code-docs` skill that answers Kimi Code product questions (CLI usage, configuration, membership, error codes) against the official documentation with source links. It triggers automatically on product questions, or run `/check-kimi-code-docs`. + +- [#1707](https://github.com/MoonshotAI/kimi-code/pull/1707) [`8490c3e`](https://github.com/MoonshotAI/kimi-code/commit/8490c3e36b6a6cc3ba5c0f15d93b87347ce23878) Thanks [@sailist](https://github.com/sailist)! - Add the number of messages dropped during compaction retries to the session wire log's LLM request traces. + +- [#1740](https://github.com/MoonshotAI/kimi-code/pull/1740) [`a74ab44`](https://github.com/MoonshotAI/kimi-code/commit/a74ab44ac7d5656e2dd9cf93b8e484936b05a0c8) Thanks [@sailist](https://github.com/sailist)! - Increase the default per-step LLM retry budget from 3 to 10 attempts, so transient provider failures (429 / overload) are retried with exponential backoff for a few minutes before the turn fails. Tune with `loop_control.max_retries_per_step` in config.toml. + +- [#1707](https://github.com/MoonshotAI/kimi-code/pull/1707) [`8490c3e`](https://github.com/MoonshotAI/kimi-code/commit/8490c3e36b6a6cc3ba5c0f15d93b87347ce23878) Thanks [@sailist](https://github.com/sailist)! - Rename the dynamic tool loading model capability from `select_tools` to `dynamically_loaded_tools`, matching the model catalog vocabulary; the `select_tools` tool and the `tool-select` flag are unchanged. + +- [#1698](https://github.com/MoonshotAI/kimi-code/pull/1698) [`722694a`](https://github.com/MoonshotAI/kimi-code/commit/722694adf99c53dc608d417ea6d8c90a5712c33f) Thanks [@chengluyu](https://github.com/chengluyu)! - Enforce goal wall-clock budgets while model or tool work is still running. + +- [#1730](https://github.com/MoonshotAI/kimi-code/pull/1730) [`72f425e`](https://github.com/MoonshotAI/kimi-code/commit/72f425e18d0264010e1442af67ee8d9acf5f0659) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix tool call id collisions across turns for Gemini-protocol models, which merged separate swarm runs into a single card in the web UI. + +- [#1695](https://github.com/MoonshotAI/kimi-code/pull/1695) [`5c0f17c`](https://github.com/MoonshotAI/kimi-code/commit/5c0f17cfcf99c27eb697be11ae9b61243d993e4a) Thanks [@chengluyu](https://github.com/chengluyu)! - Preserve active goal elapsed time across crash recovery. + +- [#1743](https://github.com/MoonshotAI/kimi-code/pull/1743) [`481b28b`](https://github.com/MoonshotAI/kimi-code/commit/481b28b8f4d527c43c640c4d742c52aa006c3bb0) Thanks [@chengluyu](https://github.com/chengluyu)! - Correct the guidance text shown when a goal cannot be paused or resumed. + +- [#1692](https://github.com/MoonshotAI/kimi-code/pull/1692) [`e53cd79`](https://github.com/MoonshotAI/kimi-code/commit/e53cd799572db6b2c73f6938703d586b83013cec) Thanks [@chengluyu](https://github.com/chengluyu)! - Allow goals to use every configured turn before the turn budget stops further work. + +- [#1719](https://github.com/MoonshotAI/kimi-code/pull/1719) [`b24a347`](https://github.com/MoonshotAI/kimi-code/commit/b24a347e20a3efa7bba948316784a76439ed7cf5) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Restore the AgentSwarm member list after a page refresh on the v2 backend. + +- [#1704](https://github.com/MoonshotAI/kimi-code/pull/1704) [`38a2363`](https://github.com/MoonshotAI/kimi-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - Fix sessions created by newer builds failing to open in older CLI builds on the same machine; new sessions are written in a compatible layout, and existing sessions are healed on first open. + +- [#1708](https://github.com/MoonshotAI/kimi-code/pull/1708) [`ddfdfb0`](https://github.com/MoonshotAI/kimi-code/commit/ddfdfb0b09b59d95888eca7e9ddb7bb63be5e204) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix sub-agent completions being signaled as session turn completions, which fired premature completion notifications, sounds, and unread markers while the main turn was still running. + +- [#1714](https://github.com/MoonshotAI/kimi-code/pull/1714) [`20b6972`](https://github.com/MoonshotAI/kimi-code/commit/20b69724aafc8fb0b56a414988eb762a8b8a3ed1) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix code block copy buttons when the web UI is served over plain HTTP. + +- [#1643](https://github.com/MoonshotAI/kimi-code/pull/1643) [`d8d4e8c`](https://github.com/MoonshotAI/kimi-code/commit/d8d4e8ceb55d7a5cae7ce9b579996c9ff5601914) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Prevent long streaming responses from stalling after a tab is backgrounded. + +- [#1715](https://github.com/MoonshotAI/kimi-code/pull/1715) [`de493ae`](https://github.com/MoonshotAI/kimi-code/commit/de493aeec973623bc0e258d6598f6d9215693a5f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Use an upward chevron for the expand button on minimized plan review and question cards so the icon matches the direction the cards open. + +- [#1641](https://github.com/MoonshotAI/kimi-code/pull/1641) [`b6ae0a1`](https://github.com/MoonshotAI/kimi-code/commit/b6ae0a1054635fc71efde61dafa03da8a8b0c4c8) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show session list loading failures without discarding sessions that are still available. + +- [#1719](https://github.com/MoonshotAI/kimi-code/pull/1719) [`b24a347`](https://github.com/MoonshotAI/kimi-code/commit/b24a347e20a3efa7bba948316784a76439ed7cf5) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Expand the AgentSwarm card by default while its subagents are still running. + +- [#1693](https://github.com/MoonshotAI/kimi-code/pull/1693) [`7de218a`](https://github.com/MoonshotAI/kimi-code/commit/7de218a909d8f3e676ea3c160834090c9f19ca54) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Resume paused goals when you select Resume. + +- [#1700](https://github.com/MoonshotAI/kimi-code/pull/1700) [`3107f96`](https://github.com/MoonshotAI/kimi-code/commit/3107f963a532de88d0affd0a08c60749455c5013) Thanks [@chengluyu](https://github.com/chengluyu)! - Prevent late activity from replaced goals from changing or consuming the budget of replacement goals. + +- [#1459](https://github.com/MoonshotAI/kimi-code/pull/1459) [`6eb8e13`](https://github.com/MoonshotAI/kimi-code/commit/6eb8e13417f28a553b4183f113e5b96eb31e4211) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix mobile safe-area handling, including the composer floating above the on-screen keyboard on iOS, doubled landscape insets, the PWA top bar under the notch, and toasts overlapping the composer as it grows. + +- [#1696](https://github.com/MoonshotAI/kimi-code/pull/1696) [`b781e8c`](https://github.com/MoonshotAI/kimi-code/commit/b781e8cbcfac2cd0e73e3b1b79fa3386c632fa5b) Thanks [@chengluyu](https://github.com/chengluyu)! - Preserve final status messages when automatic goal continuations reach a budget or report a blocker. + +- [#1722](https://github.com/MoonshotAI/kimi-code/pull/1722) [`3703d03`](https://github.com/MoonshotAI/kimi-code/commit/3703d0346e79e42f18b5097f5606e6ef7b0ff2dd) Thanks [@sailist](https://github.com/sailist)! - In print mode (`kimi -p`), keep the run alive by default while background tasks are pending and feed each completion back to the main agent as a new turn, with an effectively unbounded wait ceiling and turn cap and a 72-hour subagent timeout. Set `print_background_mode = "exit"` (or `"drain"`) to restore the previous exit-after-one-turn behavior. + +- [#1737](https://github.com/MoonshotAI/kimi-code/pull/1737) [`5d6ff02`](https://github.com/MoonshotAI/kimi-code/commit/5d6ff022b1a3732cf0b12d1a87497870def52c0c) Thanks [@sailist](https://github.com/sailist)! - In print mode (`kimi -p`), background Bash tasks and subagents no longer have a timeout by default — they run until they finish or the model stops them, and a foreground Bash command that times out is moved to the background without a new deadline. Interactive defaults are unchanged; tune per mode with `bash_task_timeout_s` under `[background]` or `timeout_ms` under `[subagent]` (`0` = no timeout). + +- [#1697](https://github.com/MoonshotAI/kimi-code/pull/1697) [`2bf009f`](https://github.com/MoonshotAI/kimi-code/commit/2bf009fe27d1b0259e90f285e94264a8bf6b5832) Thanks [@chengluyu](https://github.com/chengluyu)! - Reject subagent goal requests consistently instead of starting goals they cannot finish. + +- [#1711](https://github.com/MoonshotAI/kimi-code/pull/1711) [`9eff230`](https://github.com/MoonshotAI/kimi-code/commit/9eff230f976c6bd8cc757678293276d8dec013d8) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Log failed requests, WebSocket auth rejections, shutdowns, and key operations (abort, cancel, approvals, config changes) in the web UI server so daemon problems can be diagnosed from its logs. + +- [#1704](https://github.com/MoonshotAI/kimi-code/pull/1704) [`38a2363`](https://github.com/MoonshotAI/kimi-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - Fix `kimi server` reporting the internal server package version instead of the CLI version in its metadata; the web UI settings now show the CLI version. + +- [#1741](https://github.com/MoonshotAI/kimi-code/pull/1741) [`8a3f1ff`](https://github.com/MoonshotAI/kimi-code/commit/8a3f1ffa6fbd7855fd0b10d96587afc6b690ebe3) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Fix the session title not being generated when the first message is a skill slash command. + +- [#1694](https://github.com/MoonshotAI/kimi-code/pull/1694) [`513f374`](https://github.com/MoonshotAI/kimi-code/commit/513f374aa08bd86b428f62697c1ca12594d533e9) Thanks [@chengluyu](https://github.com/chengluyu)! - Reject malformed persisted goal records during session recovery. + +- [#1704](https://github.com/MoonshotAI/kimi-code/pull/1704) [`38a2363`](https://github.com/MoonshotAI/kimi-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - web: Show each message's actual send time in chat history after reloading a session, instead of the session creation time. + +- [#1711](https://github.com/MoonshotAI/kimi-code/pull/1711) [`9eff230`](https://github.com/MoonshotAI/kimi-code/commit/9eff230f976c6bd8cc757678293276d8dec013d8) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Surface server error details when actions such as stopping a session, archiving, or toggling modes fail, instead of failing silently, and log every operation failure to the console and the exported web log. + +- [#1701](https://github.com/MoonshotAI/kimi-code/pull/1701) [`07c3632`](https://github.com/MoonshotAI/kimi-code/commit/07c3632415fa77972c49c39d7171ee5a4790bd01) Thanks [@sailist](https://github.com/sailist)! - Keep the workspace catalog complete and durable: creating a session registers its directory as a workspace, the server backfills missing workspaces from session history at startup, and a removed workspace no longer reappears after a restart. + ## 0.24.1 ### Patch Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index d67164fec..7b8a06914 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.24.1", + "version": "0.26.0", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 292aeaa64..64f18d490 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -2,7 +2,7 @@ import { execSync, spawnSync } from 'node:child_process'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { log } from '@moonshot-ai/kimi-code-sdk'; +import { flushDiagnosticLogsSync, log } from '@moonshot-ai/kimi-code-sdk'; import { setCrashPhase, setTelemetryContext, @@ -176,6 +176,14 @@ export async function runShell( // raw mode with a hidden cursor and XON/XOFF flow control disabled. Restore // both before exiting so the user's shell is usable afterwards. const emergencyExit = (exitCode: number): void => { + // The crash log above is only enqueued into the async sink; flush it + // synchronously or the `process.exit()` below would drop the one line that + // explains why we crashed. Best-effort: an exit path must never throw. + try { + flushDiagnosticLogsSync(); + } catch { + /* ignore */ + } restoreTerminalModes(); restoreStty(); process.exit(exitCode); diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts index 6a903123d..b7a9ffb03 100644 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -401,6 +401,9 @@ async function runServerInProcess( const v2 = await startServer({ host: options.host, port: options.port, + // Report the CLI's product version as `server_version` (/meta, web UI) + // rather than kap-server's private package version. + version, logLevel: options.logLevel, logger, debugEndpoints: options.debugEndpoints, 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 dd8e6c7a9..b284511ee 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -10,7 +10,8 @@ * native `DomainEvent` stream (payloads are already v1-protocol-shaped), * - drives a turn through `IAgentPromptService.enqueue()` and awaits * `Turn.result` for authoritative completion, - * - drains background tasks (config-driven) before exiting. + * - applies the print-mode background policy (config-driven, v1-aligned: + * `exit` / `drain` / `steer`) before exiting. * * Selected by `runPrompt` when `KIMI_CODE_EXPERIMENTAL_FLAG` is set. */ @@ -34,13 +35,16 @@ import { ensureMainAgent, hostRequestHeadersSeed, logSeed, + resolveAgentTaskConfig, resolveKimiHome, resolveLoggingConfig, + resolvePrintBackgroundMode, skillCatalogRuntimeOptionsSeed, type DomainEvent, type IAgentScopeHandle, type ISessionScopeHandle, type LoopRunResult, + type PrintBackgroundMode, type Scope, } from '@moonshot-ai/agent-core-v2'; import { createKimiDefaultHeaders, createKimiDeviceId } from '@moonshot-ai/kimi-code-oauth'; @@ -81,12 +85,9 @@ import { const PROMPT_UI_MODE = 'print'; const DEFAULT_PRINT_WAIT_CEILING_S = 3600; -const TASK_CONFIG_SECTION = 'task'; -const LEGACY_BACKGROUND_CONFIG_SECTION = 'background'; - -interface TaskPrintWaitConfig { - readonly printWaitCeilingS?: number; -} +const DEFAULT_PRINT_MAX_TURNS = 50; +/** Re-check `goalActive` at least this often while waiting for goal turns. */ +const GOAL_WAIT_POLL_MS = 250; export async function runV2Print( opts: CLIOptions, @@ -158,9 +159,11 @@ export async function runV2Print( removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanup); try { - const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr); - restorePermission = resolved.restorePermission; - + // Install the appender BEFORE resolving the session: `session_started` and + // `session_load_failed` fire inside create()/resume(), so an appender wired + // up only after resolveNativeSession() would drop them to the null appender. + // The model below is the best known up front; a resumed session's real + // model is reconciled via setContext once resolved. telemetryService = app.accessor.get(ITelemetryService); if (telemetryEnabled) { telemetryService.setAppender( @@ -168,12 +171,16 @@ export async function runV2Print( deviceId, appName: CLI_USER_AGENT_PRODUCT, uiMode: PROMPT_UI_MODE, - model: resolved.telemetryModel, + model: opts.model ?? defaultModel, getAccessToken: async () => (await auth.getCachedAccessToken()) ?? null, }), ); } - telemetryService.setContext({ sessionId: resolved.session.id }); + + const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr); + restorePermission = resolved.restorePermission; + + telemetryService.setContext({ sessionId: resolved.session.id, model: resolved.telemetryModel }); if (firstLaunch) { telemetryService.track2('first_launch'); } @@ -336,8 +343,13 @@ async function runNativeTurn( await agent.accessor.get(IAuthSummaryService).ensureReady(); + const turnEndings = createPrintTurnEndings(); const subscription = agent.accessor.get(IEventBus).subscribe((event: DomainEvent) => { dispatchNativeEvent(writer, event, stderr); + // Arm the turn-endings collector before `turn.result` settles so a + // background-task completion that steers a new turn right after the main + // turn ends cannot have its `turn.ended` slip past the policy loop. + if (event.type === 'turn.ended') turnEndings.push(event); }); try { const handle = await agent.accessor.get(IAgentPromptService).enqueue({ @@ -361,16 +373,41 @@ async function runNativeTurn( } const result = await turn.result; - // Turn settled, but `-p` is not done until any background work the turn - // spawned has drained (config-bounded). Flush the buffered assistant - // message first so a long drain does not withhold the final message. + // Turn settled, but `-p` is not done until the print-mode background + // policy says so (config-driven: exit / drain / steer). Flush the buffered + // assistant message first so a long drain/steer wait does not withhold the + // final message. writer.flushAssistant(); if (result.type === 'completed') { + const configService = app.accessor.get(IConfigService); + const taskConfig = resolveAgentTaskConfig(configService); + const goalService = agent.accessor.get(IAgentGoalService); 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. + await applyPrintBackgroundPolicy({ + mode: resolvePrintBackgroundMode(configService), + ceilingS: taskConfig?.printWaitCeilingS ?? DEFAULT_PRINT_WAIT_CEILING_S, + maxTurns: taskConfig?.printMaxTurns ?? DEFAULT_PRINT_MAX_TURNS, + countPending: () => countPendingBackgroundTasks(session), + drain: () => drainBackgroundTasks(session, taskConfig?.printWaitCeilingS), + turnEndings, + skipTurnId: turn.id, + warn: (message) => stderr.write(`Warning: ${message}\n`), + now: () => Date.now(), + goalActive: () => goalService.getGoal().goal?.status === 'active', + }); + } catch (error) { + // A steered turn that fails fails the run (v1 parity). Anything else + // is best-effort: a wedged background task must not fail the (already + // completed) main turn. + if (error instanceof PrintSteeredTurnFailedError) { + writer.finish(); + throw error; + } + stderr.write( + `Warning: print background policy failed: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); } writer.finish(); return; @@ -466,12 +503,182 @@ function dispatchNativeEvent( } } -async function drainBackgroundTasks(app: Scope, session: ISessionScopeHandle): Promise { - const config = app.accessor.get(IConfigService); - const section = - config.get(TASK_CONFIG_SECTION) ?? - config.get(LEGACY_BACKGROUND_CONFIG_SECTION); - const ceilingS = section?.printWaitCeilingS; +export type PrintTurnEnding = Extract; + +/** + * Source of `turn.ended` events for the print steer loop. `next` resolves with + * the next ending (skipping `skipTurnId`, the main turn's own buffered + * ending), or `null` when `remainingMs` elapses first. + */ +export interface PrintTurnEndings { + next(remainingMs: number, skipTurnId: number): Promise; +} + +/** + * Buffered `turn.ended` collector fed from the agent event bus. Events that + * arrive while no one is waiting are queued, so endings that fire between the + * main turn settling and the policy loop starting are not missed. + */ +export function createPrintTurnEndings(): PrintTurnEndings & { + push: (event: PrintTurnEnding) => void; +} { + const buffer: PrintTurnEnding[] = []; + let waiter: ((ending: PrintTurnEnding | null) => void) | undefined; + return { + push: (event) => { + const resolve = waiter; + if (resolve !== undefined) { + waiter = undefined; + resolve(event); + return; + } + buffer.push(event); + }, + next: async (remainingMs, skipTurnId) => { + const deadlineAt = Date.now() + remainingMs; + const waitOnce = (ms: number): Promise => + new Promise((resolve) => { + let settled = false; + const settle = (value: PrintTurnEnding | null): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + waiter = undefined; + // oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the single resolve; the rule cannot see it + resolve(value); + }; + const timer = Number.isFinite(ms) + ? setTimeout(() => { + settle(null); + }, ms) + : undefined; + waiter = settle; + }); + for (;;) { + while (buffer.length > 0) { + const ending = buffer.shift()!; + if (ending.turnId !== skipTurnId) return ending; + } + const ms = deadlineAt - Date.now(); + if (ms <= 0) return null; + const ending = await waitOnce(ms); + if (ending === null) return null; + if (ending.turnId !== skipTurnId) return ending; + // The skipped turn's own ending: keep waiting within the same budget. + } + }, + }; +} + +/** A background-task completion steered a new main turn that did not complete. */ +export class PrintSteeredTurnFailedError extends Error {} + +export interface PrintBackgroundPolicyInput { + readonly mode: PrintBackgroundMode; + readonly ceilingS: number; + readonly maxTurns: number; + readonly countPending: () => number; + readonly drain: () => Promise; + readonly turnEndings: PrintTurnEndings; + readonly skipTurnId: number; + readonly warn: (message: string) => void; + readonly now: () => number; + /** + * Reports whether an agent goal is still `active`. v2 drives goal + * continuation as new turns (v1 keeps a single turn alive), so a `-p` goal + * run must stay alive until the goal leaves `active`, independent of the + * background policy. + */ + readonly goalActive?: () => boolean; +} + +/** + * Apply the print-mode (`kimi -p`) background-task policy after the main turn + * completes. Mirrors v1's `Session.handlePrintMainTurnCompleted`: + * - goal : while a goal is `active`, keep waiting for its continuation + * turns (bounded by `ceilingS` as a safety net), regardless of + * the background mode; the goal summary drives the exit code. + * - 'exit' : return immediately (default). + * - 'drain' : suppress + drain background tasks, then return. + * - 'steer' : while background tasks are still pending, stay alive so task + * completions steer new main turns; return once quiescent, or + * when the wall-clock ceiling (`ceilingS`) or the turn cap + * (`maxTurns`) is reached. A steered turn that does not complete + * fails the run. + */ +export async function applyPrintBackgroundPolicy( + input: PrintBackgroundPolicyInput, +): Promise { + if (input.goalActive !== undefined) { + const goalDeadline = input.now() + input.ceilingS * 1000; + while (input.goalActive()) { + // Also wake on a short poll: a goal can leave `active` without any + // further turn.ended (budget block at a turn boundary, or a pause after + // a continuation-launch failure), which would otherwise hang the run + // until the ceiling. + const ended = await input.turnEndings.next( + Math.min(goalDeadline - input.now(), GOAL_WAIT_POLL_MS), + input.skipTurnId, + ); + if (ended === null && input.now() >= goalDeadline) { + input.warn(`print goal wait ceiling reached (${input.ceilingS}s), finishing`); + return; + } + // A continuation turn that does not complete pauses/blocks the goal, so + // the loop condition exits on the next check. + } + } + if (input.mode === 'exit') return; + if (input.mode === 'drain') { + await input.drain(); + return; + } + + // 'steer' + const deadline = input.now() + input.ceilingS * 1000; + let turns = 0; + for (;;) { + turns += 1; + if (input.now() >= deadline) { + input.warn(`print steer ceiling reached (${input.ceilingS}s), finishing`); + return; + } + if (turns > input.maxTurns) { + input.warn(`print steer max turns reached (${input.maxTurns}), finishing`); + return; + } + if (input.countPending() === 0) return; + const ended = await input.turnEndings.next(deadline - input.now(), input.skipTurnId); + if (ended === null) return; + if (ended.reason !== 'completed') { + throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended)); + } + } +} + +function formatTurnEndingFailure(ending: PrintTurnEnding): string { + if (ending.error?.code === 'provider.filtered') { + return 'Provider safety policy blocked the response.'; + } + if (ending.error !== undefined) return `${ending.error.code}: ${ending.error.message}`; + if (ending.reason === 'blocked') { + return 'Prompt hook blocked the request.'; + } + return `Prompt turn ended with reason: ${ending.reason}`; +} + +function countPendingBackgroundTasks(session: ISessionScopeHandle): number { + let count = 0; + for (const handle of session.accessor.get(IAgentLifecycleService).list()) { + count += handle.accessor.get(IAgentTaskService).list(true).length; + } + return count; +} + +async function drainBackgroundTasks( + session: ISessionScopeHandle, + ceilingS: number | undefined, +): Promise { const ceilingMs = typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0 ? ceilingS * 1000 diff --git a/apps/kimi-code/src/core/harness.ts b/apps/kimi-code/src/core/harness.ts index 52d9da753..85f00d9cd 100644 --- a/apps/kimi-code/src/core/harness.ts +++ b/apps/kimi-code/src/core/harness.ts @@ -18,6 +18,7 @@ import { dirname } from 'node:path'; import { bootstrap, ensureMainAgent, + IAgentActivityView, IAgentPermissionModeService, IAgentProfileService, IBootstrapService, @@ -26,7 +27,6 @@ import { IModelResolver, IPluginService, IProviderService, - ISessionActivity, ISessionContext, ISessionExportService, ISessionIndex, @@ -71,7 +71,6 @@ import type { FlagExplanation, PermissionMode, ResumedSessionState, - SessionEvent, TelemetryClient, TelemetryContextPatch, TelemetryProperties, @@ -311,11 +310,15 @@ export class CoreHarness { // 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.`, - ); + if (active !== undefined) { + const main = await ensureMainAgent(active.handle); + const activity = main.accessor.get(IAgentActivityView).state(); + if (activity.turn !== undefined || activity.background.length > 0) { + 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) { diff --git a/apps/kimi-code/src/core/replay.ts b/apps/kimi-code/src/core/replay.ts index 103db8014..2e2745c31 100644 --- a/apps/kimi-code/src/core/replay.ts +++ b/apps/kimi-code/src/core/replay.ts @@ -19,20 +19,24 @@ import { IAgentPermissionModeService, IAgentPermissionRulesService, IAgentPlanService, + AGENT_WIRE_RECORD_KEY, IAgentProfileService, + IAgentScopeContext, IAgentSwarmService, IAgentTaskService, IAgentToolRegistryService, IAgentUsageService, - IAgentWireRecordService, + IAppendLogStore, ISessionMetadata, ISessionTodoService, + IWireService, MAIN_AGENT_ID, type AgentMeta, type IAgentScopeHandle, type ISessionScopeHandle, type SessionMeta, type ToolInfo, + type WireRecord, } from '@moonshot-ai/agent-core-v2'; import { @@ -107,7 +111,17 @@ export async function buildResumedAgents( async function buildReplayFromWireRecords( accessor: IAgentScopeHandle['accessor'], ): Promise { - const records = accessor.get(IAgentWireRecordService).getRecords(); + // Flush first so the read sees every appended record, then fold the + // persisted journal one-shot — the same read pattern + // `MessageLegacyService.readTranscript` uses. + await accessor.get(IWireService).flush(); + const scope = accessor.get(IAgentScopeContext).scope(); + const records: WireRecord[] = []; + for await (const record of accessor + .get(IAppendLogStore) + .read(scope, AGENT_WIRE_RECORD_KEY)) { + records.push(record); + } const { entries } = reduceTranscript(records); const rehydrated = await rehydrateTranscript(entries, accessor.get(IAgentBlobService)); return rehydrated.map(projectTranscriptEntry); @@ -190,7 +204,7 @@ function projectSessionMetadata(meta: SessionMeta): ResumedSessionMetadata { 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, + 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/transcript.ts b/apps/kimi-code/src/core/transcript.ts index f39273aa5..e491e97be 100644 --- a/apps/kimi-code/src/core/transcript.ts +++ b/apps/kimi-code/src/core/transcript.ts @@ -13,12 +13,12 @@ * 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. + * Form: the reducer set is a plain local table (the same shape a wire model + * would declare), folded one-shot over the persisted `wire.jsonl` records + * (`IAppendLogStore.read`) — resume's `wire.restore` runs inside v2 before + * the facade sees the session, so a facade-side live fold would always miss + * the restore. 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): @@ -50,7 +50,6 @@ import { contextApplyCompaction, contextClear, contextUndo, - defineDerivedModel, isRealUserInput, planModeCancel, planModeEnter, @@ -485,10 +484,9 @@ function snapshotFromGoalStateLike(goal: GoalStateLike): GoalSnapshot { // -- public model + one-shot reduce ----------------------------------------- -export const TranscriptModel = defineDerivedModel( - 'kimi.tui.transcript', - () => ({ entries: [], working: INITIAL_WORKING }), - { +type TranscriptReducer = (state: TranscriptModelState, payload: unknown) => TranscriptModelState; + +const transcriptReducers: Record = { [contextAppendMessage.type]: (state, payload: unknown) => applyAppendMessage(state, toMutableMessage((payload as { message: ContextMessageLike }).message)), [contextAppendLoopEvent.type]: (state, payload: unknown) => @@ -525,8 +523,13 @@ export const TranscriptModel = defineDerivedModel( entries: [...state.entries, { type: 'config_updated', config: payload as ConfigUpdateWirePayload }], working: state.working, }), - }, -); +}; + +export const TranscriptModel = { + name: 'kimi.tui.transcript', + initial: (): TranscriptModelState => ({ entries: [], working: INITIAL_WORKING }), + reducers: transcriptReducers, +}; function stripSummaryPrefix(text: string): string { return text.startsWith(COMPACTION_SUMMARY_PREFIX) diff --git a/apps/kimi-code/src/migration/detect-pending.ts b/apps/kimi-code/src/migration/detect-pending.ts index 48435d2bc..cf121bf60 100644 --- a/apps/kimi-code/src/migration/detect-pending.ts +++ b/apps/kimi-code/src/migration/detect-pending.ts @@ -3,10 +3,13 @@ * shown. Cheap, synchronous-ish, no TTY required. Returns the MigrationPlan to * drive the screen, or null when there is nothing to offer. */ -import { existsSync, readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { existsSync } from 'node:fs'; -import { detectMigration, type MigrationPlan } from '@moonshot-ai/migration-legacy'; +import { + detectMigration, + shouldSuppressMigration, + type MigrationPlan, +} from '@moonshot-ai/migration-legacy'; export interface DetectPendingInput { readonly sourceHome: string; @@ -24,11 +27,11 @@ export async function detectPendingMigration( ): Promise { const { sourceHome, targetHome } = input; if (!existsSync(sourceHome)) return null; - if (input.ignoreMarker !== true) { - if (migrationAlreadyTargeted(join(sourceHome, '.migrated-to-kimi-code'), targetHome)) { - return null; - } - if (existsSync(join(targetHome, '.skip-migration-from-kimi-cli'))) return null; + if ( + input.ignoreMarker !== true && + shouldSuppressMigration({ sourceHome, targetHome }) + ) { + return null; } let plan: MigrationPlan; @@ -52,21 +55,3 @@ export async function detectPendingMigration( return plan; } - -/** - * True when the legacy `.migrated-to-kimi-code` marker records a migration - * into *this* target home. A marker written for a different `KIMI_CODE_HOME` - * must not suppress the prompt — that target has never received migrated data. - * An unreadable/old marker without `target_path` is treated as "matches" - * (conservative: do not re-prompt when the marker exists but is ambiguous). - */ -function migrationAlreadyTargeted(markerPath: string, targetHome: string): boolean { - if (!existsSync(markerPath)) return false; - try { - const parsed = JSON.parse(readFileSync(markerPath, 'utf-8')) as { target_path?: unknown }; - if (typeof parsed.target_path !== 'string') return true; - return resolve(parsed.target_path) === resolve(targetHome); - } catch { - return true; - } -} diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 762b6249f..9fe1e2128 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -36,6 +36,21 @@ import type { SlashCommandHost } from './dispatch'; const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000; +const MODEL_SWITCH_CACHE_WARNING = + 'Note: Switching models invalidates the existing prompt cache. Use /new to avoid extra token costs.'; +const EFFORT_SWITCH_CACHE_WARNING = + 'Note: Switching effort invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + +/** True once the conversation has at least one user message: a switch from + * then on resends the accumulated context, losing the cache. Shell-command + * echoes are also 'user' transcript entries but carry an empty `bullet`, so + * they're excluded. */ +function hasConversationHistory(host: SlashCommandHost): boolean { + return host.state.transcriptEntries.some( + (entry) => entry.kind === 'user' && entry.bullet !== '', + ); +} + function currentTuiConfig(host: SlashCommandHost): TuiConfig { return { theme: host.state.appState.theme, @@ -46,6 +61,14 @@ function currentTuiConfig(host: SlashCommandHost): TuiConfig { }; } +function effectiveModelForHost(host: SlashCommandHost, model: ModelAlias): ModelAlias { + const providerType = host.state.appState.availableProviders[model.provider]?.type; + // Flat models (no named provider, e.g. inline base_url served by a v2 + // backend) have no provider entry to look up; their own protocol declaration + // plays the provider-identity role, mirroring the resolver. + return effectiveModelAlias(model, providerType ?? model.protocol); +} + export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise { const session = host.session; if (session === undefined) { @@ -252,7 +275,7 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): host.showError('No model selected. Run /model to select one first.'); return; } - const effective = effectiveModelAlias(model); + const effective = effectiveModelForHost(host, model); const segments = segmentsFor(effective); const arg = args.trim().toLowerCase(); if (arg.length === 0) { @@ -260,10 +283,19 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): return; } if (!segments.includes(arg)) { - host.showError( - `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, + const providerType = host.state.appState.availableProviders[effective.provider]?.type; + const protocol = effective.protocol ?? providerType; + if (protocol !== 'anthropic') { + host.showError( + `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, + ); + return; + } + const knownEfforts = effective.supportEfforts?.join(', ') ?? 'none declared'; + host.showStatus( + `Thinking effort "${arg}" is not listed for ${alias} (known: ${knownEfforts}). Sending "${arg}" unchanged; the configured provider will validate it.`, + 'warning', ); - return; } await performModelSwitch(host, alias, arg, true); } @@ -280,6 +312,7 @@ function showEffortPicker( new EffortSelectorComponent({ efforts: segments, currentValue, + warning: hasConversationHistory(host) ? EFFORT_SWITCH_CACHE_WARNING : undefined, onSelect: (effort) => { host.restoreEditor(); void performModelSwitch(host, alias, effort, true); @@ -376,7 +409,13 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise } export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void { - const entries = Object.entries(host.state.appState.availableModels); + const models = Object.fromEntries( + Object.entries(host.state.appState.availableModels).map(([alias, model]) => [ + alias, + effectiveModelForHost(host, model), + ]), + ); + const entries = Object.entries(models); if (entries.length === 0) { host.showNotice( 'No models configured', @@ -386,10 +425,11 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = } host.mountEditorReplacement( new TabbedModelSelectorComponent({ - models: host.state.appState.availableModels, + models, currentValue: host.state.appState.model, selectedValue, currentThinkingEffort: host.state.appState.thinkingEffort, + warning: hasConversationHistory(host) ? MODEL_SWITCH_CACHE_WARNING : undefined, onSelect: ({ alias, thinking }) => { host.restoreEditor(); void performModelSwitch(host, alias, thinking, true); diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 3a7de564e..e732ebc2f 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -3,7 +3,7 @@ * * Layout: * Line 1: [yolo] [plan] - * Line 2: context: XX.X% (raw X / projection Y / all Z) + * Line 2: context: N% (raw X / projection Y / all Z) */ import type { Component } from '@moonshot-ai/pi-tui'; @@ -23,7 +23,11 @@ import { type GitStatus, type GitStatusCache, } from '#/utils/git/git-status'; -import { safeUsageRatio } from '#/utils/usage/usage-format'; +import { + formatTokenCount, + usagePercent, + usagePercentFromRatio, +} from '#/utils/usage/usage-format'; const MAX_CWD_SEGMENTS = 3; const GOAL_TIMER_INTERVAL_MS = 1_000; @@ -154,33 +158,33 @@ function shortenCwd(path: string): string { return `…/${tail}`; } -function formatTokenCount(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(n); -} - -function safeUsage(usage: number): number { - return safeUsageRatio(usage); -} - +/** + * Footer context readout: the folded projection size against the window, with + * the raw stored-history size alongside for fold visibility. Percent comes + * from the exact token counts when both are known (the ratio can lag a step + * behind); otherwise it falls back to the precomputed ratio. Counts use the + * shared 1024-based formatter. + */ function formatContextStatus( usage: number, tokens?: number, maxTokens?: number, rawTokens?: number, ): string { - const pct = `${(safeUsage(usage) * 100).toFixed(1)}%`; - if (maxTokens && maxTokens > 0 && tokens !== undefined) { + if (maxTokens !== undefined && maxTokens > 0 && tokens !== undefined) { + const pct = String(usagePercent(tokens, maxTokens)); // raw = whole stored history, projection = folded view the model sees, // all = the context-window ceiling the percentage is measured against. - const raw = rawTokens ?? tokens; - return ( - `context: ${pct} (raw ${formatTokenCount(raw)} / ` + - `projection ${formatTokenCount(tokens)} / all ${formatTokenCount(maxTokens)})` - ); + // raw reads 0 until the first measurement — show the compact form then. + if (rawTokens !== undefined && rawTokens > 0) { + return ( + `context: ${pct}% (raw ${formatTokenCount(rawTokens)} / ` + + `projection ${formatTokenCount(tokens)} / all ${formatTokenCount(maxTokens)})` + ); + } + return `context: ${pct}% (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`; } - return `context: ${pct}`; + return `context: ${String(usagePercentFromRatio(usage))}%`; } export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string { 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 13df75d0e..89c04f8ed 100644 --- a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts @@ -3,6 +3,7 @@ import { Key, matchesKey, truncateToWidth, + wrapTextWithAnsi, type Focusable, } from '@moonshot-ai/pi-tui'; @@ -22,6 +23,10 @@ export interface EffortSelectorOptions { /** When provided, Alt+S applies the choice to the current session only. */ readonly onSessionOnlySelect?: (effort: ThinkingEffort) => void; readonly onCancel: () => void; + /** When set, rendered as warning-colored lines directly below the key-hint + * line; wraps instead of truncating when it exceeds the width (e.g. the + * mid-conversation switch cost notice). */ + readonly warning?: string; } /** @@ -76,8 +81,13 @@ export class EffortSelectorComponent extends Container implements Focusable { currentTheme.fg('primary', '─'.repeat(width)), currentTheme.boldFg('primary', ` ${this.opts.title ?? 'Select thinking effort'}`), currentTheme.fg('textMuted', ` ${hintParts.join(' · ')}`), - '', ]; + if (this.opts.warning !== undefined) { + for (const line of wrapTextWithAnsi(this.opts.warning, Math.max(1, width - 1))) { + lines.push(currentTheme.fg('warning', ` ${line}`)); + } + } + lines.push(''); const segments = this.opts.efforts.map((effort, index) => { const label = effortLabel(effort); 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 6cc23f0ee..5f42ee187 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -5,6 +5,7 @@ import { matchesKey, truncateToWidth, visibleWidth, + wrapTextWithAnsi, type Focusable, } from '@moonshot-ai/pi-tui'; @@ -73,6 +74,10 @@ export interface ModelSelectorOptions { /** When true, the hint line mentions the Tab provider switch — set by * TabbedModelSelectorComponent so the inner list advertises the tab keys. */ readonly providerSwitchHint?: boolean; + /** When set, rendered as warning-colored lines directly below the key-hint + * line; wraps instead of truncating when it exceeds the width (e.g. the + * mid-conversation switch cost notice). */ + readonly warning?: string; readonly onSelect: (selection: ModelSelection) => void; /** When provided, Alt+S invokes this instead of onSelect — used to apply the * choice to the current session only, without persisting it as the default. */ @@ -286,8 +291,13 @@ export class ModelSelectorComponent extends Container implements Focusable { currentTheme.fg('primary', '─'.repeat(width)), currentTheme.boldFg('primary', ' Select a model') + titleSuffix, currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), - '', ]; + if (this.opts.warning !== undefined) { + for (const line of wrapTextWithAnsi(this.opts.warning, Math.max(1, width - 1))) { + lines.push(currentTheme.fg('warning', ` ${line}`)); + } + } + lines.push(''); if (searchable && view.query.length > 0) { lines.push(currentTheme.fg('primary', ' Search: ') + currentTheme.fg('text', view.query)); 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 5666a9b2b..59dcd5056 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 @@ -43,6 +43,10 @@ export interface TabbedModelSelectorOptions { /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; + /** Forwarded to each inner selector; when set, warning-colored lines are + * rendered directly below the key-hint line, wrapping as needed (e.g. the + * mid-conversation switch cost notice). */ + readonly warning?: string; readonly onSelect: (selection: ModelSelection) => void; /** Forwarded to each inner selector; when set, Alt+S applies the choice to * the current session only without persisting it as the default. */ @@ -100,24 +104,20 @@ export class TabbedModelSelectorComponent extends Container implements Focusable if (this.tabs.length <= 1) { return inner.map((line) => truncateToWidth(line, width)); } - // Layout: divider, title, hint, blank, tab strip, blank, then the model - // list. The inner selector's blank line (inner[3]) separates the hint from - // the tab strip; an extra blank separates the tabs from their list. + // Layout: divider, title, hint, optional warning, blank, tab strip, blank, + // then the model list. The header ends at its first blank line — keep that + // blank above the strip, and separate the tabs from the list with another + // blank. const stripLine = renderTabStrip({ labels: this.tabs.map((tab) => tab.label), activeIndex: this.activeIndex, width, colors: currentTheme.palette, }); - const out: string[] = [ - inner[0] ?? '', - inner[1] ?? '', - inner[2] ?? '', - inner[3] ?? '', - stripLine, - '', - ]; - for (let i = 4; i < inner.length; i++) out.push(inner[i]!); + const headerEnd = inner.findIndex((line) => line === ''); + const splitAt = headerEnd === -1 ? 3 : headerEnd; + const out: string[] = [...inner.slice(0, splitAt + 1), stripLine, '']; + for (let i = splitAt + 1; i < inner.length; i++) out.push(inner[i]!); return out.map((line) => truncateToWidth(line, width)); } @@ -182,6 +182,7 @@ function makeSelector( currentThinkingEffort: opts.currentThinkingEffort, searchable: true, providerSwitchHint: true, + warning: opts.warning, onSelect: opts.onSelect, onSessionOnlySelect: opts.onSessionOnlySelect, onCancel: opts.onCancel, diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index e3532b157..b1677e87b 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -397,12 +397,21 @@ export class CustomEditor extends Editor { } if (this.onPasteImage !== undefined) { const handler = this.onPasteImage; - void handler().then((handled) => { - if (!handled) { - this.onTextPaste?.(); - super.handleInput.call(this, normalized); - } - }); + const pasteAsText = (): void => { + this.onTextPaste?.(); + super.handleInput.call(this, normalized); + }; + void handler().then( + (handled) => { + if (!handled) pasteAsText(); + }, + () => { + // A rejecting image-paste handler must not leak an unhandled + // rejection (the CLI turns those into a silent exit) — treat it + // the same as "no image available" and fall back to text paste. + pasteAsText(); + }, + ); return; } } diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index 6fd1624d5..7e4752945 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -20,6 +20,7 @@ import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; +import { formatTokenCount } from '#/utils/usage/usage-format'; import type { ToolCallComponent, ToolCallSubagentSnapshot } from './tool-call'; @@ -371,7 +372,5 @@ function formatElapsed(seconds: number): string { } function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k tok`; - return `${String(n)} tok`; + return `${formatTokenCount(n)} tok`; } 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 907401ef6..e7e20c808 100644 --- a/apps/kimi-code/src/tui/components/messages/status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -20,6 +20,7 @@ import { ratioSeverity, renderProgressBar, safeUsageRatio, + usagePercent, } from '#/utils/usage/usage-format'; import { @@ -133,7 +134,7 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { const bar = renderProgressBar(safeRatio, 20); const barColoured = currentTheme.fg(severityToken(ratioSeverity(safeRatio)), bar); lines.push( - ` ${barColoured} ${value(`${(safeRatio * 100).toFixed(1)}%`.padStart(6, ' '))} ` + + ` ${barColoured} ${value(`${String(usagePercent(tokens, maxTokens))}%`.padStart(6, ' '))} ` + muted(`(${formatTokenCount(tokens)} / ${formatTokenCount(maxTokens)})`), ); } else { 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 1b03fc638..4459b4cce 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -28,6 +28,7 @@ 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'; +import { formatTokenCount } from '#/utils/usage/usage-format'; import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress'; import { PlanBoxComponent } from './plan-box'; @@ -138,8 +139,7 @@ function str(v: unknown): string { function formatSubagentContextTokens(contextTokens: number | undefined): string | undefined { if (contextTokens === undefined || contextTokens <= 0) return undefined; - const formatted = contextTokens >= 1000 ? `${(contextTokens / 1000).toFixed(1)}k` : String(contextTokens); - return `${formatted} tok`; + return `${formatTokenCount(contextTokens)} tok`; } function usageInputTotal(usage: TokenUsage): number { @@ -154,8 +154,7 @@ function usageTotal(usage: TokenUsage | undefined): number { function formatSubagentTokens(usage: TokenUsage | undefined): string | undefined { const total = usageTotal(usage); if (total <= 0) return undefined; - const formatted = total >= 1000 ? `${(total / 1000).toFixed(1)}k` : String(total); - return `${formatted} tok`; + return `${formatTokenCount(total)} tok`; } function formatByteSize(bytes: number): string { @@ -2318,9 +2317,7 @@ function computeLatestActivity( } function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k tok`; - return `${String(n)} tok`; + return `${formatTokenCount(n)} tok`; } function formatActivityLine( 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 4d8a1e657..52296ea9c 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -13,6 +13,7 @@ import { ratioSeverity, renderProgressBar, safeUsageRatio, + usagePercent, } from '#/utils/usage/usage-format'; import { currentTheme } from '#/tui/theme'; import type { ColorToken } from '#/tui/theme'; @@ -266,7 +267,7 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { if (options.maxContextTokens > 0) { const ratio = safeUsageRatio(options.contextUsage); const bar = renderProgressBar(ratio, 20); - const pct = `${(ratio * 100).toFixed(1)}%`; + const pct = `${String(usagePercent(options.contextTokens, options.maxContextTokens))}%`; const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar); lines.push(''); lines.push(accent('Context window')); diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index a13d062b5..f64f2a8d2 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -667,7 +667,7 @@ export class SessionReplayRenderer { switch (result.decision) { case 'rejected': content = - result.selected_label === 'Revise' ? 'Plan sent back for revision' : 'Plan review rejected'; + result.selectedLabel === 'Revise' ? 'Plan sent back for revision' : 'Plan review rejected'; break; case 'cancelled': content = 'Plan review cancelled'; diff --git a/apps/kimi-code/src/tui/utils/goal-completion.ts b/apps/kimi-code/src/tui/utils/goal-completion.ts index 7217eb97d..a00e378a7 100644 --- a/apps/kimi-code/src/tui/utils/goal-completion.ts +++ b/apps/kimi-code/src/tui/utils/goal-completion.ts @@ -1,5 +1,7 @@ import type { GoalSnapshot } from '#/core/index'; +import { formatTokenCount } from '#/utils/usage/usage-format'; + interface GoalCompletionStats { readonly terminalReason?: string | undefined; readonly turnsUsed: number; @@ -19,7 +21,7 @@ export function buildGoalCompletionMessage(goal: GoalSnapshot): string { export function buildGoalCompletionMessageFromStats(goal: GoalCompletionStats): string { const head = `✓ Goal complete${goal.terminalReason ? ` — ${goal.terminalReason}` : ''}.`; const turns = `${goal.turnsUsed} turn${goal.turnsUsed === 1 ? '' : 's'}`; - const stats = `Worked ${turns} over ${formatElapsed(goal.wallClockMs)}, using ${formatTokens(goal.tokensUsed)} tokens.`; + const stats = `Worked ${turns} over ${formatElapsed(goal.wallClockMs)}, using ${formatTokenCount(goal.tokensUsed)} tokens.`; return `${head}\n${stats}`; } @@ -32,9 +34,3 @@ function formatElapsed(ms: number): string { const hours = Math.floor(minutes / 60); return `${hours}h${(minutes % 60).toString().padStart(2, '0')}m`; } - -function formatTokens(tokens: number): string { - if (tokens < 1000) return String(tokens); - if (tokens < 1_000_000) return `${(tokens / 1000).toFixed(1)}k`; - return `${(tokens / 1_000_000).toFixed(1)}M`; -} diff --git a/apps/kimi-code/src/utils/usage/usage-format.ts b/apps/kimi-code/src/utils/usage/usage-format.ts index 34db897a2..b44adb3f0 100644 --- a/apps/kimi-code/src/utils/usage/usage-format.ts +++ b/apps/kimi-code/src/utils/usage/usage-format.ts @@ -5,11 +5,40 @@ * command itself chalks the colour afterwards. */ +/** + * Format a token count in 1024-based units: context sizes are powers of + * two, so 262144 reads as "256k", not "262.1k". k values at or above + * 100 are rounded to whole numbers ("977k"). + */ export function formatTokenCount(n: number): string { if (!Number.isFinite(n) || n < 0) return '0'; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(Math.round(n)); + if (n >= 1024 * 1024) return `${trimDecimal(n / (1024 * 1024))}M`; + if (n >= 1024) { + const k = n / 1024; + return `${k >= 100 ? Math.round(k) : trimDecimal(k)}k`; + } + return String(n); +} + +/** One decimal place, dropping a redundant ".0" ("1.0" → "1", "1.5" stays). */ +function trimDecimal(v: number): string { + const s = v.toFixed(1); + return s.endsWith('.0') ? s.slice(0, -2) : s; +} + +/** + * Usage as a whole-number percentage of `max`, ceiled so any non-zero + * usage shows at least 1%, clamped to [0, 100]. A non-positive or + * non-finite `max` reports 0. + */ +export function usagePercent(used: number, max: number): number { + if (!Number.isFinite(max) || max <= 0) return 0; + return Math.min(100, Math.max(0, Math.ceil((used / max) * 100))); +} + +/** `usagePercent` for callers that only know the ratio (NaN-safe). */ +export function usagePercentFromRatio(ratio: number): number { + return Math.min(100, Math.max(0, Math.ceil(safeUsageRatio(ratio) * 100))); } /** diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 5d2302d75..10bca3320 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -61,6 +61,7 @@ const mocks = vi.hoisted(() => { track: lifecycleTrack, })), resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home'), + flushDiagnosticLogsSync: vi.fn(), harnessCreatesDeviceIdOnConstruction: false, execSync: vi.fn(), TuiConfigParseError, @@ -72,6 +73,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { return { ...actual, resolveKimiHome: mocks.resolveKimiHome, + flushDiagnosticLogsSync: mocks.flushDiagnosticLogsSync, }; }); @@ -567,6 +569,101 @@ describe('runShell', () => { expect(mocks.kimiTuiConstructor).not.toHaveBeenCalled(); }); + it('flushes diagnostic logs synchronously before exiting on a runtime crash', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + const processOnSpy = vi.spyOn(process, 'on'); + const stdout = captureProcessWrite('stdout'); + const exitSpy = mockProcessExit(); + + try { + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + + const handler = processOnSpy.mock.calls.find( + ([event]) => event === 'uncaughtException', + )?.[1] as ((error: unknown) => void) | undefined; + expect(handler).toBeDefined(); + + // The async log sink cannot flush before process.exit() runs, so the + // crash handler must force a synchronous flush or the crash reason is + // lost (regression: uncaughtException logs never reached disk). + expect(() => handler?.(new Error('boom'))).toThrow(ExitCalled); + expect(mocks.flushDiagnosticLogsSync).toHaveBeenCalledOnce(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mocks.flushDiagnosticLogsSync.mock.invocationCallOrder[0]!).toBeLessThan( + exitSpy.mock.invocationCallOrder[0]!, + ); + } finally { + processOnSpy.mockRestore(); + exitSpy.mockRestore(); + stdout.restore(); + } + }); + + it('flushes diagnostic logs synchronously before exiting on an unhandled rejection', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + const processOnSpy = vi.spyOn(process, 'on'); + const stdout = captureProcessWrite('stdout'); + const exitSpy = mockProcessExit(); + + try { + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + + const handler = processOnSpy.mock.calls.find( + ([event]) => event === 'unhandledRejection', + )?.[1] as ((reason: unknown) => void) | undefined; + expect(handler).toBeDefined(); + + expect(() => handler?.(new Error('boom'))).toThrow(ExitCalled); + expect(mocks.flushDiagnosticLogsSync).toHaveBeenCalledOnce(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mocks.flushDiagnosticLogsSync.mock.invocationCallOrder[0]!).toBeLessThan( + exitSpy.mock.invocationCallOrder[0]!, + ); + } finally { + processOnSpy.mockRestore(); + exitSpy.mockRestore(); + stdout.restore(); + } + }); + it('closes the harness when TUI startup fails', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/apps/kimi-code/test/cli/run-v2-print.test.ts b/apps/kimi-code/test/cli/run-v2-print.test.ts new file mode 100644 index 000000000..2e93c2137 --- /dev/null +++ b/apps/kimi-code/test/cli/run-v2-print.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + applyPrintBackgroundPolicy, + createPrintTurnEndings, + PrintSteeredTurnFailedError, + type PrintTurnEnding, + type PrintTurnEndings, +} from '#/cli/v2/run-v2-print'; + +function ending( + turnId: number, + reason: PrintTurnEnding['reason'] = 'completed', +): PrintTurnEnding { + return { type: 'turn.ended', turnId, reason }; +} + +interface ScriptedEntry { + readonly event: PrintTurnEnding; + /** Side effect applied when this entry is consumed (e.g. mutate pending). */ + readonly apply?: () => void; +} + +/** + * Scripted `PrintTurnEndings`: replays queued endings (honouring `skipTurnId`), + * then resolves `null` once the script is exhausted (the wait "timed out"). + */ +function scriptedTurnEndings(entries: ScriptedEntry[]): PrintTurnEndings { + const queue = [...entries]; + return { + next: async (_remainingMs: number, skipTurnId: number) => { + while (queue.length > 0) { + const entry = queue.shift()!; + if (entry.event.turnId === skipTurnId) continue; + entry.apply?.(); + return entry.event; + } + return null; + }, + }; +} + +describe('applyPrintBackgroundPolicy', () => { + it('exit returns immediately without draining or waiting', async () => { + const drain = vi.fn(async () => {}); + const countPending = vi.fn(() => 1); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 60, + maxTurns: 50, + countPending, + drain, + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + }); + expect(drain).not.toHaveBeenCalled(); + expect(countPending).not.toHaveBeenCalled(); + }); + + it('drain drains once and returns', async () => { + const drain = vi.fn(async () => {}); + await applyPrintBackgroundPolicy({ + mode: 'drain', + ceilingS: 60, + maxTurns: 50, + countPending: () => 1, + drain, + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + }); + expect(drain).toHaveBeenCalledTimes(1); + }); + + it('steer returns once background tasks are quiescent', async () => { + let pending = 1; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 50, + countPending: () => pending, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + // The main turn's own buffered ending is skipped. + { event: ending(1) }, + // A background task completed and steered a new turn; it finished and + // no tasks remain. + { event: ending(2), apply: () => { pending = 0; } }, + ]), + skipTurnId: 1, + warn, + now: () => Date.now(), + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('steer finishes with a warning when max turns is reached', async () => { + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 2, + countPending: () => 1, + drain: async () => {}, + turnEndings: scriptedTurnEndings([{ event: ending(2) }, { event: ending(3) }]), + skipTurnId: 1, + warn, + now: () => Date.now(), + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('max turns'); + }); + + it('steer finishes with a warning when the ceiling is reached', async () => { + let now = 0; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 10, + maxTurns: 50, + countPending: () => 1, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { event: ending(2), apply: () => { now = 10_001; } }, + ]), + skipTurnId: 1, + warn, + now: () => now, + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('ceiling'); + }); + + it('steer returns when the wait times out with tasks still pending', async () => { + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 50, + countPending: () => 1, + drain: async () => {}, + // Empty script: no further turn ends before the deadline. + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn, + now: () => Date.now(), + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('steer throws when a steered turn does not complete', async () => { + await expect( + applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 50, + countPending: () => 1, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { + event: { + type: 'turn.ended', + turnId: 2, + reason: 'failed', + error: { code: 'provider.overloaded', message: 'try later' }, + } as PrintTurnEnding, + }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + }), + ).rejects.toThrow(PrintSteeredTurnFailedError); + }); + + it('waits for goal continuation turns before applying the mode', async () => { + let active = true; + let consumed = 0; + const drain = vi.fn(async () => {}); + await applyPrintBackgroundPolicy({ + mode: 'drain', + ceilingS: 60, + maxTurns: 50, + countPending: () => 0, + drain, + turnEndings: scriptedTurnEndings([ + { event: ending(2), apply: () => { consumed += 1; } }, + { + event: ending(3), + apply: () => { + consumed += 1; + active = false; + }, + }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + goalActive: () => active, + }); + // Both continuation turns ended before the mode ('drain') ran. + expect(consumed).toBe(2); + expect(drain).toHaveBeenCalledTimes(1); + }); + + it('warns and returns when the goal wait hits the ceiling', async () => { + let now = 0; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 10, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + // No continuation turn ever ends; the poll interval elapses each time. + turnEndings: { + next: async () => { + now = 10_001; + return null; + }, + }, + skipTurnId: 1, + warn, + now: () => now, + goalActive: () => true, + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('goal wait ceiling'); + }); + + it('exits the goal wait promptly when the goal settles without a turn ending', async () => { + let active = true; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + // Poll interval elapses; the goal settles (paused/blocked) mid-wait + // without producing a turn.ended. + turnEndings: { + next: async () => { + active = false; + return null; + }, + }, + skipTurnId: 1, + warn, + now: () => Date.now(), + goalActive: () => active, + }); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe('createPrintTurnEndings', () => { + it('buffers events pushed before next() and skips the given turn id', async () => { + const endings = createPrintTurnEndings(); + endings.push(ending(1)); + endings.push(ending(2)); + await expect(endings.next(1000, 1)).resolves.toMatchObject({ turnId: 2 }); + }); + + it('delivers a pushed event to a pending next()', async () => { + const endings = createPrintTurnEndings(); + const pending = endings.next(1000, 1); + endings.push(ending(3)); + await expect(pending).resolves.toMatchObject({ turnId: 3 }); + }); + + it('resolves null when the remaining time elapses', async () => { + const endings = createPrintTurnEndings(); + await expect(endings.next(5, 1)).resolves.toBeNull(); + }); + + it('keeps waiting when only the skipped turn ends', async () => { + const endings = createPrintTurnEndings(); + const pending = endings.next(1000, 1); + endings.push(ending(1)); + endings.push(ending(4)); + await expect(pending).resolves.toMatchObject({ turnId: 4 }); + }); +}); diff --git a/apps/kimi-code/test/core/harness.test.ts b/apps/kimi-code/test/core/harness.test.ts index 11a803a4c..8a15312d0 100644 --- a/apps/kimi-code/test/core/harness.test.ts +++ b/apps/kimi-code/test/core/harness.test.ts @@ -11,6 +11,7 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { + IAgentActivityView, IAgentBlobService, IAgentContextMemoryService, IAgentContextSizeService, @@ -19,11 +20,12 @@ import { IAgentPermissionRulesService, IAgentPlanService, IAgentProfileService, + IAgentScopeContext, IAgentSwarmService, IAgentTaskService, IAgentToolRegistryService, IAgentUsageService, - IAgentWireRecordService, + IAppendLogStore, IBootstrapService, IConfigService, IEventBus, @@ -32,7 +34,6 @@ import { IModelResolver, IPluginService, IProviderService, - ISessionActivity, ISessionApprovalService, ISessionContext, ISessionCronService, @@ -44,6 +45,7 @@ import { ISessionQuestionService, ISessionTodoService, ISessionWorkspaceContext, + IWireService, IWorkspaceRegistry, } from '@moonshot-ai/agent-core-v2'; import { CoreErrorCodes, isCoreError } from '../../src/core/errors'; @@ -173,7 +175,18 @@ function makeFixture(options?: { [IAgentUsageService, { status: () => usage }], [IAgentToolRegistryService, { list: () => [] }], [IAgentTaskService, { list: () => [] }], - [IAgentWireRecordService, { getRecords: () => [] }], + [ + IAgentActivityView, + { + state: () => + options?.activityStatus === 'running' + ? { lifecycle: 'ready', turn: { turnId: 1 }, background: [] } + : { lifecycle: 'ready', background: [] }, + }, + ], + [IWireService, { flush: () => Promise.resolve() }], + [IAgentScopeContext, { scope: () => 'agent-main' }], + [IAppendLogStore, { read: async function* () {} }], [IAgentBlobService, { loadParts: async (parts: readonly unknown[]) => parts }], ] as ReadonlyArray, }; @@ -223,7 +236,6 @@ function makeFixture(options?: { ISessionWorkspaceContext, { workDir, additionalDirs: ['/extra'], addAdditionalDir: record(`${sid}.addAdditionalDir`) }, ], - [ISessionActivity, { status: () => options?.activityStatus ?? 'idle' }], [ISessionTodoService, { getTodos: () => [] }], ]), }; diff --git a/apps/kimi-code/test/core/replay.test.ts b/apps/kimi-code/test/core/replay.test.ts index ee09430b6..6b33799d7 100644 --- a/apps/kimi-code/test/core/replay.test.ts +++ b/apps/kimi-code/test/core/replay.test.ts @@ -11,13 +11,15 @@ import { IAgentPermissionRulesService, IAgentPlanService, IAgentProfileService, + IAgentScopeContext, IAgentSwarmService, IAgentTaskService, IAgentToolRegistryService, IAgentUsageService, - IAgentWireRecordService, + IAppendLogStore, ISessionMetadata, ISessionTodoService, + IWireService, } from '@moonshot-ai/agent-core-v2'; import { buildResumedAgents, buildResumedSessionState } from '../../src/core/replay'; @@ -141,7 +143,16 @@ function makeFixture(metaOverrides?: Record) { [IAgentSwarmService, { isActive: true }], [IAgentUsageService, { status: () => usage }], [IAgentToolRegistryService, { list: () => toolInfos }], - [IAgentWireRecordService, { getRecords: () => wireRecords }], + [IWireService, { flush: () => Promise.resolve() }], + [IAgentScopeContext, { scope: () => 'agent-main' }], + [ + IAppendLogStore, + { + read: async function* () { + for (const record of wireRecords) yield record; + }, + }, + ], [IAgentBlobService, { loadParts: async (parts: readonly unknown[]) => parts }], [ IAgentTaskService, diff --git a/apps/kimi-code/test/helpers/process.ts b/apps/kimi-code/test/helpers/process.ts index 5a861724b..1da718fac 100644 --- a/apps/kimi-code/test/helpers/process.ts +++ b/apps/kimi-code/test/helpers/process.ts @@ -6,7 +6,7 @@ export class ExitCalled extends Error { } } -export function mockProcessExit(): { mockRestore(): void } { +export function mockProcessExit() { return vi.spyOn(process, 'exit').mockImplementation(((code?: string | number | null) => { throw new ExitCalled(Number(code ?? 0)); }) as never); diff --git a/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts index e74fa7aa1..53ff5b170 100644 --- a/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/effort-selector.test.ts @@ -102,4 +102,50 @@ describe('EffortSelectorComponent', () => { picker.handleInput(ESC); expect(onCancel).toHaveBeenCalledTimes(1); }); + + it('renders the warning line directly below the key-hint line when provided', () => { + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + warning: 'Switching may increase token usage.', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('←→ switch')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toContain('Switching may increase token usage.'); + }); + + it('renders no warning line without the warning option', () => { + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('←→ switch')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toBe(''); + }); + + it('wraps a warning longer than the width instead of truncating it', () => { + const warning = + 'Note: Switching effort invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + warning, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(40).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('←→ switch')); + expect(lines[hintIdx + 1]).not.toBe(''); + expect(lines[hintIdx + 2]).not.toBe(''); + // Word-wrapped: nothing dropped — the full warning survives across lines. + const squashed = lines.join('').replaceAll(/\s+/g, ''); + expect(squashed).toContain(warning.replaceAll(/\s+/g, '')); + }); }); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index ba1499e04..8fced4176 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -338,6 +338,55 @@ describe('ModelSelectorComponent', () => { expect(out).toContain('Thinking (←→ to switch)'); }); + it('derives official Anthropic effort segments from the model name', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + opus: { + provider: 'anthropic', + model: 'claude-opus-4-6', + maxContextSize: 200000, + }, + }, + currentValue: 'opus', + currentThinkingEffort: 'high', + onSelect, + onCancel: vi.fn(), + }); + + const out = text(picker); + expect(out).toContain('Low'); + expect(out).toContain('[ High ]'); + expect(out).toContain('Max'); + expect(out).toContain('Off'); + expect(out).not.toContain('Xhigh'); + + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'opus', thinking: 'max' }); + }); + + it('derives official always-on Anthropic models without an Off segment', () => { + const picker = new ModelSelectorComponent({ + models: { + fable: { + provider: 'anthropic', + model: 'claude-fable-5', + maxContextSize: 200000, + }, + }, + currentValue: 'fable', + currentThinkingEffort: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + expect(out).toContain('Xhigh'); + expect(out).toContain('Max'); + expect(out).not.toContain('Off'); + }); + it('cycles efforts with Left/Right and clamps at the ends', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ @@ -421,6 +470,45 @@ describe('ModelSelectorComponent', () => { // middle entry (medium), not a hardcoded level. expect(text(picker)).toContain('[ Medium ]'); }); + + it('renders the warning line directly below the key-hint line when provided', () => { + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + warning: 'Switching may increase token usage.', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = picker.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('↑↓ navigate')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toContain('Switching may increase token usage.'); + // Model list is pushed below the inserted warning line, not overlapped. + expect(lines.findIndex((l) => l.includes('Kimi K2'))).toBeGreaterThan(hintIdx + 1); + }); + + it('wraps a warning longer than the width instead of truncating it', () => { + const warning = + 'Note: Switching models invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinkingEffort: 'on', + warning, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = picker.render(50).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('↑↓ navigate')); + expect(lines[hintIdx + 1]).not.toBe(''); + expect(lines[hintIdx + 2]).not.toBe(''); + // Word-wrapped: nothing dropped — the full warning survives across lines. + const squashed = lines.join('').replaceAll(/\s+/g, ''); + expect(squashed).toContain(warning.replaceAll(/\s+/g, '')); + }); }); describe('ModelSelectorComponent overrides', () => { diff --git a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index 28fc4210f..3f4287486 100644 --- a/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -131,4 +131,26 @@ describe('TabbedModelSelectorComponent', () => { // It comes first, before the navigation hint. expect(hint!.indexOf('Tab toggle provider')).toBeLessThan(hint!.indexOf('↑↓ navigate')); }); + + it('keeps the tab strip between hint and list when a warning line is present', () => { + const component = new TabbedModelSelectorComponent({ + models: { + k2: model('Kimi K2', 'managed:kimi-code'), + gpt: model('GPT-5', 'openai'), + }, + currentValue: 'k2', + currentThinkingEffort: 'off', + warning: 'Switching may increase token usage.', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = component.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('navigate') && l.includes('Esc cancel')); + expect(lines[hintIdx + 1]).toContain('Switching may increase token usage.'); + expect(lines[hintIdx + 2]).toBe(''); // blank between warning and tabs + const stripIdx = lines.findIndex((l) => l.includes('All') && l.includes('openai')); + expect(stripIdx).toBe(hintIdx + 3); + expect(lines[stripIdx + 1]).toBe(''); // blank between tabs and list + expect(lines.findIndex((l) => l.includes('Kimi K2'))).toBeGreaterThan(stripIdx); + }); }); diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 96598f6f5..cf7184b3b 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -587,6 +587,34 @@ describe('CustomEditor paste marker expansion', () => { editor.handleInput('x'); expect(editor.getText()).toContain('x'); }); + + it('falls back to the text paste path when the image paste handler rejects', async () => { + const editor = makeEditor(); + const onTextPaste = vi.fn(); + editor.onTextPaste = onTextPaste; + editor.onPasteImage = vi.fn(async () => { + throw new Error('clipboard backend broken'); + }); + + // Regression: a rejecting onPasteImage must not leak an unhandled + // rejection — the CLI's crash path turns those into a silent exit. + const rejections: unknown[] = []; + const onRejection = (reason: unknown): void => { + rejections.push(reason); + }; + process.on('unhandledRejection', onRejection); + try { + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(onTextPaste).toHaveBeenCalledOnce(); + expect(rejections).toHaveLength(0); + } finally { + process.off('unhandledRejection', onRejection); + } + }); }); describe('CustomEditor shortcut telemetry hooks', () => { diff --git a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts index ed69d3a85..43e1aa8d1 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts @@ -55,7 +55,7 @@ describe('buildGoalReportLines', () => { expect(out).toContain('Running'); expect(out).toContain('4m 12s'); expect(out).toContain('Turns'); - expect(out).toContain('128.4k'); // formatTokenCount + expect(out).toContain('125k'); // formatTokenCount }); it('shows a no-stop-condition note for an unbounded active goal', () => { diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index e1b2b013e..1cb20833d 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -60,8 +60,8 @@ describe('status panel report lines', () => { expect(output).toContain('Session ses-1'); expect(output).toContain('Title Implement status'); expect(output).toContain('Context window'); - expect(output).toContain('25.0%'); - expect(output).toContain('(3.0k / 12.0k)'); + expect(output).toContain('25%'); + expect(output).toContain('(2.9k / 11.7k)'); expect(output).toContain('Plan usage'); expect(output).toContain('8% used'); expect(output).not.toContain('Account'); diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index cf2598f82..199031896 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -40,9 +40,9 @@ describe('UsagePanelComponent', () => { }).map(strip); expect(lines).toContain('Session usage'); - expect(lines).toContain(' kimi input 2.0k output 250 total 2.3k'); + expect(lines).toContain(' kimi input 2k output 250 total 2.2k'); expect(lines).toContain('Context window'); - expect(lines.join('\n')).toContain('25.0%'); + expect(lines.join('\n')).toContain('25%'); expect(lines).toContain('Plan usage'); expect(lines.join('\n')).toContain('20% used'); expect(lines.join('\n')).toContain('resets tomorrow'); diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index 629cd6ec9..f7c576d9a 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -45,32 +45,50 @@ function baseState(overrides: Partial = {}): AppState { } describe('FooterComponent — context NaN resilience', () => { - it('NaN usage → renders 0.0% (never literal "NaN%")', () => { + it('NaN usage → renders 0% (never literal "NaN%")', () => { const fc = new FooterComponent(baseState({ contextUsage: Number.NaN })); const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); - expect(out).toMatch(/context: 0\.0%/); + expect(out).toMatch(/context: 0%/); }); - it('undefined-ish (coerced) usage → renders 0.0%', () => { + it('undefined-ish (coerced) usage → renders 0%', () => { const fc = new FooterComponent( baseState({ contextUsage: undefined as unknown as number }), ); const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); - expect(out).toMatch(/context: 0\.0%/); + expect(out).toMatch(/context: 0%/); }); - it('clamps ratios above 1.0 → renders 100.0%', () => { + it('clamps ratios above 1.0 → renders 100%', () => { const fc = new FooterComponent(baseState({ contextUsage: 1.5 })); const out = strip(fc.render(120).join('')); - expect(out).toMatch(/context: 100\.0%/); + expect(out).toMatch(/context: 100%/); }); - it('ratio 0.427 → renders 42.7%', () => { + it('ratio 0.427 → renders 43% (ceiled whole percent)', () => { const fc = new FooterComponent(baseState({ contextUsage: 0.427 })); const out = strip(fc.render(200).join('')); - expect(out).toMatch(/context: 42\.7%/); + expect(out).toMatch(/context: 43%/); + }); + + it('tiny non-zero usage → renders 1% (ceil floor)', () => { + const fc = new FooterComponent(baseState({ contextUsage: 0.0004 })); + const out = strip(fc.render(200).join('')); + expect(out).toMatch(/context: 1%/); + }); + + it('valid tokens/maxTokens → percent from tokens, counts in 1024 units', () => { + const fc = new FooterComponent( + baseState({ + contextUsage: 0.427, + contextTokens: 430_080, + maxContextTokens: 1_048_576, + }), + ); + const out = strip(fc.render(200).join('')); + expect(out).toMatch(/context: 42% \(420k\/1M\)/); }); it('renders raw / projection / all token counts when the window is known', () => { @@ -83,7 +101,7 @@ describe('FooterComponent — context NaN resilience', () => { }), ); const out = strip(fc.render(200).join('')); - expect(out).toMatch(/context: 25\.0% \(raw 400\.0k \/ projection 250\.0k \/ all 1\.0M\)/); + expect(out).toMatch(/context: 25% \(raw 391k \/ projection 244k \/ all 977k\)/); }); it('tokens provided but max=0 → falls back to percent-only, no division-by-zero artefact', () => { @@ -92,7 +110,7 @@ describe('FooterComponent — context NaN resilience', () => { ); const out = strip(fc.render(200).join('')); expect(out).not.toMatch(/Infinity|NaN/); - expect(out).toMatch(/context: 0\.0%/); + expect(out).toMatch(/context: 0%/); // With maxTokens=0, token-count annotation is suppressed. expect(out).not.toMatch(/\(500\//); }); @@ -105,7 +123,7 @@ describe('FooterComponent — context NaN resilience', () => { const out = strip(footer.render(200).join('')); expect(out).toContain('kimi-k2-5'); expect(out).not.toContain(' k2 '); - expect(out).toMatch(/context: 50\.0%/); + expect(out).toMatch(/context: 50%/); }); it('shows "thinking" label when thinking is enabled, hides it when disabled', () => { @@ -123,7 +141,7 @@ describe('FooterComponent — context NaN resilience', () => { const [, line2] = footer.render(120); expect(strip(line2 ?? '')).toContain('Press Ctrl-C again to exit'); - expect(strip(line2 ?? '')).toContain('context: 0.0%'); + expect(strip(line2 ?? '')).toContain('context: 0%'); }); it('highlights the pull request badge separately from git status text', () => { 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 83456780b..0ca292e13 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 @@ -20,6 +20,7 @@ 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'; +import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; import { MOON_SPINNER_FRAMES } from '#/tui/constant/rendering'; import { @@ -4583,7 +4584,7 @@ command = "vim" expect(output).toContain('Permissions auto'); expect(output).toContain('Plan mode on'); expect(output).toContain('Context window'); - expect(output).toContain('25.0%'); + expect(output).toContain('25%'); }); }); @@ -5881,16 +5882,20 @@ describe('/model status displayName override', () => { }); describe('/effort support_efforts override', () => { - it('rejects efforts hidden by support_efforts override', async () => { + it('warns and applies efforts hidden by an Anthropic support_efforts override', async () => { const session = makeSession(); const { driver } = await makeDriver(session, { getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, models: { k2: { - provider: 'managed:kimi-code', - model: 'kimi-k2', + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', maxContextSize: 100, - displayName: 'Kimi K2', + displayName: 'Compatible Model', capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'], overrides: { supportEfforts: ['low', 'high'] }, @@ -5904,8 +5909,125 @@ describe('/effort support_efforts override', () => { await driver.handleUserInput('/effort max'); await vi.waitFor(() => { - expect(renderTranscript(driver)).toContain('Unsupported thinking effort "max" for k2. Available: off, low, high'); + expect(session.setThinking).toHaveBeenCalledWith('max'); }); - expect(renderTranscript(driver)).not.toContain('Switched to Kimi K2 with thinking max.'); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Thinking set to max.'); + }); + const transcript = renderTranscript(driver).replaceAll(/\s+/g, ' '); + expect(transcript).toContain( + 'Thinking effort "max" is not listed for k2 (known: low, high). Sending "max" unchanged; the configured provider will validate it.', + ); + expect(transcript).toContain('Thinking set to max.'); + }); + + it('offers the latest Opus efforts for an unknown Anthropic-compatible model', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'anthropic', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + maxContextSize: 100, + }, + }, + defaultModel: 'k2', + })), + }); + + driver.handleUserInput('/effort'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent; + expect(picker.render(80).join('\n')).toContain('Max'); + }); + + it('offers no fallback efforts for an unknown model on a Kimi provider using the Anthropic protocol', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + }, + }, + defaultModel: 'k2', + })), + }); + + driver.handleUserInput('/effort'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent; + expect(picker.render(80).join('\n')).not.toContain('Max'); + }); + + it('offers the latest Opus efforts for a flat providerless Anthropic model', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + providers: {}, + models: { + // v2 flat model shape: no named provider, inline endpoint + protocol. + k2: { + model: 'compatible-model', + baseUrl: 'https://anthropic.example.test', + protocol: 'anthropic', + maxContextSize: 100, + }, + }, + defaultModel: 'k2', + })), + }); + + driver.handleUserInput('/effort'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent; + expect(picker.render(80).join('\n')).toContain('Max'); + }); + + it('keeps rejecting efforts hidden by a Kimi support_efforts override', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + kimi: { type: 'kimi', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'kimi', + model: 'kimi-model', + maxContextSize: 100, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + }); + + driver.handleUserInput('/effort max'); + + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain( + 'Unsupported thinking effort "max" for k2. Available: off, low, high', + ); + }); + expect(session.setThinking).not.toHaveBeenCalled(); }); }); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 1bf0767b2..8645dd120 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -539,7 +539,7 @@ describe('KimiTUI resume message replay', () => { expect(transcript).toContain('Goal resumed'); expect(transcript).toContain('Goal blocked'); expect(transcript).toContain('Goal complete — done'); - expect(transcript).toContain('Worked 1 turn over 7m15s, using 4.3k tokens.'); + expect(transcript).toContain('Worked 1 turn over 7m15s, using 4.2k tokens.'); }); it('filters resume-normalization goal pause markers in TUI replay', async () => { @@ -585,7 +585,7 @@ describe('KimiTUI resume message replay', () => { expect(entry).toMatchObject({ kind: 'assistant', renderMode: 'markdown', - content: '✓ Goal complete.\nWorked 1 turn over 7m15s, using 4.3M tokens.', + content: '✓ Goal complete.\nWorked 1 turn over 7m15s, using 4.1M tokens.', }); }); diff --git a/apps/kimi-code/test/tui/utils/goal-completion.test.ts b/apps/kimi-code/test/tui/utils/goal-completion.test.ts index 0ef499e19..ac09feb73 100644 --- a/apps/kimi-code/test/tui/utils/goal-completion.test.ts +++ b/apps/kimi-code/test/tui/utils/goal-completion.test.ts @@ -20,7 +20,7 @@ describe('buildGoalCompletionMessage', () => { const text = buildGoalCompletionMessage(snapshot()); expect(text).toContain('Goal complete — all tests pass.'); expect(text).toContain('3 turns'); - expect(text).toContain('12.5k tokens'); + expect(text).toContain('12.2k tokens'); expect(text).toContain('4m20s'); }); diff --git a/apps/kimi-code/test/utils/usage/debug-timing.test.ts b/apps/kimi-code/test/utils/usage/debug-timing.test.ts index 353b10ee5..5cd04ee24 100644 --- a/apps/kimi-code/test/utils/usage/debug-timing.test.ts +++ b/apps/kimi-code/test/utils/usage/debug-timing.test.ts @@ -39,7 +39,7 @@ describe('formatStepDebugTiming', () => { }, }); expect(result).toBe( - '[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s) | tokens in 2.0k | cache read 1.2k (60%) / write 100', + '[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s) | tokens in 2k | cache read 1.2k (60%) / write 100', ); }); @@ -54,7 +54,7 @@ describe('formatStepDebugTiming', () => { output: 200, }, }); - expect(result).toContain('tokens in 1.0k'); + expect(result).toContain('tokens in 1000'); expect(result).toContain('cache read 0 (0%)'); expect(result).not.toContain('/ write 0'); }); diff --git a/apps/kimi-code/test/utils/usage/usage-format.test.ts b/apps/kimi-code/test/utils/usage/usage-format.test.ts index 264bb5c55..f22078285 100644 --- a/apps/kimi-code/test/utils/usage/usage-format.test.ts +++ b/apps/kimi-code/test/utils/usage/usage-format.test.ts @@ -5,6 +5,8 @@ import { renderProgressBar, ratioSeverity, safeUsageRatio, + usagePercent, + usagePercentFromRatio, } from '#/utils/usage/usage-format'; describe('formatTokenCount', () => { @@ -14,15 +16,27 @@ describe('formatTokenCount', () => { expect(formatTokenCount(999)).toBe('999'); }); - it('rounds integers over 1k to 1 decimal', () => { - expect(formatTokenCount(1_000)).toBe('1.0k'); - expect(formatTokenCount(1_234)).toBe('1.2k'); - expect(formatTokenCount(9_876)).toBe('9.9k'); + it('switches to k at 1024 and trims a redundant ".0"', () => { + expect(formatTokenCount(1_000)).toBe('1000'); + expect(formatTokenCount(1_024)).toBe('1k'); + expect(formatTokenCount(1_536)).toBe('1.5k'); + expect(formatTokenCount(2_048)).toBe('2k'); }); - it('switches to M above a million', () => { - expect(formatTokenCount(1_000_000)).toBe('1.0M'); - expect(formatTokenCount(2_500_000)).toBe('2.5M'); + it('rounds k values to 1 decimal', () => { + expect(formatTokenCount(50_552)).toBe('49.4k'); + expect(formatTokenCount(262_144)).toBe('256k'); + }); + + it('rounds k values at or above 100k to whole k', () => { + expect(formatTokenCount(102_400)).toBe('100k'); + expect(formatTokenCount(999_999)).toBe('977k'); + }); + + it('switches to M at 1024*1024', () => { + expect(formatTokenCount(1_048_576)).toBe('1M'); + expect(formatTokenCount(1_572_864)).toBe('1.5M'); + expect(formatTokenCount(10_485_760)).toBe('10M'); }); it('clamps negatives and NaN to 0', () => { @@ -32,6 +46,51 @@ describe('formatTokenCount', () => { }); }); +describe('usagePercent', () => { + it('returns 0 for zero usage', () => { + expect(usagePercent(0, 1000)).toBe(0); + }); + + it('ceil-guarantees at least 1% for any non-zero usage', () => { + expect(usagePercent(4, 10_000)).toBe(1); + }); + + it('ceils fractional percentages', () => { + expect(usagePercent(427, 1000)).toBe(43); + expect(usagePercent(992, 1000)).toBe(100); + }); + + it('clamps to 100 when used meets or exceeds max', () => { + expect(usagePercent(1000, 1000)).toBe(100); + expect(usagePercent(1200, 1000)).toBe(100); + }); + + it('returns 0 for a non-positive or non-finite max', () => { + expect(usagePercent(500, 0)).toBe(0); + expect(usagePercent(500, -1)).toBe(0); + expect(usagePercent(500, Number.NaN)).toBe(0); + }); +}); + +describe('usagePercentFromRatio', () => { + it('coerces NaN to 0', () => { + expect(usagePercentFromRatio(Number.NaN)).toBe(0); + }); + + it('returns 0 for zero usage', () => { + expect(usagePercentFromRatio(0)).toBe(0); + }); + + it('ceil-guarantees at least 1% for any non-zero ratio', () => { + expect(usagePercentFromRatio(0.004)).toBe(1); + }); + + it('ceils fractional percentages and clamps above 100', () => { + expect(usagePercentFromRatio(0.427)).toBe(43); + expect(usagePercentFromRatio(1.5)).toBe(100); + }); +}); + describe('renderProgressBar', () => { it('empty bar at ratio 0', () => { expect(renderProgressBar(0, 10)).toBe('░'.repeat(10)); diff --git a/apps/kimi-web/index.html b/apps/kimi-web/index.html index dff0375e4..67fc5d790 100644 --- a/apps/kimi-web/index.html +++ b/apps/kimi-web/index.html @@ -3,25 +3,16 @@ - + - + the data attributes. Mirrors applyColorSchemeToDocument. Loaded from the + external /boot.js (a classic script, so still render-blocking) because + the server's Content-Security-Policy forbids inline scripts. --> + Kimi Code Web diff --git a/apps/kimi-web/public/boot.js b/apps/kimi-web/public/boot.js new file mode 100644 index 000000000..ec495553a --- /dev/null +++ b/apps/kimi-web/public/boot.js @@ -0,0 +1,10 @@ +(function () { + try { + var v = localStorage.getItem('kimi-web.color-scheme'); + if (v === 'light' || v === 'dark' || v === 'system') { + document.documentElement.dataset.colorScheme = v; + } + } catch { + /* ignore */ + } +})(); diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 5674a55ec..f8ab512d3 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -27,6 +27,9 @@ import GlobalLoading from './components/GlobalLoading.vue'; import DebugPanel from './debug/DebugPanel.vue'; import { isTraceEnabled } from './debug/trace'; import { useKimiWebClient } from './composables/useKimiWebClient'; +import { useConfirmDialog } from './composables/useConfirmDialog'; +import type { PromptAttachment } from './composables/useKimiWebClient'; +import type { TurnAttachment } from './types'; import { useAuthGate } from './composables/useAuthGate'; import { usePageTitle } from './composables/usePageTitle'; import { useSidebarLayout } from './composables/useSidebarLayout'; @@ -72,6 +75,7 @@ provide( (toolCallId: string): SwarmMember[] => client.swarmMembersByToolCallId.value.get(toolCallId) ?? [], ); const { t } = useI18n(); +const { confirm } = useConfirmDialog(); // KAP/daemon debug panel — opt-in via ?debug=1 or localStorage kimi-web.debug=1. const debugEnabled = isTraceEnabled(); @@ -143,6 +147,28 @@ function openOnboarding(): void { showOnboarding.value = true; } +// iOS Safari does not shrink `dvh` for the on-screen keyboard. Instead it pans +// the visual viewport (offsetTop > 0) to reveal the focused field, which a +// 100dvh in-flow shell cannot follow: the dock ends up behind the keyboard, or +// the page shows a blank band past the shell's bottom edge. Pin the shell to +// the VISUAL viewport instead: position:fixed + top/height mirrored from +// visualViewport (height shrinks with the keyboard, offsetTop tracks the pan). +// No-ops on desktop, where offsetTop is 0 and height equals innerHeight. +let appHeightRaf = 0; +function setAppHeight(): void { + const vv = window.visualViewport; + const root = document.documentElement.style; + root.setProperty('--app-height', `${vv?.height ?? window.innerHeight}px`); + root.setProperty('--app-top', `${vv?.offsetTop ?? 0}px`); +} +function syncAppHeight(): void { + if (appHeightRaf) return; + appHeightRaf = requestAnimationFrame(() => { + appHeightRaf = 0; + setAppHeight(); + }); +} + onMounted(() => { // Register the 401 listener before the first requests go out, so a token // rejection during the initial load() can never be missed. @@ -154,6 +180,10 @@ onMounted(() => { }); void client.load(); loadSidebarCollapsed(); + setAppHeight(); + window.visualViewport?.addEventListener('resize', syncAppHeight); + window.visualViewport?.addEventListener('scroll', syncAppHeight); + window.addEventListener('resize', syncAppHeight); // Capture-phase so Escape closes the side detail layer BEFORE the // conversation pane's bubble-phase handler interrupts a running prompt. document.addEventListener('keydown', onGlobalKeydown, true); @@ -161,6 +191,15 @@ onMounted(() => { onUnmounted(() => { document.removeEventListener('keydown', onGlobalKeydown, true); + window.visualViewport?.removeEventListener('resize', syncAppHeight); + window.visualViewport?.removeEventListener('scroll', syncAppHeight); + window.removeEventListener('resize', syncAppHeight); + if (appHeightRaf) { + cancelAnimationFrame(appHeightRaf); + appHeightRaf = 0; + } + document.documentElement.style.removeProperty('--app-height'); + document.documentElement.style.removeProperty('--app-top'); if (offAuthRequired !== null) { offAuthRequired(); offAuthRequired = null; @@ -283,7 +322,7 @@ const showSettings = ref(false); type SubmitPayload = { text: string; - attachments: { fileId: string; kind: 'image' | 'video' }[]; + attachments: PromptAttachment[]; }; const pendingWorkspaceSubmit = ref(null); // Inline error shown inside the add-workspace picker after the daemon rejects @@ -321,8 +360,10 @@ async function openModelPicker(): Promise { modelsUnavailable.value = false; showModelPicker.value = true; try { - await client.refreshOAuthProviderModels(); - await client.loadModels(); + // Full refresh first (every refreshable provider, not just OAuth), so the + // list always reflects the live catalog — the WS model-catalog event that + // used to keep the cache warm is no longer forwarded by the daemon. + await client.refreshAllProviders(); } catch { modelsUnavailable.value = true; } finally { @@ -376,14 +417,43 @@ async function handleAddProvider(input: { type: string; apiKey?: string; baseUrl await client.addProvider(input); } -async function handleDeleteProvider(id: string): Promise { - await client.deleteProvider(id); -} - async function handleRefreshProvider(id: string): Promise { await client.refreshProvider(id); } +// Destructive session/workspace/provider actions confirm through the shared +// modal here (the menu components only emit the intent). Each passes its work +// as the dialog `action`, so the dialog stays open with a loading state until +// the operation settles. All three client calls toast their own errors and +// never reject. +async function confirmArchiveSession(id: string): Promise { + await confirm({ + title: t('sidebar.archive'), + message: t('sidebar.archiveConfirm'), + variant: 'danger', + action: () => client.archiveSession(id), + }); +} + +async function confirmDeleteWorkspace(id: string): Promise { + const name = client.workspacesView.value.find((w) => w.id === id)?.name ?? id; + await confirm({ + title: t('sidebar.removeWorkspace'), + message: t('workspace.removeWorkspaceConfirm', { name }), + variant: 'danger', + action: () => client.deleteWorkspace(id), + }); +} + +async function confirmDeleteProvider(id: string): Promise { + await confirm({ + title: t('providers.delete'), + message: t('providers.confirmDelete'), + variant: 'danger', + action: () => client.deleteProvider(id), + }); +} + async function handleUpdateConfig(patch: Partial): Promise { configSaving.value = true; try { @@ -420,11 +490,11 @@ async function handleLoginSuccess(): Promise { // then drop that message's text back into the composer for editing. async function handleEditMessage(payload: { text: string; - images?: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[]; + attachments?: TurnAttachment[]; }): Promise { await client.undo(1); await nextTick(); - conversationPaneRef.value?.loadComposerForEdit(payload.text, payload.images); + conversationPaneRef.value?.loadComposerForEdit(payload.text, payload.attachments); } // Handler for slash commands emitted by Composer (via ConversationPane) @@ -671,11 +741,11 @@ function openPr(url: string): void { @select-workspace="client.openWorkspace($event)" @add-workspace="showAddWorkspace = true" @rename="(id, title) => client.renameSession(id, title)" - @archive="(id) => client.archiveSession(id)" + @archive="confirmArchiveSession($event)" @fork="(id) => client.forkSession(id)" @export="(id) => client.exportSession(id)" @rename-workspace="(id, name) => client.renameWorkspace(id, name)" - @delete-workspace="(id) => client.deleteWorkspace(id)" + @delete-workspace="confirmDeleteWorkspace($event)" @reorder-workspaces="client.reorderWorkspaces($event)" @set-workspace-sort-mode="client.setWorkspaceSortMode($event)" @load-more-sessions="(id) => void client.loadMoreSessions(id)" @@ -732,10 +802,11 @@ function openPr(url: string): void { :pending-question-actions="client.pendingQuestionActions" :pending-approval-actions="client.pendingApprovalActions" :running="running" + :turn-active="client.turnActive.value" :queued="client.queued.value" :search-files="client.searchFiles" :upload-image="client.uploadImage" - :sending="client.isSending.value" + :working="client.working.value" :starting="client.isStartingFirstPrompt.value" :fast-moon="client.fastMoon.value" :file-reload-key="client.activeSessionId.value" @@ -778,7 +849,7 @@ function openPr(url: string): void { @refresh-git-status="client.activeSessionId.value && client.loadGitStatus(client.activeSessionId.value)" @rename-session="(id, title) => client.renameSession(id, title)" @fork-session="(id) => client.forkSession(id)" - @archive-session="(id) => client.archiveSession(id)" + @archive-session="confirmArchiveSession($event)" @export-session="(id) => client.exportSession(id)" @compact="client.compact()" @pick-model="openModelPicker()" @@ -957,7 +1028,7 @@ function openPr(url: string): void { :unavailable="providersUnavailable" @add="handleAddProvider($event)" @refresh="handleRefreshProvider($event)" - @delete="handleDeleteProvider($event)" + @delete="confirmDeleteProvider($event)" @open-login="() => { showProviders = false; openLogin(); }" @close="showProviders = false" /> @@ -1022,8 +1093,8 @@ function openPr(url: string): void { @create-in-workspace="handleCreateSessionInWorkspace($event)" @add-workspace="showAddWorkspace = true" @rename="(id, title) => client.renameSession(id, title)" - @archive="(id) => client.archiveSession(id)" - @delete-workspace="(id) => client.deleteWorkspace(id)" + @archive="confirmArchiveSession($event)" + @delete-workspace="confirmDeleteWorkspace($event)" @load-more="(id) => void client.loadMoreSessions(id)" /> @@ -1071,8 +1142,17 @@ function openPr(url: string): void { .gload-fade-leave-to { opacity: 0; } .app-shell { + /* Pinned to the visual viewport (see setAppHeight): --app-top tracks iOS's + keyboard pan and --app-height shrinks with the keyboard, so the shell + always covers exactly the visible area. Fixed positioning keeps it out of + the document flow that iOS pans. */ + position: fixed; + top: var(--app-top, 0px); + left: 0; + right: 0; height: 100vh; height: 100dvh; + height: var(--app-height, 100dvh); display: flex; flex-direction: column; overflow: hidden; @@ -1248,10 +1328,10 @@ function openPr(url: string): void { .auth-page { align-items: flex-start; padding: - max(48px, env(safe-area-inset-top)) - max(20px, env(safe-area-inset-right)) - max(24px, env(safe-area-inset-bottom)) - max(20px, env(safe-area-inset-left)); + max(48px, var(--safe-top)) + max(20px, var(--safe-right)) + max(24px, var(--safe-bottom)) + max(20px, var(--safe-left)); } .auth-page-copy h1 { font-size: 26px; diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index 17d4cf542..88c47b799 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -57,6 +57,7 @@ const MAIN_AGENT_TRANSCRIPT_FRAMES = new Set([ 'tool.result', 'agent.status.updated', 'prompt.completed', + 'prompt.aborted', 'error', ]); @@ -127,6 +128,10 @@ interface SessionState { // Subagent lifecycle deltas after spawned only carry subagentId. Keep the // spawned metadata here so later updates can replace the full AppTask. subagentMeta: Map; + + // Bubble cleared by turn.step.retrying, to be reused by the retried + // step.started (same turn) instead of stacking a new bubble. + retryReuseMsgId: string | undefined; } function createSessionState(): SessionState { @@ -148,6 +153,7 @@ function createSessionState(): SessionState { model: '', messages: [], subagentMeta: new Map(), + retryReuseMsgId: undefined, }; } @@ -699,12 +705,11 @@ export function createAgentProjector(): AgentProjector { // ----------------------------------------------------------------------- case 'turn.started': { // Bind turnId → promptId. Generate a synthetic one if none was pre-bound. - // Session status is intentionally NOT projected here — the daemon's - // `event.session.status_changed` is the single source of status - // transitions (it carries the authoritative previousStatus / - // currentPromptId and dedupes per real transition); projecting a - // second running/idle event per turn from the raw stream made every - // turn-end consumer (notifications, sounds) fire twice. + // Session busy is intentionally NOT projected here — the daemon's + // `event.session.work_changed` is the single source of the busy fact + // (it re-reads the authoritative drain registry and dedupes per real + // transition); projecting a second busy flip per turn from the raw + // stream made every turn-end consumer fire twice. const turnId: number = p?.turnId; const existingPromptId = s.currentPromptId ?? ulid('pr_'); s.currentPromptId = existingPromptId; @@ -714,6 +719,9 @@ export function createAgentProjector(): AgentProjector { // Fresh turn → fresh step stream offsets. s.turnTextLen = 0; s.turnThinkLen = 0; + // Main-conversation liveness (the moon) keys off the main agent's turn + // boundary directly — only main-agent frames reach this switch arm. + out.push({ type: 'turnActiveChanged', sessionId, active: true }); break; } @@ -736,6 +744,17 @@ export function createAgentProjector(): AgentProjector { s.turnTextLen = 0; s.turnThinkLen = 0; + // A retry continuation: refill the bubble turn.step.retrying cleared, + // instead of creating a second bubble with the same step's content. + if (s.retryReuseMsgId !== undefined) { + const reuseId = s.retryReuseMsgId; + s.retryReuseMsgId = undefined; + if (getMsgById(s, reuseId) !== undefined) { + s.currentAssistantMsgId = reuseId; + break; + } + } + // Create a new pending assistant message const msg = startAssistantMessage(s, sessionId, promptId); s.currentAssistantMsgId = msg.id; @@ -957,6 +976,16 @@ export function createAgentProjector(): AgentProjector { const reason: string = p?.reason ?? 'completed'; const durationMs = numberField(p ?? {}, 'durationMs'); + // Main-conversation liveness: the prompt this turn served is done. + // This — not the session-busy status — is what ends the working moon. + // It MUST be emitted first in this arm: the onMainTurnEnd side effect + // gates on `seq > lastSeqBySession`, and sibling events in this arm + // advance that cursor — emitted after them, this event would compare + // equal and the prompt-finish cleanup (moon, queue drain) would never + // fire (observed: moon stuck when a turn ends with background tasks + // still running, where no work_changed(busy:false) fallback exists). + out.push({ type: 'turnActiveChanged', sessionId, active: false, reason: p?.reason }); + if (msgId) { finishAssistantMessage(s, msgId); const msg = getMsgById(s, msgId); @@ -976,30 +1005,86 @@ export function createAgentProjector(): AgentProjector { const usageSnapshot = buildUsageSnapshot(s); out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot }); - // No sessionStatusChanged here — see turn.started. The daemon's - // `event.session.status_changed` flips the session to idle/aborted. + // No busy projection here — see turn.started. The daemon's + // `event.session.work_changed` flips the session busy fact. // Clear per-turn state. Reset the stream offsets too so a stale length // from this turn can't wedge the next turn's delta alignment into a - // silent skip if its turn.started is missed across a reconnect. + // silent skip if its turn.started is missed across a reconnect. The + // retry reuse target is per-turn as well: if the turn died between + // turn.step.retrying and the retried step.started, the next prompt + // must open a fresh bubble, not refill this turn's emptied one. s.currentAssistantMsgId = undefined; s.currentPromptId = undefined; s.turnTextLen = 0; s.turnThinkLen = 0; + s.retryReuseMsgId = undefined; break; } // ----------------------------------------------------------------------- case 'prompt.completed': { - // No-op at AppEvent level — turn.ended already handles the transition to idle + // No state change at AppEvent level — turn.ended / the session + // status_changed ahead of this event already finished the prompt. The + // event rides along so the web layer can spot the one case that has no + // turn-level signal: a prompt blocked before any turn started (reason + // 'blocked'), which would otherwise pin the in-flight state forever. + const promptId: string | undefined = p?.promptId; + if (typeof promptId === 'string' && promptId.length > 0) { + out.push({ type: 'promptCompleted', sessionId, promptId, reason: p?.reason ?? 'completed' }); + } break; } // ----------------------------------------------------------------------- - case 'turn.step.retrying': + case 'prompt.aborted': { + // Fires both for an active-turn abort (a turn.ended + status_changed + // precede it — the prompt is already finished) and for a QUEUED prompt + // that never started a turn (no turn events, no status flip). The web + // layer keys on promptId to clear the in-flight state in the latter case. + const promptId: string | undefined = p?.promptId; + if (typeof promptId === 'string' && promptId.length > 0) { + out.push({ type: 'promptAborted', sessionId, promptId }); + } + break; + } + + // ----------------------------------------------------------------------- + case 'turn.step.retrying': { + // The step's stream restarts from offset 0. Reuse the abandoned + // bubble instead of stacking a new one: strip its streamed parts and + // keep the id in retryReuseMsgId so the retried step.started refills + // it in place. Otherwise the failed attempt's partial bubble stays + // rendered next to the retry's full stream — the "text/tool shown + // twice" duplication (far more visible since the retry budget grew). + const msgId = s.currentAssistantMsgId; + if (msgId !== undefined) { + const msg = getMsgById(s, msgId); + if (msg !== undefined) { + msg.content = msg.content.filter( + (c) => c.type !== 'text' && c.type !== 'thinking' && c.type !== 'toolUse', + ); + out.push({ + type: 'messageUpdated', + sessionId, + messageId: msgId, + content: msg.content.map((c) => ({ ...c })), + status: 'pending', + }); + s.retryReuseMsgId = msgId; + } + } + s.turnTextLen = 0; + s.turnThinkLen = 0; + s.toolStartTimes.clear(); + break; + } + case 'turn.step.interrupted': { - // Discard current assistant message; next step.started will create a new one + // Discard current assistant message; next step.started will create a + // new one. Drop any pending retry reuse target for the same reason. s.currentAssistantMsgId = undefined; + s.retryReuseMsgId = undefined; break; } @@ -1088,10 +1173,20 @@ export function createAgentProjector(): AgentProjector { // ----------------------------------------------------------------------- case 'error': { - // Fold into an unknown event so the reducer pushes a warning string + // Fold into an unknown event so the reducer surfaces it as a structured + // error notice (semantic title + code/status/requestId details). The + // wire payload already carries name/details/retryable — pass them + // through untouched; the reducer decides what to display. out.push({ type: 'unknown', - raw: { _agentError: true, code: p?.code, message: p?.message }, + raw: { + _agentError: true, + code: p?.code, + message: p?.message, + name: p?.name, + details: p?.details, + retryable: p?.retryable, + }, }); break; } @@ -1124,6 +1219,48 @@ export function createAgentProjector(): AgentProjector { : typeof info.command === 'string' ? info.command : i18n.global.t('tasks.defaultDescription'); + // A background subagent registers into the background-task store under + // a fresh task id that differs from its agent id. Record the task id on + // the existing WS-owned row (keyed by agent id) instead of adding a + // second row — REST `/tasks` returns the same agent keyed by task id, + // and keepLiveSubagents folds that copy into this row. + if (info.kind === 'agent') { + const agentId = + typeof info.agentId === 'string' && info.agentId.length > 0 + ? info.agentId + : undefined; + if (agentId !== undefined) { + // Key by agent id even when the spawn event never reached this + // client (subscribed late): later agent-scoped progress frames are + // routed by agent id, and seeding subagentMeta here keeps them on + // this one row instead of synthesizing a second one. + const task = patchSubagent(s, sessionId, agentId, { + description, + backgroundTaskId: taskId, + runInBackground: true, + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); + } else { + // No agent id — nothing to link; key the row by the background + // task id so the REST poll dedupes it. + out.push({ + type: 'taskCreated', + sessionId, + task: { + id: taskId, + sessionId, + kind: 'subagent', + description, + status: 'running', + createdAt: startedAt ?? new Date().toISOString(), + startedAt, + subagentPhase: 'queued', + runInBackground: true, + }, + }); + } + break; + } const command = typeof info.command === 'string' ? info.command : undefined; out.push({ type: 'taskCreated', @@ -1318,6 +1455,7 @@ const KNOWN_AGENT_CORE_TYPES = new Set([ 'agent.status.updated', 'prompt.submitted', 'prompt.completed', + 'prompt.aborted', 'session.meta.updated', 'compaction.started', 'compaction.completed', diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 30c883910..714ff47e2 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -17,7 +17,6 @@ import type { AppSessionCursor, AppSessionRuntimeStatus, AppSessionSnapshot, - AppSessionStatus, AppTask, AppTaskStatus, AppTerminal, @@ -52,7 +51,6 @@ import { toWireApprovalResponse, toWirePromptSubmission, toWireQuestionResponse, - toWireSessionStatus, toAppWorkspace, wireEventSeq, wireEventSessionId, @@ -347,7 +345,7 @@ export class DaemonKimiWebApi implements KimiWebApi { async listSessions( input?: PageRequest & { - status?: AppSessionStatus; + busy?: boolean; workspaceId?: string; includeArchive?: boolean; archivedOnly?: boolean; @@ -358,7 +356,7 @@ export class DaemonKimiWebApi implements KimiWebApi { before_id: input?.beforeId, after_id: input?.afterId, page_size: input?.pageSize, - status: input?.status ? toWireSessionStatus(input.status) : undefined, + busy: input?.busy, include_archive: input?.includeArchive, archived_only: input?.archivedOnly, exclude_empty: input?.excludeEmpty, @@ -566,7 +564,7 @@ export class DaemonKimiWebApi implements KimiWebApi { }; traceKeyEvent('session:snapshot:accepted', { sessionId, - status: snapshot.session.status, + busy: snapshot.session.busy, seq: snapshot.asOfSeq, messageCount: snapshot.messages.length, durationMs: Date.now() - startedAt, @@ -1178,8 +1176,6 @@ export class DaemonKimiWebApi implements KimiWebApi { name: e.name, path: e.path, isDir: e.is_dir, - isGitRepo: e.is_git_repo, - branch: e.branch, })), }; } catch { @@ -1435,6 +1431,18 @@ export class DaemonKimiWebApi implements KimiWebApi { const { type, seq, session_id: sessionId, payload, offset } = frame; const appEvents = projector.project(type, payload, sessionId, { offset }); for (const appEvent of appEvents) { + const turnId = (payload as { turnId?: unknown } | null)?.turnId; + const stream = + appEvent.type === 'assistantDelta' && + typeof turnId === 'number' && + typeof offset === 'number' && + (type === 'assistant.delta' || type === 'thinking.delta') + ? { + turnId, + offset, + kind: type === 'assistant.delta' ? ('text' as const) : ('thinking' as const), + } + : undefined; // historyCompacted from the projector is either a compaction signal // (reason auto_compact — no reload, the divider marker handles it) or // a delta-gap recovery (reason delta_gap — a real resync, routed to @@ -1442,7 +1450,7 @@ export class DaemonKimiWebApi implements KimiWebApi { if (appEvent.type === 'historyCompacted' && !isCompactionReason(appEvent.reason)) { handlers.onResync(sessionId, seq); } - handlers.onEvent(appEvent, { sessionId, seq }); + handlers.onEvent(appEvent, { sessionId, seq, stream }); } }, diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 667b70f7d..4916bfa7b 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -15,6 +15,8 @@ import type { AppGoal, AppMessage, AppMessageContent, + AppNotice, + AppNoticeDetail, AppWarning, AppQuestionRequest, AppSession, @@ -66,6 +68,11 @@ export interface KimiClientState { * live event won the race even when the goal entry stayed absent. */ goalVersionBySession: Record; lastSeqBySession: Record; + /** MAIN-agent turn in flight, per session — set from the main agent's + * turn.started/turn.ended boundary events and seeded from the snapshot's + * (main-only) inFlightTurn. Half of the working moon; subagent turns never + * reach the events that set this. */ + turnActiveBySession: Record; compactionBySession: Record; config?: AppConfig | null; warnings: AppWarning[]; @@ -83,6 +90,7 @@ export function createInitialState(): KimiClientState { goalBySession: {}, goalVersionBySession: {}, lastSeqBySession: {}, + turnActiveBySession: {}, compactionBySession: {}, warnings: [], }; @@ -110,6 +118,7 @@ function cloneState(s: KimiClientState): KimiClientState { goalBySession: { ...s.goalBySession }, goalVersionBySession: { ...s.goalVersionBySession }, lastSeqBySession: { ...s.lastSeqBySession }, + turnActiveBySession: { ...s.turnActiveBySession }, compactionBySession: { ...s.compactionBySession }, warnings: [...s.warnings], }; @@ -229,6 +238,64 @@ function appendToolOutputToMessages(messages: AppMessage[], toolCallId: string, // Reducer // --------------------------------------------------------------------------- +/** Agent error code → semantic title key under `warnings.agentError`. Codes + * come from the protocol error domain (agent-core-v2 `ProtocolErrors`); + * anything unmapped falls back to the generic `title`. */ +const AGENT_ERROR_TITLE_KEYS: Readonly> = { + 'provider.connection_error': 'connection', + 'provider.auth_error': 'auth', + 'provider.rate_limit': 'rateLimit', + 'provider.overloaded': 'overloaded', + 'provider.filtered': 'filtered', + 'provider.api_error': 'api', + 'context.overflow': 'contextOverflow', +}; + +interface AgentErrorRaw { + code?: string; + message?: string; + name?: string; + details?: Record; +} + +/** + * Build the structured error notice for a failed agent turn (typically a + * model-provider failure). The wire payload already carries the coded error — + * surface it in full so a rate-limit / auth / endpoint failure is diagnosable + * from the toast: semantic title, the provider's raw message as the body, and + * a diagnostics list (error code, HTTP status, request id, SDK error name, + * plus any extra detail fields such as finishReason). + */ +function buildAgentErrorNotice(raw: AgentErrorRaw): AppNotice { + const t = i18n.global.t; + const details: AppNoticeDetail[] = []; + const push = (label: string, value: unknown): void => { + if (typeof value === 'number' || typeof value === 'boolean') { + details.push({ label, value: String(value) }); + } else if (typeof value === 'string' && value.length > 0) { + details.push({ label, value }); + } + }; + push(t('warnings.details.code'), raw.code); + const rawDetails = raw.details ?? {}; + push(t('warnings.details.status'), rawDetails['statusCode']); + push(t('warnings.details.requestId'), rawDetails['requestId']); + push(t('warnings.details.errorName'), raw.name); + // Keep any remaining detail fields (finishReason, rawFinishReason, …) so no + // diagnostics the daemon sent are hidden. + for (const [key, value] of Object.entries(rawDetails)) { + if (key === 'statusCode' || key === 'requestId') continue; + push(key, value); + } + const titleKey = (raw.code !== undefined ? AGENT_ERROR_TITLE_KEYS[raw.code] : undefined) ?? 'title'; + return { + severity: 'error', + title: t(`warnings.agentError.${titleKey}`), + message: raw.message, + details: details.length > 0 ? details : undefined, + }; +} + /** * Apply a single AppEvent to the state, returning a new state object. * The event carries `_wireSeq` and `_wireSessionId` as hidden extras when @@ -282,6 +349,7 @@ export function reduceAppEvent( delete next.approvalsBySession[id]; delete next.questionsBySession[id]; delete next.lastSeqBySession[id]; + delete next.turnActiveBySession[id]; if (next.activeSessionId === id) { next.activeSessionId = undefined; } @@ -289,15 +357,25 @@ export function reduceAppEvent( } // ------------------------------------------------------------------------- - case 'sessionStatusChanged': { + case 'sessionWorkChanged': { next.sessions = next.sessions.map((s) => { if (s.id !== event.sessionId) return s; return { ...s, - status: event.status, - currentPromptId: event.currentPromptId, + busy: event.busy, + mainTurnActive: event.mainTurnActive ?? (event.busy ? s.mainTurnActive : false), + pendingInteraction: event.pendingInteraction ?? s.pendingInteraction, + // Authoritative, not nullish-merge: an omitted last_turn_reason is + // how the server says "no current outcome" (a fresh turn cleared + // the previous one), so the stale value must not survive. + lastTurnReason: event.lastTurnReason, }; }); + if (event.mainTurnActive === true) { + next.turnActiveBySession[event.sessionId] = true; + } else if (event.mainTurnActive === false || !event.busy) { + delete next.turnActiveBySession[event.sessionId]; + } break; } @@ -582,6 +660,7 @@ export function reduceAppEvent( parentToolCallId: event.task.parentToolCallId ?? previous.parentToolCallId, subagentType: event.task.subagentType ?? previous.subagentType, runInBackground: event.task.runInBackground ?? previous.runInBackground, + backgroundTaskId: event.task.backgroundTaskId ?? previous.backgroundTaskId, }; next.tasksBySession[sid] = patched; } @@ -664,6 +743,29 @@ export function reduceAppEvent( case 'agentTurnEnded': break; + // ------------------------------------------------------------------------- + // Prompt-level lifecycle events drive the web layer's in-flight cleanup + // (see useKimiWebClient.processEvent), not reducer state. Advance seq + // silently. + case 'promptCompleted': + case 'promptAborted': + break; + + // ------------------------------------------------------------------------- + case 'turnActiveChanged': { + next.sessions = next.sessions.map((session) => + session.id === event.sessionId + ? { ...session, mainTurnActive: event.active } + : session, + ); + if (event.active) { + next.turnActiveBySession[event.sessionId] = true; + } else { + delete next.turnActiveBySession[event.sessionId]; + } + break; + } + case 'unknown': { // Distinguish no-op known events (sentinel _noop) from agent errors/warnings // and truly unknown events. @@ -673,18 +775,20 @@ export function reduceAppEvent( _agentWarning?: boolean; code?: string; message?: string; + name?: string; + details?: Record; type?: string; } | null; if (raw && raw._noop === true) { // No-op streaming/tool event — seq already advanced, nothing else to do - } else if (raw && (raw._agentError || raw._agentWarning)) { - // Surface the agent's real error/warning message (e.g. a 403 from the - // model provider) instead of a useless "Unhandled event". - const label = raw._agentError - ? i18n.global.t('warnings.errorLabel') - : i18n.global.t('warnings.noteLabel'); - const msg = raw.message ?? raw.code ?? 'agent error'; - next.warnings = [...next.warnings, `${label}: ${msg}`]; + } else if (raw && raw._agentError) { + // Surface the agent's real error (e.g. a 429 from the model provider) + // as a structured notice: semantic title + raw provider message + + // diagnostics (code / HTTP status / request id) for troubleshooting. + next.warnings = [...next.warnings, buildAgentErrorNotice(raw)]; + } else if (raw && raw._agentWarning) { + const msg = raw.message ?? raw.code ?? 'agent warning'; + next.warnings = [...next.warnings, `${i18n.global.t('warnings.noteLabel')}: ${msg}`]; } else { // Truly unknown — push a warning const wireType = raw?.type ?? '(unknown)'; diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index 648734ae3..5eeda29e0 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -15,7 +15,6 @@ import type { AppMessageRole, AppQuestionRequest, AppSession, - AppSessionStatus, AppSessionUsage, AppTask, AppTaskStatus, @@ -46,7 +45,6 @@ import type { WireQuestionRequest, WireQuestionResponse, WireSession, - WireSessionStatus, WireSessionUsage, WireWorkspace, WireEvent, @@ -88,33 +86,16 @@ export function isPlaceholderSessionUsage(usage: AppSessionUsage): boolean { ); } -export function toAppSessionStatus(wire: WireSessionStatus): AppSessionStatus { - switch (wire) { - case 'idle': return 'idle'; - case 'running': return 'running'; - case 'awaiting_approval': return 'awaitingApproval'; - case 'awaiting_question': return 'awaitingQuestion'; - case 'aborted': return 'aborted'; - } -} - -export function toWireSessionStatus(status: AppSessionStatus): WireSessionStatus { - switch (status) { - case 'idle': return 'idle'; - case 'running': return 'running'; - case 'awaitingApproval': return 'awaiting_approval'; - case 'awaitingQuestion': return 'awaiting_question'; - case 'aborted': return 'aborted'; - } -} - export function toAppSession(wire: WireSession): AppSession { return { id: wire.id, title: wire.title, createdAt: wire.created_at, updatedAt: wire.updated_at, - status: toAppSessionStatus(wire.status), + busy: wire.busy, + mainTurnActive: wire.main_turn_active, + pendingInteraction: wire.pending_interaction, + lastTurnReason: wire.last_turn_reason, archived: wire.archived ?? false, currentPromptId: wire.current_prompt_id, lastPrompt: wire.last_prompt, @@ -136,8 +117,6 @@ export function toAppWorkspace(wire: WireWorkspace): AppWorkspace { id: wire.id, root: wire.root, name: wire.name, - isGitRepo: wire.is_git_repo, - branch: wire.branch ?? undefined, lastOpenedAt: wire.last_opened_at, sessionCount: wire.session_count, }; @@ -530,13 +509,31 @@ export function toAppEvent(wire: WireEvent): AppEvent { root: w.payload.root, }; + case 'event.session.work_changed': + return { + type: 'sessionWorkChanged', + sessionId: w.session_id, + busy: w.payload.busy, + mainTurnActive: w.payload.main_turn_active, + pendingInteraction: w.payload.pending_interaction, + lastTurnReason: w.payload.last_turn_reason, + }; + + // Deprecated: old journals may still carry status_changed; fold it onto + // the busy flag (awaiting/running were live work, aborted was not). case 'event.session.status_changed': return { - type: 'sessionStatusChanged', + type: 'sessionWorkChanged', sessionId: w.session_id, - status: toAppSessionStatus(w.payload.status), - previousStatus: toAppSessionStatus(w.payload.previous_status), - currentPromptId: w.payload.current_prompt_id, + busy: w.payload.status !== 'idle' && w.payload.status !== 'aborted', + mainTurnActive: w.payload.status !== 'idle' && w.payload.status !== 'aborted', + pendingInteraction: + w.payload.status === 'awaiting_approval' + ? 'approval' + : w.payload.status === 'awaiting_question' + ? 'question' + : 'none', + lastTurnReason: w.payload.status === 'aborted' ? 'cancelled' : undefined, }; case 'event.session.usage_updated': diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index e401d5faf..7831dc83d 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -66,7 +66,10 @@ export interface WireSession { title: string; created_at: string; updated_at: string; - status: WireSessionStatus; + busy: boolean; + main_turn_active?: boolean; + pending_interaction?: 'none' | 'approval' | 'question'; + last_turn_reason?: 'completed' | 'cancelled' | 'failed'; archived: boolean; current_prompt_id?: string; /** Text of the most recent user prompt, for search/preview. */ @@ -156,8 +159,6 @@ export interface WireWorkspace { id: string; root: string; name: string; - is_git_repo: boolean; - branch: string | null; last_opened_at?: string; session_count: number; } @@ -166,8 +167,6 @@ export interface WireFsBrowseEntry { name: string; path: string; is_dir: boolean; - is_git_repo: boolean; - branch?: string; } export interface WireFsBrowseResult { @@ -678,6 +677,13 @@ interface WireEventBase { type WireEventSessionCreated = WireEventBase<'event.session.created', { session: WireSession }>; type WireEventSessionUpdated = WireEventBase<'event.session.updated', { session: WireSession; changed_fields: string[] }>; type WireEventSessionDeleted = WireEventBase<'event.session.deleted', { session_id: string }>; +type WireEventSessionWorkChanged = WireEventBase<'event.session.work_changed', { + busy: boolean; + main_turn_active?: boolean; + pending_interaction?: 'none' | 'approval' | 'question'; + last_turn_reason?: 'completed' | 'cancelled' | 'failed'; +}>; +/** @deprecated Old journals may still carry this; mapped onto busy for replay. */ type WireEventSessionStatusChanged = WireEventBase<'event.session.status_changed', { status: WireSessionStatus; previous_status: WireSessionStatus; @@ -829,6 +835,7 @@ export type WireEvent = | WireEventSessionCreated | WireEventSessionUpdated | WireEventSessionDeleted + | WireEventSessionWorkChanged | WireEventSessionStatusChanged | WireEventSessionUsageUpdated | WireEventSessionHistoryCompacted diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 84b495bcd..7fb19f8bf 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -41,13 +41,6 @@ export type AppWarning = string | AppNotice; // Session // --------------------------------------------------------------------------- -export type AppSessionStatus = - | 'idle' - | 'running' - | 'awaitingApproval' - | 'awaitingQuestion' - | 'aborted'; - export interface AppSessionUsage { inputTokens: number; outputTokens: number; @@ -67,7 +60,19 @@ export interface AppSession { title: string; createdAt: string; updatedAt: string; - status: AppSessionStatus; + /** Any agent in the session holds an active turn or background lease. + * Awaiting states ride the approval/question channels; turn outcomes ride + * turn.ended. */ + busy: boolean; + /** Whether the main agent has an active turn. Unlike busy, this excludes + * background tasks and sub-agent work. */ + mainTurnActive?: boolean; + /** List-level fallback for the action-required badge. */ + pendingInteraction?: 'none' | 'approval' | 'question'; + /** Outcome of the main agent's most recent turn (when the server reports + * one). Presentation rule for the "aborted" tag: + * `!busy && (cancelled | failed)`. */ + lastTurnReason?: 'completed' | 'cancelled' | 'failed'; archived: boolean; currentPromptId?: string; /** Text of the most recent user prompt, for search/preview. */ @@ -120,10 +125,6 @@ export interface AppWorkspace { root: string; /** Display name — defaults to basename(root), may be renamed on the daemon. */ name: string; - /** Whether root is inside a git repository. */ - isGitRepo: boolean; - /** Current branch, when known. */ - branch?: string; /** ISO timestamp of when this workspace was last opened. */ lastOpenedAt?: string; /** Number of sessions belonging to this workspace. */ @@ -135,8 +136,6 @@ export interface FsBrowseEntry { name: string; path: string; isDir: boolean; - isGitRepo: boolean; - branch?: string; } export interface FsBrowseResult { @@ -338,6 +337,12 @@ export interface AppTask { * the dock: the dock lists background subagents, while foreground subagents * render inline in the message flow as the `Agent` tool card. */ runInBackground?: boolean; + /** The id this same subagent has in the server's background-task store + * (REST `/tasks`), learned from the `task.started` registration event. The + * WS event stream keys the agent by agent id while REST keys it by task id; + * this links the two so the REST copy can be folded into this row and so + * cancel can target the id REST actually knows. */ + backgroundTaskId?: string; } // --------------------------------------------------------------------------- @@ -417,7 +422,14 @@ export type AppEvent = | { type: 'workspaceDeleted'; workspaceId: string; root: string } | { type: 'sessionUpdated'; session: AppSession; changedFields: string[] } | { type: 'sessionDeleted'; sessionId: string } - | { type: 'sessionStatusChanged'; sessionId: string; status: AppSessionStatus; previousStatus: AppSessionStatus; currentPromptId?: string } + | { + type: 'sessionWorkChanged'; + sessionId: string; + busy: boolean; + mainTurnActive?: boolean; + pendingInteraction?: 'none' | 'approval' | 'question'; + lastTurnReason?: 'completed' | 'cancelled' | 'failed'; + } | { type: 'sessionMetaUpdated'; sessionId: string; title?: string; lastPrompt?: string } | { type: 'sessionUsageUpdated'; sessionId: string; usage: AppSessionUsage; model?: string; swarmMode?: boolean; planMode?: boolean } | { type: 'historyCompacted'; sessionId: string; beforeSeq: number; reason: string; summaryMessageId?: string } @@ -454,6 +466,20 @@ export type AppEvent = kind?: 'line' | 'text'; } | { type: 'taskCompleted'; sessionId: string; taskId: string; status: AppTaskStatus; outputPreview?: string; outputBytes?: number } + // Prompt-level lifecycle (distinct from turn-level): a prompt that never + // produced a turn — blocked by a pre-submit hook, or aborted while queued — + // gets no turn.ended and no session status flip, so these are the web layer's + // only signal to clear the per-session in-flight state. A normal turn's + // prompt.completed is a no-op for state (the status_changed ahead of it + // already finished the prompt). + | { type: 'promptCompleted'; sessionId: string; promptId: string; reason: string } + | { type: 'promptAborted'; sessionId: string; promptId: string } + // The MAIN agent's turn boundary — the single source of truth for "the main + // conversation has a turn in flight" (half of the working moon, and the + // streaming reveal). Deliberately NOT derived from session status: a + // background subagent or BTW side chat keeps the session busy but must not + // light up the main conversation's moon. `reason` rides on deactivation. + | { type: 'turnActiveChanged'; sessionId: string; active: boolean; reason?: string } | { type: 'goalUpdated'; sessionId: string; goal: AppGoal | null } | { type: 'configChanged'; changedFields: string[]; config: AppConfig } | { @@ -512,7 +538,7 @@ export interface AppSessionSnapshot { } export interface KimiEventHandlers { - onEvent(event: AppEvent, meta: { sessionId: string; seq: number }): void; + onEvent(event: AppEvent, meta: KimiEventMeta): void; onResync(sessionId: string, currentSeq: number, epoch?: string): void; onError(code: number, msg: string, fatal: boolean): void; onConnectionChange(connected: boolean): void; @@ -520,6 +546,18 @@ export interface KimiEventHandlers { onTerminalExit?(sessionId: string, terminalId: string, exitCode: number | null): void; } +/** Raw stream coordinates are present only for kap-server assistant/thinking + deltas. They let the render queue merge chunks without guessing continuity. */ +export interface KimiEventMeta { + sessionId: string; + seq: number; + stream?: { + turnId: number; + offset: number; + kind: 'text' | 'thinking'; + }; +} + export interface KimiEventConnection { subscribe(sessionId: string, cursor?: AppSessionCursor): void; unsubscribe(sessionId: string): void; @@ -667,7 +705,7 @@ export interface AppSessionWarning { export interface KimiWebApi { getHealth(): Promise<{ status: 'ok'; uptimeSec: number }>; getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record; openInApps: string[]; dangerousBypassAuth: boolean; backend: 'v1' | 'v2' }>; - listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string; includeArchive?: boolean; archivedOnly?: boolean; excludeEmpty?: boolean }): Promise>; + listSessions(input?: PageRequest & { busy?: boolean; workspaceId?: string; includeArchive?: boolean; archivedOnly?: boolean; excludeEmpty?: boolean }): Promise>; createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise; /** Fetch one session by id (deep links beyond the first listSessions page). */ getSession(sessionId: string): Promise; diff --git a/apps/kimi-web/src/components/ResizeHandle.vue b/apps/kimi-web/src/components/ResizeHandle.vue index b37a8c246..c2887ad2f 100644 --- a/apps/kimi-web/src/components/ResizeHandle.vue +++ b/apps/kimi-web/src/components/ResizeHandle.vue @@ -69,7 +69,9 @@ watch(dragging, (d) => emit('update:dragging', d)); touch-action: none; /* sits over the 1px column border so the whole 4px strip is grabbable */ margin: 0 -2px; - z-index: var(--z-sticky); + /* above pane-level sticky chrome (chat dock, headers at --z-sticky): its 2px + overhang into the neighbour pane must stay visible and grabbable */ + z-index: var(--z-dropdown); } .rh-bar { position: absolute; diff --git a/apps/kimi-web/src/components/SessionRow.vue b/apps/kimi-web/src/components/SessionRow.vue index bc3b55cf3..e95cce49a 100644 --- a/apps/kimi-web/src/components/SessionRow.vue +++ b/apps/kimi-web/src/components/SessionRow.vue @@ -8,7 +8,6 @@ import type { Session } from '../types'; import { copyTextToClipboard } from '../lib/clipboard'; import Spinner from './ui/Spinner.vue'; import Badge from './ui/Badge.vue'; -import { useConfirmDialog } from '../composables/useConfirmDialog'; import IconButton from './ui/IconButton.vue'; import Menu from './ui/Menu.vue'; import MenuItem from './ui/MenuItem.vue'; @@ -16,7 +15,6 @@ import Icon from './ui/Icon.vue'; import Tooltip from './ui/Tooltip.vue'; const { t } = useI18n(); -const { confirm } = useConfirmDialog(); const props = withDefaults( defineProps<{ @@ -168,18 +166,11 @@ function exportRow(): void { emit('export', props.session.id); } -// Archive confirm — modal, consistent with remove-workspace. -async function startArchive(): Promise { +// Archive — the modal confirm and the async work live in App.vue +// (confirmArchiveSession); the row only emits the intent. +function startArchive(): void { closeMenu(); - if ( - await confirm({ - title: t('sidebar.archive'), - message: t('sidebar.archiveConfirm'), - variant: 'danger', - }) - ) { - emit('archive', props.session.id); - } + emit('archive', props.session.id); } // Expose closeMenu so the parent can close on outside-click. @@ -214,12 +205,11 @@ defineExpose({ closeMenu }); + permission request is waiting. The list-level interaction fact is + the fallback for sessions whose detailed pending lists aren't loaded. --> @@ -228,17 +218,20 @@ defineExpose({ closeMenu }); {{ t('workspace.awaitingPermission') }} - + diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index b7e6089ab..e422b2208 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -30,10 +30,8 @@ import Kbd from './ui/Kbd.vue'; import Menu from './ui/Menu.vue'; import MenuItem from './ui/MenuItem.vue'; import Pill from './ui/Pill.vue'; -import { useConfirmDialog } from '../composables/useConfirmDialog'; const { t } = useI18n(); -const { confirm } = useConfirmDialog(); // Dev-only affordance: when the page is served by the Vite dev server, the // logo turns yellow and a backend pill next to the brand shows the engine @@ -371,19 +369,12 @@ function startRenameFromMenu(): void { closeGhMenu(); } -async function deleteFromMenu(): Promise { +function deleteFromMenu(): void { const ws = ghMenuTarget.value; if (!ws) return; closeGhMenu(); - if ( - await confirm({ - title: t('sidebar.removeWorkspace'), - message: t('workspace.removeWorkspaceConfirm', { name: ws.name }), - variant: 'danger', - }) - ) { - emit('deleteWorkspace', ws.id); - } + // The modal confirm + async delete live in App.vue (confirmDeleteWorkspace). + emit('deleteWorkspace', ws.id); } // --------------------------------------------------------------------------- @@ -449,17 +440,10 @@ function startRenameWs(ws: WorkspaceView): void { closeWsMenu(); } -async function deleteWs(ws: WorkspaceView): Promise { +function deleteWs(ws: WorkspaceView): void { closeWsMenu(); - if ( - await confirm({ - title: t('sidebar.removeWorkspace'), - message: t('workspace.removeWorkspaceConfirm', { name: ws.name }), - variant: 'danger', - }) - ) { - emit('deleteWorkspace', ws.id); - } + // The modal confirm + async delete live in App.vue (confirmDeleteWorkspace). + emit('deleteWorkspace', ws.id); } // --------------------------------------------------------------------------- @@ -571,6 +555,7 @@ async function chooseBackend(name: BackendName): Promise { } const next = await switchDevBackend(name); if (next === null) { + console.warn('[kimi-web] dev backend switch failed:', name); closeBackendMenu(); return; } diff --git a/apps/kimi-web/src/components/WarningToasts.vue b/apps/kimi-web/src/components/WarningToasts.vue index a6addba38..ba03f448d 100644 --- a/apps/kimi-web/src/components/WarningToasts.vue +++ b/apps/kimi-web/src/components/WarningToasts.vue @@ -295,7 +295,9 @@ onUnmounted(() => { .toasts { left: 12px; right: 12px; - bottom: calc(76px + env(safe-area-inset-bottom)); + /* Sit just above the chat dock; --dock-h already includes the dock's own + safe-area padding, so no extra safe-bottom term is needed. */ + bottom: calc(var(--dock-h, 76px) + 8px); width: auto; max-height: 50vh; } diff --git a/apps/kimi-web/src/components/chat/ApprovalCard.vue b/apps/kimi-web/src/components/chat/ApprovalCard.vue index 6dd2e36bb..33542020c 100644 --- a/apps/kimi-web/src/components/chat/ApprovalCard.vue +++ b/apps/kimi-web/src/components/chat/ApprovalCard.vue @@ -198,7 +198,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown)); :label="minimized ? t('question.expand') : t('question.minimize')" @click="minimized = !minimized" > - + diff --git a/apps/kimi-web/src/components/chat/AttachmentChip.vue b/apps/kimi-web/src/components/chat/AttachmentChip.vue new file mode 100644 index 000000000..b245664d2 --- /dev/null +++ b/apps/kimi-web/src/components/chat/AttachmentChip.vue @@ -0,0 +1,204 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chat/ChatDock.vue b/apps/kimi-web/src/components/chat/ChatDock.vue index 27a8a8a47..4ce3de522 100644 --- a/apps/kimi-web/src/components/chat/ChatDock.vue +++ b/apps/kimi-web/src/components/chat/ChatDock.vue @@ -3,11 +3,12 @@