Skip to content

feat(agent-runtime): finish architecture unification plan (Phases 3-7) - #258

Merged
dylanneve1 merged 26 commits into
mainfrom
feat/architecture-unification-completion
Jun 10, 2026
Merged

feat(agent-runtime): finish architecture unification plan (Phases 3-7)#258
dylanneve1 merged 26 commits into
mainfrom
feat/architecture-unification-completion

Conversation

@dylanneve1

@dylanneve1 dylanneve1 commented May 25, 2026

Copy link
Copy Markdown
Owner

Summary

Completes the agent-runtime architecture unification. Every backend now
exposes a single composed Backend of orthogonal capability slots and emits
one canonical AgentEvent stream; every JSON store sits on one JsonStore<T>
primitive; and the intermediate scaffolding built along the way (adapter /
registry / resolver / RunPolicy / tool-registry / event-log-renderer) is gone.

Net −3,150 lines. See src/core/agent-runtime/README.md for the module map.

The unified Backend

QueryBackend (a fat optional bag) is replaced by a composed Backend with
explicit capability slots — chat, background, models, sessions,
tools, usage, control. composeBackend({...}) is the single builder, and
a slot's presence is the capability (no mirrored flag record to drift).
Consumers read backend.chat?.runChatTurn(...).

  • Chat is one event stream. ChatBackend.runChatTurn returns
    AsyncIterable<AgentEvent> (run_started → text_delta* → assistant_message* → tool_call* → usage → completed, or → error). The Claude SDK handler
    emits these natively; the callback-based backends (Codex, Kilo, OpenCode,
    OpenAI Agents) wrap their handleMessage through shared/handler-to-events.ts.
    event-bridge.ts:pipeEventsToCallbacks adapts the stream back to the
    dispatcher's callback contract.
  • One model resolver. core/active-model.ts:resolveActiveModelForChat is
    the single 5-step chain (per-chat override → backend canonical → operator
    default → legacy global → none) returning { model, ref, source }. The
    dispatcher requires it and refuses to send when no model resolves.
  • Tiered ModelCatalog. A required resolution core (resolveModelInfo /
    getDefaultModelId / getRawModelInfo) plus an optional picker/browse
    surface. A fixed-model backend implements three methods; the /model picker
    degrades gracefully when the rest are absent.
  • One error taxonomy. classify()classifiedToAgentError() is the
    single error→AgentError boundary; the native handler and the wrapper both
    route through it (no more per-path message-substring guessing).

Storage

All JSON-backed stores sit on JsonStore<T> with the envelope
{ schemaVersion, savedAt, data }, a migrate hook that loads each store's
pre-envelope legacy shape (existing ~/.talon/data/*.json round-trips
unchanged), a .bak backup-on-write with corrupt-primary fallback, and
sync/async twins for the bootstrap/flush paths.

Tests

  • Per-backend contract suite (backend-contract.test.ts) runs
    assertBackendContract across every BackendId through the real
    handlerToEventscomposeBackend path, and asserts the contract
    assertions themselves reject malformed streams.
  • npm run typecheck, lint, format:check clean; full unit/integration
    suite green.

Migration notes

  • On first boot after deploy, confirm the existing on-disk JSON files
    round-trip through the new migrate hooks (covered by legacy-shape tests).

🤖 Generated with Claude Code

dylanneve1 and others added 24 commits June 10, 2026 15:57
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.
…rkers

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.
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.
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.
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.
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.
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.
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.
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.
…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.
`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.
…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>
`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>
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>
`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>
`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>
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>
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>
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>
…, 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>
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>
…m 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>
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>
@dylanneve1
dylanneve1 force-pushed the feat/architecture-unification-completion branch from c010658 to 816c07a Compare June 10, 2026 16:10
@dylanneve1

Copy link
Copy Markdown
Owner Author

Rebased onto latest main (bc8ae9e) and pushed. Notes for review:

Conflict resolution — main's fixes that touched files this PR deletes were verified preserved in their replacements:

New commits on top of the rebase:

  • 82f2122 — the stream retry helper still used the transient setChatModel flip that fix: thread fallback model through params instead of setChatModel #265 proved is a silent no-op (params.model outranks it). Fallback model now threads through buildRetryStream.
  • 8586491 — dropped the dead handleMessage wrapper in claude-sdk (only tests consumed it); watchdog test now drains the real runChatTurn stream. Also fixed the stub-claude SEA build on Windows (unquoted C:\Program Files path).
  • 816c07atalon-bootstrap.ts now boots through the real initBackendAndDispatcher and drives turns through the production dispatcher.execute() instead of a parallel test-only wiring path. Adds the missing dream config toggle (mirrors pulse/heartbeat).

Local verification: typecheck clean, lint clean (pre-existing warnings only), unit suite green, all 47 stub-claude functional integration tests green (now running on Windows too). package.functional and claude-live-discovery fail locally due to corporate proxy/TLS — they fail identically on unmodified main.

@claudiusthebot claudiusthebot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed after the event-bridge delivery fix. The previous text-block delivery failure regression is covered now: callback-shaped backends wait for assistant_message delivery acknowledgement, the bridge resolves/rejects that acknowledgement, and dispatcher tests pin the Codex-style retry path. Local focused tests, typecheck, format, lint, and the rerun of the Kilo bootstrap case pass; GitHub CI is green.

@dylanneve1
dylanneve1 merged commit d30d596 into main Jun 10, 2026
34 checks passed
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