Skip to content

fix(chat): a provider that cannot steer queues mid-turn messages instead of faking delivery - #10

Merged
leonardoxr merged 4 commits into
main-xavierfrom
feat/omp-follow-up-honesty
Aug 19, 2026
Merged

fix(chat): a provider that cannot steer queues mid-turn messages instead of faking delivery#10
leonardoxr merged 4 commits into
main-xavierfrom
feat/omp-follow-up-honesty

Conversation

@leonardoxr

Copy link
Copy Markdown
Owner

Stacked on #9 (feat/provider-stall-visibility) — until that merges, this diff includes the stack. This fix itself is the single commit 86d699f59.

Problem (reproduced live, twice — the second bug report WAS the bug)

Messages sent while an omp turn ran appeared in the timeline as delivered, but the agent only received them after the turn settled — minutes later. The reporter's duplicate follow-up arriving post-turn was itself an instance of the defect.

Root cause chain:

  1. Default followUpBehavior: "steer" dispatches thread.turn.start, which immediately writes the user message into the timeline.
  2. The omp adapter treats it as a steer and calls session/prompt — which blocks on AcpSessionRuntime's prompt-serialization semaphore until the in-flight prompt RPC returns.
  3. That semaphore is load-bearing: omp over ACP has no steering surface — a concurrent session/prompt implicitly cancels the running turn (omp acp-agent.ts prompt handler). Real steering (streamingBehavior: "steer") exists only on omp's RPC transport.

So for omp, "steer" was a silent queue wearing a delivered-message costume.

Fix

Report truthfully instead of pretending:

  • ServerProvider.midTurnSteering?: "native" | "queued" (same pattern as showInteractionModeToggle; absent = native, so every legacy producer is untouched). omp declares "queued" with the rationale in code.
  • The web composer resolves mid-turn sends on such providers to the follow-up queue: the message lands as a queued card — visible, editable, reorderable — and the queue reactor delivers it the moment the turn genuinely ends, which is when it would have arrived anyway. Same delivery time, honest UI.
  • The effective behavior swaps steer→queue so the primary button and chord affordances match, and resolveFollowUpDelivery enforces the rule for the explicit send-now chord too. interrupt is unchanged — stop + fresh prompt needs no steering and remains the immediacy option for omp.

Verification

  • resolveFollowUpDelivery matrix: default steer → queue, explicit send → queue, interrupt intact, idle sends intact, native providers untouched.
  • omp provider snapshot asserts midTurnSteering: "queued".
  • 35 focused tests green; typecheck clean on contracts/server/web/mobile/client-runtime.

Follow-up (upstream, not this repo): an omp ACP extension mirroring RPC-mode streamingBehavior: "steer" would enable real mid-turn steering; this change makes the transport limitation visible instead of hiding it.

Built with Claude Fable 5 on Oh My Pi.

…l tool output

The omp provider rendered a bare timeline: thinking was discarded, tool rows
flattened to generic labels with one-line 84-char output summaries, inline
diffs were dropped on the wire, and subagents never reached the Agents panel
because omp's subagent frames are RPC-mode-only.

Server:
- OmpAdapter buffers agent_thought_chunk segments and settles them as
  reasoning items; ingestion appends them as reasoning.completed activities.
- The wire projection keeps a capped multi-line rawOutput.fullText and the
  ACP diff content blocks on tool.completed rows (tool.updated stays slim),
  plus a bounded toolInfo (device/tool name, action, scalar args, eval code)
  derived from rawInput — retroactive for already-persisted threads.
- omp's task tool calls synthesize the full subagent lifecycle from the
  TaskToolDetails streamed in rawOutput: task.started per subtask, deduped
  task.progress with intent/tool/usage/model, and task.completed driven by
  per-subtask progress status — the async-job mode acks the call as
  completed while agents still run, and the runtime evicts completed calls
  from its merge map, so the roster is cached per toolCallId and agents
  outliving the turn are marked backgrounded instead of spinning forever.
- AcpSessionRuntime gains an opt-in emission predicate so omp task progress
  in rawOutput survives the title/detail update-suppression gate.
- Informative provider titles (omp per-call intents) win the row heading
  over flattened action labels, and update frames no longer overwrite them
  with the bare "Tool" fallback.

Web:
- Thinking rows render italic/dimmed and expand to markdown.
- Expanded tool rows show the full output, per-file FileDiff blocks built
  from the preserved diff contents, structured tool args, syntax-highlighted
  eval code, and markdown LSP results; icons key on the tool identity.

Verified live against a real omp session: thinking, intent titles, inline
diffs, and the Agents panel roster with per-agent status, intent, model,
token and tool counts.

Built with Claude Fable 5 on Oh My Pi.
…chat

# Conflicts:
#	apps/web/src/components/chat/MessagesTimeline.tsx
…-looking working timer

A thread sat at 'Working for 10m' with zero output while the provider (omp)
retried upstream 529s internally forever without sending a single ACP frame.
Nothing in the UI distinguished that from real work.

Server:
- AcpSessionRuntime stamps every inbound native frame (session/update before
  any filtering, permission requests, elicitations) and exposes a liveness
  snapshot with the in-flight tool-call count.
- OmpAdapter runs a 15s watchdog per session: when a running turn has had no
  frame for 75s — observed retry cadence was 60-105s, normal streaming gaps
  are seconds, worst-case first-token latency stays under ~60s — it emits a
  transition-only session.state.changed carrying providerQuietSince, and a
  clearing transition when frames resume. An open tool call, a pending
  approval, or a pending user-input question suppresses the flag: those wait
  on the tool or the human, not the provider. Every sendTurn resets the
  silence baseline.
- OrchestrationSession gains optional providerQuietSince, persisted through
  a new projection column so a client opening the thread mid-stall sees the
  flag in the snapshot — exactly the incident scenario. getSnapshot also had
  a latent inline session mapping that silently dropped unknown fields; it
  now goes through mapSessionRow.

Clients:
- Web swaps the working row for 'No output from the provider for Xs · last
  activity HH:MM:SS — still waiting; you can stop the turn' (same
  self-ticking text-node pattern, one commit per transition). Stop stays in
  the composer.
- Mobile renders the same truthful label, and — audit finding — now shows
  session.lastError in the chat at all: a compact error card above the
  composer. Web already surfaced it via ThreadErrorBanner.

Deterministic tests: quiet-transition resolver matrix, ingestion mark/clear/
settle round-trip through the real SQL projection, and native-frame stamping
via the mock ACP agent. Verified live by SIGSTOPping the provider mid-turn
(flag appeared at +85s with the freeze timestamp) and resuming (flag cleared,
turn settled).

Built with Claude Fable 5 on Oh My Pi.
…ead of faking delivery

Messages sent while an omp turn ran appeared in the timeline as delivered,
but only reached the agent after the turn settled. omp over ACP has no
steering surface: a concurrent session/prompt implicitly cancels the running
turn, and T3's prompt serialization (which prevents exactly that cancel)
holds the message invisibly until the in-flight prompt returns.

The provider snapshot now carries midTurnSteering ('native' | 'queued',
absent means native so legacy producers are untouched), omp declares
'queued', and the web composer routes mid-turn sends on such providers to
the follow-up queue: the message shows up as a queued card — visible,
editable, reorderable — and the queue reactor delivers it the moment the
turn genuinely ends, which is when it would have arrived anyway. The
effective follow-up behavior swaps steer→queue so the button and chord
affordances tell the same truth, and the resolver enforces it for the
explicit send-now chord too. Interrupt is unchanged: stop plus a fresh
prompt needs no steering.

Real mid-turn steering for omp needs an ACP extension mirroring its
RPC-mode streamingBehavior; that is an upstream follow-up.

Built with Claude Fable 5 on Oh My Pi.
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Aug 19, 2026
@leonardoxr
leonardoxr merged commit 2cb3560 into main-xavier Aug 19, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant