# Turn Runtime Technical Specification Status: implemented and live. All chat, background, and knowledge callers run on this runtime; the legacy runs runtime (`src/runtime/legacy/`) remains only for code-mode sessions and the mini-apps host API, and is deleted as a unit when those migrate. The companion session layer is specified in `session-design.md`. This document specifies a new turn-oriented agent loop for `@x/core`. It is intended to replace the behavioral responsibilities of the current run runtime eventually, but migration and replacement are explicitly outside the initial implementation scope. The design was developed independently of sessions. Session storage, conversation ordering, queued user messages, context compaction, and selection of the next active turn will be designed separately. ## 1. Goals The turn runtime must: 1. Execute one complete agent turn from one user input. 2. Repeatedly call a model and process tool calls until the model produces a final response, the turn suspends, or the turn reaches a terminal outcome. 3. Persist every durable fact to one append-only JSONL file per turn. 4. Support turns that do not belong to a session. 5. Support suspension for human permission decisions and externally executed asynchronous tools. 6. Recover deterministically from the durable log after a process restart. 7. Keep all runtime dependencies explicit and injectable. 8. Keep the core loop small and focused on flow control. 9. Expose normalized live events without coupling turn correctness to event consumption. 10. Make historical turns reconstructable for the UI and other consumers. ## 2. Non-goals The first implementation will not include: - Session storage or session scheduling. - Queued user messages. - Enforcement of the session-level one-active-turn rule. - Context pruning or compaction. - Agent-as-tool behavior. An agent tool handler may implement this separately, but the parent turn sees it only as a normal sync or async tool. - Inline, caller-supplied agents. - Mid-turn agent, model, prompt, or tool-set switching. - Tool argument validation beyond what the model/tool integration already provides. - External-input idempotency keys or duplicate-delivery handling. - Automatic model-call retries. - Cross-process locking. - Explicit `fsync` after every append. - Automatic repair of corrupt JSONL files. - Stream backpressure or bounded event buffering. - A shared UI timeline selector. - Migration from the current run system. ## 3. Core terminology ### 3.1 Turn A turn starts with one intentional user message and contains all agent work caused by that message: - Model requests and responses. - Permission checks and decisions. - Sync tool executions. - Async tool requests, progress, and results. - Repeated model calls after tool results. - Final completion, failure, cancellation, or suspension. Permission decisions and async tool results do not create new turns. They advance the existing turn. ### 3.2 Session A session is an optional higher-level conversation and scheduling concept. A turn has `sessionId: string | null`, but turn execution never reads session state. The session layer will eventually own: - Turn ordering. - One-active-turn enforcement. - Queued user messages. - Context construction from prior turns. - Context pruning and compaction. - Session-wide permission grants. ### 3.3 Agent An agent is a reusable execution preset consisting primarily of: - A system prompt. - A model/provider selection. - A set of tools. The current codebase follows this model across Copilot, background-task, live-note, knowledge-sync, and user-defined agents. An agent is resolved once when a turn is created. The resulting execution snapshot is immutable for the lifetime of the turn. ## 4. Architectural principles ### 4.1 The JSONL log is the source of truth There is one JSONL file per turn. Every line contains one validated JSON event. State is always reconstructed by replaying the file from the beginning. There are no mutable summary files, checkpoints, or storage sidecars. ### 4.2 Events describe facts, not current process liveness The durable log must never claim that a turn is currently running. A process can crash at any time and make such a claim false. Durable events may state that: - A model request was prepared and requested. - A tool invocation was requested. - A result was received. - A turn suspended. - A turn completed, failed, or was cancelled. Whether an `advanceTurn` invocation is currently active is ephemeral application state communicated through the application bus. ### 4.3 The turn runtime has no hidden dependencies `TurnRuntime` is a class used as an immutable dependency container. It holds no mutable per-turn execution state. All active turn state is reconstructed from the repository inside each invocation. Awilix constructs the singleton using PROXY/destructured-object injection. `TurnRuntime` must never resolve dependencies from the Awilix container itself. ### 4.4 Durable barriers precede side effects When a durable event represents intent to perform an external side effect, the event is successfully appended before the side effect begins. Examples: - Append `model_call_requested` before calling the model. - Append `tool_invocation_requested` before invoking a sync tool or exposing an async tool request. This does not provide exactly-once execution. It provides conservative recovery semantics after ambiguous interruptions. ### 4.5 Execution order and model-facing order are separate Tool calls may complete in any order. The next model request must always contain tool results in the original order emitted by the model. Sync tools in one batch execute concurrently: invocation events are appended serially in source order before any execution starts, then all sync executions run at once, each appending its progress and result as it lands. Result order in the log is therefore physical completion order and is not deterministic across runs; any given log still replays deterministically. Async tools naturally complete independently. No behavior may rely on physical completion order. Durable appends are serialized through a single internal queue per invocation: the reduce → persist → stream ritual runs to completion for one batch of events before the next begins, so file order, in-memory order, and stream order are identical by construction even while executions overlap. The reduce step comes first as a validation gate: the batch is reduced against the in-memory history before anything is written, so an illegal append (for example, a misbehaving tool reporting progress after its terminal result) rejects in memory for its caller only and never becomes durable. A persisted illegal event would make every future read of the file fail and, through context references (section 6.6), block the whole session chain behind it. ## 5. Storage design ### 5.1 File location Turn files live under: ```text WorkDir/storage/turns/YYYY/MM/DD/.jsonl ``` The existing time-based `IMonotonicallyIncreasingIdGenerator` produces IDs such as: ```text 2025-11-11T04-36-29Z-0001234-000 ``` The deterministic path is therefore: ```text WorkDir/storage/turns/2025/11/11/2025-11-11T04-36-29Z-0001234-000.jsonl ``` The repository validates the ID format before deriving the path. It extracts the UTC `YYYY-MM-DD` prefix and rejects malformed or path-like values. ### 5.2 File rules - The first line is always `turn_created`. - The first line contains `schemaVersion: 1`. - Every event contains `turnId` and an ISO timestamp `ts`. - Physical line order is authoritative event order. - There are no generic event IDs or sequence numbers. - Domain identifiers such as `toolCallId` and `modelCallIndex` remain explicit. - Every event must be JSON-serializable. - `undefined`, `BigInt`, functions, cyclic data, class instances, and raw `Error` objects are rejected. - Reads validate every line strictly. - Any malformed line, including a malformed final line, makes the turn corrupt. - The repository does not truncate, repair, or skip malformed lines. - Unknown schema versions and unknown event types fail loudly. - Appends are awaited but are not explicitly flushed with `fsync` initially. ### 5.3 Repository contract ```ts interface ITurnRepo { create(event: TurnCreated): Promise; read(turnId: string): Promise; append(turnId: string, events: TurnEvent[]): Promise; withLock(turnId: string, fn: () => Promise): Promise; } ``` Rules: - `create` fails if the target file already exists. - `append` validates events before writing. - `read` validates every line and verifies event `turnId` values. - `withLock` provides in-process per-turn exclusion. - Cross-process coordination is out of scope. - Whether lock contention waits or reports busy is an implementation detail for the first version. - Listing, deletion, session lookup, and presentation metadata are not required by the loop-facing repository contract. ## 6. Agent resolution ### 6.1 Caller input `createTurn` accepts a registered agent ID and an optional atomic model override: ```ts type JsonPrimitive = string | number | boolean | null; type JsonValue = | JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; interface ModelDescriptor { provider: string; model: string; } interface RequestedAgent { agentId: string; overrides?: { model?: ModelDescriptor; // Opaque composition hints interpreted by the agent resolver (e.g. // work-dir id, voice/search/code modes). Persisted verbatim for audit. // The resolver decides which keys affect system-prompt bytes; callers // should keep prompt-affecting inputs session-sticky to preserve // provider prefix caching across turns. composition?: JsonValue; }; } ``` Provider and model are overridden together. Independent partial provider/model overrides are not supported. Inline resolved agents are not supported initially because there is no current application use case. ### 6.2 AgentResolver responsibilities `IAgentResolver` absorbs the current agent-assembly responsibilities: - Built-in agent selection currently handled by `loadAgent`. - User-defined agent loading from `IAgentsRepo`. - Dynamic Copilot, background-task, live-note, and knowledge-agent builders. - Agent-specific system-prompt augmentation. - Agent Notes, work-directory context, and similar prompt assembly. - Model override, agent configuration, and application-default precedence. - Tool attachment resolution. - Tool availability filtering. - Creation of immutable serializable tool descriptors. ```ts interface ResolvedAgent { agentId: string; systemPrompt: string; model: ModelDescriptor; tools: ToolDescriptor[]; } interface IAgentResolver { resolve(agent: RequestedAgent): Promise; } ``` The resolved system prompt is the final byte-for-byte string used by model requests. The turn loop never appends additional instructions. Model resolution precedence is: 1. `createTurn` model override. 2. Model/provider configured on the agent. 3. Application default model/provider. ### 6.3 Tool identity Model-facing names and runtime implementation identities are distinct: ```ts interface ToolDescriptor { toolId: string; name: string; description: string; inputSchema: JsonValue; execution: "sync" | "async"; requiresHuman: boolean; } ``` - `toolId` is the stable registry lookup identity. - `name` is the name advertised to the model. - The distinction supports aliases and MCP-backed tools. - The descriptor is fully snapshotted at turn creation. - `advanceTurn` may not silently add, remove, or modify tools. ### 6.4 Requested and resolved agent snapshots `turn_created` stores both caller intent and resolved output: ```ts agent: { requested: RequestedAgent; resolved: ResolvedAgent; } ``` This makes defaults, aliases, and overrides visible during debugging. If agent resolution fails, `createTurn` rejects without creating a turn file. ### 6.5 Model and tool implementation materialization Agent resolution and live runtime materialization are separate responsibilities, but all dependencies are constructor-injected: - `IAgentResolver` creates the immutable serializable snapshot during `createTurn`. - `IModelRegistry` resolves the persisted model descriptor to a live AI SDK model during `advanceTurn`. - `IToolRegistry` resolves persisted tool descriptors to live implementations during `advanceTurn`. This lets an old turn resume from its snapshot even if the registered agent definition later changes. ### 6.6 Context references and materialization Session turns do not inline their conversation prefix. Context is either an inline message array or a reference to the immediately preceding turn: ```ts type TurnContext = | { previousTurnId: string } | ConversationMessage[]; ``` - `{ previousTurnId }` references an immutable prior turn. The materialized context is that turn's closed transcript: its own materialized context, followed by its input and every message it produced, including synthetic closure results for failed, cancelled, or interrupted work. - Inline arrays are used by standalone turns and by callers that assemble context themselves (for example after a future compaction). Materialization is performed by an injected resolver: ```ts interface IContextResolver { resolve(context: TurnContext): Promise; } ``` Rules: - Resolution happens at the start of every `advanceTurn` invocation, from durable state only. Normal execution and crash recovery therefore share one code path. - Resolution traverses references recursively until an inline context terminates the chain. - Turns with any terminal status participate in resolution. Their transcripts are structurally complete because unresolved work receives synthetic results before a terminal event is appended. - A reference to a missing or corrupt turn file is an infrastructure error. It rejects the execution and does not append `turn_failed`. - The reducer treats `context` as opaque data and never resolves references. The app wires the resolver through a decorator (`context-elision.ts`) that applies transmit-time elision to the materialized prefix: tool results from prior turns above a size threshold are replaced with a short placeholder telling the model to re-run the tool if it needs the output; inline image parts from prior turns (video-mode webcam and screen-share frames) are replaced with a text part recording how many frames of each kind were dropped; and middle-pane note snapshots on prior-turn user messages keep their kind and path but have their content replaced with a placeholder pointing at the still-readable file. The durable log is untouched; only the transmitted bytes change. Elision is a pure function of each message, so resolved prefixes stay byte-stable across calls (provider prefix caches keep working), and the current turn's own messages never pass through the resolver, so in-flight tool results and just-captured frames are always sent verbatim. Policy lives in `config/context.json` (`elideHistoricToolResults`, default true; `elideHistoricToolResultsThresholdChars`, default 2500; `elideHistoricImages`, default true; `elideHistoricMiddlePaneContent`, default true). The inspect CLI composes through the same decorated resolver. Caveat: elision makes the composed payload a function of the durable log PLUS the config in effect at compose time — the one deliberate exception to "recomposition from durable state alone" (§8.3). Within a turn this is harmless (policy is loaded once per execution, at resolve time), but inspecting an old turn after a config change may show different prefix bytes than were transmitted; the inspect CLI prints the active policy so the divergence is visible. If exact-bytes replay ever becomes a hard requirement, record the applied policy on the turn and compose from that. Relatedly, Anthropic-family requests get cache_control breakpoints stamped at the transport layer (`models/prompt-caching.ts`, applied inside the model registry bridge just before streamText). These are provider metadata only — message content is untouched, nothing is persisted, and inspect does not render them; non-Anthropic requests pass through byte-identical. ### 6.7 Agent snapshot inheritance Session turns whose resolved system prompt and tool set are byte-identical to the context predecessor's materialized snapshot persist an inherited form instead of repeating ~tens of KB per turn: ```ts type ResolvedAgentSnapshot = | ResolvedAgent | { agentId: string; model: ModelDescriptor; inheritedFrom: string }; ``` Rules: - Inheritance is decided at `createTurn` by comparing against the predecessor's materialized snapshot; any difference in system prompt or tools persists the full snapshot. An unreadable predecessor falls back to the full snapshot. - The model stays concrete: it is small, the session index denormalizes it, and a mid-session model switch must not block inheritance. On materialization the inherited record's own `agentId`/`model` win; only the heavy fields come from the chain base. - `inheritedFrom` must equal the turn's `context.previousTurnId` (reducer invariant); materialization walks the chain with cycle detection (`IContextResolver.resolveAgent`), exactly like context references. - The reducer treats inherited snapshots as opaque: tool identity for extraction arrives via `tool_invocation_requested` events instead of the descriptor lookup. ## 7. Turn creation schema The authoritative initial event is: ```ts interface TurnCreated { type: "turn_created"; schemaVersion: 1; turnId: string; ts: string; sessionId: string | null; agent: { requested: RequestedAgent; resolved: ResolvedAgent; }; context: TurnContext; input: UserMessage; config: { autoPermission: boolean; humanAvailable: boolean; maxModelCalls: number; }; } ``` Rules: - `sessionId` is explicitly nullable. - `context` is either an inline message array or a reference to the previous turn (section 6.6). - Inline context contains prior user, assistant, and tool messages only. - System-role messages in inline context are rejected. - A context reference is resolved by the injected context resolver during `advanceTurn`, never at creation and never by the reducer. - The resolved agent owns the single authoritative system prompt. - `input` is the user message that defines this turn boundary. - `autoPermission` defaults to `false` before persistence. - `humanAvailable` is required explicitly. - `maxModelCalls` defaults to `20` before persistence. - Persisted values are fully resolved and immutable. The capability is named `humanAvailable`, not `headless`. `headless` describes one deployment mode, while this flag states the exact runtime capability used by permission fallback and human-dependent tools. The canonical system prompt is written once in `turn_created`. It may be deliberately duplicated inside each `model_call_requested.request` because that event records the exact model input for auditing. ## 8. Shared event schemas All durable serializable event schemas live in `@x/shared`. The repository, runtime, live stream implementation, and filesystem behavior live in `@x/core`. ### 8.1 Base event ```ts interface BaseTurnEvent { turnId: string; ts: string; } ``` ### 8.2 Usage Usage means token usage only. Monetary pricing and cost calculation are out of scope. ```ts interface TurnUsage { inputTokens?: number; outputTokens?: number; totalTokens?: number; reasoningTokens?: number; cachedInputTokens?: number; } ``` Usage is stored per completed model call and aggregated across the turn. ### 8.3 Model request events Every AI SDK `streamText` invocation performs exactly one model step. Tool execution is controlled manually by the turn loop. ```ts // Compact string refs, so raw JSONL reads naturally: // "context" the inline context block // "input" the turn's user message // "assistant:" → that model_call_completed's message // "toolResult:" → that tool_result type ModelRequestMessageRef = string; interface ModelRequest { contextRef?: { previousTurnId: string }; // call 0 only (cross-turn prefix) messages: ModelRequestMessageRef[]; // what is NEW since the previous call parameters: Record; } interface ModelCallRequested extends BaseTurnEvent { type: "model_call_requested"; modelCallIndex: number; request: ModelRequest; } ``` `modelCallIndex` starts at `0` and increments monotonically for each primary agent model call in the turn. `request` is a list of references into the turn's own events: every referenced byte exists exactly once in the file. A request records only what is new since the previous model call — call 0 is `["context"?, "input"]` (context only when inline and nonempty; cross-turn prefixes ride `contextRef`); call N is `["assistant:N-1", …that batch's "toolResult:" refs in source order]`; a re-issued call after an interruption is `[]`. The system prompt and tool set are not repeated: they are byte-identical to `turn_created.agent.resolved` by construction. The exact provider payload is rebuilt by the request composer (`composeModelRequest` in core): resolved system prompt, wrapped tool definitions, the materialized cross-turn prefix, and every request's references resolved against the turn's events, all passed through the model bridge's `encodeMessages` (the deterministic structural→wire conversion — user-message context weaving, attachment rendering, tool-result enveloping). The loop transmits exactly the composer's output, and debugging/audit calls the same function, so the durable file plus the composer reproduce the wire bytes and the two views cannot diverge. Requests exclude credentials, auth headers, functions, model objects, and transport objects. `parameters` holds only canonical, provider-agnostic generation knobs. The model bridge whitelists what it forwards (`temperature`, `topP`, `maxOutputTokens`, `providerOptions`) and maps `reasoningEffort` (`"low" | "medium" | "high"`, stamped from `turn_created.config` onto every call) into provider-specific options at invoke time — transport-only, like prompt caching, so a persisted turn replays correctly on a different model. The name `requested` is intentional. The event proves durable intent, not that the provider definitely received the request. ### 8.4 Provider step events Normalized AI SDK step events may be persisted for debugging: ```ts interface ModelStepEvent extends BaseTurnEvent { type: "model_step_event"; modelCallIndex: number; event: DurableLlmStepStreamEvent; } ``` The wrapper may contain normalized events such as: - Text start/end. - Reasoning start/end. - Completed tool calls. - Finish-step metadata and usage. - Normalized provider errors. Raw text and reasoning delta events are not durable. Partial tool-argument fragments are also not required; completed parsed tool calls are durable. There is one `TurnEvent` union. Events are not permanently classified as "state" or "diagnostic" because reducer usage will evolve. The reducer decides which known events currently affect derived state. ### 8.5 Completed and failed model calls ```ts interface ModelCallCompleted extends BaseTurnEvent { type: "model_call_completed"; modelCallIndex: number; message: AssistantMessage; finishReason: string; usage: TurnUsage; providerMetadata?: JsonValue; } interface ModelCallFailed extends BaseTurnEvent { type: "model_call_failed"; modelCallIndex: number; error: string; } ``` `model_call_completed` is the canonical completed model response even when its content duplicates provider step events. Any successfully completed assistant response without tool calls completes the turn, including responses whose finish reason is `length` or `content-filter`. Provider and stream failures fail the turn. Only the primary model calls directly controlled by the turn loop are recorded as model calls. Internal model calls hidden inside the permission classifier or tool implementations belong to those dependencies and are not turn-loop model calls. ## 9. Permission model ### 9.1 Dependencies An injected permission checker determines whether a tool call needs permission: ```ts interface PermissionCheckAllowed { required: false; } interface PermissionCheckRequired { required: true; request: JsonValue; } interface IPermissionChecker { check(input: PermissionCheckInput): Promise< PermissionCheckAllowed | PermissionCheckRequired >; } ``` Tool-specific policy, command analysis, filesystem boundaries, and allowlists remain outside the loop. The real checker bridge implements that policy from per-tool declarations in the builtin catalog (`tools/types.ts`) and fails closed: any tool without an explicit `"none"` declaration — undeclared builtins, `mcp:*` attachments on user agents, unknown toolId families — requires permission. Composio and MCP executions produce family-specific request payloads (the shared `ToolPermissionMetadata` kinds); everything else falls back to a generic `{kind: "tool"}` request. The audited set of gated builtins is pinned by a catalog test. When automatic permission is enabled, the injected classifier handles all permission-required calls from one model response in one batch: ```ts interface PermissionClassification { toolCallId: string; decision: "allow" | "deny" | "defer"; reason: string; } interface PermissionClassificationBatch { turnId: string; // Conversation context: resolved context plus current-turn settled // messages (the pending batch's tool results are not yet terminal). messages: ConversationMessage[]; requests: PermissionClassificationInput[]; } interface IPermissionClassifier { classify( batch: PermissionClassificationBatch, signal: AbortSignal, ): Promise; } ``` The classifier's internal implementation and internal model calls are opaque to the turn loop. ### 9.2 Permission events ```ts interface ToolPermissionRequired extends BaseTurnEvent { type: "tool_permission_required"; toolCallId: string; toolName: string; request: JsonValue; checkerError?: string; } interface ToolPermissionClassified extends BaseTurnEvent { type: "tool_permission_classified"; toolCallId: string; decision: "allow" | "deny" | "defer"; reason: string; } interface ToolPermissionClassificationFailed extends BaseTurnEvent { type: "tool_permission_classification_failed"; toolCallIds: string[]; error: string; } interface ToolPermissionResolved extends BaseTurnEvent { type: "tool_permission_resolved"; toolCallId: string; decision: "allow" | "deny"; source: "classifier" | "human" | "human_unavailable"; reason?: string; metadata?: JsonValue; } ``` Only `tool_permission_resolved` is an effective execution decision. ### 9.3 Permission behavior matrix `autoPermission` and `humanAvailable` are orthogonal: | autoPermission | humanAvailable | Behavior | | --- | --- | --- | | false | true | Ask human and suspend. | | false | false | Deny and continue with an error tool result. | | true | true | Classify; deferred calls ask human. | | true | false | Classify; deferred calls are denied. | Classifier decisions behave as follows: - `allow`: resolve allow and execute/dispatch immediately. - `deny`: resolve deny and create an error tool result. - `defer`: ask a human if available; otherwise deny. - Classifier failure or omitted decision: record failure and treat as `defer`. If the permission checker itself throws, the loop fails closed: - Record the checker error as a permission-required event. - Ask a human if available. - Otherwise create a denied error result. - Never execute automatically. ### 9.4 Partial decisions Permission resolution is per tool call, not a batch barrier. When one permission decision arrives: - An allowed sync tool executes immediately. - An allowed async tool is exposed immediately. - A denied tool receives an immediate error result. - Other unresolved permission requests remain pending. - The turn suspends again if any external inputs remain outstanding. Permission responses are accepted one at a time. The caller is never required to batch external inputs. ### 9.5 Permission scopes The turn consumes only the effective allow/deny decision for the current tool call. Session-wide or global approval grants are persisted by the caller before advancing the turn. Scope may be retained as audit metadata, but the loop does not interpret or apply it. ## 10. Tool model ### 10.1 Runtime tools Sync and async execution are immutable tool metadata: ```ts interface ToolExecutionContext { turnId: string; toolCallId: string; signal: AbortSignal; reportProgress(progress: JsonValue): Promise; } interface SyncRuntimeTool { descriptor: ToolDescriptor & { execution: "sync" }; execute( input: JsonValue, context: ToolExecutionContext, ): Promise; } interface AsyncRuntimeTool { descriptor: ToolDescriptor & { execution: "async" }; } type RuntimeTool = SyncRuntimeTool | AsyncRuntimeTool; ``` An async tool has no in-process executor. Its request is exposed externally and its progress/result is later supplied through `advanceTurn`. `ask-human` is an ordinary async tool. Other externally resolved async tools may be added without changing the loop. ### 10.2 Human-dependent tools `requiresHuman` is generic metadata, not a hard-coded `ask-human` name check. If `requiresHuman` is true and `humanAvailable` is false: - Append the tool invocation request. - Append an immediate runtime error result such as "Human input is unavailable for this turn." - Continue the tool batch. `humanAvailable` affects human-dependent tools and permission fallback. It does not disable other async tools. ### 10.3 Tool events ```ts interface ToolInvocationRequested extends BaseTurnEvent { type: "tool_invocation_requested"; toolCallId: string; toolId: string; toolName: string; execution: "sync" | "async"; input: JsonValue; } interface ToolProgress extends BaseTurnEvent { type: "tool_progress"; toolCallId: string; source: "sync" | "async"; progress: JsonValue; } interface ToolResultData { output: JsonValue; isError: boolean; metadata?: JsonValue; } interface ToolResult extends BaseTurnEvent { type: "tool_result"; toolCallId: string; toolName: string; source: "sync" | "async" | "runtime"; result: ToolResultData; } ``` `runtime` results cover: - Permission denial. - Unknown tools. - Invalid or unusable model tool calls. - Human unavailability. - Cancellation. - Interrupted sync execution. A tool result may exist without an invocation event when execution is rejected before dispatch, such as permission denial or unknown tool handling. ### 10.4 Progress Any sync or async tool may report progress. - Sync tools call and await `reportProgress`. - The loop appends `tool_progress` before resolving that callback. - Async progress arrives as one external `advanceTurn` input. - Progress is durable and informational. - Progress does not satisfy the pending tool call. - Progress after a terminal tool result is rejected. - Backend flow control may ignore progress while UI reducers retain it. No progress write queue or non-blocking batching is included initially. ### 10.5 Tool scheduling and ordering For one completed assistant response: 1. Identify all model-produced tool calls and their source order. 2. Determine permission requirements. 3. Apply automatic permission decisions when enabled. 4. Advance each tool independently as its permission is resolved. 5. Record invocations for allowed tools serially in source order (sync and async alike), then execute all allowed sync tools concurrently. There is no concurrency cap and no per-tool serialization; tools that share state must tolerate racing (or reject stale operations, as file edits do via their search/replace precondition). Secondary kill-path state (the abort registry) is scoped per tool call, never per turn. 6. Expose allowed async tool requests. 7. Suspend when any permissions or async results remain outstanding. 8. Once all calls have terminal results, build the next model request with results in original model-call order. The model is not called while any tool in the current batch lacks a terminal result. ## 11. Suspension and external inputs ### 11.1 Suspension event ```ts interface TurnSuspended extends BaseTurnEvent { type: "turn_suspended"; pendingPermissions: Array<{ toolCallId: string; toolName: string; request: JsonValue; }>; pendingAsyncTools: Array<{ toolCallId: string; toolId: string; toolName: string; input: JsonValue; }>; usage: TurnUsage; } ``` Before an invocation returns suspended, it appends a full snapshot of currently pending external work. Calling `advanceTurn` with no input when the turn is already suspended returns the current suspended outcome without appending a duplicate event. After a valid external input, if work remains pending, a new suspension snapshot is appended before returning. ### 11.2 One input per invocation ```ts type TurnExternalInput = | { type: "permission_decision"; toolCallId: string; decision: "allow" | "deny"; metadata?: JsonValue; } | { type: "async_tool_progress"; toolCallId: string; progress: JsonValue; } | { type: "async_tool_result"; toolCallId: string; result: ToolResultData; } | { type: "cancel"; reason?: string; }; ``` Each `advanceTurn` accepts at most one input. It validates that input against the current durable pending state, persists the resulting semantic event, advances as far as possible, and settles again. Inputs targeting calls that are no longer pending are rejected. No caller-generated input IDs or duplicate-delivery semantics are included. ### 11.3 Partial async results Async progress and results may arrive independently and in any order. - Persist each input immediately. - Keep the turn suspended while any permission or async result remains pending. - Do not initiate the next model call until every original tool call has a terminal result. ## 12. Terminal events ```ts interface TurnCompleted extends BaseTurnEvent { type: "turn_completed"; output: AssistantMessage; finishReason: string; usage: TurnUsage; } interface TurnFailed extends BaseTurnEvent { type: "turn_failed"; error: string; code?: string; usage: TurnUsage; } interface TurnCancelled extends BaseTurnEvent { type: "turn_cancelled"; reason?: string; usage: TurnUsage; } ``` The completed event deliberately duplicates the final assistant message, finish reason, and aggregate usage so the final line is a self-contained completion summary. Errors begin as a single descriptive string. Structured details may be added later when they are naturally available upstream; the loop does not invent structure by parsing arbitrary errors. `code` is an optional machine-readable discriminator for failures that callers must tell apart. This specification defines `"model-call-limit"` (section 20); other codes may be added when a caller concretely needs them. Terminal states are immutable: - Events may not be appended after completion, failure, or cancellation. - Re-advancing a terminal turn returns its existing outcome. - Retrying a failed turn means creating a new turn with a new ID. ## 13. Turn event union and live deltas ```ts type TurnEvent = | TurnCreated | ModelCallRequested | ModelStepEvent | ModelCallCompleted | ModelCallFailed | ToolPermissionRequired | ToolPermissionClassified | ToolPermissionClassificationFailed | ToolPermissionResolved | ToolInvocationRequested | ToolProgress | ToolResult | TurnSuspended | TurnCompleted | TurnFailed | TurnCancelled; ``` Ephemeral model deltas are stream-only: ```ts interface TextDelta { type: "text_delta"; turnId: string; modelCallIndex: number; delta: string; } interface ReasoningDelta { type: "reasoning_delta"; turnId: string; modelCallIndex: number; delta: string; } type TurnStreamEvent = TurnEvent | TextDelta | ReasoningDelta; ``` All durable provider step events are appended before the loop reads the next provider stream event. This intentionally applies simple filesystem backpressure. Text and reasoning deltas bypass storage. ## 14. Derived turn state ### 14.1 One canonical reducer `@x/shared` owns one pure reducer used by core and renderer: ```ts function reduceTurn(events: TurnEvent[]): TurnState; ``` Properties: - Pure and deterministic. - Performs no I/O. - Performs no expensive external work. - Replays the full bounded turn log from the beginning. - Known events that do not currently affect state may be ignored. - Invalid event transitions throw a corruption/state error. - There is no incremental reducer API initially. Consumers may append a newly received durable event to their local event list and call `reduceTurn` again. ### 14.2 State shape ```ts interface TurnState { definition: TurnCreated; modelCalls: ModelCallState[]; toolCalls: ToolCallState[]; suspension?: TurnSuspended; terminal?: TurnCompleted | TurnFailed | TurnCancelled; usage: TurnUsage; } interface ModelCallState { index: number; request: ModelRequest; stepEvents: DurableLlmStepStreamEvent[]; response?: AssistantMessage; finishReason?: string; usage?: TurnUsage; error?: string; } interface ToolCallState { modelCallIndex: number; order: number; toolCallId: string; toolId?: string; toolName: string; input: JsonValue; execution?: "sync" | "async"; permission?: { required: ToolPermissionRequired; classification?: ToolPermissionClassified; resolved?: ToolPermissionResolved; }; invocation?: ToolInvocationRequested; progress: ToolProgress[]; result?: ToolResult; } ``` The exact implementation may refine these field names, but the state must retain enough information for both execution and historical presentation. ### 14.3 No durable or derived running status `TurnState` does not contain a single status field. Consumers derive what they need from: - `suspension`. - `terminal`. - Outstanding model and tool records. - Ephemeral application bus activity. This avoids encoding process liveness in durable or replayed state. ### 14.4 No transient streaming buffer `TurnState` contains only durable projections. It does not contain partial text or reasoning buffers. The renderer owns those ephemeral buffers while handling live deltas and discards them when a canonical model response arrives. ### 14.5 No shared timeline selector initially The renderer may interpret `modelCalls` and `toolCalls` directly. A shared semantic timeline selector can be added later if duplicated presentation logic becomes a concrete problem. ### 14.6 Historical read API ```ts interface Turn { turnId: string; events: TurnEvent[]; } interface ITurnRuntime { getTurn(turnId: string): Promise; } ``` `getTurn` is strictly read-only: - It reads and validates the JSONL file. - It returns the validated event list. - It never resumes work. - It never appends recovery records. - Callers use the shared reducer to obtain `TurnState`. Only `advanceTurn` may reconcile or advance execution. Prior session context remains inside `state.definition.context`. Historical UI for one turn should normally show the triggering input and activity produced by that turn, not repeat the prior context. Session presentation will handle composition later. ## 15. TurnRuntime API and dependency injection ### 15.1 Constructor ```ts interface TurnRuntimeDependencies { turnRepo: ITurnRepo; idGenerator: IMonotonicallyIncreasingIdGenerator; clock: IClock; agentResolver: IAgentResolver; modelRegistry: IModelRegistry; toolRegistry: IToolRegistry; contextResolver: IContextResolver; permissionChecker: IPermissionChecker; permissionClassifier: IPermissionClassifier; lifecycleBus: ITurnLifecycleBus; } class TurnRuntime implements ITurnRuntime { constructor({ turnRepo, idGenerator, clock, agentResolver, modelRegistry, toolRegistry, contextResolver, permissionChecker, permissionClassifier, lifecycleBus, }: TurnRuntimeDependencies); } ``` The injected clock exposes a simple deterministic timestamp source for unit tests. The existing ID generator remains responsible for turn IDs. Awilix registration is singleton-scoped: ```ts turnRepo: asClass(FSTurnRepo).singleton(), turnRuntime: asClass(TurnRuntime).singleton(), ``` Model, tool, permission, clock, and bus dependencies are also explicitly registered and injected. ### 15.2 Public API ```ts interface CreateTurnInput { agent: RequestedAgent; sessionId?: string | null; context: TurnContext; input: UserMessage; config: { autoPermission?: boolean; humanAvailable: boolean; maxModelCalls?: number; }; } interface ITurnRuntime { createTurn(input: CreateTurnInput): Promise; advanceTurn( turnId: string, input?: TurnExternalInput, options?: { signal?: AbortSignal }, ): TurnExecution; getTurn(turnId: string): Promise; } ``` Creation and execution remain separate operations. A turn may exist before its first model call. ### 15.3 Runtime dependency validation Before advancing, the runtime resolves live model and tool implementations from the persisted descriptors and validates compatibility. Missing or mismatched runtime dependencies: - Reject the execution. - Do not append `turn_failed`. - Leave the turn unchanged so the caller can fix its environment and retry. Cancel inputs are exempt from this validation: they are applied before dependency materialization (section 22), because cancellation is the terminal exit for environments that never come back. Provider failures after actual execution begins are modeled turn failures. ## 16. Hot TurnExecution stream ### 16.1 Shape ```ts interface TurnExecution { events: AsyncIterable; outcome: Promise; } ``` `advanceTurn` returns immediately with a hot execution object. Execution starts independently of event consumption. ### 16.2 Rationale A plain async generator would make execution pull-driven: - Never consuming it would mean execution never starts. - Slow consumers would pause the loop at every yield. - Abandoning a partially consumed iterator could leave execution paused and a lock held indefinitely. The hot stream prevents turn correctness and lock release from depending on an observer. ### 16.3 Initial stream semantics - One event consumer is assumed. - The caller may fan events out through its own event emitter or bus bridge. - Events produced by that `advanceTurn` invocation only are emitted. - Historical events are not replayed. - Durable events enter the stream only after successful persistence. - Text and reasoning deltas enter without persistence. - The stream uses a simple in-memory queue. - There is no backpressure or queue bound initially. - A slow or absent consumer may cause memory growth. - If the consumer closes, subsequent events are dropped. - Closing or abandoning event consumption does not cancel turn execution. - Cancellation is explicit through `AbortSignal` or a cancel input. - `outcome` may be awaited independently of `events`. ### 16.4 Outcome ```ts type TurnOutcome = | { status: "completed"; output: AssistantMessage; finishReason: string; usage: TurnUsage; } | { status: "suspended"; pendingPermissions: TurnSuspended["pendingPermissions"]; pendingAsyncTools: TurnSuspended["pendingAsyncTools"]; usage: TurnUsage; } | { status: "failed"; error: string; usage: TurnUsage; } | { status: "cancelled"; reason?: string; usage: TurnUsage; }; ``` ### 16.5 Infrastructure errors If trustworthy turn state cannot be established: - Event iteration terminates by throwing the infrastructure error. - `outcome` rejects with the same error. - Already persisted events remain in the file. - No synthetic `turn_failed` is appended unless the failure is explicitly a modeled turn failure. Infrastructure examples: - Corrupt JSONL. - Repository failure. - Lock failure. - Missing or mismatched injected runtime dependency. ## 17. Ephemeral application lifecycle events `TurnRuntime` publishes process-lifecycle events through the injected bus: ```ts interface TurnProcessingStart { type: "turn-processing-start"; turnId: string; } interface TurnProcessingEnd { type: "turn-processing-end"; turnId: string; } ``` These events are never written to `TurnRepo` and never replayed. The UI may maintain an in-memory set of active turn IDs. If the process crashes, that set disappears, which accurately reflects that no execution is known to be active. Lifecycle publication is observational and must not alter durable turn semantics. Live durable events and deltas are consumed from `TurnExecution` by the application and may be forwarded over the existing bus/IPC bridge. ### 17.1 Turn event bus Implemented alongside the lifecycle bus: the runtime publishes every turn's events to an injected `ITurnEventBus` (`event-hub.ts`), tagged with the turn's `sessionId`, regardless of who started the turn — session chat, headless runners, spawned sub-agents. This is the process-wide delivery spine; `TurnExecution` remains the single-consumer, invocation-scoped stream for the initiating caller. - `turn_created` is published by `createTurn` (it never flows through an execution stream); every other durable event is published by the advance loop immediately after its `stream.push`, inside the serialized commit ritual, so bus order equals file order. - Durable events carry `offset`, their 1-based line index in the turn file (`this.events.length` at commit time, which is absolute because each invocation re-reads the full log). A late subscriber joins a live turn by subscribing first, fetching the `getTurn` snapshot, and discarding bus events with `offset <= snapshot.length` — no gaps, no duplicates, no sequence numbers in the durable schema. - Text/reasoning deltas are published without an offset (they are not durable). The app-layer IPC bridge (`turns:events`) broadcasts durable events to every window; deltas are forwarded only to windows that declared they are watching that turn (`turns:subscribe` / `turns:unsubscribe`, a per-webContents registry in the app layer). - The bus is ephemeral and observational, like the lifecycle bus: listener errors are swallowed, nothing durable depends on delivery, and a crash losing listeners accurately reflects that no execution is known active. ## 18. Main execution algorithm At a high level, `advanceTurn` performs: ```text 1. Start hot execution task. 2. Acquire per-turn lock. 3. Publish turn-processing-start. 4. Read and validate JSONL. 5. Reduce events to TurnState. 6. Validate terminal state and the optional input. 7. If the input is a cancel, cancel immediately — live dependencies are never materialized for cancellation (section 22). 8. Materialize context through the injected context resolver and validate runtime dependencies. 9. Apply the optional single external input. 10. Repeatedly advance deterministic work: a. Recover from the current durable boundary. b. Resolve permissions. c. Execute eligible sync tools. d. Expose eligible async tools. e. If external work remains, append turn_suspended and settle. f. If a tool batch is complete, build ordered tool-result messages. g. If no model-call budget remains, fail. h. Append model_call_requested with exact input. i. Call streamText for one step. j. Persist normalized non-delta events and stream deltas. k. Append model_call_completed or model_call_failed. l. Complete when the response has no tool calls. 11. Publish turn-processing-end in finalization. 12. Release the lock. 13. Resolve/reject outcome and close/error event stream. ``` The loop does not read session queues, accept new user messages, switch agents, or transform context. ## 19. Context and model requests Context selection before turn creation belongs to the caller/session layer. The turn receives either inline messages or a reference to the previous turn (section 6.6). Within a turn: - The initial context — inline or resolved from the reference — is immutable. - The system prompt is immutable. - The model and tools are immutable. - The loop appends current-turn assistant messages and ordered tool results. - No pruning or compaction occurs. - Context overflow is a model failure. Every model call persists the current-turn portion of its request byte-for-byte, even when it duplicates earlier events in the same file (section 8.3). The referenced prefix is deterministic and is not re-inlined. Within-turn storage size is not treated as a constraint. ## 20. Model-call limit `maxModelCalls` limits primary agent model calls, not permission-classifier or tool-internal model calls. When the final allowed model call returns tool calls: 1. Resolve permissions. 2. Execute/dispatch and collect the complete tool batch. 3. Do not make a disallowed next model call. 4. Append `turn_failed` with `code: "model-call-limit"`. If the final allowed call returns a response without tool calls, complete normally. Limit exhaustion is a distinguishable outcome, not a generic failure. The transcript is structurally complete — every tool call has a result — so a caller such as the session layer can offer continuation by creating a new turn whose context references the exhausted turn. ## 21. Failure semantics ### 21.1 Model failures - No automatic retry in the turn loop. - The injected model/provider may perform internal transport retries. - Append `model_call_failed`. - Append `turn_failed`. - Return a failed outcome. These rules apply to failures observed during live execution. Closing a model call that was interrupted by a process crash is a recovery action (section 23) and does not fail the turn. ### 21.2 Tool failures Tool failures do not fail the turn: - Sync tool throws: append an error tool result. - Async tool reports failure: append an error tool result. - Permission denial: append an error tool result. - Human unavailable: append an error tool result. Once every call has a result, send all results to the model in source order. ### 21.3 Unknown tools and unusable calls Unknown tools and unusable model tool calls become runtime error results so the model can observe and potentially correct its behavior. They do not fail the turn directly. ### 21.4 Storage and configuration failures Repository failures, corrupt data, and incompatible live dependencies reject the execution without pretending the agent turn itself failed. ## 22. Cancellation Cancellation enters through: - An `AbortSignal` for an active invocation. - A `{ type: "cancel" }` external input for a suspended turn. Rules: - Propagate the signal to the model, permission classifier, and sync tools. - Create synthetic cancellation results for unresolved tool calls from a completed assistant response. - Append `model_call_failed` when an active model call is cancelled. - Append `turn_cancelled`. - Never initiate another model call. - Reject late permission decisions, progress, and results after cancellation. - A cancel input is applied before live-dependency materialization: cancellation never requires the context, agent snapshot, model, or tools to resolve, so a turn whose environment is no longer resolvable (provider removed from config, builtin renamed, context chain unreadable) can always be cancelled — the terminal exit that keeps its session usable. Sync tools are cooperatively cancellable. A tool that ignores the signal may not settle immediately. The runtime cannot guarantee rollback of external side effects. ## 23. Recovery model Provider streams are not resumable. Sync tool side effects are not assumed idempotent. Recovery never repeats side-effecting work; side-effect-free model calls are re-issued rather than failing the turn. Calling `advanceTurn(turnId)` with no external input is the recovery entry point. | Last durable condition | Recovery behavior | | --- | --- | | `turn_created` only | Safely initiate the first model call. | | `model_call_requested` without completion/failure | Append `model_call_failed` (interrupted) to close the call, then re-issue the request as a new model call with the next index. The re-issue counts against `maxModelCalls`. No `turn_failed`. | | Completed model response before permission/tool processing | Safely continue processing. | | Permission required/classification partially processed | Continue unresolved permission work safely. | | Permission resolved allow before invocation | Safely invoke the tool. | | Sync `tool_invocation_requested` without result | Append an indeterminate error tool result and continue the turn; never re-execute. No `turn_failed`. | | Async `tool_invocation_requested` without result | Remain suspended awaiting external result. | | Some async results present | Preserve them and await remaining inputs. | | All tool results present before next model request | Safely initiate the next model call. | | Terminal event present | Return existing outcome without writing or executing. | | Corrupt JSONL | Reject as infrastructure error. | The synthetic interrupted sync result should communicate: ```text Tool execution was interrupted; its outcome is unknown and it was not retried. ``` This preserves a structurally complete transcript for later session context without claiming that the side effect did or did not happen. The model sees the indeterminate result on the next model call and can decide whether to re-run the tool, verify its effect, or proceed — consistent with section 21.2's rule that tool-level problems are conversational, not terminal. Async invocation requests act as durable external requests. Exactly-once delivery is not guaranteed. External systems that perform side effects should use `toolCallId` as a correlation/deduplication key if they need that guarantee, but the first turn implementation does not enforce it. ## 24. Reducer invariants `reduceTurn` must reject impossible histories, including: - Missing or non-first `turn_created`. - Multiple `turn_created` events. - Mismatched turn IDs. - Unsupported schema version. - Model-call indices that are reused or out of order. - Concurrent unresolved primary model requests. - Model completion/failure without a matching request. - Duplicate model completion/failure. - Duplicate tool-call IDs within one model response. - Permission records targeting unknown tool calls. - Conflicting effective permission decisions. - Tool progress after a terminal tool result. - Duplicate terminal tool results. - Async external results for sync tools. - Sync execution results for async tools unless explicitly represented as runtime-generated errors. - Starting the next model call before all prior tool calls have results. - Model-facing tool-result ordering that differs from original call order. - Completion while tool calls remain unresolved. - Terminal turn events while unresolved calls lack synthetic terminal results. - Multiple terminal turn events. - Any event after a terminal turn event. - Mutation of immutable turn definition data. - `model_call_requested.contextRef` values inconsistent with the turn definition's context, or present on a non-initial model call. - Request references that do not match the transcript: call 0 must be `[{context}?, {input}]`; call N must reference the previous completed call's response and its batch's tool results in source order (or nothing, after an interrupted call). The reducer validates `context` structurally but treats it as opaque; it never resolves references (section 6.6). The runtime also uses the reducer as its append gate: every batch is reduced against the existing history before it is persisted (section 4.5), so a history that violates these invariants cannot become durable through the loop. ## 25. Historical and live UI behavior ### 25.1 Historical load ```text 1. UI requests a turn by ID. 2. TurnRuntime.getTurn reads and validates the JSONL file. 3. Core returns { turnId, events }. 4. Renderer calls reduceTurn(events). 5. Renderer renders messages, tools, progress, permissions, usage, and terminal information from TurnState. ``` ### 25.2 Live updates ```text 1. TurnRuntime publishes turn-processing-start. 2. The application consumes TurnExecution.events. 3. The application forwards events through the existing bus/IPC bridge. 4. The renderer appends each durable TurnEvent to its local event list. 5. The renderer reruns reduceTurn(events). 6. Text/reasoning deltas update separate ephemeral renderer buffers. 7. A canonical completed model response replaces/clears partial display state. 8. TurnRuntime publishes turn-processing-end. ``` The UI derives current activity from ephemeral bus events. It does not infer that a turn is running from the JSONL log. ## 26. Required unit-test scenarios All dependencies are injectable, so these tests must use fakes/mocks and avoid real providers, tools, clocks, filesystems where unnecessary, and permission classifiers. ### 26.1 Plain model response Expected sequence: ```text turn_created model_call_requested model_step_event(s) model_call_completed turn_completed ``` Assertions: - Exact model request is persisted. - Deltas are streamed but absent from JSONL. - Completed response includes finish reason and usage. - Terminal event duplicates final output and aggregate usage. - Bus processing events are emitted but not persisted. ### 26.2 Mixed sync and async tools Model order: ```text sync-A, async-B, sync-C, async-D ``` Assertions: - Sync tools execute and store results. - Async invocation requests are exposed and turn suspends. - Async results can arrive in reverse order. - The next model request contains results in original source order. - Physical event order does not change model-facing order. ### 26.3 Partial human permission decisions Assertions: - All required permissions are recorded. - One approval advances only its tool. - Approved sync tools execute immediately. - Approved async tools become pending immediately. - Denied calls receive runtime error results. - Remaining permissions and async tools coexist in one suspension snapshot. - Async results may arrive while other permissions remain unresolved. - No next model call occurs until all original calls have terminal results. ### 26.4 Automatic permission classification Test `allow`, `deny`, and `defer` in one classifier batch. Assertions: - Classifier decisions and effective decisions are distinct records. - Allow executes. - Deny creates an error result without invocation. - Defer asks a human when available. - Defer denies when no human is available. - Classifier failure and missing decisions normalize to defer. - Manual mode never calls the classifier. ### 26.5 Cancellation Test cancellation: - While suspended. - During a model call. - During a sync tool. Assertions: - Signals are propagated. - Unresolved calls receive synthetic cancellation results. - Turn becomes terminal cancelled. - No subsequent model request occurs. - Late external inputs are rejected. - Cancellation succeeds when live dependencies can no longer be resolved, while non-cancel inputs against the same broken environment reject with the turn file unchanged. ### 26.6 Failures Assertions: - Provider failure appends model and turn failures. - Sync throw becomes an error tool result and does not fail the turn. - Async failure becomes an error tool result. - Permission-checker failure fails closed. - Repository, corruption, and dependency errors reject stream and outcome. - Infrastructure errors do not append a false `turn_failed` event. ### 26.7 Process crash and recovery Construct logs ending at every recovery boundary in the recovery table. Assertions: - Safe boundaries resume. - Unmatched model requests are closed as interrupted and re-issued; the re-issue counts against the model-call budget and no `turn_failed` is appended. - Unmatched sync invocation requests get indeterminate error results and the turn continues. - Unmatched async requests remain pending. - Completed tool batches proceed to the next model call. - Terminal turns perform no writes or side effects. ### 26.8 Historical and live reconstruction Assertions: - `getTurn` is read-only. - Full replay reconstructs messages, model calls, tools, progress, permissions, usage, suspension, and terminal data. - Appending a durable live event and rerunning the reducer matches full replay. - Ephemeral deltas are absent after reload. - Final completed messages remain available after reload. - No running state is inferred from durable events. ### 26.9 Model-call limit With `maxModelCalls = 10`: - A tool-free response on call 10 completes normally. - Tool calls from call 10 are fully processed. - Call 11 is never made. - After the call-10 tool batch completes, the turn fails with a limit error. - The limit failure carries `code: "model-call-limit"`. ## 27. Additional unit-test coverage Beyond the nine end-to-end loop scenarios, implementation should include focused tests for: ### 27.1 Repository - Deterministic date-partitioned paths. - ID path-traversal rejection. - Create-if-absent behavior. - Append ordering. - Read/write schema validation. - Mismatched turn IDs. - Empty files. - Malformed first, middle, and final lines. - Unsupported schema versions. - In-process locking. ### 27.2 Reducer - Every valid event transition. - Every corruption invariant. - Usage aggregation. - Original tool-call ordering. - Suspension replacement/invalidation after new inputs. - Terminal immutability. - Multiple model iterations. - Progress retention. - Requested versus resolved agent snapshots. ### 27.3 Hot stream - Execution starts without event consumption. - Outcome resolves without draining events. - Events retain order. - Durable events appear only after repository append. - Closing the consumer drops future events without cancelling execution. - Producer completion closes events. - Infrastructure failure errors events and rejects outcome with the same error. - Cancellation remains explicit. ### 27.4 Agent resolution - Override precedence. - Application-default fallback. - Final system prompt snapshot. - Tool availability filtering. - Tool aliases and stable `toolId` resolution. - Resolution failure creates no file. - Agent edits after creation do not alter the turn. - Runtime dependency mismatch leaves the turn unchanged. ### 27.5 Context resolution - Inline context passes through unchanged. - A single reference materializes the referenced turn's closed transcript. - A chain of references materializes recursively down to the inline base. - Referenced failed/cancelled/exhausted turns contribute their synthetic closure results. - A reference to a missing or corrupt turn file is an infrastructure error and appends nothing. - The composed request equals what the loop sent (byte-for-byte property: durable file + composer reproduce the provider payload). - The reducer never resolves references. ## 28. Suggested module layout This is a suggested organization, not a locked implementation requirement: ```text apps/x/packages/shared/src/turns.ts # durable schemas + reducer (unchanged home) apps/x/packages/core/src/runtime/ turns/ # the engine: runtime.ts, stream, event-hub, context # resolution/elision, request composer, repos, inspect-cli bridges/ # real implementations of the engine's seams (agent/tool/ # model resolvers, permission checker/classifier) sessions/ # session layer (session-design.md) assembly/ # what an agent is: registry, compose-instructions, # workspace-context, message-encoding, permission-metadata, # headless runners, spawn-agent, copilot/, capabilities/, # skills/ tools/ # builtin-tool catalog + domain modules + exec plumbing legacy/ # the dying runs engine (engine.ts + runs.ts + repos) ``` The final reducer location must permit both core and renderer to use exactly the same pure implementation. It may therefore live entirely in `@x/shared` despite being used heavily by `@x/core`. ## 29. Deferred design work The following topics must be handled in later specifications rather than implicitly added to the turn loop: ### 29.1 Sessions The session layer is specified in `session-design.md`. It covers the session JSONL schema, turn references and ordering, one-active-turn enforcement, context assembly through turn references, the in-memory index, session UI projections, and startup reconciliation. Queued user messages, steering, session-level permission grants, and compaction remain deferred there with committed future shapes. ### 29.2 Agent-as-tool The main loop treats agent tools as ordinary sync or async tools. A dedicated tool handler may create and execute child turns, forward progress, and return a parent tool result. No subflow or parent-turn concept is added to this initial turn schema. Implemented (v1) as the `spawn-agent` builtin: a sync tool whose handler (`RealToolRegistry`) runs the child as a standalone headless turn (`runSpawnedAgent`), records `{kind: "subagent", childTurnId}` as durable tool progress (the only parent→child link), and returns the child's final text plus a status envelope. `RequestedAgent` is a union of by-id and inline variants; inline agents resolve through `InlineAgentResolver`. Depth is capped at 1: both resolvers strip the spawn tool from children, and the handler refuses child-shaped parents. Parallel fan-out comes from concurrent sync-tool execution (§10.5), not from async suspension; async (restart survivability for long children) remains future work. ### 29.3 Reliability enhancements - External-input idempotency. - Exactly-once async dispatch acknowledgements. - Tool retry-safety metadata. - Automatic retry policies. - Cross-process locking. - `fsync` durability. - Torn-write repair. - Event-stream bounds and backpressure. - Large-blob sidecars, if ever required. ### 29.4 API refinements - Inline agent support if a real use case appears. - Explicit tool-argument validation. - Shared semantic timeline selectors. - Public query projections beyond `getTurn`. - Turn listing and deletion APIs. ## 30. Implementation acceptance criteria The turn layer is implementation-complete only when: 1. All durable schemas are defined and exported from `@x/shared`. 2. Filesystem storage follows the deterministic partitioned JSONL layout. 3. `TurnRuntime` uses explicit PROXY constructor injection. 4. Agent resolution produces one immutable snapshot. 5. Each model invocation is exactly one AI SDK model step. 6. Sync, async, permission, suspension, cancellation, and recovery behavior matches this specification. 7. The pure reducer is shared by core and renderer. 8. No durable running state exists. 9. The hot stream is independent of consumer progress. 10. Every required scenario and focused test group passes using mocked dependencies. 11. No session, agent-as-tool, compaction, or migration behavior leaks into the turn loop.