diff options
| author | Adam Malczewski <[email protected]> | 2026-06-06 18:55:53 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-06 18:55:53 +0900 |
| commit | 22936857685c318b71752d625808100b1a96e63e (patch) | |
| tree | 5e10a73d616c206e3820a8d8568e5f3d4c8a302e /packages/kernel/src | |
| parent | 969afc45f895230fe3da1c737f18e64452efc8f2 (diff) | |
| download | dispatch-22936857685c318b71752d625808100b1a96e63e.tar.gz dispatch-22936857685c318b71752d625808100b1a96e63e.zip | |
feat(frontend,wire): surface system (FE slice 1) + @dispatch/wire types-only split (B2)
FE slice 1 — backend-declared, frontend-agnostic surface system (verified live): new types-only @dispatch/ui-contract (SurfaceSpec / field kinds / region / ActionRef / catalog), surface-registry (typed service handle), transport-ws (Bun WS :24205, path-agnostic upgrade), surface-loaded-extensions (first real surface); kernel HostAPI.getExtensions; host-bin wiring; bin/up. Harness: retire AGENTS 'backend only', ORCHESTRATOR §3/§7/§8, frontend-design.md locked.
B2 — wire-types split (chat-slice prerequisite): new types-only @dispatch/wire single-sources the wire ABI (AgentEvent + 11 variants; conversation model Chunk/ChatMessage/Role/TurnId/StepId + 6 chunk variants; Usage) with zero @dispatch/* deps. @dispatch/kernel re-exports via shims so its public surface is byte-identical (zero consumer blast radius). transport-contract re-exports AgentEvent from @dispatch/wire and drops its @dispatch/kernel dependency, so HTTP clients (the web frontend) consume the wire without the kernel runtime.
tsc -b + biome clean; 460 vitest + 77 bun pass.
Diffstat (limited to 'packages/kernel/src')
| -rw-r--r-- | packages/kernel/src/contracts/conversation.ts | 101 | ||||
| -rw-r--r-- | packages/kernel/src/contracts/events.ts | 133 | ||||
| -rw-r--r-- | packages/kernel/src/contracts/extension.ts | 3 | ||||
| -rw-r--r-- | packages/kernel/src/contracts/provider.ts | 12 | ||||
| -rw-r--r-- | packages/kernel/src/host/host.test.ts | 111 | ||||
| -rw-r--r-- | packages/kernel/src/host/host.ts | 7 |
6 files changed, 154 insertions, 213 deletions
diff --git a/packages/kernel/src/contracts/conversation.ts b/packages/kernel/src/contracts/conversation.ts index c9ad0eb..ec9a389 100644 --- a/packages/kernel/src/contracts/conversation.ts +++ b/packages/kernel/src/contracts/conversation.ts @@ -1,91 +1,20 @@ /** * Conversation model — the kernel's representation of a dialogue. * - * The kernel owns only the types and pure transforms. Persistence is a core - * extension (conversation-store). A turn is one user→assistant cycle; a step - * is one LLM round-trip within a turn. Chunks are append-only. + * Re-exported from @dispatch/wire so the kernel barrel surface stays + * byte-identical. The canonical definitions live in @dispatch/wire. */ -/** Who produced a message. */ -export type Role = "system" | "user" | "assistant" | "tool"; - -/** Opaque identifier for a turn (one user→assistant cycle). */ -export type TurnId = string & { readonly __brand: "TurnId" }; - -/** Opaque identifier for a step (one LLM round-trip within a turn). */ -export type StepId = string & { readonly __brand: "StepId" }; - -/** - * A chunk is one ordered piece of a message — the atomic unit of the - * append-only conversation log. Discriminated by `type`. - */ -export type Chunk = - | TextChunk - | ThinkingChunk - | ToolCallChunk - | ToolResultChunk - | ErrorChunk - | SystemChunk; - -/** A piece of plain text content from the assistant or user. */ -export interface TextChunk { - readonly type: "text"; - readonly text: string; -} - -/** A piece of model reasoning / thinking content (e.g. extended thinking). */ -export interface ThinkingChunk { - readonly type: "thinking"; - readonly text: string; -} - -/** - * A model's request to run a tool. The kernel routes by `name`; the tool - * implementation never sees this directly — it receives parsed `input` via - * `ToolContract.execute`. - */ -export interface ToolCallChunk { - readonly type: "tool-call"; - readonly toolCallId: string; - readonly toolName: string; - readonly input: unknown; -} - -/** - * The result of a tool execution, attributed to the originating tool-call id. - * The kernel guarantees every tool-call chunk gets exactly one result chunk - * (synthesized if interrupted — see reconcile). - */ -export interface ToolResultChunk { - readonly type: "tool-result"; - readonly toolCallId: string; - readonly toolName: string; - readonly content: string; - readonly isError: boolean; -} - -/** An error that occurred during generation or tool dispatch. */ -export interface ErrorChunk { - readonly type: "error"; - readonly message: string; - readonly code?: string; -} - -/** - * A system-injected message (e.g. system prompt, context assembly output). - * Kept distinct from text so the log records provenance. - */ -export interface SystemChunk { - readonly type: "system"; - readonly text: string; -} - -/** - * A chat message: a role plus an ordered sequence of chunks. Messages are the - * unit passed to and from the provider; chunks are the unit persisted and - * rendered. - */ -export interface ChatMessage { - readonly role: Role; - readonly chunks: readonly Chunk[]; -} +export type { + ChatMessage, + Chunk, + ErrorChunk, + Role, + StepId, + SystemChunk, + TextChunk, + ThinkingChunk, + ToolCallChunk, + ToolResultChunk, + TurnId, +} from "@dispatch/wire"; diff --git a/packages/kernel/src/contracts/events.ts b/packages/kernel/src/contracts/events.ts index 74e23fd..8737b02 100644 --- a/packages/kernel/src/contracts/events.ts +++ b/packages/kernel/src/contracts/events.ts @@ -1,122 +1,21 @@ /** * Outward events — the event type the runtime emits to the outside world. * - * These are the events transport extensions push to clients, notification - * extensions react to, and conversation-store uses for persistence. - * Discriminated by `type`. + * Re-exported from @dispatch/wire so the kernel barrel surface stays + * byte-identical. The canonical definitions live in @dispatch/wire. */ -import type { Usage } from "./provider.js"; - -/** - * The union of all events the runtime emits outward during a turn. - * Consumers (transport, persistence, notifications) pattern-match on `type`. - */ -export type AgentEvent = - | StatusEvent - | TurnStartEvent - | TurnTextDeltaEvent - | TurnReasoningDeltaEvent - | TurnToolCallEvent - | TurnToolResultEvent - | TurnToolOutputEvent - | TurnUsageEvent - | TurnErrorEvent - | TurnDoneEvent - | TurnSealedEvent; - -/** Status change for a conversation (e.g. idle → running). */ -export interface StatusEvent { - readonly type: "status"; - readonly conversationId: string; - readonly status: string; -} - -/** A turn has begun. */ -export interface TurnStartEvent { - readonly type: "turn-start"; - readonly conversationId: string; - readonly turnId: string; -} - -/** Incremental text content from the model during a turn. */ -export interface TurnTextDeltaEvent { - readonly type: "text-delta"; - readonly conversationId: string; - readonly turnId: string; - readonly delta: string; -} - -/** Incremental reasoning / thinking content during a turn. */ -export interface TurnReasoningDeltaEvent { - readonly type: "reasoning-delta"; - readonly conversationId: string; - readonly turnId: string; - readonly delta: string; -} - -/** The model has requested a tool to be run. */ -export interface TurnToolCallEvent { - readonly type: "tool-call"; - readonly conversationId: string; - readonly turnId: string; - readonly toolCallId: string; - readonly toolName: string; - readonly input: unknown; -} - -/** A tool has completed execution. */ -export interface TurnToolResultEvent { - readonly type: "tool-result"; - readonly conversationId: string; - readonly turnId: string; - readonly toolCallId: string; - readonly toolName: string; - readonly content: string; - readonly isError: boolean; -} - -/** Streaming output from a tool execution (e.g. shell stdout/stderr). */ -export interface TurnToolOutputEvent { - readonly type: "tool-output"; - readonly conversationId: string; - readonly turnId: string; - readonly toolCallId: string; - readonly data: string; - readonly stream: "stdout" | "stderr"; -} - -/** Token usage for the current step or turn. */ -export interface TurnUsageEvent { - readonly type: "usage"; - readonly conversationId: string; - readonly turnId: string; - readonly usage: Usage; -} - -/** An error occurred during the turn. */ -export interface TurnErrorEvent { - readonly type: "error"; - readonly conversationId: string; - readonly turnId: string; - readonly message: string; - readonly code?: string; -} - -/** The turn has completed (model finished generating). */ -export interface TurnDoneEvent { - readonly type: "done"; - readonly conversationId: string; - readonly turnId: string; - readonly reason: string; -} - -/** - * The turn has been sealed — all chunks persisted, history is final. - * This is the hook point for post-turn extensions (compaction, cache-warm). - */ -export interface TurnSealedEvent { - readonly type: "turn-sealed"; - readonly conversationId: string; - readonly turnId: string; -} +export type { + AgentEvent, + StatusEvent, + TurnDoneEvent, + TurnErrorEvent, + TurnReasoningDeltaEvent, + TurnSealedEvent, + TurnStartEvent, + TurnTextDeltaEvent, + TurnToolCallEvent, + TurnToolOutputEvent, + TurnToolResultEvent, + TurnUsageEvent, +} from "@dispatch/wire"; diff --git a/packages/kernel/src/contracts/extension.ts b/packages/kernel/src/contracts/extension.ts index 00b41f1..1760cf9 100644 --- a/packages/kernel/src/contracts/extension.ts +++ b/packages/kernel/src/contracts/extension.ts @@ -232,6 +232,9 @@ export interface HostAPI { /** Look up a single auth provider by id. */ readonly getAuthProvider: (id: string) => AuthContract | undefined; + /** Read-only view of all activated extensions' manifests (what is loaded). */ + readonly getExtensions: () => readonly Manifest[]; + /** Register a scheduled job with the host's scheduler. */ readonly scheduler: { readonly register: (job: ScheduledJob) => void; diff --git a/packages/kernel/src/contracts/provider.ts b/packages/kernel/src/contracts/provider.ts index ee58c1d..0686c19 100644 --- a/packages/kernel/src/contracts/provider.ts +++ b/packages/kernel/src/contracts/provider.ts @@ -6,20 +6,12 @@ * translates its responses into `ProviderEvent`s. */ +import type { Usage } from "@dispatch/wire"; import type { ChatMessage } from "./conversation.js"; import type { Logger } from "./logging.js"; import type { ToolContract } from "./tool.js"; -/** - * Token usage counters for a single step. All fields are counts of tokens. - * Cache fields are optional because not all providers expose cache metrics. - */ -export interface Usage { - readonly inputTokens: number; - readonly outputTokens: number; - readonly cacheReadTokens?: number; - readonly cacheWriteTokens?: number; -} +export type { Usage } from "@dispatch/wire"; /** * Events a provider yields during a single `stream` call. The kernel consumes diff --git a/packages/kernel/src/host/host.test.ts b/packages/kernel/src/host/host.test.ts index 430447c..106dd56 100644 --- a/packages/kernel/src/host/host.test.ts +++ b/packages/kernel/src/host/host.test.ts @@ -726,6 +726,117 @@ describe("createHost", () => { }); }); + describe("getExtensions", () => { + it("returns empty array when no extensions are activated", async () => { + const host = createHost([], deps); + await host.activate(); + + expect(host.getExtensions()).toEqual([]); + }); + + it("returns manifests of all activated extensions", async () => { + const a = createExtension("ext-a"); + const b = createExtension("ext-b"); + + const host = createHost([a, b], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(2); + expect(exts.map((e) => e.id)).toContain("ext-a"); + expect(exts.map((e) => e.id)).toContain("ext-b"); + }); + + it("returns manifests in activation order", async () => { + const a = createExtension("a"); + const b = createExtension("b", { dependsOn: ["a"] }); + const c = createExtension("c", { dependsOn: ["b"] }); + + const host = createHost([c, b, a], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts.map((e) => e.id)).toEqual(["a", "b", "c"]); + }); + + it("excludes extensions that failed to activate", async () => { + const a = createExtension("good"); + const b = createExtension("bad", { + activate: () => { + throw new Error("boom"); + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(1); + expect(exts[0]?.id).toBe("good"); + }); + + it("excludes extensions disabled by apiVersion incompatibility", async () => { + const good = createExtension("good"); + const bad = createExtension("bad", { apiVersion: "^99.0.0" }); + + const host = createHost([good, bad], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(exts).toHaveLength(1); + expect(exts[0]?.id).toBe("good"); + }); + + it("returns a frozen array", async () => { + const ext = createExtension("ext"); + const host = createHost([ext], deps); + await host.activate(); + + const exts = host.getExtensions(); + expect(Object.isFrozen(exts)).toBe(true); + }); + + it("HostAPI getExtensions reflects activated extensions after full activation", async () => { + const a = createExtension("ext-a"); + const b = createExtension("ext-b", { + dependsOn: ["ext-a"], + activate: () => {}, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + // Use getHostAPI() to verify the post-activation view + const api = host.getHostAPI(); + const capturedExtsAfter = api.getExtensions(); + + expect(capturedExtsAfter).toHaveLength(2); + expect(capturedExtsAfter.map((e) => e.id)).toEqual(["ext-a", "ext-b"]); + }); + + it("HostAPI getExtensions during activation sees only previously activated", async () => { + const seenDuringActivation: string[][] = []; + + const a = createExtension("a", { + activate: (host) => { + seenDuringActivation.push(host.getExtensions().map((e) => e.id)); + }, + }); + const b = createExtension("b", { + activate: (host) => { + seenDuringActivation.push(host.getExtensions().map((e) => e.id)); + }, + }); + + const host = createHost([a, b], deps); + await host.activate(); + + // When a activates, activated[] is empty (a hasn't been pushed yet) + // When b activates, activated[] has [a] (b hasn't been pushed yet) + expect(seenDuringActivation).toEqual([[], ["a"]]); + }); + }); + describe("DAG errors", () => { it("throws on missing dependency", () => { const ext = createExtension("a", { dependsOn: ["missing"] }); diff --git a/packages/kernel/src/host/host.ts b/packages/kernel/src/host/host.ts index 2331625..8aa4f78 100644 --- a/packages/kernel/src/host/host.ts +++ b/packages/kernel/src/host/host.ts @@ -57,6 +57,7 @@ export interface Host { readonly getScheduledJobs: () => readonly ScheduledJob[]; readonly getMigrations: () => readonly string[]; readonly getDisabled: () => readonly DisabledExtension[]; + readonly getExtensions: () => readonly Manifest[]; readonly getHostAPI: () => HostAPI; } @@ -150,6 +151,9 @@ export function createHost(extensions: readonly Extension[], deps: HostDeps): Ho getAuthProvider(id: string) { return authProviders.get(id); }, + getExtensions() { + return Object.freeze(activated.map((e) => e.manifest)); + }, scheduler: { register(job: ScheduledJob) { scheduledJobs.push(job); @@ -213,6 +217,9 @@ export function createHost(extensions: readonly Extension[], deps: HostDeps): Ho getDisabled() { return disabled; }, + getExtensions() { + return Object.freeze(activated.map((e) => e.manifest)); + }, getHostAPI() { return buildHostAPI("__host__", { registrationClosed: true }); }, |
