From d55ac51b76617cbc7c150a4ec6fc43a6ffcda940 Mon Sep 17 00:00:00 2001 From: likun Date: Sun, 14 Jun 2026 17:43:28 +0800 Subject: [PATCH 1/4] Document runtime v2 architecture evolution --- docs/runtime-v2-architecture-evolution.md | 660 ++++++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 docs/runtime-v2-architecture-evolution.md diff --git a/docs/runtime-v2-architecture-evolution.md b/docs/runtime-v2-architecture-evolution.md new file mode 100644 index 0000000000..bd518e5e51 --- /dev/null +++ b/docs/runtime-v2-architecture-evolution.md @@ -0,0 +1,660 @@ +# Runtime v2 Architecture Evolution + +This document proposes a long-term evolution path for Maka's agent runtime. It +is written for developers who need to understand why the runtime needs another +architecture pass, what should stay stable, and how the codebase can move there +without a big-bang rewrite. + +The goal is not to replace the Vercel AI SDK. The goal is to make Maka own its +runtime semantics while continuing to use the AI SDK as the main model/tool +stepping engine. + +## Executive Summary + +Maka already has a working local coding agent runtime: + +- Electron desktop entry points +- session JSONL storage +- model streaming through the AI SDK +- tool execution and permission prompts +- abort handling +- bot and OpenGateway entry points +- tool artifacts and usage telemetry +- an internal `AgentRun` ledger + +The current architecture is better than the original monolithic path, but the +runtime still lacks one stable center of gravity. The same run is currently +represented through several related but separate structures: + +- `StoredMessage` in the session JSONL +- renderer-facing `SessionEvent` +- `AgentRunEvent` in the run ledger +- best-effort `RunTraceEvent` +- telemetry records + +That split makes the system hard to evolve. It also makes important questions +harder than they should be: + +- Which facts are the real runtime truth? +- Which objects are only UI projections? +- Which events should be replayed into the next model request? +- Which events are diagnostic-only? +- Where should permission, tool, recovery, and telemetry semantics live? + +Runtime v2 should introduce a canonical invocation/event spine: + +```text +RuntimeRunner + -> InvocationContext + -> AgentFlow + -> AiSdkFlow + -> AI SDK streamText + -> ToolRuntime + -> RuntimeEvent ledger + -> projections +``` + +The key shift is: + +```text +Today: + SessionManager + StoredMessage/SessionEvent are close to the runtime center. + +Target: + Invocation + RuntimeEvent + AgentFlow are the runtime center. + StoredMessage, renderer SessionEvent, AgentRunStore, RunTrace, and telemetry + become projections or ledgers derived from canonical runtime facts. +``` + +## Design Inputs + +This proposal is based on the current Maka implementation and on the runtime +structure documented in the Google ADK Go reading materials. The useful lesson +from ADK is not to copy type names directly. The useful lesson is the layering: + +```text +Runner -> Agent -> Flow -> Model/Tool -> Event -> Session +``` + +In that model: + +- `Runner` owns the invocation shell and persistence boundary. +- `Agent` owns lifecycle and routing. +- `Flow` owns the model/tool loop. +- `Event` is the shared runtime fact language. +- `Session` stores durable history and scoped state. +- tool, callback, plugin, instruction, workflow, entrypoint, and telemetry + layers attach around that axis without becoming the axis themselves. + +Maka has different product constraints, so the evolution should be adapted: + +- keep the AI SDK as the long-term main flow implementation; +- preserve Electron and existing user-visible session behavior; +- preserve existing JSONL compatibility; +- reuse `ToolRuntime`, `AgentRunStore`, and `RunTrace`; +- avoid a flag day where all storage and UI projections change at once. + +## Current Runtime Path + +Today, one user turn approximately flows like this: + +```text +renderer / bot / gateway + -> desktop main / entrypoint code + -> ensureSessionCanSend(...) + -> SessionManager.sendMessage(sessionId, input) + -> new AgentRun(...) + -> AgentRun.execute() + -> append user StoredMessage + -> append turn_state=running + -> lock connection snapshot + -> ensureActive() / build AiSdkBackend + -> register active run + -> AiSdkBackend.send() + -> PermissionEngine.beginTurn() + -> RunTrace + -> ModelAdapter.resolveModel() + -> build AI SDK tool map + -> materialize prior StoredMessage[] into AI SDK messages + -> AI SDK streamText({ tools, stopWhen: stepCountIs(maxSteps) }) + -> AI SDK owns model -> tool -> model stepping + -> tool.execute(...) calls ToolRuntime + -> pump fullStream into SessionEvent queue + -> append assistant StoredMessage + -> append token_usage StoredMessage + -> queue complete/error/abort + -> AgentRun updates SessionHeader / turn_state / AgentRunStore + -> cleanup active run +``` + +This split has some good properties: + +- `SessionManager.sendMessage()` no longer owns the whole hot path. +- `AgentRun` gives a durable run lifecycle record. +- `ToolRuntime` is a real boundary for tool permission, execution, artifacts, + and tool telemetry. +- `ModelAdapter` isolates provider/AI SDK stream and error normalization. + +But it also has structural limits: + +- Maka does not have its own explicit `Flow.Run`; the model/tool loop is + implicit inside `AiSdkBackend.send()` plus the AI SDK. +- Cross-turn model history is built from selected `StoredMessage` types, not + from a canonical runtime event history. +- tool calls and tool results are persisted for UI/history, but their role in + future model context is an implicit policy inside `materializePriorMessages()`. +- permission and tool events are represented across `StoredMessage`, + `SessionEvent`, `RunTraceEvent`, and telemetry rather than one canonical fact. +- entrypoints still own some runtime readiness semantics, such as connection + readiness/rebind checks. + +## Target Architecture + +The target architecture keeps the AI SDK as the primary model/tool stepping +engine, but wraps it in Maka-owned invocation and event semantics. + +```text +┌────────────────────────────────────────────────────────────────────┐ +│ Entrypoint Adapters │ +│ │ +│ Desktop IPC Bot Gateway OpenGateway │ +│ - parse input - parse input - parse HTTP │ +│ - validate shape - auth/rate limit - SSE/JSON │ +│ - broadcast output - send bot reply - gateway output │ +│ │ +│ Entrypoints are protocol adapters. They do not own runtime loop, │ +│ readiness, recovery, or model/tool policy. │ +└──────────────────────────────┬─────────────────────────────────────┘ + │ RuntimeRequest + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ RuntimeRunner │ +│ │ +│ - RuntimeGate preflight │ +│ - create invocation/run context │ +│ - append user RuntimeEvent │ +│ - resolve active agent/flow policy │ +│ - run AgentFlow │ +│ - persist canonical events │ +│ - drive projections │ +└──────────────────────────────┬─────────────────────────────────────┘ + │ InvocationContext + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ AgentFlow Interface │ +│ │ +│ interface AgentFlow { │ +│ run(ctx, input): AsyncIterable │ +│ } │ +│ │ +│ AiSdkFlow is the default long-term implementation: │ +│ │ +│ - build AI SDK messages from runtime history projection │ +│ - build AI SDK tool map │ +│ - call streamText({ tools, stopWhen: stepCountIs(...) }) │ +│ - map text/thinking/tool/usage/finish/error to RuntimeEvent │ +│ - delegate tool execution to ToolRuntime │ +└──────────────────────────────┬─────────────────────────────────────┘ + │ tool.execute seam + ▼ +┌────────────────────────────────────────────────────────────────────┐ +│ ToolRuntime │ +│ │ +│ - declaration/schema/registry │ +│ - permission evaluation and parked decisions │ +│ - tool implementation execution │ +│ - output deltas and artifacts │ +│ - tool telemetry │ +│ - RuntimeEvent(tool_call/tool_result/permission/artifact) │ +│ - returns result to AI SDK so streamText can continue stepping │ +└────────────────────────────────────────────────────────────────────┘ +``` + +Shared services sit beside this path: + +```text +RuntimeEventLedger + canonical invocation facts + +ProjectionManager + RuntimeEvent -> StoredMessage JSONL + RuntimeEvent -> renderer SessionEvent stream + RuntimeEvent -> TurnRecord / SessionHeader + RuntimeEvent -> AgentRunStore / RunTrace + RuntimeEvent -> TelemetryRepo + +SessionService + event history + scoped state + legacy JSONL compatibility + +RuntimeGate + readiness / rebind / blocked/running/waiting guards + +Plugin / Callback / Instruction + prompt injection / audit / retry-reflect / telemetry hooks +``` + +## Canonical RuntimeEvent + +Runtime v2 needs a single internal fact model. A sketch: + +```ts +type RuntimeEvent = { + id: string; + invocationId: string; + runId: string; + sessionId: string; + turnId: string; + ts: number; + + author: 'user' | 'agent' | 'tool' | 'system'; + role: 'user' | 'model' | 'tool' | 'system'; + branch?: string; + partial: boolean; + + content?: { + text?: string; + thinking?: string; + functionCall?: { + id: string; + name: string; + args: unknown; + }; + functionResponse?: { + id: string; + name: string; + result: unknown; + isError?: boolean; + }; + error?: { + code?: string; + reason?: string; + message: string; + }; + }; + + actions?: { + stateDelta?: Record; + artifactDelta?: Record; + permissionRequest?: PermissionRequest; + permissionDecision?: PermissionDecision; + transferToAgent?: string; + endInvocation?: boolean; + tokenUsage?: TokenUsage; + }; + + refs?: { + storedMessageId?: string; + traceEventId?: string; + toolCallId?: string; + providerEventId?: string; + }; +}; +``` + +This event is not a UI event and not a trace event. It is the internal runtime +fact. Other records should either be written from it or be explicitly linked to +it. + +## Consumption Paths + +### Desktop User Message + +```text +Renderer + -> preload IPC + -> main IPC handler + -> normalize input + -> validate attachment shape + -> RuntimeRunner.run({ source: 'desktop', sessionId, text, attachments }) + -> RuntimeGate.preflight() + -> create invocation + -> append RuntimeEvent(user) + -> ProjectionManager writes user StoredMessage and renderer append event + -> AiSdkFlow.run(ctx) + -> build AI SDK messages from runtime history projection + -> streamText(...) + -> partial model chunks -> RuntimeEvent(partial model text) + -> final model output -> RuntimeEvent(final model text) + -> tool calls go through ToolRuntime + -> ProjectionManager writes: + -> assistant StoredMessage + -> TurnRecord + -> SessionHeader + -> AgentRun ledger status + -> telemetry usage/tool records + -> main process streams projected SessionEvents back to renderer +``` + +The renderer consumes a projection stream. It does not consume the runtime core +directly. + +### Tool Call and Permission + +```text +AiSdkFlow + -> AI SDK asks tool.execute({ args, toolCallId }) + -> ToolRuntime.executeTool() + -> RuntimeEvent(tool_call) + -> StoredMessage tool_call + -> renderer tool_start + -> PermissionEngine.evaluate() + ├─ allow + │ -> run tool.impl + │ -> RuntimeEvent(tool_result) + │ -> return result to AI SDK + │ + ├─ block + │ -> RuntimeEvent(tool_result isError synthetic) + │ -> return synthetic error result to AI SDK + │ + └─ prompt + -> RuntimeEvent(permission_request) + -> renderer permission modal + -> session waiting_for_user projection + -> park tool execution + -> respondPermission(...) + -> RuntimeEvent(permission_decision) + -> allow/deny branch +``` + +Permission is a runtime action, not just a UI event. It should be represented in +`RuntimeEvent.actions`. + +### Model History Construction + +Today, model history is built from stored messages and skips several runtime +message types. Runtime v2 should make this an explicit projection: + +```text +RuntimeEvent history + -> ModelHistoryProjector + include: + user text events + model final text events + selected system/instruction events + function call events when required by the provider protocol + tool function response events + exclude: + partial chunks + token usage + trace diagnostics + UI-only system notes + permission ack unless deliberately exposed to the model + -> AI SDK messages +``` + +This makes the "what does the next model call see?" policy reviewable and +testable. + +### Startup Recovery + +```text +App startup + -> RuntimeRecovery.scan() + -> read invocation/run ledger + -> classify non-terminal invocations by latest RuntimeEvent: + - model stream started with no terminal event -> failed app_restarted + - tool started with no result -> failed interrupted_tool + - permission_request pending -> waiting_for_user or failed by policy + - final model event persisted but run header not completed -> complete it + -> append recovery RuntimeEvent + -> ProjectionManager repairs: + - TurnRecord + - SessionHeader + - StoredMessage system note if needed + - AgentRun terminal status +``` + +Recovery should reason from canonical invocation facts first and then repair +projections. It should not independently guess from multiple stores unless it is +handling legacy data. + +### Bot and OpenGateway + +```text +Bot / OpenGateway + -> normalize external protocol + -> RuntimeRunner.run({ source: 'bot' | 'gateway', ... }) + -> same RuntimeGate + -> same AiSdkFlow + -> same ToolRuntime + -> projected output: + bot: collect final response and send bot reply + gateway: format JSON/SSE response + desktop: optional fan-out when the same session is visible +``` + +The runtime should not have three subtly different send paths for desktop, bot, +and gateway. + +## Source of Truth Boundaries + +The intended boundary is: + +```text +RuntimeEventLedger + runtime fact + +StoredMessage JSONL + user-visible conversation projection and legacy read model + +renderer SessionEvent + transport projection for UI streaming + +AgentRunStore + invocation/run lifecycle ledger, eventually closely linked with RuntimeEvent + +RunTrace + diagnostic-only projection; failures must not affect execution + +TelemetryRepo + economic and operational projection +``` + +This boundary is the main reason to do the work. Without it, adding more +features will keep increasing the number of places that need to agree about the +same run. + +## Proposed Module Shape + +One possible file layout: + +```text +packages/runtime/src/ + runtime-event.ts + runtime-event-projection.ts + invocation-context.ts + runtime-runner.ts + runtime-gate.ts + runtime-recovery.ts + + flows/ + agent-flow.ts + ai-sdk-flow.ts + + model/ + model-adapter.ts + model-history.ts + + tools/ + tool-runtime.ts + tool-registry.ts + permission-events.ts + + projections/ + stored-message-projection.ts + session-event-projection.ts + turn-record-projection.ts + telemetry-projection.ts + + session-manager.ts +``` + +This is not a required final file layout. The important part is the direction: +`SessionManager` becomes a facade and session CRUD owner, not the runtime brain. + +## Migration Plan + +### Phase 1: RuntimeEvent RFC and Adapters + +Add the canonical event types and adapters without changing the run path. + +Deliverables: + +- `RuntimeEvent` and `RuntimeEventActions` +- mapping tests for current `StoredMessage`, `SessionEvent`, `AgentRunEvent`, + and `RunTraceEvent` +- documentation of which events are facts and which are projections + +Reason: + +This gives the team a shared language before changing control flow. + +### Phase 2: RuntimeRunner Shell + +Introduce `RuntimeRunner.run()` and make `SessionManager.sendMessage()` delegate +to it while still using current `AgentRun.execute()`. + +Deliverables: + +- `RuntimeRunner` +- `InvocationContext` +- `RuntimeGate` placeholder for readiness policy +- backward-compatible `SessionManager` facade +- tests proving current UI-visible behavior remains stable + +Reason: + +This moves invocation ownership out of `SessionManager` without changing the +AI SDK or storage path all at once. + +### Phase 3: AgentRunStore to Invocation Ledger + +Upgrade the run ledger to carry invocation/event linkage. + +Deliverables: + +- run headers linked to `invocationId` +- run events linked to canonical runtime event ids where possible +- recovery tests that reason from invocation facts and repair projections + +Reason: + +The current run ledger is already useful. It should become part of the runtime +spine instead of a parallel diagnostic store. + +### Phase 4: AiSdkFlow Formalization + +Extract/formalize the current `AiSdkBackend.send()` loop into `AiSdkFlow`. + +Deliverables: + +- `AgentFlow` interface +- `AiSdkFlow` implementation +- `ModelHistoryProjector` +- `AiSdkFlow` emits canonical `RuntimeEvent`s +- `AiSdkBackend` becomes configuration/factory shell rather than the runtime + loop owner + +Reason: + +The AI SDK remains the main engine, but Maka gains explicit flow input/output +semantics. + +### Phase 5: ToolRuntime Event Actions + +Make tool calls, tool results, permission requests, permission decisions, state +deltas, and artifact deltas first-class runtime event actions. + +Deliverables: + +- `tool_call` / `tool_result` runtime events +- permission request/decision runtime actions +- artifact delta linkage +- tests for allow/block/prompt/deny/abort paths through canonical events + +Reason: + +Tools are where model intent becomes local side effects. That boundary must be +auditable and replayable. + +### Phase 6: Entrypoint Cleanup + +Move readiness and rebind policy out of desktop main and into runtime gates. + +Deliverables: + +- `RuntimeGate` owns connection readiness/rebind/session blocked/running guards +- desktop/bot/gateway entrypoints call the same runner APIs +- contract tests proving the same session state behaves the same across + desktop, bot, and gateway paths + +Reason: + +Entry points should translate protocols; they should not own runtime policy. + +### Phase 7: Model History Correctness + +Build future model prompts from runtime event history projection. + +Deliverables: + +- explicit policy for which runtime events enter model history +- tests for tool-result replay into the next model call +- tests excluding partial chunks, telemetry, trace, and UI-only notes +- provider compatibility tests around AI SDK message shapes + +Reason: + +The next model call must see the right history for the right reason. This +should be a tested runtime policy, not incidental stored-message filtering. + +## What Not To Do + +Avoid these failure modes: + +- Do not rewrite all provider streaming logic from scratch. +- Do not remove the AI SDK as the main flow engine. +- Do not delete session JSONL compatibility. +- Do not move UI concerns into `RuntimeEvent`. +- Do not let `RunTrace` become a success/failure source of truth. +- Do not let desktop main remain the owner of readiness/rebind runtime policy. +- Do not introduce a second tool runtime beside the current `ToolRuntime`; + evolve it into the event/action model. + +## Success Criteria + +Runtime v2 is working when these statements are true: + +- A developer can answer "what happened in this turn?" by reading one + invocation/event ledger. +- UI messages, turn records, run records, trace rows, and telemetry can be + traced back to canonical runtime event ids. +- desktop, bot, and gateway entry points share the same runtime readiness and + execution semantics. +- model history construction is an explicit tested projection from runtime + events. +- tool permission and tool result behavior is visible as runtime actions, not + scattered side effects. +- `SessionManager` is mostly session CRUD plus backward-compatible facade + methods. +- the AI SDK remains a supported first-class flow implementation through + `AiSdkFlow`. + +## Open Questions + +- Should canonical `RuntimeEvent` be stored as a separate JSONL immediately, or + first mirrored into `AgentRunStore` events? +- Should `StoredMessage` projection be synchronous with event append, or should + projection failures be recoverable/replayable? +- How much of current `RunTrace` should be folded into `RuntimeEvent` refs + versus left as diagnostic-only rows? +- Should permission prompts be model-visible events, UI-only events, or + configurable by flow policy? +- How should branch/agent transfer be represented before Maka has a full + multi-agent tree? +- What is the compatibility strategy for old sessions that have no runtime + event ledger? + +These should be answered in the RFC before implementation starts. From 065a172aa018d4b1abf480b9e9421674cc54ab0f Mon Sep 17 00:00:00 2001 From: likun Date: Sun, 14 Jun 2026 20:22:48 +0800 Subject: [PATCH 2/4] Add Runtime v2 implementation skeleton --- docs/runtime-v2-implementation-notes.md | 112 +++ packages/core/package.json | 1 + .../core/src/__tests__/runtime-event.test.ts | 333 ++++++++ packages/core/src/index.ts | 36 + packages/core/src/runtime-event.ts | 312 ++++++++ packages/runtime/package.json | 8 +- .../runtime/src/__tests__/ai-sdk-flow.test.ts | 433 ++++++++++ .../__tests__/runtime-event-adapters.test.ts | 754 ++++++++++++++++++ .../src/__tests__/runtime-runner.test.ts | 315 ++++++++ packages/runtime/src/agent-flow.ts | 161 ++++ packages/runtime/src/ai-sdk-flow.ts | 458 +++++++++++ packages/runtime/src/index.ts | 81 ++ packages/runtime/src/invocation-context.ts | 169 ++++ packages/runtime/src/model-history.ts | 142 ++++ .../runtime/src/runtime-event-adapters.ts | 266 ++++++ packages/runtime/src/runtime-runner.ts | 280 +++++++ 16 files changed, 3860 insertions(+), 1 deletion(-) create mode 100644 docs/runtime-v2-implementation-notes.md create mode 100644 packages/core/src/__tests__/runtime-event.test.ts create mode 100644 packages/core/src/runtime-event.ts create mode 100644 packages/runtime/src/__tests__/ai-sdk-flow.test.ts create mode 100644 packages/runtime/src/__tests__/runtime-event-adapters.test.ts create mode 100644 packages/runtime/src/__tests__/runtime-runner.test.ts create mode 100644 packages/runtime/src/agent-flow.ts create mode 100644 packages/runtime/src/ai-sdk-flow.ts create mode 100644 packages/runtime/src/invocation-context.ts create mode 100644 packages/runtime/src/model-history.ts create mode 100644 packages/runtime/src/runtime-event-adapters.ts create mode 100644 packages/runtime/src/runtime-runner.ts diff --git a/docs/runtime-v2-implementation-notes.md b/docs/runtime-v2-implementation-notes.md new file mode 100644 index 0000000000..8212615137 --- /dev/null +++ b/docs/runtime-v2-implementation-notes.md @@ -0,0 +1,112 @@ +# Runtime v2 implementation notes + +Status: Phase 1–4 skeleton landed (compile-safe, tested). The production +`SessionManager.sendMessage` hot path is **unchanged**; the v2 seam exists +in parallel so future work can migrate onto it incrementally. + +Source plan: `docs/runtime-v2-architecture-evolution.md`. + +## What landed + +### Core contract (`@maka/core`) + +- `packages/core/src/runtime-event.ts` — the canonical `RuntimeEvent` fact + model (role / author / status enums, content discriminated union, actions, + refs, pure helpers `isTerminalRuntimeEvent` / + `runtimeEventHasModelVisibleContent` / `createRuntimeEventId`). +- `packages/core/src/__tests__/runtime-event.test.ts` — focused contract + tests. +- New subpath export `@maka/core/runtime-event`, plus a barrel re-export of + the public surface from `packages/core/src/index.ts`. + +### Runtime v2 seam (`@maka/runtime`) + +Five new modules, each importable via its canonical subpath AND re-exported +(selectively) from the runtime barrel: + +| Module | Subpath | Role | +|---|---|---| +| `runtime-event-adapters.ts` | `@maka/runtime/runtime-event-adapters` | Legacy `StoredMessage` ↔ `RuntimeEvent` bridge (user/assistant/system_note text + thinking; tool/permission/tokenUsage return `null`). | +| `model-history.ts` | `@maka/runtime/model-history` | Policy-driven `buildModelHistoryFromRuntimeEvents()` replacing ad-hoc `StoredMessage` filtering. | +| `invocation-context.ts` | `@maka/runtime/invocation-context` | `InvocationRequest` / `InvocationContext` spine, injectable `newId`/`now` providers, `InvocationResult` envelope. | +| `runtime-runner.ts` | `@maka/runtime/runtime-runner` | `RuntimeRunner.run()` collecting shell: preflight gate → context → user event → flow dispatch → terminal collection. | +| `agent-flow.ts` | `@maka/runtime/agent-flow` | Formal `AgentFlow` / `AgentFlowControl` / `FlowInput` seam. | +| `ai-sdk-flow.ts` | `@maka/runtime/ai-sdk-flow` | `AiSdkFlow` wrapping an `AgentBackend`; `mapSessionEventToRuntimeEvent()` placeholder mapping. | + +Each module ships a co-located test suite +(`runtime-event-adapters.test.ts`, `runtime-runner.test.ts`, +`ai-sdk-flow.test.ts`). + +### Exports consolidated by the steward + +- `packages/core/package.json` — added `"./runtime-event"`. +- `packages/core/src/index.ts` — re-exports the `RuntimeEvent` surface. +- `packages/runtime/package.json` — added six subpath exports. +- `packages/runtime/src/index.ts` — selective barrel re-exports. + +## Known reconciliation point: two `InvocationContext` types + +Two modules independently declare `InvocationContext`: + +- `invocation-context.ts` — the **canonical** runner spine (required + `source`, `startedAt`, `request`, `newId`, `now`). +- `agent-flow.ts` — a structurally **wider** flow-seam context (optional + `newId`/`now`; no `source`/`startedAt`/`request`). + +The runner's context is assignable to the flow's (extra required fields are +harmless when passed into `AgentFlow.run()`), so the two compose today. The +runtime barrel re-exports **only** the canonical runner `InvocationContext` +to avoid a name clash; the flow's narrower view stays reachable via +`@maka/runtime/agent-flow`. + +**Future reconciliation (not done in this increment):** unify on one +`InvocationContext` owned by `invocation-context.ts`, have `agent-flow.ts` +import it, and update `ai-sdk-flow.test.ts`'s minimal `ctx` fixture to +supply the required spine fields. Deferred because it is a behavior-shaping +merge best done alongside the SessionManager-delegation node. + +## What remains (by phase) + +- **Phase 5 — Tool-event actions:** promote `tool_output_delta` / + `tool_progress` `SessionEvent`s to a dedicated tool-progress runtime + action (currently partial tool-role heartbeats). Refine + `mapSessionEventToRuntimeEvent` role/author policy. +- **Phase 6 — RuntimeGate:** implement the real preflight (connection + readiness/rebind, blocked/running/waiting guards) behind `RuntimeGate` + and inject it into desktop + bot/gateway entrypoints. +- **Phase 7 — Projection:** drive `StoredMessage` / `TurnRecord` / + `SessionHeader` / `AgentRunStore` / `RunTrace` / `TelemetryRepo` writes + from `InvocationResult.events`. Wire + `buildModelHistoryFromRuntimeEvents()` into the live + `AiSdkBackend.materializePriorMessages` path. +- **SessionManager delegation:** replace the body of + `SessionManager.sendMessage` with `RuntimeRunner.run(...)` behind a + feature flag, mapping `InvocationResult.events` → existing + `SessionEvent` projection. A streaming `async *stream()` variant may be + added then if the renderer needs live deltas. Today `RuntimeRunner.run()` + is **collecting** (returns `Promise`), not streaming. +- **`abort` + `complete` coalescing:** `AiSdkFlow` is a faithful translator + (the backend emits `abort` then a trailing `complete`, and the flow emits + both). Coalescing into a single terminal event is a runner/projection + concern. +- **`AgentFlowLike` vs `AgentFlow`:** the runner defines a local + `AgentFlowLike` (`run(ctx, request)`) that predates the formal + `AgentFlow` (`run(ctx, input: FlowInput)`). Their second parameters + differ, so they are not cleanly assignable today. Convergence is a + SessionManager-delegation task. + +## Verification snapshot + +All commands run from the repository root (`$RIVE_WORKSPACE`): + +``` +npm run build # all workspaces — clean +npm run typecheck # all workspaces — clean +npm --workspace @maka/core run test # 613 pass / 0 fail +npm --workspace @maka/runtime run test # 384 pass / 0 fail +git diff --check # clean +``` + +No production source (`session-manager.ts`, `ai-sdk-backend.ts`, +`agent-run.ts`, `materializer.ts`) was modified. The v2 seam is purely +additive. diff --git a/packages/core/package.json b/packages/core/package.json index 825b2bea6e..92bcd0e610 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,6 +8,7 @@ "types": "./dist/index.d.ts", "exports": { ".": "./dist/index.js", + "./runtime-event": "./dist/runtime-event.js", "./events": "./dist/events.js", "./session": "./dist/session.js", "./agent-run": "./dist/agent-run.js", diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts new file mode 100644 index 0000000000..e0a5bbad10 --- /dev/null +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -0,0 +1,333 @@ +import { describe, test } from 'node:test'; +import { expect } from '../test-helpers.js'; +import { + RUNTIME_EVENT_AUTHORS, + RUNTIME_EVENT_CONTENT_KINDS, + RUNTIME_EVENT_ROLES, + RUNTIME_EVENT_STATUSES, + TERMINAL_RUNTIME_EVENT_STATUSES, + createRuntimeEventId, + isRuntimeEventAuthor, + isRuntimeEventRole, + isRuntimeEventStatus, + isTerminalRuntimeEvent, + isTerminalRuntimeEventStatus, + isPartialRuntimeEvent, + runtimeEventHasModelVisibleContent, + type RuntimeEvent, + type RuntimeEventActions, + type RuntimeEventContent, +} from '../runtime-event.js'; + +/** Minimal valid RuntimeEvent; callers spread overrides on top. */ +function baseEvent(overrides: Partial = {}): RuntimeEvent { + return { + id: 'evt-1', + invocationId: 'inv-1', + runId: 'run-1', + sessionId: 'sess-1', + turnId: 'turn-1', + ts: 100, + partial: false, + role: 'model', + author: 'agent', + ...overrides, + }; +} + +describe('RuntimeEvent role / author / status enums', () => { + test('locks the role enum and guard', () => { + expect(RUNTIME_EVENT_ROLES).toEqual(['user', 'model', 'tool', 'system']); + expect(isRuntimeEventRole('model')).toBe(true); + expect(isRuntimeEventRole('assistant')).toBe(false); + expect(isRuntimeEventRole(123)).toBe(false); + }); + + test('locks the author enum (agent ≠ model) and guard', () => { + expect(RUNTIME_EVENT_AUTHORS).toEqual(['user', 'agent', 'tool', 'system']); + expect(isRuntimeEventAuthor('agent')).toBe(true); + expect(isRuntimeEventAuthor('model')).toBe(false); + expect(isRuntimeEventAuthor(null)).toBe(false); + }); + + test('locks the status enum, terminal subset, and guards', () => { + expect(RUNTIME_EVENT_STATUSES).toEqual([ + 'streaming', + 'completed', + 'failed', + 'aborted', + 'cancelled', + ]); + expect(TERMINAL_RUNTIME_EVENT_STATUSES).toEqual([ + 'completed', + 'failed', + 'aborted', + 'cancelled', + ]); + expect(isRuntimeEventStatus('streaming')).toBe(true); + expect(isRuntimeEventStatus('idle')).toBe(false); + expect(isTerminalRuntimeEventStatus('completed')).toBe(true); + expect(isTerminalRuntimeEventStatus('streaming')).toBe(false); + expect(isTerminalRuntimeEventStatus('nope')).toBe(false); + }); + + test('content kind list matches the discriminated union', () => { + expect(RUNTIME_EVENT_CONTENT_KINDS).toEqual([ + 'text', + 'thinking', + 'function_call', + 'function_response', + 'error', + ]); + }); +}); + +describe('RuntimeEvent content variants', () => { + test('text content carries a string body', () => { + const content: RuntimeEventContent = { kind: 'text', text: 'hello' }; + if (content.kind !== 'text') throw new Error('unreachable'); + expect(content.text).toBe('hello'); + }); + + test('thinking content may carry a replay signature', () => { + const content: RuntimeEventContent = { + kind: 'thinking', + text: 'reasoning', + signature: 'sig', + }; + if (content.kind !== 'thinking') throw new Error('unreachable'); + expect(content.signature).toBe('sig'); + }); + + test('function_call and function_response share an id', () => { + const call: RuntimeEventContent = { + kind: 'function_call', + id: 'tc-1', + name: 'Read', + args: { path: '/x' }, + }; + const response: RuntimeEventContent = { + kind: 'function_response', + id: 'tc-1', + name: 'Read', + result: 'ok', + isError: false, + }; + if (call.kind !== 'function_call' || response.kind !== 'function_response') { + throw new Error('unreachable'); + } + expect(call.id).toBe(response.id); + expect(response.isError).toBe(false); + }); + + test('error content keeps the existing ErrorEvent shape', () => { + const content: RuntimeEventContent = { + kind: 'error', + reason: 'provider_5xx', + message: 'upstream failed', + }; + if (content.kind !== 'error') throw new Error('unreachable'); + expect(content.message).toBe('upstream failed'); + }); +}); + +describe('RuntimeEvent actions', () => { + test('a terminal action can carry endInvocation + tokenUsage', () => { + const actions: RuntimeEventActions = { + endInvocation: true, + tokenUsage: { input: 10, output: 5, costUsd: 0.001 }, + }; + expect(actions.endInvocation).toBe(true); + expect(actions.tokenUsage?.input).toBe(10); + }); + + test('permission request/decision are first-class actions', () => { + const actions: RuntimeEventActions = { + permissionRequest: { + requestId: 'pr-1', + toolUseId: 'tc-1', + toolName: 'Bash', + category: 'shell_unsafe', + reason: 'shell_dangerous', + args: { command: 'rm foo' }, + }, + permissionDecision: { requestId: 'pr-1', decision: 'deny' }, + }; + expect(actions.permissionRequest?.category).toBe('shell_unsafe'); + expect(actions.permissionDecision?.decision).toBe('deny'); + }); + + test('state/artifact deltas accept primitive values', () => { + const actions: RuntimeEventActions = { + stateDelta: { retries: 1 }, + artifactDelta: { 'out.md': 2048 }, + }; + expect(actions.stateDelta?.retries).toBe(1); + expect(actions.artifactDelta?.['out.md']).toBe(2048); + }); +}); + +describe('isTerminalRuntimeEvent', () => { + test('a content event with no status is not terminal', () => { + expect(isTerminalRuntimeEvent(baseEvent({ content: { kind: 'text', text: 'hi' } }))).toBe(false); + }); + + test('a terminal status makes the event terminal', () => { + for (const status of TERMINAL_RUNTIME_EVENT_STATUSES) { + expect(isTerminalRuntimeEvent(baseEvent({ status }))).toBe(true); + } + }); + + test('streaming status is NOT terminal', () => { + expect(isTerminalRuntimeEvent(baseEvent({ status: 'streaming' }))).toBe(false); + }); + + test('actions.endInvocation === true is terminal even without status', () => { + expect( + isTerminalRuntimeEvent(baseEvent({ actions: { endInvocation: true } })), + ).toBe(true); + }); + + test('actions.endInvocation === false is NOT terminal', () => { + expect( + isTerminalRuntimeEvent(baseEvent({ actions: { endInvocation: false } })), + ).toBe(false); + }); +}); + +describe('isPartialRuntimeEvent', () => { + test('reflects the partial flag exactly', () => { + expect(isPartialRuntimeEvent(baseEvent({ partial: true }))).toBe(true); + expect(isPartialRuntimeEvent(baseEvent({ partial: false }))).toBe(false); + }); +}); + +describe('runtimeEventHasModelVisibleContent', () => { + test('text content is model-visible when non-empty', () => { + expect( + runtimeEventHasModelVisibleContent( + baseEvent({ role: 'user', content: { kind: 'text', text: 'hi' } }), + ), + ).toBe(true); + }); + + test('empty text content is NOT model-visible', () => { + expect( + runtimeEventHasModelVisibleContent( + baseEvent({ content: { kind: 'text', text: '' } }), + ), + ).toBe(false); + }); + + test('thinking, function_call, and function_response are model-visible', () => { + expect( + runtimeEventHasModelVisibleContent( + baseEvent({ content: { kind: 'thinking', text: 'r' } }), + ), + ).toBe(true); + expect( + runtimeEventHasModelVisibleContent( + baseEvent({ content: { kind: 'function_call', id: '1', name: 'Read', args: {} } }), + ), + ).toBe(true); + expect( + runtimeEventHasModelVisibleContent( + baseEvent({ + content: { kind: 'function_response', id: '1', name: 'Read', result: 'ok' }, + }), + ), + ).toBe(true); + }); + + test('a tool error returned to the model (function_response isError) is still visible', () => { + expect( + runtimeEventHasModelVisibleContent( + baseEvent({ + content: { + kind: 'function_response', + id: '1', + name: 'Bash', + result: 'boom', + isError: true, + }, + }), + ), + ).toBe(true); + }); + + test('error-only content is NOT model-visible', () => { + expect( + runtimeEventHasModelVisibleContent( + baseEvent({ content: { kind: 'error', message: 'upstream failed' } }), + ), + ).toBe(false); + }); + + test('pure action / refs events are NOT model-visible', () => { + expect( + runtimeEventHasModelVisibleContent( + baseEvent({ actions: { tokenUsage: { input: 1, output: 1 } } }), + ), + ).toBe(false); + expect( + runtimeEventHasModelVisibleContent(baseEvent({ refs: { toolCallId: 'tc-1' } })), + ).toBe(false); + }); +}); + +describe('createRuntimeEventId', () => { + test('honors the prefix and returns a string', () => { + const id = createRuntimeEventId('turn'); + expect(typeof id).toBe('string'); + expect(id.startsWith('turn_')).toBe(true); + }); + + test('uses the default prefix when none is given', () => { + expect(createRuntimeEventId().startsWith('rt-event_')).toBe(true); + }); + + test('never collides within a process', () => { + const ids = new Set(); + for (let i = 0; i < 500; i += 1) ids.add(createRuntimeEventId()); + expect(ids.size).toBe(500); + }); +}); + +describe('RuntimeEvent shape compile-time contract', () => { + test('a full user event satisfies the type', () => { + const event: RuntimeEvent = { + id: 'evt-u1', + invocationId: 'inv-1', + runId: 'run-1', + sessionId: 'sess-1', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'hello' }, + }; + expect(event.role).toBe('user'); + expect(isTerminalRuntimeEvent(event)).toBe(false); + }); + + test('a terminal agent event with branch + refs satisfies the type', () => { + const event: RuntimeEvent = { + id: 'evt-t1', + invocationId: 'inv-1', + runId: 'run-1', + sessionId: 'sess-1', + turnId: 'turn-1', + ts: 99, + branch: 'main', + partial: false, + role: 'model', + author: 'agent', + status: 'completed', + actions: { endInvocation: true, tokenUsage: { input: 1, output: 2 } }, + refs: { storedMessageId: 'm1', toolCallId: 'tc-1' }, + }; + expect(isTerminalRuntimeEvent(event)).toBe(true); + expect(event.branch).toBe('main'); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 05f7dbfcfc..96b9179c05 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,6 +37,42 @@ export { TOOL_OUTPUT_STREAMS, } from './events.js'; +// runtime-event.ts — canonical Runtime v2 event contract. +// Subpath `@maka/core/runtime-event` is the canonical import; these barrel +// re-exports are for convenience. +export type { + RuntimeEvent, + RuntimeEventRole, + RuntimeEventAuthor, + RuntimeEventStatus, + RuntimeEventTextContent, + RuntimeEventThinkingContent, + RuntimeEventFunctionCallContent, + RuntimeEventFunctionResponseContent, + RuntimeEventErrorContent, + RuntimeEventContent, + RuntimeEventContentKind, + RuntimeEventTokenUsage, + RuntimeEventPermissionDecision, + RuntimeEventActions, + RuntimeEventRefs, +} from './runtime-event.js'; +export { + RUNTIME_EVENT_ROLES, + RUNTIME_EVENT_AUTHORS, + RUNTIME_EVENT_STATUSES, + TERMINAL_RUNTIME_EVENT_STATUSES, + RUNTIME_EVENT_CONTENT_KINDS, + isRuntimeEventRole, + isRuntimeEventAuthor, + isRuntimeEventStatus, + isTerminalRuntimeEventStatus, + isTerminalRuntimeEvent, + isPartialRuntimeEvent, + runtimeEventHasModelVisibleContent, + createRuntimeEventId, +} from './runtime-event.js'; + // session.ts export type { SessionHeader, diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts new file mode 100644 index 0000000000..e122c685ec --- /dev/null +++ b/packages/core/src/runtime-event.ts @@ -0,0 +1,312 @@ +/** + * Canonical Runtime v2 event contract. + * + * This is the single internal runtime fact model. It is NOT a UI event + * (see ./events.ts `SessionEvent`) and NOT a trace row (RunTrace) or + * telemetry record. StoredMessage JSONL, renderer SessionEvent, + * AgentRunStore, RunTrace, and TelemetryRepo are all projections that + * should be written from — or explicitly linked to — these events. + * + * Source: docs/runtime-v2-architecture-evolution.md §Canonical RuntimeEvent + * + * Phase 1 scope: types + small pure helpers only. No storage, runner, + * projection, or ledger logic lives here. Those arrive in later nodes. + */ + +import type { PermissionRequest, PermissionResponse } from './permission.js'; + +// ============================================================================ +// Role / Author / Status +// ============================================================================ + +/** + * Conversation role the event plays in model history. Maps 1:1 with the + * roles providers expect in a message history (user / model / tool / + * system). Role is about *what lane* the content belongs to. + */ +export const RUNTIME_EVENT_ROLES = ['user', 'model', 'tool', 'system'] as const; +export type RuntimeEventRole = typeof RUNTIME_EVENT_ROLES[number]; + +export function isRuntimeEventRole(value: unknown): value is RuntimeEventRole { + return typeof value === 'string' && (RUNTIME_EVENT_ROLES as readonly string[]).includes(value); +} + +/** + * Who authored the event inside the runtime. `agent` covers the model + + * flow orchestration; `tool` covers tool execution; `system` covers the + * runner, gate, and recovery. Author is about *which subsystem* produced + * the fact, which is orthogonal to the model-history `role`. + * + * Not every (author, role) combination is meaningful, but the runtime — + * not this type module — owns the policy that constrains them. + */ +export const RUNTIME_EVENT_AUTHORS = ['user', 'agent', 'tool', 'system'] as const; +export type RuntimeEventAuthor = typeof RUNTIME_EVENT_AUTHORS[number]; + +export function isRuntimeEventAuthor(value: unknown): value is RuntimeEventAuthor { + return typeof value === 'string' && (RUNTIME_EVENT_AUTHORS as readonly string[]).includes(value); +} + +/** + * Lifecycle status an event asserts about its invocation/turn. Omitted on + * ordinary in-flight content events. Terminal values (completed / failed / + * aborted / cancelled) mark the last event of an invocation; `streaming` + * marks a non-terminal partial event that still carries lifecycle intent + * (e.g. a flow heartbeat) without being a content delta. + */ +export const RUNTIME_EVENT_STATUSES = [ + 'streaming', + 'completed', + 'failed', + 'aborted', + 'cancelled', +] as const; +export type RuntimeEventStatus = typeof RUNTIME_EVENT_STATUSES[number]; + +export const TERMINAL_RUNTIME_EVENT_STATUSES: readonly RuntimeEventStatus[] = [ + 'completed', + 'failed', + 'aborted', + 'cancelled', +]; + +export function isRuntimeEventStatus(value: unknown): value is RuntimeEventStatus { + return typeof value === 'string' && (RUNTIME_EVENT_STATUSES as readonly string[]).includes(value); +} + +export function isTerminalRuntimeEventStatus(value: unknown): boolean { + return ( + typeof value === 'string' && + (TERMINAL_RUNTIME_EVENT_STATUSES as readonly string[]).includes(value) + ); +} + +// ============================================================================ +// Content (model-facing payload) +// ============================================================================ + +export interface RuntimeEventTextContent { + kind: 'text'; + text: string; +} + +export interface RuntimeEventThinkingContent { + kind: 'thinking'; + text: string; + /** Anthropic signed thinking — MUST be re-sent on replay when present. */ + signature?: string; +} + +export interface RuntimeEventFunctionCallContent { + kind: 'function_call'; + /** Matches the tool-call id the provider issued and the matching response. */ + id: string; + name: string; + args: unknown; +} + +export interface RuntimeEventFunctionResponseContent { + kind: 'function_response'; + /** Matches RuntimeEventFunctionCallContent.id. */ + id: string; + name: string; + result: unknown; + isError?: boolean; +} + +export interface RuntimeEventErrorContent { + kind: 'error'; + code?: string; + /** Stable machine-readable reason for routing; mirrors ErrorEvent.reason. */ + reason?: string; + message: string; + /** Adapter MUST scrub secrets before populating this field. */ + details?: string[] | Record; +} + +/** + * Content union for user/model text, model thinking, function call, + * function response, and error payloads. Discriminated by `kind` to + * match the existing ToolResultContent convention. + */ +export type RuntimeEventContent = + | RuntimeEventTextContent + | RuntimeEventThinkingContent + | RuntimeEventFunctionCallContent + | RuntimeEventFunctionResponseContent + | RuntimeEventErrorContent; + +export const RUNTIME_EVENT_CONTENT_KINDS = [ + 'text', + 'thinking', + 'function_call', + 'function_response', + 'error', +] as const; +export type RuntimeEventContentKind = typeof RUNTIME_EVENT_CONTENT_KINDS[number]; + +// ============================================================================ +// Actions (control / side-effect intent) +// ============================================================================ + +/** + * Token usage carried as a runtime action rather than a content payload. + * Mirrors TokenUsageEvent / TokenUsageMessage so projections can map 1:1. + */ +export interface RuntimeEventTokenUsage { + input: number; + output: number; + cacheRead?: number; + cacheCreation?: number; + costUsd?: number; + contextRemaining?: number; +} + +/** + * Permission decision attached to an event. This is the same shape as + * `PermissionResponse` (aliased as `PermissionDecision` in + * ./backend-types.ts); the runtime records the decision as an action so + * allow/deny is a first-class runtime fact, not just a UI echo. + */ +export type RuntimeEventPermissionDecision = PermissionResponse; + +/** + * Control and side-effect intent carried alongside content. An event may + * carry content, actions, both, or (rarely) neither — but a terminal + * event without `actions.endInvocation` MUST assert a terminal `status`. + */ +export interface RuntimeEventActions { + /** Patch applied to invocation-scoped runtime state. */ + stateDelta?: Record; + /** Artifact key → primitive delta (size/bytes/version counters, etc.). */ + artifactDelta?: Record; + /** A permission prompt raised for a tool call. */ + permissionRequest?: PermissionRequest; + /** A resolved permission decision (allow/deny) for a prior request. */ + permissionDecision?: RuntimeEventPermissionDecision; + /** Hand off the invocation to another agent (multi-agent transfer). */ + transferToAgent?: string; + /** Marks the event that closes the invocation. */ + endInvocation?: boolean; + /** Token accounting for the model call this event summarizes. */ + tokenUsage?: RuntimeEventTokenUsage; +} + +// ============================================================================ +// Refs (links to projections / ledgers) +// ============================================================================ + +/** + * Links back to the projection/ledger rows written from (or correlated + * with) this event. Refs are diagnostics/audit pointers; a missing ref + * never changes runtime behavior. `toolCallId` doubles as the matching + * key for function_call ↔ function_response when provider ids differ. + */ +export interface RuntimeEventRefs { + storedMessageId?: string; + traceEventId?: string; + toolCallId?: string; + providerEventId?: string; + artifactId?: string; +} + +// ============================================================================ +// RuntimeEvent +// ============================================================================ + +/** + * The canonical runtime fact. + * + * Identity hierarchy: `sessionId` ⊃ `invocationId` ⊃ `runId` ⊃ `turnId`. + * `invocationId` is the durable spine id; `runId`/`turnId` name the + * specific execution attempt and user turn within it. `ts` is Unix ms. + * + * `partial: true` marks a transient chunk (streaming text, progress) that + * is superseded by a later non-partial event. Projections decide whether + * to persist partials; model history MUST exclude them. + */ +export interface RuntimeEvent { + /** Event uuid — used for dedup on reconnect/replay. */ + id: string; + /** Durable invocation spine id; groups every run/turn of one request. */ + invocationId: string; + /** Specific run/attempt within the invocation (maps to AgentRunHeader.runId). */ + runId: string; + sessionId: string; + /** Groups all events from one agent turn (maps to StoredMessage.turnId). */ + turnId: string; + /** Unix ms timestamp. */ + ts: number; + + /** Optional branch/agent lane for future multi-agent trees. */ + branch?: string; + /** True for transient streaming chunks superseded by a later event. */ + partial: boolean; + + role: RuntimeEventRole; + author: RuntimeEventAuthor; + /** Lifecycle assertion; omitted on ordinary in-flight content events. */ + status?: RuntimeEventStatus; + + content?: RuntimeEventContent; + actions?: RuntimeEventActions; + refs?: RuntimeEventRefs; +} + +// ============================================================================ +// Pure helpers +// ============================================================================ + +/** + * True if the event marks the end of its invocation — either by asserting + * a terminal `status` or by carrying `actions.endInvocation === true`. + * A single terminal event SHOULD carry exactly one of these signals. + */ +export function isTerminalRuntimeEvent(event: RuntimeEvent): boolean { + if (event.status !== undefined && isTerminalRuntimeEventStatus(event.status)) return true; + return event.actions?.endInvocation === true; +} + +/** True for transient streaming/progress chunks that a later event supersedes. */ +export function isPartialRuntimeEvent(event: RuntimeEvent): boolean { + return event.partial === true; +} + +/** + * True if the event carries content whose kind is eligible for model + * history projection: text, thinking, function_call, or function_response. + * Error-only content and pure action/refs events are NOT model-visible. + * + * This is a content-kind check only. Callers still apply `partial` + * filtering (partial chunks are never replayed into the next model call). + */ +export function runtimeEventHasModelVisibleContent(event: RuntimeEvent): boolean { + const content = event.content; + if (!content) return false; + switch (content.kind) { + case 'text': + return content.text.length > 0; + case 'thinking': + case 'function_call': + case 'function_response': + return true; + case 'error': + return false; + } +} + +let __runtimeEventSeq = 0; + +/** + * Best-effort unique id for runtime events. Monotonic within a process so + * two ids never collide even when generated in the same millisecond. + * + * Runtime/runner layers MAY replace this with a stronger uuid source; it + * exists here only so early adopters and tests have a default. Tests that + * need deterministic ids SHOULD pass literal strings rather than rely on + * this helper's exact output. + */ +export function createRuntimeEventId(prefix = 'rt-event'): string { + __runtimeEventSeq += 1; + return `${prefix}_${Date.now().toString(36)}_${__runtimeEventSeq.toString(36)}`; +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b67690811e..a46717e73b 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -25,7 +25,13 @@ "./network/proxy-test": "./dist/network/proxy-test.js", "./network/proxy-parser": "./dist/network/proxy-parser.js", "./telemetry": "./dist/telemetry/index.js", - "./bots": "./dist/bots/index.js" + "./bots": "./dist/bots/index.js", + "./runtime-event-adapters": "./dist/runtime-event-adapters.js", + "./model-history": "./dist/model-history.js", + "./invocation-context": "./dist/invocation-context.js", + "./runtime-runner": "./dist/runtime-runner.js", + "./agent-flow": "./dist/agent-flow.js", + "./ai-sdk-flow": "./dist/ai-sdk-flow.js" }, "scripts": { "build": "tsc -p tsconfig.json", diff --git a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts new file mode 100644 index 0000000000..51d1773c03 --- /dev/null +++ b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts @@ -0,0 +1,433 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import type { BackendKind } from '@maka/core/session'; +import type { SessionEvent } from '@maka/core/events'; +import type { PermissionDecision } from '@maka/core/backend-types'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { + isTerminalRuntimeEvent, + isPartialRuntimeEvent, +} from '@maka/core/runtime-event'; + +import { + AiSdkFlow, + mapCompleteStopReason, + mapSessionEventToRuntimeEvent, + createSessionEventMapMemory, +} from '../ai-sdk-flow.js'; +import { + flowSupportsControl, +} from '../agent-flow.js'; +import type { AgentBackend } from '../ai-sdk-backend.js'; + +// ============================================================================ +// Fake backend — scripted SessionEvent stream + recorded control calls +// ============================================================================ + +interface ScriptedBackendCtor { + kind?: BackendKind; + sessionId?: string; + events: SessionEvent[]; + /** Optional gate: send() awaits this after yielding each event. */ + gate?: () => Promise; +} + +class ScriptedBackend implements AgentBackend { + readonly kind: BackendKind; + readonly sessionId: string; + readonly stopCalls: Array<'user_stop' | 'redirect'> = []; + readonly permissionCalls: PermissionDecision[] = []; + disposeCalls = 0; + sendCalls = 0; + private readonly events: SessionEvent[]; + private readonly gate?: () => Promise; + + constructor(c: ScriptedBackendCtor) { + this.kind = c.kind ?? 'ai-sdk'; + this.sessionId = c.sessionId ?? 'session-1'; + this.events = c.events; + this.gate = c.gate; + } + + async *send(): AsyncIterable { + this.sendCalls += 1; + for (const e of this.events) { + yield e; + if (this.gate) await this.gate(); + } + } + + async stop(reason: 'user_stop' | 'redirect'): Promise { + this.stopCalls.push(reason); + } + + async respondToPermission(decision: PermissionDecision): Promise { + this.permissionCalls.push(decision); + } + + async dispose(): Promise { + this.disposeCalls += 1; + } +} + +// ============================================================================ +// Event builders +// ============================================================================ + +let __seq = 0; +type DistributiveOmit = T extends any ? Omit : never; +function ev(e: DistributiveOmit & Partial>): SessionEvent { + __seq += 1; + return { id: `evt-${__seq}`, turnId: 'turn-1', ts: e.ts ?? __seq, ...e } as SessionEvent; +} + +const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + newId: () => 'rt-id', + now: () => 1000, +}; + +function collect(stream: AsyncIterable): Promise { + const out: RuntimeEvent[] = []; + return (async () => { + for await (const e of stream) out.push(e); + return out; + })(); +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('AiSdkFlow seam', () => { + test('implements AgentFlow + AgentFlowControl and reflects the wrapped backend', () => { + const backend = new ScriptedBackend({ events: [] }); + const flow = new AiSdkFlow({ backend }); + + assert.equal(flow.kind, 'ai-sdk'); + assert.equal(flow.sessionId, 'session-1'); + assert.equal(typeof flow.run, 'function'); + assert.equal(flowSupportsControl(flow), true); + assert.equal(flow.backendRef, backend); + // Structural: an AiSdkFlow is assignable to the AgentFlow contract. + const _asFlow: import('../agent-flow.js').AgentFlow = flow; + void _asFlow; + }); + + test('maps a normal turn preserving event order and terminal guarantee', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'text_delta', messageId: 'm1', text: 'Hel' }), + ev({ type: 'text_delta', messageId: 'm1', text: 'lo' }), + ev({ type: 'text_complete', messageId: 'm1', text: 'Hello' }), + ev({ type: 'token_usage', input: 10, output: 5 }), + ev({ type: 'complete', stopReason: 'end_turn' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + + const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); + + assert.equal(out.length, 5); + // Order preserved. + assert.deepEqual( + out.map((e) => e.content?.kind ?? null), + ['text', 'text', 'text', null, null], + ); + // Deltas are partial; complete is not. + assert.equal(isPartialRuntimeEvent(out[0]), true); + assert.equal(isPartialRuntimeEvent(out[2]), false); + // Identity spine propagated. + assert.equal(out[0].invocationId, 'inv-1'); + assert.equal(out[0].runId, 'run-1'); + assert.equal(out[0].sessionId, 'session-1'); + assert.equal(out[0].turnId, 'turn-1'); + // id reused from source for 1:1 dedup linkage. + assert.equal(out[0].id, 'evt-1'); + // Token usage carried as an action. + assert.deepEqual(out[3].actions?.tokenUsage, { input: 10, output: 5 }); + // Stream closes with a terminal event. + assert.equal(isTerminalRuntimeEvent(out[out.length - 1]), true); + assert.equal(out[out.length - 1].status, 'completed'); + assert.equal(out[out.length - 1].actions?.endInvocation, true); + // send was invoked exactly once with the turn id. + assert.equal(backend.sendCalls, 1); + }); + + test('maps thinking deltas/signature onto model thinking content', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'thinking_delta', messageId: 'm1', text: 'hm' }), + ev({ type: 'thinking_complete', messageId: 'm1', text: 'hmm', signature: 'sig' }), + ev({ type: 'complete', stopReason: 'end_turn' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); + + assert.equal(isPartialRuntimeEvent(out[0]), true); + assert.equal(out[1].content?.kind, 'thinking'); + assert.equal((out[1].content as { signature?: string }).signature, 'sig'); + assert.equal(isPartialRuntimeEvent(out[1]), false); + }); + + test('preserves toolName linkage between tool_start and tool_result', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'tool_start', toolUseId: 'tu-1', toolName: 'read', args: { path: '/a' } }), + ev({ + type: 'tool_result', + toolUseId: 'tu-1', + isError: false, + content: { kind: 'text', text: 'body' }, + durationMs: 42, + }), + ev({ type: 'complete', stopReason: 'end_turn' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + const out = await collect(flow.run(ctx, { text: 'read it', context: [] })); + + // tool_start -> function_call + const call = out[0]; + assert.equal(call.role, 'model'); + assert.equal(call.author, 'agent'); + assert.equal(call.content?.kind, 'function_call'); + const fnCall = call.content as { id: string; name: string; args: unknown }; + assert.equal(fnCall.name, 'read'); + assert.equal(fnCall.id, 'tu-1'); + assert.equal(call.refs?.toolCallId, 'tu-1'); + + // tool_result -> function_response with the remembered name + const result = out[1]; + assert.equal(result.role, 'tool'); + assert.equal(result.author, 'tool'); + assert.equal(result.content?.kind, 'function_response'); + const fnResp = result.content as { id: string; name: string; result: unknown; isError?: boolean }; + assert.equal(fnResp.name, 'read', 'tool_result recovers toolName from the prior tool_start'); + assert.equal(fnResp.isError, undefined); + assert.equal(result.refs?.toolCallId, 'tu-1'); + assert.deepEqual(result.actions?.stateDelta, { durationMs: 42 }); + }); + + test('maps permission request/decision as first-class runtime actions', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ + type: 'permission_request', + requestId: 'req-1', + toolUseId: 'tu-2', + toolName: 'bash', + category: 'shell_unsafe', + reason: 'shell_dangerous', + args: { cmd: 'rm -rf /' }, + hint: 'destructive', + }), + ev({ + type: 'permission_decision_ack', + requestId: 'req-1', + toolUseId: 'tu-2', + decision: 'deny', + rememberForTurn: true, + }), + ev({ type: 'complete', stopReason: 'permission_handoff' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + const out = await collect(flow.run(ctx, { text: 'do it', context: [] })); + + const req = out[0]; + assert.equal(req.author, 'system'); + assert.equal(req.actions?.permissionRequest?.requestId, 'req-1'); + assert.equal(req.actions?.permissionRequest?.toolName, 'bash'); + assert.equal(req.actions?.permissionRequest?.hint, 'destructive'); + + const ack = out[1]; + assert.equal(ack.author, 'user', 'permission decision is authored by the user'); + assert.deepEqual(ack.actions?.permissionDecision, { + requestId: 'req-1', + decision: 'deny', + rememberForTurn: true, + }); + + // permission_handoff stopReason maps to completed (run streamed to a halt). + assert.equal(out[2].status, 'completed'); + }); + + test('maps the error path preserving error content + terminal failed', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'error', recoverable: false, code: 'AUTH', reason: 'auth_failed', message: 'no token' }), + ev({ type: 'complete', stopReason: 'error' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); + + const err = out[0]; + assert.equal(err.content?.kind, 'error'); + const errContent = err.content as { code?: string; reason?: string; message: string }; + assert.equal(errContent.message, 'no token'); + assert.equal(errContent.code, 'AUTH'); + assert.equal(errContent.reason, 'auth_failed'); + // error event itself is non-terminal; the trailing complete carries failed. + assert.equal(isTerminalRuntimeEvent(err), false); + + assert.equal(out[1].status, 'failed'); + assert.equal(isTerminalRuntimeEvent(out[1]), true); + }); + + test('maps the abort path preserving order (faithful, no coalescing)', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'text_delta', messageId: 'm1', text: 'par' }), + ev({ type: 'abort', reason: 'user_stop' }), + ev({ type: 'complete', stopReason: 'user_stop' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); + + // The adapter is faithful to the backend stream: both abort and the + // trailing complete are emitted (coalescing is a projection concern). + assert.equal(out.length, 3); + assert.equal(out[1].status, 'aborted'); + assert.equal(out[1].actions?.endInvocation, true); + assert.equal(isTerminalRuntimeEvent(out[1]), true); + // Stream closes with the trailing terminal complete. + assert.equal(isTerminalRuntimeEvent(out[2]), true); + assert.equal(out[2].status, 'aborted'); + }); + + test('delegates stop / respondToPermission / dispose to the wrapped backend', async () => { + const backend = new ScriptedBackend({ events: [] }); + const flow = new AiSdkFlow({ backend }); + + await flow.stop('redirect'); + await flow.respondToPermission({ requestId: 'r', decision: 'allow' }); + await flow.dispose(); + + assert.deepEqual(backend.stopCalls, ['redirect']); + assert.deepEqual(backend.permissionCalls, [{ requestId: 'r', decision: 'allow' }]); + assert.equal(backend.disposeCalls, 1); + }); + + test('throws on session id mismatch between ctx and backend', async () => { + const backend = new ScriptedBackend({ sessionId: 'session-1', events: [] }); + const flow = new AiSdkFlow({ backend }); + + await assert.rejects( + collect( + flow.run( + { ...ctx, sessionId: 'other' }, + { text: 'hi', context: [] }, + ), + ), + /AiSdkFlow session mismatch/, + ); + }); + + test('bridges FlowInput.abortSignal onto backend.stop("user_stop")', async () => { + let releaseGate: () => void = () => {}; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'text_delta', messageId: 'm1', text: 'x' }), + ev({ type: 'complete', stopReason: 'end_turn' }), + ], + gate: () => gate, + }); + // stop releases the gate so send() can advance to the terminal event. + const realStop = backend.stop.bind(backend); + backend.stop = async (reason) => { + await realStop(reason); + releaseGate(); + }; + + const flow = new AiSdkFlow({ backend }); + const ctrl = new AbortController(); + const runPromise = collect( + flow.run(ctx, { text: 'hi', context: [], abortSignal: ctrl.signal }), + ); + + // Let the generator yield the first event and park on the gate. + await new Promise((r) => setTimeout(r, 0)); + ctrl.abort(); + const out = await runPromise; + + assert.deepEqual(backend.stopCalls, ['user_stop']); + assert.equal(out.length, 2); + assert.equal(isTerminalRuntimeEvent(out[out.length - 1]), true); + }); +}); + +// ============================================================================ +// Pure mapping unit tests +// ============================================================================ + +describe('mapSessionEventToRuntimeEvent (pure)', () => { + test('mapCompleteStopReason covers all stop reasons', () => { + assert.equal(mapCompleteStopReason('end_turn'), 'completed'); + assert.equal(mapCompleteStopReason('max_tokens'), 'completed'); + assert.equal(mapCompleteStopReason('plan_handoff'), 'completed'); + assert.equal(mapCompleteStopReason('permission_handoff'), 'completed'); + assert.equal(mapCompleteStopReason('user_stop'), 'aborted'); + assert.equal(mapCompleteStopReason('error'), 'failed'); + }); + + test('tool_output_delta and tool_progress map to partial tool-role heartbeats', () => { + const mem = createSessionEventMapMemory(); + const a = mapSessionEventToRuntimeEvent( + ev({ type: 'tool_output_delta', sessionId: 'session-1', toolCallId: 'tu-1', toolUseId: 'tu-1', seq: 1, stream: 'stdout', chunk: 'c', redacted: false, createdAt: 1 }), + ctx, + mem, + ); + assert.equal(a.partial, true); + assert.equal(a.role, 'tool'); + assert.equal(a.author, 'tool'); + assert.equal(a.refs?.toolCallId, 'tu-1'); + + const b = mapSessionEventToRuntimeEvent( + ev({ type: 'tool_progress', toolUseId: 'tu-1', chunk: 'c' }), + ctx, + mem, + ); + assert.equal(b.partial, true); + assert.equal(b.role, 'tool'); + }); + + test('plan_submitted maps to an agent-authored state delta', () => { + const a = mapSessionEventToRuntimeEvent( + ev({ type: 'plan_submitted', planId: 'p1', title: 'T', markdownPath: '/p.md' }), + ctx, + ); + assert.equal(a.role, 'system'); + assert.equal(a.author, 'agent'); + assert.deepEqual(a.actions?.stateDelta, { planId: 'p1', title: 'T', markdownPath: '/p.md' }); + }); + + test('tool_result without a prior tool_start still maps (name falls back to empty)', () => { + const a = mapSessionEventToRuntimeEvent( + ev({ type: 'tool_result', toolUseId: 'orphan', isError: true, content: { kind: 'text', text: 'boom' } }), + ctx, + ); + const fnResp = a.content as { name: string; isError?: boolean }; + assert.equal(fnResp.name, ''); + assert.equal(fnResp.isError, true); + }); + + test('branch is propagated when present on the context', () => { + const a = mapSessionEventToRuntimeEvent( + ev({ type: 'complete', stopReason: 'end_turn' }), + { ...ctx, branch: 'agent-b' }, + ); + assert.equal(a.branch, 'agent-b'); + }); +}); diff --git a/packages/runtime/src/__tests__/runtime-event-adapters.test.ts b/packages/runtime/src/__tests__/runtime-event-adapters.test.ts new file mode 100644 index 0000000000..ae914a0c7c --- /dev/null +++ b/packages/runtime/src/__tests__/runtime-event-adapters.test.ts @@ -0,0 +1,754 @@ +/** + * Tests for runtime-event-adapters and model-history projection. + * + * Run: `npm --workspace @maka/runtime run test` + * + * Proves the policy from the work node body: + * - partial model chunks are not included in durable model history; + * - tool/function response events can be included when model-visible; + * - diagnostics/token/permission-only events are excluded; + * - legacy user/assistant/system stored messages convert safely. + */ + +import { describe, test } from 'node:test'; +import { expect } from '../test-helpers.js'; +import type { + UserMessage, + AssistantMessage, + SystemNoteMessage, + ToolCallMessage, + ToolResultMessage, + TokenUsageMessage, + PermissionDecisionMessage, + TurnStateMessage, + StoredMessage, +} from '@maka/core/session'; +import type { + RuntimeEvent, + RuntimeEventContent, +} from '@maka/core/runtime-event'; +import { + storedMessageToRuntimeEvent, + storedMessageToRuntimeEvents, + runtimeEventToStoredMessageDraft, +} from '../runtime-event-adapters.js'; +import { + buildModelHistoryFromRuntimeEvents, + type ModelHistoryEntry, +} from '../model-history.js'; + +// ---------- StoredMessage fixtures ---------- + +const ts = 1_700_000_000_000; +const turnId = 't1'; + +const user = (id: string, text: string): UserMessage => ({ + type: 'user', + id, + turnId, + ts: ts + 1, + text, +}); + +const assistant = ( + id: string, + text: string, + thinking?: { text: string; signature?: string }, +): AssistantMessage => ({ + type: 'assistant', + id, + turnId, + ts: ts + 2, + text, + modelId: 'claude-sonnet-4-5', + ...(thinking ? { thinking } : {}), +}); + +const note = (id: string, kind: SystemNoteMessage['kind']): SystemNoteMessage => ({ + type: 'system_note', + id, + ts: ts + 3, + kind, +}); + +const toolCall = (id: string, name: string, args: unknown = {}): ToolCallMessage => ({ + type: 'tool_call', + id, + turnId, + ts: ts + 4, + toolName: name, + args, +}); + +const toolResult = ( + toolUseId: string, + isError: boolean, + text: string, +): ToolResultMessage => ({ + type: 'tool_result', + id: `r-${toolUseId}`, + turnId, + ts: ts + 5, + toolUseId, + isError, + content: { kind: 'text', text }, +}); + +const tokens = (id: string): TokenUsageMessage => ({ + type: 'token_usage', + id, + turnId, + ts: ts + 6, + input: 10, + output: 5, +}); + +const permission = (id: string): PermissionDecisionMessage => ({ + type: 'permission_decision', + id, + turnId, + ts: ts + 7, + toolUseId: 'tu-1', + toolName: 'Write', + decision: 'allow', +}); + +const turnState = (id: string): TurnStateMessage => ({ + type: 'turn_state', + id, + turnId, + ts: ts + 8, + status: 'completed', + partialOutputRetained: false, +}); + +const ctx = { + sessionId: 'sess-1', + invocationId: 'inv-1', + runId: 'run-1', +}; + +// ---------- RuntimeEvent fixtures ---------- + +let __seq = 0; +function ev(overrides: Partial & { content?: RuntimeEventContent } = {}): RuntimeEvent { + __seq += 1; + return { + id: `evt-${__seq}`, + invocationId: 'inv-1', + runId: 'run-1', + sessionId: 'sess-1', + turnId: 'turn-1', + ts: ts + __seq, + partial: false, + role: 'user', + author: 'user', + ...overrides, + }; +} + +// ============================================================================ +// storedMessageToRuntimeEvent (singular) +// ============================================================================ + +describe('storedMessageToRuntimeEvent', () => { + test('user message → role user, text content, refs link', () => { + const e = storedMessageToRuntimeEvent(user('u1', 'hello'), ctx); + expect(e).not.toBeNull(); + if (!e) return; + expect(e.role).toBe('user'); + expect(e.author).toBe('user'); + expect(e.partial).toBe(false); + expect(e.content).toEqual({ kind: 'text', text: 'hello' }); + expect(e.refs?.storedMessageId).toBe('u1'); + expect(e.sessionId).toBe('sess-1'); + expect(e.turnId).toBe(turnId); + expect(e.ts).toBe(ts + 1); + }); + + test('assistant message (text only) → role model, text content; thinking dropped', () => { + const e = storedMessageToRuntimeEvent(assistant('a1', 'hi'), ctx); + if (!e) throw new Error('expected event'); + expect(e.role).toBe('model'); + expect(e.author).toBe('agent'); + expect(e.content).toEqual({ kind: 'text', text: 'hi' }); + }); + + test('system_note → role system, text content labels the note kind', () => { + const e = storedMessageToRuntimeEvent(note('n1', 'session_start'), ctx); + if (!e) throw new Error('expected event'); + expect(e.role).toBe('system'); + expect(e.author).toBe('system'); + expect(e.content).toEqual({ kind: 'text', text: 'system_note:session_start' }); + }); + + test('tool_call → null (needs runtime-runner-owned mapping)', () => { + expect(storedMessageToRuntimeEvent(toolCall('tc1', 'Read'), ctx)).toBeNull(); + }); + + test('tool_result → null', () => { + expect(storedMessageToRuntimeEvent(toolResult('tc1', false, 'data'), ctx)).toBeNull(); + }); + + test('token_usage → null', () => { + expect(storedMessageToRuntimeEvent(tokens('tu1'), ctx)).toBeNull(); + }); + + test('permission_decision → null', () => { + expect(storedMessageToRuntimeEvent(permission('pd1'), ctx)).toBeNull(); + }); + + test('turn_state → null', () => { + expect(storedMessageToRuntimeEvent(turnState('ts1'), ctx)).toBeNull(); + }); + + test('context ts override is honored', () => { + const e = storedMessageToRuntimeEvent(user('u', 'x'), { ...ctx, ts: 9999 }); + if (!e) throw new Error('expected event'); + expect(e.ts).toBe(9999); + }); + + test('context turnId override is honored (session-level note has no turnId)', () => { + const e = storedMessageToRuntimeEvent(note('n', 'session_start'), { + ...ctx, + turnId: 'override-turn', + }); + if (!e) throw new Error('expected event'); + expect(e.turnId).toBe('override-turn'); + }); + + test('session-level note without turnId defaults to empty string', () => { + const e = storedMessageToRuntimeEvent(note('n', 'session_resume'), ctx); + if (!e) throw new Error('expected event'); + expect(e.turnId).toBe(''); + }); + + test('custom newId is used for generated event ids', () => { + const e = storedMessageToRuntimeEvent(user('u', 'x'), { + ...ctx, + newId: () => 'fixed-id', + }); + if (!e) throw new Error('expected event'); + expect(e.id).toBe('fixed-id'); + }); +}); + +// ============================================================================ +// storedMessageToRuntimeEvents (plural — captures thinking) +// ============================================================================ + +describe('storedMessageToRuntimeEvents', () => { + test('assistant without thinking → single text event', () => { + const out = storedMessageToRuntimeEvents(assistant('a1', 'hi'), ctx); + expect(out).toHaveLength(1); + expect(out[0]?.content?.kind).toBe('text'); + }); + + test('assistant with thinking → [text event, thinking event]', () => { + const out = storedMessageToRuntimeEvents( + assistant('a2', 'answer', { text: 'reasoning', signature: 'sig-1' }), + ctx, + ); + expect(out).toHaveLength(2); + expect(out[0]?.content).toEqual({ kind: 'text', text: 'answer' }); + expect(out[1]?.content).toEqual({ + kind: 'thinking', + text: 'reasoning', + signature: 'sig-1', + }); + expect(out[1]?.role).toBe('model'); + expect(out[1]?.refs?.storedMessageId).toBe('a2'); + }); + + test('user message → single event (same as singular)', () => { + const out = storedMessageToRuntimeEvents(user('u', 'hello'), ctx); + expect(out).toHaveLength(1); + expect(out[0]?.content).toEqual({ kind: 'text', text: 'hello' }); + }); + + test('tool_call → empty array', () => { + expect(storedMessageToRuntimeEvents(toolCall('tc', 'Read'), ctx)).toEqual([]); + }); + + test('assistant with empty thinking text → single text event only', () => { + const out = storedMessageToRuntimeEvents( + assistant('a3', 'hi', { text: '' }), + ctx, + ); + expect(out).toHaveLength(1); + expect(out[0]?.content?.kind).toBe('text'); + }); +}); + +// ============================================================================ +// runtimeEventToStoredMessageDraft +// ============================================================================ + +describe('runtimeEventToStoredMessageDraft', () => { + test('user text event → UserMessage', () => { + const event = ev({ + role: 'user', + author: 'user', + content: { kind: 'text', text: 'hello' }, + refs: { storedMessageId: 'u1' }, + }); + const draft = runtimeEventToStoredMessageDraft(event); + expect(draft).not.toBeNull(); + if (!draft) return; + expect(draft.type).toBe('user'); + if (draft.type !== 'user') return; + expect(draft.id).toBe('u1'); + expect(draft.text).toBe('hello'); + expect(draft.turnId).toBe(event.turnId); + expect(draft.ts).toBe(event.ts); + }); + + test('model text event with modelId → AssistantMessage', () => { + const event = ev({ + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'answer' }, + refs: { storedMessageId: 'a1' }, + }); + const draft = runtimeEventToStoredMessageDraft(event, { modelId: 'gpt-4o' }); + expect(draft).not.toBeNull(); + if (!draft) return; + expect(draft.type).toBe('assistant'); + if (draft.type !== 'assistant') return; + expect(draft.id).toBe('a1'); + expect(draft.text).toBe('answer'); + expect(draft.modelId).toBe('gpt-4o'); + }); + + test('model text event without modelId → null (no safe legacy shape)', () => { + const event = ev({ + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'answer' }, + }); + expect(runtimeEventToStoredMessageDraft(event)).toBeNull(); + }); + + test('thinking event → null', () => { + const event = ev({ + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'hmm' }, + }); + expect(runtimeEventToStoredMessageDraft(event, { modelId: 'm' })).toBeNull(); + }); + + test('function_call event → null (tool projection owned elsewhere)', () => { + const event = ev({ + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'fc1', name: 'Read', args: {} }, + }); + expect(runtimeEventToStoredMessageDraft(event)).toBeNull(); + }); + + test('function_response event → null', () => { + const event = ev({ + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'fc1', + name: 'Read', + result: 'data', + isError: false, + }, + }); + expect(runtimeEventToStoredMessageDraft(event)).toBeNull(); + }); + + test('actions-only event (token usage) → null', () => { + const event = ev({ + role: 'system', + author: 'system', + actions: { + tokenUsage: { input: 10, output: 5 }, + }, + }); + expect(runtimeEventToStoredMessageDraft(event)).toBeNull(); + }); + + test('error-content event → null', () => { + const event = ev({ + role: 'model', + author: 'agent', + content: { kind: 'error', message: 'boom' }, + }); + expect(runtimeEventToStoredMessageDraft(event)).toBeNull(); + }); + + test('round-trip: user message → event → draft preserves text', () => { + const original = user('orig', 'round-trip text'); + const event = storedMessageToRuntimeEvent(original, ctx); + if (!event) throw new Error('expected event'); + const draft = runtimeEventToStoredMessageDraft(event); + if (!draft || draft.type !== 'user') throw new Error('expected user draft'); + expect(draft.text).toBe('round-trip text'); + expect(draft.id).toBe('orig'); + }); +}); + +// ============================================================================ +// buildModelHistoryFromRuntimeEvents — policy +// ============================================================================ + +describe('buildModelHistoryFromRuntimeEvents', () => { + test('empty input → empty history', () => { + expect(buildModelHistoryFromRuntimeEvents([])).toEqual([]); + }); + + test('user + final model text → two entries in order', () => { + const events: RuntimeEvent[] = [ + ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'q' } }), + ev({ role: 'model', author: 'agent', content: { kind: 'text', text: 'a' } }), + ]; + const out = buildModelHistoryFromRuntimeEvents(events); + expect(out).toHaveLength(2); + expect(out[0]?.role).toBe('user'); + expect(out[1]?.role).toBe('model'); + expect(out[0]?.content).toEqual({ kind: 'text', text: 'q' }); + }); + + test('POLICY: partial model chunks are excluded', () => { + const events: RuntimeEvent[] = [ + ev({ + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'streaming chunk...' }, + }), + ev({ + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'final answer' }, + }), + ]; + const out = buildModelHistoryFromRuntimeEvents(events); + expect(out).toHaveLength(1); + expect(out[0]?.content).toEqual({ kind: 'text', text: 'final answer' }); + }); + + test('POLICY: function_call + function_response included by default', () => { + const events: RuntimeEvent[] = [ + ev({ + role: 'user', + author: 'user', + content: { kind: 'text', text: 'read the file' }, + }), + ev({ + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'fc1', + name: 'Read', + args: { path: '/x' }, + }, + }), + ev({ + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'fc1', + name: 'Read', + result: 'file contents', + isError: false, + }, + }), + ev({ + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'done' }, + }), + ]; + const out = buildModelHistoryFromRuntimeEvents(events); + expect(out).toHaveLength(4); + expect(out.map((e) => e.role)).toEqual(['user', 'model', 'tool', 'model']); + expect(out[1]?.content?.kind).toBe('function_call'); + expect(out[2]?.content?.kind).toBe('function_response'); + }); + + test('POLICY: function_response with isError stays model-visible', () => { + const events: RuntimeEvent[] = [ + ev({ + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'fc1', + name: 'Write', + result: 'denied', + isError: true, + }, + }), + ]; + const out = buildModelHistoryFromRuntimeEvents(events); + expect(out).toHaveLength(1); + expect((out[0]?.content as { isError?: boolean }).isError).toBe(true); + }); + + test('POLICY: tool events excluded when includeToolEvents=false (text-only replay)', () => { + const events: RuntimeEvent[] = [ + ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'q' } }), + ev({ + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'fc1', name: 'Read', args: {} }, + }), + ev({ + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'fc1', + name: 'Read', + result: 'data', + }, + }), + ev({ role: 'model', author: 'agent', content: { kind: 'text', text: 'a' } }), + ]; + const out = buildModelHistoryFromRuntimeEvents(events, { + includeToolEvents: false, + }); + expect(out).toHaveLength(2); + expect(out.map((e) => e.role)).toEqual(['user', 'model']); + }); + + test('POLICY: token-usage (actions-only) event excluded', () => { + const events: RuntimeEvent[] = [ + ev({ + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'a' }, + actions: { tokenUsage: { input: 100, output: 50 } }, + }), + ev({ + role: 'system', + author: 'system', + actions: { tokenUsage: { input: 0, output: 0 } }, + }), + ]; + const out = buildModelHistoryFromRuntimeEvents(events); + expect(out).toHaveLength(1); + expect(out[0]?.content?.kind).toBe('text'); + }); + + test('POLICY: permission ack (actions-only) event excluded', () => { + const events: RuntimeEvent[] = [ + ev({ + role: 'system', + author: 'system', + actions: { + permissionDecision: { + requestId: 'req-1', + decision: 'allow', + }, + }, + }), + ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'q' } }), + ]; + const out = buildModelHistoryFromRuntimeEvents(events); + expect(out).toHaveLength(1); + expect(out[0]?.role).toBe('user'); + }); + + test('POLICY: error-only content event excluded', () => { + const events: RuntimeEvent[] = [ + ev({ + role: 'model', + author: 'agent', + content: { kind: 'error', message: 'something broke' }, + }), + ev({ role: 'model', author: 'agent', content: { kind: 'text', text: 'a' } }), + ]; + const out = buildModelHistoryFromRuntimeEvents(events); + expect(out).toHaveLength(1); + expect(out[0]?.content?.kind).toBe('text'); + }); + + test('POLICY: system-role (UI note) event excluded by default', () => { + const events: RuntimeEvent[] = [ + ev({ + role: 'system', + author: 'system', + content: { kind: 'text', text: 'system_note:session_start' }, + }), + ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'q' } }), + ]; + const out = buildModelHistoryFromRuntimeEvents(events); + expect(out).toHaveLength(1); + expect(out[0]?.role).toBe('user'); + }); + + test('POLICY: system-role event included when includeSystemEvents=true', () => { + const events: RuntimeEvent[] = [ + ev({ + role: 'system', + author: 'system', + content: { kind: 'text', text: 'You are a helpful assistant.' }, + }), + ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'q' } }), + ]; + const out = buildModelHistoryFromRuntimeEvents(events, { + includeSystemEvents: true, + }); + expect(out).toHaveLength(2); + expect(out[0]?.role).toBe('system'); + }); + + test('POLICY: thinking excluded by default, included when includeThinking=true', () => { + const events: RuntimeEvent[] = [ + ev({ + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'reasoning', signature: 's' }, + }), + ev({ role: 'model', author: 'agent', content: { kind: 'text', text: 'a' } }), + ]; + expect(buildModelHistoryFromRuntimeEvents(events)).toHaveLength(1); + const out = buildModelHistoryFromRuntimeEvents(events, { + includeThinking: true, + }); + expect(out).toHaveLength(2); + expect(out[0]?.content?.kind).toBe('thinking'); + }); + + test('endInvocation terminal marker with no content → excluded', () => { + const events: RuntimeEvent[] = [ + ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'q' } }), + ev({ + role: 'model', + author: 'agent', + status: 'completed', + actions: { endInvocation: true }, + }), + ]; + const out = buildModelHistoryFromRuntimeEvents(events); + expect(out).toHaveLength(1); + expect(out[0]?.role).toBe('user'); + }); + + test('entries preserve event order and carry eventId + ts', () => { + const events: RuntimeEvent[] = [ + ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'a' } }), + ev({ role: 'model', author: 'agent', content: { kind: 'text', text: 'b' } }), + ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'c' } }), + ]; + const out = buildModelHistoryFromRuntimeEvents(events); + expect(out.map((e) => e.eventId)).toEqual(events.map((e) => e.id)); + expect(out.map((e) => e.ts)).toEqual(events.map((e) => e.ts)); + }); + + test('full durable-history-shaped stream: partials + finals + diagnostics', () => { + // Mirrors a realistic turn: streaming chunks (partial), final assistant + // text, tool call/response, token usage, system note, terminal marker. + const events: RuntimeEvent[] = [ + ev({ + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'Let me ' }, + }), + ev({ + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'Let me check' }, + }), + ev({ + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'fc1', + name: 'Read', + args: { path: '/a' }, + }, + }), + ev({ + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'fc1', + name: 'Read', + result: 'contents', + }, + }), + ev({ + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'Here is the file.' }, + }), + ev({ + role: 'system', + author: 'system', + actions: { tokenUsage: { input: 10, output: 5 } }, + }), + ev({ + role: 'system', + author: 'system', + content: { kind: 'text', text: 'system_note:mode_change' }, + }), + ev({ + role: 'model', + author: 'agent', + status: 'completed', + actions: { endInvocation: true }, + }), + ]; + const out = buildModelHistoryFromRuntimeEvents(events); + // Only: function_call, function_response, final text. + expect(out.map((e) => e.content?.kind)).toEqual([ + 'function_call', + 'function_response', + 'text', + ]); + }); +}); + +// ============================================================================ +// Adapter + projection integration +// ============================================================================ + +describe('adapter → projection integration', () => { + test('legacy messages convert to events then project to clean history', () => { + const messages: StoredMessage[] = [ + user('u1', 'what is 2+2?'), + assistant('a1', 'it is 4'), + tokens('tu1'), + note('n1', 'mode_change'), + ]; + const events: RuntimeEvent[] = []; + for (const m of messages) { + events.push(...storedMessageToRuntimeEvents(m, ctx)); + } + // Only user + assistant text survive projection (system note excluded, + // token_usage never produced an event). + const history = buildModelHistoryFromRuntimeEvents(events); + expect(history).toHaveLength(2); + expect(history[0]?.role).toBe('user'); + expect(history[1]?.role).toBe('model'); + expect((history[0]?.content as { text: string }).text).toBe('what is 2+2?'); + }); + + test('ModelHistoryEntry type carries the discriminated content union', () => { + const entry: ModelHistoryEntry = { + role: 'model', + content: { kind: 'function_call', id: 'fc1', name: 'Read', args: {} }, + ts: 1, + eventId: 'e1', + }; + if (entry.content.kind === 'function_call') { + expect(entry.content.name).toBe('Read'); + } else { + throw new Error('discriminator failed'); + } + }); +}); diff --git a/packages/runtime/src/__tests__/runtime-runner.test.ts b/packages/runtime/src/__tests__/runtime-runner.test.ts new file mode 100644 index 0000000000..91e4cefbd5 --- /dev/null +++ b/packages/runtime/src/__tests__/runtime-runner.test.ts @@ -0,0 +1,315 @@ +import { describe, test } from 'node:test'; +import { expect } from '../test-helpers.js'; +import { + RuntimeRunner, + runtimeGateFromCallback, + type AgentFlowLike, + type RuntimeGate, +} from '../runtime-runner.js'; +import type { + InvocationContext, + InvocationProviders, + InvocationRequest, +} from '../invocation-context.js'; +import type { + RuntimeEvent, + RuntimeEventStatus, +} from '@maka/core/runtime-event'; + +// ============================================================================ +// Test fakes / helpers +// ============================================================================ + +/** Deterministic providers so event ids and timestamps are predictable. */ +function makeProviders(): InvocationProviders & { count: () => number } { + let n = 0; + return { + newId: () => `id-${(n += 1)}`, + now: () => 1000 + n, + count: () => n, + }; +} + +function makeRequest(overrides: Partial = {}): InvocationRequest { + return { + sessionId: 'sess-1', + turnId: 'turn-1', + text: 'hi', + source: 'test', + ...overrides, + }; +} + +/** + * Fake flow that runs a script to produce its events. The script receives + * the InvocationContext so events can line up with the invocation spine. + */ +class ScriptFlow implements AgentFlowLike { + readonly seen: InvocationContext[] = []; + constructor( + private readonly script: (ctx: InvocationContext) => + RuntimeEvent[] | Promise, + ) {} + + async *run(ctx: InvocationContext): AsyncIterable { + this.seen.push(ctx); + for (const ev of await this.script(ctx)) { + yield ev; + } + } +} + +/** Flow that throws on first iteration. */ +class ThrowingFlow implements AgentFlowLike { + ran = false; + constructor(private readonly error: unknown) {} + async *run(): AsyncIterable { + this.ran = true; + throw this.error; + } +} + +function flowTextEvent(ctx: InvocationContext, text: string): RuntimeEvent { + return { + id: ctx.newId(), + invocationId: ctx.invocationId, + runId: ctx.runId, + sessionId: ctx.sessionId, + turnId: ctx.turnId, + ts: ctx.now(), + ...(ctx.branch ? { branch: ctx.branch } : {}), + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'text', text }, + }; +} + +function flowTerminalEvent( + ctx: InvocationContext, + status: RuntimeEventStatus, +): RuntimeEvent { + return { + id: ctx.newId(), + invocationId: ctx.invocationId, + runId: ctx.runId, + sessionId: ctx.sessionId, + turnId: ctx.turnId, + ts: ctx.now(), + ...(ctx.branch ? { branch: ctx.branch } : {}), + partial: false, + role: 'model', + author: 'agent', + status, + actions: { endInvocation: true }, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('RuntimeRunner', () => { + test('preflight failure returns no flow events and does not call the flow', async () => { + const providers = makeProviders(); + const flow = new ScriptFlow(() => [flowTextEvent({} as never, 'should-not-happen')]); + const gate: RuntimeGate = { + preflight: async () => ({ ok: false, reason: 'session_blocked' }), + }; + const runner = new RuntimeRunner({ flow, gate, providers }); + + const result = await runner.run(makeRequest()); + + expect(result.status).toBe('failed'); + expect(result.events).toEqual([]); + expect(flow.seen).toEqual([]); + expect(result.failure?.class).toBe('preflight'); + expect(result.failure?.message).toBe('session_blocked'); + expect(result.startedAt <= result.finishedAt).toBe(true); + }); + + test('initial user RuntimeEvent is emitted before any flow event', async () => { + const providers = makeProviders(); + const flow = new ScriptFlow((ctx) => [flowTextEvent(ctx, 'hello')]); + const runner = new RuntimeRunner({ flow, providers }); + + const result = await runner.run(makeRequest({ text: 'ping' })); + + expect(result.status).toBe('completed'); + expect(result.events).toHaveLength(2); + + const userEvent = result.events[0]!; + expect(userEvent.role).toBe('user'); + expect(userEvent.author).toBe('user'); + expect(userEvent.partial).toBe(false); + expect(userEvent.content).toEqual({ kind: 'text', text: 'ping' }); + expect(userEvent.sessionId).toBe('sess-1'); + expect(userEvent.turnId).toBe('turn-1'); + + // The flow event follows the user event and is on a different lane. + expect(result.events[1]!.role).toBe('model'); + expect(result.events[1]!.author).toBe('agent'); + }); + + test('a terminal event ends the result and stops collecting flow events', async () => { + const providers = makeProviders(); + const flow = new ScriptFlow((ctx) => [ + flowTextEvent(ctx, 'partial'), + flowTerminalEvent(ctx, 'completed'), + // These should never be collected once the terminal event is seen. + flowTextEvent(ctx, 'after-terminal-1'), + flowTerminalEvent(ctx, 'failed'), + ]); + const runner = new RuntimeRunner({ flow, providers }); + + const result = await runner.run(makeRequest()); + + expect(result.status).toBe('completed'); + // user + partial text + terminal = 3; nothing after the terminal event. + expect(result.events).toHaveLength(3); + const terminal = result.events.at(-1)!; + expect(terminal.status).toBe('completed'); + expect(terminal.actions?.endInvocation).toBe(true); + expect( + result.events.some( + (ev) => ev.content?.kind === 'text' && ev.content.text === 'after-terminal-1', + ), + ).toBe(false); + }); + + test('a flow that throws maps to a failed result (user event retained)', async () => { + const providers = makeProviders(); + const flow = new ThrowingFlow(new Error('boom')); + const runner = new RuntimeRunner({ flow, providers }); + + const result = await runner.run(makeRequest()); + + expect(result.status).toBe('failed'); + expect(result.failure?.class).toBe('Error'); + expect(result.failure?.message).toBe('boom'); + expect(flow.ran).toBe(true); + // The user event was collected before the flow threw. + expect(result.events).toHaveLength(1); + expect(result.events[0]!.author).toBe('user'); + }); + + test('a flow emitting an aborted terminal event maps to a failed result', async () => { + const providers = makeProviders(); + const flow = new ScriptFlow((ctx) => [flowTerminalEvent(ctx, 'aborted')]); + const runner = new RuntimeRunner({ flow, providers }); + + const result = await runner.run(makeRequest()); + + expect(result.status).toBe('failed'); + expect(result.failure?.class).toBe('aborted'); + expect(result.failure?.terminalStatus).toBe('aborted'); + }); + + test('a flow emitting a failed terminal event surfaces error content as failure message', async () => { + const providers = makeProviders(); + const flow = new ScriptFlow((ctx) => [ + { + ...flowTerminalEvent(ctx, 'failed'), + content: { kind: 'error', message: 'provider 500' }, + }, + ]); + const runner = new RuntimeRunner({ flow, providers }); + + const result = await runner.run(makeRequest()); + + expect(result.status).toBe('failed'); + expect(result.failure?.class).toBe('failed'); + expect(result.failure?.message).toBe('provider 500'); + expect(result.failure?.terminalStatus).toBe('failed'); + }); + + test('omitting the gate means preflight always passes', async () => { + const providers = makeProviders(); + const flow = new ScriptFlow((ctx) => [ + flowTextEvent(ctx, 'ok'), + flowTerminalEvent(ctx, 'completed'), + ]); + const runner = new RuntimeRunner({ flow, providers }); + + const result = await runner.run(makeRequest()); + + expect(result.status).toBe('completed'); + expect(result.events).toHaveLength(3); + }); + + test('runtimeGateFromCallback adapts a sync callback', async () => { + const providers = makeProviders(); + let flowCalled = false; + const flow: AgentFlowLike = { + async *run(): AsyncIterable { + flowCalled = true; + }, + }; + const gate = runtimeGateFromCallback(() => ({ ok: false, reason: 'nope' })); + const runner = new RuntimeRunner({ flow, gate, providers }); + + const result = await runner.run(makeRequest()); + + expect(result.status).toBe('failed'); + expect(result.failure?.class).toBe('preflight'); + expect(flowCalled).toBe(false); + }); + + test('already-aborted signal before dispatch yields a failed result without flow dispatch', async () => { + const providers = makeProviders(); + const ac = new AbortController(); + ac.abort(); + const flow = new ScriptFlow((ctx) => [flowTextEvent(ctx, 'nope')]); + const runner = new RuntimeRunner({ flow, providers }); + + const result = await runner.run(makeRequest({ abortSignal: ac.signal })); + + expect(result.status).toBe('failed'); + expect(result.failure?.class).toBe('aborted'); + expect(result.events).toEqual([]); + expect(flow.seen).toEqual([]); + }); + + test('emitted events carry the invocation identity hierarchy', async () => { + const providers = makeProviders(); + const flow = new ScriptFlow((ctx) => [ + flowTextEvent(ctx, 'a'), + flowTerminalEvent(ctx, 'completed'), + ]); + const runner = new RuntimeRunner({ flow, providers }); + + const result = await runner.run( + makeRequest({ sessionId: 'sess-7', turnId: 'turn-7', branch: 'b1' }), + ); + + expect(result.invocationId).toBeDefined(); + expect(result.runId).toBeDefined(); + expect(result.invocationId !== result.runId).toBe(true); + for (const ev of result.events) { + expect(ev.invocationId).toBe(result.invocationId); + expect(ev.runId).toBe(result.runId); + expect(ev.sessionId).toBe('sess-7'); + expect(ev.turnId).toBe('turn-7'); + expect(ev.branch).toBe('b1'); + } + }); + + test('flow receives a context wired to the injected providers and request', async () => { + const providers = makeProviders(); + const flow = new ScriptFlow((ctx) => [flowTextEvent(ctx, 'x')]); + const runner = new RuntimeRunner({ flow, providers }); + + await runner.run(makeRequest({ source: 'gateway', text: 'hello' })); + + expect(flow.seen).toHaveLength(1); + const ctx = flow.seen[0]!; + expect(ctx.source).toBe('gateway'); + expect(ctx.request.text).toBe('hello'); + expect(ctx.sessionId).toBe('sess-1'); + expect(ctx.turnId).toBe('turn-1'); + expect(typeof ctx.newId()).toBe('string'); + expect(typeof ctx.now()).toBe('number'); + // Providers are shared, so a fresh id from ctx is unique against runId. + expect(ctx.newId() !== ctx.runId).toBe(true); + }); +}); diff --git a/packages/runtime/src/agent-flow.ts b/packages/runtime/src/agent-flow.ts new file mode 100644 index 0000000000..329d8a6ec2 --- /dev/null +++ b/packages/runtime/src/agent-flow.ts @@ -0,0 +1,161 @@ +/** + * AgentFlow — the runtime v2 model/tool loop seam. + * + * Source: docs/runtime-v2-architecture-evolution.md §Target Architecture, + * §Proposed Module Shape (flows/agent-flow.ts), and Phase 4 deliverables. + * + * Layering (from the architecture doc): + * + * RuntimeRunner + * -> InvocationContext + * -> AgentFlow ← this module defines the interface + * -> AiSdkFlow ← default long-term implementation (./ai-sdk-flow.ts) + * -> AI SDK streamText + * -> ToolRuntime + * -> RuntimeEvent ledger + * -> projections + * + * A Flow owns the model/tool loop for one invocation. It consumes an + * InvocationContext + a FlowInput (the user turn) and emits the canonical + * `RuntimeEvent` stream. The current stepping engine lives inside + * `AiSdkBackend.send()`; `AiSdkFlow` wraps that backend and normalizes its + * renderer-facing `SessionEvent` stream into canonical `RuntimeEvent`s + * without rewriting the stepping logic. That keeps the AI SDK as Maka's + * first-class long-term flow engine (Phase 4 design intent). + * + * Phase 4 scope (this node): interface + adapter/wrapper + mapping helpers + * + tests. It does NOT migrate SessionManager onto the runner, does NOT + * rewrite `AiSdkBackend.send()`, and does NOT change current renderer + * behavior. The seam exists so future work can move from + * `SessionManager -> AgentRun -> AiSdkBackend` to + * `RuntimeRunner -> AiSdkFlow` without a flag day. + */ + +import type { AttachmentRef } from '@maka/core/events'; +import type { StoredMessage } from '@maka/core/session'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; + +// ============================================================================ +// InvocationContext — identity + runtime services handed to a Flow +// ============================================================================ + +/** + * Durable invocation spine identity plus the runtime services a Flow needs. + * + * Identity hierarchy mirrors `RuntimeEvent`: + * `sessionId` ⊃ `invocationId` ⊃ `runId` ⊃ `turnId`. + * + * `invocationId` is the durable spine id; `runId` names the specific + * execution attempt; `turnId` groups all events from one user turn (and + * matches `StoredMessage.turnId` / `BackendSendInput.turnId`). + * + * Today `SessionManager -> AgentRun -> AiSdkBackend` carries only + * `sessionId` + `turnId`; `invocationId`/`runId` are introduced here as the + * forward-compatible spine. Adapters that still talk to the legacy backend + * path pass `invocationId`/`runId` through for projection linkage even + * though the backend itself does not consume them yet. + */ +export interface InvocationContext { + sessionId: string; + invocationId: string; + runId: string; + turnId: string; + /** Optional branch/agent lane for future multi-agent trees. */ + branch?: string; + /** id generator; default `crypto.randomUUID()`. */ + newId?: () => string; + /** Clock; default `Date.now()`. */ + now?: () => number; +} + +// ============================================================================ +// FlowInput — the user turn handed to a Flow +// ============================================================================ + +/** + * The user turn input a Flow runs on. This is the flow-level analogue of + * `BackendSendInput`, expressed without binding to a specific backend. + * + * `context` is the prior conversation history (`StoredMessage[]`) the flow + * projects into model history. Today the AI SDK path forwards this straight + * to `AiSdkBackend.send()`; in the target architecture it flows through a + * `ModelHistoryProjector` (Phase 7), but that projection is owned upstream + * of the flow, not inside it. + */ +export interface FlowInput { + /** User turn text. */ + text: string; + /** Optional attachments bound to the user message. */ + attachments?: AttachmentRef[]; + /** + * Prior conversation history for model-history projection. The flow does + * not own the inclusion policy; it receives whatever the runner/gate + * resolved. + */ + context: StoredMessage[]; + /** Abort signal propagated to the underlying engine. */ + abortSignal?: AbortSignal; +} + +// ============================================================================ +// AgentFlow — the model/tool loop seam +// ============================================================================ + +/** + * Owns the model/tool loop for one invocation. + * + * `run()` returns an async iterable of canonical `RuntimeEvent`s. The flow + * is responsible for: + * - building provider messages from the input/history, + * - driving the model/tool stepping engine, + * - delegating tool execution to `ToolRuntime`, + * - mapping every model/tool/permission/usage/error/finish fact to a + * `RuntimeEvent`. + * + * A flow MUST emit exactly one terminal event (`isTerminalRuntimeEvent`) + * per invocation, whether the turn completed, errored, aborted, or was + * cancelled. Non-terminal partial chunks carry `partial: true`. + * + * Control surface (`stop` / `respondToPermission` / `dispose`) is optional + * on the interface because not every flow implementation owns a steppable + * engine. `AiSdkFlow` exposes these and delegates them to the wrapped + * backend so the current control semantics are preserved. + */ +export interface AgentFlow { + /** Stable label for telemetry/diagnostics, e.g. `'ai-sdk'`. */ + readonly kind: string; + /** Session this flow is bound to. */ + readonly sessionId: string; + /** Run the model/tool loop, emitting canonical runtime facts. */ + run(ctx: InvocationContext, input: FlowInput): AsyncIterable; +} + +// ============================================================================ +// AgentFlowControl — optional lifecycle/steering surface +// ============================================================================ + +/** + * Optional steering surface for flows that wrap a steppable engine. Mirrors + * the existing `AgentBackend` control methods so callers (SessionManager + * today, RuntimeRunner tomorrow) can stop a turn, answer a permission + * prompt, or tear the flow down without depending on a concrete class. + * + * `AiSdkFlow` implements this; pure/projection-only flows may omit it. + */ +export interface AgentFlowControl { + stop(reason: 'user_stop' | 'redirect'): Promise; + respondToPermission(decision: import('@maka/core/backend-types').PermissionDecision): Promise; + dispose(): Promise; +} + +/** + * Type guard for the optional control surface. Callers that have an + * `AgentFlow` and need steering can narrow with this helper. + */ +export function flowSupportsControl(flow: AgentFlow): flow is AgentFlow & AgentFlowControl { + return ( + typeof (flow as AgentFlow & Partial).stop === 'function' && + typeof (flow as AgentFlow & Partial).respondToPermission === 'function' && + typeof (flow as AgentFlow & Partial).dispose === 'function' + ); +} diff --git a/packages/runtime/src/ai-sdk-flow.ts b/packages/runtime/src/ai-sdk-flow.ts new file mode 100644 index 0000000000..a8f8b547a7 --- /dev/null +++ b/packages/runtime/src/ai-sdk-flow.ts @@ -0,0 +1,458 @@ +/** + * AiSdkFlow — the default long-term AgentFlow implementation. + * + * Source: docs/runtime-v2-architecture-evolution.md §Target Architecture + * and §Migration Plan › Phase 4 (AiSdkFlow Formalization). + * + * Design intent (preserved by this node): + * - The AI SDK remains Maka's first-class long-term flow engine. This + * flow is the formal seam around the existing stepping engine, NOT a + * replacement for it. + * - The current model/tool loop lives inside `AiSdkBackend.send()`. This + * flow does NOT reimplement streaming. It wraps an `AgentBackend` (the + * production instance is `AiSdkBackend`) and normalizes its + * renderer-facing `SessionEvent` stream into canonical `RuntimeEvent`s. + * - This keeps current SessionManager behavior stable while giving future + * work a single target: `RuntimeRunner -> AiSdkFlow` instead of + * `SessionManager -> AgentRun -> AiSdkBackend`. + * + * What this adapter owns: + * - `run(ctx, input)`: drive the wrapped backend and emit `RuntimeEvent`s. + * - `mapSessionEventToRuntimeEvent`: a documented, testable placeholder + * mapping from the existing `SessionEvent` union onto `RuntimeEvent`. + * - control surface (`stop` / `respondToPermission` / `dispose`): delegate + * to the wrapped backend so current control semantics are preserved. + * + * What this adapter deliberately does NOT do: + * - rewrite or fork `AiSdkBackend.send()`; + * - coalesce the backend's `abort` + trailing `complete` into one event + * (the adapter is faithful to the source stream; coalescing is a + * runner/projection concern); + * - own model-history projection (Phase 7) or tool-event actions (Phase 5). + */ + +import type { CompleteEvent, SessionEvent } from '@maka/core/events'; +import type { PermissionDecision } from '@maka/core/backend-types'; +import type { RuntimeEvent, RuntimeEventStatus } from '@maka/core/runtime-event'; + +import type { AgentBackend } from './ai-sdk-backend.js'; +import { + type AgentFlow, + type AgentFlowControl, + type FlowInput, + type InvocationContext, +} from './agent-flow.js'; + +// ============================================================================ +// SessionEvent → RuntimeEvent mapping (placeholder, Phase 4) +// ============================================================================ + +/** The `CompleteEvent.stopReason` literal union, re-declared for portability. */ +export type CompleteStopReason = CompleteEvent['stopReason']; + +/** + * Map a `CompleteEvent.stopReason` onto a terminal `RuntimeEventStatus`. + * + * `end_turn` / `max_tokens` / `*_handoff` all represent the streaming phase + * ending normally (control may be handed off, but the run is not a failure), + * so they map to `completed`. `user_stop` maps to `aborted`; `error` to + * `failed`. Phase 5+ may introduce a richer `waiting`/`handoff` status. + */ +export function mapCompleteStopReason(reason: CompleteStopReason): RuntimeEventStatus { + switch (reason) { + case 'user_stop': + return 'aborted'; + case 'error': + return 'failed'; + case 'end_turn': + case 'max_tokens': + case 'plan_handoff': + case 'permission_handoff': + return 'completed'; + default: + return 'completed'; + } +} + +/** + * Shared, mutable tool-name lookup accumulated as the stream flows. The AI + * SDK backend emits `ToolStartEvent` (which carries `toolName`) before the + * matching `ToolResultEvent` (which does not). Remembering the name keeps + * `function_response` content populated without a second source of truth. + */ +export interface SessionEventMapMemory { + toolNameByUseId: Map; +} + +export function createSessionEventMapMemory(): SessionEventMapMemory { + return { toolNameByUseId: new Map() }; +} + +/** + * Resolve the runtime identity shared by every event of an invocation. + * Reuses the source `SessionEvent.id` as the canonical event id so the + * adapter keeps 1:1 dedup linkage with the backend stream. + */ +function resolveBase(event: SessionEvent, ctx: InvocationContext) { + const now = ctx.now ?? (() => Date.now()); + const base = { + id: event.id, + invocationId: ctx.invocationId, + runId: ctx.runId, + sessionId: ctx.sessionId, + turnId: ctx.turnId, + ts: typeof event.ts === 'number' ? event.ts : now(), + partial: false, + }; + if (ctx.branch !== undefined) (base as { branch?: string }).branch = ctx.branch; + return base; +} + +/** + * Map one renderer-facing `SessionEvent` onto a canonical `RuntimeEvent`. + * + * This is the Phase 4 placeholder mapping documented in the architecture + * doc. It is deterministic given `(event, ctx, memory)` and carries no I/O. + * Role/author choices: + * + * - model text/thinking → role 'model', author 'agent' + * - tool_start (function call) → role 'model', author 'agent' + * - tool progress/output deltas → role 'tool', author 'tool' (partial) + * - tool_result (function resp) → role 'tool', author 'tool' + * - permission_request → role 'system', author 'system' + * - permission_decision_ack → role 'system', author 'user' + * - plan_submitted → role 'system', author 'agent' + * - token_usage → role 'system', author 'system' + * - error → role 'system', author 'system' + * - abort → role 'system', author 'system' (terminal) + * - complete → role 'system', author 'system' (terminal) + * + * `memory` is mutated for `tool_start` (records `toolName`) and read for + * `tool_result`. Callers SHOULD pass one memory instance per invocation so + * the `toolUseId → toolName` linkage is consistent across the stream. + */ +export function mapSessionEventToRuntimeEvent( + event: SessionEvent, + ctx: InvocationContext, + memory: SessionEventMapMemory = createSessionEventMapMemory(), +): RuntimeEvent { + const base = resolveBase(event, ctx); + + switch (event.type) { + // ── Model text ──────────────────────────────────────────────────────── + case 'text_delta': + return { + ...base, + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text: event.text }, + refs: { providerEventId: event.messageId }, + }; + case 'text_complete': + return { + ...base, + role: 'model', + author: 'agent', + content: { kind: 'text', text: event.text }, + refs: { providerEventId: event.messageId }, + }; + + // ── Model thinking ──────────────────────────────────────────────────── + case 'thinking_delta': + return { + ...base, + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: event.text }, + refs: { providerEventId: event.messageId }, + }; + case 'thinking_complete': + return { + ...base, + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: event.text, + ...(event.signature !== undefined ? { signature: event.signature } : {}), + }, + refs: { providerEventId: event.messageId }, + }; + + // ── Tool calls / results ────────────────────────────────────────────── + case 'tool_start': { + memory.toolNameByUseId.set(event.toolUseId, event.toolName); + const ev: RuntimeEvent = { + ...base, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: event.toolUseId, + name: event.toolName, + args: event.args, + }, + refs: { toolCallId: event.toolUseId }, + }; + if (event.displayName !== undefined || event.intent !== undefined) { + const stateDelta: Record = {}; + if (event.displayName !== undefined) stateDelta.displayName = event.displayName; + if (event.intent !== undefined) stateDelta.intent = event.intent; + ev.actions = { stateDelta }; + } + return ev; + } + case 'tool_output_delta': + // Transient tool stdout/stderr side-channel. Carried as a partial + // tool-role heartbeat; the canonical tool result is the function_response + // below. Phase 5 may promote this to a dedicated tool-progress action. + return { + ...base, + partial: true, + role: 'tool', + author: 'tool', + refs: { toolCallId: event.toolUseId }, + }; + case 'tool_progress': + return { + ...base, + partial: true, + role: 'tool', + author: 'tool', + refs: { toolCallId: event.toolUseId }, + }; + case 'tool_result': { + const name = memory.toolNameByUseId.get(event.toolUseId) ?? ''; + const ev: RuntimeEvent = { + ...base, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: event.toolUseId, + name, + result: event.content, + ...(event.isError ? { isError: true } : {}), + }, + refs: { toolCallId: event.toolUseId }, + }; + if (event.durationMs !== undefined) { + ev.actions = { stateDelta: { durationMs: event.durationMs } }; + } + return ev; + } + + // ── Permission (first-class runtime action, not just a UI echo) ─────── + case 'permission_request': + return { + ...base, + role: 'system', + author: 'system', + actions: { + permissionRequest: { + requestId: event.requestId, + toolUseId: event.toolUseId, + toolName: event.toolName, + category: event.category, + reason: event.reason, + args: event.args, + ...(event.hint !== undefined ? { hint: event.hint } : {}), + }, + }, + refs: { toolCallId: event.toolUseId }, + }; + case 'permission_decision_ack': + return { + ...base, + role: 'system', + author: 'user', + actions: { + permissionDecision: { + requestId: event.requestId, + decision: event.decision, + ...(event.rememberForTurn !== undefined ? { rememberForTurn: event.rememberForTurn } : {}), + }, + }, + refs: { toolCallId: event.toolUseId }, + }; + + // ── Plan handoff (placeholder; Phase 5/7 refines) ───────────────────── + case 'plan_submitted': + return { + ...base, + role: 'system', + author: 'agent', + actions: { + stateDelta: { + planId: event.planId, + title: event.title, + markdownPath: event.markdownPath, + }, + }, + }; + + // ── Token usage ─────────────────────────────────────────────────────── + case 'token_usage': + return { + ...base, + role: 'system', + author: 'system', + actions: { + tokenUsage: { + input: event.input, + output: event.output, + ...(event.cacheRead !== undefined ? { cacheRead: event.cacheRead } : {}), + ...(event.cacheCreation !== undefined ? { cacheCreation: event.cacheCreation } : {}), + ...(event.costUsd !== undefined ? { costUsd: event.costUsd } : {}), + ...(event.contextRemaining !== undefined + ? { contextRemaining: event.contextRemaining } + : {}), + }, + }, + }; + + // ── Error ───────────────────────────────────────────────────────────── + case 'error': + // No status here: the backend follows with a terminal `complete(error)`. + // Keeping status off the error event avoids a double-terminal in the + // error path; the trailing complete carries the terminal signal. + return { + ...base, + role: 'system', + author: 'system', + content: { + kind: 'error', + ...(event.code !== undefined ? { code: event.code } : {}), + ...(event.reason !== undefined ? { reason: event.reason } : {}), + message: event.message, + ...(event.details !== undefined ? { details: event.details } : {}), + }, + }; + + // ── Terminal: abort + complete ──────────────────────────────────────── + case 'abort': + return { + ...base, + role: 'system', + author: 'system', + status: 'aborted', + actions: { endInvocation: true }, + }; + case 'complete': + return { + ...base, + role: 'system', + author: 'system', + status: mapCompleteStopReason(event.stopReason), + actions: { endInvocation: true }, + }; + + default: { + // Exhaustiveness guard: if SessionEvent grows a new variant, the + // mapping falls through to a diagnostic event instead of dropping it. + const _exhaustive: never = event; + void _exhaustive; + return { + ...base, + role: 'system', + author: 'system', + actions: { + stateDelta: { unmappedSessionEventType: (event as { type?: string }).type ?? 'unknown' }, + }, + }; + } + } +} + +// ============================================================================ +// AiSdkFlow — AgentFlow over a wrapped AgentBackend +// ============================================================================ + +export interface AiSdkFlowInput { + /** The wrapped stepping engine. Production: AiSdkBackend. Tests: any AgentBackend. */ + backend: AgentBackend; +} + +/** + * Default long-term `AgentFlow` implementation. + * + * Wraps an existing `AgentBackend` (the production instance is + * `AiSdkBackend`) and exposes the canonical `AgentFlow.run()` seam. The + * adapter delegates all stepping to the backend's `send()` and only + * translates `SessionEvent → RuntimeEvent`, so it cannot destabilize the + * current `SessionManager` path: nothing changes until a caller opts into + * `AiSdkFlow.run()`. + * + * Control surface delegates 1:1 to the wrapped backend, preserving the + * existing `stop` / `respondToPermission` / `dispose` semantics. + */ +export class AiSdkFlow implements AgentFlow, AgentFlowControl { + readonly kind: string; + readonly sessionId: string; + private readonly backend: AgentBackend; + + constructor(input: AiSdkFlowInput) { + this.backend = input.backend; + this.sessionId = input.backend.sessionId; + this.kind = input.backend.kind; + } + + /** The wrapped backend (exposed for runners that need the raw control surface). */ + get backendRef(): AgentBackend { + return this.backend; + } + + async *run(ctx: InvocationContext, input: FlowInput): AsyncIterable { + if (ctx.sessionId !== this.sessionId) { + throw new Error( + `AiSdkFlow session mismatch: ctx.sessionId=${ctx.sessionId} but backend is bound to ${this.sessionId}`, + ); + } + + // Bridge the FlowInput.abortSignal seam onto the backend's stop() control. + // The legacy backend owns its own AbortController; this just routes an + // external signal to the existing steering method. + const abortSignal = input.abortSignal; + let onAbort: (() => void) | null = null; + if (abortSignal) { + if (abortSignal.aborted) { + await this.stop('user_stop').catch(() => {}); + } else { + onAbort = () => { + void this.stop('user_stop').catch(() => {}); + }; + abortSignal.addEventListener('abort', onAbort, { once: true }); + } + } + + const memory = createSessionEventMapMemory(); + try { + for await (const sessionEvent of this.backend.send({ + turnId: ctx.turnId, + text: input.text, + ...(input.attachments !== undefined ? { attachments: input.attachments } : {}), + context: input.context, + })) { + yield mapSessionEventToRuntimeEvent(sessionEvent, ctx, memory); + } + } finally { + if (abortSignal && onAbort) { + abortSignal.removeEventListener('abort', onAbort); + } + } + } + + async stop(reason: 'user_stop' | 'redirect'): Promise { + await this.backend.stop(reason); + } + + async respondToPermission(decision: PermissionDecision): Promise { + await this.backend.respondToPermission(decision); + } + + async dispose(): Promise { + await this.backend.dispose(); + } +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 38c6f89094..6f05e2701d 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -123,3 +123,84 @@ export type { WechatBridgeQrCodeResult, SendCapable, } from './bots/index.js'; + +// ─────────────────────────────────────────────────────────────────────────── +// Runtime v2 seam (Phase 1–4 increments). +// +// Subpath imports (e.g. `@maka/runtime/runtime-runner`) remain canonical; +// the barrel re-exports below are for convenience. NOTE: `InvocationContext` +// is exported here from `./invocation-context.js` (the canonical runner +// spine). `./agent-flow.js` declares a structurally wider +// `InvocationContext` for its own seam; it is intentionally NOT re-exported +// from the barrel to avoid a name clash. The runner's context is assignable +// to the flow's context, so callers that construct the runner context can +// pass it to an `AgentFlow.run()`. See +// `docs/runtime-v2-implementation-notes.md` for the reconciliation plan. +// ─────────────────────────────────────────────────────────────────────────── + +// invocation-context.ts — runner spine types + providers. +export type { + InvocationContext, + InvocationRequest, + InvocationSource, + InvocationLineage, + InvocationProviders, + InvocationResult, + InvocationResultStatus, + InvocationFailure, +} from './invocation-context.js'; +export { + INVOCATION_SOURCES, + isInvocationSource, + createDefaultInvocationProviders, +} from './invocation-context.js'; + +// runtime-runner.ts — RuntimeRunner shell + gate. +export { RuntimeRunner, runtimeGateFromCallback } from './runtime-runner.js'; +export type { + RuntimeGate, + RuntimeGateDecision, + AgentFlowLike, + RuntimeRunnerDeps, +} from './runtime-runner.js'; + +// runtime-event-adapters.ts — legacy StoredMessage ↔ RuntimeEvent bridge. +export { + storedMessageToRuntimeEvent, + storedMessageToRuntimeEvents, + runtimeEventToStoredMessageDraft, + createRuntimeEventId, +} from './runtime-event-adapters.js'; +export type { + StoredMessageEventContext, + RuntimeEventToDraftOptions, +} from './runtime-event-adapters.js'; + +// model-history.ts — policy-driven model-history projection. +export { buildModelHistoryFromRuntimeEvents } from './model-history.js'; +export type { + ModelHistoryEntry, + BuildModelHistoryOptions, +} from './model-history.js'; + +// agent-flow.ts — formal Flow seam (InvocationContext intentionally omitted; +// see note above). +export type { + AgentFlow, + AgentFlowControl, + FlowInput, +} from './agent-flow.js'; +export { flowSupportsControl } from './agent-flow.js'; + +// ai-sdk-flow.ts — default AgentFlow implementation over AiSdkBackend. +export { + AiSdkFlow, + mapSessionEventToRuntimeEvent, + mapCompleteStopReason, + createSessionEventMapMemory, +} from './ai-sdk-flow.js'; +export type { + AiSdkFlowInput, + CompleteStopReason, + SessionEventMapMemory, +} from './ai-sdk-flow.js'; diff --git a/packages/runtime/src/invocation-context.ts b/packages/runtime/src/invocation-context.ts new file mode 100644 index 0000000000..a1fe6b6972 --- /dev/null +++ b/packages/runtime/src/invocation-context.ts @@ -0,0 +1,169 @@ +/** + * InvocationContext — Runtime v2 invocation/run spine. + * + * Source: docs/runtime-v2-architecture-evolution.md §Target Architecture, + * §Proposed Module Shape, and Phase 2 (RuntimeRunner Shell). + * + * Phase 2 scope (this node): types + injectable providers only. The + * RuntimeRunner consumes these to build a testable invocation shell driven + * by fake services. It is deliberately NOT wired to SessionStore / + * SessionManager yet — that delegation lands in a later phase, after the + * AgentFlow / projection nodes exist. The value here is the seam and tests. + * + * Identity hierarchy carried on every context: sessionId ⊃ invocationId ⊃ + * runId ⊃ turnId. These mirror the canonical RuntimeEvent fields so events + * minted inside a flow stay 1:1 with the invocation that produced them. + */ + +import type { RuntimeEvent, RuntimeEventStatus } from '@maka/core/runtime-event'; + +// ============================================================================ +// InvocationSource +// ============================================================================ + +/** + * Where the invocation entered the runtime. Desktop, bot, and gateway should + * eventually share the same runner; `test` covers in-process fake-service + * invocations like the ones in this node's test suite. + */ +export const INVOCATION_SOURCES = ['desktop', 'bot', 'gateway', 'test'] as const; +export type InvocationSource = typeof INVOCATION_SOURCES[number]; + +export function isInvocationSource(value: unknown): value is InvocationSource { + return typeof value === 'string' && (INVOCATION_SOURCES as readonly string[]).includes(value); +} + +// ============================================================================ +// InvocationLineage — retry / regenerate / branch pointers +// ============================================================================ + +/** + * Optional lineage carried from the entrypoint. Mirrors the relevant + * UserMessageInput fields so a future StoredMessage turn_state projection + * can map 1:1 without re-deriving shape. + */ +export interface InvocationLineage { + parentTurnId?: string; + retriedFromTurnId?: string; + regeneratedFromTurnId?: string; + branchOfTurnId?: string; + parentSessionId?: string; +} + +// ============================================================================ +// InvocationRequest — input to RuntimeRunner.run() +// ============================================================================ + +/** + * Request to run one agent invocation. The runner owns preflight, context + * creation, the initial user RuntimeEvent, and flow dispatch. It does not + * read or write SessionStore in this skeleton. + * + * `invocationId` / `runId` are generated by the runner through the injected + * providers (see InvocationContext); they are intentionally not on the + * request so callers cannot assert a fake spine identity. + */ +export interface InvocationRequest { + sessionId: string; + turnId: string; + text: string; + source: InvocationSource; + /** Optional branch/agent lane; forwarded onto every emitted event. */ + branch?: string; + /** Lineage for retry/regenerate/branch projections. */ + lineage?: InvocationLineage; + /** Caller-owned abort signal; flows and tools SHOULD observe it. */ + abortSignal?: AbortSignal; +} + +// ============================================================================ +// InvocationContext — created by RuntimeRunner through injected providers +// ============================================================================ + +/** + * The invocation/run spine handed to AgentFlow.run(ctx, request). Carries + * the durable identity hierarchy plus the injectable id/time providers a + * flow uses to mint canonical RuntimeEvents that line up with the spine. + */ +export interface InvocationContext { + sessionId: string; + /** Durable invocation spine id; groups every run/turn of one request. */ + invocationId: string; + /** Specific run/attempt within the invocation. */ + runId: string; + turnId: string; + /** Optional branch/agent lane (forwarded from the request). */ + branch?: string; + source: InvocationSource; + /** Unix ms timestamp captured when the runner created the context. */ + startedAt: number; + /** Caller-owned abort signal; flows and tools SHOULD observe it. */ + abortSignal?: AbortSignal; + /** The original request, for flows that need source/lineage/text. */ + request: InvocationRequest; + /** Injectable id provider (same instance the runner uses). */ + newId: () => string; + /** Injectable clock (same instance the runner uses). */ + now: () => number; +} + +// ============================================================================ +// InvocationProviders — injectable id/time so tests can be deterministic +// ============================================================================ + +export interface InvocationProviders { + newId: () => string; + now: () => number; +} + +/** + * Best-effort default providers. Real entrypoints already inject stronger + * id/time sources (see SessionManagerDeps.newId / now); tests SHOULD inject + * their own deterministic providers rather than rely on this default. + */ +export function createDefaultInvocationProviders(): InvocationProviders { + return { + newId: () => + typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `inv_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`, + now: () => Date.now(), + }; +} + +// ============================================================================ +// InvocationResult — structured outcome returned by RuntimeRunner.run() +// ============================================================================ + +/** + * Result envelope collapses non-completed outcomes to 'failed'. The precise + * terminal RuntimeEventStatus that produced a failure is retained inside + * `failure.terminalStatus` so callers do not lose aborted/cancelled detail. + */ +export type InvocationResultStatus = 'completed' | 'failed'; + +export interface InvocationFailure { + /** + * Stable machine-readable class. Today one of: 'preflight', 'aborted', + * the terminal RuntimeEventStatus ('failed' | 'aborted' | 'cancelled'), + * or the thrown error's name. + */ + class: string; + message?: string; + /** Precise terminal RuntimeEventStatus when the failure came from a terminal event. */ + terminalStatus?: RuntimeEventStatus; +} + +export interface InvocationResult { + invocationId: string; + runId: string; + sessionId: string; + turnId: string; + status: InvocationResultStatus; + /** Every RuntimeEvent collected, in emission order (user event first). */ + events: RuntimeEvent[]; + /** Present when status === 'failed'. */ + failure?: InvocationFailure; + startedAt: number; + finishedAt: number; +} diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts new file mode 100644 index 0000000000..6ebc9c0fef --- /dev/null +++ b/packages/runtime/src/model-history.ts @@ -0,0 +1,142 @@ +/** + * Model history projection — build the model-visible message history from a + * RuntimeEvent stream. + * + * Source: docs/runtime-v2-architecture-evolution.md §Model history + * + * Phase 1 scope: pure, synchronous projection. Replaces the ad-hoc + * StoredMessage filtering in AiSdkBackend.materializePriorMessages with an + * explicit, policy-driven filter over canonical events. The output is a + * neutral `ModelHistoryEntry[]` that callers (ai-sdk backend, flow runner) + * translate into provider-specific message shapes. + * + * Policy (why an event is KEPT): + * - non-partial (final content, not a transient streaming chunk) + * - model-visible content kind: text / thinking / function_call / + * function_response (per runtimeEventHasModelVisibleContent) + * - role is user, model, or tool (system excluded unless opted in) + * + * Policy (why an event is DROPPED): + * - partial === true (streaming chunks superseded by a later final event) + * - error-only content (a tool error surfaced to the model is a + * function_response with isError, which stays visible) + * - actions-only / refs-only events (token usage, permission acks, + * state deltas, end-invocation markers) + * - system-role events by default (UI-only notes; system instructions + * are injected fresh by the runner, not replayed from history) + * + * Thinking and tool events are opt-in/opt-out so callers can match the + * replay contract of their provider (V0.1 text-only replay cannot use + * them; Anthropic replay can re-use signed thinking, etc.). + * + * NOTE: imports the new `@maka/core/runtime-event` subpath. The steward + * node re-exports it from the core barrel. + */ + +import { + isPartialRuntimeEvent, + runtimeEventHasModelVisibleContent, + type RuntimeEvent, + type RuntimeEventContent, + type RuntimeEventRole, +} from '@maka/core/runtime-event'; + +// ============================================================================ +// Output type +// ============================================================================ + +/** + * One model-facing history entry. `content` is the canonical + * RuntimeEventContent (discriminated by `kind`); `role` is the + * model-history lane the entry plays for the next model call. + */ +export interface ModelHistoryEntry { + role: RuntimeEventRole; + content: RuntimeEventContent; + ts: number; + eventId: string; +} + +// ============================================================================ +// Options +// ============================================================================ + +export interface BuildModelHistoryOptions { + /** + * Include function_call / function_response entries. Default `true`. + * Set `false` for providers whose replay format cannot represent prior + * tool turns (the V0.1 ai-sdk text-only replay path). + */ + includeToolEvents?: boolean; + /** + * Include system-role events (system notes / instructions). Default + * `false`. System instructions are normally injected fresh by the + * runner each turn, not replayed from durable history. + */ + includeSystemEvents?: boolean; + /** + * Include thinking-content entries. Default `false`. Thinking replay + * is provider-specific (Anthropic signed signatures); callers that + * need it opt in and reattach signatures from the event content. + */ + includeThinking?: boolean; +} + +// ============================================================================ +// Projection +// ============================================================================ + +/** + * Build the model-visible history from a RuntimeEvent stream. + * + * Events SHOULD be supplied in causal order; the projection preserves + * input order. Partial events are always excluded — callers MUST NOT + * replay transient streaming chunks into the next model call. + * + * The default options match the durable-history policy: user/model text + * and tool calls/responses are kept; thinking, system notes, token usage, + * permission acks, and diagnostics are dropped. + */ +export function buildModelHistoryFromRuntimeEvents( + events: readonly RuntimeEvent[], + options: BuildModelHistoryOptions = {}, +): ModelHistoryEntry[] { + const includeToolEvents = options.includeToolEvents ?? true; + const includeSystemEvents = options.includeSystemEvents ?? false; + const includeThinking = options.includeThinking ?? false; + + const out: ModelHistoryEntry[] = []; + for (const event of events) { + // 1. Never replay transient streaming chunks. + if (isPartialRuntimeEvent(event)) continue; + + // 2. Only model-visible content kinds (text/thinking/function_*). + if (!runtimeEventHasModelVisibleContent(event)) continue; + + const content = event.content; + if (!content) continue; + + // 3. System-role events are UI notes by default; opt in for + // model-injected system instructions. + if (event.role === 'system' && !includeSystemEvents) continue; + + // 4. Thinking replay is provider-specific; opt in. + if (content.kind === 'thinking' && !includeThinking) continue; + + // 5. Tool function_call / function_response; opt out for text-only. + if ( + !includeToolEvents && + (content.kind === 'function_call' || content.kind === 'function_response') + ) { + continue; + } + + out.push({ + role: event.role, + content, + ts: event.ts, + eventId: event.id, + }); + } + return out; +} diff --git a/packages/runtime/src/runtime-event-adapters.ts b/packages/runtime/src/runtime-event-adapters.ts new file mode 100644 index 0000000000..c481e64a35 --- /dev/null +++ b/packages/runtime/src/runtime-event-adapters.ts @@ -0,0 +1,266 @@ +/** + * RuntimeEvent adapters — narrow bridges between the legacy StoredMessage + * JSONL format and the canonical RuntimeEvent fact model. + * + * Source: docs/runtime-v2-architecture-evolution.md §Consumption Channels + * + * Phase 1 scope: pure, synchronous, allocation-only adapters. They do NOT + * touch storage, do NOT mutate their inputs, and do NOT invent fields the + * source message cannot supply. Every adapter is total: when a message + * kind cannot be converted safely, the helper returns `null` (singular) or + * omits the entry (plural) rather than throwing. + * + * The reverse direction (RuntimeEvent → StoredMessage draft) is provided + * only for the straightforward user/model text cases. Tool, permission, + * token-usage, and lifecycle events are deliberately NOT forced back into + * legacy storage shapes — those projections are owned by later nodes and + * the materializer already covers the UI path. + * + * NOTE: imports the new `@maka/core/runtime-event` subpath. The steward + * node re-exports it from the core barrel; until then the subpath in + * `packages/core/package.json` is the canonical entry point. + */ + +import type { + StoredMessage, + UserMessage, + AssistantMessage, + SystemNoteMessage, +} from '@maka/core/session'; +import { createRuntimeEventId, type RuntimeEvent } from '@maka/core/runtime-event'; + +// ============================================================================ +// Shared context for legacy → event conversion +// ============================================================================ + +export interface StoredMessageEventContext { + sessionId: string; + invocationId: string; + runId: string; + /** Defaults to the message turnId (or '' for session-level notes). */ + turnId?: string; + /** Defaults to the message ts. */ + ts?: number; + /** id generator for new RuntimeEvents; defaults to createRuntimeEventId. */ + newId?: () => string; +} + +interface ResolvedEventCtx { + sessionId: string; + invocationId: string; + runId: string; + turnId: string; + ts: number; + newId: () => string; +} + +function resolveCtx( + ctx: StoredMessageEventContext, + message: StoredMessage, +): ResolvedEventCtx { + return { + sessionId: ctx.sessionId, + invocationId: ctx.invocationId, + runId: ctx.runId, + turnId: ctx.turnId ?? message.turnId ?? '', + ts: ctx.ts ?? message.ts, + newId: ctx.newId ?? (() => createRuntimeEventId('rt-legacy')), + }; +} + +// ============================================================================ +// StoredMessage → RuntimeEvent +// ============================================================================ + +/** + * Convert a legacy StoredMessage into the PRIMARY RuntimeEvent it carries. + * + * Safe conversions: + * user → role 'user', author 'user', text content + * assistant → role 'model', author 'agent', text content (thinking omitted) + * system_note → role 'system', author 'system', text content + * + * Returns null for tool_call, tool_result, permission_decision, + * token_usage, and turn_state — these need richer mapping (function_call / + * function_response content, actions, refs) that the runtime runner owns. + * + * Assistant `thinking` is dropped in this narrow singular form; use + * `storedMessageToRuntimeEvents` to capture thinking as a separate event. + */ +export function storedMessageToRuntimeEvent( + message: StoredMessage, + ctx: StoredMessageEventContext, +): RuntimeEvent | null { + const d = resolveCtx(ctx, message); + switch (message.type) { + case 'user': + return { + id: d.newId(), + invocationId: d.invocationId, + runId: d.runId, + sessionId: d.sessionId, + turnId: d.turnId, + ts: d.ts, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: message.text }, + refs: { storedMessageId: message.id }, + }; + + case 'assistant': + return { + id: d.newId(), + invocationId: d.invocationId, + runId: d.runId, + sessionId: d.sessionId, + turnId: d.turnId, + ts: d.ts, + partial: false, + role: 'model', + author: 'agent', + content: { kind: 'text', text: message.text }, + refs: { storedMessageId: message.id }, + }; + + case 'system_note': + return { + id: d.newId(), + invocationId: d.invocationId, + runId: d.runId, + sessionId: d.sessionId, + turnId: d.turnId, + ts: d.ts, + partial: false, + role: 'system', + author: 'system', + content: { kind: 'text', text: systemNoteText(message) }, + refs: { storedMessageId: message.id }, + }; + + default: + return null; + } +} + +/** + * Convert a legacy StoredMessage into ALL RuntimeEvents it implies. + * + * Multi-event cases: + * assistant with thinking → [model text event, model thinking event] + * + * Returns an empty array for message kinds that have no safe conversion + * (tool_call, tool_result, permission_decision, token_usage, turn_state). + */ +export function storedMessageToRuntimeEvents( + message: StoredMessage, + ctx: StoredMessageEventContext, +): RuntimeEvent[] { + const primary = storedMessageToRuntimeEvent(message, ctx); + const out: RuntimeEvent[] = []; + if (primary) out.push(primary); + + if (message.type === 'assistant' && message.thinking && message.thinking.text.length > 0) { + const d = resolveCtx(ctx, message); + out.push({ + id: d.newId(), + invocationId: d.invocationId, + runId: d.runId, + sessionId: d.sessionId, + turnId: d.turnId, + ts: d.ts, + partial: false, + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: message.thinking.text, + ...(message.thinking.signature !== undefined + ? { signature: message.thinking.signature } + : {}), + }, + refs: { storedMessageId: message.id }, + }); + } + + return out; +} + +/** + * Stable, machine-readable label for a SystemNoteMessage. The model-history + * projection excludes system-role events by default, so this text never + * leaks into prompts; it exists only so the event carries a non-empty + * content payload for audit/UI projections. + */ +function systemNoteText(m: SystemNoteMessage): string { + return `system_note:${m.kind}`; +} + +// ============================================================================ +// RuntimeEvent → StoredMessage draft +// ============================================================================ + +export interface RuntimeEventToDraftOptions { + /** + * Required to emit AssistantMessage drafts from model-role text events. + * When omitted, model-role events return null (no safe legacy shape). + */ + modelId?: string; + /** id generator for new StoredMessages; defaults to createRuntimeEventId. */ + newId?: () => string; +} + +/** + * Convert a RuntimeEvent into a legacy StoredMessage draft, when the + * mapping is straightforward and lossless. + * + * Straightforward cases: + * role 'user' + text content → UserMessage + * role 'model' + text content → AssistantMessage (requires options.modelId) + * + * Returns null for every other shape (thinking, function_call, + * function_response, error, actions-only, system notes) — these are not + * forced into legacy storage. Tool/function projections will be owned by + * the runtime runner / tool-runtime nodes. + * + * The returned message is a complete StoredMessage with id/turnId/ts + * filled from the event (or generated when absent). The caller is + * responsible for appending it to the store. + */ +export function runtimeEventToStoredMessageDraft( + event: RuntimeEvent, + options: RuntimeEventToDraftOptions = {}, +): StoredMessage | null { + const newId = options.newId ?? (() => createRuntimeEventId('msg')); + const content = event.content; + if (!content) return null; + + if (event.role === 'user' && content.kind === 'text') { + const draft: UserMessage = { + type: 'user', + id: event.refs?.storedMessageId ?? newId(), + turnId: event.turnId, + ts: event.ts, + text: content.text, + }; + return draft; + } + + if (event.role === 'model' && content.kind === 'text') { + if (!options.modelId) return null; + const draft: AssistantMessage = { + type: 'assistant', + id: event.refs?.storedMessageId ?? newId(), + turnId: event.turnId, + ts: event.ts, + text: content.text, + modelId: options.modelId, + }; + return draft; + } + + return null; +} + +// Re-export the id helper for adapter callers that want the same default. +export { createRuntimeEventId } from '@maka/core/runtime-event'; diff --git a/packages/runtime/src/runtime-runner.ts b/packages/runtime/src/runtime-runner.ts new file mode 100644 index 0000000000..e5b00fbef0 --- /dev/null +++ b/packages/runtime/src/runtime-runner.ts @@ -0,0 +1,280 @@ +/** + * RuntimeRunner — Runtime v2 invocation shell. + * + * Source: docs/runtime-v2-architecture-evolution.md §Target Architecture and + * Phase 2 (RuntimeRunner Shell). + * + * This is an internal seam, not the production hot path. It is intentionally + * decoupled from SessionManager / SessionStore so it can be exercised in + * tests with fake services, and so SessionManager.sendMessage can delegate to + * it incrementally in a later phase without a big-bang rewrite. + * + * Responsibilities (per the node spec): + * 1. Run an injectable preflight gate. + * 2. Create the InvocationContext through injected id/time providers. + * 3. Emit (collect) the initial user RuntimeEvent. + * 4. Dispatch to an injected AgentFlow and collect canonical RuntimeEvents. + * 5. Return a structured result with the collected events and a terminal + * status. + * + * Out-of-scope (deliberately): SessionStore writes, projection driving, + * AgentRunStore ledger writes, and replacing SessionManager.sendMessage. + */ + +import { + isTerminalRuntimeEvent, + type RuntimeEvent, + type RuntimeEventStatus, +} from '@maka/core/runtime-event'; +import type { + InvocationContext, + InvocationFailure, + InvocationProviders, + InvocationRequest, + InvocationResult, + InvocationResultStatus, +} from './invocation-context.js'; +import { createDefaultInvocationProviders } from './invocation-context.js'; + +// ============================================================================ +// RuntimeGate — narrow preflight seam +// ============================================================================ + +/** + * Decision returned by a RuntimeGate preflight. `ok: false` blocks the + * invocation before any context is created or event emitted. + */ +export interface RuntimeGateDecision { + ok: boolean; + /** Machine-readable reason when ok === false (surfaced as failure.message). */ + reason?: string; +} + +/** + * Narrow preflight interface for readiness/blocked/running/waiting policy. + * Kept injectable so tests can pass a stub and Phase 6 can move desktop + * main's readiness/rebind checks behind a real implementation. + */ +export interface RuntimeGate { + preflight(request: InvocationRequest): Promise; +} + +/** + * Functional gate from a callback. Convenient for tests; also the shape a + * future Phase 6 gate will compose from readiness rules. + */ +export function runtimeGateFromCallback( + preflight: ( + request: InvocationRequest, + ) => Promise | RuntimeGateDecision, +): RuntimeGate { + return { + preflight: async (request) => preflight(request), + }; +} + +// ============================================================================ +// AgentFlowLike — local flow seam +// ============================================================================ + +/** + * Minimal flow contract RuntimeRunner dispatches to. The formal AgentFlow + * interface (AiSdkFlow node) will be assignable to this; it is defined + * locally so this skeleton does not block on — or duplicate — the flow + * node's public surface. + */ +export interface AgentFlowLike { + run(ctx: InvocationContext, request: InvocationRequest): AsyncIterable; +} + +// ============================================================================ +// RuntimeRunnerDeps +// ============================================================================ + +export interface RuntimeRunnerDeps { + flow: AgentFlowLike; + /** Optional preflight gate; omitted means "always allow". */ + gate?: RuntimeGate; + /** Injectable id/time providers. Defaults to crypto.randomUUID / Date.now. */ + providers?: InvocationProviders; +} + +// ============================================================================ +// RuntimeRunner +// ============================================================================ + +export class RuntimeRunner { + private readonly flow: AgentFlowLike; + private readonly gate: RuntimeGate | undefined; + private readonly providers: InvocationProviders; + + constructor(deps: RuntimeRunnerDeps) { + this.flow = deps.flow; + this.gate = deps.gate; + this.providers = deps.providers ?? createDefaultInvocationProviders(); + } + + /** + * Run one invocation end-to-end and return a structured result. + * + * Event order is guaranteed: the initial user RuntimeEvent is always + * collected before any flow event. Collection stops at the first terminal + * RuntimeEvent; a terminal event is what ends the result. + */ + async run(request: InvocationRequest): Promise { + const startedAt = this.providers.now(); + const invocationId = this.providers.newId(); + const runId = this.providers.newId(); + + // 1. Preflight (injectable gate). On failure we admit no invocation: no + // context, no user event, no flow dispatch. + if (this.gate) { + const decision = await this.gate.preflight(request); + if (!decision.ok) { + return this.buildResult({ + request, + invocationId, + runId, + startedAt, + finishedAt: this.providers.now(), + status: 'failed', + events: [], + failure: { + class: 'preflight', + ...(decision.reason ? { message: decision.reason } : {}), + }, + }); + } + } + + // 2. Abort already signalled before dispatch. Fail fast without emitting + // a user event or calling the flow, mirroring the preflight path. + if (request.abortSignal?.aborted) { + return this.buildResult({ + request, + invocationId, + runId, + startedAt, + finishedAt: this.providers.now(), + status: 'failed', + events: [], + failure: { + class: 'aborted', + message: 'abort signal already set before dispatch', + }, + }); + } + + // 3. Create the invocation context through the injected providers. + const ctx: InvocationContext = { + sessionId: request.sessionId, + invocationId, + runId, + turnId: request.turnId, + ...(request.branch ? { branch: request.branch } : {}), + source: request.source, + startedAt, + ...(request.abortSignal ? { abortSignal: request.abortSignal } : {}), + request, + newId: this.providers.newId, + now: this.providers.now, + }; + + const events: RuntimeEvent[] = []; + + // 4. Emit the initial user RuntimeEvent before any flow event. + events.push(buildUserEvent(ctx, request)); + + // 5. Dispatch to the flow and collect canonical events. The first + // terminal event ends the result; events emitted after it are not + // collected. A thrown error or a non-completed terminal status maps + // the result to 'failed'. + let failure: InvocationFailure | undefined; + try { + for await (const ev of this.flow.run(ctx, request)) { + events.push(ev); + if (isTerminalRuntimeEvent(ev)) { + failure = failureFromTerminalEvent(ev); + break; + } + } + } catch (error) { + failure = { + class: error instanceof Error && error.name ? error.name : 'error', + ...(error instanceof Error && error.message ? { message: error.message } : {}), + }; + } + + const status: InvocationResultStatus = failure ? 'failed' : 'completed'; + return this.buildResult({ + request, + invocationId, + runId, + startedAt, + finishedAt: this.providers.now(), + status, + events, + ...(failure ? { failure } : {}), + }); + } + + private buildResult(args: { + request: InvocationRequest; + invocationId: string; + runId: string; + startedAt: number; + finishedAt: number; + status: InvocationResultStatus; + events: RuntimeEvent[]; + failure?: InvocationFailure; + }): InvocationResult { + return { + invocationId: args.invocationId, + runId: args.runId, + sessionId: args.request.sessionId, + turnId: args.request.turnId, + status: args.status, + events: args.events, + ...(args.failure ? { failure: args.failure } : {}), + startedAt: args.startedAt, + finishedAt: args.finishedAt, + }; + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function buildUserEvent(ctx: InvocationContext, request: InvocationRequest): RuntimeEvent { + return { + id: ctx.newId(), + invocationId: ctx.invocationId, + runId: ctx.runId, + sessionId: ctx.sessionId, + turnId: ctx.turnId, + ts: ctx.startedAt, + ...(ctx.branch ? { branch: ctx.branch } : {}), + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: request.text }, + }; +} + +/** + * Map a terminal RuntimeEvent to a failure when its status is anything other + * than 'completed'. A terminal event without an explicit status (e.g. one + * that only carries actions.endInvocation) is treated as completed. + */ +function failureFromTerminalEvent(event: RuntimeEvent): InvocationFailure | undefined { + const status: RuntimeEventStatus | undefined = event.status; + if (status === undefined || status === 'completed') return undefined; + const content = event.content; + const message = content?.kind === 'error' ? content.message : undefined; + return { + class: status, + ...(message ? { message } : {}), + terminalStatus: status, + }; +} From 326c12a8c688a048170bcaefb40f7bbe41e35f78 Mon Sep 17 00:00:00 2001 From: likun Date: Sun, 14 Jun 2026 20:57:32 +0800 Subject: [PATCH 3/4] Fix runtime v2 runner flow contracts --- .../core/src/__tests__/runtime-event.test.ts | 22 ++++ packages/core/src/runtime-event.ts | 8 ++ .../runtime/src/__tests__/ai-sdk-flow.test.ts | 114 ++++++++++++++++-- .../__tests__/runtime-event-adapters.test.ts | 67 ++++++++++ .../src/__tests__/runtime-runner.test.ts | 83 ++++++++++++- packages/runtime/src/ai-sdk-flow.ts | 18 ++- packages/runtime/src/invocation-context.ts | 10 ++ .../runtime/src/runtime-event-adapters.ts | 12 +- packages/runtime/src/runtime-runner.ts | 31 ++++- 9 files changed, 345 insertions(+), 20 deletions(-) diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index e0a5bbad10..281116577c 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -89,6 +89,28 @@ describe('RuntimeEvent content variants', () => { expect(content.text).toBe('hello'); }); + test('text content can carry attachment refs without changing its kind', () => { + const content: RuntimeEventContent = { + kind: 'text', + text: 'see attached', + attachments: [ + { + kind: 'image', + name: 'chart.png', + mimeType: 'image/png', + bytes: 123, + ref: { + kind: 'session_file', + sessionId: 'sess-1', + relativePath: 'attachments/chart.png', + }, + }, + ], + }; + if (content.kind !== 'text') throw new Error('unreachable'); + expect(content.attachments?.[0]?.name).toBe('chart.png'); + }); + test('thinking content may carry a replay signature', () => { const content: RuntimeEventContent = { kind: 'thinking', diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index e122c685ec..9fd3076b22 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -13,6 +13,7 @@ * projection, or ledger logic lives here. Those arrive in later nodes. */ +import type { AttachmentRef } from './events.js'; import type { PermissionRequest, PermissionResponse } from './permission.js'; // ============================================================================ @@ -88,6 +89,13 @@ export function isTerminalRuntimeEventStatus(value: unknown): boolean { export interface RuntimeEventTextContent { kind: 'text'; text: string; + /** + * Optional user-bound attachments carried with the text turn. Adapters + * MUST preserve these when converting legacy UserMessage rows so + * RuntimeEvent history does not silently degrade multimodal/file turns + * into plain text. + */ + attachments?: AttachmentRef[]; } export interface RuntimeEventThinkingContent { diff --git a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts index 51d1773c03..f6d145ae5b 100644 --- a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts @@ -3,7 +3,7 @@ import { describe, test } from 'node:test'; import type { BackendKind } from '@maka/core/session'; import type { SessionEvent } from '@maka/core/events'; -import type { PermissionDecision } from '@maka/core/backend-types'; +import type { BackendSendInput, PermissionDecision } from '@maka/core/backend-types'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { isTerminalRuntimeEvent, @@ -20,6 +20,7 @@ import { flowSupportsControl, } from '../agent-flow.js'; import type { AgentBackend } from '../ai-sdk-backend.js'; +import { RuntimeRunner } from '../runtime-runner.js'; // ============================================================================ // Fake backend — scripted SessionEvent stream + recorded control calls @@ -38,6 +39,7 @@ class ScriptedBackend implements AgentBackend { readonly sessionId: string; readonly stopCalls: Array<'user_stop' | 'redirect'> = []; readonly permissionCalls: PermissionDecision[] = []; + readonly sendInputs: BackendSendInput[] = []; disposeCalls = 0; sendCalls = 0; private readonly events: SessionEvent[]; @@ -50,8 +52,9 @@ class ScriptedBackend implements AgentBackend { this.gate = c.gate; } - async *send(): AsyncIterable { + async *send(input: BackendSendInput): AsyncIterable { this.sendCalls += 1; + this.sendInputs.push(input); for (const e of this.events) { yield e; if (this.gate) await this.gate(); @@ -158,6 +161,55 @@ describe('AiSdkFlow seam', () => { assert.equal(backend.sendCalls, 1); }); + test('RuntimeRunner dispatches AiSdkFlow with defined context and preserved attachments', async () => { + const attachment = { + kind: 'image' as const, + name: 'chart.png', + mimeType: 'image/png', + bytes: 123, + ref: { kind: 'session_file' as const, sessionId: 'session-1', relativePath: 'attachments/chart.png' }, + }; + const history = [ + { + type: 'user' as const, + id: 'u-prev', + turnId: 'turn-prev', + ts: 1, + text: 'previous', + }, + ]; + const backend = new ScriptedBackend({ + events: [ev({ type: 'complete', stopReason: 'end_turn' })], + }); + const flow = new AiSdkFlow({ backend }); + let idSeq = 0; + const runner = new RuntimeRunner({ + flow, + providers: { + newId: () => `rt-${(idSeq += 1)}`, + now: () => 1000, + }, + }); + + const result = await runner.run({ + sessionId: 'session-1', + turnId: 'turn-1', + text: 'hi', + attachments: [attachment], + context: history, + source: 'test', + }); + + assert.equal(result.status, 'completed'); + assert.equal(backend.sendInputs.length, 1); + assert.deepEqual(backend.sendInputs[0], { + turnId: 'turn-1', + text: 'hi', + attachments: [attachment], + context: history, + }); + }); + test('maps thinking deltas/signature onto model thinking content', async () => { const backend = new ScriptedBackend({ events: [ @@ -281,7 +333,7 @@ describe('AiSdkFlow seam', () => { assert.equal(isTerminalRuntimeEvent(out[1]), true); }); - test('maps the abort path preserving order (faithful, no coalescing)', async () => { + test('maps the abort path to exactly one terminal event', async () => { const backend = new ScriptedBackend({ events: [ ev({ type: 'text_delta', messageId: 'm1', text: 'par' }), @@ -292,15 +344,59 @@ describe('AiSdkFlow seam', () => { const flow = new AiSdkFlow({ backend }); const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); - // The adapter is faithful to the backend stream: both abort and the - // trailing complete are emitted (coalescing is a projection concern). - assert.equal(out.length, 3); + // AgentFlow guarantees exactly one terminal event, so the trailing + // complete(user_stop) from the legacy backend is coalesced away. + assert.equal(out.length, 2); assert.equal(out[1].status, 'aborted'); assert.equal(out[1].actions?.endInvocation, true); assert.equal(isTerminalRuntimeEvent(out[1]), true); - // Stream closes with the trailing terminal complete. - assert.equal(isTerminalRuntimeEvent(out[2]), true); - assert.equal(out[2].status, 'aborted'); + assert.equal(out.filter(isTerminalRuntimeEvent).length, 1); + }); + + test('stops yielding after the first terminal event', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'abort', reason: 'user_stop' }), + ev({ type: 'text_delta', messageId: 'm1', text: 'after-terminal' }), + ev({ type: 'complete', stopReason: 'user_stop' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); + + assert.equal(out.length, 1); + assert.equal(out[0]?.status, 'aborted'); + assert.equal(isTerminalRuntimeEvent(out[0]), true); + }); + + test('RuntimeRunner consumes AiSdkFlow abort as one coherent failed outcome', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'text_delta', messageId: 'm1', text: 'par' }), + ev({ type: 'abort', reason: 'user_stop' }), + ev({ type: 'complete', stopReason: 'user_stop' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + let idSeq = 0; + const runner = new RuntimeRunner({ + flow, + providers: { + newId: () => `id-${(idSeq += 1)}`, + now: () => 1000, + }, + }); + + const result = await runner.run({ + sessionId: 'session-1', + turnId: 'turn-1', + text: 'hi', + source: 'test', + }); + + assert.equal(result.status, 'failed'); + assert.equal(result.failure?.class, 'aborted'); + assert.equal(result.events.filter(isTerminalRuntimeEvent).length, 1); }); test('delegates stop / respondToPermission / dispose to the wrapped backend', async () => { diff --git a/packages/runtime/src/__tests__/runtime-event-adapters.test.ts b/packages/runtime/src/__tests__/runtime-event-adapters.test.ts index ae914a0c7c..2b2d2aa2ef 100644 --- a/packages/runtime/src/__tests__/runtime-event-adapters.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-adapters.test.ts @@ -12,6 +12,7 @@ import { describe, test } from 'node:test'; import { expect } from '../test-helpers.js'; +import type { AttachmentRef } from '@maka/core/events'; import type { UserMessage, AssistantMessage, @@ -42,6 +43,14 @@ import { const ts = 1_700_000_000_000; const turnId = 't1'; +const attachment: AttachmentRef = { + kind: 'pdf', + name: 'brief.pdf', + mimeType: 'application/pdf', + bytes: 2048, + ref: { kind: 'session_file', sessionId: 'sess-1', relativePath: 'attachments/brief.pdf' }, +}; + const user = (id: string, text: string): UserMessage => ({ type: 'user', id, @@ -166,6 +175,20 @@ describe('storedMessageToRuntimeEvent', () => { expect(e.ts).toBe(ts + 1); }); + test('user message with attachments preserves attachment refs in text content', () => { + const e = storedMessageToRuntimeEvent( + { ...user('u-attach', 'see attached'), attachments: [attachment] }, + ctx, + ); + expect(e).not.toBeNull(); + if (!e) return; + expect(e.content).toEqual({ + kind: 'text', + text: 'see attached', + attachments: [attachment], + }); + }); + test('assistant message (text only) → role model, text content; thinking dropped', () => { const e = storedMessageToRuntimeEvent(assistant('a1', 'hi'), ctx); if (!e) throw new Error('expected event'); @@ -266,6 +289,19 @@ describe('storedMessageToRuntimeEvents', () => { expect(out[0]?.content).toEqual({ kind: 'text', text: 'hello' }); }); + test('user message with attachments → single attachment-preserving event', () => { + const out = storedMessageToRuntimeEvents( + { ...user('u-attach', 'see attached'), attachments: [attachment] }, + ctx, + ); + expect(out).toHaveLength(1); + expect(out[0]?.content).toEqual({ + kind: 'text', + text: 'see attached', + attachments: [attachment], + }); + }); + test('tool_call → empty array', () => { expect(storedMessageToRuntimeEvents(toolCall('tc', 'Read'), ctx)).toEqual([]); }); @@ -303,6 +339,19 @@ describe('runtimeEventToStoredMessageDraft', () => { expect(draft.ts).toBe(event.ts); }); + test('user text event with attachments → UserMessage with attachments', () => { + const event = ev({ + role: 'user', + author: 'user', + content: { kind: 'text', text: 'see attached', attachments: [attachment] }, + refs: { storedMessageId: 'u-attach' }, + }); + const draft = runtimeEventToStoredMessageDraft(event); + expect(draft).not.toBeNull(); + if (!draft || draft.type !== 'user') return; + expect(draft.attachments).toEqual([attachment]); + }); + test('model text event with modelId → AssistantMessage', () => { const event = ev({ role: 'model', @@ -329,6 +378,24 @@ describe('runtimeEventToStoredMessageDraft', () => { expect(runtimeEventToStoredMessageDraft(event)).toBeNull(); }); + test('partial user and model text events → null', () => { + const partialUser = ev({ + partial: true, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'typing...' }, + }); + const partialModel = ev({ + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'streaming...' }, + }); + + expect(runtimeEventToStoredMessageDraft(partialUser)).toBeNull(); + expect(runtimeEventToStoredMessageDraft(partialModel, { modelId: 'gpt-4o' })).toBeNull(); + }); + test('thinking event → null', () => { const event = ev({ role: 'model', diff --git a/packages/runtime/src/__tests__/runtime-runner.test.ts b/packages/runtime/src/__tests__/runtime-runner.test.ts index 91e4cefbd5..6815f1384f 100644 --- a/packages/runtime/src/__tests__/runtime-runner.test.ts +++ b/packages/runtime/src/__tests__/runtime-runner.test.ts @@ -6,6 +6,7 @@ import { type AgentFlowLike, type RuntimeGate, } from '../runtime-runner.js'; +import type { AttachmentRef } from '@maka/core/events'; import type { InvocationContext, InvocationProviders, @@ -40,6 +41,14 @@ function makeRequest(overrides: Partial = {}): InvocationRequ }; } +const attachment: AttachmentRef = { + kind: 'image', + name: 'chart.png', + mimeType: 'image/png', + bytes: 123, + ref: { kind: 'session_file', sessionId: 'sess-1', relativePath: 'attachments/chart.png' }, +}; + /** * Fake flow that runs a script to produce its events. The script receives * the InvocationContext so events can line up with the invocation spine. @@ -130,13 +139,16 @@ describe('RuntimeRunner', () => { test('initial user RuntimeEvent is emitted before any flow event', async () => { const providers = makeProviders(); - const flow = new ScriptFlow((ctx) => [flowTextEvent(ctx, 'hello')]); + const flow = new ScriptFlow((ctx) => [ + flowTextEvent(ctx, 'hello'), + flowTerminalEvent(ctx, 'completed'), + ]); const runner = new RuntimeRunner({ flow, providers }); const result = await runner.run(makeRequest({ text: 'ping' })); expect(result.status).toBe('completed'); - expect(result.events).toHaveLength(2); + expect(result.events).toHaveLength(3); const userEvent = result.events[0]!; expect(userEvent.role).toBe('user'); @@ -151,6 +163,20 @@ describe('RuntimeRunner', () => { expect(result.events[1]!.author).toBe('agent'); }); + test('a flow that exhausts without a terminal event maps to a failed result', async () => { + const providers = makeProviders(); + const flow = new ScriptFlow((ctx) => [flowTextEvent(ctx, 'hello')]); + const runner = new RuntimeRunner({ flow, providers }); + + const result = await runner.run(makeRequest()); + + expect(result.status).toBe('failed'); + expect(result.failure?.class).toBe('missing_terminal_event'); + expect(result.events).toHaveLength(2); + expect(result.events[0]!.author).toBe('user'); + expect(result.events[1]!.author).toBe('agent'); + }); + test('a terminal event ends the result and stops collecting flow events', async () => { const providers = makeProviders(); const flow = new ScriptFlow((ctx) => [ @@ -312,4 +338,57 @@ describe('RuntimeRunner', () => { // Providers are shared, so a fresh id from ctx is unique against runId. expect(ctx.newId() !== ctx.runId).toBe(true); }); + + test('flow receives normalized FlowInput with context default and attachments preserved', async () => { + const providers = makeProviders(); + const context = [ + { + type: 'user' as const, + id: 'u-prev', + turnId: 'prev-turn', + ts: 1, + text: 'previous', + }, + ]; + let seenInput: Parameters[1] | undefined; + const flow: AgentFlowLike = { + async *run(ctx, input) { + seenInput = input; + yield flowTerminalEvent(ctx, 'completed'); + }, + }; + const runner = new RuntimeRunner({ flow, providers }); + + const result = await runner.run( + makeRequest({ text: 'with file', context, attachments: [attachment] }), + ); + + expect(result.status).toBe('completed'); + expect(seenInput).toEqual({ + text: 'with file', + context, + attachments: [attachment], + }); + expect(result.events[0]!.content).toEqual({ + kind: 'text', + text: 'with file', + attachments: [attachment], + }); + }); + + test('flow input context defaults to an empty array', async () => { + const providers = makeProviders(); + let seenInput: Parameters[1] | undefined; + const flow: AgentFlowLike = { + async *run(ctx, input) { + seenInput = input; + yield flowTerminalEvent(ctx, 'completed'); + }, + }; + const runner = new RuntimeRunner({ flow, providers }); + + await runner.run(makeRequest()); + + expect(seenInput?.context).toEqual([]); + }); }); diff --git a/packages/runtime/src/ai-sdk-flow.ts b/packages/runtime/src/ai-sdk-flow.ts index a8f8b547a7..bf18444275 100644 --- a/packages/runtime/src/ai-sdk-flow.ts +++ b/packages/runtime/src/ai-sdk-flow.ts @@ -20,20 +20,20 @@ * - `run(ctx, input)`: drive the wrapped backend and emit `RuntimeEvent`s. * - `mapSessionEventToRuntimeEvent`: a documented, testable placeholder * mapping from the existing `SessionEvent` union onto `RuntimeEvent`. + * - coalesce duplicate terminal backend facts (e.g. `abort` followed by + * trailing `complete(user_stop)`) so the AgentFlow contract stays at + * exactly one terminal RuntimeEvent. * - control surface (`stop` / `respondToPermission` / `dispose`): delegate * to the wrapped backend so current control semantics are preserved. * * What this adapter deliberately does NOT do: * - rewrite or fork `AiSdkBackend.send()`; - * - coalesce the backend's `abort` + trailing `complete` into one event - * (the adapter is faithful to the source stream; coalescing is a - * runner/projection concern); * - own model-history projection (Phase 7) or tool-event actions (Phase 5). */ import type { CompleteEvent, SessionEvent } from '@maka/core/events'; import type { PermissionDecision } from '@maka/core/backend-types'; -import type { RuntimeEvent, RuntimeEventStatus } from '@maka/core/runtime-event'; +import { isTerminalRuntimeEvent, type RuntimeEvent, type RuntimeEventStatus } from '@maka/core/runtime-event'; import type { AgentBackend } from './ai-sdk-backend.js'; import { @@ -428,6 +428,7 @@ export class AiSdkFlow implements AgentFlow, AgentFlowControl { } const memory = createSessionEventMapMemory(); + let terminalEmitted = false; try { for await (const sessionEvent of this.backend.send({ turnId: ctx.turnId, @@ -435,7 +436,14 @@ export class AiSdkFlow implements AgentFlow, AgentFlowControl { ...(input.attachments !== undefined ? { attachments: input.attachments } : {}), context: input.context, })) { - yield mapSessionEventToRuntimeEvent(sessionEvent, ctx, memory); + const runtimeEvent = mapSessionEventToRuntimeEvent(sessionEvent, ctx, memory); + if (isTerminalRuntimeEvent(runtimeEvent)) { + if (terminalEmitted) continue; + terminalEmitted = true; + yield runtimeEvent; + break; + } + yield runtimeEvent; } } finally { if (abortSignal && onAbort) { diff --git a/packages/runtime/src/invocation-context.ts b/packages/runtime/src/invocation-context.ts index a1fe6b6972..10ecde4e9c 100644 --- a/packages/runtime/src/invocation-context.ts +++ b/packages/runtime/src/invocation-context.ts @@ -15,7 +15,9 @@ * minted inside a flow stay 1:1 with the invocation that produced them. */ +import type { AttachmentRef } from '@maka/core/events'; import type { RuntimeEvent, RuntimeEventStatus } from '@maka/core/runtime-event'; +import type { StoredMessage } from '@maka/core/session'; // ============================================================================ // InvocationSource @@ -67,6 +69,14 @@ export interface InvocationRequest { sessionId: string; turnId: string; text: string; + /** Optional attachments bound to this user turn. */ + attachments?: AttachmentRef[]; + /** + * Prior conversation history resolved by the caller/gate. RuntimeRunner + * passes this to AgentFlow as `context`, defaulting to [] so flows never + * receive an undefined model-history input. + */ + context?: StoredMessage[]; source: InvocationSource; /** Optional branch/agent lane; forwarded onto every emitted event. */ branch?: string; diff --git a/packages/runtime/src/runtime-event-adapters.ts b/packages/runtime/src/runtime-event-adapters.ts index c481e64a35..3b9f0b8c01 100644 --- a/packages/runtime/src/runtime-event-adapters.ts +++ b/packages/runtime/src/runtime-event-adapters.ts @@ -104,7 +104,13 @@ export function storedMessageToRuntimeEvent( partial: false, role: 'user', author: 'user', - content: { kind: 'text', text: message.text }, + content: { + kind: 'text', + text: message.text, + ...(message.attachments !== undefined && message.attachments.length > 0 + ? { attachments: message.attachments } + : {}), + }, refs: { storedMessageId: message.id }, }; @@ -231,6 +237,7 @@ export function runtimeEventToStoredMessageDraft( event: RuntimeEvent, options: RuntimeEventToDraftOptions = {}, ): StoredMessage | null { + if (event.partial) return null; const newId = options.newId ?? (() => createRuntimeEventId('msg')); const content = event.content; if (!content) return null; @@ -242,6 +249,9 @@ export function runtimeEventToStoredMessageDraft( turnId: event.turnId, ts: event.ts, text: content.text, + ...(content.attachments !== undefined && content.attachments.length > 0 + ? { attachments: content.attachments } + : {}), }; return draft; } diff --git a/packages/runtime/src/runtime-runner.ts b/packages/runtime/src/runtime-runner.ts index e5b00fbef0..c0d5f8a6d8 100644 --- a/packages/runtime/src/runtime-runner.ts +++ b/packages/runtime/src/runtime-runner.ts @@ -35,6 +35,7 @@ import type { InvocationResultStatus, } from './invocation-context.js'; import { createDefaultInvocationProviders } from './invocation-context.js'; +import type { FlowInput } from './agent-flow.js'; // ============================================================================ // RuntimeGate — narrow preflight seam @@ -84,7 +85,7 @@ export function runtimeGateFromCallback( * node's public surface. */ export interface AgentFlowLike { - run(ctx: InvocationContext, request: InvocationRequest): AsyncIterable; + run(ctx: InvocationContext, input: FlowInput): AsyncIterable; } // ============================================================================ @@ -184,16 +185,19 @@ export class RuntimeRunner { // 4. Emit the initial user RuntimeEvent before any flow event. events.push(buildUserEvent(ctx, request)); + const flowInput = buildFlowInput(request); // 5. Dispatch to the flow and collect canonical events. The first // terminal event ends the result; events emitted after it are not // collected. A thrown error or a non-completed terminal status maps // the result to 'failed'. let failure: InvocationFailure | undefined; + let terminalSeen = false; try { - for await (const ev of this.flow.run(ctx, request)) { + for await (const ev of this.flow.run(ctx, flowInput)) { events.push(ev); if (isTerminalRuntimeEvent(ev)) { + terminalSeen = true; failure = failureFromTerminalEvent(ev); break; } @@ -204,6 +208,12 @@ export class RuntimeRunner { ...(error instanceof Error && error.message ? { message: error.message } : {}), }; } + if (!failure && !terminalSeen) { + failure = { + class: 'missing_terminal_event', + message: 'flow exhausted without a terminal RuntimeEvent', + }; + } const status: InvocationResultStatus = failure ? 'failed' : 'completed'; return this.buildResult({ @@ -258,7 +268,22 @@ function buildUserEvent(ctx: InvocationContext, request: InvocationRequest): Run partial: false, role: 'user', author: 'user', - content: { kind: 'text', text: request.text }, + content: { + kind: 'text', + text: request.text, + ...(request.attachments !== undefined && request.attachments.length > 0 + ? { attachments: request.attachments } + : {}), + }, + }; +} + +function buildFlowInput(request: InvocationRequest): FlowInput { + return { + text: request.text, + context: request.context ?? [], + ...(request.attachments !== undefined ? { attachments: request.attachments } : {}), + ...(request.abortSignal ? { abortSignal: request.abortSignal } : {}), }; } From b6d7bdb12b7661617a3c11f0729e47f59a1d47f8 Mon Sep 17 00:00:00 2001 From: likun Date: Sun, 14 Jun 2026 22:40:39 +0800 Subject: [PATCH 4/4] Wire RuntimeRunner through SessionManager --- .../localized-main-shell-contract.test.ts | 6 +- .../__tests__/web-search-boundary.test.ts | 2 +- .../src/__tests__/runtime-runner.test.ts | 62 ++++++- .../src/__tests__/session-manager.test.ts | 53 ++++++ packages/runtime/src/invocation-context.ts | 19 +- packages/runtime/src/runtime-runner.ts | 45 +++-- packages/runtime/src/session-manager.ts | 171 +++++++++++++++++- 7 files changed, 319 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/main/__tests__/localized-main-shell-contract.test.ts b/apps/desktop/src/main/__tests__/localized-main-shell-contract.test.ts index 01af232cd9..980e63ad8f 100644 --- a/apps/desktop/src/main/__tests__/localized-main-shell-contract.test.ts +++ b/apps/desktop/src/main/__tests__/localized-main-shell-contract.test.ts @@ -423,7 +423,7 @@ describe('localized main shell contract', () => { it('surfaces permission denial in Chinese instead of raw English backend text', async () => { const components = await readFile(resolve(process.cwd(), '..', '..', 'packages', 'ui', 'src', 'components.tsx'), 'utf8'); - const aiSdk = await readFile(resolve(process.cwd(), '..', '..', 'packages', 'runtime', 'src', 'ai-sdk-backend.ts'), 'utf8'); + const toolRuntime = await readFile(resolve(process.cwd(), '..', '..', 'packages', 'runtime', 'src', 'tool-runtime.ts'), 'utf8'); const piAgent = await readFile(resolve(process.cwd(), '..', '..', 'packages', 'runtime', 'src', 'pi-agent-backend.ts'), 'utf8'); assert.match(components, /formatUserVisibleToolText\(text: string\)[\s\S]*User denied permission[\s\S]*用户已拒绝权限请求/); @@ -433,8 +433,8 @@ describe('localized main shell contract', () => { assert.match(components, /item\.result && !permissionDenied/); assert.match(components, /formatUserVisibleToolText\(redactSecrets\(extractErrorText\(props\.result\)\)\)/); assert.match(components, /capLines\(formatUserVisibleToolText\(redactSecrets\(content\.text\)\)\)/); - assert.match(aiSdk, /const reason = '用户已拒绝权限请求';/); + assert.match(toolRuntime, /const reason = '用户已拒绝权限请求';/); assert.match(piAgent, /text: '用户已拒绝权限请求'/); - assert.doesNotMatch(`${aiSdk}\n${piAgent}`, /User denied permission/); + assert.doesNotMatch(`${toolRuntime}\n${piAgent}`, /User denied permission/); }); }); diff --git a/apps/desktop/src/main/__tests__/web-search-boundary.test.ts b/apps/desktop/src/main/__tests__/web-search-boundary.test.ts index 1e4094bddf..9dea1c5c16 100644 --- a/apps/desktop/src/main/__tests__/web-search-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/web-search-boundary.test.ts @@ -360,7 +360,7 @@ describe('web-search renderer boundary (PR-WEB-SEARCH-TAVILY-0)', () => { it('WebSearch agent errors render as repair-oriented cards, not raw JSON', async () => { const ui = await readFile(join(REPO_ROOT, 'packages/ui/src/components.tsx'), 'utf8'); - const runtime = await readFile(join(REPO_ROOT, 'packages/runtime/src/ai-sdk-backend.ts'), 'utf8'); + const runtime = await readFile(join(REPO_ROOT, 'packages/runtime/src/tool-runtime.ts'), 'utf8'); const agentTool = await readFile(join(REPO_ROOT, 'apps/desktop/src/main/web-search/agent-tool.ts'), 'utf8'); const coreEvents = await readFile(join(REPO_ROOT, 'packages/core/src/events.ts'), 'utf8'); const overlay = ui.match(/function OverlayPreview[\s\S]*?if \(content\.kind === 'json'\)/); diff --git a/packages/runtime/src/__tests__/runtime-runner.test.ts b/packages/runtime/src/__tests__/runtime-runner.test.ts index 6815f1384f..60d71677e8 100644 --- a/packages/runtime/src/__tests__/runtime-runner.test.ts +++ b/packages/runtime/src/__tests__/runtime-runner.test.ts @@ -177,7 +177,39 @@ describe('RuntimeRunner', () => { expect(result.events[1]!.author).toBe('agent'); }); - test('a terminal event ends the result and stops collecting flow events', async () => { + test('caller-provided invocationId and runId are used across result, user event, and flow', async () => { + const providers = makeProviders(); + const flow = new ScriptFlow((ctx) => [ + flowTextEvent(ctx, 'flow-uses-caller-ids'), + flowTerminalEvent(ctx, 'completed'), + ]); + const runner = new RuntimeRunner({ flow, providers }); + + const result = await runner.run( + makeRequest({ + invocationId: 'inv-production-1', + runId: 'run-production-1', + }), + ); + + expect(result.invocationId).toBe('inv-production-1'); + expect(result.runId).toBe('run-production-1'); + expect(flow.seen).toHaveLength(1); + expect(flow.seen[0]!.invocationId).toBe('inv-production-1'); + expect(flow.seen[0]!.runId).toBe('run-production-1'); + + const userEvent = result.events[0]!; + expect(userEvent.author).toBe('user'); + expect(userEvent.invocationId).toBe('inv-production-1'); + expect(userEvent.runId).toBe('run-production-1'); + + for (const ev of result.events) { + expect(ev.invocationId).toBe('inv-production-1'); + expect(ev.runId).toBe('run-production-1'); + } + }); + + test('default behavior stops collecting at the first terminal flow event', async () => { const providers = makeProviders(); const flow = new ScriptFlow((ctx) => [ flowTextEvent(ctx, 'partial'), @@ -203,6 +235,34 @@ describe('RuntimeRunner', () => { ).toBe(false); }); + test('stopOnTerminal false keeps draining and fails on any non-completed terminal event', async () => { + const providers = makeProviders(); + const flow = new ScriptFlow((ctx) => [ + flowTerminalEvent(ctx, 'completed'), + flowTextEvent(ctx, 'cleanup-after-completed'), + flowTerminalEvent(ctx, 'aborted'), + flowTextEvent(ctx, 'cleanup-after-aborted'), + ]); + const runner = new RuntimeRunner({ flow, providers, stopOnTerminal: false }); + + const result = await runner.run(makeRequest()); + + expect(result.status).toBe('failed'); + expect(result.failure?.class).toBe('aborted'); + expect(result.failure?.terminalStatus).toBe('aborted'); + expect(result.events).toHaveLength(5); + expect( + result.events.some( + (ev) => ev.content?.kind === 'text' && ev.content.text === 'cleanup-after-completed', + ), + ).toBe(true); + expect( + result.events.some( + (ev) => ev.content?.kind === 'text' && ev.content.text === 'cleanup-after-aborted', + ), + ).toBe(true); + }); + test('a flow that throws maps to a failed result (user event retained)', async () => { const providers = makeProviders(); const flow = new ThrowingFlow(new Error('boom')); diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 989d6ffd2d..a8f88ce3b1 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -23,6 +23,7 @@ import { type SessionStore, } from '../session-manager.js'; import type { AgentBackend } from '../ai-sdk-backend.js'; +import type { InvocationResult } from '../invocation-context.js'; describe('SessionManager permission mode updates', () => { test('updates header, rebuilds active backend, and writes an audit note', async () => { @@ -207,6 +208,50 @@ describe('SessionManager permission mode updates', () => { expect(built).toEqual(['Before']); }); + test('sendMessage is driven through RuntimeRunner while preserving the SessionEvent stream', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const observed: InvocationResult[] = []; + backends.register('fake', (ctx) => new TestBackend(ctx)); + const manager = new SessionManager({ + store, + runStore, + backends, + newId: nextId(), + now: nextNow(6_500), + runtimeSource: 'test', + runtimeInvocationObserver: (result) => { + observed.push(result); + }, + }); + const session = await manager.createSession(makeInput()); + + const sessionEvents = await collectSessionEvents( + manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' }), + ); + + expect(sessionEvents.map((event) => event.type)).toEqual(['text_delta', 'complete']); + expect(sessionEvents.map((event) => event.id)).toEqual(['turn-1-delta', 'turn-1-complete']); + expect(observed.length).toBe(1); + + const [run] = await runStore.listSessionRuns(session.id); + if (!run) throw new Error('AgentRunStore run was not created'); + const result = observed[0]!; + expect(result.runId).toBe(run.runId); + expect(result.sessionId).toBe(session.id); + expect(result.turnId).toBe('turn-1'); + expect(result.status).toBe('completed'); + expect(result.events.map((event) => event.runId)).toEqual([run.runId, run.runId, run.runId]); + expect(result.events.map((event) => event.sessionId)).toEqual([session.id, session.id, session.id]); + expect(result.events.map((event) => event.turnId)).toEqual(['turn-1', 'turn-1', 'turn-1']); + expect(result.events.map((event) => event.role)).toEqual(['user', 'model', 'system']); + expect(result.events.map((event) => event.id)).toEqual(['id-3', 'turn-1-delta', 'turn-1-complete']); + expect(result.events[0]?.content).toEqual({ kind: 'text', text: 'hello' }); + expect(result.events[1]?.content).toEqual({ kind: 'text', text: 'ok' }); + expect(result.events[2]?.status).toBe('completed'); + }); + test('rejects backend configuration updates while a turn is actively streaming', async () => { const store = new MemorySessionStore(); const backends = new BackendRegistry(); @@ -1168,6 +1213,14 @@ async function drain(iterable: AsyncIterable): Promise { } } +async function collectSessionEvents(iterable: AsyncIterable): Promise { + const events: SessionEvent[] = []; + for await (const event of iterable) { + events.push(event); + } + return events; +} + async function expectRejects(promise: Promise, pattern: RegExp): Promise { try { await promise; diff --git a/packages/runtime/src/invocation-context.ts b/packages/runtime/src/invocation-context.ts index 10ecde4e9c..4cd2fa6ed1 100644 --- a/packages/runtime/src/invocation-context.ts +++ b/packages/runtime/src/invocation-context.ts @@ -4,11 +4,9 @@ * Source: docs/runtime-v2-architecture-evolution.md §Target Architecture, * §Proposed Module Shape, and Phase 2 (RuntimeRunner Shell). * - * Phase 2 scope (this node): types + injectable providers only. The - * RuntimeRunner consumes these to build a testable invocation shell driven - * by fake services. It is deliberately NOT wired to SessionStore / - * SessionManager yet — that delegation lands in a later phase, after the - * AgentFlow / projection nodes exist. The value here is the seam and tests. + * Phase 2 scope: types + injectable providers. RuntimeRunner consumes these + * to build a testable invocation shell and can also be handed production ids + * from an already-created AgentRun while migration wiring is in progress. * * Identity hierarchy carried on every context: sessionId ⊃ invocationId ⊃ * runId ⊃ turnId. These mirror the canonical RuntimeEvent fields so events @@ -58,15 +56,14 @@ export interface InvocationLineage { /** * Request to run one agent invocation. The runner owns preflight, context - * creation, the initial user RuntimeEvent, and flow dispatch. It does not - * read or write SessionStore in this skeleton. - * - * `invocationId` / `runId` are generated by the runner through the injected - * providers (see InvocationContext); they are intentionally not on the - * request so callers cannot assert a fake spine identity. + * creation, the initial user RuntimeEvent, and flow dispatch. Callers may + * provide existing production spine ids (for example from AgentRun); when + * omitted, the runner generates them through the injected providers. */ export interface InvocationRequest { sessionId: string; + invocationId?: string; + runId?: string; turnId: string; text: string; /** Optional attachments bound to this user turn. */ diff --git a/packages/runtime/src/runtime-runner.ts b/packages/runtime/src/runtime-runner.ts index c0d5f8a6d8..06b97361e4 100644 --- a/packages/runtime/src/runtime-runner.ts +++ b/packages/runtime/src/runtime-runner.ts @@ -4,10 +4,10 @@ * Source: docs/runtime-v2-architecture-evolution.md §Target Architecture and * Phase 2 (RuntimeRunner Shell). * - * This is an internal seam, not the production hot path. It is intentionally - * decoupled from SessionManager / SessionStore so it can be exercised in - * tests with fake services, and so SessionManager.sendMessage can delegate to - * it incrementally in a later phase without a big-bang rewrite. + * RuntimeRunner is the invocation shell. It remains decoupled from + * SessionManager / SessionStore so it can be exercised with fake services, + * while still being able to wrap production AgentRun streams during the + * Runtime v2 migration. * * Responsibilities (per the node spec): * 1. Run an injectable preflight gate. @@ -17,8 +17,9 @@ * 5. Return a structured result with the collected events and a terminal * status. * - * Out-of-scope (deliberately): SessionStore writes, projection driving, - * AgentRunStore ledger writes, and replacing SessionManager.sendMessage. + * Out-of-scope (deliberately): direct SessionStore writes, projection + * driving, and AgentRunStore ledger writes. Those remain owned by AgentRun + * while SessionManager delegates invocation execution through this shell. */ import { @@ -98,6 +99,12 @@ export interface RuntimeRunnerDeps { gate?: RuntimeGate; /** Injectable id/time providers. Defaults to crypto.randomUUID / Date.now. */ providers?: InvocationProviders; + /** + * Whether to stop collecting at the first terminal RuntimeEvent. Defaults + * to true for standalone runner callers; production bridges can set false + * to keep draining cleanup/trailing events from wrapped streams. + */ + stopOnTerminal?: boolean; } // ============================================================================ @@ -108,24 +115,27 @@ export class RuntimeRunner { private readonly flow: AgentFlowLike; private readonly gate: RuntimeGate | undefined; private readonly providers: InvocationProviders; + private readonly stopOnTerminal: boolean; constructor(deps: RuntimeRunnerDeps) { this.flow = deps.flow; this.gate = deps.gate; this.providers = deps.providers ?? createDefaultInvocationProviders(); + this.stopOnTerminal = deps.stopOnTerminal ?? true; } /** * Run one invocation end-to-end and return a structured result. * * Event order is guaranteed: the initial user RuntimeEvent is always - * collected before any flow event. Collection stops at the first terminal - * RuntimeEvent; a terminal event is what ends the result. + * collected before any flow event. By default collection stops at the first + * terminal RuntimeEvent; callers that wrap streams with cleanup/trailing + * events can opt into full draining through RuntimeRunnerDeps. */ async run(request: InvocationRequest): Promise { const startedAt = this.providers.now(); - const invocationId = this.providers.newId(); - const runId = this.providers.newId(); + const invocationId = request.invocationId ?? this.providers.newId(); + const runId = request.runId ?? this.providers.newId(); // 1. Preflight (injectable gate). On failure we admit no invocation: no // context, no user event, no flow dispatch. @@ -187,10 +197,11 @@ export class RuntimeRunner { events.push(buildUserEvent(ctx, request)); const flowInput = buildFlowInput(request); - // 5. Dispatch to the flow and collect canonical events. The first - // terminal event ends the result; events emitted after it are not - // collected. A thrown error or a non-completed terminal status maps - // the result to 'failed'. + // 5. Dispatch to the flow and collect canonical events. By default the + // first terminal event ends collection; when stopOnTerminal is false, + // keep draining while remembering any non-completed terminal status. + // A thrown error or a non-completed terminal status maps the result + // to 'failed'. let failure: InvocationFailure | undefined; let terminalSeen = false; try { @@ -198,8 +209,10 @@ export class RuntimeRunner { events.push(ev); if (isTerminalRuntimeEvent(ev)) { terminalSeen = true; - failure = failureFromTerminalEvent(ev); - break; + failure ??= failureFromTerminalEvent(ev); + if (this.stopOnTerminal) { + break; + } } } } catch (error) { diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 9deae6258d..54713d05de 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -52,6 +52,15 @@ import type { AgentBackend } from './ai-sdk-backend.js'; import type { RunTraceRecorder } from './run-trace.js'; import { AgentRun, type AgentRunActiveSession, type AgentRunLineage } from './agent-run.js'; import { classifyAgentRunRecovery, type AgentRunRecoveryDecision } from './agent-run-recovery.js'; +import { + createSessionEventMapMemory, + mapSessionEventToRuntimeEvent, +} from './ai-sdk-flow.js'; +import type { + InvocationResult, + InvocationSource, +} from './invocation-context.js'; +import { RuntimeRunner } from './runtime-runner.js'; export interface StopSessionInput { source?: 'stop_button'; @@ -119,6 +128,8 @@ export interface SessionManagerDeps { backends: BackendRegistry; newId: () => string; now: () => number; + runtimeSource?: InvocationSource; + runtimeInvocationObserver?: (result: InvocationResult) => void | Promise; } interface ActiveSession extends AgentRunActiveSession { @@ -313,12 +324,11 @@ export class SessionManager { * (desktop main) is expected to forward the events to the renderer over * the IPC bridge. * - * Phase 1 vertical (§9): - * 1. Append UserMessage to JSONL + flush. - * 2. Lock connection (set connectionLocked=true) if not already. - * 3. Lookup or build the AgentBackend for this session. - * 4. backend.send(input) → forward events. - * 5. Update lastMessageAt + hasUnread when complete. + * Runtime v2 bridge: + * 1. Create one AgentRun, which remains the persistence/ledger owner. + * 2. Run that AgentRun through RuntimeRunner for canonical RuntimeEvents. + * 3. Forward the original SessionEvents to callers unchanged. + * 4. Drain the AgentRun stream fully so stop/abort cleanup semantics stay intact. */ async *sendMessage( sessionId: string, @@ -344,7 +354,70 @@ export class SessionManager { this.appendTurnState(targetSessionId, turnId, status, lineage, options), }, }); - yield* run.execute(); + + const sessionEvents = new AsyncEventQueue(); + const abortController = new AbortController(); + let agentRunIterator: AsyncIterator | undefined; + let flowDone = false; + const runner = new RuntimeRunner({ + providers: { newId: this.deps.newId, now: this.deps.now }, + stopOnTerminal: false, + flow: { + run: async function* (ctx, request) { + const memory = createSessionEventMapMemory(); + agentRunIterator = run.execute()[Symbol.asyncIterator](); + try { + while (!request.abortSignal?.aborted) { + const next = await agentRunIterator.next(); + if (next.done) break; + if (request.abortSignal?.aborted) break; + await sessionEvents.push(next.value); + yield mapSessionEventToRuntimeEvent(next.value, ctx, memory); + } + } catch (error) { + if (!isAsyncEventQueueClosed(error)) { + sessionEvents.fail(error); + } + throw error; + } finally { + flowDone = true; + if (request.abortSignal?.aborted) { + await agentRunIterator.return?.().catch(() => undefined); + } + sessionEvents.close(); + } + }, + }, + }); + const runnerResult = runner.run({ + sessionId, + runId: run.runId, + turnId: run.turnId, + text: input.text, + source: this.deps.runtimeSource ?? 'desktop', + lineage: run.lineage, + abortSignal: abortController.signal, + }).then(async (result) => { + await this.deps.runtimeInvocationObserver?.(result); + return result; + }, (error) => { + sessionEvents.fail(error); + throw error; + }); + + try { + for await (const event of sessionEvents) { + yield event; + } + await runnerResult; + } finally { + if (!flowDone) { + abortController.abort(); + sessionEvents.close(); + await agentRunIterator?.return?.().catch(() => undefined); + } + await runnerResult.catch(() => undefined); + } } async stopSession(sessionId: string, input: StopSessionInput = {}): Promise { @@ -813,6 +886,90 @@ function normalizeStopSessionSource(source: StopSessionInput['source'] | undefin } } +class AsyncEventQueueClosed extends Error { + constructor() { + super('Async event queue closed'); + this.name = 'AsyncEventQueueClosed'; + } +} + +function isAsyncEventQueueClosed(error: unknown): boolean { + return error instanceof AsyncEventQueueClosed; +} + +interface AsyncEventQueueEntry { + value: T; + delivered: () => void; + rejected: (error: unknown) => void; +} + +class AsyncEventQueue implements AsyncIterable { + private readonly values: Array> = []; + private readonly waiters: Array<{ + resolve: (entry: AsyncEventQueueEntry | undefined) => void; + reject: (error: unknown) => void; + }> = []; + private closed = false; + private failure: unknown; + + [Symbol.asyncIterator](): AsyncIterator { + return this.consume()[Symbol.asyncIterator](); + } + + push(value: T): Promise { + if (this.failure) return Promise.reject(this.failure); + if (this.closed) return Promise.reject(new AsyncEventQueueClosed()); + return new Promise((resolve, reject) => { + const entry = { value, delivered: resolve, rejected: reject }; + const waiter = this.waiters.shift(); + if (waiter) { + waiter.resolve(entry); + return; + } + this.values.push(entry); + }); + } + + fail(error: unknown): void { + if (this.failure) return; + this.failure = error; + for (const value of this.values.splice(0)) value.rejected(error); + for (const waiter of this.waiters.splice(0)) waiter.reject(error); + } + + close(): void { + if (this.closed) return; + this.closed = true; + const closed = new AsyncEventQueueClosed(); + for (const value of this.values.splice(0)) value.rejected(closed); + for (const waiter of this.waiters.splice(0)) waiter.resolve(undefined); + } + + private async *consume(): AsyncIterable { + while (true) { + const entry = await this.nextEntry(); + if (!entry) return; + try { + yield entry.value; + } finally { + entry.delivered(); + } + } + } + + private nextEntry(): Promise | undefined> { + if (this.values.length > 0) { + const next = this.values.shift()!; + return Promise.resolve(next); + } + if (this.failure) return Promise.reject(this.failure); + if (this.closed) return Promise.resolve(undefined); + return new Promise | undefined>((resolve, reject) => { + this.waiters.push({ resolve, reject }); + }); + } +} + // Re-export the suppressed-unused types so this file is the canonical home // for them. (Avoids TS "imported but unused" warnings.) export type {