kimi-code/packages/klient/test/facade.test.ts
Haozhe 64f053cf46
feat: agent-core-v2 permission/workspace refactors and transcript durability (#2021)
* refactor(agent-core-v2): extract toolApproval domain from permissionGate

- add agent-scoped `toolApproval` domain owning the approval round-trip:
  builds approval requests, drives the session/approval broker, publishes
  permission.approval.* events, records session approval rules, and
  resolves ask continuations
- slim permissionPolicy down to the static risk-adjudication chain; drop
  the dynamic `registerPolicy` mechanism
- move harness constraints out of the policy chain into their owning
  domains as toolExecutor hooks ordered before 'permission': plan-mode
  guard (plan), swarm batch exclusivity and AgentSwarm approve (swarm),
  goal-start review (goal), exit-plan review (plan/exitPlanModeReview),
  and btw deny (session/btw)
- delete the now-unused policies (deny-all, plan-mode-guard-deny,
  plan-mode-tool-approve, goal-start-review-ask, swarm-mode-agent-swarm-
  approve, agent-swarm-exclusive-deny)
- update Permission.md, AGENTS.md, and check-domain-layers for the new
  domain layout

* feat(kimi-inspect): add App Services view for app-scope service reflection

- add `services` top-level view (`AppServicesView`) on the NavRail, showing
  the full-width app-scope Service panel grid; session/agent scopes stay in
  the Chat view's Inspector
- extract shared `ServicePanels` from Inspector and add `methodArgs` helper
  to build per-method argument editors from channel metadata
- support variadic service calls in `panels.ts` (`call(svc, method, ...args)`)
- update agent-core-dev skill docs for the toolApproval extraction and the
  guard/review-off-chain permission design

* refactor(agent-core-v2): restructure workspace domains

- rename workspaceRegistry to workspace and workspaceLocalConfig to
  projectLocalConfig
- extract id-spelling resolution into the workspaceAliases domain
  (IWorkspaceAliases)
- extract workspace-centric session queries into workspaceSessions
  (IWorkspaceSessions)
- update kap-server routes, klient contracts, and related tests to the
  new services

* feat(transcript): add op-batch sequencing and point-to-point catch-up

- wire: transcriptSeqSchema with per-agent batch seq watermark on
  transcript.reset/ops and the REST transcript response, the
  transcript_since subscription cursor, and the GET transcript/ops
  catch-up shape; seq stays optional everywhere so legacy peers fall
  back to loss-signal-driven refreshes
- wire: drop interactionFrame, interactions live on the interaction
  item in the ops stream
- kap-server: TranscriptService assigns consecutive per-agent batch
  seqs and retains them in a bounded in-memory journal; the
  transcript_since cursor replays journaled batches instead of a
  baseline reset, and the baseline reset is now items-empty because
  history always pages in over REST
- kimi-inspect: transcript REST/WS clients track the op-batch
  watermark and run seq-gap/reconnect catch-up with full-refresh
  fallback; add the Transcript audit panel (AuditTrail timeline,
  structural diff, state tree) docked right of the chat
- docs: sync AGENTS.md and agent-core-dev skill notes with the new
  transcript contract

* feat(transcript): persist plan revisions and task/interaction facts, add user-messages endpoint

- record each ExitPlanMode submission as a versioned plan blob via a reference-only plan.revision op, projected live and cold as a plan.revision marker plus the plan badge (reviewPath, version)
- persist task.started/task.terminated (with a bounded output tail) and interaction.request/interaction.resolved ops, and add foldFacts so a cold transcript rebuilds tasks, interactions, todos and goal/plan/swarm meta from the wire journal
- add GET /sessions/{session_id}/transcript/user-messages returning all turn-opening prompts grouped per agent

* fix(agent-core-v2): anchor external PreToolUse hooks before the permission gate

The toolApproval extraction forces the gate to construct early (planService injects it to anchor plan-guard), so the 'permission' hook registered ahead of 'externalHooks' and a policy ask waited on the approval broker before PreToolUse could block, hanging the turn. Fetch the gate first in registerListeners and register the PreToolUse hook with before: 'permission', falling back to appending when the gate is stubbed without its hook.

* refactor(agent-core-v2): replace ordered onBeforeExecuteTool hook with veto-event pattern

- introduce BeforeToolExecuteEvent with veto/allow/pass/waitUntil statements
- add BeforeToolExecuteEmitter with two-pass fire (immediate then deferred)
- split readiness work into separate onWillExecuteTool participation event
- migrate all domain listeners: permissionGate, plan, goal, swarm, btw,
  externalHooks, toolDedupe, mcp
- remove IAgentPermissionGate force-injection for hook ordering
- update docs, tests, and domain-layer check to match

* refactor(agent-core-v2): unify veto payload on ExecutableToolResult

- veto() and waitUntil factories now carry a plain ExecutableToolResult:
  isError reads as a denial, anything else as a short-circuit;
  the block/reason/syntheticResult weak union is gone
- add denyToolExecution(reason) helper for the common denial shape, and
  narrow the fire/authorize return to BeforeExecuteDecision ({ veto } or
  { executionMetadata })
- narrow the policy 'result' resolution to { kind: 'result'; result }
- settle vetoed calls through a single normalize/merge path in the executor

* fix(agent-core-v2): pull up IAgentPermissionGate in agent activation

The permission gate only subscribes `onBeforeExecuteTool` from its
constructor. The veto-event refactor removed the ordering-driven
force-injections that used to pull it up, so without an explicit
resolution tool execution would run without policy adjudication.

* refactor(agent-core-v2): fold systemReminder domain into contextMemory appendTagged

- add `appendTagged(content, tag, origin)` to `IAgentContextMemoryService`,
  storing content pure with a `tag` field on `ContextMessage`
- apply the XML tag at projection time in `contextProjector` via the new
  `tag.ts` helpers (`wrapTag` / `applyTagToContent`)
- delete the `systemReminder` domain and migrate all call sites
  (contextInjector, goal, plugin, prompt, swarm, btw, sessionInit,
  toolSelectAnnouncements) to `appendTagged`
- build toolDedupe reminder strings with `wrapTag`

* refactor(klient): merge providers/models/catalog into global.kosong facade

Converge three separate facade namespaces (global.providers,
global.models, global.catalog) into a single global.kosong facade
that exposes two domain concepts: provider (CRUD) and model
(read-only view). Add streaming generate() method for direct
LLM calls through the facade.

- Define ProviderAuth (api-key | oauth), ProviderInput,
  AnonymousProviderInput, GenerateInput, GenerateParams,
  GenerateEvent as klient-owned public types
- Extend KlientChannel with stream() for AsyncIterable transport
- Add streaming IPC protocol (stream/stream_data/stream_end/
  stream_error/stream_cancel frame types)
- Add StreamingProcedureContract, ScopedStreamCaller, and
  per-chunk zod validation in the contract layer
- Implement generate via dispatcher special-case routing to
  IModelCatalog.getRequester().request()
- Rename events: providers.changed -> kosong.providers.changed,
  models.changed -> kosong.models.changed,
  catalog.changed -> kosong.changed
- Remove GlobalProvidersFacade, GlobalModelsFacade,
  GlobalCatalogFacade, and ModelRecord from public exports
- Update all tests, examples, and README

BREAKING CHANGE: global.providers, global.models, and
global.catalog replaced by global.kosong; event names changed;
ModelRecord no longer exported.

* feat(transcript,kimi-inspect): add tag field to text frames and improve session creation

transcript:
- add optional `tag` field to TextFrame, textFrameSchema, and HistoryMessage
- propagate tag through contextTranscript MutableMessage

kimi-inspect:
- render tagged frames with violet badge and distinct styling in ChatView
- skip cwd prompt for workspace-based session creation in Sidebar
- auto-bind default model on new sessions via resolveDefaultModel

* refactor(transcript): rename wire/ directory to contract/

The transcript package's REST/WS schemas and event types lived in
src/wire/, which collided with the engine's persisted wire.jsonl
record vocabulary. Rename it to src/contract/ so "wire" unambiguously
refers to wire.jsonl records (foldWireRecordFacts, HistoryWireRecord
stay unchanged).

- rename src/wire/{schema,events}.ts to src/contract/
- update index exports and test imports accordingly
- reword comments: "wire shape" -> "contract shape", "on the wire" ->
  "in ops" / "in transit" / "on the WS channel" / "transcript API"
- events.ts: "transcript frame" -> "transcript event" for WS envelope
  messages, avoiding confusion with TranscriptFrame
- kap-server tests: TranscriptWire/TurnWire/FrameWire/OpsCatchupWire/
  UserMessagesWire -> *Contract
- update AGENTS.md references

* refactor(transcript): rename wire/ directory to contract/

The transcript package's REST/WS schemas and event types lived in
src/wire/, which collided with the engine's persisted wire.jsonl
record vocabulary. Rename it to src/contract/ so "wire" unambiguously
refers to wire.jsonl records (foldWireRecordFacts, HistoryWireRecord
stay unchanged).

- rename src/wire/{schema,events}.ts to src/contract/
- update index exports and test imports accordingly
- reword comments: "wire shape" -> "contract shape", "on the wire" ->
  "in ops" / "in transit" / "on the WS channel" / "transcript API"
- kap-server tests: TranscriptWire/TurnWire/FrameWire/OpsCatchupWire/
  UserMessagesWire -> *Contract
- update AGENTS.md references

* Revert "refactor(agent-core-v2): fold systemReminder domain into contextMemory appendTagged"

This reverts commit 55afaa3d96f729c4f73a71f1fbd23d3f6087453b.

Restore the systemReminder domain: reminders go back to being baked
into message text at write time, and ContextMessage loses the `tag`
field (projection-time wrapping is removed with it).

* feat(transcript): add wire-equivalent detail, dedupe session events

- transcript: add step usage/timing/retry, turn durationMs/error/usage,
  tool inputText/progress, task resultSummary/error, meta.agent status,
  a global prompts entity, and the 'hook' marker
- kap-server: project the new fields in coreEventMap and suppress
  transcript-projected session events on connections subscribed to the
  transcript protocol (live fan-out and cursor replay)
- kimi-inspect: mechanical type sync for the new snapshot prompts field

* fix(klient): resolve lint errors in ipc channel stream and e2e matrix test
2026-07-22 19:21:56 +08:00

213 lines
7.9 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import type {
EventSourceRef,
IDisposable,
KlientChannel,
ScopeRef,
} from '../src/core/channel.js';
import { createKlientFromChannel } from '../src/core/klient.js';
import { KlientValidationError } from '../src/core/validation.js';
const tick = (ms = 0): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
/** Records calls, replays scripted results, and captures listen subscriptions. */
class FakeChannel implements KlientChannel {
readonly calls: Array<{ scope: ScopeRef; service: string; method: string; args: unknown[] }> = [];
readonly subscriptions: Array<{ source: EventSourceRef; dispose: ReturnType<typeof vi.fn> }> =
[];
result: unknown;
/** Keyed `${service}.${method}` result overrides. */
readonly results = new Map<string, unknown>();
private readonly handlers = new Map<number, (data: unknown) => void>();
private nextSub = 0;
call(scope: ScopeRef, service: string, method: string, args: unknown[]): Promise<unknown> {
this.calls.push({ scope, service, method, args });
const key = `${service}.${method}`;
return Promise.resolve(this.results.has(key) ? this.results.get(key) : this.result);
}
// eslint-disable-next-line @typescript-eslint/require-await
async *stream(_scope: ScopeRef, _service: string, _method: string, _args: unknown[]): AsyncIterableIterator<unknown> {
// stub — streaming is not exercised in facade tests
}
listen(_scope: ScopeRef, source: EventSourceRef, handler: (data: unknown) => void): IDisposable {
const id = this.nextSub;
this.nextSub += 1;
this.handlers.set(id, handler);
const dispose = vi.fn(() => {
this.handlers.delete(id);
});
this.subscriptions.push({ source, dispose });
return { dispose };
}
/** Push a raw payload into the Nth subscription (0-based). */
emit(index: number, data: unknown): void {
this.handlers.get(index)?.(data);
}
close(): Promise<void> {
return Promise.resolve();
}
}
const SUMMARY = {
id: 's1',
workspaceId: 'w1',
createdAt: 1,
updatedAt: 2,
archived: false,
};
describe('facade routing', () => {
it('reshapes single-object params into positional wire args', async () => {
const channel = new FakeChannel();
const klient = createKlientFromChannel(channel);
channel.result = { id: 'w1', root: '/x', name: 'n', createdAt: 1, lastOpenedAt: 2 };
await klient.global.workspaces.createOrTouch({ root: '/x', name: 'n' });
expect(channel.calls[0]).toMatchObject({
service: 'workspaceService',
method: 'createOrTouch',
args: ['/x', 'n'],
});
channel.result = undefined; // void output
await klient.global.plugins.setMcpServerEnabled({ id: 'p', server: 's', enabled: true });
expect(channel.calls[1]).toMatchObject({
service: 'pluginService',
method: 'setPluginMcpServerEnabled',
args: [{ id: 'p', server: 's', enabled: true }],
});
channel.results.set('oauthService.status', { loggedIn: false });
await klient.global.auth.status();
expect(channel.calls[2]).toMatchObject({
service: 'oauthService',
method: 'status',
args: [undefined],
});
});
it('env() fans out property reads and merges them', async () => {
const channel = new FakeChannel();
const klient = createKlientFromChannel(channel);
channel.result = 'v';
const env = await klient.global.env();
expect(env.platform).toBe('v');
expect(env.logsDir).toBe('v');
expect(channel.calls).toHaveLength(12);
expect(channel.calls.every((call) => call.service === 'bootstrapService')).toBe(true);
});
it('env() resolves once and serves repeats from the cache', async () => {
const channel = new FakeChannel();
const klient = createKlientFromChannel(channel);
channel.result = 'v';
await klient.global.env();
expect(channel.calls).toHaveLength(12);
const again = await klient.global.env();
expect(again.platform).toBe('v');
expect(channel.calls).toHaveLength(12);
});
});
describe('contract validation', () => {
it('rejects invalid input before the call leaves the client', async () => {
const channel = new FakeChannel();
const klient = createKlientFromChannel(channel);
await expect(
klient.global.sessions.list({ limit: '20' as unknown as number }),
).rejects.toBeInstanceOf(KlientValidationError);
expect(channel.calls).toHaveLength(0);
});
it('rejects drifted output payloads', async () => {
const channel = new FakeChannel();
const klient = createKlientFromChannel(channel);
channel.result = { id: 's1' }; // missing required SessionSummary fields
await expect(klient.global.sessions.get('s1')).rejects.toBeInstanceOf(KlientValidationError);
});
it('passes valid payloads through and returns parsed output', async () => {
const channel = new FakeChannel();
const klient = createKlientFromChannel(channel);
channel.result = SUMMARY;
await expect(klient.global.sessions.get('s1')).resolves.toEqual(SUMMARY);
});
it('validate:false skips both directions', async () => {
const channel = new FakeChannel();
const klient = createKlientFromChannel(channel, { validate: false });
channel.result = { anything: true };
await expect(
klient.global.sessions.list({ limit: '20' as unknown as number }),
).resolves.toEqual({ anything: true });
});
});
describe('event hub', () => {
it('maps public names to emitter sources and validates payloads', async () => {
const channel = new FakeChannel();
const klient = createKlientFromChannel(channel);
const seen: unknown[] = [];
const errors: Error[] = [];
klient.events.onError((error) => {
errors.push(error);
});
klient.events.on('kosong.providers.changed', (event) => seen.push(event));
expect(channel.subscriptions[0]?.source).toEqual({
kind: 'emitter',
service: 'providerService',
event: 'onDidChangeProviders',
});
channel.emit(0, { added: ['p1'], removed: [], changed: [] });
channel.emit(0, { added: 'not-an-array' });
await tick();
expect(seen).toEqual([{ added: ['p1'], removed: [], changed: [] }]);
expect(errors).toHaveLength(1);
expect(errors[0]).toBeInstanceOf(KlientValidationError);
});
it('shares one bus subscription across bus-derived events and filters by type', async () => {
const channel = new FakeChannel();
const klient = createKlientFromChannel(channel);
const archived: unknown[] = [];
const catalog: unknown[] = [];
const subA = klient.events.on('session.archived', (event) => archived.push(event));
const subB = klient.events.on('kosong.changed', (event) => catalog.push(event));
expect(channel.subscriptions).toHaveLength(1);
expect(channel.subscriptions[0]?.source).toEqual({ kind: 'stream', name: 'events' });
channel.emit(0, { type: 'event.session.archived', payload: { sessionId: 's1' } });
channel.emit(0, { type: 'event.model_catalog.changed', payload: { changed: [], unchanged: [], failed: [] } });
channel.emit(0, { type: 'unrelated.type', payload: {} });
await tick();
expect(archived).toEqual([{ sessionId: 's1' }]);
expect(catalog).toEqual([{ changed: [], unchanged: [], failed: [] }]);
subA.dispose();
expect(channel.subscriptions[0]?.dispose).not.toHaveBeenCalled();
subB.dispose();
expect(channel.subscriptions[0]?.dispose).toHaveBeenCalledTimes(1);
});
it('disposes the emitter subscription when the last listener detaches', async () => {
const channel = new FakeChannel();
const klient = createKlientFromChannel(channel);
const a = klient.events.on('config.changed', () => undefined);
const b = klient.events.on('config.changed', () => undefined);
expect(channel.subscriptions).toHaveLength(1);
a.dispose();
expect(channel.subscriptions[0]?.dispose).not.toHaveBeenCalled();
b.dispose();
expect(channel.subscriptions[0]?.dispose).toHaveBeenCalledTimes(1);
});
});