Skip to content

Feat/teams frontend - #1

Closed
dylanneve1 wants to merge 13 commits into
mainfrom
feat/teams-frontend
Closed

Feat/teams frontend#1
dylanneve1 wants to merge 13 commits into
mainfrom
feat/teams-frontend

Conversation

@dylanneve1

Copy link
Copy Markdown
Owner

No description provided.

dylanneve1 and others added 13 commits March 19, 2026 08:50
- Generalize chatId from number to string across core interfaces (ContextManager,
  FrontendActionHandler, ExecuteParams, dispatcher, gateway, pulse, cron)
- Add frontend selection in config (telegram/teams/terminal) with per-frontend validation
- Create Teams frontend module: BotFrameworkAdapter, Express webhook server,
  activity handlers, conversation reference store, adaptive cards support
- Map 30+ gateway actions to Teams API with graceful errors for unsupported features
- Add 16 Teams-specific tests, update all existing tests for string chatIds
- Update MCP tool descriptions to be platform-neutral

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests real imports (formatting, conversation store, action handler with mocked
adapter), mention stripping, rate limiting, adaptive card construction,
content type mapping, conversation type detection, sender ID extraction,
message queue behavior, context manager gateway delegation, and all 30+
action handler cases including unsupported/Graph API actions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix senderId always being 0: use deterministic hash of AAD Object ID
  strings to produce stable numeric IDs for history/threading
- Add error handling for media file operations (missing/unreadable files)
- Add null checks in edit/delete when no conversation reference exists
- Log scheduled message failures instead of swallowing errors
- Flush Teams conversation store on uncaught exception shutdown
- Add 7 config tests for Teams: frontend selection, credential validation,
  missing credentials error, default port, terminal frontend without token
- Add 8 action handler edge case tests: no-ref edit/delete, missing
  file_path, nonexistent file, hashStringId determinism and correctness

412 tests passing, 0 type errors, 0 lint warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ChatID namespacing:
- Telegram chatIDs prefixed with "tg:" (e.g., "tg:123456789")
- Teams chatIDs prefixed with "teams:" (e.g., "teams:19:abc@thread.tacv2")
- Terminal uses "terminal" (unchanged)
- Prevents cross-frontend collisions in sessions, history, settings, cron

Lazy frontend/backend imports:
- Frontends and backends loaded via dynamic import() in index.ts
- Only the selected platform's dependencies are required at runtime
- grammy, botbuilder, @opencode-ai/sdk moved to optionalDependencies

App identity update:
- Package renamed from "talon-telegram-bot" to "talon"
- Description: "Agentic AI harness" (not Telegram-specific)
- Keywords updated to reflect multi-platform support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CLI overhaul:
- Setup wizard now asks frontend selection first (Telegram/Teams/Terminal)
- Shows platform-specific prompts (bot token for TG, clientId/secret/tenantId for Teams)
- Status, config, doctor, and main menu are all frontend-aware
- Branding updated: "Agentic AI harness" (not Telegram-specific)
- Help text updated to say "Start the bot" not "Start the Telegram bot"

CLI startChat:
- Dynamic imports for backends (no crash if opencode SDK not installed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Log module now peeks at talon.json at init time to detect terminal
frontend, suppressing console output so the REPL owns stdout cleanly.
Works for both npm start and talon chat paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Config accepts frontend as string or array: ["telegram", "teams"]
- Gateway routes actions to correct handler by chatId prefix (tg:/teams:)
- addFrontendHandler(prefix, handler) replaces setFrontendHandler
- index.ts creates all selected frontends, multiplexes sendTyping/sendMessage
- All frontends start concurrently (Telegram polls + Teams webhook)
- Each frontend validates its own credentials independently

Example config:
  { "frontend": ["telegram", "teams"], "botToken": "...", "teams": {...} }

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use npx tsx instead of node --import tsx on Windows (ESM loader hooks
  hang on Windows with the --import flag)
- Fix URL.pathname leading slash on Windows (/C:/... β†’ C:/...)
- Applied to both Claude SDK and OpenCode backends

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…o confirm)

Setup wizard now uses p.multiselect for frontend selection, allowing
multiple platforms to be selected simultaneously. Config saves as
array when multiple selected, string when single.

All CLI views (status, config, main menu) updated to display arrays.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Telegram actions: ALL 30+ grammy Bot API and userbot calls now use
   numChatId (numeric) instead of prefixed chatId string. Before this
   fix, actions like react, edit, delete, pin, poll, sticker, etc.
   would pass "tg:12345" to the Telegram API causing runtime failures.

2. Multi-frontend multiplexer: prefix-based routing instead of
   exception-based try/catch. acquire/release/sendTyping/sendMessage
   now route to the correct frontend by chatId prefix instead of
   trying all frontends and swallowing errors.

3. getMessageCount no longer double-counts (all frontends share the
   same gateway singleton, so query once not reduce over all).

4. uncaughtException handler: call frontend.stop() directly instead
   of async import().then() race with process.exit().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… stale session cleanup

- Replace `node --import tsx` with `tsx` in npm scripts (ESM loader hooks hang on Windows)
- Add configurable `claudeBinary` path in setup wizard and config schema
- Pass `pathToClaudeCodeExecutable` to Agent SDK when configured
- Clear stale session IDs on startup to prevent silent hang when resuming dead sessions
- Delete CLAUDECODE env var to prevent nested-session errors on Windows
- Use `where` instead of `which` on win32 in doctor check
- Add SDK event logging for debugging
- Move platform-specific deps to optionalDependencies, add rx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… binary, stale session cleanup"

This reverts commit fae9d02.
…e binary, stale session cleanup"

This reverts commit 989d72f.
@dylanneve1 dylanneve1 closed this Apr 3, 2026
dylanneve1 added a commit that referenced this pull request Apr 10, 2026
- Validate mcpServer.args elements are strings and reject empty command (#1)
- Shell-quote interpolated paths in dream bash commands (#2, #11)
- Replace CLI-based diary write with mempalace_diary_write MCP tool (#3)
- Make validation error message platform-agnostic (#4)
- Update mempalacePython comment for platform-dependent default (#5)
- Wrap mp.init() in Promise.race with 30s timeout (#6)
- Make init conditional on successful validation, pass actual config (#7)
- Move import mempalace check into validateConfig (#8)
- Replace execFileSync with async execFile in init() (#9)
- Document that registerPlugin does NOT call init (#10)
- Update dream prompt header from "4-stage" to "5-stage" (#12)
- Update getPluginMcpServers JSDoc to document mcpServer path (#13, #17)
- Add .min(1) to palacePath/pythonPath zod schemas (#14)
- Distinguish ENOENT/EACCES/EPERM from import failures in validation (#15)
- Fix test name from "logs warning" to match actual behavior (#16)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dylanneve1 added a commit that referenced this pull request Apr 10, 2026
* feat: integrate mempalace as built-in plugin for long-term memory

Adds mempalace (Python MCP server) as a first-class memory system.
When enabled, the agent gets semantic search, knowledge graph, and
verbatim memory storage via ChromaDB β€” all local, zero API calls.

Key changes:
- Extend plugin system with `mcpServer` field for non-Node MCP servers
- Add `registerPlugin()` for built-in plugin registration
- Create mempalace plugin (factory pattern, validates python venv)
- Wire mempalace into dream mode (Stage 5: mine logs into palace)
- Add `mempalace` config schema (enabled, palacePath, pythonPath)
- Add default paths for palace dir and python venv binary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(dream): mine daily notes instead of raw logs, add diary writing

Dream Stage 5 now mines memory/daily/ (curated observations) instead of
raw logs/ directory, eliminating junk chunks (tool JSON, df output, etc).
Added personal diary writing instruction β€” agent reflects on feelings,
state of mind, learnings, and loose threads after each dream run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR #27 review comments

- plugin.ts: validate mcpServer.args entries are strings and reject empty command
- dream.ts: quote interpolated paths in shell commands, replace mcp_server CLI diary with direct file write
- mempalace/index.ts: platform-agnostic error message for missing python binary
- paths.ts: update comment to reflect platform-dependent venv path
- bootstrap.ts: wrap mempalace init in 30s timeout to match loadSinglePlugin behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: restore mempalace CLI diary writer, keep path quoting

Copilot suggested removing the mcp_server CLI invocation for diary
writing but that's the intended mempalace interface. Restored it
with quoted paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add gating/validation tests for mempalace integration

- plugin.ts: test rejection of empty mcpServer.command and non-string args elements
- dream.ts: test mempalace section gating β€” verify mining/diary instructions
  only appear when mempalace is configured, skip message when not
- 1306 tests passing (4 new)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: upgrade mempalace system prompt with comprehensive tool docs

Adapted from mempalace SKILL.md (v3.1.0). Key improvements:
- Session protocol (verify before responding, invalidate stale facts)
- Full tool documentation including kg_timeline, traverse, find_tunnels,
  diary_read/write, delete_drawer, graph_stats, check_duplicate
- Semantic search tips (meaning-based, not keyword)
- Knowledge graph temporal validity guidance
- Tests updated to verify all tool names appear in prompt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract mempalace prompt to prompts/mempalace.md

Move system prompt instructions out of TypeScript into a .md file,
matching the pattern used by dream.md and other prompts. Plugin loads
and interpolates {{palacePath}} at runtime with graceful fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace pixi.intel.com registry URLs in lockfile with npmjs.org

Lockfile had resolved URLs pointing to pixi.intel.com (private/corporate
registry) for @Anthropic-AI packages, causing CI to fail with ENOTFOUND.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve all lint warnings and formatting issues

Remove unused imports, variables, and catch bindings across 14 files.
Add yield statements to generator function mocks. Fix prettier formatting.

0 lint warnings, 0 format issues, 1307 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address all 17 Copilot review comments on PR #27

- Validate mcpServer.args elements are strings and reject empty command (#1)
- Shell-quote interpolated paths in dream bash commands (#2, #11)
- Replace CLI-based diary write with mempalace_diary_write MCP tool (#3)
- Make validation error message platform-agnostic (#4)
- Update mempalacePython comment for platform-dependent default (#5)
- Wrap mp.init() in Promise.race with 30s timeout (#6)
- Make init conditional on successful validation, pass actual config (#7)
- Move import mempalace check into validateConfig (#8)
- Replace execFileSync with async execFile in init() (#9)
- Document that registerPlugin does NOT call init (#10)
- Update dream prompt header from "4-stage" to "5-stage" (#12)
- Update getPluginMcpServers JSDoc to document mcpServer path (#13, #17)
- Add .min(1) to palacePath/pythonPath zod schemas (#14)
- Distinguish ENOENT/EACCES/EPERM from import failures in validation (#15)
- Fix test name from "logs warning" to match actual behavior (#16)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add 'mempalace' to LogComponent type

TypeScript type check was failing because 'mempalace' wasn't in the
LogComponent union type used by log/logError/logWarn functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: allow MCP tools in dream prompt when required by Stage 5

Update tool access statement to permit MCP tools for mempalace
mining stage instead of blanket-blocking all MCP tools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: duplicate guard in registerPlugin, pass MCP servers to dream

- registerPlugin now checks for duplicates before setting env vars
  or logging success, preventing misleading logs and env clobbering
- Dream agent now receives mempalace MCP servers when configured,
  so Stage 5 diary/mining tools actually work

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add selective MCP server loading via 'only' filter

getPluginMcpServers now accepts an optional plugin name filter:
- omitted = all plugins (backwards compatible for chat sessions)
- [] = none
- ["mempalace"] = only mempalace

Dream mode uses ["mempalace"] to load only what it needs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: load mempalace prompt from dirs.prompts, fix dream systemPrompt

- Mempalace prompt now loads from ~/.talon/prompts/mempalace.md
  (user-customisable, seeded on first run) instead of relative to
  source file. Consistent with heartbeat/dream prompt loading.
- Dream systemPrompt now permits MemPalace MCP tools when configured,
  preventing conflict with the markdown prompt's Stage 5 instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: move duplicate check before validation, gate dream on plugin registration

- registerPlugin checks for duplicates before running validateConfig,
  avoiding expensive re-validation on accidental double registration
- Dream mempalace integration now gated on getPlugin("mempalace")
  instead of just config.mempalace.enabled, so failed validation
  or registration doesn't cause dream-time MCP tool failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mempalace): correct CLI arg order for status check

The --palace flag is a global option that must come before the
subcommand. Wrong order caused the init health check to always
fail with exit 2, logging a misleading "not yet initialized" warning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address Copilot review round 8 β€” validation, error handling, unused param

- Validate `import mempalace.mcp_server` (actual spawned module) instead of
  just `import mempalace` in validateConfig
- Add timeout/killed error branching in validateConfig catch block
  (ETIMEDOUT, signal, killed) with specific messages instead of generic
  "not installed"
- Include stderr details in import failure messages for debugging
- Remove unused `config` from ProcessAndReplyParams and all processAndReply
  call sites (flushQueue, retry, callback handler)
- Replace `mempalace status` CLI smoke test in init() with a simple import
  check β€” fixes false "Palace not yet initialized" warning when palace IS
  initialized but CLI subcommand doesn't exist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove config from message queue chain, fix pythonPath comment

- Remove config from queue entry type, enqueueMessage signature, and
  all 3 call sites β€” completes the cleanup started in round 8
- Eliminates unnecessary TalonConfig reference (including botToken) from
  queue state
- Update mempalace plugin header comment to document platform-dependent
  pythonPath default (bin/python on Unix, Scripts/python.exe on Windows)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
dylanneve1 pushed a commit that referenced this pull request May 24, 2026
…#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 pushed a commit that referenced this pull request May 25, 2026
…#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 pushed a commit that referenced this pull request May 25, 2026
* feat(status): /status consumes ModelRef β€” Phase 2.2

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

* feat(model-menu): /model consumes ModelRef + add modelId fallback β€” Phase 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.

* feat(agent-runtime): registry shim + ToolRegistry + JsonStore + contract 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

* feat(agent-runtime): AgentEvent β†’ legacy callbacks bridge β€” Phase 3 plumbing

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

* docs(agent-runtime): module README + migration cookbook

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.

* refactor(config): wire backend zod enums to BACKEND_IDS literal

`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.

* feat(agent-runtime): AgentEventLogRenderer β€” Phase 4 prep

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

* refactor(codex): migrate oauth-incompat store to JsonStore β€” Phase 6.x #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

* chore: prettier fix for agent-runtime README

---------

Co-authored-by: claudiusthebot <noreply@anthropic.com>
Co-authored-by: claudiusthebot <claudiusthebot@users.noreply.github.com>
dylanneve1 added a commit that referenced this pull request Jun 10, 2026
Five remaining stores migrate from hand-rolled writeFileAtomic.sync +
dirty-flag autosave to the unified `JsonStore<T>` envelope. Codex
OAuth-incompat (Phase 6.x #1) already shipped in #255.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Docs:

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

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

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

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

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

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

Phase 3 β€” backends emit AgentEvent natively:

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

Phase 4 β€” `AgentEventLogRenderer` consumers:

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

Phase 5 β€” centralised tool surface:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests:

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

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

* chore: fix prettier formatting (Code Quality CI)

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

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

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

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

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

2890 tests pass, typecheck clean.

* chore: delete unused Phase 4 + Phase 5 infrastructure

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

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

Knock-on:

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

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

2831 tests pass, typecheck clean.

* refactor: delete RunPolicy β€” pure ceremony

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

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

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

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

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

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

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

Knock-on:

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

2824 tests pass, typecheck clean.

* refactor: collapse the two model resolvers into one

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

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

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

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

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

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

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

2807 tests pass, typecheck clean.

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

Resolves two architectural seams from the recent audit:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: thread fallback model through stream retry params

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

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

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

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

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

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

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

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

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

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

* style: fix prettier drift

* fix: preserve text-block delivery failures through events

---------

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant