From 7cfa0692a8dffca8cea54acb23a7c81578eeaf51 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 31 Jul 2026 09:55:33 +0800 Subject: [PATCH 1/6] feat(kap-server): let fs:search resolve a workspace ref for draft sessions - fs:search accepts a workspace id or absolute root in the session_id slot so the @ file mention works before the session exists - kimi-web searchFiles falls back to the active workspace id in draft state --- AGENTS.md | 2 +- .../composables/client/useWorkspaceState.ts | 12 +++-- packages/kap-server/src/routes/fs.ts | 51 +++++++++++++++++-- packages/kap-server/test/fs.test.ts | 36 +++++++++++++ 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9cd3a8dbf5..0730a7a641 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,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/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` — four scopes, App/Workspace/Session/Agent; session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `IWorkspaceHandlerService`, and the fs routes resolve session → handler → the Workspace-scope fs services). 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, Workspace scope addressable alongside App/Session/Agent; `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 (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) 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). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. 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. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first, and a candidate set truncated at `LITERAL_CANDIDATE_CAP` is flagged `incomplete: 'candidate_cap'`). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. +- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2` — four scopes, App/Workspace/Session/Agent; session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `IWorkspaceHandlerService`, and the fs routes resolve session → handler → the Workspace-scope fs services, with one exception: `fs:search` also accepts a workspace reference (registered id or absolute root) in the `{session_id}` slot, so a not-yet-created draft session's `@` file mention resolves the workspace handler directly). 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, Workspace scope addressable alongside App/Session/Agent; `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 (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) 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). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. 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. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first, and a candidate set truncated at `LITERAL_CANDIDATE_CAP` is flagged `incomplete: 'candidate_cap'`). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. - `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`. - `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm) that produces a syntax tree with tree-sitter-bash 0.25.0 named-node type names and UTF-16 code-unit offsets. `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget (default 50 ms / 50k nodes, plus per-chain recursion depth caps) and returns a discriminated `ParseResult` (`{ ok, rootNode, hasError }` or `{ ok: false, reason: 'aborted' }`) — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; consumers (e.g. Bash tool permission matching) live elsewhere. Known deviations from the reference are tracked in the package README's "Known differences" section, pinned by differential fixtures tested against the real `tree-sitter-bash` wasm (dev-only). diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 03a541fe0a..5b0c1f35b7 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -2734,14 +2734,18 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta /** * Search files in the active session using the daemon searchFiles endpoint. - * Returns {path, name}[] — defensive, returns [] on error or no active session. + * In the new-session draft state (workspace picked, session not yet created) + * the workspace reference is sent instead — the daemon resolves a workspace + * id or root to the same workspace fs service, so `@` works before the first + * prompt. Returns {path, name}[] — defensive, returns [] on error or when + * neither an active session nor an active workspace exists. */ async function searchFiles(query: string): Promise> { - const sid = rawState.activeSessionId; - if (!sid) return []; + const id = rawState.activeSessionId ?? rawState.activeWorkspaceId; + if (!id) return []; try { const api = getKimiWebApi(); - const result = await api.searchFiles(sid, { query, limit: 20 }); + const result = await api.searchFiles(id, { query, limit: 20 }); return result.items.map((item) => ({ path: item.path, name: item.name })); } catch { return []; diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index d01a9cb546..05ee440fce 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -10,13 +10,24 @@ * "session → handler → workspace fs" chain (chdir is gone, so the handler * root is the one fixed fs root). The wire schema comes from the engine's own * `workspaceFs` domain contract (`agent-core-v2`). + * + * Draft-session fallback: a client composing the first prompt of a new + * session (e.g. kimi-web's new-session draft) has no session id yet, so it + * passes the workspace reference — registered workspace id or absolute root — + * in the `{session_id}` slot. Only `fs:search` serves those (the `@` file + * mention must work before the session exists): the route resolves the + * workspace's handler directly and uses the same Workspace-scope fs service a + * real session would resolve to. URL and wire schema are unchanged. */ import { createReadStream } from 'node:fs'; +import { isAbsolute } from 'node:path'; import { ErrorCodes, IWorkspaceFsService, + IWorkspaceLifecycleService, + IWorkspaceService, getLiveSessionById, resumeSessionById, isError2, @@ -113,6 +124,32 @@ function resolveFs(core: Scope, sessionId: string): IWorkspaceFsService { return session.accessor.get(IWorkspaceFsService); } +/** + * Workspace fallback for `fs:search` (see the file header): resolve a + * workspace reference — registered id, or an absolute root registered on the + * spot — to its handler's `IWorkspaceFsService`. `undefined` when the ref is + * neither a known workspace nor an existing absolute directory. + */ +async function resolveWorkspaceFs( + core: Scope, + ref: string, +): Promise { + const workspaces = core.accessor.get(IWorkspaceService); + let ws = await workspaces.get(ref); + if (ws === undefined) { + if (!isAbsolute(ref)) return undefined; + try { + ws = await workspaces.createOrTouch(ref); + } catch { + return undefined; + } + } + const handler = await core.accessor + .get(IWorkspaceLifecycleService) + .handlerFor({ workspaceId: ws.id, root: ws.root }); + return handler.accessor.get(IWorkspaceFsService); +} + export function registerFsRoutes(app: FsRouteHost, core: Scope): void { const fsActionRoute = defineRoute( { @@ -161,7 +198,13 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { // which reads the persisted cwd. `resume` returns undefined only when the // session is unknown or its workspace is gone. const session = await resumeSessionById(core.accessor, session_id); - if (session === undefined) { + // Draft-session fallback (file header): no session yet, but the client + // addressed a workspace — `fs:search` resolves it directly. + const workspaceFs = + session === undefined && fsAction === 'search' + ? await resolveWorkspaceFs(core, session_id) + : undefined; + if (session === undefined && workspaceFs === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), ); @@ -189,7 +232,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { await handleMkdir(core, session_id, req, reply); return; case 'search': - await handleSearch(core, session_id, req, reply); + await handleSearch(workspaceFs ?? resolveFs(core, session_id), req, reply); return; case 'grep': await handleGrep(core, session_id, req, reply); @@ -404,13 +447,13 @@ async function handleMkdir(core: Scope, sessionId: string, req: Req, reply: Repl reply.send(okEnvelope(data, req.id)); } -async function handleSearch(core: Scope, sessionId: string, req: Req, reply: Reply): Promise { +async function handleSearch(fs: IWorkspaceFsService, req: Req, reply: Reply): Promise { const parsed = fsSearchRequestSchema.safeParse(req.body ?? {}); if (!parsed.success) { reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); return; } - const data = await resolveFs(core, sessionId).search(parsed.data); + const data = await fs.search(parsed.data); reply.send(okEnvelope(data, req.id)); } diff --git a/packages/kap-server/test/fs.test.ts b/packages/kap-server/test/fs.test.ts index 6cc18ecd1f..b4935dee42 100644 --- a/packages/kap-server/test/fs.test.ts +++ b/packages/kap-server/test/fs.test.ts @@ -209,6 +209,42 @@ describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => { expect(body.data.items.map((i) => i.path)).toContain('alpha.ts'); }); + it('fs:search resolves a registered workspace id when no session exists', async () => { + await writeFile(join(work!, 'gamma.ts'), ''); + // Register the workspace without creating any session (the kimi-web + // new-session draft addresses the workspace directly). + const res = await fetch(`${base}/api/v1/workspaces`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ root: work }), + } as never); + const created = (await res.json()) as Envelope<{ id: string }>; + expect(created.code).toBe(0); + const body = await postFs<{ items: { path: string }[]; truncated: boolean }>( + created.data.id, + 'search', + { query: 'gamma' }, + ); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('gamma.ts'); + }); + + it('fs:search resolves an unregistered workspace root path', async () => { + await writeFile(join(work!, 'delta.ts'), ''); + const body = await postFs<{ items: { path: string }[]; truncated: boolean }>( + encodeURIComponent(work!), + 'search', + { query: 'delta' }, + ); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('delta.ts'); + }); + + it('fs:search still maps an unknown ref to SESSION_NOT_FOUND', async () => { + const body = await postFs('does-not-exist', 'search', { query: 'x' }); + expect(body.code).toBe(ErrorCode.SESSION_NOT_FOUND); + }); + it('fs:grep finds matching lines', async () => { await writeFile(join(work!, 'a.txt'), 'hello world\nfoo bar\n'); const id = await createSession(); From ff57f5fa06a9e32c599d192845e74e7c05c97c83 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 31 Jul 2026 10:49:16 +0800 Subject: [PATCH 2/6] fix(agent-core-v2): report empty thinking level for unbound main agent - sessionLegacyService.status returns thinking_level '' when the main agent has no bound model (mirroring model: undefined), so clients fall back to the catalog default instead of folding in the wire model's 'off' zero value - add regression test for a never-bound main agent status - add web changesets: draft @ file mention, new-session thinking level --- .changeset/web-draft-at-mention.md | 5 ++ .changeset/web-new-session-thinking-level.md | 5 ++ .../app/sessionLegacy/sessionLegacyService.ts | 6 +- .../src/app/sessionLegacy/sessionProtocol.ts | 3 + .../app/sessionLegacy/sessionLegacy.test.ts | 65 +++++++++++++++++++ 5 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 .changeset/web-draft-at-mention.md create mode 100644 .changeset/web-new-session-thinking-level.md diff --git a/.changeset/web-draft-at-mention.md b/.changeset/web-draft-at-mention.md new file mode 100644 index 0000000000..8ac2990006 --- /dev/null +++ b/.changeset/web-draft-at-mention.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Make the @ file mention work in a new-session draft, before the first prompt creates the session. diff --git a/.changeset/web-new-session-thinking-level.md b/.changeset/web-new-session-thinking-level.md new file mode 100644 index 0000000000..9099b39912 --- /dev/null +++ b/.changeset/web-new-session-thinking-level.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix new sessions showing the thinking level (e.g. Max) while the first message actually ran with thinking off. diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index c49b754797..980929d51a 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -204,7 +204,11 @@ export class SessionLegacyService implements ISessionLegacyService { return { busy: this.readBusy(sessionId), model: model === '' ? undefined : model, - thinking_level: profile.getEffectiveThinkingLevel(), + // An unbound agent has no thinking level to report: the effective value + // would be the wire model's zero value ('off'), which clients fold in as + // the session's real pick. Report '' instead — same "nothing to report" + // convention as `model` above — so they fall back to the catalog default. + thinking_level: model === '' ? '' : profile.getEffectiveThinkingLevel(), permission: permission.mode, plan_mode: planData !== null, swarm_mode: swarm.isActive, diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts index 4922c50730..09d1bc897d 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts @@ -82,6 +82,9 @@ export type UpdateSessionProfileRequest = z.infer { }); }); + it('reports an empty thinking level for a never-bound main agent', async () => { + // A fresh session's main agent is materialized unbound (no Profile / Model + // — see kap-server's ensureMainAgent). The wire model's initial + // thinkingLevel is the zero value 'off'; reporting it would make clients + // fold a level nobody chose into the session's real state, so the status + // edge must report '' (mirroring `model: undefined`) instead. + const profile = { + _serviceBrand: undefined, + data: () => ({ + cwd: '/workspace', + modelAlias: undefined, + modelCapabilities: UNKNOWN_CAPABILITY, + thinkingLevel: 'off', + systemPrompt: '', + }), + getModel: () => '', + getModelCapabilities: () => UNKNOWN_CAPABILITY, + getEffectiveThinkingLevel: () => 'off', + } as unknown as IAgentProfileService; + const agent: IAgentScopeHandle = { + id: 'main', + kind: LifecycleScope.Agent, + accessor: accessor([ + [IAgentProfileService, profile], + [IAgentContextSizeService, { get: () => ({ size: 0, measured: 0, estimated: 0 }) }], + [IAgentPermissionModeService, { mode: 'manual' }], + [IAgentPlanService, { status: () => Promise.resolve(null) }], + [IAgentSwarmService, { isActive: false }], + // Unbound: assembleStatus resolves the default model's context cap, + // which reads the `defaultModel` config section first. + [IConfigService, { get: () => undefined }], + [ + IAgentActivityView, + { state: () => ({ lifecycle: 'ready', background: [] }) }, + ], + ]), + dispose: () => {}, + }; + const agents = { + create: () => Promise.resolve(agent), + whenReady: () => Promise.resolve(agent), + list: () => [agent], + } as unknown as IAgentLifecycleService; + const session: ISessionScopeHandle = { + id: 'session-unbound', + kind: LifecycleScope.Session, + accessor: accessor([ + [IAgentLifecycleService, agents], + [ISessionCronService, { _serviceBrand: undefined }], + ]), + dispose: () => {}, + }; + stubSessionChain(ix, session); + ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService)); + + const status = await ix.get(ISessionLegacyService).status('session-unbound'); + + expect(status).toMatchObject({ + busy: false, + model: undefined, + thinking_level: '', + }); + }); + it('uses the input cap as the status denominator and clamps usage to 1', async () => { const profile = { _serviceBrand: undefined, From 91e133b88b947aede1f5109933baffa72f915e3d Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 31 Jul 2026 11:11:13 +0800 Subject: [PATCH 3/6] perf(minidb): make text index rebuilds async and non-blocking - TextIndex.build() yields to the event loop during tokenization and batches postings writes (~1 MiB), so large rebuilds no longer hard-block the host process - writes landing mid-build are queued and replayed onto the new base at swap time, keeping the rebuilt index exact - PostingsFile.rebuildSync renamed to async rebuild with a synchronous commit section (beforeRename hook + atomic rename) - onCompacted hook is now awaited (sync or async); open-time compaction runs in the background so open() returns without blocking on the snapshot rewrite and postings rebuild - compaction skips the postings rebuild when the index's write buffer is clean (needsRebuild) - createTextIndex registers before building so concurrent writes feed the build queue; dropTextIndex throws while a build is in flight --- packages/minidb/src/compaction.ts | 13 +- packages/minidb/src/index.ts | 60 +++-- packages/minidb/src/text-index.ts | 239 ++++++++++++------ packages/minidb/src/text-postings.ts | 51 +++- packages/minidb/test/compaction-fault.test.ts | 4 +- packages/minidb/test/compaction.test.ts | 26 ++ packages/minidb/test/text-index.test.ts | 106 +++++++- 7 files changed, 381 insertions(+), 118 deletions(-) diff --git a/packages/minidb/src/compaction.ts b/packages/minidb/src/compaction.ts index 3da409fb16..9b09ef1484 100644 --- a/packages/minidb/src/compaction.ts +++ b/packages/minidb/src/compaction.ts @@ -33,7 +33,8 @@ // finishes; the pause scales with the tail the pre-copy did // not drain — the same bounded end-of-rewrite pause Redis // accepts for its AOF diff flush. -// 4. bookkeeping — stats + onCompacted() (rebuild derived text postings). +// 4. bookkeeping — stats + awaiting onCompacted() (rebuild derived text +// postings — yields to the event loop, writers unaffected). // // Crash safety: recovery is `load db.snapshot` + `replay db.wal`, last-writer // wins. We rename the snapshot BEFORE the WAL. If a crash lands between the two @@ -71,10 +72,10 @@ export interface CompactionTarget { * remapped value pointers read from the new files. On Windows it is also * closed before the rotation renames (see rotateReplace). */ valueReader?: { reopenBoth(): void; close?(): void }; - /** Optional hook invoked after the snapshot + WAL rotation succeeds, so the - * owner can rewrite derived on-disk state (e.g. text postings) against the - * new live set. */ - onCompacted?: () => void; + /** Optional hook invoked (and awaited) after the snapshot + WAL rotation + * succeeds, so the owner can rewrite derived on-disk state (e.g. text + * postings) against the new live set. */ + onCompacted?: () => void | Promise; } export function shouldCompact(db: CompactionTarget): boolean { @@ -161,7 +162,7 @@ export async function compact(db: CompactionTarget): Promise { await runCompaction(db); // The onCompacted hook is part of the compaction: a run whose hook // throws is counted as a compactError, not a successful compaction. - db.onCompacted?.(); + await db.onCompacted?.(); db.stats.compactions++; db.lastCompactError = null; } catch (err) { diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index 7458909712..53f91e69ae 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -286,9 +286,10 @@ export class MiniDb { /** Hook called by compaction after the store snapshot + WAL are rotated, so * derived on-disk state (text postings) can be rewritten against the new - * live set. Structural part of the CompactionTarget interface. */ - onCompacted = (): void => { - this.rebuildTextPostings(); + * live set. Structural part of the CompactionTarget interface; the + * compaction awaits it, so it may be sync or async. */ + onCompacted: () => void | Promise = async (): Promise => { + await this.rebuildTextPostings(); }; static async open(opts: OpenOptions): Promise> { @@ -395,12 +396,20 @@ export class MiniDb { await db.loadIndexDefinitions(); await db.loadCompoundIndexDefinitions(); await db.loadTextIndexDefinitions(); - db.rebuildAllIndexes(); + await db.rebuildAllIndexes(); // A read-only instance never compacts: rotation would rename the live // writer's snapshot/WAL out from under it and lose its acknowledged data. - if (!db.readOnly && db.autoCompact && shouldCompact(db)) await compact(db); + // The writer's open-time compaction is fire-and-forget (same as + // maybeAutoCompact): recovery already applied the full WAL, so the db is + // complete and consistent the moment open() returns. Awaiting the + // compaction here blocked open() on the whole snapshot rewrite + text + // postings rebuild — tens of seconds of stalled startup on a large db. + if (!db.readOnly && db.autoCompact && shouldCompact(db)) compact(db).catch(() => {}); } catch (err) { + // A background open-time compaction may still be in flight: settle it + // before tearing down the WAL/store/handles it touches. + if (db.compacting && db._compactDone) await db._compactDone.catch(() => {}); // Release every resource acquired so far: an open that fails after the // WAL/store are set up must not leak a file handle or keep the everysec / // active-expire timers running. @@ -490,19 +499,24 @@ export class MiniDb { return path.join(this.dir, `db.text-${safe}.postings`); } - /** Rebuild every text index's on-disk postings from the live Store. Drops - * the in-memory delta + tombstones and reclaims orphaned postings records. - * Invoked after compaction (postings are pure derived state, so this is - * only for space/latency, never for correctness). */ - private rebuildTextPostings(): void { - for (const ti of this.text.values()) ti.build(this.textRecords()); + /** Rebuild every dirty text index's on-disk postings from the live Store. + * Drops the in-memory delta + tombstones and reclaims orphaned postings + * records. Invoked after compaction (postings are pure derived state, so + * this is only for space/latency, never for correctness). Indexes with an + * empty write buffer are skipped: the open-time build just produced a + * fresh base, so a compaction landing right after open must not redo the + * exact same (expensive) pass. */ + private async rebuildTextPostings(): Promise { + for (const ti of this.text.values()) { + if (ti.needsRebuild()) await ti.build(this.textRecords()); + } } - private rebuildAllIndexes(): void { + private async rebuildAllIndexes(): Promise { this.indexes.rebuild(this._liveRecordsRaw()); this.dt.rebuild([...this.liveRecords()].map(({ key, dt }) => ({ key: this.pk(key), dt }))); this.compound.rebuild(this.liveRecords()); - for (const [, ti] of this.text) ti.build(this.textRecords()); + for (const [, ti] of this.text) await ti.build(this.textRecords()); } private *_liveRecordsRaw(): Generator<{ key: Buffer; value: unknown }> { @@ -1246,11 +1260,20 @@ export class MiniDb { if (this.text.has(name)) throw new Error(`text index "${name}" already exists`); const ti = new TextIndex({ fields, ...textIndexTokenizers(tokenizer), postingsPath: this.textPostingsPath(name) }); const def: TextIndexDef = { name, fields: fields ?? null, tokenizer }; - // Build BEFORE registering: a failed build must leave no phantom index - // behind — a registered-but-unbuilt index would both poison every write - // path that walks this.text and make a retry fail with "already exists". - ti.build(this.textRecords()); + // Register BEFORE building: the build yields to the event loop, and + // registering makes concurrent writes feed the index's build queue, which + // the build replays onto the new base — so the finished index reflects + // every write whenever it landed. Until the build completes, searches on + // the index see only its post-registration delta. A failed build unwinds + // the registration, so a retry cannot hit a phantom "already exists". this.text.set(name, ti); + try { + await ti.build(this.textRecords()); + } catch (e) { + this.text.delete(name); + ti.close(); + throw e; + } this.textDefs.push(def); try { await this.persistTextIndexDefinitions(); @@ -1269,6 +1292,9 @@ export class MiniDb { this.ensureOpen(); this.ensureWritable(); const ti = this.text.get(name); + // Dropping mid-build would orphan the in-flight postings write (the file + // is removed while the build is still producing it). + if (ti?.building) throw new Error(`text index "${name}" is still building`); const ok = this.text.delete(name); if (ti) { ti.close(); diff --git a/packages/minidb/src/text-index.ts b/packages/minidb/src/text-index.ts index 5080840e1f..3830b9a0a5 100644 --- a/packages/minidb/src/text-index.ts +++ b/packages/minidb/src/text-index.ts @@ -13,6 +13,11 @@ // LRU cache), merges the in-memory `delta`, drops tombstones, and scores // by TF-IDF. Synchronous by design so db.search()/db.query() keep their // synchronous API. +// - Builds: open and compaction rebuild the whole index from the Store. +// `build()` is async and yields to the event loop periodically, so a big +// rebuild never hard-blocks the host process; writes landing mid-build +// keep applying to the live view (searches stay correct) and are queued +// for a synchronous replay onto the new base at swap time. // - Durability: the postings file is a pure derived cache of the Store; it is // rebuilt from the Store on open and on compaction. The Store (snapshot + // WAL) is the source of truth, so a crash never loses postings — they are @@ -31,6 +36,12 @@ const CJK = /[\u3400-\u9fff\u3040-\u30ff\uff00-\uffef]+/g; // pathological document cannot destroy the index. const MAX_TERM_CHARS = 0xffff; +const yieldToLoop = (): Promise => new Promise((r) => setImmediate(r)); +// `build()` yields to the event loop at the first of these two watermarks, so +// many-small-docs and few-huge-docs corpora are both bounded per slice. +const BUILD_YIELD_DOCS = 2048; +const BUILD_YIELD_TOKENS = 500_000; + /** Tokenize text into terms (lowercased latin words + CJK uni/bigrams). */ export function tokenize(str: unknown): string[] { const s = String(str).toLowerCase(); @@ -93,6 +104,11 @@ export interface SearchOptions { const EMPTY_MAP: ReadonlyMap = new Map(); +/** One write that landed while a `build()` was in flight (see buildQueue). */ +type BuildOp = + | { readonly kind: 'add'; readonly key: string; readonly doc: unknown } + | { readonly kind: 'remove'; readonly key: string }; + export class TextIndex { private readonly fields: readonly string[] | null; private readonly tokenizer: (text: string) => string[]; @@ -114,6 +130,17 @@ export class TextIndex { private deltaCount = 0; private readonly removed = new Set(); // tombstoned docIDs + /** + * Ops that landed while a `build()` was in flight. The ops ALSO apply to + * the live view as usual (searches stay correct during the build); the + * queue exists so the freshly-staged base can replay them at swap time — + * the staged iteration may have missed them (already-visited key) or seen + * them (unvisited key), so replaying the exact op stream onto the new base + * is what keeps the rebuild precise instead of eventually consistent. + * Null outside a build. + */ + private buildQueue: BuildOp[] | null = null; + // Memory-base mode (no postingsPath): base postings kept in RAM. private memBase: Map> | null = null; @@ -158,99 +185,158 @@ export class TextIndex { return n; } + /** Whether a `build()` is currently in flight. */ + get building(): boolean { + return this.buildQueue !== null; + } + + /** + * Whether a postings rebuild would change anything: the write buffer + * (delta + tombstones) is non-empty. Right after a build both are empty, so + * a compaction landing immediately after an open-time build does not redo + * the exact same pass. A build in flight also counts as fresh — its queue + * replay already folds every concurrent mutation into the new base. + */ + needsRebuild(): boolean { + if (this.buildQueue !== null) return false; + return this.deltaCount > 0 || this.removed.size > 0; + } + /** * Rebuild the index from scratch over `entries` (the live Store view). * Assigns fresh dense docIDs, writes a new postings file (disk mode) or * replaces the in-memory base (memory mode), and clears the delta + * tombstones. Called on open and on compaction. * + * Async and event-loop friendly: the tokenization pass yields every + * BUILD_YIELD_DOCS docs / BUILD_YIELD_TOKENS tokens and the postings write + * batches its I/O, so a large rebuild never hard-blocks the host process + * for many seconds the way the old fully-synchronous build did. Mutations + * arriving mid-build keep applying to the live view (searches stay correct) + * and are recorded in `buildQueue`; once the new base is swapped in, the + * queue is replayed synchronously, so the result is exactly as if those ops + * had arrived after the rebuild. + * * Atomic on failure: everything is staged off to the side first and swapped * in only after the new postings file is durably renamed (disk mode), so a - * failed rebuild (e.g. a transient ENOSPC/EMFILE inside rebuildSync) leaves - * the PREVIOUS index fully functional instead of silently emptying it until - * the next successful build. + * failed rebuild (e.g. a transient ENOSPC/EMFILE inside PostingsFile.rebuild) + * leaves the PREVIOUS index fully functional instead of silently emptying it + * until the next successful build. */ - build(entries: Iterable<{ key: string; value: unknown }>): void { - // Staged state. - const agg = new Map>(); // term -> (docID -> freq) - const newKeys: (string | undefined)[] = []; // docID -> key - const newKeyToId = new Map(); // key -> docID - const newDocLen = new Map(); // docID -> token count - let n = 0; - for (const { key, value } of entries) { - const docID = newKeys.length; - newKeys.push(key); - newKeyToId.set(key, docID); - const tokens = this.tokenizer(this.extract(value)); - const counts = new Map(); - for (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1); - for (const [t, c] of counts) { - let m = agg.get(t); - if (!m) agg.set(t, (m = new Map())); - m.set(docID, c); // docIDs increase monotonically -> insertion order is sorted + async build(entries: Iterable<{ key: string; value: unknown }>): Promise { + if (this.buildQueue !== null) throw new Error('text index build already in progress'); + const queue: BuildOp[] = []; + this.buildQueue = queue; + try { + // Staged state. + const agg = new Map>(); // term -> (docID -> freq) + const newKeys: (string | undefined)[] = []; // docID -> key + const newKeyToId = new Map(); // key -> docID + const newDocLen = new Map(); // docID -> token count + let n = 0; + let docsSinceYield = 0; + let tokensSinceYield = 0; + for (const { key, value } of entries) { + const docID = newKeys.length; + newKeys.push(key); + newKeyToId.set(key, docID); + const tokens = this.tokenizer(this.extract(value)); + const counts = new Map(); + for (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1); + for (const [t, c] of counts) { + let m = agg.get(t); + if (!m) agg.set(t, (m = new Map())); + m.set(docID, c); // docIDs increase monotonically -> insertion order is sorted + } + newDocLen.set(docID, tokens.length); + n++; + docsSinceYield++; + tokensSinceYield += tokens.length; + if (docsSinceYield >= BUILD_YIELD_DOCS || tokensSinceYield >= BUILD_YIELD_TOKENS) { + docsSinceYield = 0; + tokensSinceYield = 0; + await yieldToLoop(); + } } - newDocLen.set(docID, tokens.length); - n++; - } - if (this.path) { - // Disk mode: write the new postings file (tmp + fsync + atomic rename in - // rebuildSync). The old read handle is closed before the rename (an open - // fd would block the rename on Windows), so the in-memory state below is - // NOT touched until the new file is in place — if rebuildSync throws, - // the old file is still intact and is simply re-attached. - const oldPf = this.pf; - if (oldPf) { - oldPf.close(); - this.pf = null; - } - let dict: Map; - try { - dict = PostingsFile.rebuildSync(this.path, aggToSorted(agg)); - } catch (e) { - // rebuildSync failed before the atomic rename, so the old file is - // intact: re-attach it and keep serving the previous index until the - // next successful build. - if (oldPf) { - try { - this.pf = PostingsFile.open(this.path); - } catch { - /* old handle unrecoverable; the next successful build fixes it */ + if (this.path) { + // Disk mode: write the new postings file (tmp + fsync + atomic rename + // in PostingsFile.rebuild). The old read handle is closed only at the + // rename — and only on Windows, where an open fd would block it (POSIX + // keeps the old inode readable through the rename, so searches never + // lose the base). A rebuild that throws before its commit leaves the + // old file/handle untouched; a commit-time failure re-attaches it. + const oldPf = this.pf; + let dict: Map; + try { + dict = await PostingsFile.rebuild(this.path, aggToSorted(agg), { + beforeRename: + process.platform === 'win32' && oldPf !== null + ? () => { + oldPf.close(); + if (this.pf === oldPf) this.pf = null; + } + : undefined, + }); + } catch (e) { + if (oldPf !== null && !oldPf.open) { + try { + this.pf = PostingsFile.open(this.path); + } catch { + /* old handle unrecoverable; the next successful build fixes it */ + } } + throw e; } - throw e; + // The rename happened — the old postings are replaced on disk, so from + // here the swap commits to the new index. A failed reopen (EMFILE & co.) + // is not special-cased: readBase treats a null pf as an empty base, so + // reads degrade to delta-only until the next build instead of reading + // through a stale dictionary. + const newPf = PostingsFile.open(this.path); + this.postings.clear(); + for (const [t, e] of dict) this.postings.set(t, e); + oldPf?.close(); + this.pf = newPf; + } else { + // Memory mode: pure in-memory staging, no fallible I/O involved. + this.memBase = agg; } - // The rename happened — the old postings are replaced on disk, so from - // here the swap commits to the new index. A failed reopen (EMFILE & co.) - // is not special-cased: readBase treats a null pf as an empty base, so - // reads degrade to delta-only until the next build instead of reading - // through a stale dictionary. - const newPf = PostingsFile.open(this.path); - this.postings.clear(); - for (const [t, e] of dict) this.postings.set(t, e); - this.pf = newPf; - } else { - // Memory mode: pure in-memory staging, no fallible I/O involved. - this.memBase = agg; - } - // Swap in the staged per-doc state and drop the write buffer. - this.docLen.clear(); - for (const [id, len] of newDocLen) this.docLen.set(id, len); - this.keys.length = 0; - for (const k of newKeys) this.keys.push(k); - this.keyToId.clear(); - for (const [k, id] of newKeyToId) this.keyToId.set(k, id); - this.delta.clear(); - this.deltaCount = 0; - this.removed.clear(); - this.cache.clear(); - this.N = n; + // Swap in the staged per-doc state, drop the write buffer, and replay + // the ops that landed mid-build onto the new base — one synchronous + // segment, so no mutation can interleave mid-swap. + this.docLen.clear(); + for (const [id, len] of newDocLen) this.docLen.set(id, len); + this.keys.length = 0; + for (const k of newKeys) this.keys.push(k); + this.keyToId.clear(); + for (const [k, id] of newKeyToId) this.keyToId.set(k, id); + this.delta.clear(); + this.deltaCount = 0; + this.removed.clear(); + this.cache.clear(); + this.N = n; + this.buildQueue = null; + for (const op of queue) { + if (op.kind === 'add') this.add(op.key, op.doc); + else this.remove(op.key); + } + } catch (e) { + // Staging never touched the live view, so the previous index is intact; + // the queued ops were already applied to it — just disarm the queue. + if (this.buildQueue === queue) this.buildQueue = null; + throw e; + } } /** Add or replace a document. Overwrites tombstone the old docID. */ add(key: string, doc: unknown): void { - if (this.keyToId.has(key)) this.remove(key); + this.buildQueue?.push({ kind: 'add', key, doc }); + // The overwrite's internal remove must NOT queue a second op: replaying + // the queue applies the add (which itself displaces the old docID), and a + // queued remove would then delete the freshly-added doc. + if (this.keyToId.has(key)) this.removeInner(key); const docID = this.keys.length; this.keys.push(key); this.keyToId.set(key, docID); @@ -269,6 +355,11 @@ export class TextIndex { /** Remove a document by key (tombstone its docID). */ remove(key: string): void { + this.buildQueue?.push({ kind: 'remove', key }); + this.removeInner(key); + } + + private removeInner(key: string): void { const id = this.keyToId.get(key); if (id === undefined) return; this.removed.add(id); diff --git a/packages/minidb/src/text-postings.ts b/packages/minidb/src/text-postings.ts index 4687d3db55..116e52120e 100644 --- a/packages/minidb/src/text-postings.ts +++ b/packages/minidb/src/text-postings.ts @@ -22,11 +22,15 @@ // and gives ~5-10x compression for dense docID ranges. import fs from 'node:fs'; +import fsp from 'node:fs/promises'; import path from 'node:path'; import { crc32 } from './crc32.js'; const HEADER_LEN = 2 + 4 + 4; // termLen + df + payloadLen (term is variable) const CRC_LEN = 4; +// Coalesce record writes into ~1 MiB writev batches; each batch await is also +// the rebuild's event-loop yield point. +const FLUSH_BYTES = 1 << 20; // ---- varint (unsigned LEB128, uint32) ------------------------------------ @@ -147,7 +151,8 @@ export interface PostingEntry { * Append-only postings file with synchronous positioned reads. Synchronous I/O * is deliberate: `TextIndex.search()` is synchronous (so `db.search()` / * `db.query()` keep their sync API), and hot records are served from the OS - * page cache or the in-memory LRU cache anyway. + * page cache or the in-memory LRU cache anyway. Rewrites ({@link rebuild}) are + * the async counterpart — they run in the background of a live database. */ export class PostingsFile { private fd: number | null = null; @@ -157,7 +162,7 @@ export class PostingsFile { /** * Open an existing postings file for positioned reads. Throws if the file is * missing — callers treat a missing file as an empty index. Read-only: the - * file is only ever rewritten wholesale by {@link rebuildSync}, so the fd + * file is only ever rewritten wholesale by {@link rebuild}, so the fd * stays valid until the next rebuild (which the caller must close + reopen). */ static open(filePath: string): PostingsFile { @@ -197,33 +202,53 @@ export class PostingsFile { * atomically renames over ``. Returns the new term dictionary. The old * file (if any) is replaced only after the new one is fully durable, so a * crash mid-build leaves the previous file intact. + * + * Async so a large rebuild does not starve the event loop: record writes are + * coalesced into ~1 MiB writev batches (each batch await is a yield point). + * The commit section is SYNCHRONOUS — `hooks.beforeRename` (e.g. closing the + * previous read handle, required on Windows) and the rename itself run as + * one atomic step, so a reader swaps over without an interleavable gap. */ - static rebuildSync( + static async rebuild( filePath: string, iter: Iterable<{ term: string; entries: readonly (readonly [number, number])[] }>, - ): Map { + hooks: { beforeRename?: () => void } = {}, + ): Promise> { const tmp = filePath + '.tmp'; - const fd = fs.openSync(tmp, 'w'); const dict = new Map(); let off = 0; + let batch: Buffer[] = []; + let batchBytes = 0; + const fh = await fsp.open(tmp, 'w'); + const flushBatch = async (): Promise => { + if (batch.length === 0) return; + const buf = Buffer.concat(batch); + batch = []; + batchBytes = 0; + let written = 0; + while (written < buf.length) { + const { bytesWritten } = await fh.write(buf, written); + if (bytesWritten === 0) throw new Error('postings: rebuild write made no progress'); + written += bytesWritten; + } + }; try { for (const { term, entries } of iter) { if (entries.length === 0) continue; const payload = encodePostingList(entries); const rec = encodeRecord(term, entries.length, payload); - let written = 0; - while (written < rec.length) { - const w = fs.writeSync(fd, rec, written, rec.length - written, off + written); - if (w === 0) throw new Error('postings: rebuild write made no progress'); - written += w; - } dict.set(term, { off, len: rec.length, df: entries.length }); + batch.push(rec); + batchBytes += rec.length; off += rec.length; + if (batchBytes >= FLUSH_BYTES) await flushBatch(); } - fs.fsyncSync(fd); + await flushBatch(); + await fh.sync(); } finally { - fs.closeSync(fd); + await fh.close(); } + hooks.beforeRename?.(); fs.renameSync(tmp, filePath); // Best-effort directory fsync so the rename survives a crash. try { diff --git a/packages/minidb/test/compaction-fault.test.ts b/packages/minidb/test/compaction-fault.test.ts index bea39dfa50..bb544f8456 100644 --- a/packages/minidb/test/compaction-fault.test.ts +++ b/packages/minidb/test/compaction-fault.test.ts @@ -344,9 +344,9 @@ test('a compaction whose onCompacted hook throws counts as a compactError, not a const hook = db.onCompacted; let failHook = true; - db.onCompacted = () => { + db.onCompacted = async () => { if (failHook) throw new Error('injected hook failure'); - hook(); + await hook(); }; await assert.rejects(db.compact(), /injected hook failure/); // The hook is part of the compaction: compactions counts only fully diff --git a/packages/minidb/test/compaction.test.ts b/packages/minidb/test/compaction.test.ts index c2e64480b1..e936587492 100644 --- a/packages/minidb/test/compaction.test.ts +++ b/packages/minidb/test/compaction.test.ts @@ -80,6 +80,32 @@ test('auto-compaction triggers when the WAL crosses the threshold', async () => } }); +test('open-time compaction runs in the background — open() returns with the full dataset', async () => { + const dir = await tmpDir(); + try { + // Grow the WAL well past the threshold without any compaction. + let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + for (let i = 0; i < 200; i++) await db.set(`k${i}`, `v${i}`.padEnd(50, 'x')); + await db.close(); + + // Reopen with a 1 KiB threshold: the WAL far exceeds it, so compaction + // fires — but open() must NOT block on the rewrite. The recovered store + // is already complete and consistent the moment open() resolves. + db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1024 }); + assert.equal(db.size, 200); + for (let i = 0; i < 200; i++) assert.equal(db.get(`k${i}`), `v${i}`.padEnd(50, 'x')); + + // The background compaction finishes on its own and shrinks the WAL. + if (db.compacting) await db._compactDone; + assert.ok(db.stats.compactions >= 1, 'open-time background compaction ran'); + assert.ok((await fs.stat(path.join(dir, 'db.wal'))).size < 1024, 'WAL shrank'); + assert.equal(db.size, 200); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + test('del-then-compact drops tombstoned keys from the snapshot', async () => { const dir = await tmpDir(); try { diff --git a/packages/minidb/test/text-index.test.ts b/packages/minidb/test/text-index.test.ts index 9489d8233e..85f73b22f7 100644 --- a/packages/minidb/test/text-index.test.ts +++ b/packages/minidb/test/text-index.test.ts @@ -69,7 +69,7 @@ test('PostingsFile: rebuild + positioned read', async () => { const dir = await tmpDir(); try { const p = path.join(dir, 'x.postings'); - const dict = PostingsFile.rebuildSync(p, [ + const dict = await PostingsFile.rebuild(p, [ { term: 'hello', entries: [ @@ -103,7 +103,7 @@ test('PostingsFile: rebuild + positioned read', async () => { pf.close(); // rebuild is atomic: a second rebuild replaces the file and dict. - const dict2 = PostingsFile.rebuildSync(p, [{ term: 'only', entries: [[7, 1]] }]); + const dict2 = await PostingsFile.rebuild(p, [{ term: 'only', entries: [[7, 1]] }]); assert.equal(dict2.size, 1); const pf2 = PostingsFile.open(p); assert.deepEqual(pf2.read(dict2.get('only')!), [[7, 1]]); @@ -117,7 +117,7 @@ test('PostingsFile: corrupt record throws on read', async () => { const dir = await tmpDir(); try { const p = path.join(dir, 'x.postings'); - const dict = PostingsFile.rebuildSync(p, [{ term: 'a', entries: [[1, 1]] }]); + const dict = await PostingsFile.rebuild(p, [{ term: 'a', entries: [[1, 1]] }]); // flip a byte in the file payload const e = dict.get('a')!; const fd = fssync.openSync(p, 'r+'); @@ -190,7 +190,7 @@ test('TextIndex: build persists to disk + merges delta after build', async () => try { const p = path.join(dir, 't.postings'); const ti = new TextIndex({ postingsPath: p }); - ti.build([ + await ti.build([ { key: 'a', value: { bio: 'hello world' } }, { key: 'b', value: { bio: '我住在北京' } }, ]); @@ -206,7 +206,7 @@ test('TextIndex: build persists to disk + merges delta after build', async () => // (delta is volatile by design; the db rebuilds from the Store on open). const ti2 = new TextIndex({ postingsPath: p }); // rebuild base from the file's perspective by re-reading the same entries - ti2.build([ + await ti2.build([ { key: 'a', value: { bio: 'hello world' } }, { key: 'b', value: { bio: '我住在北京' } }, ]); @@ -217,6 +217,42 @@ test('TextIndex: build persists to disk + merges delta after build', async () => } }); +test('TextIndex: writes landing mid-build are replayed onto the new base', async () => { + const dir = await tmpDir(); + try { + const p = path.join(dir, 't.postings'); + const ti = new TextIndex({ postingsPath: p }); + // More docs than BUILD_YIELD_DOCS (2048), so the build is guaranteed to + // yield at least once before its swap — the setImmediate below then lands + // strictly inside the build window. + const docs = Array.from({ length: 3000 }, (_, i) => ({ + key: `d${i}`, + value: { bio: `hello doc${i}` }, + })); + const buildP = ti.build(docs); + let landedMidBuild = false; + setImmediate(() => { + landedMidBuild = ti.building; + ti.add('extra', { bio: 'hello extra' }); // new key + ti.add('d0', { bio: 'goodbye replaced' }); // overwrite a staged key + ti.remove('d1'); // delete a staged key + }); + await buildP; + assert.ok(landedMidBuild, 'writes landed while the build was in flight'); + + // Live view during the build stayed correct, and the queue replay made + // the new base exact: 3000 staged + extra − replaced-d0 − removed-d1. + assert.equal(ti.N, 3000); + assert.deepEqual(ti.search('extra').map((h) => h.key), ['extra']); + assert.deepEqual(ti.search('goodbye').map((h) => h.key), ['d0']); + assert.deepEqual(ti.search('doc1').map((h) => h.key), []); + assert.equal(ti.search('hello', { limit: 10_000 }).length, 2999); + ti.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + test('TextIndex: memory-only mode (no postingsPath)', () => { const ti = new TextIndex(); // memory base ti.add('a', { bio: 'hello world' }); @@ -277,6 +313,64 @@ test('MiniDb: compaction rebuilds postings (file reclaimed)', async () => { } }); +test('MiniDb: compaction skips the postings rebuild when the index is clean', async () => { + const dir = await tmpDir(); + // Count TextIndex.build calls to prove which compactions rebuilt postings. + const orig = TextIndex.prototype.build; + let builds = 0; + TextIndex.prototype.build = async function (this: TextIndex, ...args) { + builds++; + return orig.apply(this, args); + } as typeof orig; + try { + const db = await MiniDb.open({ dir, valueCodec: 'json', autoCompact: false }); + await db.createTextIndex('bio', { fields: ['bio'] }); // build #1 + await db.set('a', { bio: 'hello world' }); + await db.compact(); // delta dirty -> rebuild #2 + await db.compact(); // clean now -> rebuild skipped + assert.equal(builds, 2); + assert.deepEqual(db.search('bio', 'hello').map((h) => h.key), ['a']); + await db.close(); + } finally { + TextIndex.prototype.build = orig; + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('MiniDb: writes during a compaction postings rebuild stay consistent', async () => { + const dir = await tmpDir(); + try { + const db = await MiniDb.open({ dir, valueCodec: 'json', autoCompact: false }); + await db.createTextIndex('bio', { fields: ['bio'] }); + // More docs than the snapshot yield cadence, so the compaction is still + // running when the setImmediate writes below land. + for (let i = 0; i < 3000; i++) await db.set(`d${i}`, { bio: `hello doc${i}` }); + + const compactP = db.compact(); + setImmediate(() => { + void db.set('extra', { bio: 'hello extra' }); + void db.set('d0', { bio: 'goodbye replaced' }); + void db.del('d1'); + }); + await compactP; + assert.equal(db.stats.compactions, 1); + + assert.equal(db.search('bio', 'hello', { limit: 10_000 }).length, 2999); + assert.deepEqual(db.search('bio', 'extra').map((h) => h.key), ['extra']); + assert.deepEqual(db.search('bio', 'goodbye').map((h) => h.key), ['d0']); + assert.deepEqual(db.search('bio', 'doc1').map((h) => h.key), []); + await db.close(); + + // The mid-compaction writes are durable and consistent across a reopen. + const db2 = await MiniDb.open({ dir, valueCodec: 'json' }); + assert.equal(db2.search('bio', 'hello', { limit: 10_000 }).length, 2999); + assert.deepEqual(db2.search('bio', 'extra').map((h) => h.key), ['extra']); + await db2.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + // ---- trigram (n-gram literal tokenizer) ------------------------------------ test('trigram: normalization (case, NFKC, code points)', () => { @@ -379,7 +473,7 @@ test('TextIndex: n-gram tokenizer delta add/remove/overwrite', async () => { tokenizer: createNgramTokenizer(), queryTokenizer: createNgramTokenizer({ forQuery: true }), }); - ti.build([{ key: 'a', value: { text: 'C++ guide' } }]); + await ti.build([{ key: 'a', value: { text: 'C++ guide' } }]); assert.deepEqual(ti.search('c++').map((h) => h.key), ['a']); // writes after build land in the delta and stay searchable From 7c0a93b0ce6c1e014a8a410f9287d1fd136db514 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 31 Jul 2026 11:36:40 +0800 Subject: [PATCH 4/6] refactor(agent-core-v2): rename workspaceHandler to sessionLifecycle - rename IWorkspaceHandlerService to ISessionLifecycleService and move src/workspace/workspaceHandler/ to src/workspace/sessionLifecycle/; update all consumers (gateway, sessionExport, sessionLegacy, sessionLookup, kap-server, klient, node-sdk, kimi-inspect, kimi-code) - rename IStateService to IAppStateService and add the Workspace-scope IWorkspaceStateService, so the state domain spans all four scope tiers - add cascading StateRegistry.inspect(): each tier injects the parent tier's registry and folds App to current scope into one StateInspection tree; check-domain-layers gains a Rule 2b exemption for state-on-state imports --- .agents/skills/agent-core-dev/align.md | 2 +- .agents/skills/agent-core-dev/design.md | 16 ++-- .../agent-core-dev/domain-boundaries.md | 2 +- .../skills/agent-core-dev/edge-exposure.md | 4 +- AGENTS.md | 4 +- apps/kimi-code/src/cli/v2/run-v2-print.ts | 4 +- apps/kimi-code/test/cli/v2-run-print.test.ts | 10 +-- apps/kimi-inspect/src/App.tsx | 4 +- apps/kimi-inspect/src/channel/client.ts | 2 +- .../src/components/ModelCatalogView.tsx | 4 +- apps/kimi-inspect/src/components/Sidebar.tsx | 4 +- packages/agent-core-v2/AGENTS.md | 2 +- .../agent-core-v2/docs/rw-model-design.md | 6 +- .../scripts/check-domain-layers.mjs | 41 ++++++--- .../src/_base/state/stateRegistry.ts | 39 ++++++++- .../src/agent/plan/configSection.ts | 2 +- .../agent-core-v2/src/agent/plan/planOps.ts | 2 +- .../src/agent/state/agentState.ts | 5 +- .../src/agent/state/agentStateService.ts | 13 ++- .../src/app/bootstrap/bootstrapService.ts | 2 +- .../agent-core-v2/src/app/gateway/gateway.ts | 2 +- .../src/app/gateway/gatewayService.ts | 6 +- .../app/sessionExport/sessionExportService.ts | 8 +- .../src/app/sessionIndex/sessionIndex.ts | 2 +- .../src/app/sessionLegacy/sessionLegacy.ts | 4 +- .../app/sessionLegacy/sessionLegacyService.ts | 4 +- .../src/app/state/{state.ts => appState.ts} | 13 +-- .../src/app/state/appStateService.ts | 26 ++++++ .../src/app/state/stateService.ts | 18 ---- .../app/workspaceLifecycle/sessionLookup.ts | 22 ++--- .../workspaceLifecycle/workspaceLifecycle.ts | 2 +- .../workspaceLifecycleService.ts | 6 +- packages/agent-core-v2/src/index.ts | 12 +-- .../src/os/interface/hostEnvironment.ts | 2 +- .../src/session/agentLifecycle/mainAgent.ts | 2 +- packages/agent-core-v2/src/session/errors.ts | 2 +- .../externalHooks/externalHooksService.ts | 2 +- .../src/session/mcp/sessionMcpHandle.ts | 2 +- .../session/process/processRunnerService.ts | 2 +- .../agentProfileCatalogSeed.ts | 2 +- .../session/sessionContext/sessionContext.ts | 2 +- .../instructionsProvider.ts | 2 +- .../sessionLifecycleHooks.ts | 2 +- .../sessionSkillCatalog/skillCatalogData.ts | 2 +- .../sessionToolPolicyGate.ts | 2 +- .../src/session/state/sessionState.ts | 5 +- .../src/session/state/sessionStateService.ts | 13 ++- .../session/workspaceInfo/workspaceInfo.ts | 2 +- .../addressing.ts | 2 +- .../sessionLifecycle.ts} | 10 +-- .../sessionLifecycleService.ts} | 14 +-- .../src/workspace/state/workspaceState.ts | 21 +++++ .../workspace/state/workspaceStateService.ts | 33 +++++++ .../test/_base/state/stateRegistry.test.ts | 87 ++++++++++++++++--- .../test/app/gateway/gateway.test.ts | 6 +- .../app/messageLegacy/messageLegacy.test.ts | 4 +- .../app/sessionExport/sessionExport.test.ts | 6 +- .../app/sessionLegacy/sessionLegacy.test.ts | 4 +- .../workspaceLifecycle.test.ts | 22 ++--- packages/agent-core-v2/test/harness/agent.ts | 10 +++ .../test/session/question/question.test.ts | 10 ++- .../sessionActivityService.test.ts | 7 +- .../sessionLog/sessionLogService.test.ts | 21 +++-- .../sessionSkillCatalog/skillCatalog.test.ts | 3 + packages/agent-core-v2/test/state/stubs.ts | 20 +++-- .../sessionLifecycle.test.ts} | 34 ++++++-- .../workspaceDirs/workspaceDirs.test.ts | 34 ++++++-- .../test/workspace/workspaceResources.test.ts | 22 +++-- packages/kap-server/src/routes/sessions.ts | 20 ++--- packages/kap-server/test/rpc.test.ts | 4 +- .../test/services/transcript.test.ts | 6 +- .../test/sessionEventBroadcaster.test.ts | 6 +- packages/kap-server/test/snapshot.test.ts | 4 +- packages/klient/src/contract/index.ts | 4 +- .../klient/src/contract/session/lifecycle.ts | 8 +- packages/klient/src/core/facade/global.ts | 2 +- packages/klient/src/core/facade/session.ts | 10 +-- .../src/transports/memory/serviceRegistry.ts | 4 +- packages/klient/test/contract-parity.ts | 2 +- packages/node-sdk/src/sdk-rpc-client-v2.ts | 10 +-- 80 files changed, 532 insertions(+), 255 deletions(-) rename packages/agent-core-v2/src/app/state/{state.ts => appState.ts} (53%) create mode 100644 packages/agent-core-v2/src/app/state/appStateService.ts delete mode 100644 packages/agent-core-v2/src/app/state/stateService.ts rename packages/agent-core-v2/src/workspace/{workspaceHandler => sessionLifecycle}/addressing.ts (94%) rename packages/agent-core-v2/src/workspace/{workspaceHandler/workspaceHandler.ts => sessionLifecycle/sessionLifecycle.ts} (92%) rename packages/agent-core-v2/src/workspace/{workspaceHandler/workspaceHandlerService.ts => sessionLifecycle/sessionLifecycleService.ts} (99%) create mode 100644 packages/agent-core-v2/src/workspace/state/workspaceState.ts create mode 100644 packages/agent-core-v2/src/workspace/state/workspaceStateService.ts rename packages/agent-core-v2/test/workspace/{workspaceHandler/workspaceHandler.test.ts => sessionLifecycle/sessionLifecycle.test.ts} (97%) diff --git a/.agents/skills/agent-core-dev/align.md b/.agents/skills/agent-core-dev/align.md index ae3d2a8b6e..3f6e6778f5 100644 --- a/.agents/skills/agent-core-dev/align.md +++ b/.agents/skills/agent-core-dev/align.md @@ -63,7 +63,7 @@ Worked example — v1 `ISessionService` (one class, ~600 lines) holds: - this session's metadata → **per-session** unit → v2 `sessionMetaStore` (`ISessionMetaStore`, Session); - this session's activity / status → **per-session** unit → v2 `sessionActivity`; - this session's context projection → **per-session** unit → v2 `sessionContext`; -- child-agent lifecycle driven by a session → **per-session** unit → v2 `agentLifecycle`; create/close/archive/fork of the session itself → **per-workspace** unit → v2 `workspaceHandler` (Workspace, one per live workspace handler). +- child-agent lifecycle driven by a session → **per-session** unit → v2 `agentLifecycle`; create/close/archive/fork of the session itself → **per-workspace** unit → v2 `sessionLifecycle` (Workspace, one per live workspace handler). A v1 class that maps cleanly to one v1 decorator often becomes **three to five** v2 Services. That is expected and correct — do not try to keep the v1 class shape. diff --git a/.agents/skills/agent-core-dev/design.md b/.agents/skills/agent-core-dev/design.md index a7778e7b9b..fc7035f741 100644 --- a/.agents/skills/agent-core-dev/design.md +++ b/.agents/skills/agent-core-dev/design.md @@ -228,17 +228,17 @@ Read it as: - `──holds──►` = the ancestor owns a handle to the child scope (it stores the key, not the service). DI allows this. - `accessor.get(...)` = a **runtime borrow**, not a dependency edge. It must cross an `IScopeHandle`, run on demand, never be cached, and finish before the child scope is disposed. -Worked example — `workspaceHandler`: +Worked example — `sessionLifecycle`: ```text -domain: `workspaceHandler` (owning scope: Workspace) +domain: `sessionLifecycle` (owning scope: Workspace) ├─ serves (who uses me) │ ├─ (inject) — (none) │ └─ (accessor) │ ├─ sessionLegacy @App(edge) — v1-compatible create/fork/archive/… │ └─ gateway / rpc @App(edge) — native v2 session lifecycle actions ├─ exposes (interfaces I provide, by scope) -│ ├─ Workspace : IWorkspaceHandlerService — owns this workspace's live session scope tree +│ ├─ Workspace : ISessionLifecycleService — owns this workspace's live session scope tree │ ├─ Session : — — (per-session state lives in sessionMetadata / agentLifecycle / …) │ └─ Agent : — — (per-agent state lives in agentLifecycle) └─ depends (what I inject) @@ -252,17 +252,17 @@ domain: `workspaceHandler` (owning scope: Workspace) └─ event @App direct — broadcasts session-level facts (e.g. archived) ``` -Cross-scope borrow for `workspaceHandler`: +Cross-scope borrow for `sessionLifecycle`: ```text App scope WorkspaceLifecycleService ──holds──► IScopeHandle(workspaceId) (one per live handler) │ - │ accessor.get(IWorkspaceHandlerService) + │ accessor.get(ISessionLifecycleService) │ └── resolve runs inside the Workspace scope ▼ Workspace scope (workspaceId) - WorkspaceHandlerService ──holds──► IScopeHandle(sessionId) + SessionLifecycleService ──holds──► IScopeHandle(sessionId) │ │ accessor.get(ISessionMetadata) … │ └── resolve runs inside the Session scope @@ -274,8 +274,8 @@ App scope How the three lenses shaped it: - **Scope (§2)** → the live registry of one workspace's session scopes is per-handler, so it is Workspace-scoped; the process-wide handler registry lives in the App-scoped `workspaceLifecycle`; per-session data stays in Session-scoped services, reached through the handle's `accessor`. -- **Dependency direction (§5)** → `workspaceHandler` is consumed by the edge via `accessor` borrows; it never imports the edge. Every downward arrow lands on a peer or a more foundational Service. -- **Extension points (§4)** → new per-session behavior plugs into the Session-scoped services (`sessionMetadata`, `agentLifecycle`, `sessionActivity`); new transports stay at the edge. Neither edits `workspaceHandler`. +- **Dependency direction (§5)** → `sessionLifecycle` is consumed by the edge via `accessor` borrows; it never imports the edge. Every downward arrow lands on a peer or a more foundational Service. +- **Extension points (§4)** → new per-session behavior plugs into the Session-scoped services (`sessionMetadata`, `agentLifecycle`, `sessionActivity`); new transports stay at the edge. Neither edits `sessionLifecycle`. For a multi-scope split, the `exposes` block fills more than one scope — see the `records` pattern in §3. diff --git a/.agents/skills/agent-core-dev/domain-boundaries.md b/.agents/skills/agent-core-dev/domain-boundaries.md index 8f97847a92..cd3eb8ee64 100644 --- a/.agents/skills/agent-core-dev/domain-boundaries.md +++ b/.agents/skills/agent-core-dev/domain-boundaries.md @@ -82,7 +82,7 @@ The `session` domain owns only Session-level identity, metadata, lifecycle comma |---|---|---| | `sessionId`, `workspaceId`, `sessionDir`, `metaScope` | `sessionContext` | Seeded facts; no IO | | `SessionMeta` | `sessionMetadata` | Durable atomic document; entity-like | -| Open session scope registry | `workspaceHandler` | Workspace-scope live handles, one registry per workspace handler (the process-wide handler registry is `workspaceLifecycle`); not the persisted entity table | +| Open session scope registry | `sessionLifecycle` | Workspace-scope live handles, one registry per workspace handler (the process-wide handler registry is `workspaceLifecycle`); not the persisted entity table | | Session commands such as `archive()` | `session` | Orchestrates metadata, agent teardown, and events | | Persisted session list / get / count | `sessionIndex` | Backend-neutral read model | | Running / idle / awaiting status | `sessionActivity` | Derived from interactions and active turns; owns no state | diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md index db03a8e79c..9105bb9488 100644 --- a/.agents/skills/agent-core-dev/edge-exposure.md +++ b/.agents/skills/agent-core-dev/edge-exposure.md @@ -85,7 +85,7 @@ Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`. | `session` | `setArchived` | ISessionMetadata.setArchived | POST | | `session` | `status` | ISessionActivity.status | GET | | `session` | `isIdle` | ISessionActivity.isIdle | GET | -| `session` | `archive` | IWorkspaceHandlerService.archive | POST | +| `session` | `archive` | ISessionLifecycleService.archive | POST | | `approvals` | `listPending` | IApprovalService.listPending | GET | | `approvals` | `decide` | IApprovalService.decide | POST | | `questions` | `listPending` | IQuestionService.listPending | GET | @@ -128,7 +128,7 @@ These fail §2 and must be wrapped in a facade that takes ids and returns data: | Service | Why not direct | Facade shape | |---|---|---| -| IWorkspaceHandlerService | returns `IScopeHandle` | `sessions.create` / `fork` / `close` / `archive` → wire Session | +| ISessionLifecycleService | returns `IScopeHandle` | `sessions.create` / `fork` / `close` / `archive` → wire Session | | IAgentPromptService / IAgentTurnService | returns `Turn` handle | `prompts.submit` / `steer` / `abort` / `undo` | | ILLMRequester | `AsyncIterable` stream | stream over WS, not RPC | | ISubagentHost | `SubagentHandle` | `subagents.spawn` / `resume` → info | diff --git a/AGENTS.md b/AGENTS.md index 0730a7a641..e36a0df62a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,14 +19,14 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `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, the global message search (`src/components/SearchView.tsx` — cross-session full-text search over `POST /api/v1/search`, cursor-paged via a manual Load more; an exact-match checkbox maps to the API's `mode: 'literal'` substring search, which ignores sort and orders newest-first; a `live`/`index` badge on the results shows which server route served them (in-memory session transcript vs the persisted index)), 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, joined by the Workspace Services view (`src/components/WorkspaceServicesView.tsx`) — the workspace-scope counterpart with a left sidebar directory browser (`src/components/WorkspaceDirBrowser.tsx` — server-side fs browsing over the App-scope `IHostFolderBrowser`, marking entries that are registered workspaces with their `IWorkspaceTrust` trust state, and registering a picked folder on demand via `IWorkspaceService.createOrTouch`), its proxies riding the `/workspace/:id` route, which materializes the handler on demand via `IWorkspaceLifecycleService.handlerFor`; the Agent scope stays in the Chat view's right dock (`src/components/RightPanel.tsx`) across two tabs: the `Agent` tab (`Inspector`: agent switcher + a Plan lookup card — `PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan` — plus the agent Service panels) and the `State` tab (every key an Agent Service registered into the agent-state container, polled live via `IAgentStateService.snapshot()` — the same live diff-tree view as the session State tab, sharing `StateCard` from `src/components/StateCard.tsx`), while the Session scope has its own column right next to the session-list sidebar (`src/components/SessionPane.tsx`) with two tabs: Services (the pending-interactions card — `src/components/InteractionsCard.tsx` — plus the session Service panels) and State (every key a Session Service registered into the session-state container, read on demand via `ISessionStateService.snapshot()`)). 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. Session-level coarse status is the one exception: `src/activity/` holds a second `/api/v1/ws` client (`GlobalEventsWs`) that subscribes to nothing and consumes the server-pushed global facts — `event.session.work_changed` updates a per-session activity map (`SessionActivityHub` + subscribe/version store, seeded on connect/reconnect from `GET /api/v1/sessions`), while `event.session.created` / `session.meta.updated` invalidate the `['sessions']` query; the `Sidebar` session rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities`. 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 and carries an in-chat search bar (`src/components/ChatSearchBar.tsx`): it searches the current session via `POST /api/v1/search` with `container: { session_id }` (usually served by the live route, since selecting a session resumes it), and a result click funnels through the app shell's `openSearchHit` — the same agent-switch + `ChatJump` (page-back, scroll, flash) path the global search view uses; 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 an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `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: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, 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/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) 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/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`_base/di/scope.ts`). The `workspace/` domain owns one Workspace scope per materialized workspace handler: the App-scope `IWorkspaceLifecycleService` keeps the live handler registry (create-or-get + join, handlers never closed), and each handler's `IWorkspaceHandlerService` owns session create/resume/fork/close as its child scopes — there is no App-level session lifecycle facade, callers compose `ISessionIndex` → `handlerFor` → the handler. Workspace-scope services hold the handler-shared resources loaded once per handler and refreshed by fs watch: skills / AGENTS.md (`workspaceSkillCatalog` / `workspaceInstructions`), the workspace agent-profile loader (`workspaceAgentProfileLoader` — agent profiles follow the Contribution / Registry / Catalog extension point: the domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId`; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles, and each Session-scope `sessionAgentProfileCatalog` projects the registry directly (name-level dedup + builtin-override rule in the projection), seeded with only the workspace key), one shared MCP connection set (`workspaceMcp`, pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` — mcp.json files + plugin contributions, fs-watch refreshed — and MCP persistence, the `[mcp]` config section plus OAuth credentials, lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong), fs / fs-watch / process runner / git (`workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit`), the additional-directory set (`workspaceDirs`, backed by `.kimi-code/local.toml`), the os-level tool veto (`workspaceToolPolicy`), and the trust marker (`workspaceTrust` — persisted under the home, keyed by `encodeWorkDirKey(root)`; while a workspace is untrusted, `workspaceMcpConfig` skips the project-level `.mcp.json` / `.kimi-code/mcp.json` files, and the state flips through kap-server's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes). Session/Agent scopes consume these through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …) — Session/Agent never import the Workspace domain. See `packages/agent-core-v2/AGENTS.md` and use the `agent-core-dev` skill (`.agents/skills/agent-core-dev/SKILL.md`) when developing here. +- `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`_base/di/scope.ts`). The `workspace/` domain owns one Workspace scope per materialized workspace handler: the App-scope `IWorkspaceLifecycleService` keeps the live handler registry (create-or-get + join, handlers never closed), and each handler's `ISessionLifecycleService` owns session create/resume/fork/close as its child scopes — there is no App-level session lifecycle facade, callers compose `ISessionIndex` → `handlerFor` → the handler. Workspace-scope services hold the handler-shared resources loaded once per handler and refreshed by fs watch: skills / AGENTS.md (`workspaceSkillCatalog` / `workspaceInstructions`), the workspace agent-profile loader (`workspaceAgentProfileLoader` — agent profiles follow the Contribution / Registry / Catalog extension point: the domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId`; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles, and each Session-scope `sessionAgentProfileCatalog` projects the registry directly (name-level dedup + builtin-override rule in the projection), seeded with only the workspace key), one shared MCP connection set (`workspaceMcp`, pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` — mcp.json files + plugin contributions, fs-watch refreshed — and MCP persistence, the `[mcp]` config section plus OAuth credentials, lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong), fs / fs-watch / process runner / git (`workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit`), the additional-directory set (`workspaceDirs`, backed by `.kimi-code/local.toml`), the os-level tool veto (`workspaceToolPolicy`), and the trust marker (`workspaceTrust` — persisted under the home, keyed by `encodeWorkDirKey(root)`; while a workspace is untrusted, `workspaceMcpConfig` skips the project-level `.mcp.json` / `.kimi-code/mcp.json` files, and the state flips through kap-server's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes). Session/Agent scopes consume these through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …) — Session/Agent never import the Workspace domain. See `packages/agent-core-v2/AGENTS.md` and use the `agent-core-dev` skill (`.agents/skills/agent-core-dev/SKILL.md`) when developing here. - `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 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` — four scopes, App/Workspace/Session/Agent; session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `IWorkspaceHandlerService`, and the fs routes resolve session → handler → the Workspace-scope fs services, with one exception: `fs:search` also accepts a workspace reference (registered id or absolute root) in the `{session_id}` slot, so a not-yet-created draft session's `@` file mention resolves the workspace handler directly). 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, Workspace scope addressable alongside App/Session/Agent; `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 (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) 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). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. 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. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first, and a candidate set truncated at `LITERAL_CANDIDATE_CAP` is flagged `incomplete: 'candidate_cap'`). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. +- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2` — four scopes, App/Workspace/Session/Agent; session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `ISessionLifecycleService`, and the fs routes resolve session → handler → the Workspace-scope fs services, with one exception: `fs:search` also accepts a workspace reference (registered id or absolute root) in the `{session_id}` slot, so a not-yet-created draft session's `@` file mention resolves the workspace handler directly; the first-class session-less form is `POST /api/v1/workspace/fs:search` (the workspace reference travels in the body)). 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, Workspace scope addressable alongside App/Session/Agent; `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 (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) 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). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. 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. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first, and a candidate set truncated at `LITERAL_CANDIDATE_CAP` is flagged `incomplete: 'candidate_cap'`). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. - `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`. - `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm) that produces a syntax tree with tree-sitter-bash 0.25.0 named-node type names and UTF-16 code-unit offsets. `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget (default 50 ms / 50k nodes, plus per-chain recursion depth caps) and returns a discriminated `ParseResult` (`{ ok, rootNode, hasError }` or `{ ok: false, reason: 'aborted' }`) — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; consumers (e.g. Bash tool permission matching) live elsewhere. Known deviations from the reference are tracked in the package README's "Known differences" section, pinned by differential fixtures tested against the real `tree-sitter-bash` wasm (dev-only). 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 456979bdf8..d9f6237cf4 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -32,7 +32,7 @@ import { IOAuthToolkit, ISessionCronService, ISessionIndex, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, ITelemetryService, PRINT_MAX_TURNS_DEFAULT, @@ -377,7 +377,7 @@ async function resolveNativeSession( const model = requireConfiguredModel(opts.model, defaultModel); const handler = await workspaceLifecycle.handlerFor({ root: workDir }); - const session = await handler.accessor.get(IWorkspaceHandlerService).create({ + const session = await handler.accessor.get(ISessionLifecycleService).create({ workDir, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, mainAgentBinding: { diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index 54ccbb0527..fab72a136b 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -20,7 +20,7 @@ import { IOAuthToolkit, ISessionCronService, ISessionIndex, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, ISkillCatalogRuntimeOptions, ITelemetryService, @@ -182,7 +182,7 @@ function makeFakeHarness() { const handlerServices = new Map([ [ - IWorkspaceHandlerService, + ISessionLifecycleService, { create: vi.fn(async () => session), resume: vi.fn(async () => session), @@ -345,7 +345,7 @@ describe('runV2Print', () => { const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); expect(seeded?.[1]).toMatchObject({ explicitFiles: ['/agents/reviewer.md'] }); - const lifecycle = handlerServices.get(IWorkspaceHandlerService) as { + const lifecycle = handlerServices.get(ISessionLifecycleService) as { create: ReturnType; }; expect(lifecycle.create).toHaveBeenCalledWith({ @@ -380,7 +380,7 @@ describe('runV2Print', () => { const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); expect(seeded?.[1]).toMatchObject({ explicitFiles: [agentFile] }); - const lifecycle = handlerServices.get(IWorkspaceHandlerService) as { + const lifecycle = handlerServices.get(ISessionLifecycleService) as { create: ReturnType; }; expect(lifecycle.create).toHaveBeenCalledWith({ @@ -396,7 +396,7 @@ describe('runV2Print', () => { const stdout = writer(); const stderr = writer(); const { app, handlerServices } = makeFakeHarness(); - const lifecycle = handlerServices.get(IWorkspaceHandlerService) as { + const lifecycle = handlerServices.get(ISessionLifecycleService) as { create: ReturnType; }; lifecycle.create.mockRejectedValueOnce(new Error('Unknown agent profile')); diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx index bb634b04f0..2624dcf54f 100644 --- a/apps/kimi-inspect/src/App.tsx +++ b/apps/kimi-inspect/src/App.tsx @@ -17,7 +17,7 @@ */ import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; -import { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; import { useEffect, useState } from 'react'; import type { AuditTrail } from './audit/trail'; @@ -62,7 +62,7 @@ export function App() { .get(sessionId) .then((summary) => { if (summary === undefined) throw new Error(`session ${sessionId} does not exist`); - return klient.workspace(summary.workspaceId).service(IWorkspaceHandlerService).resume(sessionId); + return klient.workspace(summary.workspaceId).service(ISessionLifecycleService).resume(sessionId); }) .then(() => { if (!cancelled) setReady(true); diff --git a/apps/kimi-inspect/src/channel/client.ts b/apps/kimi-inspect/src/channel/client.ts index 6f3b36458e..280f3ed829 100644 --- a/apps/kimi-inspect/src/channel/client.ts +++ b/apps/kimi-inspect/src/channel/client.ts @@ -6,7 +6,7 @@ * * const client = createInspectClient({ url: 'http://127.0.0.1:58627' }); * await client.core(ISessionIndex).list({}); - * await client.workspace('wd_1').service(IWorkspaceHandlerService).resume('s1'); + * await client.workspace('wd_1').service(ISessionLifecycleService).resume('s1'); * await client.session('s1').service(ISessionMetadata).read(); * await client.session('s1').agent('main').service(IAgentRPCService).cancel({}); * diff --git a/apps/kimi-inspect/src/components/ModelCatalogView.tsx b/apps/kimi-inspect/src/components/ModelCatalogView.tsx index 49a592b5d8..7d61f0f0a3 100644 --- a/apps/kimi-inspect/src/components/ModelCatalogView.tsx +++ b/apps/kimi-inspect/src/components/ModelCatalogView.tsx @@ -19,7 +19,7 @@ import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; -import { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; import type { InspectionSource } from '@moonshot-ai/agent-core-v2/kosong/contract/inspection'; import type { TokenUsage } from '@moonshot-ai/agent-core-v2/kosong/contract/usage'; import { @@ -410,7 +410,7 @@ function ModelSection({ if (summary !== undefined) { await klient .workspace(summary.workspaceId) - .service(IWorkspaceHandlerService) + .service(ISessionLifecycleService) .resume(sessionId); } await klient diff --git a/apps/kimi-inspect/src/components/Sidebar.tsx b/apps/kimi-inspect/src/components/Sidebar.tsx index 69461161fc..dfbadfb264 100644 --- a/apps/kimi-inspect/src/components/Sidebar.tsx +++ b/apps/kimi-inspect/src/components/Sidebar.tsx @@ -13,7 +13,7 @@ import { ISessionIndex, type SessionSummary, } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; -import { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; import { IWorkspaceService, type Workspace, @@ -113,7 +113,7 @@ export function Sidebar({ if (summary !== undefined) { await klient .workspace(summary.workspaceId) - .service(IWorkspaceHandlerService) + .service(ISessionLifecycleService) .resume(sessionId); } await klient.session(sessionId).agent('main').service(IAgentProfileService).setModel(model); diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index f8c82bbb7a..13981c1897 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -4,7 +4,7 @@ ## Scopes -Four `LifecycleScope` tiers — `App` (0) / `Workspace` (1) / `Session` (2) / `Agent` (3) (`src/_base/di/scope.ts`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `workspaceHandler` owns the session lifecycle (create/resume/fork/close) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events. Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId` (the registry dedups per source id; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged, name-deduped read view directly — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.kimi-code/mcp.json`). Dependency red line: **Session/Agent never import the Workspace domain**; the App-level `ISessionLifecycleService` / `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex` → `workspaceLifecycle.handlerFor` → the handler instead. +Four `LifecycleScope` tiers — `App` (0) / `Workspace` (1) / `Session` (2) / `Agent` (3) (`src/_base/di/scope.ts`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `sessionLifecycle` owns the session lifecycle (create/resume/fork/close) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events. Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId` (the registry dedups per source id; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged, name-deduped read view directly — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.kimi-code/mcp.json`). Dependency red line: **Session/Agent never import the Workspace domain**; the old App-level session-lifecycle facade and `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex` → `workspaceLifecycle.handlerFor` → the handler instead. ## Examples diff --git a/packages/agent-core-v2/docs/rw-model-design.md b/packages/agent-core-v2/docs/rw-model-design.md index 766946ee75..5e5ad829de 100644 --- a/packages/agent-core-v2/docs/rw-model-design.md +++ b/packages/agent-core-v2/docs/rw-model-design.md @@ -65,7 +65,7 @@ - W3 Session 域借 main agent 的 wire 写(todo/cron),main 缺失时**静默丢写** (`sessionTodoService.ts:99-100`),且要 `as never` 绕过类型。 - W4 fork 直接在 appendLogStore 层改写 wire log,绕过全部写模型 - (`workspaceHandlerService.ts` 的 `fork` / `copyAgentWire`)。 + (`sessionLifecycleService.ts` 的 `fork` / `copyAgentWire`)。 - W5 restore 期 append 在 wireRecord 层被静默吞掉(`wireRecordService.ts:81`), 但 recordService 仍然 foldViews、仍然跑 facet——"进内存不进磁盘"完全隐式。 @@ -98,7 +98,7 @@ onChange 处理器若 append 会无检测地重入。 - L3 restore 正确性依赖三重隐式契约:DI 构造顺序 + hook 注册顺序 + "resumer 先于 hooks";`doResume` 需手动预热 contextMemory - (现 `workspaceHandlerService.ts` 的 `doResume` / `materializeSession`)。 + (现 `sessionLifecycleService.ts` 的 `doResume` / `materializeSession`)。 - L4 相位规则(restoring / postRestoring / live)在 append/signal/push/hook 四条通道上各不相同,没有一处集中定义。 @@ -189,7 +189,7 @@ 逻辑 seq 顺序,因此边缘 journal 的 seq 与核心逻辑 seq 单调一致。 - fork 保持现实现(复制 main 的 wire log);接口上表达为 `stream.forkInto(target)`,实现仍走 appendLogStore(W4 的接口层收口: - 唯一入口,不再散落在 workspaceHandler 里手写)。 + 唯一入口,不再散落在 sessionLifecycle 里手写)。 - App scope 一条逻辑流(config/model catalog/session 生命周期),取代 `IEventService`(V4)——App 流本就无持久化,纯接口替换。 - **Topic = 流上的类型化过滤视角**,不是独立机制。订阅方用 diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index c040f2609d..6e73d366cf 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -86,7 +86,7 @@ const DOMAIN_LAYER = new Map([ // with no IO, so it sits in L1. ['sessionContext', 1], // `sessionLifecycleHooks` is the per-session lifecycle hook-slots seed - // (created by the Workspace-scope `workspaceHandler`, registered into by + // (created by the Workspace-scope `sessionLifecycle`, registered into by // Session-scope adapters such as `externalHooks`) plus the shared // create-source/close-reason vocabulary; a pure contract with no IO, so it // sits in L1 beside `sessionContext`. @@ -123,10 +123,13 @@ const DOMAIN_LAYER = new Map([ // Depends only on `_base`; sits in L1 beside the other program-control // layer substrates. ['task', 1], - // `state` is the per-scope keyed state container (`IStateService` / - // `ISessionStateService` / `IAgentStateService`, one per scope tier under - // `app/state`, `session/state`, `agent/state` — all resolve to this domain). - // It wraps the `_base` `StateRegistry` and depends on nothing else, so any + // `state` is the per-scope keyed state container (`IAppStateService` / + // `IWorkspaceStateService` / `ISessionStateService` / `IAgentStateService`, + // one per scope tier under `app/state`, `workspace/state`, `session/state`, + // `agent/state` — all resolve to this domain). It wraps the `_base` + // `StateRegistry`; each tier injects the parent tier's registry so + // `inspect()` cascades App → … → current scope (see the Rule 2b + // state-on-state exemption). It depends on nothing else, so any // domain may hold its plain-data state through it; sits in L1 beside `event`. ['state', 1], // `bashParser` is the App-scope adapter over the pure @@ -242,7 +245,7 @@ const DOMAIN_LAYER = new Map([ // `activityView` is the Agent-scope read model folding the agent's own event // bus into the activity projection (`agent.activity.updated`); it owns no // authoritative state (turn mechanics live in `loop`, admission/drain in - // `workspaceHandler`, background bookkeeping in `agentLifecycle`). + // `sessionLifecycle`, background bookkeeping in `agentLifecycle`). ['activityView', 4], ['context', 4], ['message', 4], @@ -319,14 +322,15 @@ const DOMAIN_LAYER = new Map([ ['btw', 5], // L6 — coordination ['agentLifecycle', 6], - // `workspaceHandler` is the Workspace-scope anchor of one materialized + // `sessionLifecycle` is the Workspace-scope anchor of one materialized // workspace: it owns the session lifecycle (create/resume/fork/close) of - // that workspace's sessions as its child scopes — the re-scoped heir of - // the deleted App-scope `sessionLifecycle` domain — so it sits in L6. - ['workspaceHandler', 6], + // that workspace's sessions as its child scopes. It revives the name of + // the deleted App-scope `sessionLifecycle` domain — formerly named + // `workspaceHandler` — so it sits in L6. + ['sessionLifecycle', 6], // `workspaceLifecycle` is the App-scope owner of the live handler registry // (create-or-get + in-flight join, handlers never closed). It coordinates - // the `workspace` catalog, `sessionIndex`, and the `workspaceHandler` + // the `workspace` catalog, `sessionIndex`, and the `sessionLifecycle` // domain, so it sits in L6 beside them. ['workspaceLifecycle', 6], // `subagent` drives turns on other agents (`run`) and hosts the @@ -407,7 +411,9 @@ const TWO_LEVEL_SCOPES = new Set(['persistence', 'os', 'kosong']); * workspace lifecycle domain (`src/app/workspaceLifecycle/**`). Workspace * capabilities reach sessions only through session-domain contracts + scope * seeds. The numeric layers cannot express this (the workspace domains sit - * at L6 beside their consumers), so it is checked directly. + * at L6 beside their consumers), so it is checked directly. One scoped + * exemption: the cross-tier `state` domain's own parent-chain injection + * (state-on-state imports) — see Rule 2b in `checkSource`. */ const SESSION_AGENT_TIERS = new Set(['session', 'agent']); const WORKSPACE_LIFECYCLE_PREFIX = 'app/workspaceLifecycle/'; @@ -762,11 +768,20 @@ export function checkSource(source, absFile) { // Rule 2b: scope direction — Session/Agent tiers never import the // Workspace tier (`src/workspace/**` or `src/app/workspaceLifecycle/**`). + // Exemption: the `state` domain spans all four scope tiers as ONE L1 + // domain (`app/state`, `workspace/state`, `session/state`, `agent/state`), + // and each per-tier service injects the parent tier's registry for the + // `inspect()` cascade — state-on-state imports are that domain's own + // shape, not a workspace-capability reach. const sourceTier = scopeTierOf(absFile); if (SESSION_AGENT_TIERS.has(sourceTier)) { const targetTier = scopeTierOf(targetAbs); const targetRel = relative(SRC_ROOT, targetAbs).split(/[\\/]/).join('/'); - if (targetTier === 'workspace' || targetRel.startsWith(WORKSPACE_LIFECYCLE_PREFIX)) { + const stateOnState = sourceDomain === 'state' && targetDomainOf(targetAbs) === 'state'; + if ( + !stateOnState && + (targetTier === 'workspace' || targetRel.startsWith(WORKSPACE_LIFECYCLE_PREFIX)) + ) { violations.push({ file: absFile, line, diff --git a/packages/agent-core-v2/src/_base/state/stateRegistry.ts b/packages/agent-core-v2/src/_base/state/stateRegistry.ts index 2603dd3218..a5fed30141 100644 --- a/packages/agent-core-v2/src/_base/state/stateRegistry.ts +++ b/packages/agent-core-v2/src/_base/state/stateRegistry.ts @@ -3,9 +3,10 @@ * * Owns the typed `StateKey` / `defineState(name, initial)` descriptor (the * state counterpart of wire's `defineModel`), the `IStateRegistry` base - * interface shared by the per-scope state services (`IStateService` / - * `ISessionStateService` / `IAgentStateService`), and the `StateRegistry` - * implementation backing them: a `Map`-backed store where keys are declared + * interface shared by the per-scope state services (`IAppStateService` / + * `IWorkspaceStateService` / `ISessionStateService` / `IAgentStateService`), + * and the `StateRegistry` implementation backing them: a `Map`-backed store + * where keys are declared * up front (`register`), read and replaced (`get` / `set`), and observed * (`onDidChange(key)` per key, `onDidChangeAny` globally). Two exports serve * debugging: `entries()` returns the live key/value references for in-process @@ -18,6 +19,13 @@ * fan the copy out until the heap is exhausted. Misuse (duplicate registration, reading or writing an * unregistered key) is a caller bug and raises `BugIndicatingError`. * + * Cascading inspection: each scope's state service keeps a reference to the + * parent scope's registry (`inspectParent`, assigned from the injected + * parent-tier state service; App is the root) and declares its tier name + * (`inspectScope`). `inspect()` folds that chain into a `StateInspection` + * tree — this scope's `snapshot()` plus the ancestors' — so one RPC call + * from any scope tier exports the whole App → … → current-scope state path. + * * Values are stored as-is — the container does not freeze or clone, so * replacing the whole value via `set` is the recommended update style; * mutating a held `Map` / `Set` in place bypasses change notification. @@ -43,6 +51,17 @@ export interface StateChange { readonly value: unknown; } +/** + * One scope tier's contribution to a cascading state inspection: the tier + * name, its JSON-safe `snapshot()`, and the parent tiers' inspections + * (absent at the App root). + */ +export interface StateInspection { + readonly scope: string; + readonly state: Record; + readonly parent?: StateInspection; +} + export interface IStateRegistry { register(key: StateKey): void; has(key: StateKey): boolean; @@ -52,6 +71,7 @@ export interface IStateRegistry { readonly onDidChangeAny: Event; entries(): readonly [string, unknown][]; snapshot(): Record; + inspect(): StateInspection; } export class StateRegistry extends Disposable implements IStateRegistry { @@ -60,6 +80,11 @@ export class StateRegistry extends Disposable implements IStateRegistry { private readonly anyEmitter = this._register(new Emitter()); readonly onDidChangeAny: Event = this.anyEmitter.event; + /** Scope-tier name reported by `inspect()`; each scoped binding sets it. */ + protected readonly inspectScope: string = 'unknown'; + /** The parent scope's registry for the `inspect()` cascade; root = none. */ + protected inspectParent?: IStateRegistry; + register(key: StateKey): void { if (this.values.has(key.name)) { throw new BugIndicatingError(`state key '${key.name}' is already registered`); @@ -107,6 +132,14 @@ export class StateRegistry extends Disposable implements IStateRegistry { } return out; } + + inspect(): StateInspection { + return { + scope: this.inspectScope, + state: this.snapshot(), + parent: this.inspectParent?.inspect(), + }; + } } function toJsonSafe(value: unknown, seen: WeakSet): unknown { diff --git a/packages/agent-core-v2/src/agent/plan/configSection.ts b/packages/agent-core-v2/src/agent/plan/configSection.ts index 18c1844553..04b1cfce0a 100644 --- a/packages/agent-core-v2/src/agent/plan/configSection.ts +++ b/packages/agent-core-v2/src/agent/plan/configSection.ts @@ -4,7 +4,7 @@ * Top-level boolean preference (`default_plan_mode` on disk, v1-compatible): * when `true`, every freshly created session starts in plan mode. Resumed / * forked sessions restore plan state from wire records and ignore this. Read by - * `workspaceHandler` at session creation; runtime plan state lives on the wire + * `sessionLifecycle` at session creation; runtime plan state lives on the wire * `PlanModel`, not here. */ diff --git a/packages/agent-core-v2/src/agent/plan/planOps.ts b/packages/agent-core-v2/src/agent/plan/planOps.ts index 31087362f6..1781af0184 100644 --- a/packages/agent-core-v2/src/agent/plan/planOps.ts +++ b/packages/agent-core-v2/src/agent/plan/planOps.ts @@ -26,7 +26,7 @@ * `agent.status.updated` planMode slice — are NOT part of `apply`: they run * after `wire.dispatch` on the live path, and `wire.replay` rebuilds the * Model silently from the persisted `plan_mode.*` / `plan.revision` records - * (seeded by `workspaceHandler`). The legacy `toReplay: plan_updated` + * (seeded by `sessionLifecycle`). The legacy `toReplay: plan_updated` * projection is dropped (inert — nothing reads it). `plan.revision` carries * a `toEvent` so the live transcript projector can map it onto a marker plus * the plan badge; replay never emits it. Consumed by the Agent-scope diff --git a/packages/agent-core-v2/src/agent/state/agentState.ts b/packages/agent-core-v2/src/agent/state/agentState.ts index e0d513f549..036866e2ad 100644 --- a/packages/agent-core-v2/src/agent/state/agentState.ts +++ b/packages/agent-core-v2/src/agent/state/agentState.ts @@ -5,8 +5,9 @@ * services declare their plain-data state as typed keys (`defineState` from * `_base`) and read/write them through this container, so per-agent shared * state lives in one observable place and dies with the agent. Shares the - * `IStateRegistry` method set with its App/Session counterparts. Bound at - * Agent scope. + * `IStateRegistry` method set with its App/Workspace/Session counterparts; + * its `inspect()` cascade continues into the Session tier. Bound at Agent + * scope. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/agent/state/agentStateService.ts b/packages/agent-core-v2/src/agent/state/agentStateService.ts index 564f18a923..bfa590f9a6 100644 --- a/packages/agent-core-v2/src/agent/state/agentStateService.ts +++ b/packages/agent-core-v2/src/agent/state/agentStateService.ts @@ -2,17 +2,26 @@ * `state` domain (L1) — `IAgentStateService` implementation. * * Thin per-scope binding over the `_base` `StateRegistry`; the container owns - * construction and disposal, so registered state dies with the scope. Bound - * at Agent scope. + * construction and disposal, so registered state dies with the scope. Injects + * the Session-tier state service as its `inspect()` cascade parent (the + * parameter is optional so tests can construct a bare container; DI always + * injects). Bound at Agent scope. */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { StateRegistry } from '#/_base/state/stateRegistry'; +import { ISessionStateService } from '#/session/state/sessionState'; import { IAgentStateService } from './agentState'; export class AgentStateService extends StateRegistry implements IAgentStateService { declare readonly _serviceBrand: undefined; + protected override readonly inspectScope = 'agent'; + + constructor(@ISessionStateService sessionState?: ISessionStateService) { + super(); + this.inspectParent = sessionState; + } } registerScopedService( diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts index 13dd69b334..30b3436647 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts @@ -6,7 +6,7 @@ * `scope(name)` values and `configKey` are computed once at construction so * business code can read them synchronously. Session/agent persistence * addressing is NOT here — it derives from the workspace handler's - * persistence scope (`workspaceHandler` addressing). + * persistence scope (`sessionLifecycle` addressing). * * Bound at App scope. */ diff --git a/packages/agent-core-v2/src/app/gateway/gateway.ts b/packages/agent-core-v2/src/app/gateway/gateway.ts index 80dab0fbe4..5e255e66bf 100644 --- a/packages/agent-core-v2/src/app/gateway/gateway.ts +++ b/packages/agent-core-v2/src/app/gateway/gateway.ts @@ -3,7 +3,7 @@ * * Defines the public contracts of the gateway layer: the `IRestGateway` / * `IWSGateway` entry points. Session scope creation is owned by the workspace - * handler (`workspaceHandler`); the gateway resolves sessions through the live + * handler (`sessionLifecycle`); the gateway resolves sessions through the live * handler registry (`workspaceLifecycle`). * App-scoped — shared across the application. */ diff --git a/packages/agent-core-v2/src/app/gateway/gatewayService.ts b/packages/agent-core-v2/src/app/gateway/gatewayService.ts index 8fec17b534..c20f4e1809 100644 --- a/packages/agent-core-v2/src/app/gateway/gatewayService.ts +++ b/packages/agent-core-v2/src/app/gateway/gatewayService.ts @@ -2,7 +2,7 @@ * `gateway` domain (L7) — `IRestGateway` / `IWSGateway` implementations. * * Owns the REST/WS entry points; resolves sessions through the live handler - * registry (`workspaceLifecycle` → the handler's `IWorkspaceHandlerService`), + * registry (`workspaceLifecycle` → the handler's `ISessionLifecycleService`), * agents through `agentLifecycle`, drives turns through `prompt` / `loop`, * and flushes logs through `log`. Bound at App scope. * @@ -20,7 +20,7 @@ import { import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ILogService } from '#/_base/log/log'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { IAgentLoopService } from '#/agent/loop/loop'; @@ -45,7 +45,7 @@ export class RestGateway implements IRestGateway { private liveSession(sessionId: string) { for (const handler of this.workspaceLifecycle.handlers.list()) { - const handle = handler.accessor.get(IWorkspaceHandlerService).get(sessionId); + const handle = handler.accessor.get(ISessionLifecycleService).get(sessionId); if (handle !== undefined) return handle; } return undefined; diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index 7024d1ef11..a1fc8b565c 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -2,7 +2,7 @@ * `sessionExport` domain (L6) — `ISessionExportService` implementation. * * Coordinates live session flushing through the live handler registry - * (`workspaceLifecycle` → the handler's `IWorkspaceHandlerService`), derives + * (`workspaceLifecycle` → the handler's `ISessionLifecycleService`), derives * session paths from the handler-chain addressing, reads persisted summaries * through `sessionIndex`, and packages diagnostic files through the local * zip writer. Bound at App scope. @@ -22,8 +22,8 @@ import { IWorkspaceService } from '#/app/workspace/workspace'; import { sessionDirOf, workspacePersistenceScope, -} from '#/workspace/workspaceHandler/addressing'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +} from '#/workspace/sessionLifecycle/addressing'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; @@ -151,7 +151,7 @@ export class SessionExportService implements ISessionExportService { private liveSession(sessionId: string): ISessionScopeHandle | undefined { for (const handler of this.workspaceLifecycle.handlers.list()) { - const handle = handler.accessor.get(IWorkspaceHandlerService).get(sessionId); + const handle = handler.accessor.get(ISessionLifecycleService).get(sessionId); if (handle !== undefined) return handle; } return undefined; diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index 743e85717c..2c196b6b91 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -5,7 +5,7 @@ * query facade over the set of persisted sessions (open or closed). It * enumerates sessions and derives session identity (`workspaceId`), returning * data (`SessionSummary`) or counts — never filesystem paths or live handles. - * Writes (create / archive) live in `workspaceHandler` / `session`; the index + * Writes (create / archive) live in `sessionLifecycle` / `session`; the index * is a read model. Backends are deployment-specific (local filesystem today; * database / query store on a server). */ diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts index 555969dede..3886beff1d 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacy.ts @@ -5,12 +5,12 @@ * metadata merge, and the cross-domain `agent_config` patch), * `GET /sessions/{id}/status` (`status`), and `GET /sessions/{id}/goal` * (`goal`) on top of the native v2 services - * (`IWorkspaceHandlerService`, `IAgentProfileService`, …). + * (`ISessionLifecycleService`, `IAgentProfileService`, …). * * The thin pass-through actions (`fork` / `compact` / `abort` / `archive`), the * `:undo` action, and the `/sessions/{id}/children` endpoints are deliberately * NOT wrapped here: the edge route calls the native services directly — - * `IWorkspaceHandlerService.fork` / `archive` / `createChild`, + * `ISessionLifecycleService.fork` / `archive` / `createChild`, * `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`, * `IAgentPromptService.undo`, and `ISessionIndex.list({ childOf })` — because * none of them carries v1-only projection worth centralizing beyond what the diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 980929d51a..d5e833b51d 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -4,13 +4,13 @@ * Stateless App-scope dispatcher: each method resolves the target session (and * its main agent) per call through the shared `sessionLookup` composition * (`sessionIndex` → `workspaceLifecycle.handlerFor` → the handler's - * `IWorkspaceHandlerService`), delegates to the native v2 services, and projects + * `ISessionLifecycleService`), delegates to the native v2 services, and projects * the result into the v1 wire shape. Only `updateProfile` (the cross-domain * `agent_config` patch), `status` (the best-effort status rollup), and `goal` * (the current-goal read) live here; * the `:undo`, `fork`-as-child, and child-listing actions were pushed down into * the native services (`IAgentPromptService.undo`, - * `IWorkspaceHandlerService.createChild`, `ISessionIndex.list({ childOf })`) and + * `ISessionLifecycleService.createChild`, `ISessionIndex.list({ childOf })`) and * are called by the edge route directly. No business logic is duplicated here; * the real work stays in the native services. */ diff --git a/packages/agent-core-v2/src/app/state/state.ts b/packages/agent-core-v2/src/app/state/appState.ts similarity index 53% rename from packages/agent-core-v2/src/app/state/state.ts rename to packages/agent-core-v2/src/app/state/appState.ts index 36ff553bce..54fee375e5 100644 --- a/packages/agent-core-v2/src/app/state/state.ts +++ b/packages/agent-core-v2/src/app/state/appState.ts @@ -1,20 +1,21 @@ /** * `state` domain (L1) — App-scope keyed state container contract. * - * Defines `IStateService`, the App-scope state service: App-tier services + * Defines `IAppStateService`, the App-scope state service: App-tier services * declare their plain-data state as typed keys (`defineState` from `_base`) * and read/write them through this container, so process-wide shared state * lives in one observable place instead of scattering across private fields. - * Shares the `IStateRegistry` method set with its Session/Agent counterparts. - * Bound at App scope. + * Shares the `IStateRegistry` method set with its Workspace/Session/Agent + * counterparts and is the root of the four-tier `inspect()` cascade (no + * parent). Bound at App scope. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { IStateRegistry } from '#/_base/state/stateRegistry'; -export interface IStateService extends IStateRegistry { +export interface IAppStateService extends IStateRegistry { readonly _serviceBrand: undefined; } -export const IStateService: ServiceIdentifier = - createDecorator('stateService'); +export const IAppStateService: ServiceIdentifier = + createDecorator('appStateService'); diff --git a/packages/agent-core-v2/src/app/state/appStateService.ts b/packages/agent-core-v2/src/app/state/appStateService.ts new file mode 100644 index 0000000000..4d2a83663e --- /dev/null +++ b/packages/agent-core-v2/src/app/state/appStateService.ts @@ -0,0 +1,26 @@ +/** + * `state` domain (L1) — `IAppStateService` implementation. + * + * Thin per-scope binding over the `_base` `StateRegistry`; the container owns + * construction and disposal, so registered state dies with the scope. The + * root of the four-tier `inspect()` cascade — the only tier without an + * `inspectParent`. Bound at App scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { StateRegistry } from '#/_base/state/stateRegistry'; + +import { IAppStateService } from './appState'; + +export class AppStateService extends StateRegistry implements IAppStateService { + declare readonly _serviceBrand: undefined; + protected override readonly inspectScope = 'app'; +} + +registerScopedService( + LifecycleScope.App, + IAppStateService, + AppStateService, + ScopeActivation.OnScopeCreated, + 'state', +); diff --git a/packages/agent-core-v2/src/app/state/stateService.ts b/packages/agent-core-v2/src/app/state/stateService.ts deleted file mode 100644 index fd2dfb318d..0000000000 --- a/packages/agent-core-v2/src/app/state/stateService.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * `state` domain (L1) — `IStateService` implementation. - * - * Thin per-scope binding over the `_base` `StateRegistry`; the container owns - * construction and disposal, so registered state dies with the scope. Bound - * at App scope. - */ - -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { StateRegistry } from '#/_base/state/stateRegistry'; - -import { IStateService } from './state'; - -export class StateService extends StateRegistry implements IStateService { - declare readonly _serviceBrand: undefined; -} - -registerScopedService(LifecycleScope.App, IStateService, StateService, ScopeActivation.OnScopeCreated, 'state'); diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts index 3249ecd2f3..aa1cdd5e7b 100644 --- a/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/sessionLookup.ts @@ -2,7 +2,7 @@ * `workspaceLifecycle` domain (L6) — pure session-lookup helpers over the handler chain. * * The explicit `sessionIndex` → `IWorkspaceLifecycleService.handlerFor` → - * handler `IWorkspaceHandlerService` composition, shared by every caller + * handler `ISessionLifecycleService` composition, shared by every caller * that addresses a session by id from outside the Workspace scope (edge * routes, in-process SDKs). These are plain functions over a STABLE * accessor (a `Scope` / scope-handle `accessor`, never a transient @@ -18,9 +18,9 @@ import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, isError2 } from '#/errors'; import { - IWorkspaceHandlerService, + ISessionLifecycleService, type ResumeSessionOptions, -} from '#/workspace/workspaceHandler/workspaceHandler'; +} from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IWorkspaceLifecycleService } from './workspaceLifecycle'; @@ -70,7 +70,7 @@ export async function resumeSessionById( throw error; } if (handler === undefined) return undefined; - return handler.accessor.get(IWorkspaceHandlerService).resume(sessionId, opts); + return handler.accessor.get(ISessionLifecycleService).resume(sessionId, opts); } /** The live handler holding `sessionId`, without materializing anything. */ @@ -79,7 +79,7 @@ export function liveHandlerForSession( sessionId: string, ): IWorkspaceScopeHandle | undefined { for (const handler of accessor.get(IWorkspaceLifecycleService).handlers.list()) { - if (handler.accessor.get(IWorkspaceHandlerService).get(sessionId) !== undefined) { + if (handler.accessor.get(ISessionLifecycleService).get(sessionId) !== undefined) { return handler; } } @@ -92,7 +92,7 @@ export function getLiveSessionById( sessionId: string, ): ISessionScopeHandle | undefined { return liveHandlerForSession(accessor, sessionId)?.accessor - .get(IWorkspaceHandlerService) + .get(ISessionLifecycleService) .get(sessionId); } @@ -103,28 +103,28 @@ export async function closeSessionById( ): Promise { const handler = liveHandlerForSession(accessor, sessionId); if (handler === undefined) return; - await handler.accessor.get(IWorkspaceHandlerService).close(sessionId); + await handler.accessor.get(ISessionLifecycleService).close(sessionId); } /** - * Subscribe `follow` to the `IWorkspaceHandlerService` of every handler — + * Subscribe `follow` to the `ISessionLifecycleService` of every handler — * present and future (handlers are never closed, so subscriptions stay * valid for the App lifetime). For App-scope observers of per-handler * events (e.g. `onDidCloseSession`). */ export function followWorkspaceHandlers( accessor: ServicesAccessor, - follow: (service: IWorkspaceHandlerService) => IDisposable, + follow: (service: ISessionLifecycleService) => IDisposable, ): IDisposable { const lifecycle = accessor.get(IWorkspaceLifecycleService); const store = new DisposableStore(); for (const handler of lifecycle.handlers.list()) { - store.add(follow(handler.accessor.get(IWorkspaceHandlerService))); + store.add(follow(handler.accessor.get(ISessionLifecycleService))); } store.add( lifecycle.onDidMaterializeHandler((handler) => { if (!store.isDisposed) { - store.add(follow(handler.accessor.get(IWorkspaceHandlerService))); + store.add(follow(handler.accessor.get(ISessionLifecycleService))); } }), ); diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts index 01ab161954..d9595b96b6 100644 --- a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycle.ts @@ -14,7 +14,7 @@ * `onDidMaterializeHandler` for App-scope observers that must follow every * handler's per-handler services. There is deliberately NO App-scope * session lifecycle entry point — session create/resume/fork lives on the - * handler's `IWorkspaceHandlerService`; callers compose `sessionIndex` → + * handler's `ISessionLifecycleService`; callers compose `sessionIndex` → * `handlerFor` → handler (see `sessionLookup`). */ diff --git a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts index 744fb61f9c..400afb9c6d 100644 --- a/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts +++ b/packages/agent-core-v2/src/app/workspaceLifecycle/workspaceLifecycleService.ts @@ -36,8 +36,8 @@ import { workspaceContextSeed, type IWorkspaceContext, } from '#/workspace/workspaceContext/workspaceContext'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; -import { workspacePersistenceScope } from '#/workspace/workspaceHandler/addressing'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; +import { workspacePersistenceScope } from '#/workspace/sessionLifecycle/addressing'; import { IWorkspaceLifecycleService, @@ -68,7 +68,7 @@ export class WorkspaceLifecycleService extends Disposable implements IWorkspaceL const handler = this.live.get(workspaceId); if (handler === undefined) return []; return handler.accessor - .get(IWorkspaceHandlerService) + .get(ISessionLifecycleService) .list() .map((session) => session.id); }, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 88391356e9..bbfe4a348e 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -66,8 +66,10 @@ export { IEventBus, type DomainEvent } from '#/app/event/eventBus'; export { IEventService, type DomainEvent as GlobalEvent } from '#/app/event/event'; export * from '#/_base/state/stateRegistry'; export * from '#/_base/contribution/registry'; -export * from '#/app/state/state'; -import '#/app/state/stateService'; +export * from '#/app/state/appState'; +import '#/app/state/appStateService'; +export * from '#/workspace/state/workspaceState'; +import '#/workspace/state/workspaceStateService'; export * from '#/session/state/sessionState'; import '#/session/state/sessionStateService'; export * from '#/agent/state/agentState'; @@ -358,9 +360,9 @@ export * from '#/app/workspaceLifecycle/workspaceLifecycle'; export * from '#/app/workspaceLifecycle/workspaceLifecycleService'; export * from '#/app/workspaceLifecycle/sessionLookup'; export * from '#/workspace/workspaceContext/workspaceContext'; -export * from '#/workspace/workspaceHandler/workspaceHandler'; -export * from '#/workspace/workspaceHandler/workspaceHandlerService'; -export * from '#/workspace/workspaceHandler/addressing'; +export * from '#/workspace/sessionLifecycle/sessionLifecycle'; +export * from '#/workspace/sessionLifecycle/sessionLifecycleService'; +export * from '#/workspace/sessionLifecycle/addressing'; export * from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; export * from '#/session/externalHooks/externalHooks'; export * from '#/session/externalHooks/externalHooksService'; diff --git a/packages/agent-core-v2/src/os/interface/hostEnvironment.ts b/packages/agent-core-v2/src/os/interface/hostEnvironment.ts index ec024f33b3..4d0ea45efe 100644 --- a/packages/agent-core-v2/src/os/interface/hostEnvironment.ts +++ b/packages/agent-core-v2/src/os/interface/hostEnvironment.ts @@ -11,7 +11,7 @@ * * Async initialization: probing (`ready`) discovers the shell path — on * Windows this may run `git.exe --exec-path`. The composition root - * (`workspaceLifecycle` / `workspaceHandler`) `await`s `ready` before creating + * (`workspaceLifecycle` / `sessionLifecycle`) `await`s `ready` before creating * any Session scope, so * every Session/Agent-scope consumer reads the sync fields safely. * diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts index 17e4c55672..4d81f6f56e 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts @@ -12,7 +12,7 @@ * and returns an already-created main agent as-is — so concurrent * bootstrappers always receive the same, fully-bootstrapped handle (activity * lane `idle`). Session services activated when their scope is created (cron, - * external hooks) are materialized by `workspaceHandler.materializeSession`; + * external hooks) are materialized by `sessionLifecycle.materializeSession`; * the default permission posture is * applied in `bindBootstrap`. * diff --git a/packages/agent-core-v2/src/session/errors.ts b/packages/agent-core-v2/src/session/errors.ts index d39a72fc1d..5413a89c54 100644 --- a/packages/agent-core-v2/src/session/errors.ts +++ b/packages/agent-core-v2/src/session/errors.ts @@ -1,6 +1,6 @@ /** * `session` domain error codes — shared across the session layer - * (`workspaceHandler` / `sessionLegacy` / `messageLegacy`). + * (`sessionLifecycle` / `sessionLegacy` / `messageLegacy`). */ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; diff --git a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts index f31af7adc0..876748dfa9 100644 --- a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts @@ -3,7 +3,7 @@ * commands. * * Registers with the per-session `sessionLifecycleHooks` slots (seeded by - * the Workspace-scope `workspaceHandler`, which runs them around + * the Workspace-scope `sessionLifecycle`, which runs them around * create/close) to run `SessionStart` and `SessionEnd` external commands * for the current `sessionContext`, and * observes the requester-side agent-run hook slot (`onWillStartAgentTask`) and diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts b/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts index 1f40ac1a34..ee5ccd74eb 100644 --- a/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts +++ b/packages/agent-core-v2/src/session/mcp/sessionMcpHandle.ts @@ -8,7 +8,7 @@ * connections exist) plus the initial-connect readiness promise. The contract * carries no IO of its own — connecting, reloading and watching MCP config * files live on the workspace side. Seeded into the Session scope by - * `workspaceHandler` when the session is materialized; the Agent-scope `mcp` + * `sessionLifecycle` when the session is materialized; the Agent-scope `mcp` * mirror resolves it upward through the scope tree. Session-scoped. */ diff --git a/packages/agent-core-v2/src/session/process/processRunnerService.ts b/packages/agent-core-v2/src/session/process/processRunnerService.ts index cc2aaafa39..c9b7a22bee 100644 --- a/packages/agent-core-v2/src/session/process/processRunnerService.ts +++ b/packages/agent-core-v2/src/session/process/processRunnerService.ts @@ -11,7 +11,7 @@ * This Session-scope registration is the DEFAULT for scopes built without a * workspace handler (test hosts, harness agents). Real sessions get the * handler-shared Workspace-scope runner (`workspaceProcess`) as a scope seed - * from `workspaceHandler`, which shadows this registration — same pattern as + * from `sessionLifecycle`, which shadows this registration — same pattern as * the other workspace-capability injection contracts. */ diff --git a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts index 5e8db0716e..e3498508de 100644 --- a/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts +++ b/packages/agent-core-v2/src/session/sessionAgentProfileCatalog/agentProfileCatalogSeed.ts @@ -10,7 +10,7 @@ * view is seeded anymore. The key travels as a seed (rather than being * recomputed from the session's workDir) because the handler's id may be * folded from an alias spelling of the root. Seeded into the Session scope by - * `workspaceHandler` when the session is materialized. Session-scoped. + * `sessionLifecycle` when the session is materialized. Session-scoped. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts b/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts index 2b6704029b..8dd6ced37b 100644 --- a/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts +++ b/packages/agent-core-v2/src/session/sessionContext/sessionContext.ts @@ -7,7 +7,7 @@ * `scope(subKey?)` * helper that returns the session's persistence scope (or a child under it, * e.g. `scope('agents/main/cron')`). Seeded into the Session scope by - * `workspaceHandler` when the session is created. + * `sessionLifecycle` when the session is created. * * `cwd` is the default root the `process` runner spawns in and the seed the * `workspaceContext` derives its read-only `workDir` from. Pure facts — no diff --git a/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts b/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts index 183b6301cb..a033942d95 100644 --- a/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts +++ b/packages/agent-core-v2/src/session/sessionInstructions/instructionsProvider.ts @@ -8,7 +8,7 @@ * watched instruction file invalidates the snapshot. The contract carries no * IO — loading and watching live on the workspace side; consumers (the * agent's `profile` service) read the seed and re-read it off `onDidChange`. - * Seeded into the Session scope by `workspaceHandler` when the session is + * Seeded into the Session scope by `sessionLifecycle` when the session is * materialized. Session-scoped. */ diff --git a/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts b/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts index be9d54975a..d6a32aa74f 100644 --- a/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts +++ b/packages/agent-core-v2/src/session/sessionLifecycleHooks/sessionLifecycleHooks.ts @@ -2,7 +2,7 @@ * `sessionLifecycleHooks` domain (L1) — per-session lifecycle hook slots. * * Defines the `ISessionLifecycleHooks` seed: one ordered hook-slots instance - * per session, created by the Workspace-scope `workspaceHandler` when it + * per session, created by the Workspace-scope `sessionLifecycle` when it * materializes the session, seeded into the Session scope, and run by the * handler around the session's create (`onDidCreateSession`) and close * (`onWillCloseSession`). Session-scope consumers (e.g. `externalHooks`) diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts index c9e353ca37..2761c2a5b2 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogData.ts @@ -7,7 +7,7 @@ * source-keyed change event. The contract carries no IO — discovery, merging * and rescanning all live on the workspace side; the Session-scope * `ISessionSkillCatalog` business view reads this seed and refreshes itself - * off `onDidChange`. Seeded into the Session scope by `workspaceHandler` when + * off `onDidChange`. Seeded into the Session scope by `sessionLifecycle` when * the session is materialized. Session-scoped. */ diff --git a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts index d089e7e4fb..76ce203c5e 100644 --- a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts +++ b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts @@ -8,7 +8,7 @@ * workspace config live on the workspace side; the Agent-scope `toolPolicy` * and `toolActivation` read this seed and apply the veto (it outranks every * Agent-side policy layer). Seeded into the Session scope by - * `workspaceHandler` when the session is materialized; a no-op default + * `sessionLifecycle` when the session is materialized; a no-op default * registration keeps scopes built without a handler (tests) resolvable. * Session-scoped. */ diff --git a/packages/agent-core-v2/src/session/state/sessionState.ts b/packages/agent-core-v2/src/session/state/sessionState.ts index 33d0ddffcc..2874d36814 100644 --- a/packages/agent-core-v2/src/session/state/sessionState.ts +++ b/packages/agent-core-v2/src/session/state/sessionState.ts @@ -5,8 +5,9 @@ * Session-tier services declare their plain-data state as typed keys * (`defineState` from `_base`) and read/write them through this container, so * per-session shared state lives in one observable place and dies with the - * session. Shares the `IStateRegistry` method set with its App/Agent - * counterparts. Bound at Session scope. + * session. Shares the `IStateRegistry` method set with its + * App/Workspace/Agent counterparts; its `inspect()` cascade continues into + * the Workspace tier. Bound at Session scope. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/session/state/sessionStateService.ts b/packages/agent-core-v2/src/session/state/sessionStateService.ts index 1625cea246..54d0a5ccd7 100644 --- a/packages/agent-core-v2/src/session/state/sessionStateService.ts +++ b/packages/agent-core-v2/src/session/state/sessionStateService.ts @@ -2,17 +2,26 @@ * `state` domain (L1) — `ISessionStateService` implementation. * * Thin per-scope binding over the `_base` `StateRegistry`; the container owns - * construction and disposal, so registered state dies with the scope. Bound - * at Session scope. + * construction and disposal, so registered state dies with the scope. Injects + * the Workspace-tier state service as its `inspect()` cascade parent (the + * parameter is optional so tests can construct a bare container; DI always + * injects). Bound at Session scope. */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { StateRegistry } from '#/_base/state/stateRegistry'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { ISessionStateService } from './sessionState'; export class SessionStateService extends StateRegistry implements ISessionStateService { declare readonly _serviceBrand: undefined; + protected override readonly inspectScope = 'session'; + + constructor(@IWorkspaceStateService workspaceState?: IWorkspaceStateService) { + super(); + this.inspectParent = workspaceState; + } } registerScopedService( diff --git a/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts b/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts index 209397ce1e..6d1b11ea59 100644 --- a/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts +++ b/packages/agent-core-v2/src/session/workspaceInfo/workspaceInfo.ts @@ -8,7 +8,7 @@ * (`.kimi-code/local.toml`), caller-dir merging and file watching all live * on the workspace side; the Session-scope `workspaceContext` read view * reads this seed and refreshes itself off `onDidChange`. Seeded into the - * Session scope by `workspaceHandler` when the session is materialized. + * Session scope by `sessionLifecycle` when the session is materialized. * Session-scoped. */ diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/addressing.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/addressing.ts similarity index 94% rename from packages/agent-core-v2/src/workspace/workspaceHandler/addressing.ts rename to packages/agent-core-v2/src/workspace/sessionLifecycle/addressing.ts index 83cc605a94..fb40bdfaa0 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/addressing.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/addressing.ts @@ -1,5 +1,5 @@ /** - * `workspaceHandler` domain (L6) — persistence addressing along the handler chain. + * `sessionLifecycle` domain (L6) — persistence addressing along the handler chain. * * Pure functions deriving the persistence scope strings and on-disk * directories from the handler's `persistenceScope` (`sessions/{wd_id}`): diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts similarity index 92% rename from packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts rename to packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts index a97aa1c9a1..d81b92711e 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandler.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts @@ -1,9 +1,9 @@ /** - * `workspaceHandler` domain (L6) — per-handler session lifecycle contract. + * `sessionLifecycle` domain (L6) — per-handler session lifecycle contract. * * Defines the public contract of one workspace handler: the * `CreateSessionOptions`, `ForkSessionOptions`, `CreateChildSessionOptions`, - * `ResumeSessionOptions`, and the `IWorkspaceHandlerService` used to create + * `ResumeSessionOptions`, and the `ISessionLifecycleService` used to create * sessions (`create`), look up the live ones (`get` / `list`), close them * (`close`), archive/restore them, fork them (`fork`), and fork-then-tag * them as direct children (`createChild`) — always as child scopes of THIS @@ -89,7 +89,7 @@ export interface SessionForkedEvent { readonly handle: ISessionScopeHandle; } -export interface IWorkspaceHandlerService { +export interface ISessionLifecycleService { readonly _serviceBrand: undefined; readonly onDidCreateSession: Event; @@ -107,5 +107,5 @@ export interface IWorkspaceHandlerService { createChild(opts: CreateChildSessionOptions): Promise; } -export const IWorkspaceHandlerService: ServiceIdentifier = - createDecorator('workspaceHandlerService'); +export const ISessionLifecycleService: ServiceIdentifier = + createDecorator('sessionLifecycleService'); diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts similarity index 99% rename from packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts rename to packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 09d18a235f..ee7f5a17b1 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -1,5 +1,5 @@ /** - * `workspaceHandler` domain (L6) — `IWorkspaceHandlerService` implementation. + * `sessionLifecycle` domain (L6) — `ISessionLifecycleService` implementation. * * Owns the registry of THIS handler's open Session child scopes, creating * them through the DI scope tree (children of the handler's Workspace @@ -146,14 +146,14 @@ import { type SessionCreatedEvent, type SessionForkedEvent, type SessionWillCloseEvent, - IWorkspaceHandlerService, -} from './workspaceHandler'; + ISessionLifecycleService, +} from './sessionLifecycle'; type MaterializeSessionOptions = Omit & { readonly sessionId: string; }; -export class WorkspaceHandlerService extends Disposable implements IWorkspaceHandlerService { +export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { declare readonly _serviceBrand: undefined; private readonly sessions = new Map(); private readonly _onDidCreateSession = this._register(new Emitter()); @@ -682,10 +682,10 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan registerScopedService( LifecycleScope.Workspace, - IWorkspaceHandlerService, - WorkspaceHandlerService, + ISessionLifecycleService, + SessionLifecycleService, ScopeActivation.OnScopeCreated, - 'workspaceHandler', + 'sessionLifecycle', ); async function collect(iterable: AsyncIterable): Promise { diff --git a/packages/agent-core-v2/src/workspace/state/workspaceState.ts b/packages/agent-core-v2/src/workspace/state/workspaceState.ts new file mode 100644 index 0000000000..a2bf979bb4 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/state/workspaceState.ts @@ -0,0 +1,21 @@ +/** + * `state` domain (L1) — Workspace-scope keyed state container contract. + * + * Defines `IWorkspaceStateService`, the Workspace-scope state service: + * Workspace-tier services declare their plain-data state as typed keys + * (`defineState` from `_base`) and read/write them through this container, so + * per-handler shared state lives in one observable place and dies with the + * workspace handler. Shares the `IStateRegistry` method set with its + * App/Session/Agent counterparts; its `inspect()` cascade continues into the + * App tier. Bound at Workspace scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IStateRegistry } from '#/_base/state/stateRegistry'; + +export interface IWorkspaceStateService extends IStateRegistry { + readonly _serviceBrand: undefined; +} + +export const IWorkspaceStateService: ServiceIdentifier = + createDecorator('workspaceStateService'); diff --git a/packages/agent-core-v2/src/workspace/state/workspaceStateService.ts b/packages/agent-core-v2/src/workspace/state/workspaceStateService.ts new file mode 100644 index 0000000000..6622952151 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/state/workspaceStateService.ts @@ -0,0 +1,33 @@ +/** + * `state` domain (L1) — `IWorkspaceStateService` implementation. + * + * Thin per-scope binding over the `_base` `StateRegistry`; the container owns + * construction and disposal, so registered state dies with the scope. Injects + * the App-tier state service as its `inspect()` cascade parent (the parameter + * is optional so tests can construct a bare container; DI always injects). + * Bound at Workspace scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { StateRegistry } from '#/_base/state/stateRegistry'; +import { IAppStateService } from '#/app/state/appState'; + +import { IWorkspaceStateService } from './workspaceState'; + +export class WorkspaceStateService extends StateRegistry implements IWorkspaceStateService { + declare readonly _serviceBrand: undefined; + protected override readonly inspectScope = 'workspace'; + + constructor(@IAppStateService appState?: IAppStateService) { + super(); + this.inspectParent = appState; + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceStateService, + WorkspaceStateService, + ScopeActivation.OnScopeCreated, + 'state', +); diff --git a/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts b/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts index 40c7a2db28..af8ef6c40c 100644 --- a/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts +++ b/packages/agent-core-v2/test/_base/state/stateRegistry.test.ts @@ -9,8 +9,10 @@ import { import { createScopedTestHost, type ScopedTestHost } from '#/_base/di/test'; import { BugIndicatingError } from '#/_base/errors/errors'; import { defineState, StateRegistry, type StateChange } from '#/_base/state/stateRegistry'; -import { IStateService } from '#/app/state/state'; -import { StateService } from '#/app/state/stateService'; +import { IAppStateService } from '#/app/state/appState'; +import { AppStateService } from '#/app/state/appStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -168,7 +170,20 @@ describe('state services (scoped)', () => { beforeEach(() => { _clearScopedRegistryForTests(); - registerScopedService(LifecycleScope.App, IStateService, StateService, ScopeActivation.OnScopeCreated, 'state'); + registerScopedService( + LifecycleScope.App, + IAppStateService, + AppStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceStateService, + WorkspaceStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); registerScopedService( LifecycleScope.Session, ISessionStateService, @@ -188,30 +203,80 @@ describe('state services (scoped)', () => { afterEach(() => host.dispose()); + function createChain() { + const workspace = host.child(LifecycleScope.Workspace, 'w1'); + const session = host.childOf(workspace, LifecycleScope.Session, 's1'); + const agent = host.childOf(session, LifecycleScope.Agent, 'main'); + return { workspace, session, agent }; + } + it('resolves a distinct state service per scope tier', () => { - const appState = host.app.accessor.get(IStateService); - const session = host.child(LifecycleScope.Session, 's1'); + const appState = host.app.accessor.get(IAppStateService); + const { workspace, session, agent } = createChain(); + const workspaceState = workspace.accessor.get(IWorkspaceStateService); const sessionState = session.accessor.get(ISessionStateService); - const agent = host.childOf(session, LifecycleScope.Agent, 'main'); const agentState = agent.accessor.get(IAgentStateService); - expect(appState).not.toBe(sessionState); + expect(appState).not.toBe(workspaceState); + expect(workspaceState).not.toBe(sessionState); expect(sessionState).not.toBe(agentState); }); it('keeps registered state invisible to sibling scope tiers', () => { const sessionKey = defineState('test.sessionOnly', () => 'seed'); - const session = host.child(LifecycleScope.Session, 's1'); + const { workspace, session, agent } = createChain(); const sessionState = session.accessor.get(ISessionStateService); sessionState.register(sessionKey); sessionState.set(sessionKey, 'live'); expect(sessionState.get(sessionKey)).toBe('live'); - const agent = host.childOf(session, LifecycleScope.Agent, 'main'); expect(agent.accessor.get(IAgentStateService).has(sessionKey)).toBe(false); - expect(host.app.accessor.get(IStateService).has(sessionKey)).toBe(false); + expect(workspace.accessor.get(IWorkspaceStateService).has(sessionKey)).toBe(false); + expect(host.app.accessor.get(IAppStateService).has(sessionKey)).toBe(false); }); it('resolves the same instance within one scope', () => { - const session = host.child(LifecycleScope.Session, 's1'); + const { session } = createChain(); expect(session.accessor.get(ISessionStateService)).toBe(session.accessor.get(ISessionStateService)); }); + + it('omits the parent link when a registry has no cascade parent', () => { + const loneKey = defineState('test.lone', () => 0); + const registry = new StateRegistry(); + registry.register(loneKey); + expect(registry.inspect()).toEqual({ + scope: 'unknown', + state: { 'test.lone': 0 }, + parent: undefined, + }); + }); + + it('cascades inspect from the agent tier up to the app root', () => { + const appKey = defineState('test.appOnly', () => 'a'); + const workspaceKey = defineState('test.workspaceOnly', () => 'w'); + const sessionKey = defineState('test.sessionCascade', () => 's'); + const agentKey = defineState('test.agentOnly', () => 'g'); + host.app.accessor.get(IAppStateService).register(appKey); + const { workspace, session, agent } = createChain(); + workspace.accessor.get(IWorkspaceStateService).register(workspaceKey); + session.accessor.get(ISessionStateService).register(sessionKey); + const agentState = agent.accessor.get(IAgentStateService); + agentState.register(agentKey); + + expect(agentState.inspect()).toEqual({ + scope: 'agent', + state: { 'test.agentOnly': 'g' }, + parent: { + scope: 'session', + state: { 'test.sessionCascade': 's' }, + parent: { + scope: 'workspace', + state: { 'test.workspaceOnly': 'w' }, + parent: { + scope: 'app', + state: { 'test.appOnly': 'a' }, + parent: undefined, + }, + }, + }, + }); + }); }); diff --git a/packages/agent-core-v2/test/app/gateway/gateway.test.ts b/packages/agent-core-v2/test/app/gateway/gateway.test.ts index f5fe1623b4..b7316d4f8b 100644 --- a/packages/agent-core-v2/test/app/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/app/gateway/gateway.test.ts @@ -12,7 +12,7 @@ import { RestGateway } from '#/app/gateway/gatewayService'; import { ILogService } from '#/_base/log/log'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IAgentLoopService } from '#/agent/loop/loop'; import { createHooks } from '#/hooks'; import { stubLog } from '../../_base/log/stubs'; @@ -88,7 +88,7 @@ describe('RestGateway', () => { dispose: () => {}, }; - const handlerService: IWorkspaceHandlerService = { + const sessionLifecycle: ISessionLifecycleService = { _serviceBrand: undefined, onDidCreateSession: () => ({ dispose: () => {} }), onDidCloseSession: () => ({ dispose: () => {} }), @@ -107,7 +107,7 @@ describe('RestGateway', () => { const handlerHandle = { id: 'wd_stub', kind: LifecycleScope.Workspace, - accessor: makeAccessor([[IWorkspaceHandlerService, handlerService]]), + accessor: makeAccessor([[ISessionLifecycleService, sessionLifecycle]]), dispose: () => {}, } as const; ix.stub(IWorkspaceLifecycleService, { diff --git a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts b/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts index 5d23169b6a..f31118c6eb 100644 --- a/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts +++ b/packages/agent-core-v2/test/app/messageLegacy/messageLegacy.test.ts @@ -12,7 +12,7 @@ import { type IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IWireService } from '#/wire/wire'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionCronService } from '#/session/cron/sessionCronService'; @@ -81,7 +81,7 @@ function buildService(opts: { kind: LifecycleScope.Workspace, accessor: { get: (token: unknown): unknown => { - if (token === IWorkspaceHandlerService) { + if (token === ISessionLifecycleService) { return { resume: (sessionId: string) => Promise.resolve(sessionId === opts.summary.id ? sessionHandle : undefined), diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index dca3deefc7..f3fa2039f8 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -44,7 +44,7 @@ import { import { writeExportZip } from '#/app/sessionExport/zip'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IWorkspaceService } from '#/app/workspace/workspace'; import { Error2 } from '#/errors'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -910,7 +910,7 @@ function registerSessionExportServices( kind: LifecycleScope.Workspace, accessor: accessorFrom([ [ - IWorkspaceHandlerService, + ISessionLifecycleService, { _serviceBrand: undefined, onDidCreateSession: noopEvent, @@ -932,7 +932,7 @@ function registerSessionExportServices( createChild: async () => { throw new Error('createChild should not be called by session export'); }, - } satisfies IWorkspaceHandlerService, + } satisfies ISessionLifecycleService, ], ]), dispose: () => {}, diff --git a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts index e570026320..a3a0a9d8ab 100644 --- a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts +++ b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts @@ -25,7 +25,7 @@ import { ISessionLegacyService } from '#/app/sessionLegacy/sessionLegacy'; import { SessionLegacyService } from '#/app/sessionLegacy/sessionLegacyService'; import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IAgentActivityView } from '#/agent/activityView/activityView'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -52,7 +52,7 @@ function stubSessionChain(ix: TestInstantiationService, session: ISessionScopeHa kind: LifecycleScope.Workspace, accessor: accessor([ [ - IWorkspaceHandlerService, + ISessionLifecycleService, { resume: () => Promise.resolve(session), get: () => session, diff --git a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts index 49adc95bfe..6401e47778 100644 --- a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts @@ -41,8 +41,8 @@ import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; import { Error2, ErrorCodes } from '#/errors'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; -import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; +import { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService'; import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs'; @@ -254,10 +254,10 @@ describe('WorkspaceLifecycleService', () => { ); registerScopedService( LifecycleScope.Workspace, - IWorkspaceHandlerService, - WorkspaceHandlerService, + ISessionLifecycleService, + SessionLifecycleService, ScopeActivation.OnScopeCreated, - 'workspaceHandler', + 'sessionLifecycle', ); registerScopedService( LifecycleScope.Workspace, @@ -390,7 +390,7 @@ describe('WorkspaceLifecycleService', () => { ]); expect(handlerA).toBe(handlerB); - const sessions = handlerA.accessor.get(IWorkspaceHandlerService); + const sessions = handlerA.accessor.get(ISessionLifecycleService); const [s1, s2] = await Promise.all([ sessions.create({ sessionId: 's1', workDir: '/tmp/proj' }), sessions.create({ sessionId: 's2', workDir: '/tmp/proj' }), @@ -493,7 +493,7 @@ describe('WorkspaceLifecycleService', () => { it('getLiveSessionById finds only live sessions', async () => { const lifecycle = build(); const handler = await lifecycle.handlerFor({ root: '/tmp/proj' }); - const sessions = handler.accessor.get(IWorkspaceHandlerService); + const sessions = handler.accessor.get(ISessionLifecycleService); await sessions.create({ sessionId: 's1', workDir: '/tmp/proj' }); expect(getLiveSessionById(host!.app.accessor, 's1')?.id).toBe('s1'); @@ -508,13 +508,13 @@ describe('WorkspaceLifecycleService', () => { ); const first = await lifecycle.handlerFor({ root: '/tmp/proj' }); - await first.accessor.get(IWorkspaceHandlerService).create({ sessionId: 's1', workDir: '/tmp/proj' }); + await first.accessor.get(ISessionLifecycleService).create({ sessionId: 's1', workDir: '/tmp/proj' }); // Materialized AFTER the follow subscription — still observed. const second = await lifecycle.handlerFor({ root: '/tmp/other' }); - await second.accessor.get(IWorkspaceHandlerService).create({ sessionId: 's2', workDir: '/tmp/other' }); + await second.accessor.get(ISessionLifecycleService).create({ sessionId: 's2', workDir: '/tmp/other' }); - await first.accessor.get(IWorkspaceHandlerService).close('s1'); - await second.accessor.get(IWorkspaceHandlerService).close('s2'); + await first.accessor.get(ISessionLifecycleService).close('s1'); + await second.accessor.get(ISessionLifecycleService).close('s2'); expect(closed.toSorted()).toEqual(['s1', 's2']); sub.dispose(); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 1faa14ee23..68e855533a 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -14,6 +14,7 @@ import { escapeXmlAttr } from '#/_base/utils/xml-escape'; import type { AgentTaskInfo } from '#/agent/task/task'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { AgentBlobServiceImpl } from '#/agent/blob/agentBlobServiceImpl'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { CHECKPOINTED_MODELS, type Checkpointed } from '#/agent/contextMemory/conversationTime'; @@ -123,6 +124,7 @@ import { IAgentLoopContinuationService, IAgentSwarmService, AgentSwarmService, + IAppStateService, ITelemetryService, IHostTerminalService, IAgentToolRegistryService, @@ -130,6 +132,7 @@ import { IAgentUserToolService, IAgentUsageService, ISessionWorkspaceContext, + IWorkspaceStateService, AgentLLMRequesterService, LifecycleScope, AgentMcpService, @@ -1209,6 +1212,13 @@ export class AgentTestContext { additionalDirs: [], onDidChange: Event.None as Event, } satisfies ISessionWorkspaceInfo); + // The harness skips the Workspace scope entirely, so the session + // state service's cascade parent is seeded directly: a workspace + // state instance chained onto the App-scope root. + reg.defineInstance( + IWorkspaceStateService, + new WorkspaceStateService(this.root.accessor.get(IAppStateService)), + ); reg.defineInstance(IAgentLifecycleService, { _serviceBrand: undefined, onDidCreate: Event.None as Event, diff --git a/packages/agent-core-v2/test/session/question/question.test.ts b/packages/agent-core-v2/test/session/question/question.test.ts index e7eb60e711..58cd695558 100644 --- a/packages/agent-core-v2/test/session/question/question.test.ts +++ b/packages/agent-core-v2/test/session/question/question.test.ts @@ -16,6 +16,8 @@ import { type QuestionRequest, ISessionQuestionService } from '#/session/questio import { SessionQuestionService } from '#/session/question/questionService'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; const noopEventBus: IEventBus = { _serviceBrand: undefined, @@ -49,7 +51,9 @@ describe('ISessionQuestionService (Session scope facade over the interaction ker disposables = new DisposableStore(); host = createScopedTestHost([stubPair(IEventBus, noopEventBus)]); - session = host.child(LifecycleScope.Session, 'session-a'); + session = host.child(LifecycleScope.Session, 'session-a', [ + stubPair(IWorkspaceStateService, new WorkspaceStateService()), + ]); }); afterEach(() => { @@ -170,7 +174,9 @@ describe('ISessionQuestionService (Session scope facade over the interaction ker }); it('Session scope isolates brokers: a question parked in A is invisible to B', () => { - const sessionB = host.child(LifecycleScope.Session, 'session-b'); + const sessionB = host.child(LifecycleScope.Session, 'session-b', [ + stubPair(IWorkspaceStateService, new WorkspaceStateService()), + ]); const questionsA = session.accessor.get(ISessionQuestionService); const questionsB = sessionB.accessor.get(ISessionQuestionService); diff --git a/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts b/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts index 782a3b9409..1ca53a71d5 100644 --- a/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts +++ b/packages/agent-core-v2/test/session/sessionActivity/sessionActivityService.test.ts @@ -23,6 +23,8 @@ import { import { SessionActivityView } from '#/session/sessionActivity/sessionActivityService'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; class FakeBus implements IEventBus { declare readonly _serviceBrand: undefined; @@ -151,7 +153,9 @@ describe('ISessionActivityView (Session scope aggregate of agent activity + inte disposables = new DisposableStore(); host = createScopedTestHost(); - session = host.child(LifecycleScope.Session, 'session-a'); + session = host.child(LifecycleScope.Session, 'session-a', [ + stubPair(IWorkspaceStateService, new WorkspaceStateService()), + ]); lifecycle = session.accessor.get(IAgentLifecycleService) as unknown as FakeAgentLifecycle; }); @@ -187,6 +191,7 @@ describe('ISessionActivityView (Session scope aggregate of agent activity + inte main.activity = turnActive(1); const seededSession = host.child(LifecycleScope.Session, 'session-seeded', [ stubPair(IAgentLifecycleService, seededLifecycle), + stubPair(IWorkspaceStateService, new WorkspaceStateService()), ]); const view = seededSession.accessor.get(ISessionActivityView); expect(view.state().busy).toBe(true); diff --git a/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts b/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts index 5d0c1ce6d4..bd1c524a2d 100644 --- a/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts +++ b/packages/agent-core-v2/test/session/sessionLog/sessionLogService.test.ts @@ -22,6 +22,8 @@ import { SessionLogService } from '#/session/sessionLog/sessionLogService'; import { makeSessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; let homeDir: string; let sessionDir: string; @@ -59,14 +61,17 @@ function buildHost() { } function testSessionSeed() { - return sessionContextSeed(makeSessionContext({ - sessionId: 's1', - workspaceId: 'test-workspace', - sessionDir, - sessionScope: 'sessions/test-workspace/s1', - metaScope: 'sessions/test-workspace/s1/session-meta', - cwd: sessionDir, - })); + return [ + ...sessionContextSeed(makeSessionContext({ + sessionId: 's1', + workspaceId: 'test-workspace', + sessionDir, + sessionScope: 'sessions/test-workspace/s1', + metaScope: 'sessions/test-workspace/s1/session-meta', + cwd: sessionDir, + })), + [IWorkspaceStateService, new WorkspaceStateService()] as const, + ]; } async function readSessionLog(): Promise { diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts index 840b05b200..3935b8a56f 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts @@ -24,6 +24,8 @@ import { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCat import { SessionSkillCatalogService } from '#/session/sessionSkillCatalog/skillCatalogService'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { stubSkill } from '../../app/skillCatalog/stubs'; @@ -71,6 +73,7 @@ describe('SessionSkillCatalogService (seed view)', () => { const host = createScopedTestHost([]); const session = host.child(LifecycleScope.Session, 's1', [ stubPair(ISessionSkillCatalogData, data), + stubPair(IWorkspaceStateService, new WorkspaceStateService()), ]); return { host, catalog: session.accessor.get(ISessionSkillCatalog) }; } diff --git a/packages/agent-core-v2/test/state/stubs.ts b/packages/agent-core-v2/test/state/stubs.ts index 646c20f4b5..f4351049e0 100644 --- a/packages/agent-core-v2/test/state/stubs.ts +++ b/packages/agent-core-v2/test/state/stubs.ts @@ -1,18 +1,26 @@ /** * Test doubles for the `state` domain: registers real `StateRegistry` - * instances for the three per-scope state service tokens. + * instances for the four per-scope state service tokens, chained so each + * tier's `inspect()` cascade resolves its parent. */ import type { ServiceRegistration } from '#/_base/di/test'; import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentStateService } from '#/agent/state/agentState'; -import { StateService } from '#/app/state/stateService'; -import { IStateService } from '#/app/state/state'; +import { AppStateService } from '#/app/state/appStateService'; +import { IAppStateService } from '#/app/state/appState'; import { SessionStateService } from '#/session/state/sessionStateService'; import { ISessionStateService } from '#/session/state/sessionState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; export function registerStateServices(reg: ServiceRegistration): void { - reg.defineInstance(IStateService, new StateService()); - reg.defineInstance(ISessionStateService, new SessionStateService()); - reg.defineInstance(IAgentStateService, new AgentStateService()); + const app = new AppStateService(); + const workspace = new WorkspaceStateService(app); + const session = new SessionStateService(workspace); + const agent = new AgentStateService(session); + reg.defineInstance(IAppStateService, app); + reg.defineInstance(IWorkspaceStateService, workspace); + reg.defineInstance(ISessionStateService, session); + reg.defineInstance(IAgentStateService, agent); } diff --git a/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts similarity index 97% rename from packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts rename to packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index 9beaa25c89..1611d33a1e 100644 --- a/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -46,8 +46,8 @@ import { CRON_SESSION_TAG, type CronTask } from '#/app/cron/cronTask'; import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle'; import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycleService'; import { resumeSessionById } from '#/app/workspaceLifecycle/sessionLookup'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; -import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; +import { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService'; import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; import { IAgentActivityView } from '#/agent/activityView/activityView'; @@ -67,6 +67,10 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IAppStateService } from '#/app/state/appState'; +import { AppStateService } from '#/app/state/appStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -473,7 +477,7 @@ class RecordingSessionExternalHooksService } } -describe('WorkspaceHandlerService', () => { +describe('SessionLifecycleService', () => { let host: ScopedTestHost | undefined; let telemetryRecords: TelemetryRecord[]; let tmpRoots: string[]; @@ -490,12 +494,26 @@ describe('WorkspaceHandlerService', () => { ScopeActivation.OnDemand, 'workspaceLifecycle', ); + registerScopedService( + LifecycleScope.App, + IAppStateService, + AppStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceStateService, + WorkspaceStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); registerScopedService( LifecycleScope.Workspace, - IWorkspaceHandlerService, - WorkspaceHandlerService, + ISessionLifecycleService, + SessionLifecycleService, ScopeActivation.OnScopeCreated, - 'workspaceHandler', + 'sessionLifecycle', ); registerScopedService( LifecycleScope.Workspace, @@ -540,7 +558,7 @@ describe('WorkspaceHandlerService', () => { */ async function build( extra: ReturnType[] = [], - ): Promise { + ): Promise { host = createScopedTestHost([ stubPair(IBootstrapService, bootstrapStub()), stubPair(ISessionMetadata, metadataStub()), @@ -578,7 +596,7 @@ describe('WorkspaceHandlerService', () => { ]); const lifecycle = host.app.accessor.get(IWorkspaceLifecycleService); const handler = await lifecycle.handlerFor({ root: '/tmp/proj' }); - return handler.accessor.get(IWorkspaceHandlerService); + return handler.accessor.get(ISessionLifecycleService); } async function makeTmpRoot(): Promise { diff --git a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts index 89df2cadbb..ca36f56617 100644 --- a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts @@ -2,7 +2,7 @@ * Scenario: workspace-level add-dir (the phase-3.5 behavior contract). * * Drives the REAL handler chain (WorkspaceLifecycleService → - * WorkspaceHandlerService) with the real `WorkspaceDirsService`, the real + * SessionLifecycleService) with the real `WorkspaceDirsService`, the real * node-fs `FileProjectLocalConfigService`, the real fs watch service, and * the real Session-scope `workspaceContext` view, and proves: * - a persisted `addDir` writes `.kimi-code/local.toml` and refreshes every @@ -61,6 +61,10 @@ import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolic import { ISessionProcessRunner } from '#/session/process/processRunner'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IAppStateService } from '#/app/state/appState'; +import { AppStateService } from '#/app/state/appStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; @@ -70,8 +74,8 @@ import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; -import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; +import { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService'; import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; @@ -196,10 +200,10 @@ describe('workspace add-dir (handler chain)', () => { ); registerScopedService( LifecycleScope.Workspace, - IWorkspaceHandlerService, - WorkspaceHandlerService, + ISessionLifecycleService, + SessionLifecycleService, ScopeActivation.OnScopeCreated, - 'workspaceHandler', + 'sessionLifecycle', ); registerScopedService( LifecycleScope.Workspace, @@ -215,6 +219,20 @@ describe('workspace add-dir (handler chain)', () => { ScopeActivation.OnScopeCreated, 'workspaceDirs', ); + registerScopedService( + LifecycleScope.App, + IAppStateService, + AppStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceStateService, + WorkspaceStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); registerScopedService( LifecycleScope.Session, ISessionStateService, @@ -363,10 +381,10 @@ describe('workspace add-dir (handler chain)', () => { async function handlerFor( host: ScopedTestHost, root: string, - ): Promise<{ service: IWorkspaceHandlerService; dirs: IWorkspaceDirs }> { + ): Promise<{ service: ISessionLifecycleService; dirs: IWorkspaceDirs }> { const handler = await host.app.accessor.get(IWorkspaceLifecycleService).handlerFor({ root }); return { - service: handler.accessor.get(IWorkspaceHandlerService), + service: handler.accessor.get(ISessionLifecycleService), dirs: handler.accessor.get(IWorkspaceDirs), }; } diff --git a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts index db2e24d1aa..2b188fa404 100644 --- a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts @@ -3,7 +3,7 @@ * phase-3 behavior contract). * * Drives the REAL handler chain (WorkspaceLifecycleService → - * WorkspaceHandlerService) with the real Workspace-scope resource services + * SessionLifecycleService) with the real Workspace-scope resource services * and proves: one shared MCP connection manager (and one initial connect) * for two sessions of the same workspace, no skill rescan when the second * session lists skills, and the fs-watch fan-out refreshing a live session's @@ -70,10 +70,14 @@ import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog import { SessionSkillCatalogService } from '#/session/sessionSkillCatalog/skillCatalogService'; import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; +import { IAppStateService } from '#/app/state/appState'; +import { AppStateService } from '#/app/state/appStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { ISessionProcessRunner } from '#/session/process/processRunner'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; -import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService'; +import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; +import { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService'; import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService'; import { IWorkspaceAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; @@ -170,10 +174,10 @@ describe('workspace resource sharing (handler chain)', () => { ); registerScopedService( LifecycleScope.Workspace, - IWorkspaceHandlerService, - WorkspaceHandlerService, + ISessionLifecycleService, + SessionLifecycleService, ScopeActivation.OnScopeCreated, - 'workspaceHandler', + 'sessionLifecycle', ); registerScopedService( LifecycleScope.Workspace, @@ -247,6 +251,8 @@ describe('workspace resource sharing (handler chain)', () => { 'workspaceDirs', ); registerScopedService(LifecycleScope.Session, ISessionSkillCatalog, SessionSkillCatalogService, ScopeActivation.OnScopeCreated, 'sessionSkillCatalog'); + registerScopedService(LifecycleScope.App, IAppStateService, AppStateService, ScopeActivation.OnScopeCreated, 'state'); + registerScopedService(LifecycleScope.Workspace, IWorkspaceStateService, WorkspaceStateService, ScopeActivation.OnScopeCreated, 'state'); registerScopedService(LifecycleScope.Session, ISessionStateService, SessionStateService, ScopeActivation.OnScopeCreated, 'state'); registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource, ScopeActivation.OnDemand, 'skillCatalog'); registerScopedService(LifecycleScope.App, IUserFileSkillSource, UserFileSkillSource, ScopeActivation.OnDemand, 'skillCatalog'); @@ -378,11 +384,11 @@ describe('workspace resource sharing (handler chain)', () => { ]); } - async function handlerFor(root: string): Promise { + async function handlerFor(root: string): Promise { const handler = await (host as ScopedTestHost).app.accessor .get(IWorkspaceLifecycleService) .handlerFor({ root }); - return handler.accessor.get(IWorkspaceHandlerService); + return handler.accessor.get(ISessionLifecycleService); } it('runs one shared MCP manager and one initial connect for two concurrent sessions', async () => { diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 8bf59b8c07..ce666c946c 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -19,14 +19,14 @@ * The `POST /sessions/{tail}` actions split into two groups. The thin * pass-throughs — `fork` / `compact` / `abort` / `archive` / `restore` — call * the native v2 services directly (the workspace handler's - * `IWorkspaceHandlerService.fork` / `archive` / `restore`, reached through the + * `ISessionLifecycleService.fork` / `archive` / `restore`, reached through the * `sessionIndex` → `IWorkspaceLifecycleService.handlerFor` composition, * `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`); there is no * v1-only projection to centralize, so no adapter is involved. `undo` likewise * calls `IAgentConversationUndoService.undo` directly (it throws * `session.undo_unavailable` with a structured reason) and only borrows * `ISessionLegacyService.status` for the cross-domain status rollup. The - * `/sessions/{id}/children` endpoints call `IWorkspaceHandlerService.createChild` + * `/sessions/{id}/children` endpoints call `ISessionLifecycleService.createChild` * and `ISessionIndex.list({ childOf })` directly — the child markers and * parent-title default live in the lifecycle, and the child filter lives in the * index. Only `POST /sessions/{id}/profile` (`updateProfile`), @@ -92,7 +92,7 @@ import { ISessionSecondaryModelWarningService, IEventService, IWorkspaceAliases, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, IWorkspaceService, getLiveSessionById, @@ -318,7 +318,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void // Ensure the workspace is registered so `metadata.cwd` is resolvable on // read (gap G3 — v2 does not store workDir on the session). The session // is created through the workspace's handler (`handlerFor` → the - // handler's `IWorkspaceHandlerService`) — there is no App-scope session + // handler's `ISessionLifecycleService`) — there is no App-scope session // lifecycle entry point. try { const touched = await registry.createOrTouch(workDir); @@ -326,7 +326,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void const handler = await core.accessor.get(IWorkspaceLifecycleService).handlerFor({ root: workDir, }); - const handle = await handler.accessor.get(IWorkspaceHandlerService).create({ + const handle = await handler.accessor.get(ISessionLifecycleService).create({ workDir, }); if (typeof body.title === 'string') { @@ -670,7 +670,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void `session ${parsed.id} does not exist`, ); } - const handle = await forkHandler.accessor.get(IWorkspaceHandlerService).fork({ + const handle = await forkHandler.accessor.get(ISessionLifecycleService).fork({ sourceSessionId: parsed.id, title: body.title, metadata: body.metadata, @@ -770,7 +770,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void const restored = restoreHandler === undefined ? undefined - : await restoreHandler.accessor.get(IWorkspaceHandlerService).restore(parsed.id); + : await restoreHandler.accessor.get(ISessionLifecycleService).restore(parsed.id); if (restored === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`); } @@ -793,11 +793,11 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void const archived = archiveHandler === undefined ? undefined - : await archiveHandler.accessor.get(IWorkspaceHandlerService).resume(parsed.id); + : await archiveHandler.accessor.get(ISessionLifecycleService).resume(parsed.id); if (archived === undefined || archiveHandler === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`); } - await archiveHandler.accessor.get(IWorkspaceHandlerService).archive(parsed.id); + await archiveHandler.accessor.get(ISessionLifecycleService).archive(parsed.id); requestLog(req)?.info({ session_id: parsed.id, action: 'archive' }, 'session action completed'); reply.send(okEnvelope({ archived: true }, req.id)); } catch (error) { @@ -920,7 +920,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void if (childHandler === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${session_id} does not exist`); } - const handle = await childHandler.accessor.get(IWorkspaceHandlerService).createChild({ + const handle = await childHandler.accessor.get(ISessionLifecycleService).createChild({ sourceSessionId: session_id, title: req.body.title, metadata: req.body.metadata, diff --git a/packages/kap-server/test/rpc.test.ts b/packages/kap-server/test/rpc.test.ts index be903e643d..0240a32f85 100644 --- a/packages/kap-server/test/rpc.test.ts +++ b/packages/kap-server/test/rpc.test.ts @@ -13,7 +13,7 @@ import { IPluginService, ISessionIndex, ISessionMetadata, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceService, getLiveSessionById, } from '@moonshot-ai/agent-core-v2'; @@ -296,7 +296,7 @@ describe('server-v2 /api/v1/debug RPC', () => { const workspaceId = (await server!.core.accessor.get(ISessionIndex).get(id))!.workspaceId; const { body } = await call( 'POST', - rpc('workspace', IWorkspaceHandlerService, 'archive', { wid: workspaceId }), + rpc('workspace', ISessionLifecycleService, 'archive', { wid: workspaceId }), id, ); expect(body.code).toBe(0); diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 83f8fa5d44..7cb3e37204 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -17,7 +17,7 @@ import { ISessionIndex, ISessionInteractionService, ISessionMetadata, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, LifecycleScope, SessionInteractionService, @@ -1885,7 +1885,7 @@ describe('bindSessionTranscript', () => { } function fakeCoreWithAgents(interactions: SessionInteractionService, agents: FakeAgents): Scope { - const handlerService = { + const sessionLifecycle = { onDidCloseSession: () => ({ dispose: () => undefined }), onDidArchiveSession: () => ({ dispose: () => undefined }), get: (sid: string) => (sid === 's1' ? fakeSession(interactions, agents) : undefined), @@ -1894,7 +1894,7 @@ describe('bindSessionTranscript', () => { id: 'ws', kind: LifecycleScope.Workspace, accessor: { - get: (t: unknown) => (t === IWorkspaceHandlerService ? handlerService : undefined), + get: (t: unknown) => (t === ISessionLifecycleService ? sessionLifecycle : undefined), }, dispose: () => undefined, }; diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 92b76b589b..234ed7ce94 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -30,7 +30,7 @@ import { ISessionInteractionService, IWireService, ISessionMetadata, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, MAIN_AGENT_ID, SECONDARY_DERIVED_MODEL_ID, @@ -367,7 +367,7 @@ function makeCore( }; return { id: sid, kind: LifecycleScope.Session, accessor: sessionAccessor, dispose: () => {} }; }; - const handlerService = { + const sessionLifecycle = { // Inert lifecycle events (TranscriptService subscribes on construction). onDidCloseSession: () => ({ dispose: () => {} }), onDidArchiveSession: () => ({ dispose: () => {} }), @@ -377,7 +377,7 @@ function makeCore( id: 'wd', kind: LifecycleScope.Workspace, accessor: { - get: (t: unknown) => (t === IWorkspaceHandlerService ? handlerService : undefined), + get: (t: unknown) => (t === ISessionLifecycleService ? sessionLifecycle : undefined), }, dispose: () => {}, }; diff --git a/packages/kap-server/test/snapshot.test.ts b/packages/kap-server/test/snapshot.test.ts index 5a268fda60..a850ff10ed 100644 --- a/packages/kap-server/test/snapshot.test.ts +++ b/packages/kap-server/test/snapshot.test.ts @@ -18,7 +18,7 @@ import { ISessionContext, ISessionIndex, ISessionMetadata, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, IWorkspaceService, getLiveSessionById, @@ -82,7 +82,7 @@ describe('server-v2 snapshot route enrichment', () => { const handler = { accessor: fakeAccessor([ [ - IWorkspaceHandlerService, + ISessionLifecycleService, { resume: async () => session, get: () => undefined }, ], ]), diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index b98836ec96..94d8f5a4a4 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -31,7 +31,7 @@ import { workspacesContract } from './global/workspaces.js'; import { sessionApprovalContract } from './session/approval.js'; import { sessionInteractionContract } from './session/interaction.js'; import { - workspaceHandlerContract, + sessionLifecycleContract, workspaceLifecycleContract, } from './session/lifecycle.js'; import { sessionMetadataContract } from './session/metadata.js'; @@ -54,7 +54,7 @@ export const globalContract: KlientContract = { bootstrapService: envContract, // workspace scope (+ the app-registered handler registry) workspaceLifecycleService: workspaceLifecycleContract, - workspaceHandlerService: workspaceHandlerContract, + sessionLifecycleService: sessionLifecycleContract, // session scope sessionMetadata: sessionMetadataContract, sessionInteractionService: sessionInteractionContract, diff --git a/packages/klient/src/contract/session/lifecycle.ts b/packages/klient/src/contract/session/lifecycle.ts index 0941330312..e2fbde99b2 100644 --- a/packages/klient/src/contract/session/lifecycle.ts +++ b/packages/klient/src/contract/session/lifecycle.ts @@ -1,11 +1,11 @@ /** - * `workspaceHandlerService` / `workspaceLifecycleService` — session + * `sessionLifecycleService` / `workspaceLifecycleService` — session * lifecycle after the Workspace-domain split. The App-scope * `workspaceLifecycleService` materializes one handler per workspace - * (`handlerFor`); the Workspace-scope `workspaceHandlerService` owns that + * (`handlerFor`); the Workspace-scope `sessionLifecycleService` owns that * workspace's sessions (create/resume/close/archive/restore/fork/ * createChild). Mirrors `agent-core-v2/app/workspaceLifecycle/*` and - * `agent-core-v2/workspace/workspaceHandler/*`. The engine returns scope + * `agent-core-v2/workspace/sessionLifecycle/*`. The engine returns scope * handles; over JSON only the plain data fields survive, so the wire keeps * `{ id, kind }` (loose — extra fields may appear in-process). */ @@ -47,7 +47,7 @@ export const workspaceLifecycleContract = { handlerFor: { input: z.tuple([workspaceRefSchema]), output: handleWireSchema }, } satisfies ServiceContract; -export const workspaceHandlerContract = { +export const sessionLifecycleContract = { create: { input: z.tuple([createSessionOptionsSchema]), output: handleWireSchema }, resume: { input: z.tuple([z.string()]), output: maybe(handleWireSchema) }, close: { input: z.tuple([z.string()]), output: noResult }, diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts index d21a5dbbe4..04371c86a3 100644 --- a/packages/klient/src/core/facade/global.ts +++ b/packages/klient/src/core/facade/global.ts @@ -277,7 +277,7 @@ export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStr const handler = (await scoped({}, 'workspaceLifecycleService', 'handlerFor', [ { root: workDir }, ])) as { id: string }; - const handle = (await scoped({ workspaceId: handler.id }, 'workspaceHandlerService', 'create', [ + const handle = (await scoped({ workspaceId: handler.id }, 'sessionLifecycleService', 'create', [ { workDir, additionalDirs }, ])) as { id: string }; const scope = { sessionId: handle.id }; diff --git a/packages/klient/src/core/facade/session.ts b/packages/klient/src/core/facade/session.ts index 9df21846b2..b4daa19496 100644 --- a/packages/klient/src/core/facade/session.ts +++ b/packages/klient/src/core/facade/session.ts @@ -34,7 +34,7 @@ const NOT_FOUND = 40404; export type { ScopedCaller } from './global.js'; -/** What `workspaceHandlerService.create/fork/createChild` leaves on the wire. */ +/** What `sessionLifecycleService.create/fork/createChild` leaves on the wire. */ interface HandleWire { readonly id: string; } @@ -105,7 +105,7 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess if (workspaceId === undefined) { throw new RPCError(NOT_FOUND, `session not found: ${sessionId}`); } - const handle = (await call({ workspaceId }, 'workspaceHandlerService', method, [ + const handle = (await call({ workspaceId }, 'sessionLifecycleService', method, [ { sourceSessionId: sessionId, title: input.title, metadata: input.metadata }, ])) as HandleWire; return call({ sessionId: handle.id }, 'sessionMetadata', 'read', []) as Promise; @@ -145,17 +145,17 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess close: async () => { const workspaceId = await resolveWorkspaceId(); if (workspaceId === undefined) return; - await call({ workspaceId }, 'workspaceHandlerService', 'close', [sessionId]); + await call({ workspaceId }, 'sessionLifecycleService', 'close', [sessionId]); }, archive: async () => { const workspaceId = await resolveWorkspaceId(); if (workspaceId === undefined) return; - await call({ workspaceId }, 'workspaceHandlerService', 'archive', [sessionId]); + await call({ workspaceId }, 'sessionLifecycleService', 'archive', [sessionId]); }, restore: async () => { const workspaceId = await resolveWorkspaceId(); if (workspaceId === undefined) return false; - const handle = (await call({ workspaceId }, 'workspaceHandlerService', 'restore', [ + const handle = (await call({ workspaceId }, 'sessionLifecycleService', 'restore', [ sessionId, ])) as HandleWire | null; return handle !== null; diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index b06d1c571a..e7277567f1 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -23,7 +23,7 @@ import { IBootstrapService } from '@moonshot-ai/agent-core-v2/app/bootstrap/boot import { IEventService } from '@moonshot-ai/agent-core-v2/app/event/event'; import { IHostFolderBrowser } from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; import { IWorkspaceLifecycleService } from '@moonshot-ai/agent-core-v2/app/workspaceLifecycle/workspaceLifecycle'; -import { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; +import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; @@ -52,7 +52,7 @@ export const serviceTokens: Readonly>> hostFolderBrowser: IHostFolderBrowser, bootstrapService: IBootstrapService, workspaceLifecycleService: IWorkspaceLifecycleService, - workspaceHandlerService: IWorkspaceHandlerService, + sessionLifecycleService: ISessionLifecycleService, sessionMetadata: ISessionMetadata, sessionInteractionService: ISessionInteractionService, sessionApprovalService: ISessionApprovalService, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index 2185a86c51..7d7a17942a 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -44,7 +44,7 @@ import type { CreateChildSessionOptions, CreateSessionOptions, ForkSessionOptions, -} from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; +} from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; import type { ApprovalRequest, ApprovalResponse, diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index d4b93dea4f..66886ad70b 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -197,7 +197,7 @@ import { IWorkspaceAliases, hostRequestHeadersSeed, IWorkspaceDirs, - IWorkspaceHandlerService, + ISessionLifecycleService, IWorkspaceLifecycleService, closeSessionById, followWorkspaceHandlers, @@ -932,7 +932,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { /** * v1 semantics: register the workDir as a workspace and create the session - * (the handler's `IWorkspaceHandlerService.create` does both; the klient facade + * (the handler's `ISessionLifecycleService.create` does both; the klient facade * wrapper is bypassed because it takes neither an explicit session id nor * caller metadata). The `model` / `thinking` / `permission` options are the * main-agent configuration v1 applies eagerly at creation: supplying any of @@ -960,7 +960,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { const handler = await this.engineAccessor .get(IWorkspaceLifecycleService) .handlerFor({ root: workDir }); - const handle = await handler.accessor.get(IWorkspaceHandlerService).create({ + const handle = await handler.accessor.get(ISessionLifecycleService).create({ sessionId: input.id, workDir, additionalDirs: input.additionalDirs, @@ -1016,7 +1016,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } /** - * Through `engineAccessor` (the handler chain's `IWorkspaceHandlerService.fork`) because the + * Through `engineAccessor` (the handler chain's `ISessionLifecycleService.fork`) because the * klient facade fork takes no explicit target id. Known gaps vs v1: the * engine's fork is unconditional — it never rejects an in-flight source * turn (v1's SESSION_FORK_ACTIVE_TURN) — and `turnIndex` truncation has no @@ -1033,7 +1033,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } const forkHandler = await handlerForSession(this.engineAccessor, input.id); if (forkHandler === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); - const handle = await forkHandler.accessor.get(IWorkspaceHandlerService).fork({ + const handle = await forkHandler.accessor.get(ISessionLifecycleService).fork({ sourceSessionId: input.id, newSessionId: input.forkId, title: input.title, From 848713317d99b4b9b7b9ba103061249f56837f1f Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 31 Jul 2026 11:40:27 +0800 Subject: [PATCH 5/6] feat(kap-server): add session-less POST /workspace/fs:search route Carry the workspace reference (registered id or absolute root) in the request body and resolve it to the same Workspace-scope fs service the session route uses, so clients no longer borrow the session route's {session_id} slot. kimi-web's @ file mention now calls this route with the workspace ref instead of a session id; the session-route fallback stays for wire compatibility. --- apps/kimi-web/src/api/daemon/client.ts | 6 +- apps/kimi-web/src/api/types.ts | 3 +- .../composables/client/useWorkspaceState.ts | 19 ++--- packages/kap-server/src/routes/fs.ts | 66 ++++++++++++++++++ .../apiSurface.snapshot.test.ts.snap | 4 ++ packages/kap-server/test/fs.test.ts | 69 ++++++++++++++++++- 6 files changed, 151 insertions(+), 16 deletions(-) diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index d8b1833edf..3dd6cd7042 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -968,7 +968,7 @@ export class DaemonKimiWebApi implements KimiWebApi { } async searchFiles( - sessionId: string, + workspace: string, input: { query: string; limit?: number }, ): Promise<{ items: Array<{ @@ -980,10 +980,10 @@ export class DaemonKimiWebApi implements KimiWebApi { }>; truncated: boolean; }> { - const body: Record = { query: input.query }; + const body: Record = { workspace, query: input.query }; if (input.limit !== undefined) body['limit'] = input.limit; const data = await this.http.post( - `/sessions/${encodeURIComponent(sessionId)}/fs:search`, + `/workspace/fs:search`, body, ); return { diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 6c4679f165..5413f7f587 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -747,7 +747,8 @@ export interface KimiWebApi { closeTerminal(sessionId: string, terminalId: string): Promise<{ closed: true }>; listDirectory(sessionId: string, input: { path?: string; depth?: number; includeGitStatus?: boolean }): Promise<{ items: FsEntry[]; childrenByPath?: Record; truncated: boolean }>; readFile(sessionId: string, input: { path: string; offset?: number; length?: number }): Promise<{ path: string; content: string; encoding: 'utf-8' | 'base64'; size: number; truncated: boolean; etag: string; mime: string; languageId?: string; lineCount?: number; isBinary: boolean }>; - searchFiles(sessionId: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>; + /** Search files in a workspace (no session required) — POST /workspace/fs:search. `workspace` accepts a registered workspace id or an absolute root. */ + searchFiles(workspace: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>; grepFiles(sessionId: string, input: { pattern: string; regex?: boolean; caseSensitive?: boolean }): Promise<{ files: Array<{ path: string; matches: Array<{ line: number; col: number; text: string; before: string[]; after: string[] }> }>; filesScanned: number; truncated: boolean; elapsedMs: number }>; getGitStatus(sessionId: string, paths?: string[]): Promise<{ branch: string; ahead: number; behind: number; entries: Record; additions: number; deletions: number; pullRequest: { number: number; state: string; url: string } | null }>; getFileDiff(sessionId: string, path: string): Promise<{ path: string; diff: string }>; diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 5b0c1f35b7..42e01e8e98 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -2733,19 +2733,20 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } /** - * Search files in the active session using the daemon searchFiles endpoint. - * In the new-session draft state (workspace picked, session not yet created) - * the workspace reference is sent instead — the daemon resolves a workspace - * id or root to the same workspace fs service, so `@` works before the first - * prompt. Returns {path, name}[] — defensive, returns [] on error or when - * neither an active session nor an active workspace exists. + * Search files in the active workspace via the daemon's workspace fs:search + * endpoint — no session id involved, so `@` works unchanged before the first + * prompt. The workspace ref mirrors what selectSession syncs: the active + * session's workspace, else the draft's active workspace (a registered id or + * an absolute root — the daemon resolves both). Returns {path, name}[] — + * defensive, returns [] on error or when no workspace is active. */ async function searchFiles(query: string): Promise> { - const id = rawState.activeSessionId ?? rawState.activeWorkspaceId; - if (!id) return []; + const session = rawState.sessions.find((s) => s.id === rawState.activeSessionId); + const ref = session === undefined ? rawState.activeWorkspaceId : workspaceIdForSession(session); + if (!ref) return []; try { const api = getKimiWebApi(); - const result = await api.searchFiles(id, { query, limit: 20 }); + const result = await api.searchFiles(ref, { query, limit: 20 }); return result.items.map((item) => ({ path: item.path, name: item.name })); } catch { return []; diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index 05ee440fce..033527e728 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -18,6 +18,12 @@ * mention must work before the session exists): the route resolves the * workspace's handler directly and uses the same Workspace-scope fs service a * real session would resolve to. URL and wire schema are unchanged. + * + * First-class workspace search: `POST /workspace/fs:search` carries the same + * workspace reference in the body (`workspace`), so a session-less client + * searches without borrowing the `{session_id}` slot. kimi-web's `@` mention + * uses this route; the session-route fallback above predates it and stays for + * wire compatibility. */ import { createReadStream } from 'node:fs'; @@ -43,6 +49,7 @@ import { fsMkdirRequestSchema, fsReadRequestSchema, fsSearchRequestSchema, + fsSearchResponseSchema, fsStatManyRequestSchema, fsStatRequestSchema, } from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fs'; @@ -96,6 +103,17 @@ const sessionIdAndTailParamSchema = z.object({ tail: z.string().min(1), }); +/** + * Body for `POST /workspace/fs:search`: the engine's fs-search request plus + * the workspace reference (registered workspace id or absolute root) the + * session route would otherwise carry in its `{session_id}` slot. + */ +const workspaceFsSearchBodySchema = fsSearchRequestSchema.extend({ + workspace: z.string().min(1), +}); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + const FS_ACTIONS = [ 'list', 'read', @@ -264,6 +282,54 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { fsActionRoute.handler as unknown as Parameters[2], ); + // Session-less workspace file search (file header): the `@` file mention of + // a not-yet-created session addresses the workspace directly instead of + // borrowing the session route's `{session_id}` slot. Declared with a double + // colon so find-my-way serves it on the wire as `/workspace/fs:search` + // (same convention as `/fs::browse` in `workspaceFs.ts`). + const workspaceSearchRoute = defineRoute( + { + method: 'POST', + path: '/workspace/fs::search', + body: workspaceFsSearchBodySchema, + success: { data: fsSearchResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + [ErrorCode.FS_TOO_MANY_RESULTS]: {}, + }, + description: + 'Search files in a workspace without a session. `workspace` accepts a registered workspace id or an absolute root (registered on the spot).', + tags: ['fs'], + operationId: 'workspaceFsSearch', + }, + async (req, reply) => { + const { workspace, ...searchRequest } = req.body; + const fs = await resolveWorkspaceFs(core, workspace); + if (fs === undefined) { + reply.send( + errEnvelope( + ErrorCode.WORKSPACE_NOT_FOUND, + `workspace ${workspace} does not exist`, + req.id, + ), + ); + return; + } + try { + const data = await fs.search(searchRequest); + reply.send(okEnvelope(data, req.id)); + } catch (err) { + sendMappedError(reply, req, err); + } + }, + ); + app.post( + workspaceSearchRoute.path, + workspaceSearchRoute.options, + workspaceSearchRoute.handler as unknown as Parameters[2], + ); + const downloadRoute = defineRoute( { method: 'GET', diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index 0457902050..0db6f9b9bf 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -392,6 +392,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/shutdown", ], + [ + "POST", + "/api/v1/workspace/fs:search", + ], [ "POST", "/api/v1/workspaces", diff --git a/packages/kap-server/test/fs.test.ts b/packages/kap-server/test/fs.test.ts index b4935dee42..ce2b7b8188 100644 --- a/packages/kap-server/test/fs.test.ts +++ b/packages/kap-server/test/fs.test.ts @@ -28,7 +28,7 @@ interface FsEntryWire { mime?: string; } -describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => { +describe('server-v2 /api/v1 fs routes', () => { let server: RunningServer | undefined; let home: string | undefined; /** Session work dir — kept separate from the server homeDir so the server's @@ -80,11 +80,13 @@ describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => { server = undefined; } if (home !== undefined) { - await rm(home, { recursive: true, force: true }); + // maxRetries: the async query-store shard writer can still be flushing + // after close (ENOTEMPTY on macOS) — same retry pattern as sessions.test.ts. + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); home = undefined; } if (work !== undefined) { - await rm(work, { recursive: true, force: true }); + await rm(work, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); work = undefined; } }); @@ -341,4 +343,65 @@ describe('server-v2 /api/v1/sessions/{sid}/fs:*', () => { } as never); expect(cached.status).toBe(304); }); + + // ------------------------------------------------------------------------- + // POST /api/v1/workspace/fs:search — session-less workspace file search. + // ------------------------------------------------------------------------- + + async function postWorkspaceSearch(body: unknown): Promise> { + const res = await fetch(`${base}/api/v1/workspace/fs:search`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify(body), + } as never); + return (await res.json()) as Envelope; + } + + it('workspace fs:search finds files by registered workspace id', async () => { + await writeFile(join(work!, 'epsilon.ts'), ''); + const res = await fetch(`${base}/api/v1/workspaces`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ root: work }), + } as never); + const created = (await res.json()) as Envelope<{ id: string }>; + expect(created.code).toBe(0); + + const body = await postWorkspaceSearch<{ items: { path: string }[]; truncated: boolean }>({ + workspace: created.data.id, + query: 'epsilon', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('epsilon.ts'); + }); + + it('workspace fs:search finds files by absolute root path', async () => { + await writeFile(join(work!, 'zeta.ts'), ''); + const body = await postWorkspaceSearch<{ items: { path: string }[]; truncated: boolean }>({ + workspace: work, + query: 'zeta', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('zeta.ts'); + }); + + it('workspace fs:search lists top-level entries for an empty query', async () => { + await writeFile(join(work!, 'eta.ts'), ''); + const body = await postWorkspaceSearch<{ items: { path: string }[]; truncated: boolean }>({ + workspace: work, + query: '', + }); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('eta.ts'); + }); + + it('workspace fs:search maps an unknown ref to WORKSPACE_NOT_FOUND', async () => { + const body = await postWorkspaceSearch({ workspace: 'does-not-exist', query: 'x' }); + expect(body.code).toBe(ErrorCode.WORKSPACE_NOT_FOUND); + }); + + it('workspace fs:search rejects a missing workspace field with VALIDATION_FAILED', async () => { + const body = await postWorkspaceSearch({ query: 'x' }); + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); + }); }); From 8ee2a8f23a40871c0b47dffb5570a362d1c0e5ac Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 31 Jul 2026 12:03:22 +0800 Subject: [PATCH 6/6] refactor(agent-core-v2): register workspace-scope service state into IWorkspaceStateService - move workspaceDirs / workspaceInstructions / workspaceSkillCatalog / workspaceTrust runtime state from bare instance fields into the workspace state container - extend gen-state-manifest.mts to scan app/workspace scopes, emitting AppStateSnapshot / WorkspaceStateSnapshot alongside Session/Agent - regenerate docs/state-manifest.d.ts and update AGENTS.md + agent-core-dev skill - update affected tests to register the state services and assert the new state keys --- .../agent-core-dev/service-authoring.md | 2 +- packages/agent-core-v2/AGENTS.md | 2 +- .../agent-core-v2/docs/state-manifest.d.ts | 276 +++++++++++++++++- .../scripts/gen-state-manifest.mts | 34 ++- .../workspaceDirs/workspaceDirsService.ts | 37 ++- .../workspaceInstructionsService.ts | 25 +- .../workspaceSkillCatalogService.ts | 37 ++- .../workspaceTrust/workspaceTrustService.ts | 22 +- .../workspaceLifecycle.test.ts | 18 ++ .../workspaceDirs/workspaceDirs.test.ts | 28 +- .../instructions.test.ts | 49 +++- .../skillCatalog.test.ts | 30 +- .../workspaceTrust/workspaceTrust.test.ts | 50 +++- 13 files changed, 555 insertions(+), 55 deletions(-) diff --git a/.agents/skills/agent-core-dev/service-authoring.md b/.agents/skills/agent-core-dev/service-authoring.md index 5bd569a08a..dd4ba7e355 100644 --- a/.agents/skills/agent-core-dev/service-authoring.md +++ b/.agents/skills/agent-core-dev/service-authoring.md @@ -204,7 +204,7 @@ A scoped Service may expose a factory method that returns a **new** instance of ### Runtime state goes into the per-scope state container -Session/Agent-scope Services register their runtime state into the scope's state container (`ISessionStateService` / `IAgentStateService`, both over `_base`'s `StateRegistry`) instead of holding it in bare instance fields, so per-scope state lives in one observable place (`snapshot()` / `onDidChange`) and dies with the scope. Reference: `session/interaction/interactionService.ts`. +Workspace/Session/Agent-scope Services register their runtime state into the scope's state container (`IWorkspaceStateService` / `ISessionStateService` / `IAgentStateService`, all over `_base`'s `StateRegistry`) instead of holding it in bare instance fields, so per-scope state lives in one observable place (`snapshot()` / `onDidChange`) and dies with the scope. Reference: `session/interaction/interactionService.ts`. - Declare keys in the domain file and export them: `export const interactionPendingKey = defineState>('interaction.pending', () => new Map())` — `.` naming, factory initializers. - Inject `@ISessionStateService private readonly states` (or the Agent token) and `this.states.register(key)` per key at the top of the constructor. diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index 13981c1897..c6f09df52e 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -72,4 +72,4 @@ Per-domain references live in `docs/`. - [`docs/di-testing.md`](docs/di-testing.md) — Read **before writing or touching any DI/Scope test**: picking the right harness (`InstantiationService` vs `TestInstantiationService` vs `createScopedTestHost`), declaring deps with `@IService`, stubbing collaborators, and teardown via `DisposableStore`. - [`docs/config-manifest.toml`](docs/config-manifest.toml) — Generated list of every registered config section, in the on-disk `config.toml` shape (owner, scope, defaults, env bindings, schema fields). Do not edit by hand; regenerate with `pnpm gen:config-manifest` after adding or removing a `registerConfigSection` call — `test/app/config/configManifest.test.ts` enforces freshness. - [`docs/wire-manifest.d.ts`](docs/wire-manifest.d.ts) — Generated declaration file listing every registered wire record type as a payload interface (model, persist policy, `toEvent`, cross-reducers in the doc comment; payload fields in real TS type syntax), plus a `WirePayloadMap`. Do not edit by hand; regenerate with `pnpm gen:wire-manifest` after adding or removing a `defineOp` call — `test/wire/wireManifest.test.ts` enforces freshness and checks the file parses. -- [`docs/state-manifest.d.ts`](docs/state-manifest.d.ts) — Generated declaration file listing every state key registered into `ISessionStateService` / `IAgentStateService`, as `SessionStateSnapshot` / `AgentStateSnapshot` interfaces (keys grouped by defining file), plus the `SessionStateKey` / `AgentStateKey` unions. Self-contained: every value type is expanded fully inline with each named type marked by a `/* TypeName — source/file.ts */` comment (recursion stops with a `recursive` marker) — no imports, no helper declarations. Do not edit by hand; regenerate with `pnpm gen:state-manifest` after adding or removing a `states.register(...)` call — `test/state/stateManifest.test.ts` enforces freshness and checks the file parses. +- [`docs/state-manifest.d.ts`](docs/state-manifest.d.ts) — Generated declaration file listing every state key registered into `IAppStateService` / `IWorkspaceStateService` / `ISessionStateService` / `IAgentStateService`, as `AppStateSnapshot` / `WorkspaceStateSnapshot` / `SessionStateSnapshot` / `AgentStateSnapshot` interfaces (keys grouped by defining file), plus the `AppStateKey` / `WorkspaceStateKey` / `SessionStateKey` / `AgentStateKey` unions. Self-contained: every value type is expanded fully inline with each named type marked by a `/* TypeName — source/file.ts */` comment (recursion stops with a `recursive` marker) — no imports, no helper declarations. Do not edit by hand; regenerate with `pnpm gen:state-manifest` after adding or removing a `states.register(...)` call — `test/state/stateManifest.test.ts` enforces freshness and checks the file parses. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index dfe4bd4f11..fed980fbf1 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1,11 +1,13 @@ -// Session & Agent State Manifest +// App, Workspace, Session & Agent State Manifest // // Generated by scripts/gen-state-manifest.mts — do not edit by hand. // Regenerate with: pnpm --filter @moonshot-ai/agent-core-v2 gen:state-manifest // -// Every state key registered into the Session-scope ISessionStateService or the -// Agent-scope IAgentStateService (see src/_base/state/stateRegistry.ts), collected -// statically from the `states.register(...)` call sites — a key defined via +// Every state key registered into the App-scope IAppStateService, the +// Workspace-scope IWorkspaceStateService, the Session-scope +// ISessionStateService, or the Agent-scope IAgentStateService (see +// src/_base/state/stateRegistry.ts), collected statically from the +// `states.register(...)` call sites — a key defined via // defineState but never registered does not appear here. Each entry shows the // compile-time StateKey value type fully expanded inline, so the manifest is // self-contained (no imports, no helper declarations). A named type is marked @@ -21,7 +23,15 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (Session: 18 keys · Agent: 67 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 67 keys) +// App +// Workspace +// workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts +// workspaceDirs.fileDirs src/workspace/workspaceDirs/workspaceDirsService.ts +// workspaceInstructions.current src/workspace/workspaceInstructions/workspaceInstructionsService.ts +// workspaceSkillCatalog.contributions src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts +// workspaceSkillCatalog.merged src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts +// workspaceTrust.trusted src/workspace/workspaceTrust/workspaceTrustService.ts // Session // cron.inFlight src/session/cron/sessionCronServiceImpl.ts // cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts @@ -110,6 +120,260 @@ // usage.currentTurn src/agent/usage/usageService.ts // usage.currentTurnId src/agent/usage/usageService.ts +/** App-scope keys registered into IAppStateService. */ +export interface AppStateSnapshot { +} + +export type AppStateKey = keyof AppStateSnapshot; + +/** Workspace-scope keys registered into IWorkspaceStateService. */ +export interface WorkspaceStateSnapshot { + // src/workspace/workspaceDirs/workspaceDirsService.ts + 'workspaceDirs.ephemeralDirs': readonly string[]; + 'workspaceDirs.fileDirs': readonly string[]; + // src/workspace/workspaceInstructions/workspaceInstructionsService.ts + 'workspaceInstructions.current': /* WorkspaceInstructionsSnapshot — packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructions.ts */ { + readonly agentsMd: string | undefined; + readonly agentsMdWarning: string | undefined; + }; + // src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts + 'workspaceSkillCatalog.contributions': Map; + 'workspaceSkillCatalog.merged': /* InMemorySkillCatalog — packages/agent-core-v2/src/app/skillCatalog/registry.ts */ { + registerBuiltinSkill: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + }) => void; + register: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + }, options?: { + readonly replace?: boolean; + }) => void; + recordSkipped: (skills: readonly /* SkippedSkill — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly path: string; + readonly type: string; + readonly reason: string; + }[]) => void; + addRoots: (roots: readonly string[]) => void; + getSkill: (name: string) => /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + } | undefined; + getPluginSkill: (pluginId: string, name: string) => /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + } | undefined; + renderSkillPrompt: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + }, rawArgs: string, context?: { + readonly sessionId?: string; + }) => string; + listSkills: () => readonly /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + }[]; + listInvocableSkills: () => readonly /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + }[]; + getSkillRoots: () => readonly string[]; + getSkippedByPolicy: () => readonly /* SkippedSkill — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly path: string; + readonly type: string; + readonly reason: string; + }[]; + getKimiSkillsDescription: () => string; + getModelSkillListing: () => string; + }; + // src/workspace/workspaceTrust/workspaceTrustService.ts + 'workspaceTrust.trusted': boolean; +} + +export type WorkspaceStateKey = keyof WorkspaceStateSnapshot; + /** Session-scope keys registered into ISessionStateService. */ export interface SessionStateSnapshot { // src/session/cron/sessionCronServiceImpl.ts @@ -740,7 +1004,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map value type fully expanded inline, so the manifest is', '// self-contained (no imports, no helper declarations). A named type is marked', diff --git a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts index 2207d31abc..2bf0ed466d 100644 --- a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts @@ -11,18 +11,23 @@ * mutation serializes on one tail queue; the change event fires only when * the combined list actually changed. The set reaches every session of the * handler through the `ISessionWorkspaceInfo` seed (`sessionInfo()`), a - * live read view over this service. Bound at Workspace scope. + * live read view over this service. The plain-data state (`fileDirs`, + * `ephemeralDirs`) is registered into `workspaceState` + * (`IWorkspaceStateService`) and read/written through it. Bound at + * Workspace scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; +import { defineState } from '#/_base/state/stateRegistry'; import { TimeoutTimer } from '#/_base/utils/timer'; import { subtreeWatchFilter } from '#/_base/utils/paths'; import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import type { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { @@ -33,11 +38,18 @@ import { const WATCH_DEBOUNCE_MS = 200; +export const workspaceDirsFileDirsKey = defineState( + 'workspaceDirs.fileDirs', + () => [], +); +export const workspaceDirsEphemeralDirsKey = defineState( + 'workspaceDirs.ephemeralDirs', + () => [], +); + export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { declare readonly _serviceBrand: undefined; - private fileDirs: readonly string[] = []; - private ephemeralDirs: readonly string[] = []; private projectRoot: string; private configPath: string; readonly ready: Promise; @@ -51,14 +63,33 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { @IProjectLocalConfigService private readonly localConfig: IProjectLocalConfigService, @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, @ILogService private readonly log: ILogService, + @IWorkspaceStateService private readonly states: IWorkspaceStateService, ) { super(); + this.states.register(workspaceDirsFileDirsKey); + this.states.register(workspaceDirsEphemeralDirsKey); this.projectRoot = workspace.cwd; this.configPath = ''; this.ready = this.enqueue(() => this.reloadFromDisk()); void this.ready.then(() => this.watchLocalToml()); } + private get fileDirs(): readonly string[] { + return this.states.get(workspaceDirsFileDirsKey); + } + + private set fileDirs(value: readonly string[]) { + this.states.set(workspaceDirsFileDirsKey, value); + } + + private get ephemeralDirs(): readonly string[] { + return this.states.get(workspaceDirsEphemeralDirsKey); + } + + private set ephemeralDirs(value: readonly string[]) { + this.states.set(workspaceDirsEphemeralDirsKey, value); + } + get additionalDirs(): readonly string[] { return [...new Set([...this.fileDirs, ...this.ephemeralDirs])]; } diff --git a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts index 91962bbd75..fb444f1a8f 100644 --- a/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceInstructions/workspaceInstructionsService.ts @@ -11,7 +11,9 @@ * through `hostFsWatch` and reloads debounced; the change event fires only * when the combined content or warning actually changed. The snapshot is shared by every session of * the handler through the `ISessionInstructionsProvider` seed - * (`sessionProvider()`), a live read view over this service. Bound at + * (`sessionProvider()`), a live read view over this service. The plain-data + * state (`current`) is registered into `workspaceState` + * (`IWorkspaceStateService`) and read/written through it. Bound at * Workspace scope. */ @@ -19,6 +21,7 @@ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; +import { defineState } from '#/_base/state/stateRegistry'; import { TimeoutTimer } from '#/_base/utils/timer'; import { subtreeWatchFilter } from '#/_base/utils/paths'; import { agentsMdWatchRoots, loadAgentsMdForRoots } from '#/agent/profile/context'; @@ -27,6 +30,7 @@ import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import type { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { @@ -36,16 +40,17 @@ import { const WATCH_DEBOUNCE_MS = 200; +export const workspaceInstructionsCurrentKey = defineState( + 'workspaceInstructions.current', + () => ({ agentsMd: undefined, agentsMdWarning: undefined }), +); + export class WorkspaceInstructionsService extends Disposable implements IWorkspaceInstructionsService { declare readonly _serviceBrand: undefined; - private current: WorkspaceInstructionsSnapshot = { - agentsMd: undefined, - agentsMdWarning: undefined, - }; readonly ready: Promise; private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; @@ -59,12 +64,22 @@ export class WorkspaceInstructionsService @IBootstrapService private readonly bootstrap: IBootstrapService, @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, @ILogService private readonly log: ILogService, + @IWorkspaceStateService private readonly states: IWorkspaceStateService, ) { super(); + this.states.register(workspaceInstructionsCurrentKey); this.ready = this.reload(); void this.watchCandidateFiles(); } + private get current(): WorkspaceInstructionsSnapshot { + return this.states.get(workspaceInstructionsCurrentKey); + } + + private set current(value: WorkspaceInstructionsSnapshot) { + this.states.set(workspaceInstructionsCurrentKey, value); + } + get snapshot(): WorkspaceInstructionsSnapshot { return this.current; } diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts index 148c1bb534..e2d13ae1fb 100644 --- a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts @@ -9,18 +9,22 @@ * event — no full rescan ever leaves the build-time load. The merged view is * shared by every session of the handler through the * `ISessionSkillCatalogData` seed (`sessionData()`), a live read view over - * this service. Bound at Workspace scope. + * this service. The plain-data state (`contributions`, `merged`) is + * registered into `workspaceState` (`IWorkspaceStateService`) and + * read/written through it. Bound at Workspace scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/_base/state/stateRegistry'; import { IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import type { ISkillSource, SkillContribution } from '#/app/skillCatalog/skillSource'; import type { SkillCatalog } from '#/app/skillCatalog/types'; import { IUserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; import type { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IExplicitFileSkillSource } from './explicitFileSkillSource'; import { IExtraFileSkillSource } from './extraFileSkillSource'; @@ -28,15 +32,18 @@ import { IPluginSkillSource } from './pluginSkillSource'; import { IWorkspaceRootSkillSource } from './rootFileSkillSource'; import { IWorkspaceSkillCatalog } from './workspaceSkillCatalog'; +export const workspaceSkillCatalogContributionsKey = defineState< + Map +>('workspaceSkillCatalog.contributions', () => new Map()); +export const workspaceSkillCatalogMergedKey = defineState( + 'workspaceSkillCatalog.merged', + () => new InMemorySkillCatalog(), +); + export class WorkspaceSkillCatalogService extends Disposable implements IWorkspaceSkillCatalog { declare readonly _serviceBrand: undefined; private readonly sources: readonly ISkillSource[]; - private readonly contributions = new Map< - string, - { readonly c: SkillContribution; readonly priority: number } - >(); - private merged = new InMemorySkillCatalog(); private readonly sourceLoadTails = new Map>(); readonly ready: Promise; private readonly onDidChangeEmitter = this._register(new Emitter()); @@ -49,8 +56,11 @@ export class WorkspaceSkillCatalogService extends Disposable implements IWorkspa @IExtraFileSkillSource extra: IExtraFileSkillSource, @IWorkspaceRootSkillSource workspace: IWorkspaceRootSkillSource, @IPluginSkillSource plugin: IPluginSkillSource, + @IWorkspaceStateService private readonly states: IWorkspaceStateService, ) { super(); + this.states.register(workspaceSkillCatalogContributionsKey); + this.states.register(workspaceSkillCatalogMergedKey); this.sources = [builtin, user, explicit, extra, workspace, plugin].toSorted( (a, b) => a.priority - b.priority, ); @@ -65,6 +75,21 @@ export class WorkspaceSkillCatalogService extends Disposable implements IWorkspa this.ready = this.loadAll(); } + private get contributions(): Map< + string, + { readonly c: SkillContribution; readonly priority: number } + > { + return this.states.get(workspaceSkillCatalogContributionsKey); + } + + private get merged(): InMemorySkillCatalog { + return this.states.get(workspaceSkillCatalogMergedKey); + } + + private set merged(value: InMemorySkillCatalog) { + this.states.set(workspaceSkillCatalogMergedKey, value); + } + get catalog(): SkillCatalog { return this.merged; } diff --git a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts index bbcc3282be..481ad56628 100644 --- a/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceTrust/workspaceTrustService.ts @@ -12,14 +12,18 @@ * goes through this service, so the view is in-process: another process * flipping the same record is picked up only on restart (a `docs.watch` * sync can join when a second writer exists). A read failure resolves to - * untrusted. Bound at Workspace scope. + * untrusted. The plain-data state (`trusted`) is registered into + * `workspaceState` (`IWorkspaceStateService`) and read/written through it. + * Bound at Workspace scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter } from '#/_base/event'; +import { defineState } from '#/_base/state/stateRegistry'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IWorkspaceTrust, type WorkspaceTrustChange } from './workspaceTrust'; @@ -31,26 +35,40 @@ interface TrustRecord { readonly trustedAt: number; } +export const workspaceTrustTrustedKey = defineState( + 'workspaceTrust.trusted', + () => false, +); + export class WorkspaceTrustService extends Disposable implements IWorkspaceTrust { declare readonly _serviceBrand: undefined; readonly ready: Promise; private readonly root: string; private readonly storeKey: string; - private trusted = false; private readonly changeEmitter = this._register(new Emitter()); readonly onDidChange = this.changeEmitter.event; constructor( @IWorkspaceContext workspace: IWorkspaceContext, @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, + @IWorkspaceStateService private readonly states: IWorkspaceStateService, ) { super(); + this.states.register(workspaceTrustTrustedKey); this.root = workspace.cwd; this.storeKey = encodeWorkDirKey(workspace.cwd); this.ready = this.initialize(); } + private get trusted(): boolean { + return this.states.get(workspaceTrustTrustedKey); + } + + private set trusted(value: boolean) { + this.states.set(workspaceTrustTrustedKey, value); + } + isTrusted(): boolean { return this.trusted; } diff --git a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts index 6401e47778..25c041495a 100644 --- a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts @@ -33,6 +33,10 @@ import { IExtraAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoade import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/explicitAgentProfileLoader'; import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; +import { IAppStateService } from '#/app/state/appState'; +import { AppStateService } from '#/app/state/appStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; @@ -273,6 +277,20 @@ describe('WorkspaceLifecycleService', () => { ScopeActivation.OnScopeCreated, 'workspaceDirs', ); + registerScopedService( + LifecycleScope.App, + IAppStateService, + AppStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); + registerScopedService( + LifecycleScope.Workspace, + IWorkspaceStateService, + WorkspaceStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); registerScopedService( LifecycleScope.App, IHostFileSystem, diff --git a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts index ca36f56617..2bda70bf0c 100644 --- a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts @@ -73,7 +73,11 @@ import { IExplicitAgentProfileLoader } from '#/workspace/workspaceAgentProfileLo import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; -import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; +import { + WorkspaceDirsService, + workspaceDirsEphemeralDirsKey, + workspaceDirsFileDirsKey, +} from '#/workspace/workspaceDirs/workspaceDirsService'; import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService'; import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy'; @@ -486,6 +490,28 @@ describe('workspace add-dir (handler chain)', () => { } }, 15_000); + it('registers the additional-directory sets into the workspace state container', async () => { + const homeDir = await makeRoot('kimi-add-dir-home-'); + const root = await makeProjectRoot(); + const persisted = await makeRoot('kimi-add-dir-persisted-'); + const ephemeral = await makeRoot('kimi-add-dir-ephemeral-'); + const host = buildHost(homeDir); + const handler = await host.app.accessor.get(IWorkspaceLifecycleService).handlerFor({ root }); + const dirs = handler.accessor.get(IWorkspaceDirs); + const states = handler.accessor.get(IWorkspaceStateService); + + expect(states.get(workspaceDirsFileDirsKey)).toEqual([]); + expect(states.get(workspaceDirsEphemeralDirsKey)).toEqual([]); + + await dirs.addDir({ path: persisted, persist: true }); + expect(states.get(workspaceDirsFileDirsKey)).toEqual([persisted]); + expect(states.get(workspaceDirsEphemeralDirsKey)).toEqual([]); + + await dirs.addDir({ path: ephemeral, persist: false }); + expect(states.get(workspaceDirsFileDirsKey)).toEqual([persisted]); + expect(states.get(workspaceDirsEphemeralDirsKey)).toEqual([ephemeral]); + }); + it('unions caller additionalDirs from create options into the shared set', async () => { const homeDir = await makeRoot('kimi-add-dir-home-'); const root = await makeProjectRoot(); diff --git a/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts b/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts index 9ef2fbe289..e814cabccb 100644 --- a/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceInstructions/instructions.test.ts @@ -1,6 +1,7 @@ /** - * Scenario: workspace AGENTS.md instructions — build-time snapshot and - * watch-driven refresh. + * Scenario: workspace AGENTS.md instructions — build-time snapshot, + * watch-driven refresh, and the `workspaceInstructions.current` state + * registration. * * Exercises the real `WorkspaceInstructionsService` against real temp * instruction files with a manually-fired fs-watch stub. Run: @@ -28,11 +29,16 @@ import { type HostFsChange, type IHostFsWatchHandle, } from '#/os/interface/hostFsWatch'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; -import { WorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructionsService'; +import { + WorkspaceInstructionsService, + workspaceInstructionsCurrentKey, +} from '#/workspace/workspaceInstructions/workspaceInstructionsService'; import { stubLog } from '../../_base/log/stubs'; +import { registerStateServices } from '../../state/stubs'; describe('WorkspaceInstructionsService', () => { let workDir: string; @@ -82,10 +88,14 @@ describe('WorkspaceInstructionsService', () => { } } - function createService(): IWorkspaceInstructionsService { + function createService(): { + service: IWorkspaceInstructionsService; + states: IWorkspaceStateService; + } { const ix = createServices(disposables, { strict: true, additionalServices: (reg) => { + registerStateServices(reg); reg.definePartialInstance(IWorkspaceContext, { cwd: workDir }); reg.defineInstance(IHostFileSystem, new HostFileSystem()); reg.definePartialInstance(IHostEnvironment, { homeDir: osHomeDir }); @@ -95,14 +105,14 @@ describe('WorkspaceInstructionsService', () => { reg.define(IWorkspaceInstructionsService, WorkspaceInstructionsService); }, }); - return ix.get(IWorkspaceInstructionsService); + return { service: ix.get(IWorkspaceInstructionsService), states: ix.get(IWorkspaceStateService) }; } it('loads the AGENTS.md snapshot at build and projects it through the session provider', async () => { await writeFile(join(workDir, 'AGENTS.md'), 'project instructions', 'utf8'); await writeFile(join(brandHomeDir, 'AGENTS.md'), 'brand instructions', 'utf8'); - const service = createService(); + const { service } = createService(); await service.ready; expect(service.snapshot.agentsMd).toContain('brand instructions'); @@ -115,7 +125,7 @@ describe('WorkspaceInstructionsService', () => { it('refreshes the snapshot and fires onDidChange when a watched file changes', async () => { const file = join(workDir, 'AGENTS.md'); await writeFile(file, 'old instructions', 'utf8'); - const service = createService(); + const { service } = createService(); await service.ready; expect(service.snapshot.agentsMd).toContain('old instructions'); @@ -134,7 +144,7 @@ describe('WorkspaceInstructionsService', () => { }); it('picks up a newly created AGENTS.md through the watch', async () => { - const service = createService(); + const { service } = createService(); await service.ready; expect(service.snapshot.agentsMd).toBe(''); @@ -155,7 +165,7 @@ describe('WorkspaceInstructionsService', () => { it('does not fire when a reload produces identical content', async () => { const file = join(workDir, 'AGENTS.md'); await writeFile(file, 'stable', 'utf8'); - const service = createService(); + const { service } = createService(); await service.ready; let fired = 0; @@ -167,4 +177,25 @@ describe('WorkspaceInstructionsService', () => { expect(fired).toBe(0); }); + + it('registers the snapshot into the workspace state container and tracks reloads', async () => { + const file = join(workDir, 'AGENTS.md'); + await writeFile(file, 'state instructions', 'utf8'); + const { service, states } = createService(); + await service.ready; + + expect(states.get(workspaceInstructionsCurrentKey).agentsMd).toContain('state instructions'); + + const changed = new Promise((resolvePromise) => { + const d = service.onDidChange(() => { + d.dispose(); + resolvePromise(); + }); + }); + await writeFile(file, 'updated instructions', 'utf8'); + fireWatch(file); + await changed; + + expect(states.get(workspaceInstructionsCurrentKey).agentsMd).toContain('updated instructions'); + }); }); diff --git a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts index 6931f4c3ce..5c4b92dcb4 100644 --- a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts @@ -27,6 +27,10 @@ import { PluginService } from '#/app/plugin/pluginService'; import type { ReloadSummary } from '#/app/plugin/types'; import { IProviderService } from '#/kosong/provider/provider'; import { IHostFsWatchService, type HostFsChange, type IHostFsWatchHandle } from '#/os/interface/hostFsWatch'; +import { IAppStateService } from '#/app/state/appState'; +import { AppStateService } from '#/app/state/appStateService'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; +import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IConfigService } from '#/app/config/config'; import { @@ -39,7 +43,11 @@ import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/us import { InMemorySkillDiscovery } from '#/app/skillCatalog/inMemorySkillDiscovery'; import type { SkillContribution } from '#/app/skillCatalog/skillSource'; import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog'; -import { WorkspaceSkillCatalogService } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalogService'; +import { + WorkspaceSkillCatalogService, + workspaceSkillCatalogContributionsKey, + workspaceSkillCatalogMergedKey, +} from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalogService'; import { ExplicitFileSkillSource, IExplicitFileSkillSource } from '#/workspace/workspaceSkillCatalog/explicitFileSkillSource'; import { ExtraFileSkillSource, IExtraFileSkillSource } from '#/workspace/workspaceSkillCatalog/extraFileSkillSource'; import { IWorkspaceRootSkillSource, WorkspaceRootSkillSource } from '#/workspace/workspaceSkillCatalog/rootFileSkillSource'; @@ -231,6 +239,8 @@ describe('WorkspaceSkillCatalogService', () => { registerScopedService(LifecycleScope.Workspace, IExtraFileSkillSource, ExtraFileSkillSource); registerScopedService(LifecycleScope.Workspace, IWorkspaceRootSkillSource, WorkspaceRootSkillSource); registerScopedService(LifecycleScope.Workspace, IPluginSkillSource, PluginSkillSource); + registerScopedService(LifecycleScope.App, IAppStateService, AppStateService); + registerScopedService(LifecycleScope.Workspace, IWorkspaceStateService, WorkspaceStateService); }); it('merges global and project skills; project wins on name collision', async () => { @@ -257,6 +267,24 @@ describe('WorkspaceSkillCatalogService', () => { host.dispose(); }); + it('registers contributions and the merged view into the workspace state container', async () => { + const store = new InMemorySkillDiscovery(); + store.setProjectSkills([stubSkill('project-only')]); + const ws = workspaceContextStub('/work'); + const { host, workspace } = makeHost(store, ws); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + const states = workspace.accessor.get(IWorkspaceStateService); + const contributions = states.get(workspaceSkillCatalogContributionsKey); + expect([...contributions.keys()]).toContain('workspace'); + expect(states.get(workspaceSkillCatalogMergedKey)).toBe(catalog.catalog); + // A class instance collapses to a marker in the JSON-safe snapshot. + expect(states.snapshot()['workspaceSkillCatalog.merged']).toBe('(InMemorySkillCatalog)'); + host.dispose(); + }); + it('orders project, user and plugin skills as project > user > plugin', async () => { const store = new InMemorySkillDiscovery(); store.setUserSkills([ diff --git a/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts index d21b6d734a..dcf874cfc1 100644 --- a/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceTrust/workspaceTrust.test.ts @@ -1,7 +1,8 @@ /** * Scenario: workspace trust — explicit trust/untrust flips persisted outside * the workspace, idempotency and the change event, per-root independence, - * and marker survival across a restart. + * marker survival across a restart, and the `workspaceTrust.trusted` state + * registration. * * Exercises the real `WorkspaceTrustService` against the real node-fs * `JsonAtomicDocumentStore` over a temp home. Run: @@ -21,12 +22,18 @@ import { createServices } from '#/_base/di/test'; import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IWorkspaceTrust, type WorkspaceTrustChange, } from '#/workspace/workspaceTrust/workspaceTrust'; -import { WorkspaceTrustService } from '#/workspace/workspaceTrust/workspaceTrustService'; +import { + WorkspaceTrustService, + workspaceTrustTrustedKey, +} from '#/workspace/workspaceTrust/workspaceTrustService'; + +import { registerStateServices } from '../../state/stubs'; describe('WorkspaceTrustService', () => { let homeDir: string; @@ -47,10 +54,14 @@ describe('WorkspaceTrustService', () => { ]); }); - function createService(root: string, events?: WorkspaceTrustChange[]): IWorkspaceTrust { + function createService( + root: string, + events?: WorkspaceTrustChange[], + ): { service: IWorkspaceTrust; states: IWorkspaceStateService } { const ix = createServices(disposables, { strict: true, additionalServices: (reg) => { + registerStateServices(reg); reg.definePartialInstance(IWorkspaceContext, { cwd: root }); reg.defineInstance( IAtomicDocumentStore, @@ -63,11 +74,11 @@ describe('WorkspaceTrustService', () => { if (events !== undefined) { service.onDidChange((change) => events.push(change)); } - return service; + return { service, states: ix.get(IWorkspaceStateService) }; } it('defaults to untrusted when no marker exists', async () => { - const service = createService(cwd); + const { service } = createService(cwd); await service.ready; expect(service.isTrusted()).toBe(false); @@ -76,7 +87,7 @@ describe('WorkspaceTrustService', () => { it('trust() flips the state, fires once, and stays idempotent', async () => { const events: WorkspaceTrustChange[] = []; - const service = createService(cwd, events); + const { service } = createService(cwd, events); await service.ready; await service.trust(); @@ -89,7 +100,7 @@ describe('WorkspaceTrustService', () => { it('untrust() revokes the state and both directions stay idempotent', async () => { const events: WorkspaceTrustChange[] = []; - const service = createService(cwd, events); + const { service } = createService(cwd, events); await service.ready; await service.untrust(); @@ -102,11 +113,11 @@ describe('WorkspaceTrustService', () => { }); it('keeps the marker across a restart', async () => { - const first = createService(cwd); + const { service: first } = createService(cwd); await first.ready; await first.trust(); - const second = createService(cwd); + const { service: second } = createService(cwd); await second.ready; expect(second.isTrusted()).toBe(true); @@ -115,11 +126,11 @@ describe('WorkspaceTrustService', () => { it('tracks different roots independently', async () => { const other = mkdtempSync(join(tmpdir(), 'kimi-workspace-trust-other-')); try { - const first = createService(cwd); + const { service: first } = createService(cwd); await first.ready; await first.trust(); - const second = createService(other); + const { service: second } = createService(other); await second.ready; expect(second.isTrusted()).toBe(false); @@ -127,4 +138,21 @@ describe('WorkspaceTrustService', () => { await rm(other, { recursive: true, force: true }); } }); + + it('registers the trusted flag into the workspace state container', async () => { + const { service, states } = createService(cwd); + await service.ready; + + expect(states.has(workspaceTrustTrustedKey)).toBe(true); + expect(states.get(workspaceTrustTrustedKey)).toBe(false); + + const seen: boolean[] = []; + states.onDidChange(workspaceTrustTrustedKey)((value) => seen.push(value)); + await service.trust(); + await service.untrust(); + + expect(seen).toEqual([true, false]); + expect(states.get(workspaceTrustTrustedKey)).toBe(false); + expect(states.snapshot()['workspaceTrust.trusted']).toBe(false); + }); });