diff --git a/apps/x/packages/core/docs/session-design.md b/apps/x/packages/core/docs/session-design.md new file mode 100644 index 00000000..2694f9e9 --- /dev/null +++ b/apps/x/packages/core/docs/session-design.md @@ -0,0 +1,561 @@ +# Session Layer Technical Specification + +Status: design complete for the session layer v1. No implementation exists +yet. + +This document specifies the session layer that sits above the turn runtime +defined in `turn-runtime-design.md`. That document is assumed context; this +one does not restate turn semantics. + +## 1. Goals + +The session layer must: + +1. Own conversations composed of ordered turns. +2. Persist each session as one append-only JSONL file with the same + validation discipline as turn files. +3. Enforce one active turn per session. +4. Assemble each turn's context as a reference to the previous turn. +5. Maintain an in-memory index for listing, sorting, and filtering sessions, + updated write-through and rebuilt by scanning at startup. +6. Route external inputs — permission decisions, ask-human answers, async + tool results — to the correct turn through dedicated APIs. +7. Forward live turn events to the renderer over IPC. +8. Provide headless standalone turns outside any session. + +## 2. Non-goals (v1) + +- Queued user messages. The committed future shape is in section 12.1. +- Steering / mid-turn message injection (section 12.4). +- Session-scoped permission grants ("always allow for this chat"); every + applicable tool call prompts in v1 (section 12.2). +- Context compaction; a viable mechanism sketch is recorded in section 12.3. + V1 behavior on context overflow is the turn-level model failure. +- LLM auto-titling (section 12.6). +- A persisted index cache; startup always scans (section 12.5). +- Cross-process coordination. A single main process is enforced. +- Data migration from the current runs system. Old conversations are not + converted; the old code path remains readable until it is deleted. +- Session list pagination. The index is in-memory and shipped whole. + +## 3. Terminology + +A **session** is a durable, ordered chain of turns plus presentation +metadata (title). Conversation content lives exclusively in turn files; the +session file stores turn references with denormalized metadata. + +The **index** is an in-memory projection over all session files, used for +the session list UI. It is never a source of truth. + +A **standalone turn** is a turn with `sessionId: null`, created outside any +session by headless callers. Standalone turns do not appear in the index. + +## 4. Storage design + +### 4.1 File location + +Session files live under: + +```text +WorkDir/storage/sessions/YYYY/MM/DD/.jsonl +``` + +Session IDs come from the existing +`IMonotonicallyIncreasingIdGenerator`, and the repository derives the +date-partitioned path from the ID exactly as the turn repository does, +including format validation and path-traversal rejection. + +### 4.2 File rules + +Identical discipline to turn files: + +- The first line is always `session_created` with `schemaVersion: 1`. +- Every event contains `sessionId` and an ISO timestamp `ts`. +- Physical line order is authoritative. +- Reads validate every line strictly; any malformed line makes the session + corrupt; no truncation, repair, or skipping. +- Unknown schema versions and unknown event types fail loudly. Future + additive event types (queueing, grants, compaction) arrive as a schema + version bump; the reducer will accept old and new versions and write the + newest. +- Appends are awaited but not explicitly `fsync`ed. + +### 4.3 Repository contract + +```ts +interface ISessionRepo { + create(event: SessionCreated): Promise; + read(sessionId: string): Promise; + append(sessionId: string, events: SessionEvent[]): Promise; + withLock(sessionId: string, fn: () => Promise): Promise; + listSessionIds(): Promise; + delete(sessionId: string): Promise; +} +``` + +- `create` fails if the file exists. +- `listSessionIds` enumerates the partition directories for the startup + scan. +- `delete` removes the session file only. Turn files referenced by the + session are left in place as harmless orphans (see section 9, deletion). +- `withLock` is in-process per-session exclusion, mirroring the turn repo. + +## 5. Event schemas + +All session event schemas live in `@x/shared` alongside the turn schemas. + +```ts +interface BaseSessionEvent { + sessionId: string; + ts: string; +} + +interface SessionCreated extends BaseSessionEvent { + type: "session_created"; + schemaVersion: 1; + title?: string; +} + +interface SessionTurnAppended extends BaseSessionEvent { + type: "turn_appended"; + turnId: string; + sessionSeq: number; // 1-based position of the turn in the session + agentId: string; + model: ModelDescriptor; // resolved provider/model for the turn +} + +interface SessionTitleChanged extends BaseSessionEvent { + type: "title_changed"; + title: string; +} + +type SessionEvent = + | SessionCreated + | SessionTurnAppended + | SessionTitleChanged; +``` + +`turn_appended` deliberately denormalizes `agentId` and `model` from the +turn so the index can fold from session files without opening turn files. +The turn file remains authoritative for the turn's actual configuration. + +The session file never mirrors turn outcomes. Turn lifecycle facts live only +in turn files; deriving "is this session busy/suspended/failed" reads the +latest turn (section 8). + +## 6. Session reducer + +`@x/shared` owns one pure reducer shared by core and renderer: + +```ts +function reduceSession(events: SessionEvent[]): SessionState; + +interface SessionState { + definition: SessionCreated; + title?: string; + turns: Array<{ + turnId: string; + sessionSeq: number; + agentId: string; + model: ModelDescriptor; + ts: string; + }>; + latestTurnId?: string; + createdAt: string; // definition.ts + updatedAt: string; // ts of the last event +} +``` + +Invariants (violations throw, as with the turn reducer): + +- `session_created` is present, first, and unique. +- All event `sessionId` values match. +- `sessionSeq` is strictly increasing starting at 1, with no gaps. +- `turnId` values are unique. +- Unsupported schema versions and unknown event types fail loudly. + +## 7. Write ordering and consistency + +Per user message, the session layer performs, in order: + +1. `turnRuntime.createTurn(...)` — the turn file is created. +2. `sessionRepo.append(turn_appended)` — the session references the turn. +3. `advanceTurn(...)` — execution begins. + +Rules: + +- A crash between steps 1 and 2 leaves an orphan turn file: unreferenced, + never advanced, and benign. Turns are only ever found by reference, so an + orphan is invisible. V1 does not garbage-collect orphans. +- The reverse order is forbidden: a `turn_appended` referencing a turn file + that was never created would be a dangling reference, which is corruption. +- Step 2 precedes step 3 so that a turn that is executing is always already + referenced by its session. +- Session-file appends happen under the session lock; turn-file appends are + the turn runtime's concern. + +## 8. In-memory index + +### 8.1 Shape + +```ts +interface SessionIndexEntry { + sessionId: string; + title?: string; + createdAt: string; + updatedAt: string; + turnCount: number; + lastAgentId?: string; + lastModel?: ModelDescriptor; + latestTurnId?: string; + latestTurnStatus: + | "none" // session has no turns yet + | "completed" + | "failed" + | "cancelled" + | "suspended" // durable suspension: pending permissions/async tools + | "idle"; // non-terminal, not suspended: interrupted by a crash +} +``` + +`latestTurnStatus` is derived from the latest turn's reduced state, using +the same derivation everywhere: terminal event kind if present, else +`suspended` if a suspension with outstanding work is the resting state, else +`idle`. Whether a turn is *actively processing right now* is not in the +index; it is ephemeral bus state (`turn-processing-start/end`), per the turn +specification. + +### 8.2 Startup scan + +1. `listSessionIds()`. +2. For each session: read and reduce the session file, producing the entry's + session-derived fields. +3. For each session with turns: read and reduce the latest turn file only, + producing `latestTurnStatus`. +4. Publish the completed index to the renderer. + +A corrupt session file or corrupt latest-turn file does not abort startup: +the entry is surfaced in an errored state (identifiable in the UI, excluded +from normal interaction) and the scan continues. + +### 8.3 Maintenance + +- Every session mutation updates the entry in the same code path that + appends to the session file (write-through), then publishes a + `session-index-changed` event on the application bus. +- When an `advanceTurn` outcome settles, the session layer updates + `latestTurnStatus` and publishes `session-index-changed`. +- There is no filesystem watcher. Out-of-band edits to session or turn files + while the app runs are unsupported; offline changes are reconciled by the + next startup scan. +- The main process enforces single-instance via + `app.requestSingleInstanceLock()`; all locking in both layers is + in-process. + +## 9. Sessions API + +```ts +interface SendMessageConfig { + agent: RequestedAgent; // agent id + optional model override + autoPermission?: boolean; // default false + maxModelCalls?: number; // default per turn spec +} + +interface ISessions { + createSession(input?: { title?: string }): Promise; + listSessions(): SessionIndexEntry[]; + getSession(sessionId: string): Promise; + getTurn(turnId: string): Promise; // passthrough to turn runtime + + sendMessage( + sessionId: string, + input: UserMessage, + config: SendMessageConfig, + ): Promise<{ turnId: string }>; + + respondToPermission( + turnId: string, + toolCallId: string, + decision: "allow" | "deny", + metadata?: JsonValue, + ): Promise; + + respondToAskHuman( + turnId: string, + toolCallId: string, + answer: string, + ): Promise; + + deliverAsyncToolResult( + turnId: string, + toolCallId: string, + result: ToolResultData, + ): Promise; + + stopTurn(turnId: string, reason?: string): Promise; + resumeTurn(sessionId: string): Promise; + + setTitle(sessionId: string, title: string): Promise; + deleteSession(sessionId: string): Promise; +} +``` + +### 9.1 sendMessage + +Under the per-session lock: + +1. Read and reduce the session. +2. If the session has turns, read and reduce the latest turn. If it is + non-terminal — running, suspended, or idle — reject with a typed + `TurnNotSettledError`. There is no implicit queueing, steering, or + cancel-and-replace, and no implicit routing to a pending ask-human. +3. Build context: `[]` (inline, empty) for the first turn, else + `{ previousTurnId: latestTurnId }`. +4. Create the turn: config from `SendMessageConfig` lands on the turn + (`humanAvailable: true` always, for session turns). Sessions store no + configuration; every turn is self-describing. +5. Append `turn_appended` with the next `sessionSeq` and denormalized + agent/model. +6. If the session has no title, append `title_changed` derived from the + truncated first user message. +7. Start `advanceTurn` in the background; consume its events (section 10). +8. Return `{ turnId }` immediately. The renderer follows progress through + events, not the return value. + +Continuation after a failed or exhausted (`code: "model-call-limit"`) turn +is just `sendMessage`: failed turns are terminal, and the new turn's context +reference includes the failed turn's structurally complete transcript. + +### 9.2 External inputs + +`respondToPermission`, `respondToAskHuman`, and `deliverAsyncToolResult` +each translate to one `advanceTurn(turnId, input)` call with the +corresponding `TurnExternalInput`. `respondToAskHuman` is the dedicated +endpoint for the `ask-human` tool — a thin wrapper over +`async_tool_result` — and is deliberately separate from `sendMessage`. +Validation (unknown call, already-resolved call, terminal turn) is the turn +runtime's job; the session layer passes its errors through. + +### 9.3 stopTurn and resumeTurn + +- `stopTurn` cancels via the turn runtime: aborting the active invocation's + signal if one is running, else advancing a suspended turn with a `cancel` + input. +- `resumeTurn` re-enters the latest turn with no input — the turn spec's + recovery entry point — for turns left `idle` by a crash. There is no + automatic resume sweep at startup: recovery re-issues interrupted model + calls, so resumption must be an explicit user action. Suspended turns need + no resumption; they advance when their inputs arrive. + +### 9.4 Deletion + +`deleteSession` removes the session file and the index entry, and publishes +`session-index-changed`. Referenced turn files are retained as orphans: +turns are only discoverable by reference, so orphaned files are inert. +Deleting an entity's file is not a violation of append-only discipline, +which governs mutation of live logs, not their removal. + +## 10. Event forwarding and live UI + +1. For every `advanceTurn` it initiates, the session layer consumes + `TurnExecution.events` and forwards each `TurnStreamEvent` — tagged with + `sessionId` — through the application bus to renderer windows over one + IPC channel (`sessions:events`). +2. When `outcome` settles, the session layer updates the index entry and + publishes `session-index-changed`. +3. The renderer follows the turn spec's historical/live pattern: fetch + turns via `getTurn`, run the shared `reduceTurn` per turn, compose the + session timeline turn-by-turn (each turn renders its input and its own + activity; the referenced prefix is never re-rendered from context), + append live durable events and re-reduce, and keep text/reasoning deltas + in an ephemeral overlay cleared by canonical responses. +4. Pending approvals and ask-human prompts render from the suspended turn's + reduced state, so they survive restarts without any session-layer + bookkeeping. + +## 11. Headless standalone turns + +A helper covers the non-session callers (background tasks, live notes, +knowledge pipelines, scheduled agents): + +```ts +function runHeadlessTurn(input: { + agent: RequestedAgent; + context?: ConversationMessage[]; // inline; defaults to [] + input: UserMessage; + maxModelCalls?: number; + signal?: AbortSignal; +}): Promise; +``` + +- `sessionId: null`, `autoPermission: true`, `humanAvailable: false`. +- Creates the turn, advances to the first settled outcome, and returns it. +- Standalone turns never appear in the index; callers keep their own turn + IDs if they need history. + +## 12. Deferred designs with committed shapes + +These are not implemented in v1. Their shapes are recorded so v1 decisions +stay compatible; each arrives as a session schema version bump. + +### 12.1 Queued messages + +```ts +{ type: "message_queued", queueId, message, ts } +{ type: "queued_message_replaced", queueId, message, ts } // edit +{ type: "queued_message_removed", queueId, ts } // cancel +// promotion: turn_appended gains consumedQueueIds?: string[] +``` + +The reducer derives the pending queue by supersession. Promotion rules +(collapse-into-one-turn vs FIFO, behavior after failed turns) are decided +together with steering. + +### 12.2 Session permission grants + +```ts +{ type: "permission_grant_added", grantId, toolId, ts } +{ type: "permission_grant_removed", grantId, ts } +``` + +The injected `IPermissionChecker` consults a session-keyed grants view +before answering `required: true`. V1 grants would be blanket per-toolId; +argument-pattern matchers are a separate, security-sensitive project. + +### 12.3 Compaction (mechanism sketch) + +Compaction requires zero turn-schema change. A session-level compaction +event records `{ compactionId, summary, firstKeptTurnId }`; the next turn +after a compaction uses **inline** context (summary message + kept +transcript), which restarts the reference chain and bounds resolution depth +by construction. Trigger policy and summarizer design are unspecified. + +### 12.4 Steering + +Rides the queue events with a different promotion rule, and additionally +requires a turn-level `steer` external input (a turn schema bump) with an +injection boundary after tool-batch completion. Recorded here so the session +design does not foreclose it. + +### 12.5 Persisted index cache + +If the startup scan ever gets slow, a single cache file keyed by file +mtimes can be added. It is a rebuildable cache, never a source of truth: +missing, stale, or invalid means rebuild from session files. + +### 12.6 Auto-titling + +An LLM-generated title replacing the truncated-first-message default, +appended as an ordinary `title_changed` event. + +## 13. Required test scenarios + +All tests use the in-memory/mocked turn runtime and repo fakes. + +### 13.1 Reducer + +- Valid event sequences reduce to expected state. +- Every invariant violation throws: missing/duplicate `session_created`, + mismatched sessionId, non-monotonic or gapped `sessionSeq`, duplicate + turnIds, unknown type/version. +- Title folding: default, explicit changes, last-wins. + +### 13.2 Repository + +- Date-partitioned paths, ID validation, create-if-absent. +- Strict line validation on read; corrupt files rejected whole. +- `listSessionIds` enumeration across partitions. +- Deletion removes only the session file. + +### 13.3 sendMessage + +- First turn: inline `[]` context, `sessionSeq: 1`, default title appended. +- Subsequent turns: context references the latest turn; seq increments. +- Rejection with `TurnNotSettledError` when the latest turn is running, + suspended, or idle — and success after it settles. +- Continuation after failed and model-call-limit turns. +- Concurrent sendMessage calls serialize under the session lock; exactly + one wins, the other rejects. +- Config lands on the turn; the session file stores only denormalized + agent/model on `turn_appended`. + +### 13.4 Ordering and crash simulation + +- Simulated crash between `createTurn` and `turn_appended`: orphan turn + file, session unchanged, retry produces a fresh turn. +- `turn_appended` is present before the first advance begins. + +### 13.5 External inputs + +- Permission decision, ask-human answer, and async result each advance the + correct turn with the correct input type. +- Turn-runtime rejections (unknown call, terminal turn) pass through. +- `sendMessage` never routes to ask-human. + +### 13.6 Index + +- Startup fold matches write-through state for the same history. +- Latest-turn status derivation for every status value. +- Corrupt session file yields an errored entry without aborting the scan. +- Mutations publish `session-index-changed`; deletion removes the entry. + +### 13.7 Event forwarding + +- Forwarded events are tagged with sessionId and arrive in order. +- Outcome settlement updates `latestTurnStatus`. + +### 13.8 Headless + +- Standalone turns: `sessionId: null`, auto permission, human unavailable, + absent from the index. + +## 14. Suggested module layout + +```text +apps/x/packages/shared/src/sessions.ts # event schemas, reducer, index types + +apps/x/packages/core/src/sessions/ + sessions.ts # ISessions implementation + repo.ts # ISessionRepo contract + fs-repo.ts # filesystem implementation + index.ts # in-memory index + startup scan + headless.ts # runHeadlessTurn +``` + +Awilix registration mirrors the turn runtime: singleton scope, PROXY +constructor injection, no container resolution from inside the classes. + +## 15. Integration sequence + +The rollout is staged as commits on one branch (squash-merge acceptable); +old and new stacks coexist briefly but never share state, and no data is +migrated: + +1. `@x/shared`: turn + session schemas and reducers. +2. Turn runtime + fs turn repo (unit tests only, wired to nothing). +3. Session layer + index (unit tests only). +4. Bridges: agent resolver, context resolver, tool runner, permission + checker/classifier — adapted from the `new-runtime` reference + implementation where applicable. +5. IPC (`sessions:*`) + renderer swap: Copilot chat UI moves to the + sessions API. +6. Headless callers move to `runHeadlessTurn`. +7. Delete the old runs runtime. + +## 16. Implementation acceptance criteria + +The session layer is implementation-complete only when: + +1. Session event schemas and `reduceSession` live in `@x/shared` and are + consumed unchanged by core and renderer. +2. Session files follow the partitioned append-only JSONL layout with + strict validation. +3. Turn-file-first write ordering is enforced; orphan turns are benign. +4. `sendMessage` rejects non-terminal latest turns with a typed error; no + implicit queueing, steering, or ask-human routing exists. +5. Ask-human answers flow only through the dedicated endpoint. +6. The index is write-through with a startup scan, no watcher, and no + persisted cache; single-instance is enforced. +7. Deletion removes only the session file. +8. Headless callers run standalone turns and appear nowhere in the index. +9. All required test scenarios pass with mocked dependencies. diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md new file mode 100644 index 00000000..2daaf2bb --- /dev/null +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -0,0 +1,1830 @@ +# Turn Runtime Technical Specification + +Status: design complete for the turn layer, amended after review for session +integration: context references, revised crash-recovery semantics, and a +distinguishable model-call-limit outcome. No implementation exists yet. 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. + +The initial implementation may execute sync tools sequentially for simplicity. +Async tools naturally complete independently. No behavior may rely on physical +completion order. + +## 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; + }; +} +``` + +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. + +## 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 +interface ModelRequest { + systemPrompt: string; + contextRef?: { previousTurnId: string }; + messages: ConversationMessage[]; + tools: ToolDescriptor[]; + 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` records the exact model input with one substitution: when the +turn's context is a reference, the resolved prefix is not re-inlined. +`contextRef` repeats the reference and `messages` contains only current-turn +messages — the turn input, this turn's prior assistant responses, and ordered +tool results — captured byte-for-byte as assembled immediately before +provider invocation. When the turn's context is inline, `contextRef` is +absent and `messages` begins with that inline context. The full request is +deterministically materializable because referenced turns are immutable; a +`materializeRequest(turnId, modelCallIndex)` debug utility composes resolver +output with the persisted messages. + +Within-turn duplication is deliberate: each request restates the current +turn's accumulated messages even though they also exist as earlier events in +the same file. The persisted `messages` are the ground truth of what the loop +actually assembled and sent — including any assembly bugs — which a derived +reconstruction would mask. + +`request` excludes credentials, auth headers, functions, model objects, and +transport objects. + +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. + +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 IPermissionClassifier { + classify( + requests: PermissionClassificationInput[], + 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 { + 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. Execute allowed sync tools using a simple sequential policy initially. +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; + bus: IBus; +} + +class TurnRuntime implements ITurnRuntime { + constructor({ + turnRepo, + idGenerator, + clock, + agentResolver, + modelRegistry, + toolRegistry, + contextResolver, + permissionChecker, + permissionClassifier, + bus, + }: 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. + +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. + +## 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. Materialize context through the injected context resolver. +7. Validate terminal state, input, and runtime dependencies. +8. Apply the optional single external input. +9. 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. +10. Publish turn-processing-end in finalization. +11. Release the lock. +12. 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. + +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. + +The reducer validates `context` structurally but treats it as opaque; it +never resolves references (section 6.6). + +## 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. + +### 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. +- `materializeRequest` output equals resolver output plus persisted + current-turn request messages. +- 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 + +apps/x/packages/core/src/turns/ + runtime.ts + reducer.ts # if implementation is re-exported through shared, + # locate pure code to avoid dependency cycles + repo.ts + fs-repo.ts + stream.ts + agent-resolver.ts + context-resolver.ts + model-registry.ts + tool-registry.ts + permission.ts + index.ts +``` + +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. + +### 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.