diff --git a/.agents/skills/agent-core-dev/SKILL.md b/.agents/skills/agent-core-dev/SKILL.md index d0f24b29c4..a7ec68c259 100644 --- a/.agents/skills/agent-core-dev/SKILL.md +++ b/.agents/skills/agent-core-dev/SKILL.md @@ -43,7 +43,7 @@ End-to-end procedures that span the stages. Reach for these before reading the s - Topic: [Config](config.md) — the section-registry model, App vs Session split, owning a config section, the TOML format, and the env overlay. - Topic: [Errors](errors.md) — co-located `XxxError`, the central code registry, wire serialization, boundary translation. - Topic: [Flags](flags.md) — `registerFlagDefinition`, `IFlagService.enabled(id)`, the `[experimental]` config section, resolution precedence. - - Topic: [Permission](permission.md) — composable chain-of-responsibility kernel, policy registry + composer, `modes`/`agentTypes` metadata, `resolveExecution`/`accesses`. + - Topic: [Permission](permission.md) — risk-only chain-of-responsibility kernel, harness constraints and product reviews as domain `onBeforeExecuteTool` veto listeners (`veto` / `allow` / `pass` / cold `waitUntil` factories), shared `toolApproval` round-trip, policy registry + composer, `modes`/`agentTypes` metadata, `resolveExecution`/`accesses`. - Topic: [Telemetry](telemetry.md) — emitting events via `ITelemetryService`, context propagation, and appender destinations (`ConsoleAppender` / `CloudAppender`). - [Stage 4 — Test](test.md): resolve the system under test by interface, pick `TestInstantiationService` vs `createScopedTestHost`, shared stubs, service groups, teardown. - [Stage 5 — Verify & submit](verify.md): `lint:domain`, `typecheck`, `test`, and the pre-submit checklist. diff --git a/.agents/skills/agent-core-dev/design.md b/.agents/skills/agent-core-dev/design.md index 8bdb217622..c9b4c2f0c5 100644 --- a/.agents/skills/agent-core-dev/design.md +++ b/.agents/skills/agent-core-dev/design.md @@ -243,7 +243,7 @@ domain: `sessionLifecycle` (owning scope: App) ├─ hostEnvironment @App direct — gates scope creation on the probe ├─ sessionIndex @App direct — persisted read model for cold resumes ├─ storage @App direct — atomic docs + append logs - ├─ workspaceRegistry @App direct — resolves a session's workspace + ├─ workspace @App direct — resolves a session's workspace └─ event @App direct — broadcasts session-level facts (e.g. archived) ``` diff --git a/.agents/skills/agent-core-dev/domain-boundaries.md b/.agents/skills/agent-core-dev/domain-boundaries.md index 0f7236e7a6..c2f62cc9c2 100644 --- a/.agents/skills/agent-core-dev/domain-boundaries.md +++ b/.agents/skills/agent-core-dev/domain-boundaries.md @@ -46,7 +46,7 @@ Before introducing `I{Domain}EntityService`, classify the persistence model: | **Append-log / event-sourced** | The authoritative record is "what happened" | `wireRecord`, `contextMemory`, `goal`, `plan`, `permission` transitions | | **Blob / key-value** | Large or content-addressed bytes | media offload, blob store | | **Indexed query / read model** | Derived, queryable view | `sessionIndex`, future `IQueryStore` projections | -| **Registry / catalog** | Global or scoped known items | `workspaceRegistry`, `toolRegistry` | +| **Registry / catalog** | Global or scoped known items | `workspace`, `toolRegistry` | | **Ephemeral runtime state** | No durable entity | active turn handle, pending interactions, terminal handles | See [persistence.md](persistence.md) for the `Store → Storage → backend` rules. A domain EntityService is a business facade over those stores; it is not a replacement for the store layer. @@ -101,7 +101,7 @@ The `session` domain owns only Session-level identity, metadata, lifecycle comma | Background tasks | `background` | | Cron tasks | `cron` | | Pending approvals / questions | `interaction` / `approval` / `question` | -| Workspace | `workspaceRegistry` | +| Workspace | `workspace` | | Provider / config | `provider` / `config` | Entity-service conclusion for `session`: diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md index 1cc6b25be6..738326d1ac 100644 --- a/.agents/skills/agent-core-dev/edge-exposure.md +++ b/.agents/skills/agent-core-dev/edge-exposure.md @@ -56,11 +56,11 @@ Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`. | `sessions` | `list` | ISessionIndex.list | GET | | `sessions` | `get` | ISessionIndex.get | GET | | `sessions` | `countActive` | ISessionIndex.countActive | GET | -| `workspaces` | `list` | IWorkspaceRegistry.list | GET | -| `workspaces` | `get` | IWorkspaceRegistry.get | GET | -| `workspaces` | `createOrTouch` | IWorkspaceRegistry.createOrTouch | POST | -| `workspaces` | `update` | IWorkspaceRegistry.update | POST | -| `workspaces` | `delete` | IWorkspaceRegistry.delete | POST | +| `workspaces` | `list` | IWorkspaceService.list | GET | +| `workspaces` | `get` | IWorkspaceService.get | GET | +| `workspaces` | `createOrTouch` | IWorkspaceService.createOrTouch | POST | +| `workspaces` | `update` | IWorkspaceService.update | POST | +| `workspaces` | `delete` | IWorkspaceService.delete | POST | | `config` | `get` / `getAll` / `inspect` | IConfigService.* | GET | | `config` | `set` / `replace` / `reload` | IConfigService.* | POST | | `providers` | `list` / `get` | IProviderService.* | GET | diff --git a/.agents/skills/agent-core-dev/orient.md b/.agents/skills/agent-core-dev/orient.md index 3047c43d45..402ff9304b 100644 --- a/.agents/skills/agent-core-dev/orient.md +++ b/.agents/skills/agent-core-dev/orient.md @@ -60,7 +60,7 @@ So a Session-scoped service is not "L1" — e.g. `session` is Session-scoped but |---|---|---| | L0 | base infrastructure | `_base`, `errors`, `llmProtocol` | | L1 | bridges & low-level capabilities | `log`, `telemetry`, `event`, `environment`, `bootstrap`, `storage` | -| L2 | data & cross-cutting capabilities | `records`, `wireRecord`, `config`, `provider`, `auth`, `workspaceRegistry` | +| L2 | data & cross-cutting capabilities | `records`, `wireRecord`, `config`, `provider`, `auth`, `workspace` | | L3 | registries & capabilities | `tool`, `toolRegistry`, `permission*`, `flag`, `skill`, `plugin` | | L4 | agent behaviour | `turn`, `loop`, `prompt`, `profile`, `contextMemory`, `goal`, `plan`, `swarm` | | L5 | async lifecycle | `background`, `mcp`, `cron`, `agentTool` | diff --git a/.agents/skills/agent-core-dev/permission.md b/.agents/skills/agent-core-dev/permission.md index 7db146b9ce..0a786e4bab 100644 --- a/.agents/skills/agent-core-dev/permission.md +++ b/.agents/skills/agent-core-dev/permission.md @@ -4,6 +4,8 @@ The target design for the agent-core permission system. Read this when touching > **The permission system should be a composable, registrable chain of responsibility (a microkernel).** The kernel only runs the chain in order, first hit wins; concrete permission dimensions (policies) are contributed by their owning Domain Services through a registry; tools only declare standardized resource access (`accesses`) in `resolveExecution`, and generic dimensions consume that metadata. > +> **The chain adjudicates risk only.** A policy node answers "how dangerous is this call, and may the user override that judgment?" — its `ask`/`deny` outcomes are always user-overridable. **Harness constraints are not permissions**: a mechanism that limits the agent for its own correctness (plan-mode write guard, AgentSwarm batch exclusivity, btw side-question fork, goal budget rejection) produces a hard deny with no ask channel and no per-call user exemption. Those live in their owning domains as `onBeforeExecuteTool` veto listeners that call `event.veto(...)` (precedent: `goalService.ts`'s budget/stale rejection). Product reviews (plan review, goal-start review) are likewise not permissions: the owning domain intercepts its tool with a cold `event.waitUntil(factory)` and drives the shared `IAgentToolApprovalService` round-trip itself, so the review only starts once no other listener vetoed the call. +> > **Do not introduce Casbin** — the hard part here is *decision behavior* (continuations, side effects, RPC, state machines), not "match + scalar decision". ## 1. Problem definition @@ -56,7 +58,7 @@ Casbin = single Strategy + data-driven. This design = multiple Strategies + chai 1. **The chain encodes "permission dimensions", not "tools".** Adding a tool does not lengthen the chain; only adding a dimension adds a node. 2. **Two contribution paths:** high-frequency trivial specifics go through the **data path** (rules); low-frequency new dimensions with behavior go through the **code path** (policies). -3. **Domain self-registration:** a domain that owns a dimension (plan/goal/swarm) registers its policy in DI, mirroring v2's existing "domain self-registers tools". +3. **Guard/review off-chain, risk on-chain:** harness constraints and product reviews ship with their owning domain as `onBeforeExecuteTool` veto listeners (§5.4); risk dimensions contributed by a domain self-register as chain policies in DI, mirroring v2's "domain self-registers tools". 4. **Tools declare resources; generic dimensions consume them:** bash/write/read only declare `accesses`; file/security dimensions judge centrally. ### 5.2 Core abstractions @@ -106,23 +108,23 @@ Key points: Most growth goes through the data path — node count is bounded by "kinds of behavior"; rule count grows with specifics (rule matching is a cheap Set/glob). -### 5.4 Domain self-registration +### 5.4 Domain dimensions: guard/review via the executor veto event, policy registration for risk -Mirrors v2's "domain registers tools in its constructor". `PlanService` self-registers its dimensions: +**Harness constraints and product reviews no longer live on the chain.** A domain that owns one registers an `onBeforeExecuteTool` veto listener and adjudicates through the event: ```ts -// src/plan/planService.ts -constructor(@IPermissionPolicyRegistry registry: IPermissionPolicyRegistry) { - registry.register({ name: 'plan-mode-guard-deny', phase: 'guard', - factory: a => new PlanModeGuardDenyPolicy(a.get(IPlanService)) }); - registry.register({ name: 'plan-mode-tool-approve', phase: 'mode', - factory: a => new PlanModeToolApprovePolicy(a.get(IPlanService)) }); - registry.register({ name: 'exit-plan-mode-review-ask', phase: 'user-ask', - factory: a => new ExitPlanModeReviewAskPolicy(a.get(IPlanService), a.get(IPermissionModeService)) }); +// src/plan/planService.ts — constructor +constructor(@IAgentToolExecutorService executor, ...) { + executor.onBeforeExecuteTool((event) => this.guardToolExecution(event)); } ``` -A complex domain may register a single **composite** node externally and run a small internal chain, hiding its internal order from the global chain. +- The veto event carries no id and no ordering contract. Listeners answer with `event.veto(result)` (first one wins, ends adjudication), `event.allow()` (final pass, ends everything including the permission gate's own listener), `event.pass(metadata)` (pass with an `executionMetadata` trace, ends nothing), or `event.waitUntil(factory)` (defer to a cold factory). +- **Guard** (hard deny): call `event.veto(denyToolExecution(toolApproval.formatDenyMessage(...)))`. An immediate veto suppresses every pending `waitUntil` factory, so a deny can never be preceded by someone else's approval prompt. +- **Review** (product approval): intercept the tool with `event.waitUntil(() => ...requestToolApproval(event, ask, origin))`. The factory is cold — the executor only invokes it after every listener ran without a veto or an allow, so the review's Interaction starts only once the call is otherwise clear to proceed; abstain (no statement) for every case you do not review so user rules still apply. +- **Plain allow**: do NOT `allow()` casually — prefer putting the tool in `default-tool-approve`'s whitelist so user deny/ask rules keep their precedence; reserve `allow()` for cases like the plan-file write guard that must bypass even the permission chain. + +**Risk dimensions contributed by a domain still go through the chain** (the registry path below): a domain whose state changes the *risk* verdict registers its policy via `IPermissionPolicyRegistry`, mirroring v2's "domain self-registers tools". A complex domain may register a single **composite** node externally and run a small internal chain, hiding its internal order from the global chain. ### 5.5 Tools declare resources at runtime (`resolveExecution` / `accesses`) @@ -170,37 +172,42 @@ Each new resource kind can pair with a generic dimension that consumes it; tools ### 5.6 Dimension ownership -| Dimension | Owner (who registers) | Type | +| Dimension | Owner | Type | |---|---|---| | external hook veto | `externalHooks` domain | generic | -| tool-batch exclusivity | `swarm` domain | domain-specific (ships with the AgentSwarm tool) | -| runtime-mode posture | `permissionMode` domain | generic | -| plan-mode constraints | `plan` domain | domain-specific | -| goal-start approval | `goal` domain | domain-specific | +| tool-batch exclusivity | `swarm` domain — `onBeforeExecuteTool` veto listener | harness constraint (off-chain) | +| plan-mode write guard | `plan` domain — `onBeforeExecuteTool` veto listener | harness constraint (off-chain) | +| plan review | `plan` domain — same listener's `waitUntil` + `toolApproval` | product review (off-chain) | +| goal-start review | `goal` domain — veto listener's `waitUntil` + `toolApproval` | product review (off-chain) | +| goal budget / stale rejection | `goal` domain — `onBeforeExecuteTool` veto listener | harness constraint (off-chain) | +| btw tool disablement | `btw` domain — veto listener on the fork | harness constraint (off-chain) | +| runtime-mode posture (auto/yolo) | `permissionMode` domain (chain nodes, pending the level×routing split) | generic | | static config rules | `permissionRules` domain | generic (data path) | | session approval memory | `permissionRules` domain | generic | | sensitive / special paths | generic "file-access/security" dimension | generic (consumes `accesses`) | -| tool intrinsic risk | core permission | generic (consumes tool declarations) | +| tool intrinsic risk | core permission (`default-tool-approve`) | generic (consumes tool declarations) | | workspace write trust | generic "file-access/security" dimension | generic (consumes `accesses`) | | fallback | core permission | generic | +| approval round-trip | `toolApproval` domain — shared by gate asks and domain reviews | infrastructure | -Pattern: **specific dimensions ship with their owning domain + tool; generic dimensions register centrally and apply across tools via the declared `accesses`.** +Pattern: **harness constraints and reviews ship with their owning domain as `onBeforeExecuteTool` veto listeners; risk dimensions ship as chain policies (self-registered once the registry lands); generic dimensions register centrally and apply across tools via the declared `accesses`.** ## 6. Evolution path Incremental, not big-bang: -1. **Registry + Composer (zero behavior change).** Replace the 19 hardcoded `new`s in v2 `PermissionPolicyService` with reads from `IPermissionPolicyRegistry`; register existing policies as-is. Immediately gain multi-agent/mode selectable chains and an external registration entry. -2. **Declarative modes.** Lift the mode guards in `YoloModeApprove` / `AutoModeApprove` into `modes` metadata. -3. **Sink domain dimensions.** Move registration of plan/goal/swarm policies into their owning domain service constructors. +1. ~~**Sink domain dimensions.**~~ **Done** — plan guard/review, goal-start review, swarm batch exclusivity, and btw deny-all moved out of the chain into their owning domains as `onBeforeExecuteTool` veto listeners (immediate `veto` / `allow` / `pass` statements plus cold `waitUntil` factories for approval round-trips); the shared approval round-trip was extracted to `IAgentToolApprovalService`; `registerPolicy` was removed (btw was its only production user). The chain now holds 12 risk-adjudication nodes only. +2. **Level × routing split.** Separate "risk level" (read-only / read-write / yolo posture — what `yolo-mode-approve` really is) from "interaction routing" (what `auto-mode-approve` / `auto-mode-ask-user-question-deny` really are: route permission asks and reviews without the user). The routing layer lands on the `session/approval` broker; the three remaining mode policies leave the chain here. +3. **Registry + Composer.** Replace the hardcoded `new`s in `PermissionPolicyService` with reads from `IPermissionPolicyRegistry`; lift mode guards into `modes` metadata. Chain shape becomes selectable per `(agent, mode)` and externally extensible. 4. **(On demand) extend resource types.** When non-file resources (network/DB/shell) need structural dimensions, extend the `ToolResourceAccess` union. 5. **(On demand) swap the matching kernel for Casbin.** Only when external rules genuinely need RBAC/ABAC semantics, swap the data-path rule-matching kernel for Casbin. Not before. ## Red lines (this topic) - Do not introduce Casbin — decisions are behavior bundles, not scalar effects. +- The chain adjudicates risk only. A node whose deny/ask the user cannot per-call exempt is a harness constraint: implement it as an `onBeforeExecuteTool` veto listener in the owning domain (`event.veto(...)` / `event.allow()`), never as a chain policy. +- Product reviews (plan/goal) are not permissions either: the owning domain intercepts its tool with a cold `event.waitUntil(factory)` and drives `IAgentToolApprovalService` itself; the gate only handles chain asks. - The chain encodes dimensions, not tools: a new tool must not lengthen the chain. -- New specifics go through the data path (rules); only new behavior goes through the code path (a policy node). -- A domain that owns a dimension self-registers its policy in DI; do not centralize domain policies in core. +- New specifics go through the data path (rules); only new risk behavior goes through the code path (a policy node). - Tools only declare `accesses`; generic dimensions consume them. kaos is the execution environment, not the permission abstraction. - Use `factory` (Agent-scope instantiation), not `instance`, for registered policies. diff --git a/.agents/skills/agent-core-dev/server-align.md b/.agents/skills/agent-core-dev/server-align.md index 82b9188f1d..f7976b7c9e 100644 --- a/.agents/skills/agent-core-dev/server-align.md +++ b/.agents/skills/agent-core-dev/server-align.md @@ -67,7 +67,7 @@ Self-check: "would a released v1 client get a byte-identical envelope from `pack Resolve the v2 Service that will back the route. Two cases: -**Case A — the v2 native Service already matches the v1 contract.** Use it directly. Most data/command Services (`IConfigService`, `IWorkspaceRegistry`, `IApprovalService`, `IQuestionService`, `IFileStore`, …) land here: the route is a thin adapter that resolves the scope, calls the method, and wraps the result. Examples: `routes/config.ts`, `routes/messages.ts`, `routes/questions.ts`, `routes/files.ts`. +**Case A — the v2 native Service already matches the v1 contract.** Use it directly. Most data/command Services (`IConfigService`, `IWorkspaceService`, `IApprovalService`, `IQuestionService`, `IFileStore`, …) land here: the route is a thin adapter that resolves the scope, calls the method, and wraps the result. Examples: `routes/config.ts`, `routes/messages.ts`, `routes/questions.ts`, `routes/files.ts`. **Case B — the v1 contract needs behavior that would distort the v2 domain.** Introduce a **`*LegacyService`** — an L7 edge adapter that implements the v1 contract **on top of** the v2 native Service, leaving the native Service untouched. The v2 native Service keeps serving `/api/v2`; the LegacyService serves `/api/v1`. diff --git a/.changeset/plan-revision-blobs.md b/.changeset/plan-revision-blobs.md new file mode 100644 index 0000000000..2c3b1a3f85 --- /dev/null +++ b/.changeset/plan-revision-blobs.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Record plan content as versioned facts: each plan review submission offloads the document to a versioned per-agent plan directory and journals a reference record, so the transcript surfaces plan revisions (marker + badge with review path) and rebuilds them after restarts. diff --git a/.changeset/slim-reset-baseline.md b/.changeset/slim-reset-baseline.md new file mode 100644 index 0000000000..40fdeb059d --- /dev/null +++ b/.changeset/slim-reset-baseline.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Stop embedding historical turns in the transcript WS baseline reset; it now carries only global state and the stream watermark, and clients page history through the REST transcript API. diff --git a/.changeset/transcript-event-dedup.md b/.changeset/transcript-event-dedup.md new file mode 100644 index 0000000000..ee1a23691c --- /dev/null +++ b/.changeset/transcript-event-dedup.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Extend the transcript protocol with step usage and timing, streamed tool input and progress, subagent outcomes, agent status, and the prompt queue; WebSocket connections subscribed to the transcript protocol no longer receive the equivalent legacy session events. diff --git a/.changeset/transcript-user-messages.md b/.changeset/transcript-user-messages.md new file mode 100644 index 0000000000..79859ea1c0 --- /dev/null +++ b/.changeset/transcript-user-messages.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a session API endpoint that returns all turn-opening user messages of a session, grouped per agent. Query `GET /api/v1/sessions/{session_id}/transcript/user-messages` (optionally with `?agent_id=` for a single agent) to fetch them. diff --git a/.changeset/wire-facts-cold-fold.md b/.changeset/wire-facts-cold-fold.md new file mode 100644 index 0000000000..2b09228cb7 --- /dev/null +++ b/.changeset/wire-facts-cold-fold.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Persist task lifecycle and interaction records in session wire journals, and rebuild tasks, interactions, todos, and goal/plan meta when loading a cold transcript, so transcript state survives server restarts (older sessions are unaffected). diff --git a/AGENTS.md b/AGENTS.md index 2349080c17..115fc76350 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,15 +17,15 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). - `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). Debug against the two engines via the root `pnpm dev:v1` / `pnpm dev:v2` backend scripts — the dev Sidebar shows the active backend and switches it at runtime. See `apps/kimi-web/AGENTS.md`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. -- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace and the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory: full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards, "load earlier" pages with `before_turn`), while `/api/v1/ws` is a delta-only channel (`transcript.ops`, grade `delta`; `transcript.reset` is ignored). Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). +- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace, the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies), and App Services (`src/components/AppServicesView.tsx` — the app-scope Service reflection, full width; session/agent scopes stay in the Chat view's right `Inspector`). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory: full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library); `/api/v1/ws` is a delta channel (`transcript.ops`, grade `delta`; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: `client_hello` carries `transcript_since`, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). The Transcript audit panel (`src/components/audit/`, always docked right of the chat) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. - `packages/kaos`: the execution environment and file/process abstractions. - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. -- `packages/transcript`: the isomorphic transcript rendering data layer — agent-granular L1 store, idempotent L2 operations, `off/turn/block/delta` L3 subscription granularity, framework-free L4 view registry, and turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports) and the sole owner of all transcript wire types; consumed by `packages/kap-server` (engine events → transcript, REST + WS surface; live stores backfill history from the persisted per-agent wire records — main on first attach, any agent on demand, cold sessions rebuild any agent — with 0-based turn ordinals matching the engine's). -- `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` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. +- `packages/transcript`: the isomorphic transcript rendering data layer — agent-granular L1 store, idempotent L2 operations, `off/turn/block/delta` L3 subscription granularity, framework-free L4 view registry, and turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports) and the sole owner of all transcript contract types (`src/contract/`); consumed by `packages/kap-server` (engine events → transcript, REST + WS surface; live stores backfill history from the persisted per-agent wire records — main on first attach, any agent on demand, cold sessions rebuild any agent — with 0-based turn ordinals matching the engine's). The cold rebuild is a two-level fold over `wire.jsonl` as the single source of truth: `history/groupTurns.ts` (context messages → turn tree) plus `history/foldFacts.ts` (non-context records → tasks, interactions, todos, goal/plan/swarm meta, and end-appended markers/taskrefs; interactions left pending at shutdown fold to `cancelled`). Plan content is a recorded fact too: each ExitPlanMode review submission offloads the document to `agents//plan//v.md` and persists a reference-only `plan.revision` record (`{id, version, path, sha256, bytes}`), which projects — live and cold — to a `plan.revision` marker and the `modes.plan` badge (`{reviewPath, version}`). It also owns the op-batch sequencing contract (`transcriptSeqSchema` in `contract/schema.ts`): a per-(session, agent) monotonic batch `seq` on `transcript.ops` / `transcript.reset` / the REST transcript response, the `transcript_since` subscription cursor, and the `GET .../transcript/ops` catch-up response shape — every field optional so pre-seq peers fall back to loss-signal-driven refreshes. Beyond the timeline, the model carries wire-equivalent detail: steps carry `usage` / `finishReason` / `timing` (LLM latencies) / `retry` / interrupt reason, turns carry `durationMs` / `error` / `usage`, tool frames carry the streamed `inputText` and the latest `progress`, tasks carry subagent `resultSummary` / `error` / `stateReason` / `usage`, `meta.agent` mirrors the agent status slices (model / usage / context / permission / phase), a global `prompts` entity (op `prompt.upsert`) tracks the prompt queue, and `hook.result` lands as a `'hook'` marker. These live-projected fields are NOT backfilled by the cold rebuild (known limitation). +- `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` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. - `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/ipc|memory`); both return the same `Klient`. The package also hosts the e2e suites: 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`. - `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`. diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx index 3d03745c62..bc0ff4f7a8 100644 --- a/apps/kimi-inspect/src/App.tsx +++ b/apps/kimi-inspect/src/App.tsx @@ -5,13 +5,15 @@ * interactions card fetch on demand and the sidebar polls. * Layout: header / icon rail / view. The `chat` view is the classic trio * (left sidebar with workspaces + sessions, chat, inspector); the `models` - * view is the full-width model catalog. + * view is the full-width model catalog; the `services` view is the + * full-width app-scope Service reflection (`AppServicesView`). */ import { useEffect, useState } from 'react'; import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; +import { AppServicesView } from './components/AppServicesView'; import { ChatView } from './components/ChatView'; import { Inspector } from './components/Inspector'; import { ModelCatalogView } from './components/ModelCatalogView'; @@ -72,7 +74,9 @@ export function App() {
- {view === 'models' ? ( + {view === 'services' ? ( + + ) : view === 'models' ? ( { setSessionId(id); diff --git a/apps/kimi-inspect/src/audit/audit.test.ts b/apps/kimi-inspect/src/audit/audit.test.ts new file mode 100644 index 0000000000..c601ab3bc9 --- /dev/null +++ b/apps/kimi-inspect/src/audit/audit.test.ts @@ -0,0 +1,219 @@ +/** + * Audit-layer tests: the trail recorder, the structural diff, serialization, + * and tail-preserving truncation used by the chat view's audit panel. + */ + +import { describe, expect, it } from 'vitest'; + +import { + EMPTY_AGENT_STATE, + type AgentState, + type TranscriptTurn, +} from '@moonshot-ai/transcript'; + +import { diffValue, type DiffNode } from './diff'; +import { serializeState } from './serialize'; +import { AuditTrail, AUDIT_TRAIL_MAX_ENTRIES } from './trail'; +import { tailTrunc } from './truncate'; + +function turnItem(n: number): TranscriptTurn { + return { kind: 'turn', turnId: `t${n}`, ordinal: n, state: 'completed', origin: { kind: 'user' }, steps: [] }; +} + +function stateWith(items: readonly TranscriptTurn[]): AgentState { + return { ...EMPTY_AGENT_STATE, items }; +} + +// ---------------------------------------------------------------- diff + +describe('diffValue', () => { + it('collapses reference-equal subtrees to unchanged without children', () => { + const shared = { a: 1, b: { c: 'x' } }; + const node = diffValue({ v: shared }, { v: shared }); + expect(node.status).toBe('unchanged'); + expect(node.children?.get('v')?.children).toBeUndefined(); + }); + + it('marks added, removed, and modified object keys', () => { + const node = diffValue({ keep: 1, gone: 'x', changed: 'a' }, { keep: 1, fresh: true, changed: 'b' }); + expect(node.status).toBe('modified'); + expect(node.children?.get('keep')?.status).toBe('unchanged'); + expect(node.children?.get('fresh')?.status).toBe('added'); + expect(node.children?.get('gone')).toMatchObject({ status: 'removed', prev: 'x' }); + expect(node.children?.get('changed')).toMatchObject({ status: 'modified', prev: 'a', value: 'b' }); + }); + + it('matches entity arrays by id instead of index', () => { + const prev = [turnItem(1), turnItem(2)]; + const next = [turnItem(1), { ...turnItem(2), state: 'running' as const }, turnItem(3)]; + const node = diffValue(prev, next); + expect(node.children?.get('t1')?.status).toBe('unchanged'); + expect(node.children?.get('t2')?.status).toBe('modified'); + expect(node.children?.get('t2')?.children?.get('state')).toMatchObject({ + status: 'modified', + prev: 'completed', + value: 'running', + }); + expect(node.children?.get('t3')?.status).toBe('added'); + }); + + it('keys steps by stepId (not their shared turnId) so siblings never collide', () => { + const step = (id: string, state: 'running' | 'completed') => ({ + kind: 'step' as const, + stepId: id, + turnId: 't1', + ordinal: 1, + state, + frames: [], + }); + const node = diffValue( + [step('t1.1', 'completed'), step('t1.2', 'completed')], + [step('t1.1', 'completed'), step('t1.2', 'running')], + ); + expect([...node.children?.keys() ?? []]).toEqual(['t1.1', 't1.2']); + expect(node.children?.get('t1.1')?.status).toBe('unchanged'); + expect(node.children?.get('t1.2')?.status).toBe('modified'); + }); + + it('marks removed array elements by id', () => { + const node = diffValue([turnItem(1), turnItem(2)], [turnItem(2)]); + expect(node.children?.get('t1')).toMatchObject({ status: 'removed' }); + expect(node.children?.get('t2')?.status).toBe('unchanged'); + }); + + it('marks whole-subtree adds/removes without descending', () => { + const added = diffValue(undefined, { nested: { deep: 1 } }); + expect(added.status).toBe('added'); + expect(added.children).toBeUndefined(); + const removed = diffValue({ nested: 1 }, undefined); + expect(removed.status).toBe('removed'); + expect(removed.children).toBeUndefined(); + }); + + it('treats type changes as leaf modifications', () => { + expect(diffValue('1', 1).status).toBe('modified'); + expect(diffValue(null, {}).status).toBe('modified'); + expect(diffValue([1], { 0: 1 }).status).toBe('modified'); + }); + + it('diffs two serialized states with meta changes visible (goal/plan fields)', () => { + const prev = serializeState(stateWith([turnItem(1)])); + const nextState: AgentState = { + ...stateWith([turnItem(1)]), + meta: { + goal: { objective: 'ship it', status: 'active' }, + modes: { plan: { reviewPath: '/tmp/plan.md' } }, + }, + }; + const node: DiffNode = diffValue(prev, serializeState(nextState)); + expect(node.children?.get('items')?.status).toBe('unchanged'); + const meta = node.children?.get('meta'); + expect(meta?.status).toBe('modified'); + expect(meta?.children?.get('goal')?.status).toBe('added'); + // Whole-subtree add: `modes` was absent before, so the block (plan + // included) is marked added without descending into children. + expect(meta?.children?.get('modes')?.status).toBe('added'); + expect(meta?.children?.get('modes')?.children).toBeUndefined(); + }); +}); + +// ---------------------------------------------------------------- serialize + +describe('serializeState', () => { + it('turns maps into sorted plain objects and sets into arrays', () => { + const state: AgentState = { + ...EMPTY_AGENT_STATE, + tasks: new Map([ + ['b-task', { taskId: 'b-task', kind: 'shell', state: 'running', detached: false, outputTail: '' }], + ['a-task', { taskId: 'a-task', kind: 'tool', state: 'completed', detached: false, outputTail: '' }], + ]), + pendingInteractions: new Set(['z', 'a']), + }; + const out = serializeState(state); + expect(Object.keys(out.tasks as Record)).toEqual(['a-task', 'b-task']); + expect(out.pendingInteractions).toEqual(['a', 'z']); + expect(out.hasMoreOlder).toBe(false); + }); +}); + +// ---------------------------------------------------------------- truncate + +describe('tailTrunc', () => { + it('returns short strings unchanged', () => { + expect(tailTrunc('hello')).toBe('hello'); + expect(tailTrunc('x'.repeat(500))).toBe('x'.repeat(500)); + }); + + it('keeps the tail of long strings and reports the total length', () => { + const value = 'head-padding'.repeat(100) + 'THE-TAIL'; + const out = tailTrunc(value, 50); + expect(out).toContain(`${value.length} chars total`); + expect(out.endsWith('THE-TAIL')).toBe(true); + expect(out).not.toContain('head-padding'.repeat(10)); + }); +}); + +// ---------------------------------------------------------------- trail + +describe('AuditTrail', () => { + const page = { + items: [turnItem(1)], + hasMoreOlder: false, + tasks: [], + interactions: [], + attachments: [], + todos: [], + meta: {}, + pendingInteractions: [], + }; + + it('records entries with increasing indices, timestamps, and state references', () => { + const trail = new AuditTrail(); + const s1 = stateWith([turnItem(1)]); + const s2 = stateWith([turnItem(1), turnItem(2)]); + trail.recordRest({ pageSize: 30 }, 'replace', page, s1); + trail.recordOps([{ op: 'turn.upsert', turn: turnItem(2) }], 'live', '2026-01-01T00:00:00Z', s2); + trail.recordEvent('prompt', 'hello', s2); + trail.recordReset( + { items: [], tasks: [], interactions: [], attachments: [], todos: [], prompts: [], meta: {} }, + false, + undefined, + s2, + ); + + const entries = trail.getEntries(); + expect(entries.map((entry) => entry.kind)).toEqual(['rest', 'ops', 'event', 'reset']); + expect(entries.map((entry) => entry.index)).toEqual([0, 1, 2, 3]); + expect(entries[0]!.state).toBe(s1); + expect(entries[1]!.state).toBe(s2); + expect(entries[1]).toMatchObject({ delivery: 'live', envelopeAt: '2026-01-01T00:00:00Z' }); + expect(entries[2]).toMatchObject({ event: 'prompt', detail: 'hello' }); + expect(entries.every((entry) => typeof entry.at === 'string' && entry.at.length > 0)).toBe(true); + expect(entries.every((entry) => entry.summary.length > 0)).toBe(true); + }); + + it('notifies subscribers on each record', () => { + const trail = new AuditTrail(); + let notified = 0; + const unsubscribe = trail.subscribe(() => { + notified += 1; + }); + trail.recordEvent('cancel', undefined, EMPTY_AGENT_STATE); + trail.recordEvent('gap', undefined, EMPTY_AGENT_STATE); + expect(notified).toBe(2); + unsubscribe(); + trail.recordEvent('resync', undefined, EMPTY_AGENT_STATE); + expect(notified).toBe(2); + }); + + it('drops the oldest entries beyond the cap while indices keep increasing', () => { + const trail = new AuditTrail(); + for (let i = 0; i < AUDIT_TRAIL_MAX_ENTRIES + 10; i += 1) { + trail.recordEvent('prompt', `p${i}`, EMPTY_AGENT_STATE); + } + const entries = trail.getEntries(); + expect(entries).toHaveLength(AUDIT_TRAIL_MAX_ENTRIES); + expect(entries[0]!.index).toBe(10); + expect(entries.at(-1)!.index).toBe(AUDIT_TRAIL_MAX_ENTRIES + 9); + }); +}); diff --git a/apps/kimi-inspect/src/audit/diff.ts b/apps/kimi-inspect/src/audit/diff.ts new file mode 100644 index 0000000000..613d7ebe6e --- /dev/null +++ b/apps/kimi-inspect/src/audit/diff.ts @@ -0,0 +1,120 @@ +/** + * Structural diff over serialized `AgentState` values (see `serialize.ts`). + * + * The audit panel diffs two adjacent, immutable states. Because the store + * is copy-on-write, untouched subtrees share references — the reference + * equality fast path below collapses them to `unchanged` without walking. + * + * Arrays of transcript entities are matched by their id field (turnId, + * stepId, frameId, …) rather than by index, so an upsert in the middle of + * the timeline does not turn into a cascade of spurious modifications. + */ + +export type DiffStatus = 'unchanged' | 'added' | 'removed' | 'modified'; + +export interface DiffNode { + readonly status: DiffStatus; + /** Current value (`undefined` when removed). */ + readonly value: unknown; + /** Previous value (`undefined` when added). */ + readonly prev: unknown; + /** + * Object/array children — key is the object key, the entity id, or + * `#` for plain arrays. Absent on leaves and on whole-subtree + * added/removed nodes (the renderer colors the subtree as one block). + */ + readonly children?: ReadonlyMap; +} + +/** + * Id fields checked in priority order — MOST SPECIFIC FIRST. A step carries + * both `turnId` and `stepId`, and a frame can carry `taskId` alongside its + * `frameId`; matching the wrong one mislabels the node and, worse, collides + * siblings in the children map (two steps of one turn both keyed `t1`). + */ +const ID_FIELDS = [ + 'frameId', + 'stepId', + 'interactionId', + 'attachmentId', + 'todoId', + 'markerId', + 'refId', + 'turnId', + 'taskId', +] as const; + +function elementId(element: unknown): string | undefined { + if (typeof element !== 'object' || element === null) return undefined; + for (const field of ID_FIELDS) { + const value = (element as Record)[field]; + if (typeof value === 'string') return value; + } + return undefined; +} + +/** Public for the audit UI: same id-priority keying used to match array elements. */ +export { elementId }; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function containerStatus(children: ReadonlyMap): DiffStatus { + for (const child of children.values()) { + if (child.status !== 'unchanged') return 'modified'; + } + return 'unchanged'; +} + +function diffObjects(prev: Record, next: Record): DiffNode { + const children = new Map(); + for (const key of Object.keys(next)) { + children.set(key, diffValue(prev[key], next[key])); + } + for (const key of Object.keys(prev)) { + if (!(key in next)) { + children.set(key, { status: 'removed', value: undefined, prev: prev[key] }); + } + } + return { status: containerStatus(children), value: next, prev, children }; +} + +function diffArrays(prev: readonly unknown[], next: readonly unknown[]): DiffNode { + const children = new Map(); + const keyed = + prev.every((el) => elementId(el) !== undefined) && + next.every((el) => elementId(el) !== undefined); + + if (keyed) { + const prevById = new Map(); + for (const el of prev) prevById.set(elementId(el) as string, el); + const nextIds = new Set(); + for (const el of next) { + const id = elementId(el) as string; + nextIds.add(id); + children.set(id, diffValue(prevById.get(id), el)); + } + for (const el of prev) { + const id = elementId(el) as string; + if (!nextIds.has(id)) children.set(id, { status: 'removed', value: undefined, prev: el }); + } + } else { + for (let i = 0; i < next.length; i += 1) { + children.set(`#${i}`, diffValue(prev[i], next[i])); + } + for (let i = next.length; i < prev.length; i += 1) { + children.set(`#${i}`, { status: 'removed', value: undefined, prev: prev[i] }); + } + } + return { status: containerStatus(children), value: next, prev, children }; +} + +export function diffValue(prev: unknown, next: unknown): DiffNode { + if (prev === next) return { status: 'unchanged', value: next, prev }; + if (prev === undefined) return { status: 'added', value: next, prev: undefined }; + if (next === undefined) return { status: 'removed', value: undefined, prev }; + if (isPlainObject(prev) && isPlainObject(next)) return diffObjects(prev, next); + if (Array.isArray(prev) && Array.isArray(next)) return diffArrays(prev, next); + return { status: 'modified', value: next, prev }; +} diff --git a/apps/kimi-inspect/src/audit/serialize.ts b/apps/kimi-inspect/src/audit/serialize.ts new file mode 100644 index 0000000000..ddcbecea3c --- /dev/null +++ b/apps/kimi-inspect/src/audit/serialize.ts @@ -0,0 +1,48 @@ +/** + * Serialize an `AgentState` into a plain, JSON-shaped object for the audit + * panel's state tree and structural diff. Maps become key-sorted plain + * objects (stable display order), Sets become sorted arrays; everything + * else is passed through by reference (state is immutable, so sharing is + * safe and keeps the reference-equality fast path in `diffValue` useful). + */ + +import type { + AgentState, + TranscriptAttachment, + TranscriptInteraction, + TranscriptItem, + TranscriptMeta, + TranscriptTask, + TranscriptTodo, +} from '@moonshot-ai/transcript'; + +/** Plain-object view of an `AgentState` (Maps/Sets unwrapped). */ +export interface SerializedAgentState { + readonly items: readonly TranscriptItem[]; + readonly tasks: Record; + readonly interactions: Record; + readonly attachments: Record; + readonly todos: Record; + readonly meta: TranscriptMeta; + readonly pendingInteractions: readonly string[]; + readonly hasMoreOlder: boolean; +} + +function mapToSortedObject(map: ReadonlyMap): Record { + const out: Record = {}; + for (const key of [...map.keys()].sort()) out[key] = map.get(key) as V; + return out; +} + +export function serializeState(state: AgentState): SerializedAgentState { + return { + items: state.items, + tasks: mapToSortedObject(state.tasks), + interactions: mapToSortedObject(state.interactions), + attachments: mapToSortedObject(state.attachments), + todos: mapToSortedObject(state.todos), + meta: state.meta, + pendingInteractions: [...state.pendingInteractions].sort(), + hasMoreOlder: state.hasMoreOlder, + }; +} diff --git a/apps/kimi-inspect/src/audit/trail.ts b/apps/kimi-inspect/src/audit/trail.ts new file mode 100644 index 0000000000..abbe260031 --- /dev/null +++ b/apps/kimi-inspect/src/audit/trail.ts @@ -0,0 +1,171 @@ +/** + * Audit trail for the chat view's transcript channel. + * + * A pure observer: the chat pipeline (REST loads, WS frames, user actions) + * calls the `record*` methods AFTER applying each step to the real + * `TranscriptChatStore`, passing the resulting immutable `AgentState` + * reference. Replaying the trail is therefore free — every entry already + * holds the exact state the store had at that point, ready for the + * timeline slider and the structural diff. + */ + +import type { + AgentState, + AgentTranscriptSnapshot, + TranscriptOperation, +} from '@moonshot-ai/transcript'; + +import type { TranscriptPage } from '../transcript/api'; + +export const AUDIT_TRAIL_MAX_ENTRIES = 5000; + +interface AuditEntryBase { + /** Position in the trail (stable even when old entries are dropped). */ + readonly index: number; + /** Local record time (ISO). */ + readonly at: string; + /** Store state right after this entry was applied (immutable reference). */ + readonly state: AgentState; + /** One-line summary for the timeline list. */ + readonly summary: string; +} + +export interface RestAuditEntry extends AuditEntryBase { + readonly kind: 'rest'; + readonly request: { readonly beforeTurn?: string | undefined; readonly pageSize: number }; + readonly appliedAs: 'replace' | 'prepend'; + readonly page: TranscriptPage; +} + +export interface OpsAuditEntry extends AuditEntryBase { + readonly kind: 'ops'; + /** Envelope timestamp (server send time) when present. */ + readonly envelopeAt?: string | undefined; + readonly ops: readonly TranscriptOperation[]; + /** live = applied immediately; buffered = held during a REST refresh; flushed = replayed after one; catchup = fetched via the ops catch-up endpoint after a seq gap. */ + readonly delivery: 'live' | 'buffered' | 'flushed' | 'catchup'; +} + +export interface ResetAuditEntry extends AuditEntryBase { + readonly kind: 'reset'; + readonly envelopeAt?: string | undefined; + readonly snapshot: AgentTranscriptSnapshot; + readonly hasMoreOlder: boolean; +} + +export interface EventAuditEntry extends AuditEntryBase { + readonly kind: 'event'; + readonly event: 'ack-refresh' | 'resync' | 'gap' | 'prompt' | 'cancel'; + readonly detail?: string | undefined; +} + +export type AuditEntry = RestAuditEntry | OpsAuditEntry | ResetAuditEntry | EventAuditEntry; + +type DistributiveOmit = T extends unknown ? Omit : never; + +/** Entry payload accepted by `push` (index/at are filled in there). */ +type AuditEntryInput = DistributiveOmit; + +function summarizeOps(ops: readonly TranscriptOperation[]): string { + const counts = new Map(); + for (const op of ops) counts.set(op.op, (counts.get(op.op) ?? 0) + 1); + return [...counts.entries()].map(([name, n]) => (n > 1 ? `${name}×${n}` : name)).join(', '); +} + +export class AuditTrail { + private entryList: AuditEntry[] = []; + private nextIndex = 0; + private readonly listeners = new Set<() => void>(); + + /** `useSyncExternalStore`-compatible subscribe. */ + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getEntries(): readonly AuditEntry[] { + return this.entryList; + } + + recordRest( + request: RestAuditEntry['request'], + appliedAs: RestAuditEntry['appliedAs'], + page: TranscriptPage, + state: AgentState, + ): void { + const cursor = request.beforeTurn !== undefined ? `?before_turn=${request.beforeTurn}` : ''; + this.push({ + kind: 'rest', + request, + appliedAs, + page, + state, + summary: `GET transcript${cursor} → ${page.items.length} items (${appliedAs})`, + }); + } + + recordOps( + ops: readonly TranscriptOperation[], + delivery: OpsAuditEntry['delivery'], + envelopeAt: string | undefined, + state: AgentState, + ): void { + this.push({ + kind: 'ops', + ops, + delivery, + envelopeAt, + state, + summary: `${ops.length} ops (${summarizeOps(ops)}) [${delivery}]`, + }); + } + + recordReset( + snapshot: AgentTranscriptSnapshot, + hasMoreOlder: boolean, + envelopeAt: string | undefined, + state: AgentState, + ): void { + this.push({ + kind: 'reset', + snapshot, + hasMoreOlder, + envelopeAt, + state, + summary: `reset snapshot (${snapshot.items.length} items) — ignored by chat store`, + }); + } + + recordEvent(event: EventAuditEntry['event'], detail: string | undefined, state: AgentState): void { + const label = + event === 'ack-refresh' + ? 'subscribe ack → REST refresh' + : event === 'resync' + ? 'resync_required → REST refresh' + : event === 'gap' + ? 'append gap → REST refresh' + : event === 'prompt' + ? 'prompt sent' + : 'cancel sent'; + this.push({ + kind: 'event', + event, + detail, + state, + summary: detail !== undefined && detail !== '' ? `${label}: ${detail}` : label, + }); + } + + private push(entry: AuditEntryInput): void { + const full = { ...entry, index: this.nextIndex, at: new Date().toISOString() } as AuditEntry; + this.nextIndex += 1; + const kept = + this.entryList.length >= AUDIT_TRAIL_MAX_ENTRIES + ? this.entryList.slice(this.entryList.length - AUDIT_TRAIL_MAX_ENTRIES + 1) + : this.entryList; + this.entryList = [...kept, full]; + for (const listener of this.listeners) listener(); + } +} diff --git a/apps/kimi-inspect/src/audit/truncate.ts b/apps/kimi-inspect/src/audit/truncate.ts new file mode 100644 index 0000000000..69a7c137c7 --- /dev/null +++ b/apps/kimi-inspect/src/audit/truncate.ts @@ -0,0 +1,14 @@ +/** + * Tail-preserving string truncation for the audit panel: long values are + * rendered with their total length plus the LAST `keep` characters (the + * tail is where streaming text, tool output, and prompts carry the newest + * information). Rendering-only — the underlying state is never truncated, + * and no field is ever dropped. + */ + +export const TRUNCATE_KEEP = 500; + +export function tailTrunc(value: string, keep: number = TRUNCATE_KEEP): string { + if (value.length <= keep) return value; + return `… [${value.length} chars total, showing last ${keep}]\n${value.slice(-keep)}`; +} diff --git a/apps/kimi-inspect/src/components/AppServicesView.tsx b/apps/kimi-inspect/src/components/AppServicesView.tsx new file mode 100644 index 0000000000..98ae9aa340 --- /dev/null +++ b/apps/kimi-inspect/src/components/AppServicesView.tsx @@ -0,0 +1,27 @@ +/** + * App Services view — the app-scope (server-level) Service reflection as a + * standalone rail view. Postman-style three-pane layout + * (`ScopePanelsScrollspy`): the Service list on the left, every Service's + * methods expanded in one continuously scrolling column in the middle + * (scroll position and left-side highlight kept in sync), and a + * request/response call history on the right. The proxies resolve on the + * `core` route, so this view works before any session is selected. + */ + +import { useCallback } from 'react'; + +import { serviceByName } from '../channel'; +import { useConnection } from '../connection'; +import type { AnyService } from '../panels'; +import { ScopePanelsScrollspy } from './ServicePanels'; + +export function AppServicesView() { + const { klient } = useConnection(); + const proxyFor = useCallback( + (name: string): AnyService | null => + serviceByName(klient, name, { scope: 'app' }) ?? null, + [klient], + ); + + return ; +} diff --git a/apps/kimi-inspect/src/components/ChatView.tsx b/apps/kimi-inspect/src/components/ChatView.tsx index 2f3b3d136b..91ee88b7f6 100644 --- a/apps/kimi-inspect/src/components/ChatView.tsx +++ b/apps/kimi-inspect/src/components/ChatView.tsx @@ -20,7 +20,9 @@ */ import { + createContext, useCallback, + useContext, useEffect, useLayoutEffect, useRef, @@ -29,11 +31,16 @@ import { } from 'react'; import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; +import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; +import { + ISessionQuestionService, + type QuestionItem, + type QuestionRequest, +} from '@moonshot-ai/agent-core-v2/session/question/question'; import { EMPTY_AGENT_STATE, itemId, type AgentState, - type InteractionFrame, type NoticeFrame, type ToolCallFrame, type TranscriptAttachment, @@ -51,7 +58,8 @@ import { } from '@moonshot-ai/transcript'; import { useConnection } from '../connection'; -import { fetchTranscriptPage, TRANSCRIPT_PAGE_SIZE } from '../transcript/api'; +import { AuditTrail } from '../audit/trail'; +import { fetchTranscriptOps, fetchTranscriptPage, TRANSCRIPT_PAGE_SIZE } from '../transcript/api'; import { createCoalescedRunner, oldestTurnId, @@ -60,13 +68,19 @@ import { } from '../transcript/store'; import { TranscriptWs } from '../transcript/ws'; import { ActionButton, Badge, ErrorLine, JsonView, relTime } from '../ui'; +import { AuditPanel } from './audit/AuditPanel'; const noopSubscribe = () => () => {}; +/** Active session id for deeply nested interaction views (approve/answer buttons). */ +const SessionContext = createContext(''); + interface TranscriptChannel { /** Null until the effect has created the store (pre-ready / no session). */ readonly store: TranscriptChatStore | null; readonly state: AgentState; + /** Records every step that built the store (audit panel data source). */ + readonly trail: AuditTrail | null; /** True once the initial REST page load succeeded. */ readonly loaded: boolean; /** Set when the initial/refresh load failed (e.g. server without transcript). */ @@ -85,64 +99,157 @@ function useTranscriptChannel( ): TranscriptChannel { const { baseUrl, config } = useConnection(); const token = config.token.trim(); - const [channel, setChannel] = useState<{ store: TranscriptChatStore } | null>(null); + const [channel, setChannel] = useState<{ store: TranscriptChatStore; trail: AuditTrail } | null>( + null, + ); const [loaded, setLoaded] = useState(false); const [loadError, setLoadError] = useState(null); useEffect(() => { if (!ready || sessionId === null) return; const store = new TranscriptChatStore(); + const trail = new AuditTrail(); const authToken = token === '' ? undefined : token; let disposed = false; - /** While a REST refresh is in flight, WS ops are buffered, then flushed. */ + /** While a REST reload / catch-up is in flight, WS ops are buffered, then flushed. */ let fetching = true; let buffer: TranscriptOperation[] = []; + /** Max batch seq seen while buffering (folded into the watermark on flush). */ + let bufferedSeq: number | undefined; + /** + * Op-batch watermark: the store is known to include every batch with + * seq <= lastSeq. Sourced from REST page watermarks and applied batch + * seqs; `undefined` until a sequenced server provides one (legacy + * servers never do — every recovery then falls back to full refreshes). + */ + let lastSeq: number | undefined; + /** Cursor of the in-flight recover fetch, paired with `onPageApplied`. */ + let recoverBefore: string | undefined; + /** True once the initial page load succeeded (gates reset-driven catch-up). */ + let seeded = false; - /** Full-state (re)load: newest page first, then backwards over `before_turn`. */ - const refresh = createCoalescedRunner(async (): Promise => { - fetching = true; + const noteSeq = (seq: number | undefined): void => { + if (seq === undefined) return; + lastSeq = lastSeq === undefined ? seq : Math.max(lastSeq, seq); + }; + + const flushBuffer = (): void => { + fetching = false; + if (buffer.length > 0) { + const flushed = buffer; + store.applyOps(flushed); + trail.recordOps(flushed, 'flushed', undefined, store.getState()); + noteSeq(bufferedSeq); + } buffer = []; + bufferedSeq = undefined; + }; + + /** Page (re)load body shared by the full refresh and the catch-up fallback. */ + const reloadPages = async (): Promise => { // The window's oldest turn is the re-cover anchor: after a refresh the // server window may have shifted, and only re-loading up to THIS turn // preserves the previously loaded history. const prevOldest = oldestTurnId(store.getState().items); if (prevOldest !== undefined) captureAnchor(); + const newest = await fetchTranscriptPage({ + baseUrl, + token: authToken, + sessionId, + agentId, + pageSize: TRANSCRIPT_PAGE_SIZE, + }); + if (disposed) return; + store.applyPage(newest, { replace: true }); + trail.recordRest({ pageSize: TRANSCRIPT_PAGE_SIZE }, 'replace', newest, store.getState()); + lastSeq = newest.seq; + // Re-cover the previously loaded window for refreshes (a no-op on the + // initial load, where there is no previous oldest turn). + await recoverLoadedWindow( + store, + prevOldest, + (beforeTurn) => { + recoverBefore = beforeTurn; + return fetchTranscriptPage({ + baseUrl, + token: authToken, + sessionId, + agentId, + beforeTurn, + pageSize: TRANSCRIPT_PAGE_SIZE, + }); + }, + () => disposed, + (page) => { + trail.recordRest( + { beforeTurn: recoverBefore, pageSize: TRANSCRIPT_PAGE_SIZE }, + 'prepend', + page, + store.getState(), + ); + }, + ); + if (!disposed) { + seeded = true; + setLoaded(true); + setLoadError(null); + } + }; + + /** Full-state (re)load: the legacy recovery path and the initial load. */ + const refresh = createCoalescedRunner(async (): Promise => { + fetching = true; + buffer = []; + bufferedSeq = undefined; try { - const newest = await fetchTranscriptPage({ + await reloadPages(); + } catch (error) { + if (!disposed) setLoadError(error); + } finally { + flushBuffer(); + } + }); + + /** + * Targeted catch-up: fetch exactly the op batches after our watermark + * (`GET .../transcript/ops?since_seq=`). Falls back to a full page + * reload on a legacy server (no seq / endpoint missing), a journal that + * no longer covers the gap (`complete: false`), or a fetch failure. + */ + const catchUp = createCoalescedRunner(async (): Promise => { + if (lastSeq === undefined) { + refresh(); + return; + } + fetching = true; + buffer = []; + bufferedSeq = undefined; + try { + const res = await fetchTranscriptOps({ baseUrl, token: authToken, sessionId, agentId, - pageSize: TRANSCRIPT_PAGE_SIZE, + sinceSeq: lastSeq, }); if (disposed) return; - store.applyPage(newest, { replace: true }); - // Re-cover the previously loaded window for refreshes (a no-op on the - // initial load, where there is no previous oldest turn). - await recoverLoadedWindow( - store, - prevOldest, - (beforeTurn) => - fetchTranscriptPage({ - baseUrl, - token: authToken, - sessionId, - agentId, - beforeTurn, - pageSize: TRANSCRIPT_PAGE_SIZE, - }), - () => disposed, - ); - if (!disposed) { - setLoaded(true); - setLoadError(null); + if (!res.complete) { + await reloadPages(); + } else { + for (const batch of res.batches) { + store.applyOps(batch.ops); + trail.recordOps(batch.ops, 'catchup', undefined, store.getState()); + } + noteSeq(res.latestSeq); + } + } catch { + try { + await reloadPages(); + } catch (error) { + if (!disposed) setLoadError(error); } - } catch (error) { - if (!disposed) setLoadError(error); } finally { - fetching = false; - if (buffer.length > 0) store.applyOps(buffer); - buffer = []; + flushBuffer(); } }); @@ -151,18 +258,53 @@ function useTranscriptChannel( token: authToken, sessionId, agentId, + getSince: () => lastSeq, handlers: { - onOps: (aid, ops) => { + onOps: (aid, ops, meta) => { if (aid !== agentId) return; - if (fetching) buffer.push(...ops); - else store.applyOps(ops); + if (fetching) { + buffer.push(...ops); + if (meta?.seq !== undefined) { + bufferedSeq = Math.max(bufferedSeq ?? 0, meta.seq); + } + trail.recordOps(ops, 'buffered', meta?.at, store.getState()); + return; + } + // Seq gap: the store is behind by at least one batch. Catch up + // point-to-point instead of applying on a stale base (appends are + // offset-placed and would surface a gap anyway). + if (meta?.seq !== undefined && lastSeq !== undefined && meta.seq > lastSeq + 1) { + catchUp(); + return; + } + store.applyOps(ops); + trail.recordOps(ops, 'live', meta?.at, store.getState()); + noteSeq(meta?.seq); + }, + onReset: (_aid, snapshot, hasMoreOlder, meta) => { + trail.recordReset(snapshot, hasMoreOlder, meta?.at, store.getState()); + // Sequenced mode only: a reset after seeding means the server could + // not replay from our `transcript_since` cursor (journal truncated) + // — catch up, which itself falls back to a full reload when the seq + // window is gone. On legacy servers (no watermark) resets are + // routine per-subscribe noise and stay ignored, as before. + if (seeded && lastSeq !== undefined) catchUp(); + }, + onResyncRequired: () => { + trail.recordEvent('resync', undefined, store.getState()); + catchUp(); + }, + onReconnected: () => { + trail.recordEvent('ack-refresh', undefined, store.getState()); + catchUp(); }, - onResyncRequired: refresh, - onReconnected: refresh, }, }); - store.onGap = refresh; - setChannel({ store }); + store.onGap = () => { + trail.recordEvent('gap', undefined, store.getState()); + catchUp(); + }; + setChannel({ store, trail }); setLoaded(false); setLoadError(null); refresh(); @@ -177,7 +319,7 @@ function useTranscriptChannel( channel?.store.subscribe ?? noopSubscribe, () => channel?.store.getState() ?? EMPTY_AGENT_STATE, ); - return { store: channel?.store ?? null, state, loaded, loadError }; + return { store: channel?.store ?? null, state, trail: channel?.trail ?? null, loaded, loadError }; } export function ChatView({ @@ -205,7 +347,7 @@ export function ChatView({ if (el !== null) anchorRef.current = el.scrollHeight - el.scrollTop; }, []); - const { store, state, loaded, loadError } = useTranscriptChannel( + const { store, state, trail, loaded, loadError } = useTranscriptChannel( sessionId, agentId, ready, @@ -248,6 +390,12 @@ export function ChatView({ pageSize: TRANSCRIPT_PAGE_SIZE, }); store.applyPage(page); + trail?.recordRest( + { beforeTurn: oldest, pageSize: TRANSCRIPT_PAGE_SIZE }, + 'prepend', + page, + store.getState(), + ); } catch (error) { anchorRef.current = null; setOlderError(error); @@ -256,15 +404,40 @@ export function ChatView({ } }; + // Auto-paging: the top sentinel auto-loads the previous REST page when it + // approaches the viewport (paused while a previous load failed — the retry + // button re-arms it). This replaces any manual "load earlier" action. + const loadOlderRef = useRef(loadOlder); + loadOlderRef.current = loadOlder; + const topSentinelRef = useRef(null); + const hasMoreOlder = state.hasMoreOlder; + useEffect(() => { + const sentinel = topSentinelRef.current; + const root = scrollRef.current; + if (sentinel === null || root === null || olderError !== null) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) void loadOlderRef.current(); + }, + { root, rootMargin: '400px 0px 0px 0px' }, + ); + observer.observe(sentinel); + return () => { + observer.disconnect(); + }; + }, [hasMoreOlder, loaded, olderError, loadingOlder]); + const running = state.meta.activity === 'turn' || items.some((item) => item.kind === 'turn' && item.state === 'running'); - // Interactions render inline at their anchor tool frame; entities whose - // anchor frame is outside the loaded window collect here. + // Interactions render inline at their anchor tool frame; entities without + // an anchor (or whose anchor frame is outside the loaded window) collect + // here and render floating at the bottom. const anchoredToolCallIds = collectToolCallIds(items); const unanchoredInteractions = [...state.interactions.values()].filter( - (interaction) => !anchoredToolCallIds.has(interaction.toolCallId), + (interaction) => + interaction.toolCallId === undefined || !anchoredToolCallIds.has(interaction.toolCallId), ); const latestTodo = [...state.todos.values()].at(-1); @@ -279,6 +452,7 @@ export function ChatView({ .agent(agentId) .service(IAgentRPCService) .prompt({ input: [{ type: 'text', text }] }); + trail?.recordEvent('prompt', text, state); } catch (error) { setSendError(error); } @@ -288,6 +462,7 @@ export function ChatView({ if (sessionId === null) return; try { await klient.session(sessionId).agent(agentId).service(IAgentRPCService).cancel({}); + trail?.recordEvent('cancel', undefined, state); } catch (error) { setSendError(error); } @@ -309,102 +484,125 @@ export function ChatView({ } return ( -
-
- {sessionId} - agent: {agentId} - {running ? turn running : idle} - {state.pendingInteractions.size > 0 ? ( - {state.pendingInteractions.size} pending - ) : null} -
- -
- {state.hasMoreOlder ? ( -
- void loadOlder()} disabled={loadingOlder}> - {loadingOlder ? 'Loading…' : 'Load earlier turns'} - -
- ) : null} - {olderError !== null ? ( -
- -
- ) : null} - {loadError !== null ? ( -
- -
- Failed to load the transcript — the server may be too old to expose the transcript - API. -
-
- ) : null} - {items.length === 0 && loadError === null ? ( -
- {loaded ? 'Empty transcript — send a prompt below.' : 'Loading transcript…'} + +
+
+
+ {sessionId} + agent: {agentId} + {running ? turn running : idle} + {state.pendingInteractions.size > 0 ? ( + {state.pendingInteractions.size} pending + ) : null}
- ) : null} - {latestTodo !== undefined && latestTodo.items.length > 0 ? ( -
-
todo (latest)
- {latestTodo.items.map((entry, i) => ( -
- - {entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'} - - - {entry.title} + +
+ {state.hasMoreOlder ? ( +
+ + {loadingOlder ? 'Loading earlier turns…' : ''}
+ ) : null} + {olderError !== null ? ( +
+ +
+ { + setOlderError(null); + void loadOlder(); + }} + > + Retry loading earlier turns + +
+
+ ) : null} + {loadError !== null ? ( +
+ +
+ Failed to load the transcript — the server may be too old to expose the transcript + API. +
+
+ ) : null} + {items.length === 0 && loadError === null ? ( +
+ {loaded ? 'Empty transcript — send a prompt below.' : 'Loading transcript…'} +
+ ) : null} + {latestTodo !== undefined && latestTodo.items.length > 0 ? ( +
+
todo (latest)
+ {latestTodo.items.map((entry, i) => ( +
+ + {entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'} + + + {entry.title} + +
+ ))} +
+ ) : null} + {items.map((item) => ( + // Native virtual screen: the browser skips layout/paint for + // off-screen items and remembers their last rendered size + // (`auto` in contain-intrinsic-size), so long transcripts stay + // cheap without a windowing library. +
+ +
+ ))} + {unanchoredInteractions.map((interaction) => ( + ))}
- ) : null} - {items.map((item) => ( - - ))} - {unanchoredInteractions.map((interaction) => ( - - ))} -
- -
- {sendError !== null ? ( -
- -
- ) : null} -
-