summaryrefslogtreecommitdiffhomepage
path: root/packages/kernel/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-06 18:55:53 +0900
committerAdam Malczewski <[email protected]>2026-06-06 18:55:53 +0900
commit22936857685c318b71752d625808100b1a96e63e (patch)
tree5e10a73d616c206e3820a8d8568e5f3d4c8a302e /packages/kernel/src
parent969afc45f895230fe3da1c737f18e64452efc8f2 (diff)
downloaddispatch-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.ts101
-rw-r--r--packages/kernel/src/contracts/events.ts133
-rw-r--r--packages/kernel/src/contracts/extension.ts3
-rw-r--r--packages/kernel/src/contracts/provider.ts12
-rw-r--r--packages/kernel/src/host/host.test.ts111
-rw-r--r--packages/kernel/src/host/host.ts7
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 });
},