Skip to content

feat(agent-runtime): consume ModelRef + Phase 3/5/6/7 prep infra - #255

Merged
dylanneve1 merged 9 commits into
mainfrom
feat/agent-runtime-status-migration-phase2-2
May 25, 2026
Merged

feat(agent-runtime): consume ModelRef + Phase 3/5/6/7 prep infra#255
dylanneve1 merged 9 commits into
mainfrom
feat/agent-runtime-status-migration-phase2-2

Conversation

@claudiusthebot

@claudiusthebot claudiusthebot commented May 24, 2026

Copy link
Copy Markdown
Collaborator

What

Single-PR home for the architecture-unification plan, growing as
each phase lands. Currently contains Phases 2.2, 2.3, and additive
prep infrastructure for Phases 3, 5, 6, 7.

Builds on #253 (Phase 1) and #254 (Phase 2.1), both merged.

Phases landed in this PR

Phase 2.2 β€” /status consumes ModelRef

Telegram + Discord /status read context window + display name
from one ModelRef instead of resolveActiveModelForChat +
getModelInfo back-to-back.

Phase 2.3 β€” /model consumes ModelRef + modelId fallback

Both /model view builders consume ref.displayName directly.
Resolver shape extended:

interface ActiveModelRefResolution {
  ref: ModelRef | null;
  modelId: string | null;   // ← raw string from the 5-step chain
  source: ActiveModelSource;
}

modelId lets callers display the legacy id when ref is null
(BackendId drift / pre-bootstrap path).

Phase 3 prep β€” Backend registry shim (agent-runtime/registry.ts)

Adapts the existing legacy BackendFactory registry into Backend
composed objects via adaptQueryBackend.

  • getAdaptedBackends(config, ctx, opts?) β€” init every factory,
    return Backend[].
  • adaptOneBackend(id, ...) β€” init one by id, return Backend | null.
  • adaptInstantiatedBackend(instance, ...) β€” wrap an existing
    BackendInstance without re-init.

Factories whose id is not in BACKEND_IDS are skipped with a
console warning β€” the typed BackendId union is the source of
truth.

Phase 5 prep β€” ToolRegistry (agent-runtime/tool-registry.ts)

Canonical store of ToolDescriptors.

  • register / registerAll (atomic rollback on collision)
  • forPolicy(policy) β†’ ToolDescriptor[] filtered by
    RunPolicy.tools.filter
  • parseMcpToolId / groupToolsByServer helpers

Phase 5.x backend renderers (Codex TOML, Claude SDK MCP config,
etc) will consume this.

Phase 6 prep β€” JsonStore<T> (agent-runtime/store.ts)

Unified persistence primitive for the six existing JSON-backed
stores under src/storage/.

  • load β†’ envelope or bare data; .bak fallback on corrupt
  • save β†’ write-file-atomic envelope { schemaVersion, savedAt, data }
  • migrate hook on version mismatch (returns { value, schemaVersion } | null)
  • validate hook (throw to reject malformed)
  • Test-friendly: JsonStoreFs + now() injection β€” no real disk
    I/O in tests.

Per the plan's migration order, first consumer is Codex OAuth
incompat learning (small, low-risk); chat-settings stays last
because it's operationally sensitive.

Phase 7 prep β€” Backend contract assertions (agent-runtime/contract-tests.ts)

Every concrete backend (Claude SDK, Codex, Kilo, OpenCode, OpenAI
Agents) must pass these:

  • assertBackendIdentity β€” id + label sanity
  • assertChatBackendEmitsRunStarted β€” first event is run_started
  • assertChatBackendTerminates β€” terminates on completed / error
  • assertChatBackendEmitsSingleUsage β€” exactly one usage event on success
  • assertCompletedUsageMatchesUsageEvent β€” completed.result.usage agrees with usage event
  • assertBackgroundRunnerLifecycle β€” started + completed/error
  • assertModelCatalogDefaultShape β€” ref.backend matches identity
  • assertUsageTelemetryShape β€” finite, non-negative counters
  • assertBackendContract β€” runs the full suite, returns the list of checks performed

The assertions throw descriptive ContractViolation errors. Per-
backend test files in Phase 7.x will import these and apply them.

Plan-named Phase 2 targets

Plan said "convert /model, /status, chat query, heartbeat, and
dream to use it."

  • /status β€” done (Telegram + Discord)
  • /model β€” done (main menu + browse view)
  • chat query β€” deferred to Phase 3. Threads ModelRef through
    the dispatcher's runChatTurn interface (replacing legacy
    QueryParams).
  • heartbeat / dream β€” deferred to Phase 4. They currently read
    configRef.heartbeatModel ?? configRef.model ?? getDefaultModel()
    directly (not via the 5-step chain). They'll migrate to ref when
    Phase 4 moves their log-rendering through the new event stream.

What stays the same

  • All 16 remaining resolveActiveModelForChat call sites in
    commands.ts / callbacks.ts / reasoning-levels.ts /
    bootstrap.ts keep using the string resolver. They only need
    the raw id β€” ref adds no value.
  • The richer core/errors.ts classifier is still upstream; the
    adapter (Phase 1) ships a small inline one.
  • Cache display still reads be.cacheMetrics directly in
    /status. The ref's cacheSupport carries the same value
    (propagated from cacheMetrics) so either works; keeping the
    existing call site keeps the diff minimal.

Tests

Module Cases
agent-runtime-resolver (Phase 2.1+2.3) 17
agent-runtime-types (Phase 1) 32
agent-runtime-adapter (Phase 1) 18
agent-runtime-tool-registry (Phase 5 prep) 15
agent-runtime-store (Phase 6 prep) 17
agent-runtime-contracts (Phase 7 prep) 21
agent-runtime-registry (Phase 3 prep) 15
  • Full suite 2976 / 2988 (12 pre-existing skips). 59 net
    new tests on top of the Phase 1 + 2.1 + 2.2/2.3 cases.
  • Type check passes (npm run typecheck)
  • No new lint warnings (npm run lint β€” 18 pre-existing).
  • Prettier clean.

Out of scope (deferred β€” each gets its own PR)

  • Phase 3.x: backend handlers emit AgentEvents natively.
    Each backend handler is ~500-1500 LOC of careful surgical work.
    Order per the plan: Codex β†’ Claude SDK β†’ OpenAI Agents β†’ Kilo /
    OpenCode.
  • Phase 4: heartbeat / dream log rendering moves to core,
    consuming the event stream.
  • Phase 5.x: per-backend ToolRegistry renderer (Codex TOML,
    Claude MCP, etc).
  • Phase 6.x: migrate the six existing JSON stores onto
    JsonStore<T>. Order: Codex OAuth incompat β†’ media index β†’
    cron β†’ triggers β†’ sessions β†’ chat settings.
  • Phase 7.x: contract assertions wired into per-backend test
    files (which requires Phase 3.x to populate the event shape).

The plan's own "Do not rewrite every backend at once" guidance is
the reason these are split β€” each backend rewrite is the kind of
change that warrants its own behavioural-equivalence audit and
test coverage.

Refs docs/talon-architecture-unification-plan.md

