From ec67eed544cc9352a94865b510de764440d23174 Mon Sep 17 00:00:00 2001 From: tournierjc Date: Wed, 30 Sep 2026 21:50:10 +0200 Subject: [PATCH] feat(server): expose skill policy and knowledge review over HTTP Add the session-server routes for the Skill-management surface introduced in #99, so a client other than the TUI can read and act on it: - `POST /sessions//skill-policy` sets one Session's Skill dispositions. - `GET /skills/proposals` lists Skill/Memory drafts awaiting human review. - `POST /skills/proposals//review` approves or rejects one draft. Every route is capability-optional: a runtime that does not expose the method answers 404 with the same wording the existing write surface uses. Review fixes on top of the first revision: - Clamp `limit` to the draft store's own 200 cap, and pin the proposals route to its two path shapes so a stray `/skills/` keeps falling through to the plain 404 tail whatever the runtime exposes. - Translate the knowledge-proposal domain errors at the boundary: a proposal that is gone answers 404 and an already-reviewed one 409, instead of the bare 500 a client cannot tell from a server fault. - Make the skill-policy fake merge across calls and record what the runtime receives, so the suite pins forwarding (kind/limit, `null`-clearing) instead of echoing the request back; declare the third route in the minimal-runtime 404 case. - Document the review response shape and the limit cap in docs/harness-integration.md, and point docs/tui-capabilities.md at the HTTP surface. Co-authored-by: Cursor Agent Co-authored-by: hermes-agent --- docs/harness-integration.md | 10 +- docs/tui-capabilities.md | 7 + packages/tui/src/server/http.ts | 192 +++++++++++++++++ packages/tui/src/server/runtime.ts | 4 + packages/tui/test/unit/server-http.test.ts | 227 ++++++++++++++++++++- 5 files changed, 436 insertions(+), 4 deletions(-) diff --git a/docs/harness-integration.md b/docs/harness-integration.md index 3b8f58c94..92c7c47e5 100644 --- a/docs/harness-integration.md +++ b/docs/harness-integration.md @@ -259,6 +259,9 @@ the LAN, `http://192.168.1.50:9430`. | `POST` | `/sessions//pin` | Pin/unpin. Body: `{pinned}`. | | `GET` | `/sessions//rewind-preview/` · `POST /sessions//rewind` | Rewind preview / rewind. Body: `{userMessageId, rewindTurnDiff?}`. | | `GET` | `/skills` | Skill list. Query: `agent`, `keyword`, `workspaceDir`. | +| `POST` | `/sessions//skill-policy` | Set Session Skill dispositions. Body: `{dispositions?:{:"mandatory"|"optional"|"forbidden"|null}, closed?:boolean}`. Returns the Session (with `skillPolicy`). | +| `GET` | `/skills/proposals` | Skill/Memory drafts awaiting human review. Query: `status` (`pending`\|`approved`\|`rejected`\|`cancelled`), `kind` (`skill`\|`memory`), `sessionId`, `limit` (default 50, values above 200 clamped to 200). Returns `{proposals:[…]}` with each proposal's `id`, `kind`, `action`, `status`, `title`, `summary`, `draft`, `editedDraft?` and the provenance fields. | +| `POST` | `/skills/proposals//review` | Approve or reject a draft. Body: `{decision:"approve"\|"reject", editedDraft?, reviewNote?}`. Returns `{applied, title, status}`: `applied` is `true` when an approval wrote the draft, `false` when the decision was only recorded (a rejection). Reviewing an unknown proposal answers `404` and an already-reviewed one `409`, both leaving the stored proposal untouched. | | `GET` | `/mcp` | MCP server list. Query: `keyword`, `sessionId`. | | `GET` | `/status` | Runtime diagnostics, account status, permission mode, model roster. | | `GET` | `/events` | Runtime event stream (`text/event-stream`): `questionnaire.ask`, `permission.ask`, `session.created`, `session.queue.updated`, … One subscription per connection; `: ping` keepalives every 15 s. | @@ -390,8 +393,11 @@ permissions. Loopback does not hide the port from other users on the same machine; the token file mode is what keeps those users out. There is no per-turn subscription endpoint that replays a turn started by another connection (`/events` carries Runtime events, not deltas), so a second -device joining a live turn polls the transcript until it settles. Skill writes -(create/edit/delete) are not exposed; `GET /skills` is read-only. +device joining a live turn polls the transcript until it settles. Direct Skill +file create/edit/delete is not exposed; idle Skill/Memory drafts land as +proposals under `/skills/proposals` and apply only after human +approve/reject via `/skills/proposals//review`. Per-session Skill +dispositions use `POST /sessions//skill-policy`. ## 4. Notes for callers diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 0067ad4fa..a2d1b6868 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -209,6 +209,13 @@ Interactive control is `/skills` in the TUI: Approval writes the draft only after the write succeeds. A failed write leaves the proposal pending so it can be retried or rejected. Rejection never writes. +The session server exposes the same writes to remote clients: +`POST /sessions//skill-policy` sets one Session's dispositions, and +`GET /skills/proposals` plus `POST /skills/proposals//review` list and decide +pending drafts. Routes, query parameters, and status codes are in +[harness integration](harness-integration.md). Direct Skill file create, edit, and +delete remain unexposed over HTTP. + Each user turn may add a `` reminder for Skills whose name or description overlaps the latest prompt, and a `` reminder for short overlapping Memory excerpts. Forbidden and closed-catalog Skills are diff --git a/packages/tui/src/server/http.ts b/packages/tui/src/server/http.ts index ae41c2f49..8e9c43e10 100644 --- a/packages/tui/src/server/http.ts +++ b/packages/tui/src/server/http.ts @@ -15,6 +15,8 @@ export const DEFAULT_TUI_SERVER_PORT = 8788; const TUI_SERVER_LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']); const MAX_SESSION_ID_SEGMENT_LENGTH = 256; const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024; +/** Mirrors the draft store's own cap (`local-runtime/src/knowledge/proposal-store.ts`). */ +const MAX_KNOWLEDGE_PROPOSAL_LIMIT = 200; export interface TuiServerLogger { info(message: string): void; @@ -204,6 +206,17 @@ async function handleTuiServerRequest( return; } + // Knowledge proposals (idle Skill/Memory drafts awaiting human review). The + // path shape is pinned here so a stray `/skills/` keeps falling through + // to the 404 tail of this function, whatever capabilities the runtime exposes. + if ( + segments[0] === 'skills' && + segments[1] === 'proposals' && + (segments.length === 2 || (segments.length === 4 && segments[3] === 'review')) + ) { + await handleSkillProposals(runtime, method, segments, url, request, response); + return; + } if (segments[0] === 'skills' && segments.length === 1) { await handleTopLevelList(runtime, method, url, response, 'listSkills'); return; @@ -694,6 +707,37 @@ async function handleTuiServerRequest( return; } + // /sessions/:id/skill-policy — set mandatory/optional/forbidden Skill dispositions. + if (sub === 'skill-policy' && segments.length === 3 && method === 'POST') { + if (!runtime.updateSessionSkillPolicy) { + sendJson(response, 404, { error: 'skill policy is not supported by this runtime' }); + return; + } + await handleAction(response, async () => { + const body = (await readJsonBody(request)) as { + dispositions?: unknown; + closed?: unknown; + }; + const dispositions = parseSkillPolicyDispositions(body.dispositions); + const closed = + body.closed === undefined + ? undefined + : typeof body.closed === 'boolean' + ? body.closed + : (() => { + throw new InvalidTuiServerBodyError('closed must be a boolean'); + })(); + if (dispositions === undefined && closed === undefined) { + throw new InvalidTuiServerBodyError('dispositions or closed is required'); + } + return runtime.updateSessionSkillPolicy!(sessionId, { + ...(dispositions !== undefined ? { dispositions } : {}), + ...(closed !== undefined ? { closed } : {}), + }); + }); + return; + } + // /sessions/:id/rewind + /sessions/:id/rewind-preview/:userMessageId if (sub === 'rewind' && segments.length === 3 && method === 'POST' && runtime.rewindSession) { await handleAction(response, async () => { @@ -752,6 +796,121 @@ async function handleTuiServerRequest( sendJson(response, 404, { error: 'not found' }); } +// --------------------------------------------------------------------------- +// Skill policy + knowledge proposals (kinetick-code #99) +// --------------------------------------------------------------------------- + +async function handleSkillProposals( + runtime: TuiServerRuntime, + method: string, + segments: string[], + url: URL, + request: IncomingMessage, + response: ServerResponse, +): Promise { + // GET /skills/proposals + if (segments.length === 2) { + if (method !== 'GET' && method !== 'HEAD') { + sendJson(response, 405, { error: 'method not allowed' }); + return; + } + if (!runtime.listKnowledgeProposals) { + sendJson(response, 404, { error: 'knowledge proposals are not supported by this runtime' }); + return; + } + await sendRuntimeJson(response, async () => { + const status = url.searchParams.get('status') ?? undefined; + const kind = url.searchParams.get('kind') ?? undefined; + const sessionId = url.searchParams.get('sessionId') ?? undefined; + const limit = clampKnowledgeProposalLimit(url.searchParams.get('limit')); + if ( + status !== undefined && + status !== 'pending' && + status !== 'approved' && + status !== 'rejected' && + status !== 'cancelled' + ) { + throw new InvalidTuiServerQueryError(`invalid status: ${status}`); + } + if (kind !== undefined && kind !== 'skill' && kind !== 'memory') { + throw new InvalidTuiServerQueryError(`invalid kind: ${kind}`); + } + const proposals = await runtime.listKnowledgeProposals!({ + ...(status + ? { status: status as 'pending' | 'approved' | 'rejected' | 'cancelled' } + : {}), + ...(kind ? { kind: kind as 'skill' | 'memory' } : {}), + ...(sessionId ? { sessionId } : {}), + ...(limit !== undefined ? { limit } : {}), + }); + return { proposals }; + }); + return; + } + + // POST /skills/proposals/:id/review + if (segments.length === 4 && segments[3] === 'review') { + if (method !== 'POST') { + sendJson(response, 405, { error: 'method not allowed' }); + return; + } + if (!runtime.reviewKnowledgeProposal) { + sendJson(response, 404, { error: 'knowledge proposals are not supported by this runtime' }); + return; + } + const proposalId = decodeURIComponent(segments[2]!); + await handleAction(response, async () => { + const body = (await readJsonBody(request)) as { + decision?: unknown; + editedDraft?: unknown; + reviewNote?: unknown; + }; + if (body.decision !== 'approve' && body.decision !== 'reject') { + throw new InvalidTuiServerBodyError('decision must be approve | reject'); + } + if (body.editedDraft !== undefined && typeof body.editedDraft !== 'string') { + throw new InvalidTuiServerBodyError('editedDraft must be a string'); + } + if (body.reviewNote !== undefined && typeof body.reviewNote !== 'string') { + throw new InvalidTuiServerBodyError('reviewNote must be a string'); + } + return runtime.reviewKnowledgeProposal!({ + proposalId, + decision: body.decision, + ...(typeof body.editedDraft === 'string' ? { editedDraft: body.editedDraft } : {}), + ...(typeof body.reviewNote === 'string' ? { reviewNote: body.reviewNote } : {}), + }); + }); + return; + } + + sendJson(response, 404, { error: 'not found' }); +} + +function parseSkillPolicyDispositions( + value: unknown, +): Record | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new InvalidTuiServerBodyError('dispositions must be an object'); + } + const next: Record = {}; + for (const [name, disposition] of Object.entries(value as Record)) { + if ( + disposition !== null && + disposition !== 'mandatory' && + disposition !== 'optional' && + disposition !== 'forbidden' + ) { + throw new InvalidTuiServerBodyError( + `disposition for ${name} must be mandatory | optional | forbidden | null`, + ); + } + next[name] = disposition; + } + return next; +} + // --------------------------------------------------------------------------- // Top-level resources (skills, MCP, status) // --------------------------------------------------------------------------- @@ -984,10 +1143,32 @@ function toHttpStatus(error: unknown): number { return status; } } + const domainStatus = knowledgeProposalHttpStatus(error); + if (domainStatus !== undefined) return domainStatus; if (isSessionLookupMiss(error)) return 404; return 500; } +/** + * The knowledge-proposal domain reports failures with a `code`, not a transport + * `status` (`local-runtime/src/knowledge/proposal-store.ts`), so its errors + * reach this boundary as a bare 500 — and the review route is exactly where a + * client retries: a proposal that is gone (404) must stay distinguishable from + * one that was already reviewed (409), and from the server actually failing + * (500, which stays retryable because the decision is recorded only after the + * draft applied). + * + * Matched by name rather than `instanceof`: the class lives in `local-runtime`, + * which this package does not depend on. + */ +function knowledgeProposalHttpStatus(error: unknown): number | undefined { + if (!(error instanceof Error) || error.name !== 'KnowledgeProposalError') return undefined; + const code = (error as { readonly code?: unknown }).code; + if (code === 'not-found') return 404; + if (code === 'not-pending') return 409; + return undefined; +} + function isSessionLookupMiss(error: unknown): boolean { return ( error instanceof Error && /(?:did not return session|session not found)/iu.test(error.message) @@ -1031,6 +1212,17 @@ function parseLimitQuery(value: string | null): number | undefined { return limit; } +/** + * `parseLimitQuery` bounds the low end only, which is fine for paged reads of a + * bounded collection. The draft store keeps every past proposal, so the query + * string needs a ceiling too: an unclamped `?limit=100000` is a request to + * serialize the whole store over the LAN. + */ +function clampKnowledgeProposalLimit(value: string | null): number | undefined { + const limit = parseLimitQuery(value); + return limit === undefined ? undefined : Math.min(limit, MAX_KNOWLEDGE_PROPOSAL_LIMIT); +} + function parseBooleanQuery(query: URLSearchParams, key: string): boolean | undefined { const value = query.get(key); if (value === null) return undefined; diff --git a/packages/tui/src/server/runtime.ts b/packages/tui/src/server/runtime.ts index 517e866a6..7a5f993ad 100644 --- a/packages/tui/src/server/runtime.ts +++ b/packages/tui/src/server/runtime.ts @@ -57,8 +57,12 @@ export type TuiServerRuntime = Pick< | 'getSessionUsage' | 'requestCompaction' | 'listSkills' + | 'listKnowledgeProposals' + | 'reviewKnowledgeProposal' | 'listMcpServers' | 'getContextSnapshot' + // Session skill policy (kinetick-code #99). + | 'updateSessionSkillPolicy' // Configuration (models, permission mode, diagnostics, account). | 'getRuntimeDiagnostics' | 'getAccountStatus' diff --git a/packages/tui/test/unit/server-http.test.ts b/packages/tui/test/unit/server-http.test.ts index a732248a1..bd010d1a5 100644 --- a/packages/tui/test/unit/server-http.test.ts +++ b/packages/tui/test/unit/server-http.test.ts @@ -28,6 +28,14 @@ interface RecordedRuntime { readonly messageInputs: { sessionId: string; input?: { limit?: number; before?: string } }[]; } +/** The filter object `GET /skills/proposals` builds, as the port declares it. */ +type KnowledgeProposalFilter = { + status?: 'pending' | 'approved' | 'rejected' | 'cancelled'; + kind?: 'skill' | 'memory'; + sessionId?: string; + limit?: number; +}; + /** Mirrors the Runtime's transport-neutral failure: `AppError(status, key, message)`. */ function sessionNotFound(sessionId: string): Error { return Object.assign(new Error(`Session not found: ${sessionId}`), { @@ -36,6 +44,15 @@ function sessionNotFound(sessionId: string): Error { }); } +/** + * Mirrors `KnowledgeProposalError` (`local-runtime/src/knowledge/proposal-store.ts`): + * a domain `code` and a `name`, no transport `status` — so the boundary has to + * translate it, and the HTTP suite has to prove the translation. + */ +function knowledgeProposalError(code: 'not-found' | 'not-pending', message: string): Error { + return Object.assign(new Error(message), { name: 'KnowledgeProposalError', code }); +} + function createFakeRuntime(): RecordedRuntime { const sessions: TuiSession[] = [ { sessionId: 'session-1', title: 'Fix the parser', workspaceDir: '/workspace' }, @@ -285,9 +302,29 @@ describe('session server HTTP contract', () => { // --- Write surface ------------------------------------------------------- - async function startWritable(): Promise { + /** + * The write surface records what the runtime actually received, so a test can + * assert the forwarding contract rather than the fake's own echo of the + * request. + */ + interface WriteWatch { + readonly calls: string[]; + readonly proposalFilters: KnowledgeProposalFilter[]; + readonly reviews: { proposalId: string; decision: string }[]; + } + + async function startWritable(): Promise { const writable = createFakeRuntime(); const calls: string[] = []; + const proposalFilters: KnowledgeProposalFilter[] = []; + const reviews: { proposalId: string; decision: string }[] = []; + // Mirrors the product's per-Session policy: `applySessionSkillPolicyPatch` + // merges a patch onto the stored dispositions (a `null` clears the entry) + // and `listSkillsByDisposition` projects each bucket sorted. + const storedPolicy = { + dispositions: new Map(), + closed: false, + }; Object.assign(writable.runtime, { async createSession(input: { workspaceDir: string; title?: string }) { calls.push(`create:${input.workspaceDir}:${input.title ?? ''}`); @@ -327,6 +364,70 @@ describe('session server HTTP contract', () => { async listSkills() { return { skills: [{ name: 'pdf', description: 'work with pdf files' }] }; }, + async updateSessionSkillPolicy( + sessionId: string, + patch: { + dispositions?: Record; + closed?: boolean; + }, + ) { + calls.push(`skill-policy:${sessionId}:${JSON.stringify(patch)}`); + for (const [name, disposition] of Object.entries(patch.dispositions ?? {})) { + if (disposition === null) storedPolicy.dispositions.delete(name); + else storedPolicy.dispositions.set(name, disposition); + } + if (patch.closed !== undefined) storedPolicy.closed = patch.closed; + const byDisposition = (disposition: 'mandatory' | 'optional' | 'forbidden') => + [...storedPolicy.dispositions] + .filter(([, value]) => value === disposition) + .map(([name]) => name) + .sort((left, right) => left.localeCompare(right)); + return { + sessionId, + workspaceDir: '/workspace', + skillPolicy: { + closed: storedPolicy.closed, + mandatory: byDisposition('mandatory'), + optional: byDisposition('optional'), + forbidden: byDisposition('forbidden'), + }, + }; + }, + async listKnowledgeProposals(filter: KnowledgeProposalFilter = {}) { + calls.push(`list-proposals:${JSON.stringify(filter)}`); + proposalFilters.push(filter); + return [ + { + id: 'kp_abc123', + kind: 'skill' as const, + action: 'create' as const, + status: 'pending', + title: 'Capture pdf workflow', + summary: 'Draft a pdf Skill from recent turns', + draft: '---\nname: pdf-flow\ndescription: PDF workflow\n---\n# PDF', + }, + ]; + }, + async reviewKnowledgeProposal(input: { + proposalId: string; + decision: 'approve' | 'reject'; + editedDraft?: string; + }) { + calls.push(`review:${input.proposalId}:${input.decision}`); + reviews.push({ proposalId: input.proposalId, decision: input.decision }); + // The store's own failures, which the HTTP boundary has to translate. + if (input.proposalId === 'kp_missing') { + throw knowledgeProposalError('not-found', `Proposal ${input.proposalId} not found`); + } + if (input.proposalId === 'kp_reviewed') { + throw knowledgeProposalError('not-pending', `Proposal ${input.proposalId} is approved`); + } + return { + applied: input.decision === 'approve', + title: 'Capture pdf workflow', + status: input.decision === 'approve' ? 'approved' : 'rejected', + }; + }, }); recorded = writable; (recorded as { runtime: Record }).runtime = writable.runtime; @@ -338,7 +439,7 @@ describe('session server HTTP contract', () => { token: SERVER_TOKEN, logger: { info: () => undefined, warn: () => undefined }, }); - void calls; + return { calls, proposalFilters, reviews }; } it('creates, renames, and deletes sessions through the write verbs', async () => { @@ -417,12 +518,134 @@ describe('session server HTTP contract', () => { expect(list.skills[0]?.name).toBe('pdf'); }); + it('merges session skill policy patches across calls', async () => { + await startWritable(); + const first = await get('/sessions/session-1/skill-policy', { + method: 'POST', + body: JSON.stringify({ dispositions: { pdf: 'mandatory', xlsx: 'forbidden' } }), + }); + expect(first.status).toBe(200); + expect((await first.json()) as { skillPolicy: unknown }).toMatchObject({ + skillPolicy: { closed: false, mandatory: ['pdf'], optional: [], forbidden: ['xlsx'] }, + }); + + // The `/skills clear ` shape: a `null` disposition clears one entry + // while the rest of the patch accumulates on the stored policy. + const second = await get('/sessions/session-1/skill-policy', { + method: 'POST', + body: JSON.stringify({ dispositions: { pdf: null, xlsx: 'optional' }, closed: true }), + }); + expect(second.status).toBe(200); + expect((await second.json()) as { skillPolicy: unknown }).toMatchObject({ + skillPolicy: { closed: true, mandatory: [], optional: ['xlsx'], forbidden: [] }, + }); + + const empty = await get('/sessions/session-1/skill-policy', { + method: 'POST', + body: JSON.stringify({}), + }); + expect(empty.status).toBe(400); + }); + + it('forwards the knowledge proposal filters and returns the review outcome', async () => { + const watch = await startWritable(); + const proposals = await get( + '/skills/proposals?status=pending&kind=memory&sessionId=session-1&limit=30', + ); + expect(proposals.status).toBe(200); + const page = (await proposals.json()) as { proposals: { id: string }[] }; + expect(page.proposals[0]?.id).toBe('kp_abc123'); + // `kind` and `limit` are part of the port's filter contract: the boundary + // has to forward them, not only the two fields the first client used. + expect(watch.proposalFilters).toEqual([ + { status: 'pending', kind: 'memory', sessionId: 'session-1', limit: 30 }, + ]); + + const approved = await get('/skills/proposals/kp_abc123/review', { + method: 'POST', + body: JSON.stringify({ decision: 'approve', reviewNote: 'looks right' }), + }); + expect(approved.status).toBe(200); + expect(await approved.json()).toEqual({ + applied: true, + title: 'Capture pdf workflow', + status: 'approved', + }); + expect(watch.reviews).toEqual([{ proposalId: 'kp_abc123', decision: 'approve' }]); + + const badDecision = await get('/skills/proposals/kp_abc123/review', { + method: 'POST', + body: JSON.stringify({ decision: 'maybe' }), + }); + expect(badDecision.status).toBe(400); + }); + + it('clamps an oversized proposal limit and rejects an unknown filter value', async () => { + const watch = await startWritable(); + // The draft store keeps every past proposal, so the query string alone must + // not be able to ask for all of it over the LAN. + expect((await get('/skills/proposals?limit=100000')).status).toBe(200); + expect(watch.proposalFilters.at(-1)?.limit).toBe(200); + expect((await get('/skills/proposals?status=done')).status).toBe(400); + expect((await get('/skills/proposals?kind=notes')).status).toBe(400); + expect((await get('/skills/proposals?limit=0')).status).toBe(400); + }); + + it('maps knowledge proposal domain codes to 404 and 409, not 500', async () => { + await startWritable(); + // A review of a proposal that is gone, then the double-approve retry a + // client walks into when it re-sends a decision it already made. + const missing = await get('/skills/proposals/kp_missing/review', { + method: 'POST', + body: JSON.stringify({ decision: 'approve' }), + }); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ error: 'Proposal kp_missing not found' }); + + const alreadyReviewed = await get('/skills/proposals/kp_reviewed/review', { + method: 'POST', + body: JSON.stringify({ decision: 'approve' }), + }); + expect(alreadyReviewed.status).toBe(409); + expect(await alreadyReviewed.json()).toEqual({ error: 'Proposal kp_reviewed is approved' }); + }); + + it('keeps every non-route /skills path on the plain 404 tail', async () => { + await startWritable(); + // `/skills/proposals` is the only sub-resource under `/skills`; a stray + // path must answer the same 404 whatever capabilities the runtime exposes, + // instead of inheriting the proposals route's own failure wording. + const strays = ['/skills/pdf', '/skills/proposals/kp_abc123', '/skills/proposals/a/b/c']; + for (const path of strays) { + const response = await get(path); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: 'not found' }); + } + }); + it('reports unsupported capabilities as 404 for the minimal runtime', async () => { await start(); // The minimal read-only fake runtime exposes none of the write surface. expect((await get('/sessions/session-1/prompt', { method: 'POST', body: '{}' })).status).toBe(404); expect((await get('/permissions')).status).toBe(404); expect((await get('/skills')).status).toBe(404); + expect((await get('/skills/proposals')).status).toBe(404); + expect( + ( + await get('/skills/proposals/kp_abc123/review', { + method: 'POST', + body: JSON.stringify({ decision: 'approve' }), + }) + ).status, + ).toBe(404); + expect( + ( + await get('/sessions/session-1/skill-policy', { + method: 'POST', + body: JSON.stringify({ dispositions: { pdf: 'mandatory' } }), + }) + ).status, + ).toBe(404); expect((await get('/sessions/session-1/delegation')).status).toBe(404); expect((await get('/events')).status).toBe(404); });