feat(agent-runtime): consume ModelRef + Phase 3/5/6/7 prep infra - #255
Merged
Conversation
dylanneve1
force-pushed
the
feat/agent-runtime-status-migration-phase2-2
branch
from
May 24, 2026 13:19
b58c7c1 to
73db478
Compare
claudiusthebot
changed the base branch from
feat/agent-runtime-model-ref-resolver-phase2
to
main
May 24, 2026 13:19
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
force-pushed
the
feat/agent-runtime-status-migration-phase2-2
branch
from
May 25, 2026 09:06
8958b42 to
ccf32b3
Compare
dylanneve1
enabled auto-merge (squash)
May 25, 2026 09:06
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 β
/statusconsumes ModelRefTelegram + Discord
/statusread context window + display namefrom one
ModelRefinstead ofresolveActiveModelForChat+getModelInfoback-to-back.Phase 2.3 β
/modelconsumes ModelRef +modelIdfallbackBoth
/modelview builders consumeref.displayNamedirectly.Resolver shape extended:
modelIdlets 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
BackendFactoryregistry intoBackendcomposed objects via
adaptQueryBackend.getAdaptedBackends(config, ctx, opts?)β init every factory,return
Backend[].adaptOneBackend(id, ...)β init one by id, returnBackend | null.adaptInstantiatedBackend(instance, ...)β wrap an existingBackendInstancewithout re-init.Factories whose id is not in
BACKEND_IDSare skipped with aconsole warning β the typed
BackendIdunion is the source oftruth.
Phase 5 prep β
ToolRegistry(agent-runtime/tool-registry.ts)Canonical store of
ToolDescriptors.register/registerAll(atomic rollback on collision)forPolicy(policy)βToolDescriptor[]filtered byRunPolicy.tools.filterparseMcpToolId/groupToolsByServerhelpersPhase 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;.bakfallback on corruptsaveβ write-file-atomic envelope{ schemaVersion, savedAt, data }migratehook on version mismatch (returns{ value, schemaVersion } | null)validatehook (throw to reject malformed)JsonStoreFs+now()injection β no real diskI/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 sanityassertChatBackendEmitsRunStartedβ first event isrun_startedassertChatBackendTerminatesβ terminates oncompleted/errorassertChatBackendEmitsSingleUsageβ exactly oneusageevent on successassertCompletedUsageMatchesUsageEventβ completed.result.usage agrees with usage eventassertBackgroundRunnerLifecycleβ started + completed/errorassertModelCatalogDefaultShapeβref.backendmatches identityassertUsageTelemetryShapeβ finite, non-negative countersassertBackendContractβ runs the full suite, returns the list of checks performedThe assertions throw descriptive
ContractViolationerrors. 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, anddream to use it."
/statusβ done (Telegram + Discord)/modelβ done (main menu + browse view)ModelRefthroughthe dispatcher's
runChatTurninterface (replacing legacyQueryParams).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
resolveActiveModelForChatcall sites incommands.ts/callbacks.ts/reasoning-levels.ts/bootstrap.tskeep using the string resolver. They only needthe raw id β ref adds no value.
core/errors.tsclassifier is still upstream; theadapter (Phase 1) ships a small inline one.
be.cacheMetricsdirectly in/status. The ref'scacheSupportcarries the same value(propagated from
cacheMetrics) so either works; keeping theexisting call site keeps the diff minimal.
Tests
agent-runtime-resolver(Phase 2.1+2.3)agent-runtime-types(Phase 1)agent-runtime-adapter(Phase 1)agent-runtime-tool-registry(Phase 5 prep)agent-runtime-store(Phase 6 prep)agent-runtime-contracts(Phase 7 prep)agent-runtime-registry(Phase 3 prep)new tests on top of the Phase 1 + 2.1 + 2.2/2.3 cases.
npm run typecheck)npm run lintβ 18 pre-existing).Out of scope (deferred β each gets its own PR)
AgentEvents natively.Each backend handler is ~500-1500 LOC of careful surgical work.
Order per the plan: Codex β Claude SDK β OpenAI Agents β Kilo /
OpenCode.
consuming the event stream.
Claude MCP, etc).
JsonStore<T>. Order: Codex OAuth incompat β media index βcron β triggers β sessions β chat settings.
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