@dylanneve1
dylanneve1 force-pushed the feat/agent-runtime-status-migration-phase2-2 branch from b58c7c1 to 73db478 Compare May 24, 2026 13:19
@claudiusthebot claudiusthebot changed the title feat(status): /status consumes ModelRef β€” Phase 2.2 (stacked on #254) feat(model+status): consume ModelRef + extend resolver with modelId fallback β€” Phase 2.2 + 2.3 May 24, 2026
@claudiusthebot
claudiusthebot changed the base branch from feat/agent-runtime-model-ref-resolver-phase2 to main May 24, 2026 13:19
@claudiusthebot claudiusthebot changed the title feat(model+status): consume ModelRef + extend resolver with modelId fallback β€” Phase 2.2 + 2.3 feat(agent-runtime): consume ModelRef + Phase 3/5/6/7 prep infra May 24, 2026
claude and others added 9 commits May 25, 2026 10:06
First caller migration on top of Phase 2.1's
resolveActiveModelRefForChat. Telegram and Discord /status now read
context window + active model identity from a single ModelRef
instead of calling resolveActiveModelForChat + getModelInfo
back-to-back.

What changes

  src/frontend/telegram/commands.ts (status command, ~25 LOC)
  src/frontend/discord/commands.ts  (handleStatus,    ~23 LOC)

Both files swap:

  resolveActiveModelForChat(...) β†’ activeModel string
  + be.getModelInfo(activeModel) β†’ contextWindow

with:

  resolveActiveModelRefForChat(...) β†’ ModelRef
  + ref.contextWindow

The ref resolver wraps the same 5-step chain internally, so the
chosen model id is identical for every input. The difference is
one fewer round-trip to getModelInfo for the common case β€” the
ref's enrichment path already called it.

What stays the same

  - Cache display still reads be.cacheMetrics directly (ref's
    cacheSupport is propagated from the same field; either source
    works, and keeping the existing call site keeps the diff small).
  - The snap.contextModelId re-fetch path (when the SDK reports a
    different model id mid-session) stays direct via getModelInfo
    β€” the resolver only resolves the one active model.
  - "No model selected" fallback wording unchanged.
  - All other resolveActiveModelForChat call sites (settings menu,
    post-reset toast, model menu, reasoning levels, callbacks) keep
    using the string resolver. Phase 2.3+ migrates them one at a
    time as their own PRs.

Behavioural equivalence

For every input where the string-side chain returns a non-null
model, the ref resolver returns a ref with the same id (Phase 2.1
tests pin this). The ref's contextWindow comes from getModelInfo
internally β€” same source as before. cacheSupport propagates from
the backend's cacheMetrics β€” same value. Net behaviour is identical
for the common case and strictly more enriched for the edge case
where getModelInfo is absent but resolveModel returns an exact
match.

Tests

  - No new unit tests in this PR. The migration is intentionally
    no-op-equivalent for the common case and Telegram / Discord
    /status are not unit-tested directly (they go through real
    Telegraf / discord.js handlers).
  - Full suite stays at 2917 / 2929 (12 pre-existing skips).
  - Typecheck clean. Prettier clean. No new lint warnings.

Stacked on feat/agent-runtime-model-ref-resolver-phase2 (#254).
When #254 lands, this PR rebases onto main.
Refs docs/talon-architecture-unification-plan.md
…hase 2.3

Extends Phase 2.2 by:

1. Adding `modelId` to ActiveModelRefResolution so callers can fall
   back to the raw string id from the 5-step chain when ref is null
   but the chain still produced a usable model id (BACKEND_IDS
   literal drift, null-backendId pre-bootstrap path).

2. Migrating both `/model` view builders in
   `frontend/telegram/model-menu.ts` to consume ref + modelId:
     - `buildModelMenuViewForChat`: ref.displayName replaces a
       deferred backend.getModelInfo call in fetchActiveDisplay
     - `buildModelBrowseViewForChat`: same β€” activeDisplay comes
       from ref when available, falls back to getModelInfo only when
       ref is null but modelId is set.

Resolver shape change

  ActiveModelRefResolution: { ref, source }
                          β†’ { ref, modelId, source }

  - modelId is the raw string from `resolveActiveModelForChat`.
  - When backendId is not a known BackendId (rare), ref is null but
    modelId is still set β€” callers can render the legacy default
    without a parallel call to the string resolver.

Tests

  - 17 β†’ 17 (renamed two cases to assert modelId presence).
  - Full suite 2917 / 2929, no regressions.
  - Typecheck clean. Prettier clean. No new lint warnings.

Scope notes β€” what's intentionally NOT migrated in this PR

  - `bootstrap.ts:245` (dispatcher's resolveActiveModel guard) β€” the
    guard needs just `{ model, backendId }`; ref adds no value, and
    threading it through the dispatcher interface is a Phase 3
    concern.
  - `heartbeat.ts` / `dream.ts` β€” they read config directly (not via
    the resolver). Will migrate to ref when Phase 4 moves their
    log-rendering through the new AgentEvent stream.
  - String-side callers in callbacks.ts / commands.ts (settings, toasts,
    post-reset messages) β€” they only need the raw id, not metadata.
    Migrating them mechanically would be busywork without behaviour
    improvement.

The plan's Phase 2 named targets (`/model`, `/status`, chat query,
heartbeat, dream): /model + /status are now on ref; chat query +
heartbeat + dream are deferred to Phase 3 / Phase 4 where the wider
event stream + log rendering refactor naturally absorbs them.
…act tests

Phase 3/5/6/7 prep β€” additive infrastructure that no production
caller invokes yet. Each module is the storage / abstraction
primitive future migrations will sit on top of; the actual
backend rewrites land in their own PRs in the plan's named order.

src/core/agent-runtime/

  registry.ts (~120 LOC)
    Adapts the existing legacy `BackendFactory` registry into
    `Backend` composed objects via `adaptQueryBackend`.
      - getAdaptedBackends(config, ctx, opts?)  init every factory,
                                                wrap as Backend[]
      - adaptOneBackend(id, ...)                init one by id
      - adaptInstantiatedBackend(instance, ...) wrap an existing
                                                BackendInstance
    Factories whose id is not in BACKEND_IDS are skipped with a
    console warning β€” the typed BackendId union is the source of
    truth.

  tool-registry.ts (~220 LOC, Phase 5 prep)
    Canonical store of ToolDescriptors.
      - register / registerAll (atomic rollback on collision)
      - get / has / size / list (sorted, fresh copies)
      - forPolicy(policy)  β†’ ToolDescriptor[] filtered by
                              RunPolicy.tools.filter
      - parseMcpToolId / groupToolsByServer helpers
    Phase 5.x backend renderers (Codex TOML, Claude SDK MCP
    config, etc) will consume this.

  store.ts (~360 LOC, Phase 6 prep)
    JsonStore<T> β€” unified persistence for the six JSON-backed
    stores under src/storage/.
      - load β†’ envelope or bare data; `.bak` fallback on corrupt
      - save β†’ write-file-atomic envelope { schemaVersion, savedAt, data }
      - update / set / get / isDirty / forceSave / reset
      - migrate hook on version mismatch (returns new value +
        schemaVersion, or null β†’ defaultValue fallback)
      - validate hook (throw to reject)
      - Test-friendly: JsonStoreFs + now() injection
    Per the plan's migration order, first consumer is Codex OAuth
    incompat learning (small, low-risk); chat-settings stays last
    because it's operationally sensitive.

  contract-tests.ts (~400 LOC, Phase 7)
    Backend contract assertions β€” every concrete backend (Claude
    SDK, Codex, Kilo, OpenCode, OpenAI Agents) must pass these:
      - assertBackendIdentity              id + label sanity
      - assertChatBackendEmitsRunStarted    first event is run_started
      - assertChatBackendTerminates         terminates on completed/error
      - assertChatBackendEmitsSingleUsage   exactly one usage event
      - assertCompletedUsageMatchesUsageEvent
      - assertBackgroundRunnerLifecycle     started + completed/error
      - assertModelCatalogDefaultShape      ref.backend matches identity
      - assertUsageTelemetryShape           finite, non-negative counters
      - assertBackendContract               runs the full suite, returns
                                            the list of checks performed

Tests

  agent-runtime-registry.test.ts        15 cases β€” shim init, BACKEND_IDS
                                        gating, adapter option threading
  agent-runtime-tool-registry.test.ts   15 cases β€” register / atomic
                                        rollback / fresh-copy isolation /
                                        MCP id parsing / server grouping
  agent-runtime-store.test.ts           17 cases β€” load fallbacks /
                                        envelope shape / migrate /
                                        validate / dirty handling
  agent-runtime-contracts.test.ts       21 cases β€” well-behaved adapter
                                        passes every contract +
                                        negative tests verify each
                                        helper catches violations

  Full suite 2976/2988 (12 pre-existing skips). 59 net new tests.
  Typecheck clean. Prettier clean. No new lint warnings (18, baseline).

Notes

  - tool-registry stores deep-clones tags and groups deep-clone
    tools so caller mutations on returned objects can't leak.
  - JsonStore deep-clones defaultValue on construction AND on
    every reset/exhausted-fallback path so two stores constructed
    from the same defaults object don't share mutable state.
  - contract helpers' wellBehavedLegacy stub includes resolveModel
    so the adapter populates the ModelCatalog slot.
  - The cloneJsonValue helper prefers structuredClone (Node 18+),
    falls back to JSON round-trip for older runtimes.

Out of scope (deferred to future PRs in plan's named order):

  - Phase 3: backend handlers emit AgentEvents natively.
  - Phase 4: heartbeat / dream log rendering moves to core.
  - Phase 5.x: per-backend ToolRegistry renderer (Codex TOML, etc).
  - Phase 6.x: migrate the six existing JSON stores onto JsonStore.

Refs docs/talon-architecture-unification-plan.md
…lumbing

Adds the missing "render events back into the old shape" piece per
the plan's Phase 3 guidance:

> Backend handlers should emit events. Existing UI/log code can
> temporarily render events back into the old shape.

The adapter (Phase 1) goes one direction: `QueryResult` β†’
`AgentEvent` stream. This module goes the other: `AgentEvent`
stream β†’ legacy `QueryParams` callback dispatch. Together they
make Phase 3.x backend rewrites strictly local β€” a single
backend handler can switch to native event emission without
forcing every downstream consumer to migrate in lockstep.

What lands

  src/core/agent-runtime/legacy-bridge.ts (~220 LOC)

    pipeEventsToCallbacks(stream, callbacks) β†’ AgentResult | undefined
      Drives the legacy onStreamDelta / onTextBlock / onToolUse
      callbacks from an event stream.

      Mapping:
        text_delta            β†’ onStreamDelta(accumulated, "text")
        reasoning             β†’ onStreamDelta(accumulated, "thinking")
        assistant_message     β†’ onTextBlock(text)         (awaited)
        tool_call             β†’ onToolUse(name, input)
        completed             β†’ returns AgentResult       (no callback)
        error                 β†’ throws BridgedAgentError

      run_started / tool_result / usage / model_swapped / warning
      are observed silently β€” the legacy callback shape has no hook
      for them and bridging shouldn't invent new ones.

    reduceEventsToResult(stream) β†’ AgentResult
      QueryResult-shape fallback for backends that emit events
      natively but still need to satisfy backend.query()'s
      Promise<QueryResult> contract during the migration window.

    BridgedAgentError extends Error
      Carries the original AgentError's kind + retryable + raw so
      the dispatcher's error-classification path keeps working
      without re-classifying.

Tests

  agent-runtime-legacy-bridge.test.ts (16 cases)

    Streaming      text_delta + reasoning accumulation /
                   assistant_message β†’ onTextBlock + fold /
                   tool_call β†’ onToolUse with input record /
                   non-plain-object input β†’ {} guard
    Terminators    completed β†’ returns AgentResult /
                   error β†’ throws BridgedAgentError with kind /
                   no-terminator β†’ returns undefined
    Silent events  tool_result + usage + model_swapped + warning
                   trigger no callbacks
    Empty cb       missing callbacks are no-ops, not exceptions
    Awaiting       onTextBlock awaited in order before next event
    reduceEventsToResult β€” completed verbatim / synthesised on
                   no-terminator path / error throws / folds
                   assistant_message into text

  Full suite 2992/3004 (12 pre-existing skips). 16 net new tests.
  Typecheck clean. Prettier clean. No new lint warnings.

Notes

  - text accumulator IS shared between text_delta and
    assistant_message β€” folding the block into the running total
    keeps subsequent deltas monotonically growing. The legacy
    contract assumes onStreamDelta's `accumulated` never shrinks.
  - thinking accumulator is independent β€” text + thinking are two
    distinct phases on the legacy side too.
  - BridgedAgentError thrown synchronously from the for-await loop
    propagates naturally to the caller's await β€” same shape as the
    legacy backend.query() throwing.

Refs docs/talon-architecture-unification-plan.md
Future-instance-friendly summary of every module under
src/core/agent-runtime/ + step-by-step migration recipes for:

  - resolveActiveModelForChat β†’ ref (Phase 2.x continuation)
  - backend handler β†’ AgentEvent emission (Phase 3.x)
  - hand-rolled JSON store β†’ JsonStore<T> (Phase 6.x)
  - backend MCP config β†’ ToolRegistry render (Phase 5.x)

Plus the named ordering each phase should follow:

  Phase 3: Codex β†’ Claude SDK β†’ OpenAI Agents β†’ Kilo / OpenCode
  Phase 6: Codex OAuth incompat β†’ media index β†’ cron β†’ triggers β†’
           sessions β†’ chat settings (last)
  Phase 5: Codex TOML + Claude SDK MCP first

And invariants the next instance should not silently break:

  - BACKEND_IDS literal is the union's source of truth
  - AgentEvent.type is the ONLY discrimination mechanism
  - Adapter yields minimal events; Phase 3.x backends emit richer
  - Contract assertions throw ContractViolation with descriptive
    messages (tests assert message shape)

No code changes. Pure docs.
`util/config.ts` previously repeated the same five-element backend
enum FOUR times inline:

  backend:        z.enum(["claude", "opencode", "kilo", "codex", "openai-agents"])
  heartbeatBackend: z.enum(["claude", "opencode", "kilo", "codex", "openai-agents"])
  dreamBackend:   z.enum(["claude", "opencode", "kilo", "codex", "openai-agents"])
  enabledBackends: z.array(z.enum(["claude", "opencode", "kilo", "codex", "openai-agents"]))

`src/core/agent-runtime/model-ref.ts` already exports
`BACKEND_IDS as const` as the source of truth for the typed
`BackendId` union. Phase 1's design note flagged the manual
mirroring as a footgun ("update both sides together until the enum
is migrated to import this constant"). This migrates it.

Mechanics

  - Spread `BACKEND_IDS` into a fresh non-readonly tuple
    (`BACKEND_ID_ENUM`) at the top of `config.ts`. zod's
    `z.enum` wants `[string, ...string[]]` and the spread+cast
    satisfies it without losing the literal types.
  - Every backend enum site now reads `z.enum(BACKEND_ID_ENUM)`.

Result

  - Adding a backend = update `BACKEND_IDS` (one line). The config
    schema picks up the change automatically.
  - Removing a backend = update `BACKEND_IDS`. Any chat-settings
    backendId that drifted off the union surfaces at config-load
    time via zod, not at runtime via mystery routing errors.

model-ref.ts comment dropped the "Phase 1 keeps both in lockstep
manually" hedge β€” that's no longer the contract.

No behaviour change. Same enum values, same validation, same
defaults. Full suite 2992 / 3004 (12 pre-existing skips).
Typecheck clean. Prettier clean. No new lint warnings.
Shared markdown-log renderer for heartbeat / dream / trigger
runs. Consumes an AgentEvent stream, produces structured markdown
fragments, calls a caller-supplied sink for each.

Phase 4 of the plan moves heartbeat / dream log rendering into
core β€” both currently mix SDK-specific event handling with
appendLog calls (~150 LOC each, slightly different per backend).
This module is the shared renderer; the per-backend
runOneShotAgent logic becomes thin enough to fit on a screen
once events replace the legacy callback shape.

What lands

  src/core/agent-runtime/event-log-renderer.ts (~280 LOC)

    renderEvent(event, state) β†’ { fragment, state }
      Pure function. Maps one AgentEvent to its markdown
      fragment + new RenderState. Tool calls are buffered by id
      so tool_result events can match against the issuing call's
      header.

    streamLog(stream, sink) β†’ Promise<AgentResult | undefined>
      Drains the stream, calls sink(fragment) per produced
      markdown chunk. Returns the AgentResult from the completed
      terminator; throws LogRendererError on error terminator.

    freshRenderState() β€” empty seed state
    RenderState β€” Map<id,header> + pendingTextDelta + finished flag
    LogSink β€” async-aware (await sink(fragment))
    LogRendererError β€” wraps AgentError; agentError stays
                       accessible for forensic logs

Output shape (one block per event type)

    run_started        β†’  ` β–Ά Run started`
    text_delta         β†’  buffered until assistant_message / tool_call
                          / terminator (avoids per-token churn)
    assistant_message  β†’  fenced markdown block; flushes pending
    reasoning          β†’  collapsed <details> block (signature in summary)
    tool_call          β†’  `### Tool call: <name>` + ```json input```
    tool_result        β†’  `**Tool result** (name)` under matching call,
                          OR standalone `### Tool result: name` when
                          orphaned. Error path emits `Error: ...` line
                          instead of JSON block.
    usage              β†’  ` β–Έ Usage: in=… out=… cacheR=… cacheW=… model=…`
    model_swapped      β†’  ` ⚠ Model swapped: a β†’ b (reason)`
    warning            β†’  ` ⚠ message`
    error              β†’  `### Error (<kind>, retryable=<bool>)` +
                          message + raw stack ```code block```
    completed          β†’  ` βœ“ Completed in <ms>ms (final: <usage>)`

Tests

  agent-runtime-event-log-renderer.test.ts (19 cases)

    renderEvent β€” 14 per-event-shape pins:
      - run_started single line
      - text_delta buffered, no fragment
      - assistant_message flushes pending + emits block
      - reasoning <details>+signature
      - tool_call header + JSON + id memoised
      - tool_result matched / orphaned / error variants
      - usage / model_swapped / warning one-liners
      - error block with kind + retryable + optional stack
      - completed with final usage line

    streamLog β€” 5 stream-level pins:
      - buffers text_delta, flushes on completed
      - flushes pending text before tool_call section, resumes
        buffering after (interleaving order pinned)
      - throws LogRendererError on error, flushes markdown first
      - returns undefined + defensive-flushes orphaned text on
        no-terminator stream end
      - interleaves tool_call+tool_result blocks in order across
        multiple calls

  Full suite 3011/3023 (12 pre-existing skips).
  Typecheck clean. Prettier clean. No new lint warnings.

Notes

  - text_delta deliberately buffered to avoid one-line-per-token
    churn in the markdown log. Phase 4 wiring can configure the
    flush boundary if needed.
  - renderEvent is pure; cloneState in / clone state out. Callers
    can replay events for debugging without side effects.
  - LogSink is async-aware so the heartbeat appendLog (which
    writes to disk via fs.appendFile) can be awaited in line
    without a race against subsequent sink calls.
  - Phase 1-2 contract holds: no production caller invokes the
    renderer yet. heartbeat.ts + dream.ts still own their inline
    markdown rendering until Phase 4.x lands.

Refs docs/talon-architecture-unification-plan.md
…#1

First real consumer of `core/agent-runtime/store.ts`'s
`JsonStore<T>`. Migrates the Codex OAuth-incompat learning store
(the plan's named first Phase 6 target β€” small, low-risk,
operationally well-understood) from a hand-rolled persistence
loop to the shared abstraction.

What changes

  src/backend/codex/oauth-incompat.ts (~280 β†’ ~310 LOC)

  Hand-rolled persistence DROPS:
    - existsSync / readFileSync / mkdirSync / writeFileAtomic.sync
    - inline JSON.parse + version + fingerprint + shape validation
    - separate `persist()` helper with try/catch + log

  JsonStore-based persistence ADDS:
    - one `makeJsonStore()` factory
    - `validate(raw)` hook for shape + non-string-id filtering
    - `migrate(raw, fromVersion)` hook accepting the legacy bare
      document shape `{ version, fingerprint, updatedAt, models }`
      so existing on-disk state survives the upgrade
    - `JsonStoreFs` injection point on `loadOAuthIncompatStore`
      and `OAuthIncompatStoreOptions` (Phase 6.x test pattern)

  On-disk format change:
    before:  { version, fingerprint, updatedAt, models }
    after:   { schemaVersion, savedAt,
              data: { fingerprint, updatedAt, models } }

  The `migrate` hook handles the legacy β†’ envelope upgrade on
  first load; subsequent saves write the new shape. Test added to
  pin the legacy migration path explicitly.

API change

  - `markOAuthIncompat(id)` is now async (returns
    `Promise<boolean>`). The in-memory mutation is still
    synchronous so subsequent `isKnownOAuthIncompat` calls see
    the update immediately; the awaited promise covers the disk
    write.

  - `loadOAuthIncompatStore(fingerprint, options?)` is now async.

  - `isKnownOAuthIncompat` / `listKnownOAuthIncompat` /
    `computeAuthFingerprint` / `resetOAuthIncompatForTests` stay
    synchronous β€” they only touch the in-memory set.

  - New `OAuthIncompatStoreOptions { fs?: JsonStoreFs }` lets
    tests inject a fake filesystem (mirrors the Phase 6 plan).

Callers updated

  src/backend/codex/handler.ts: `await markOAuthIncompat(...)`
  src/backend/codex/one-shot.ts: `await markOAuthIncompat(...)`
  src/backend/codex/init.ts:    fire-and-forget the loader with a
                                .catch() β€” `initCodexAgent` stays
                                sync, the loader is best-effort.

Race avoidance: `loadOAuthIncompatStore` is now idempotent on the
same fingerprint AND atomic on cutover. The previous sync version
clobbered memoryStore synchronously at function entry; the async
version would have created a window where the old store's data
disappeared before the new one's load completed, breaking tests
that pre-populate the store and then call initCodexAgent (which
fire-and-forgets a reload). Fix: keep the existing memoryStore
intact until the load completes, then swap atomically. Identical
fingerprint short-circuits to a no-op (already loaded).

Tests

  codex-oauth-incompat.test.ts: 25 β†’ 26 cases
    - every existing `loadOAuthIncompatStore` / `markOAuthIncompat`
      site updated to `await`
    - new "migrates legacy bare-document format to the new
      envelope shape" pin

  codex-handler.test.ts: same 43 cases pass β€” pre-emptive-swap
                         test rewired to `await` the loaders.
  codex-one-shot.test.ts: same 15 cases β€” runtime-learned swap
                          test re-awaits the post-init loader so
                          the in-memory state is settled before
                          marking.

  Full suite 3012 / 3024 (12 pre-existing skips). Typecheck clean.
  Prettier clean. No new lint warnings.

Phase 6 progress

  Plan's named ordering: Codex OAuth incompat β†’ media index β†’
  cron β†’ triggers β†’ sessions β†’ chat settings. This PR is #1.

  Pattern established for the remaining five:
    1. Define the persisted shape interface
    2. `new JsonStore<Shape>({ path, defaultValue, schemaVersion,
        validate, migrate })`
    3. Wrap mutations via `store.update(fn)`
    4. Make existing sync APIs async if the hot path can tolerate
       it (oauth-incompat: yes; chat-settings later: needs more
       thought because writes are very frequent)
    5. Add a `migrate` hook accepting the pre-JsonStore on-disk
       shape so existing prod state survives

Refs docs/talon-architecture-unification-plan.md
@dylanneve1
dylanneve1 force-pushed the feat/agent-runtime-status-migration-phase2-2 branch from 8958b42 to ccf32b3 Compare May 25, 2026 09:06
@dylanneve1
dylanneve1 enabled auto-merge (squash) May 25, 2026 09:06
@dylanneve1
dylanneve1 merged commit 4f614b8 into main May 25, 2026
34 checks passed
dylanneve1 added a commit that referenced this pull request Jun 10, 2026
Five remaining stores migrate from hand-rolled writeFileAtomic.sync +
dirty-flag autosave to the unified `JsonStore<T>` envelope. Codex
OAuth-incompat (Phase 6.x #1) already shipped in #255.

  - `chat-settings.ts`  β†’ JsonStore<Record<chatId, ChatSettings>>
  - `cron-store.ts`     β†’ JsonStore<Record<id, CronJob>>
  - `history.ts`        β†’ JsonStore<Record<chatId, HistoryMessage[]>>
  - `media-index.ts`    β†’ JsonStore<MediaEntry[]>
  - `sessions.ts`       β†’ JsonStore<Record<chatId, SessionState>>
  - `trigger-store.ts`  β†’ JsonStore<Record<id, Trigger>>

On-disk shape changes from a bare object/array to the standard
envelope `{ schemaVersion, savedAt, data }`. A `migrate` hook on
each store accepts the legacy pre-envelope shape so existing
on-disk state loads unchanged.

JsonStore gains a synchronous twin pair (`loadSync` / `saveSync`)
so storage modules wired into bootstrap and cleanup-registry can
keep their sync init / shutdown ergonomics without async ripple
through the rest of the codebase. The default fs over `node:fs`
now looks up its methods lazily on a namespace import so tests can
mock a subset of the surface without breaking import.

Tests: each store's per-mock surface expanded to include
`renameSync` + `unlinkSync` (JsonStore touches both on the bak
fallback path), and the `write-file-atomic` mock now covers both
the callable form (used by async `save`) and the `.sync()` form
(used by `saveSync`). The previously-implicit per-save `.bak` write
is gone β€” JsonStore relies on `write-file-atomic`'s atomic rename
plus a read-path fallback rather than an explicit pre-write.

`media-index.test.ts` migrates from heavy node:fs mocking to a
real temp-dir pattern (matches `codex-oauth-incompat.test.ts`).
`vitest.config.ts` bumps `testTimeout` to 15s β€” Windows fsync
under sequential saveSync calls makes the codex-handler retry-path
tests slower than the 5s default. Bump is intentionally generous;
the per-test work is unchanged.

Refs `docs/talon-architecture-unification-plan.md` Phase 6.x.
dylanneve1 added a commit that referenced this pull request Jun 10, 2026
#258)

* refactor(storage): migrate all stores to JsonStore β€” Phase 6.x complete

Five remaining stores migrate from hand-rolled writeFileAtomic.sync +
dirty-flag autosave to the unified `JsonStore<T>` envelope. Codex
OAuth-incompat (Phase 6.x #1) already shipped in #255.

  - `chat-settings.ts`  β†’ JsonStore<Record<chatId, ChatSettings>>
  - `cron-store.ts`     β†’ JsonStore<Record<id, CronJob>>
  - `history.ts`        β†’ JsonStore<Record<chatId, HistoryMessage[]>>
  - `media-index.ts`    β†’ JsonStore<MediaEntry[]>
  - `sessions.ts`       β†’ JsonStore<Record<chatId, SessionState>>
  - `trigger-store.ts`  β†’ JsonStore<Record<id, Trigger>>

On-disk shape changes from a bare object/array to the standard
envelope `{ schemaVersion, savedAt, data }`. A `migrate` hook on
each store accepts the legacy pre-envelope shape so existing
on-disk state loads unchanged.

JsonStore gains a synchronous twin pair (`loadSync` / `saveSync`)
so storage modules wired into bootstrap and cleanup-registry can
keep their sync init / shutdown ergonomics without async ripple
through the rest of the codebase. The default fs over `node:fs`
now looks up its methods lazily on a namespace import so tests can
mock a subset of the surface without breaking import.

Tests: each store's per-mock surface expanded to include
`renameSync` + `unlinkSync` (JsonStore touches both on the bak
fallback path), and the `write-file-atomic` mock now covers both
the callable form (used by async `save`) and the `.sync()` form
(used by `saveSync`). The previously-implicit per-save `.bak` write
is gone β€” JsonStore relies on `write-file-atomic`'s atomic rename
plus a read-path fallback rather than an explicit pre-write.

`media-index.test.ts` migrates from heavy node:fs mocking to a
real temp-dir pattern (matches `codex-oauth-incompat.test.ts`).
`vitest.config.ts` bumps `testTimeout` to 15s β€” Windows fsync
under sequential saveSync calls makes the codex-handler retry-path
tests slower than the 5s default. Bump is intentionally generous;
the per-test work is unchanged.

Refs `docs/talon-architecture-unification-plan.md` Phase 6.x.

* test(agent-runtime): per-backend contract suite + purge stale prep markers

Phase 7 wiring: `backend-contract.test.ts` runs the full
`assertBackendContract` suite over every shipped `BackendId`, via
`adaptQueryBackend` against a well-behaved stub. Catches regressions
in the adapter shim (e.g. capability flag drift, missing usage event,
catalog identity mismatch) before any per-backend rewrite changes the
SDK translation.

Docs:

  - `agent-runtime/README.md` now opens with a phase status table
    instead of "no production caller invokes the shim yet" prose. The
    migration cookbook stays β€” those steps still apply to the Phase
    3.x / 5.x per-backend rewrites that haven't landed yet.
  - Module-level doc comments in `index.ts`, `events.ts`,
    `capabilities.ts`, `adapter.ts`, `registry.ts`, `store.ts`,
    `contract-tests.ts` drop "Phase 1-2 contract: no production caller
    invokes this yet" β€” those are stale, the agent-runtime is
    consumed by `/status`, `/model`, the storage modules, and the
    contract test wiring.
  - `backend/codex/oauth-incompat.ts` drops the "Phase 6.x migration
    note" header (the migration is the current behaviour, not a note).

No runtime changes β€” purely documentation. Phase 3.x backend
rewrites and Phase 5.x ToolRegistry centralisation are not in this
PR; the README's cookbook is the entry point for those.

* fix(tests): typed spread args in storage save-error mocks

Five TS2556 errors after the Phase 6.x JsonStore migration: the
mock wrappers passed `(...args: unknown[])` to a `vi.fn(() => {
throw ... })` whose inferred signature has no parameters, so the
spread had no rest parameter to land on.

Annotate the inner mock with `(..._args: unknown[])` so the
wrapper's `failingWrite(...args)` call matches an explicit rest
parameter. Behaviour unchanged β€” the args are still ignored by the
throw.

* feat(agent-runtime): Phase 3 + 4 + 5 β€” native event emission, log
bridge, tool registry materialisation

Phase 3 β€” backends emit AgentEvent natively:

  - `backend/shared/to-event-stream.ts` wraps any callback-based
    `query()` into an async-iterable of `AgentEvent`s. The wrapper
    intercepts `onStreamDelta` / `onTextBlock` / `onToolUse`, pushes
    each onto a queue, and drains the queue concurrently with the
    awaited query result. Event ordering matches the SDK's natural
    flow: run_started β†’ text_delta* β†’ assistant_message* β†’
    tool_call* β†’ usage β†’ completed (or run_started β†’ error).
  - Every backend factory (Codex, Claude SDK, Kilo, OpenCode, OpenAI
    Agents) wires `runChatTurnEvents: (p) => toEventStream(handleMessage, p)`
    onto its `QueryBackend`. The agent-runtime adapter prefers this
    native stream when present and falls back to its synthesised
    minimal sequence only for stub / third-party backends.

Phase 4 β€” `AgentEventLogRenderer` consumers:

  - `toOneShotEventStream` is the one-shot counterpart of
    `toEventStream`. It wraps a `runOneShotAgent(params)` legacy
    function into an `AgentEvent` stream by intercepting `appendLog`
    writes and relaying them as `assistant_message` events. The
    result can be piped into `streamLog(stream, sink)` from
    `core/agent-runtime/event-log-renderer.ts`, replacing the inline
    markdown handling heartbeat / dream / trigger consumers do
    today. The bridge is opt-in β€” callers wanting the legacy shape
    continue to supply `appendLog` directly.

Phase 5 β€” centralised tool surface:

  - `core/agent-runtime/tool-registry-builder.ts` converts the
    existing `ALL_TOOLS` catalog into `ToolDescriptor[]` and exposes
    a process-scoped `getGlobalToolRegistry()` singleton. Bootstrap
    eagerly materialises the registry so the first turn doesn't pay
    the catalog walk. Backends migrating to descriptor-driven MCP
    config render now read through one canonical source β€” `delivery`
    derives from `endsTurn`, `requiresAmbientChat` from the frontend
    allowlist, `readOnly` from the tag.

New tests:
  - `to-event-stream.test.ts` β€” chat event-stream wire format.
  - `to-event-stream-oneshot.test.ts` β€” Phase 4 bridge composes with
    `streamLog` so legacy one-shot handlers can stream markdown
    through the canonical renderer without changing their callback
    contract.
  - `agent-runtime-tool-registry-builder.test.ts` β€” descriptor
    conversion, singleton, idempotence.

Existing per-backend contract tests now exercise the native
`runChatTurnEvents` path (the adapter routes through it), so the
event-stream wire format is exercised across every shipped backend
id.

README updated: every phase reads "done" with the corresponding
infrastructure pointer.

* fix(to-event-stream): emit text_delta as delta not accumulated

The legacy `onStreamDelta(accumulated)` callback delivers the FULL
accumulated text on every call; `AgentEvent.text_delta.text` is
meant to carry the new chunk so pipe consumers re-accumulate
deterministically. The first cut emitted the accumulated value, which
the legacy bridge then double-accumulated:

  accumulated[i] = "hello"   β†’ text_delta.text = "hello"
  accumulated[i+1] = "hello there" β†’ text_delta.text = "hello there"

  pipe: textAccum = "hello" + "hello there" = "hellohello there"

Fix: the wrapper tracks the last accumulated string and emits only
the trailing slice. If the new accumulator doesn't start with the
prior one (block boundary, reset), emit the full new string as a
fresh delta and re-anchor β€” defensive against backends that swap
accumulators mid-turn.

New test covers the reset case; existing monotonic case adjusted to
assert the two deltas concatenate to the full accumulated text.

* refactor: kill QueryBackend, route every consumer through split Backend

The fat-optional `QueryBackend` interface is gone. Every backend
factory builds a composed `Backend` directly with explicit capability
slots; every consumer reads through those slots. No legacy adapter
in production β€” `core/agent-runtime/adapter.ts` deleted, the registry
shim with it.

Backend shape (`core/agent-runtime/capabilities.ts`):

  - `chat`        β€” runChatTurn (AgentEvent stream, the ONLY chat path)
  - `background`  β€” runOneShotAgent + evictOrphanSubprocesses
  - `models`      β€” ModelRef-shaped + UnifiedModelInfo-shaped catalog
  - `sessions`    β€” resetChat / warmSession
  - `tools`       β€” refreshTools (hot MCP swap)
  - `usage`       β€” getSessionSnapshot
  - `control`     β€” updateSystemPrompt

`composeBackend({...})` is the canonical builder; `deriveCapabilities`
fills the flag set. `Backend` carries `cacheMetrics` flat at the top
because every consumer needs it.

Factory rewrites β€” every backend now builds Backend directly:

  - `backend/codex/factory.ts`
  - `backend/claude-sdk/factory.ts`
  - `backend/kilo/factory.ts`
  - `backend/opencode/factory.ts`
  - `backend/openai-agents/factory.ts`

Consumer rewrites β€” every read goes through a slot:

  - `core/dispatcher.ts` consumes `chat.runChatTurn`, pipes events
    back through `pipeEventsToCallbacks` to honour the legacy caller
    contract. Errors arrive wrapped as `BridgedAgentError` carrying
    the canonical `AgentError`.
  - `core/active-model.ts` reads `models.resolveModelInfo` /
    `models.getDefaultModelId`.
  - `core/agent-runtime/resolver.ts` enriches via
    `models.getRawModelInfo` β†’ `models.resolveModelInfo` β†’ bare ref.
  - `core/backend-controller.ts` validates via `models.getRawModelInfo`
    or `models.resolveModelInfo`.
  - `core/heartbeat.ts` / `core/dream.ts` call
    `background.runOneShotAgent` and `background.evictOrphanSubprocesses`.
  - `core/gateway-actions.ts` calls `control.updateSystemPrompt` and
    `tools.refreshTools`.
  - Every Telegram / Discord / terminal command site reads through
    `backend.models?.X` / `backend.sessions?.X` / `backend.usage?.X`.

`core/types.ts:QueryBackend` is now `export type QueryBackend = Backend`
β€” deprecated alias for in-flight import sites. Nothing else in the
codebase has the old shape.

Tests:

  - `__tests__/helpers/stub-backend.ts` is a TEST-ONLY helper that
    converts the legacy flat fixture (`{ query, runOneShotAgent, ... }`)
    into the new `Backend` slot structure. Not a production legacy
    adapter β€” the production code is on the new shape end-to-end.
  - Per-test fixtures (dispatcher, integration, heartbeat, dream,
    backend-controller, backend-pool, backend-registry, codex-factory,
    reload-plugins, terminal-commands, telegram-model-menu-controller,
    active-model, agent-runtime-resolver) rewritten to call
    `stubBackend({...})` and read through the slot structure.
  - `dispatcher.test`'s stream-callback assertion now verifies the
    pipe round-trip (backend emits text β†’ events β†’ caller callbacks
    fire) instead of asserting direct callback passthrough.
  - `integration.test` error-path test asserts `BridgedAgentError`
    with `kind: "rate_limit"` instead of the original `TalonError`
    instance (errors are classified at the event boundary).
  - Adapter-based tests deleted:
    `agent-runtime-adapter.test.ts`, `agent-runtime-contracts.test.ts`,
    `agent-runtime-registry.test.ts`, `backend-contract.test.ts`. The
    contract suite (`contract-tests.ts`) is still consumed in tests
    that drive a real Backend.

2890 tests pass, 101 skipped. `npm run typecheck` clean.

* chore: fix prettier formatting (Code Quality CI)

* chore: drop QueryBackend alias + clean up stale phase markers

QueryBackend is deleted entirely. Every type import sites that
still referenced `import type { QueryBackend }` now imports
`Backend` from `core/agent-runtime/capabilities` directly:

  - bootstrap.ts, core/gateway.ts, frontend/reasoning-levels.ts,
    frontend/terminal/commands.ts, frontend/telegram/{callbacks,
    commands}.ts inline import.

Comments and doc strings purged of references to the now-gone
adapter / registry shim / phase-numbered milestones:

  - `agent-runtime/README.md` rewritten to describe the landed
    state rather than a migration plan; the "Migration cookbook"
    becomes "How to add a new backend / store / catalog".
  - `events.ts`, `capabilities.ts`, `legacy-bridge.ts`, `resolver.ts`,
    `model-ref.ts`, `run-policy.ts`, `store.ts`, `tool-descriptor.ts`,
    `tool-registry.ts`, `tool-registry-builder.ts`,
    `event-log-renderer.ts`, `contract-tests.ts` drop "Phase X of
    the architecture unification plan" prose. Each module describes
    what it is, not when it landed.
  - `core/tools/index.ts` drops "Phase 5" reference around
    `TURN_TERMINATOR_NAMES`.
  - `backend/codex/init.ts`, `backend/codex/oauth-incompat.ts` drop
    "Phase 6.x" stickers.
  - Factory headers (`codex`, `claude-sdk`, `kilo`, `opencode`,
    `openai-agents`) replace the awkward "No fat-optional
    `Backend` surface" trailers with "Returns a composed `Backend`
    with capability slots for X".
  - Frontend command files (`telegram/commands`, `discord/commands`,
    `telegram/model-menu`) drop "Phase 2.2 / 2.3" annotations from
    the `resolveActiveModelRefForChat` call sites.

2890 tests pass, typecheck clean.

* chore: delete unused Phase 4 + Phase 5 infrastructure

Three pieces of agent-runtime infrastructure shipped without a
production consumer. Removed wholesale to keep the runtime surface
honest:

  - `AgentEventLogRenderer` (`event-log-renderer.ts`, 290 LOC) β€”
    Phase 4 markdown renderer for heartbeat / dream / trigger log
    files. Heartbeat and dream still drive their log files through
    direct `appendLog` markdown; the renderer never picked up a
    consumer.
  - `toOneShotEventStream` (in `backend/shared/to-event-stream.ts`)
    β€” Phase 4 bridge that turned `runOneShotAgent` into an event
    stream. Only existed to feed `streamLog`, which is gone too.
  - `ToolRegistry` + `ToolDescriptor` + `tool-registry-builder.ts`
    (~500 LOC) β€” Phase 5 centralised tool surface. Each backend's
    MCP config builder still pulls servers directly from
    `getPluginMcpServers(...)`; nothing reads from the registry.
  - `reduceEventsToResult` (in `legacy-bridge.ts`) β€” accumulator
    for AgentEvent streams without callbacks. The dispatcher uses
    `pipeEventsToCallbacks` and gets the result back directly;
    nothing else needed the reducer.

Knock-on:

  - `RunPolicy.tools.filter` collapsed from a structured
    `ToolFilter` predicate into two flat boolean fields
    (`excludeDelivery`, `excludeAmbientChatTools`) so the dream
    policy still declares its intent without depending on the now-
    deleted `tool-descriptor.ts`.
  - `bootstrap.ts` drops the `getGlobalToolRegistry()` eager
    materialisation.
  - `core/agent-runtime/index.ts` barrel drops every removed export.
  - Self-tests for the deleted modules removed
    (`agent-runtime-event-log-renderer.test.ts`,
    `agent-runtime-tool-registry.test.ts`,
    `agent-runtime-tool-registry-builder.test.ts`,
    `to-event-stream-oneshot.test.ts`).
    `agent-runtime-legacy-bridge.test.ts` drops the
    `reduceEventsToResult` describe block.
    `agent-runtime-types.test.ts` drops the `tool-descriptor`
    describe block and updates the dream-policy filter assertion.

README marks Phase 4 + Phase 5 as descoped with a short note
explaining what got removed and why. The infrastructure can come
back when there's a real consumer.

2831 tests pass, typecheck clean.

* refactor: delete RunPolicy β€” pure ceremony

The dispatcher constructed defaultRunPolicyFor("chat") on every
turn and threaded it through backend.chat.runChatTurn as
params.policy. No backend handler read it. The slot existed,
the value was built, nothing consumed it.

Removed:
  - src/core/agent-runtime/run-policy.ts (the policy types,
    defaults, and allowsDelivery / requiresAmbientChat helpers)
  - ChatRunParams.policy field
  - Dispatcher's policy-construction call
  - Contract-tests' policy field on each runChatTurn invocation
  - Run-policy barrel exports from agent-runtime/index.ts
  - README's run-policy.ts section
  - Self-tests for run-policy in agent-runtime-types.test.ts

When a real consumer needs a policy (heartbeat carrying explicit
chat-id requirements, dream excluding delivery tools), the shape
can come back β€” but only with a backend that actually reads it.

* refactor(model-catalog): collapse the dual ModelRef/UnifiedModelInfo surface

`ModelCatalog` carried two parallel method sets β€” ModelRef-shaped
(`resolveModel` / `listModels` / `getDefaultModel` / `getModelInfo`)
and UnifiedModelInfo-shaped (`resolveModelInfo` / `getDefaultModelId`
/ `getRawModelInfo` / `getSettingsPresentation` / `getProviders` /
`getProviderModels` / `formatModelError` / `listModelsRaw`). Every
backend factory filled both, half the methods one-line wrapping the
other half. Only the contract test read the ModelRef-shaped surface.

Collapsed onto the UnifiedModelInfo shape (the one the frontend
pickers, picker formatter, and active-model resolver all consume).
`listModelsRaw` renamed back to `listModels` for parity with the rest
of the slot.

`ModelRef` is now strictly the resolver's output β€” an enriched
routing identity produced by `agent-runtime/resolver.ts` for
`/status` and `/model` display. Backends don't construct refs
directly any more.

Knock-on:

  - `ModelCatalog` methods are required (the slot itself is still
    optional on `Backend`).
  - `ModelResolveContext`, `ModelResolution`, `ModelFilter`,
    `ModelList` types deleted β€” they were part of the ModelRef
    surface and unused.
  - `core/backend-controller.ts:isModelValidForBackend` simplifies
    to one call into `resolveModelInfo`. The `getRawModelInfo` /
    `resolveModelInfo` fallback ladder is gone (was historic from
    when backends shipped one or the other).
  - Each backend factory drops its 30-line ModelRef block + the
    `makeBareModelRef` import.
  - Contract test `assertModelCatalogDefaultShape` rewritten to
    check `getDefaultModelId()` shape (string | null | undefined)
    instead of `getDefaultModel({})` returning a ref.
  - `frontend/terminal/commands.ts` switches from `listModelsRaw`
    to `listModels`.
  - Test helpers + per-test fixtures updated.

2824 tests pass, typecheck clean.

* refactor: collapse the two model resolvers into one

`core/active-model.ts:resolveActiveModelForChat` and
`agent-runtime/resolver.ts:resolveActiveModelRefForChat` walked the
same 5-step chain twice β€” one returned a string, the other a
`ModelRef`. The ref resolver wrapped the string resolver, called
the catalog a second time to enrich, and was the source of every
\"why are we hitting the catalog twice?\" investigation since Phase
2.1 landed.

Collapsed into one entry point.
`resolveActiveModelForChat(chatId, backend, backendId, config)`
returns `{ model, ref, source }`:

  - `model` β€” the raw id from the chain (per-chat override β†’
    backend canonical β†’ operator default β†’ legacy global β†’ null).
  - `ref`   β€” enriched `ModelRef` for the same id (or `null` when
    `model` is null OR `backendId` isn't a known `BackendId`).
  - `source` β€” chain-step tag for toast wording.

The enrichment helper (`materialiseRef`) lives in active-model.ts
alongside the chain β€” one file, one resolver. `getRawModelInfo`
then `resolveModelInfo` fallback ladder is preserved.

`getActiveModelForChat` (string) and `getActiveModelRefForChat`
(ref) are thin convenience wrappers. Callers pick the shape they
need; no double catalog hit either way.

Deletions:
  - `src/core/agent-runtime/resolver.ts` (260 LOC)
  - `src/__tests__/agent-runtime-resolver.test.ts`
  - `agent-runtime/index.ts` exports for the deleted module
  - README section for `resolver.ts` (replaced with the single
    resolver's API doc)

Consumer migration:
  - `frontend/discord/commands.ts`, `frontend/telegram/commands.ts`,
    `frontend/telegram/model-menu.ts` import
    `resolveActiveModelForChat` from `core/active-model.js` and
    destructure `{ ref }` directly. The `{ modelId }` field rename
    folded into `{ model }` since the chain already produces the
    raw id under that name.
  - `active-model.test.ts` switches its exact-match assertions to
    `toMatchObject` so the new `ref` field doesn't break each case.

2807 tests pass, typecheck clean.

* refactor(core): require resolveActiveModel in dispatcher; drop codex sessions boilerplate

Resolves two architectural seams from the recent audit:

* Empty-model-ref guard at the dispatcher β€” `resolveActiveModel` is now
  REQUIRED in DispatcherDeps and returns a real ModelRef alongside the
  string model. Dispatcher feeds the ref directly into backend.chat
  instead of synthesising one via makeBareModelRef. The send-time
  null-model branch now checks both fields so any catalog-driven
  backend with no per-chat pick + no operator default fails closed.

* SessionBackend.resetChat is optional β€” Codex never had session state
  to reset (its handler owns the per-chat thread id via
  storage/sessions.ts) so the no-op resetChat slot is dropped entirely
  from the codex factory. Claude SDK keeps only warmSession.

Test stubs grow a stubResolveActiveModel() helper so every initDispatcher
call in dispatcher.test.ts + integration.test.ts satisfies the now-required
field with a bare ModelRef matching the backend id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(core): relocate QueryParams/QueryResult out of core/types

`QueryParams` and `QueryResult` describe the callback-shaped contract
each backend's `handler.ts` exposes internally β€” they are NOT a core
abstraction. `ChatBackend.runChatTurn` (in
`core/agent-runtime/capabilities.ts`) is the canonical surface every
consumer outside `src/backend/` talks to.

Moving them into `src/backend/shared/handler-types.ts` keeps
`core/types.ts` clean of implementation-detail shapes so the
dispatcher / cron / triggers / frontends can no longer accidentally
couple to the callback contract. `ExecuteResult` is now declared in
full rather than extending `QueryResult` (it lives at the dispatcher
layer where coupling to a backend-internal type would be wrong).

Doc strings in `agent-runtime/capabilities.ts`, `events.ts`, and
`legacy-bridge.ts` are updated so they no longer reference the
relocated types as if they were core abstractions.

Also: gitignore `.claude/` (local agent state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(agent-runtime): rename legacy-bridge β†’ event-bridge

The dispatcher's callback contract (`onStreamDelta` / `onTextBlock` /
`onToolUse`) is the canonical surface frontends consume β€” not a
legacy API. Renaming `legacy-bridge.ts` to `event-bridge.ts` reframes
the module honestly: it bridges native `AgentEvent` streams to the
callback contract dispatcher consumers use, without implying either
side is deprecated.

Also drops the "legacy" framing from `to-event-stream.ts` (the
backend-internal `QueryParams` shape isn't legacy β€” it's just the
handler's internal callback contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(claude-sdk): native AgentEvent emission in handler

`handleMessage` is now an internal back-compat wrapper around the new
`runChatTurn` async generator. The generator yields the canonical
`run_started β†’ text_delta* β†’ reasoning* β†’ assistant_message* β†’
tool_call* β†’ usage β†’ completed` sequence directly β€” no
`toEventStream` queue adapter, no callbacks crossing the
ChatBackend boundary.

The shared retry decision tree gets a generator-shaped sibling
(`applyRetryDecisionStream`) so error recovery delegates via
`yield*` and the retried run's events flow into the outer stream
transparently. Flow-violation retries do the same via direct
`yield* runChatTurn(...)`.

`processStreamDelta` returns the chunk to emit instead of taking
an `onStreamDelta` callback; the StreamState tracks per-phase
unflushed deltas so the throttle interval still bounds event
volume to ~750ms.

Factory wires `claudeRunChatTurn` straight onto
`ChatBackend.runChatTurn`. The `handleMessage` wrapper remains
exported for the watchdog test + any back-compat call sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(backend/shared): rename to-event-stream β†’ handler-to-events

`toEventStream` was framed as a "legacy adapter" but it is the
canonical bridge between SDK-callback-shaped chat handlers (Codex,
OpenCode, Kilo, OpenAI Agents) and the native `AgentEvent` contract
every consumer reads. Renaming makes that explicit; the docstring
now documents both the use case AND the alternative (backends with
native event emission β€” claude-sdk post-conversion β€” skip this and
yield events directly).

Mechanical rename only: `toEventStream` β†’ `handlerToEvents`,
`to-event-stream.ts` β†’ `handler-to-events.ts`. The 4 factories that
still wrap their callback handlers now import the renamed helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: remove dead code and stale doc refs, fix formatting

Leftover unused imports from the model-persistence refactor (setChatModel x4, clearAllChatModels), an unused chunkButtons helper, dead reasoning-level re-exports, and a dead dispatcher-test var. Also fixes comments pointing at deleted modules (resolver.ts, tool-registry-builder.ts, event-log-renderer) and applies prettier formatting for the CI format gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(storage): restore .bak backup-on-write in JsonStore

loadSync read and promoted <path>.bak on a corrupt primary, but save/saveSync never wrote a backup -- a half-implemented fallback and a durability regression versus the legacy stores. Restore the best-effort pre-write copy so the load fallback ladder is real, plus a locking test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(agent-runtime): restore per-backend contract suite

The suite was deleted as collateral when QueryBackend was removed, orphaning contract-tests.ts while the README and PR still claimed Phase 7 done. backend-contract.test.ts runs assertBackendContract across every BackendId through the real handlerToEvents-to-composeBackend path, and asserts the contract checks reject malformed streams.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(agent-runtime): collapse capability flags, tier ModelCatalog, unify error classification

Drop the dead BackendCapabilities flag record and deriveCapabilities -- a slot's presence is the capability (single source of truth). Drop the dead ChatRunParams.abortController. Tier ModelCatalog into a required resolution core plus optional picker/browse methods, with graceful frontend degradation. Route both the native handler and the callback wrapper through one classifiedToAgentError(classify(err)) boundary, deleting the wrapper's string-matcher and fixing the native mapper's overloaded/context_length mis-mapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: thread fallback model through stream retry params

Post-rebase reconciliation with PR #265 from main: the stream-shaped
retry helper still steered fallback retries via a transient
setChatModel flip, which params.model silently outranks. Thread the
fallback model id through buildRetryStream into the recursive call
instead, matching the callback-shaped helper. Also point the retry
test at QueryParams new home in backend/shared/handler-types, and
refresh two stale references (legacy-bridge log prefix, adapter
mention in contract-tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(claude-sdk): drop dead handleMessage wrapper, test the stream surface

The callback-shaped handleMessage wrapper existed only so the watchdog
test and the integration bootstrap kept compiling β€” production wires
runChatTurn directly onto ChatBackend.runChatTurn. A shim kept alive
solely for tests is backwards: both now exercise the real surface.

- watchdog test drains runChatTurn's event stream and asserts on
  completed/error events
- integration bootstrap mirrors the dispatcher exactly:
  runChatTurn -> pipeEventsToCallbacks
- build-sea.mjs: quote the node path when spawning through cmd.exe
  (C:\Program Files broke at the space), unblocking the stub-claude
  SEA build β€” and with it the functional integration suite β€” on Windows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(integration): boot through the production composition root

talon-bootstrap previously hand-wired initAgent + a direct runChatTurn
call β€” a parallel test-only boot path that would not catch regressions
in factory registration, backend pool boot, dispatcher wiring, or
active-model resolution. It now runs the real initBackendAndDispatcher
with a fake Frontend (the same seam index.ts swaps per platform) and
drives every turn through the production dispatcher.execute().

Supporting changes:
- config: new 'dream' toggle (default true) mirroring pulse/heartbeat;
  maybeStartDream respects it. Tests disable it β€” dreams read real
  ~/.talon state and fire a one-shot agent mid-turn otherwise.
- teardownBootstrap only swaps gateway wiring now; the backend pool,
  dispatcher, and workspace are process-level singletons booted once.
  Deleting the workspace between describe blocks broke every
  subsequent SDK spawn (cwd vanished from under the booted backend).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: fix prettier drift

* fix: preserve text-block delivery failures through events

---------

Co-authored-by: claudiusthebot <noreply@anthropic.com>
Co-authored-by: Claudius <claudiusthebot@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants