summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-06 21:29:52 +0900
committerAdam Malczewski <[email protected]>2026-06-06 21:29:52 +0900
commit2c5bc242a8a99e3b863c247f70b26f5883333677 (patch)
treece938fbef47d8dab44bfe5cce68ebfac7a91ca8a
parentfedf9c2695476e9ee6f95776b0244acfc37f022f (diff)
downloaddispatch-2c5bc242a8a99e3b863c247f70b26f5883333677.tar.gz
dispatch-2c5bc242a8a99e3b863c247f70b26f5883333677.zip
feat(kernel-runtime,session-orchestrator): emit turn lifecycle events
Close a gap found live: neither transport emitted turn-start/done/turn-sealed (the wire defined them; nothing fired them). turn-sealed is the FE's cache-commit signal (frontend-design §6.3); done ends the stream. - kernel-runtime: runTurn emits turn-start first and done (with finishReason) last, on every exit path (stop/tool-calls/max-steps/error/aborted). - session-orchestrator: emits turn-sealed after conversationStore.append succeeds (the kernel touches no DB, so the post-persist seal is the orchestrator's). Not emitted if append throws. No contract change (all three wire types already existed). Verified live: HTTP /chat and WS chat both stream turn-start … done turn-sealed. typecheck clean, 494 vitest + 80 bun, biome clean.
-rw-r--r--packages/kernel/src/runtime/events.ts8
-rw-r--r--packages/kernel/src/runtime/run-turn.test.ts209
-rw-r--r--packages/kernel/src/runtime/run-turn.ts6
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts117
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts2
-rw-r--r--tasks.md29
6 files changed, 361 insertions, 10 deletions
diff --git a/packages/kernel/src/runtime/events.ts b/packages/kernel/src/runtime/events.ts
index 2a92008..a209b00 100644
--- a/packages/kernel/src/runtime/events.ts
+++ b/packages/kernel/src/runtime/events.ts
@@ -48,6 +48,14 @@ export function usageEvent(conversationId: string, turnId: string, usage: Usage)
return { type: "usage", conversationId, turnId, usage };
}
+export function turnStartEvent(conversationId: string, turnId: string): AgentEvent {
+ return { type: "turn-start", conversationId, turnId };
+}
+
+export function doneEvent(conversationId: string, turnId: string, reason: string): AgentEvent {
+ return { type: "done", conversationId, turnId, reason };
+}
+
export function errorEvent(
conversationId: string,
turnId: string,
diff --git a/packages/kernel/src/runtime/run-turn.test.ts b/packages/kernel/src/runtime/run-turn.test.ts
index 48a6fbc..488a77e 100644
--- a/packages/kernel/src/runtime/run-turn.test.ts
+++ b/packages/kernel/src/runtime/run-turn.test.ts
@@ -119,7 +119,14 @@ describe("runTurn", () => {
expect(result.usage).toEqual({ inputTokens: 10, outputTokens: 5 });
const eventTypes = events.map((e) => e.type);
- expect(eventTypes).toEqual(["text-delta", "text-delta", "reasoning-delta", "usage"]);
+ expect(eventTypes).toEqual([
+ "turn-start",
+ "text-delta",
+ "text-delta",
+ "reasoning-delta",
+ "usage",
+ "done",
+ ]);
});
it("turn with one tool call executes tool, feeds result back, then finishes", async () => {
@@ -1482,4 +1489,204 @@ describe("runTurn", () => {
}
});
});
+
+ describe("lifecycle events", () => {
+ it("emits turn-start as the first event with conversation + turn ids", async () => {
+ const provider = createFakeProvider([
+ [
+ { type: "text-delta", delta: "hi" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const { events, emit } = createCollectingEmit();
+
+ await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-42",
+ turnId: "turn-99",
+ emit,
+ });
+
+ expect(events[0]?.type).toBe("turn-start");
+ if (events[0]?.type === "turn-start") {
+ expect(events[0].conversationId).toBe("conv-42");
+ expect(events[0].turnId).toBe("turn-99");
+ }
+ });
+
+ it("emits a single done event last, carrying the finishReason", async () => {
+ const provider = createFakeProvider([
+ [
+ { type: "text-delta", delta: "Hello" },
+ { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const { events, emit } = createCollectingEmit();
+
+ const result = await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit,
+ });
+
+ const lastEvent = events[events.length - 1];
+ expect(lastEvent?.type).toBe("done");
+ if (lastEvent?.type === "done") {
+ expect(lastEvent.reason).toBe(result.finishReason);
+ expect(lastEvent.conversationId).toBe("conv-1");
+ expect(lastEvent.turnId).toBe("turn-1");
+ }
+
+ const doneEvents = events.filter((e) => e.type === "done");
+ expect(doneEvents).toHaveLength(1);
+ });
+
+ it("emits done after a tool-call turn", async () => {
+ const tool = createFakeTool("echo", async (input) => ({
+ content: `echo: ${JSON.stringify(input)}`,
+ }));
+
+ const provider = createFakeProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: { x: 1 } },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const { events, emit } = createCollectingEmit();
+
+ const result = await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit,
+ });
+
+ const lastEvent = events[events.length - 1];
+ expect(lastEvent?.type).toBe("done");
+ if (lastEvent?.type === "done") {
+ expect(lastEvent.reason).toBe(result.finishReason);
+ }
+ });
+
+ it('still emits done with reason "aborted" when the turn is aborted via signal', async () => {
+ const ac = new AbortController();
+ ac.abort();
+
+ const provider = createFakeProvider([
+ [
+ { type: "text-delta", delta: "should not appear" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const { events, emit } = createCollectingEmit();
+
+ const result = await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit,
+ signal: ac.signal,
+ });
+
+ expect(result.finishReason).toBe("aborted");
+
+ const lastEvent = events[events.length - 1];
+ expect(lastEvent?.type).toBe("done");
+ if (lastEvent?.type === "done") {
+ expect(lastEvent.reason).toBe("aborted");
+ }
+ });
+
+ it('still emits done with reason "error" when the provider errors', async () => {
+ const provider: ProviderContract = {
+ id: "fake",
+ stream() {
+ return (async function* () {
+ yield { type: "text-delta", delta: "partial" } as ProviderEvent;
+ throw new Error("provider crashed");
+ })();
+ },
+ };
+
+ const { events, emit } = createCollectingEmit();
+
+ const result = await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit,
+ });
+
+ expect(result.finishReason).toBe("error");
+
+ const lastEvent = events[events.length - 1];
+ expect(lastEvent?.type).toBe("done");
+ if (lastEvent?.type === "done") {
+ expect(lastEvent.reason).toBe("error");
+ }
+ });
+
+ it("turn-start precedes every delta and done follows every delta", async () => {
+ const provider = createFakeProvider([
+ [
+ { type: "text-delta", delta: "Hello" },
+ { type: "reasoning-delta", delta: "thinking..." },
+ { type: "text-delta", delta: " world" },
+ { type: "usage", usage: { inputTokens: 5, outputTokens: 3 } },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const { events, emit } = createCollectingEmit();
+
+ await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ emit,
+ });
+
+ const turnStartIdx = events.findIndex((e) => e.type === "turn-start");
+ const doneIdx = events.findIndex((e) => e.type === "done");
+
+ expect(turnStartIdx).toBe(0);
+ expect(doneIdx).toBe(events.length - 1);
+
+ for (let i = 0; i < events.length; i++) {
+ const e = events[i];
+ if (e?.type === "text-delta" || e?.type === "reasoning-delta") {
+ expect(i).toBeGreaterThan(turnStartIdx);
+ expect(i).toBeLessThan(doneIdx);
+ }
+ }
+ });
+ });
});
diff --git a/packages/kernel/src/runtime/run-turn.ts b/packages/kernel/src/runtime/run-turn.ts
index 6b07e24..1e98351 100644
--- a/packages/kernel/src/runtime/run-turn.ts
+++ b/packages/kernel/src/runtime/run-turn.ts
@@ -5,11 +5,13 @@ import type { EventEmitter, RunTurnInput, RunTurnResult } from "../contracts/run
import type { ToolCall, ToolContract } from "../contracts/tool.js";
import { createStepDispatcher, type StepDispatcher } from "./dispatch.js";
import {
+ doneEvent,
errorEvent,
reasoningDeltaEvent,
textDeltaEvent,
toolCallEvent,
toolResultEvent,
+ turnStartEvent,
usageEvent,
} from "./events.js";
@@ -346,6 +348,8 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
// Track open tool-call spans across steps so we can close them on abort
const toolSpans = new Map<string, Span>();
+ input.emit(turnStartEvent(conversationId, turnId));
+
try {
for (let step = 0; step < MAX_STEPS; step++) {
if (signal.aborted) {
@@ -422,6 +426,8 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
}
}
+ input.emit(doneEvent(conversationId, turnId, finishReason));
+
return { messages: resultMessages, usage: totalUsage, finishReason };
}
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts
index 39c95d5..b648d42 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -364,3 +364,120 @@ describe("handleMessage model resolution", () => {
expect(captured[1]?.cwd).toBeUndefined();
});
});
+
+describe("turn-sealed event", () => {
+ it("emits turn-sealed after persisting the turn", async () => {
+ const store = createInMemoryStore();
+ const provider = createFakeProvider([
+ [
+ { type: "text-delta", delta: "ok" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const orchestrator = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ runTurn,
+ });
+
+ const { events, onEvent } = collectEvents();
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-seal",
+ text: "test",
+ onEvent,
+ });
+
+ const sealedEvents = events.filter((e) => e.type === "turn-sealed");
+ expect(sealedEvents).toHaveLength(1);
+ const sealed = sealedEvents[0] as AgentEvent & { type: "turn-sealed" };
+ expect(sealed.conversationId).toBe("conv-seal");
+ expect(sealed.turnId).toMatch(/^turn-/);
+ });
+
+ it("turn-sealed is emitted after the store append", async () => {
+ const store = createInMemoryStore();
+ const provider = createFakeProvider([
+ [
+ { type: "text-delta", delta: "ok" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const ordering: string[] = [];
+ const wrappedStore: ConversationStore = {
+ async append(conversationId, messages) {
+ await store.append(conversationId, messages);
+ ordering.push("append");
+ },
+ async load(conversationId) {
+ return store.load(conversationId);
+ },
+ async loadSince(conversationId, sinceSeq) {
+ return store.loadSince(conversationId, sinceSeq);
+ },
+ };
+
+ const orchestrator = createSessionOrchestrator({
+ conversationStore: wrappedStore,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ runTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-order",
+ text: "test",
+ onEvent: (event) => {
+ if (event.type === "turn-sealed") {
+ ordering.push("turn-sealed");
+ }
+ },
+ });
+
+ expect(ordering).toEqual(["append", "turn-sealed"]);
+ });
+
+ it("does not emit turn-sealed when append throws", async () => {
+ const provider = createFakeProvider([
+ [
+ { type: "text-delta", delta: "ok" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const failingStore: ConversationStore = {
+ async append() {
+ throw new Error("storage failure");
+ },
+ async load() {
+ return [];
+ },
+ async loadSince() {
+ return [];
+ },
+ };
+
+ const orchestrator = createSessionOrchestrator({
+ conversationStore: failingStore,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ runTurn,
+ });
+
+ const { events, onEvent } = collectEvents();
+
+ await expect(
+ orchestrator.handleMessage({
+ conversationId: "conv-fail",
+ text: "test",
+ onEvent,
+ }),
+ ).rejects.toThrow("storage failure");
+
+ const sealedEvents = events.filter((e) => e.type === "turn-sealed");
+ expect(sealedEvents).toHaveLength(0);
+ });
+});
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index a9ff4ff..311b620 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -92,6 +92,8 @@ export function createSessionOrchestrator(deps: SessionOrchestratorDeps): Sessio
const toPersist: ChatMessage[] = [userMsg, ...result.messages];
await deps.conversationStore.append(conversationId, toPersist);
+
+ onEvent({ type: "turn-sealed", conversationId, turnId });
},
};
}
diff --git a/tasks.md b/tasks.md
index 4bfe7d5..a3b9faa 100644
--- a/tasks.md
+++ b/tasks.md
@@ -439,15 +439,26 @@ streaming. Spans both repos; the backend prereqs live HERE (FE work runs in `../
criteria, NOT a transport bug (cf. slice-1 "10 vs 11 extensions" artifact). Verified by
confirming HTTP `/chat` emits the identical event set.
-#### Open item discovered live — runtime turn-lifecycle events NOT emitted (BLOCKS FE cache-commit)
-The runtime/orchestrator emits `reasoning-delta`/`text-delta`/`usage` but **NOT `turn-start`,
-`done`, or `turn-sealed`** through `emit`/`onEvent` — on EITHER transport (HTTP `/chat` + WS chat
-both confirmed). The wire DEFINES these (`TurnStartEvent`/`TurnDoneEvent`/`TurnSealedEvent`) but
-`runTurn` doesn't fire them. **`turn-sealed` is the FE's cache-commit signal (frontend-design §6.3
-— "below the last seal is immutable + cacheable"); `done` ends the stream.** Needs a
-kernel-runtime (and/or session-orchestrator) emission fix BEFORE the FE chat slice can commit
-turns to its cache. Diagnose-from-symptoms done; fix = summon the owning unit. NOT a transport-ws
-defect.
+#### Turn-lifecycle event emission — FIXED + verified live
+Was: the runtime emitted `reasoning-delta`/`text-delta`/`usage` but NOT `turn-start`/`done`/
+`turn-sealed` on either transport (the wire defined them; nothing fired them). **`turn-sealed`
+is the FE's cache-commit signal (frontend-design §6.3); `done` ends the stream.** Architecture-
+correct split (NO contract change — all three wire types already existed):
+- **kernel-runtime (owner, mimo-v2.5-pro):** `runTurn` now emits `turn-start` as the FIRST event
+ and `done` (carrying the final `finishReason`) as the LAST kernel event — on every exit path
+ (stop / tool-calls / max-steps / error / aborted). +2 event factories in `runtime/events.ts`;
+ updated the existing event-sequence assertions; +6 lifecycle tests. (NOTE: first summon
+ PLANNED but applied nothing — a planning-not-executing failure caught by `git diff --stat`
+ showing 0 kernel changes + the stale report's bogus CRs; re-summoned with an explicit
+ "EXECUTE, do not plan" directive. Trust = independent re-verification, not the report.)
+- **session-orchestrator (owner, mimo-v2.5-pro):** emits `turn-sealed` via `onEvent` AFTER
+ `conversationStore.append` succeeds (history persisted/final = the seal); not emitted if append
+ throws. +3 tests. (Kernel owns start/done — orchestrator owns the post-persist seal, since the
+ kernel touches no DB.)
+- **Verified (orchestrator):** typecheck clean, **494 vitest** (485→+9), 80 bun, biome clean,
+ no internal mocks, both in-lane. **Live (host-bin, real flash):** HTTP `/chat` AND WS chat now
+ both stream `turn-start … done turn-sealed` — first=`turn-start`, last=`turn-sealed` on both
+ carriers. Summons: prompts/lifecycle-{kernel-runtime,session-orchestrator}.md.
Then FE (`../dispatch-web`): `core/transcript` reducer + `conversation-cache` + `chat` feature.