Skip to content

fix(channels): /think executes for real; commands resolve the conversation agent - #7159

Closed
DaBlitzStein wants to merge 7 commits into
librefang:mainfrom
DaBlitzStein:fix/telegram-commands-origin
Closed

DaBlitzStein wants to merge 7 commits into
librefang:mainfrom
DaBlitzStein:fix/telegram-commands-origin

Conversation

@DaBlitzStein

@DaBlitzStein DaBlitzStein commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the two root causes behind Telegram slash commands confirming without acting (issue #7140).

1. /think was a stub

set_thinking stored nothing and never affected the model. Now:

  • Per-agent extended-thinking preference stored on KernelBridgeAdapter (keyed by agent id)
  • Applied as the per-turn thinking override on the next chat message via new send_message_with_blocks_and_sender_thinking (KernelApi method + kernel impl; default ignores the override)
  • Reply is honest: "Extended thinking enabled for this chat."

2. Command routing divergence

/new /reset /reboot /compact resolved the target agent via the router chain only, while the regular chat path (resolve_or_fallback) also consults:

A divergent resolution reset a different agent than the one holding the conversation — a real reset, invisible to the user. resolve_for_command now mirrors the chat chain layer for layer, in the same precedence order.

Not in this PR

Verification

  • cargo check --workspace --lib clean
  • cargo test -p librefang-channels --lib: 551 passed, 0 failed

Closes #7140

Follow-up commits (same issue #7140)

18ef5fd: /agent now also persists the per-conversation binding (the ack claimed the new agent while dispatch kept the old one — set_conversation_binding had zero production callers), and the streaming send path now applies per-agent /think preferences (previously only the non-streaming fallback honored them). Regression tests assert the binding side effect and the streaming-path override.

…e conversation agent

Two defects behind slash commands confirming without acting (librefang#7140):

1. /think was a stub — stored nothing, never affected the model.
   Now: per-agent extended-thinking preference stored on the bridge
   adapter and applied as the per-turn thinking override through
   send_message_with_blocks_and_sender_thinking (new KernelApi method
   + kernel impl).

2. Command routing divergence — /new /reset /reboot /compact resolved
   the target agent via the router chain only, while the chat path
   also consults the conversation override (librefang#5671), sticky holder
   (librefang#5323), per-peer binding, and instance default. A divergent
   resolution reset a different agent than the one holding the
   conversation. resolve_for_command now mirrors the chat chain layer
   for layer.

551 channels tests pass.
@github-actions github-actions Bot added size/M 50-249 lines changed area/channels Messaging channel adapters area/kernel Core kernel (scheduling, RBAC, workflows) labels Aug 13, 2026

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking compile failure: crates/librefang-api/src/channel_bridge.rs constructs KernelBridgeAdapter without the required thinking_prefs field in two test fixtures (currently around lines 3348 and 3395). This causes Quality, Unit, Ubuntu, and aarch64 to fail before the behavior can be evaluated. Please initialize the field consistently with the surrounding adapter fixtures, then rebase/update onto current main so the already-fixed dependency security failure is no longer inherited.

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two blockers remain on the current tip.

  1. thinking_prefs is keyed only by agent_id, while the user-facing result says the preference applies to this chat. One agent can serve multiple conversations. A /think command in one conversation therefore changes thinking and cost behavior for every other conversation routed to the same agent. Please key this preference by the canonical conversation identity and pass that identity through set_thinking and the send path. Add a regression with two conversations sharing one agent.

  2. resolve_for_command does not actually reproduce build_thread_key. The normal path adds peer_id from sender_user_id(message). The command path builds channel, account_id, chat_id, and thread only. Existing sticky claims are therefore stored under a different ThreadKey in group conversations and current_holder will miss them. Please share the canonical key builder or pass the already-built key into command dispatch. Add a regression where a group-thread claim includes peer_id and /new or /reset resolves the holder.

These are correctness issues in the two behaviors this PR claims to fix, so I do not recommend merging the current tip.

houko and others added 2 commits August 14, 2026 16:07
…n streaming send

The /agent command only wrote the router user-default, never the
per-conversation binding that resolve_for_command consults first, so the
ack claimed the new agent while dispatch kept the old one. Persist the
selection via set_conversation_binding keyed by (account, platform_id).

The primary streaming send path never consulted per-agent thinking prefs,
so /think was inert on streamed chats. Read the pref and pass it through.

Regression tests cover the binding side effect, the adapter write-through,
and the streaming-path override.
@houko

houko commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Test / Unit (lib+bin) and Test / Ubuntu (shard 1/4) both fail on the same case, channel_bridge::tests::streaming_send_applies_thinking_pref_set_by_think_command, at crates/librefang-api/src/channel_bridge.rs:3591:

expected provider-boundary failure, got: Ok(())

The assertion is status.is_err(), resting on the premise in the comment right above it — "Provider-less test kernel: the agent loop fails at the driver boundary (no LLM credentials)". A hosted runner has no LLM credentials, and the status channel still resolved Ok(()), so the provider-less path is not surfacing an error through that oneshot the way the test assumes.

The deeper issue is that the assertion doesn't test what the test is named for. Its own comment concedes this: "What matters here is that the stream started and ended with a reported failure — the pref read and the thinking-aware kernel entry were both reached." But status.is_err() is satisfied by any failure anywhere in the loop and would equally be satisfied if the thinking preference were never consulted at all. It is an incidental side effect of an unconfigured environment standing in for the behaviour under test, which is why an unrelated change to the provider-less path breaks it.

Asserting the actual contract would both fix the failure and make the test meaningful: that the preference set by /think reaches the kernel call. Observing the value the bridge passes down — the thinking_prefs entry, or the flag on the kernel invocation — is independent of whether a provider is configured, so it stays green on a credential-less runner and still fails if the wiring regresses.

Two notes while you're in here:

KernelBridgeAdapter is constructed inline in the test with all three fields, so every future field addition edits this test too. If the struct grows a Default or a test constructor, this site is a good candidate.

Separately: #7701 currently fails to compile against main with error[E0560]: struct 'channel_bridge::KernelBridgeAdapter' has no field named 'thinking_prefs' — it is written against the field this PR introduces. Whichever lands first, the other needs rebasing; worth sequencing them deliberately rather than letting merge order decide.

Not pushing anything to your branch.

`streaming_send_applies_thinking_pref_set_by_think_command` asserted `status.is_err()` on the reasoning that "the provider-less test kernel fails the agent loop at the driver boundary". That is a property of whichever driver `MockKernelBuilder` seeds, not of the adapter under test, and the harness now resolves the loop successfully — so the assertion failed with `expected provider-boundary failure, got: Ok(())` on both the Unit (lib+bin) and Ubuntu shard 1/4 lanes.

Assert the adapter-side contract instead. After the streaming send, the pref must still be readable under `agent_id.0.to_string()` — the exact key `set_thinking` writes and both send paths (`channel_bridge.rs:849` streaming, `:915` non-streaming) look up. A writer/reader key disagreement is what makes `/think` silently inert, which is the regression this PR fixes and which no assertion on the loop's terminal status can observe.

The stream still has to start and the status oneshot still has to resolve rather than hang or drop; both remain covered by the surrounding `expect`s.
DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Aug 18, 2026
The librefang#7701/librefang#7159 heads were rebased onto a main that replaced the
thread_ownership registry param with a derived sender_user_id in
handle_command; the conflict resolutions mixed the two APIs. Rebuild
the command path on the new API (BindingContext-based resolution) and
keep the workflow-run owner threading on run_workflow_text.
DaBlitzStein added a commit to DaBlitzStein/librefang that referenced this pull request Aug 20, 2026
…ecovered and security work

Editors
- Running-agent editor mounts AgentManifestForm in edit mode: 11 editable fields become ~40, with unknown fields preserved through extras.
- Channels get a UI at last: PUT /api/agents/{id}/channels had shipped without a single client.
- Tools tab reaches parity with Skills: a visible Customize button, per-tool assignment from Available, and MCP grants that actually apply.
- Agent-type editor reuses the same AgentManifestForm plus a Channels picker: 7 fields become 41.

Recovered work
- The layered command resolver from librefang#7159, dropped by the post-sync merge, leaving a comment that promised behaviour the code no longer had.
- workflow_runs.total_steps persistence from the closed librefang#6504, so a run reloaded after a restart no longer reports "step X of 0".

Fixes
- Agent-type saves stop destroying every field the flat JSON does not carry, and channels/routing survive the round-trip.
- The TUI model-routing editor persists instead of silently discarding input.
- /think scopes to the conversation it was typed in rather than every conversation of the agent.
- Ephemeral spawn: cost caps evaluate against the billed agent's own quota, workers run incognito, agent-type fallback cannot borrow another agent's identity, base_url and api_key_env are checked against operator configuration, and system_prompt passes the taint check.
- Skillhub degrades gracefully now that its API is gone, and the endpoint is configurable.
- A broken audit chain is diagnosable and recoverable without discarding history.
- Kernel unit tests resolve an explicit stub driver instead of depending on no driver being installed, which made the suite spawn the real claude CLI and bill for it.

Naming
- Agent templates are agent types: /api/agent-types is canonical, /api/templates stays as a deprecated alias with its original operation ids.
@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

Status note, for visibility rather than action: the per-conversation scope for /think is stacked on this branch and cannot go upstream independently, because upstream set_thinking takes (agent_id, on) only — there is no channel / chat_id in the trait signature to key a per-conversation preference on.

Waiting on this PR:

  • /think scoped to the (agent, channel, chat_id) triple the reset / reboot / compact commands already use, so the toggle affects the conversation it was typed in rather than every conversation the agent has.

For the record on the current behaviour: both upstream implementations are inert. ChannelBridge::set_thinking in crates/librefang-api/src/channel_bridge.rs:1921 returns "Extended thinking {state}. (This will take effect when supported by the model.)" without storing or applying anything, and the trait default in crates/librefang-channels/src/bridge.rs:251 returns "Extended thinking preference saved." with an empty body. So /think acknowledges and does nothing at all today.

…rt it

The `/think` regression this PR fixes is a key-drift bug: `set_thinking` writes the per-agent preference into `thinking_prefs`, and the send paths read it back, but the map key was derived independently at all three sites.
If a read site ever derived a different key the toggle would go silently inert, and the guard test could not see it — its final assertion re-derived `agent_id.0.to_string()` itself, so it pinned a literal the test controlled rather than the lookup the send path performs.

Derive the key once in `KernelBridgeAdapter::thinking_pref_key` and route the write through `store_thinking_pref` and both reads through `thinking_override_for`, so write and read cannot drift apart by construction.
`streaming_send_applies_thinking_pref_set_by_think_command` now asserts through `thinking_override_for` — the very accessor `send_message_streaming_with_sender_status` calls — and adds a negative case pinning that an agent which never used `/think` still gets `None` rather than inheriting another agent's toggle.

No behaviour change: the key expression is identical, only its definition site moved.
@github-actions github-actions Bot added the has-conflicts PR has merge conflicts that need resolution label Aug 21, 2026
@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

streaming_send_applies_thinking_pref_set_by_think_command passes as of ae83aab.

What was actually wrong.
The is_err() assertion you flagged was pinning the mock harness, not the adapter: whether the provider-less test kernel fails the turn at the driver boundary or completes it is a property of whichever driver MockKernelBuilder seeds, so the assertion went green-to-red the moment the harness started resolving the loop.
742a983 already removed it, but the replacement assertion was still not testing the right thing — it re-derived agent_id.0.to_string() inside the test and looked the pref up with that literal, which pins a key the test itself controls rather than the lookup send_message_streaming_with_sender_status actually performs.

That distinction matters here, because the bug this PR fixes is a key-drift bug.
set_thinking wrote the preference into thinking_prefs and the two send paths read it back, but all three sites derived the map key independently — so a read site that ever derived a different key would make /think silently inert, and neither the old status assertion nor a test-side key literal could observe it.

What changed (crates/librefang-api/src/channel_bridge.rs):

  • The key is derived once in thinking_pref_key (:717); the write goes through store_thinking_pref (:722, called from set_thinking :1972) and both reads through thinking_override_for (:733, called from the streaming send :879 and the blocks send :940).
    Write and read can no longer drift apart by construction.
  • The test (:3553) now asserts through thinking_override_for (:3611) — the same accessor the send path calls — plus a negative case pinning that an agent which never used /think gets None rather than inheriting another agent's toggle.

No behaviour change: the key expression is byte-identical, only its definition site moved.

Verification (CARGO_TARGET_DIR shared, all runs scoped):

  • cargo check --workspace --lib — clean.
  • cargo test -p librefang-api — 2230 passed across 90 test binaries.
    Two failures, both pre-existing on main, in files this PR does not touch, and both pass when re-run in isolation: routes::mcp_auth::tests::flow_vault_cleanup_removes_all_per_flow_keys_on_drop and wait_step_over_cap_halts_before_sleep.
    The first is a real env-var race rather than load flake — routes/mcp_auth.rs:1850 sets LIBREFANG_VAULT_KEY process-wide on the claim that "no other api test reads" it, which server.rs:3961 (pin_vault_key) disproves by setting a different key; whichever loses the race decrypts with the wrong master key.
    That is worth its own issue, separate from this PR.
  • cargo clippy --workspace --all-targets -- -D warnings — clean on this branch's crates once the Rust 1.98 breakages already on main are excluded (chunks_exact_to_as_chunks, drain_collect, map_or_identity, result_large_err), which are exactly what chore(kernel): drop the now-unused kernel_handle prelude import from messaging #7755 and fix(qa): green the workspace clippy gate and stub scrollIntoView globally for jsdom #7762 are fixing.

On ordering. Fixing #7159 first and rebasing #7701 on top is the right sequence, since #7701 builds on the thinking_prefs field introduced here.
One thing to flag before that: this branch is 153 commits behind origin/main and currently conflicts in crates/librefang-channels/src/bridge.rs.
Since the branch has already been reviewed I have not rewritten its history — how you would like that resolved (merge main in, or rebase) is your call.

…-origin

# Conflicts:
#	crates/librefang-channels/src/bridge.rs
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Aug 21, 2026

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The /think preference is described to the user as scoped to “this chat”, but it is stored and read only by agent_id (crates/librefang-api/src/channel_bridge.rs:624-627, 717-739). A single agent commonly serves multiple Telegram chats and can also serve other channel instances, so /think on in one group or DM changes the reasoning mode for every subsequent turn routed to that agent, including turns from other users and channels. This also means /think off from any one chat disables it for all of them. Please scope the preference by the actual channel session/conversation identity (at least channel/account/chat plus agent), pass that scope through set_thinking, and add a regression test with two conversations routed to the same agent proving that toggling one does not affect the other.

@github-actions github-actions Bot added has-conflicts PR has merge conflicts that need resolution and removed ready-for-review PR is ready for maintainer review labels Aug 26, 2026
@DaBlitzStein

Copy link
Copy Markdown
Contributor Author

Superseded — all three halves of this branch are on origin/main, and the review's blocking point was resolved exactly as written.

The scoping fix the review asked for. crates/librefang-api/src/channel_bridge.rs:629 now holds thinking_prefs: DashMap<(AgentId, ConversationScope), bool>, keyed through ConversationScope::from_sender (line 648). The doc comment on the field makes the same argument the review did: "Keyed by the conversation the command was typed in, never by the agent alone: one agent routinely serves many chats, so an agent-keyed preference would push one user's reasoning mode — and its token cost — onto everybody else's turns."

The regression test with two conversations routed to the same agent, also asked for by name: think_preference_is_isolated_per_conversation at line 3423.

/think applying for real. thinking_override_for is consumed on all four send paths (lines 848, 890, 925, 962), streaming included — the gap this branch opened against.

/agent persisting the conversation binding. conversation_binding / set_conversation_binding are in place, with coverage at line 3568.

Closing rather than rebasing: there is nothing here upstream does not already do, and the version upstream landed is the scoped one the review called for rather than the agent-keyed one this branch shipped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/channels Messaging channel adapters area/kernel Core kernel (scheduling, RBAC, workflows) has-conflicts PR has merge conflicts that need resolution size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Telegram slash commands appear inert — SDK drift + command-path routing divergence

2 participants