Repository navigation
Agent provider + handoff architecture overhaul #538
Description
Problem Statement
Mcode users have hit three visible quality issues with the agent layer.
First, after forking a thread, the child agent often picks up an incomplete view of the parent context. The handoff document is sized to fit the child Provider's per-turn input budget, so sections get truncated or dropped silently. On Providers with small input caps the trail thins out further. Users report that the handoff "doesn't pull the full context in like it's meant to."
Second, on page reload of an active thread, Tool calls render before the assistant message text. The narrative pipeline assembles three client-side stores from two separate hydration RPCs and the order is racy.
Third, the deterministic Handoff fallback (used when a Provider cannot summarise its own session) drops everything except the last assistant message and truncates that to 2000 characters. It ignores compact summaries, Tool calls, Narration segments, and recent files that already live in the database.
Mcode contributors have hit five maintenance issues with the same layer.
The AgentService orchestrator is 2,689 lines. It holds narrative enrichment, narrative classification, narrative persistence, plan-mode state machines, Provider-specific knob plumbing, and the agent call stack fallback in one body. The narrative pipeline guide documents six traps that all live here.
The four Providers (Claude, Cursor, Codex, Copilot) duplicate roughly 70 percent of their session-lifecycle code: session pool, idle eviction, environment snapshot, SDK session id tracking, resume-then-fallback policy. The duplication is structural, not principled, and it has caused accidental drift: Codex does not attach to the Windows JobObject, Copilot's eviction predicate is weaker than the others, only Cursor uses taskkill /T /F for hard kill.
The IAgentProvider.sendMessage call carries fifteen parameters, four of them Provider-specific (Claude's contextWindowMode, Haiku's thinking, Codex's codexFastMode, Copilot's copilotAgent). Every Provider sees every other Provider's knobs. AgentService must know which apply where, and adding a new knob widens the shared interface.
The Handoff pipeline uses defensive optional-chaining existence checks on runSideChannelQuery and runHiddenTurn even though the sessionForkOnResume capability is non-optional. Capability and method do not enforce each other. Path D (the deterministic fallback) lives as a branch inside the pipeline, not as a named adapter.
Plan mode and Build mode are exposed through two separate optional methods (setPlanAnswerMode, setPlanQuestionMode) whose semantics couple AgentService to caller-side knowledge of which Provider implements which.
Solution
For users, the agent surface improves in three ways.
The Handoff is delivered off-band by default: the full document lives in the OS temp directory and the child Reads it on its first Turn under a one-shot Scoped pre-grant. Quality stops being capped by the child Provider's input budget. The deterministic fallback is rebuilt to read every signal already in the database. Narrative items render in source order on page reload because the server hands the client a single chronological list rather than two race-prone streams.
For contributors, the agent surface deepens in four ways.
The per-Turn Provider call becomes one typed value object (TurnRequest<P>). Per-Provider knobs ride in a discriminated providerOptions bag, walled off from generic call sites. Three Provider methods retire (setSdkSessionId, setPlanAnswerMode, setPlanQuestionMode).
The persistent-CLI-session lifecycle moves behind one shared module per Provider (SessionRuntime<TState>) with a per-Provider ProtocolAdapter<TState> carrying the protocol-specific bits.
The Handoff routing collapses to one method on the Provider (forker.fork) with three concrete adapters (CleanForker, MutatingForker, DeterministicForker). MutatingForker is queued for retirement once Cursor's clean-resume behaviour is verified.
The narrative pipeline's enrichment, classification, and persistence concentrate in one module (NarrativeStore) with one write seam (observes AgentEvents) and one read seam (turn.load RPC). The six documented narrative traps gain one home.
User Stories
- As an Mcode user forking a Thread, I want the child agent to receive the full parent context, so that it can continue the work without me re-explaining.
- As an Mcode user forking on a Provider with a small per-Turn input cap, I want Handoff quality not to degrade because of the budget, so that I get the same level of continuity as on large-window Providers.
- As an Mcode user opening a freshly forked Thread, I want the child agent to Read its Handoff document on its first Turn without me being prompted for permission, so that the Handoff feels invisible.
- As an Mcode user on Supervised permission mode, I want the Scoped pre-grant to authorise only the Handoff temp file on the first Turn, so that the bypass cannot be reused for arbitrary reads.
- As an Mcode user on a Provider that cannot summarise (Codex, Copilot today), I want the deterministic Handoff to read the parent's compact summary, Tool calls, Narration segments, recent files, and fork-anchor body, so that the fallback delivers usable context rather than a single truncated message.
- As an Mcode user, I want the Handoff to honour my Fork anchor role (forking from my own message versus the assistant's reply), so that the child framing matches whether I am retrying or following up.
- As an Mcode user, I want the full Handoff document to be retained at a stable temp path until garbage collection, so that I can inspect the parent context later if I want.
- As an Mcode user reloading a Thread, I want narrative items to render in source order, so that Tool calls do not appear before the assistant message text.
- As an Mcode user reloading a Thread, I want the narrative trail of completed Turns to reappear, so that I can see what happened across the conversation.
- As an Mcode user watching a live Turn, I want the live narrative timeline (Tool calls, Narration segments, hooks) to remain consistent and not race the assistant response.
- As an Mcode user toggling Plan and Build between Turns, I want the next Turn to honour the new selection, so that interaction mode is per-Turn not per-Thread.
- As an Mcode user changing model or reasoning level between Turns, I want the next Turn to use the new selection, so that knob changes apply immediately.
- As an Mcode user resuming after restarting the app, I want the agent to pick up where I left off without me re-explaining context, so that session continuity survives restarts.
- As an Mcode user on Windows, I want agent processes to terminate cleanly when I stop a session, so that orphan processes do not accumulate.
- As an Mcode user on Codex or Copilot, I want idle eviction to free resources after inactivity, so that abandoned sessions do not consume memory indefinitely.
- As an Mcode user, I want consistent agent behaviour across the four Providers when I stop, resume, or change permission modes, so that switching Providers does not surprise me with inconsistent affordances.
- As an Mcode user changing my permission mode mid-Thread, I want the Provider session to respawn cleanly so the new policy applies, so that the change takes effect without restarting the app.
- As an Mcode user, I want stop and resume on Cursor to carry parent context without a hidden-turn dance, if the upstream
loadSessionbehaviour now supports it, so that the Provider has one less brittle code path. - As an Mcode contributor adding a new Provider in future (Kilocode, Grok, others), I want to implement only the protocol-specific shim, so that lifecycle, narrative recording, and Handoff routing come for free.
- As an Mcode contributor, I want the per-Turn Provider call surface to be one typed value object, so that I can add Provider-specific knobs without widening the shared interface.
- As an Mcode contributor, I want Provider-specific fields walled off behind a
providerOptionsdiscriminator keyed by Provider id, so that generic call sites cannot reach into the wrong Provider's knobs. - As an Mcode contributor, I want the resume-or-fresh decision encoded in one
resumeFromfield, so that I cannot ship a Provider where the resume flag and the SDK session id disagree. - As an Mcode contributor, I want the Handoff path to be a typed selection through one
provider.forkerfield, so that I cannot ship a Provider that declares a fork capability without implementing the matching adapter. - As an Mcode contributor, I want the narrative pipeline's enrichment, classification, and persistence concentrated in one module with one write seam and one read seam, so that the six documented traps have one home and one test surface.
- As an Mcode contributor, I want to test the persistent-CLI-session lifecycle (pool, idle eviction, permission registry, Scoped pre-grant) independently of any protocol, so that I can verify lifecycle without spawning a real CLI.
- As an Mcode contributor, I want the deterministic Handoff builder exercisable in isolation from any Provider, so that I can verify Path D fidelity with seeded message rows.
- As an Mcode contributor, I want the page-load hydration to use one
turn.loadRPC returning a chronological list, so that I never reintroduce the two-RPC race that today causes Tool calls to render before the assistant message. - As an Mcode contributor, I want the four Providers' accidental drift (
JobObjectadoption, eviction predicate,taskkillusage) to converge through the sharedSessionRuntime, so that correctness is uniform rather than per-Provider best-effort. - As an Mcode contributor, I want CONTEXT.md to reflect the agreed shape of Handoff delivery (off-band, Scoped pre-grant) and Provider internals (Session runtime, Protocol adapter), so that new contributors learn the current model rather than the legacy one.
- As an Mcode contributor, I want Path A's retirement gated on a verification run, so that we do not delete the
MutatingForkercode path until Cursor's clean-resume behaviour is confirmed.
Implementation Decisions
The reshape spans five candidates from the architecture review. Each is independently shippable but the cleanest implementation order is E, A, B, F, D.
E: TurnRequest
IAgentProvider.sendMessage(...15 params)becomessendTurn(req: TurnRequest<P>): Promise<void>. The rename matches the Turn vocabulary in CONTEXT.md.TurnRequest<P>is a typed value object carrying per-Turn user input (message,attachments), per-Turn knobs the user can change between Turns (model,fallbackModel,cwd,permissionMode,interactionMode,reasoningLevel,maxBudgetUsd,maxTurns), a resume signal (resumeFrom?), and a Provider-specific bag (providerOptions: ProviderOptionsByProvider[P]).ProviderOptionsByProvideris a discriminated record keyed byProviderId. Claude carries{ contextWindowMode?; thinking? }. Codex carries{ fastMode? }. Copilot carries{ agent? }. Cursor, Gemini, OpenCode carryRecord<string, never>. TheproviderOptionsfield is required; empty-knob Providers explicitly pass{}.- Three Provider methods retire:
setSdkSessionId,setPlanAnswerMode,setPlanQuestionMode. Theresume: booleanparameter retires. resumeFrom?: stringreplaces the previous two-step dance (setSdkSessionId(name, id)thensendMessage(..., resume: true)). When defined, the Provider resumes from that SDK session id. When undefined, fresh session.interactionMode: "plan" | "build"absorbs both retired plan-mode methods. CONTEXT.md was updated 2026-05-29 to clarify that Plan and Build are per-Turn states, not per-Thread states.sessionIdandthreadIdboth remain onTurnRequestuntil a follow-up confirms whether themcode-${threadId}prefix is load-bearing for any Provider.
A: NarrativeStore
- A new server-side
NarrativeStoremodule owns both the write and read sides of the narrative pipeline. The four underlying storage shapes (messages.tool_callsJSON,tool_call_records,thought_segments,hook_executions) stay unchanged. - Write seam: subscribes to
AgentEvents, performsparentToolCallIdenrichment (SDK-first per Trap 1,agentCallStackfallback),AssistantMessageBoundaryclassification, and persistence. Replaces thebufferToolCallandcloseOpenThoughtandpersistTurnpaths currently smeared acrossagent-service.ts. - Read seam: one RPC
turn.load(threadId, range)returns a chronologically-ordered list of narrative entries (assistant message bodies, Tool calls, Narration segments, hooks). Replaces today'smessage.listplusnarrative.listracing pair. - Client-side: the three Zustand maps (
toolCallsByThread,thoughtSegmentsByThread,hooksByThread) collapse into one ordered list keyed by entry id. - The page-load render ordering issue (Tool calls appearing before the assistant message) is resolved as a side effect of single-source hydration.
narrative.listmay stay temporarily for back-compat during cut-over.- The earlier "collapse into Messages with a kind discriminator" reshape was rejected by the user. Sidecar tables stay because the UX requires the trail to persist across app closes and the user prefers a render-layer fix to a schema reshape.
B: SessionRuntime and ProtocolAdapter
- A new
SessionRuntime<TState>service owns the uniform lifecycle: session pool, lazy 60-second eviction timer,JobObjectattachment, env snapshot viaEnvService,lastUsedAtplus idle TTL, SDK session id Map, permission-request registry, lazy spawn, resume-then-fallback policy. Treats per-session state as opaqueTState. - A new
ProtocolAdapter<TState>interface carries the protocol-specific I/O:spawn,isBusy(eviction guard),interrupt(protocol-level graceful),close(hard kill or disconnect), event mapping. - Each Provider satisfies its own
ProtocolAdapter(typically the Provider class itself, not a separate class). - Each Provider holds its own
SessionRuntime<TState>instance (not shared) so per-session state remains type-isolated. - Composition was chosen over inheritance. TypeScript and the tsyringe DI container both favour composition. Per-session state is genuinely type-divergent across Providers and a generic
SessionRuntime<TState>localises the parametricity instead of polluting every internal signature. - Force-convergence on the accidental drift rows is part of this change: every Provider after extraction gets the lazy 60s eviction timer,
JobObjectattachment, Windowstaskkill /T /Fin hard close, andlastUsedAt + isBusyguard. Codex gainsJobObject. Copilot gains a real busy guard. All gaintaskkill. The current divergence is accidental, not principled.
F: Handoff fidelity
- F-core: the Handoff is delivered off-band by default. The pipeline always writes the full document to a stable temp path under the OS temp directory. The inline first-Turn prompt becomes small (around 500 characters): file path pointer, a 2 to 3 sentence graceful-degradation summary, and the child's first user message.
- F-core: the pipeline issues a Scoped pre-grant so the child can Read that one file on that one Turn without prompting the user, regardless of the Thread's
permissionMode. The pre-grant is path-scoped, Turn-scoped, and one-shot. - F-core retires Full mode, Minimal mode, Character budget (as it applies to the Handoff doc body), and Overflow.
maxInputCharactersPerTurnbecomes decorative for Handoff purposes; it still gates the pointer plus summary plus user message but those are small by construction. - F-3: the deterministic Handoff builder (Path D) is rewritten to read
last_compact_summaryon the Thread row,tool_call_records,thought_segments,files_changedJSON on Messages, and the Fork anchor message body. No new infrastructure; all data sources already exist. - F-A:
MutatingForker(Path A) is queued for retirement pending a Cursor verification task. The verification is: launch a Cursor session, send a Turn, stop the session, resume, send a follow-up Turn, confirm parent context carries without a hidden turn. If the verification passes,MutatingForkerdeletes,sessionForkOnResume: "mutating"retires fromSessionForkBehavior, Cursor moves toCleanForkerorDeterministicForker, and the ladder reduces to B and D. TherunHiddenTurnmethod (already being removed by candidate D) loses its last reason to exist. - CONTEXT.md updates landed 2026-05-29: the
Handoffterm was rewritten to describe off-band-by-default delivery; a newScoped pre-grantterm was added; theModes and budgetssection retitled toHandoff deliverywith the agreed shape; theB/A/D laddersection gained a retirement-pending note for Path A.
D: SessionForker
- A new
SessionForkerinterface owns the Handoff strategy. The Handoff pipeline reduces toawait provider.forker.fork(req). - Three concrete forkers:
CleanForker(Path B and B-prime),MutatingForker(Path A, queued for retirement under F-A),DeterministicForker(Path D, now a named adapter rather than a fallback branch). - Each Provider holds a
forker: SessionForkerfield constructed in its constructor. Claude wiresCleanForker. Cursor wiresMutatingForker(pending F-A). Codex and Copilot wireDeterministicForker. The pipeline never branches on capability for dispatch. sessionForkOnResumecapability stays as metadata: used forhandoff.jsonprovenance, the fallback banner copy, and budget annotation. No longer drives dispatch.- The Provider methods
runSideChannelQuery?andrunHiddenTurn?retire. Their behaviour moves intoCleanForkerandMutatingForker. ForkRequestis unified:parentThreadId,prompt,cwd,parentSdkSessionId?,conversationHistory?,abortSignal?. Each forker uses the subset it needs.- Cross-forker fallback lives in the pipeline as an explicit retry: if a forker fails with a classified error, re-run via
DeterministicForker. Same outcome as today's existence-check fallback with a smaller surface.
Cross-candidate sequencing
- E lands first. The
sendTurncascade touches every Provider; getting it done unblocks A and B. - A lands second. After E,
AgentServiceis smaller and theNarrativeStoreextraction has less to displace. - B lands third. After A, Provider classes are thinner and the
SessionRuntimeextraction is cleaner. - F-core and F-3 land fourth. They depend on no other candidate but coordinate with the Path D builder rewrite.
- D lands fifth. It depends on the
TurnRequestshape from E. - F-A verification runs in parallel; if it passes before D ships,
MutatingForkeris never written.
Testing Decisions
What makes a good test
Tests cross the interface, not the implementation. The interface is the test surface: callers and tests use the same seam. A test that reaches past a module's interface to assert on internals is testing the wrong shape. If a test cannot be written through the interface, the module is probably shallow and should be deepened until the interface is the right surface.
Modules to test
NarrativeStore(both seams). Integration tests over the write seam (emitAgentEvents, assert on persisted rows and on theturn.loadreply ordering). Cover all six documented narrative pipeline traps: parent-id attribution for parallel sub-agents,agentCallStacklifecycle, volatile-state lifetime throughturn.persisted, render order on hydration, intentional step and sub-agent count overlap, and theAssistantMessageBoundaryclassification precedence.SessionRuntime<TState>(lifecycle in isolation). Unit tests with a fakeProtocolAdapterthat records calls. Cover pool insert and reuse, lazy eviction timer registration, idle eviction predicate including the busy guard, permission registry drain on shutdown, resume-then-fallback policy, env snapshot pass-through, SDK session id map.SessionForkeradapters.DeterministicForkeris easiest and highest value: seed parent message rows, run, assert on output structure.CleanForkerandMutatingForkerneed integration tests against a fake side-channel and a fake hidden-turn provider respectively.ScopedPreGrant. Security-relevant. Verify the grant is path-scoped (no broader path matches), Turn-scoped (does not survive into the second Turn), and one-shot (a second Read of the same path on the same Turn is not pre-granted).- The rewritten Path D builder. Deterministic input to output. Seed many input shapes (no tool calls, many tool calls, no compact summary, present compact summary, present and absent
files_changed, present and absent fork-anchor body) and assert on the resulting document structure.
Out of test scope
TurnRequest<P>is type-only; correctness comes from TypeScript, not runtime tests.ProtocolAdapter<TState>is per-Provider; integration via the existing Provider test suites.
Prior art
- Existing Vitest unit tests under
apps/web/src/components/chat/narrative/__tests__/cover count derivation. NewNarrativeStoretests join the same suite. - Playwright E2E tests at
apps/web/e2e/cover the user-facing flow. After A lands, add an E2E that reloads a Thread mid-Turn and asserts narrative items render in source order. - The codebase uses Vitest for unit and integration tests and Playwright for E2E.
bun run verifytypechecks, lints, and runs unit tests.bun run verify:e2eruns Playwright.
Out of Scope
- Schema collapse of narrative tables into
messageswith a kind discriminator. The user explicitly rejected this reshape; the sidecar tables stay. - Removing the
messages.tool_callsJSON column. The user retained it. - "Configure the Thread once" per-Provider thread configuration. The user clarified that per-Turn knobs change between Turns; configuring once is the wrong shape.
- Tightening the Handoff prompt around minimality bias. The user explicitly preferred full-context off-band delivery over a minimality-biased prompt restructure.
- Per-Workspace settings. Settings remain global today; CONTEXT.md notes this stays unchanged.
- Cross-provider switch mid-conversation. CONTEXT.md flags this as deferred.
- Compaction redesign. The existing compaction mechanism is consulted by F-3's Path D builder but otherwise untouched.
- Adding new Providers (Kilocode, Grok). User mentioned these as future work; out of scope for this PRD.
- Sub-agent display semantics in
NarrativeCounts.steps. The intentional Agent-call double-presence is documented as a design choice and is not changing under A.
Further Notes
- The architecture review that produced this PRD ran across 2026-05-28 and 2026-05-29. Five candidates were finalised (E, A, B, D, F). One candidate (C,
setInteractionMode) was closed as absorbed by E because the user confirmed Plan and Build are per-Turn states. - CONTEXT.md was sharpened inline during the review:
Session runtimeandProtocol adapteradded under Providers;Handoffrewritten for off-band-by-default delivery;Scoped pre-grantadded;Modes and budgetsretitled toHandoff deliverywith a reshape-pending note;B/A/D ladderannotated with the Path A retirement-pending note;Plan modeandBuild modeclarified as per-Turn states. - Coupling and sequencing: candidates touch overlapping files (
IAgentProvider, the four Provider impls,AgentService,handoff-pipeline.ts). Implementation order E, A, B, F, D minimises churn per pass. F-A verification can run in parallel with any of the others. - Path A retirement is gated on a small verification task on Cursor's
loadSession. The verification is recorded as an explicit step inproject_handoff_fidelity_shape.mdin the agent's memory. - The user's preferred testing stance is integration-level wherever it gives evidence of external behaviour. Tests at the interface, not at internals.
Activity
feat(web): hydrate persisted narrative from single ordered turn.load
refactor(codex): migrate CodexProvider onto SessionRuntime + Protocol…
refactor(providers): migrate Claude, Cursor, Copilot onto SessionRuntime
docs(context): add Session runtime + Protocol adapter terms (candidat…
refactor(handoff): SessionForker adapters replace B/A/D dispatch (can…
feat(handoff): F-3 deterministic Path-D builder reads DB signals
feat(handoff): ScopedPreGrant service (path/turn/one-shot Read bypass)
feat(handoff): F-core off-band delivery + ScopedPreGrant wiring
docs(context): off-band Handoff, Scoped pre-grant, Handoff delivery, …
refactor(narrative): move write seam (buffer/classify/persist) into N…
refactor(narrative): remove dead narrative.listBatch path after turn.…
refactor: agent provider + handoff architecture overhaul (E, A, B, D,…
chuks-qua commentedon Jun 3, 2026
Closed automatically when #543 merged. Recording the delivery summary for the record.
The full overhaul (candidates E, A, B, D, F) shipped in commit b1b4b8a ("refactor: agent provider + handoff architecture overhaul (E, A, B, D, F) (#543)").
Audit verdict: clean on A, B, D, F. One documented divergence on E — Claude's
setPlanAnswerModewas relocated to a concreteClaudeProvidermethod rather than absorbed intointeractionMode, because the plan flow distinguishes three internal states (build, plan-question-generation, plan-answer/revise-generation) that a binaryinteractionModecannot carry without bypassing the question wizard. The method left the sharedIAgentProviderinterface, which was the actual goal (kill the cross-provider knob leak).One follow-up carved out into its own issues: retiring the Cursor Path A mutating fork now that Cursor session resume is fixed.
chore: retire MutatingForker and the mutating fork capability (#596)