Skip to content

fix(integrations): replay Band room history when a remote ACP session can't be restored [INT-1095]#467

Open
AlexanderZ-Band wants to merge 5 commits into
mainfrom
fix/acp-client-adapter-doesnt-fall-back-to-band-histor-INT-1095
Open

fix(integrations): replay Band room history when a remote ACP session can't be restored [INT-1095]#467
AlexanderZ-Band wants to merge 5 commits into
mainfrom
fix/acp-client-adapter-doesnt-fall-back-to-band-histor-INT-1095

Conversation

@AlexanderZ-Band

@AlexanderZ-Band AlexanderZ-Band commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes INT-1095 and INT-1101. The ACP client adapter relied solely on ACP session/load to restore conversation context; whenever the remote agent process was fresh (container restart, ,fork-per-connection spawn, crash) the agent started with zero context, even though the Band room transcript is intact on the platform.

The fix adopts the existing opencode/letta "resume-else-replay" pattern, then hardens it through two review rounds. The governing invariant: a freshly created ACP session owes the room a transcript replay; a restored one does not.

What changed

Replay fallback (converters/acp_client.py, integrations/acp/client_adapter.py, integrations/acp/client_runtime.py)

  • The converter carries the room's text transcript (replay_messages, via the shared build_replay_messages helper). Adapter narration events (thought/tool_call/tool_result/task) never replay.
  • On bootstrap: session/load is validated first; on any miss (no persisted id, unavailable, or erroring — protocol errors are now a recoverable load-miss, not a turn-killing exception) the fresh session's first prompt is seeded with the transcript.
  • Off-bootstrap (INT-1101): a session minted after a mid-run teardown re-fetches the transcript via tools.fetch_room_context, so a respawn never starts amnesiac. _get_or_create_session reports created-vs-reused; _load_persisted_session's redundant bool contract was deleted.

Prompt safety (review-driven)

  • Header framed as read-only background ("treat as already handled"), with an escape hatch so explicit recall requests are never refused.
  • The live message is attributed (format_for_llm) and sits last, under a nonce'd [New Message <nonce>] boundary marker the header names — replayed content predates the turn, so it cannot spoof the boundary.

Backlog truncation (all adapters, runtime/formatters.py + preprocessing/default.py)

  • Bootstrap history stops strictly before the triggering message (messages_before): backlog messages that accumulated while the agent was offline are pending turns of their own; replaying them exposed future requests and duplicated them when their own turn arrived. Oneshot's swallow-if-seen drain is deliberately untouched (its seen_ids coupling depends on the LLM having seen the fetched page).

Codex consolidation (adapters/codex.py)

  • Its private history formatting now uses the shared helper (net −10 lines). Drops the dead "message" type (not in MessageType, no producer anywhere); whitespace-only lines filtered for all four consumers.

CI / lockfile

  • uv.lock caught up to the released 1.3.0 (the release flow bumps pyproject without regenerating the lock — the source of everyone's recurring dirty-tree noise).
  • CI now runs uv sync --locked everywhere, and the release job refreshes uv.lock on the release-please PR (checked out with the App token so the push triggers the PR's CI). First live exercise will be the next release PR — worth watching that run.

Guarding the known rehydration weaknesses

  • No last-message duplication: trigger excluded by id at both history producers; post-trigger backlog truncated; a loaded session gets no replay; unit test counts the live message appearing exactly once.
  • Current message not ignored: prompt shape pinned by test (system context → replay → attributed live message last under the boundary marker).

Testing

  • Unit: full suite green (3970 passed). New coverage: converter replay + narration exclusion, load-miss / load-error / load-success paths, cold boot, replay-once, exactly-one live message, attribution + ordering + nonce agreement, mid-run respawn re-seed (red on old code), backlog truncation (formatters + preprocessor, red on old code).
  • Live e2e (backends lane, real Copilot CLI + platform), re-run after every behavior-affecting round:
    • test_acp_recall_via_room_replay_when_session_load_misses — new, deterministic: fresh COPILOT_HOME per phase forces the load-miss; phase-2 recall carries a user-stated marker and an agent-produced fact that only the replayed agent side of the transcript can supply.
    • Matrix rehydration + context-recall copilot_acp cells: green.

Follow-ups filed

  • INT-1101fixed here (In Review); residual noted there: one room's failure still tears down the runtime momentarily (context now recovers, so no longer silent loss).
  • INT-1102 — unify the replay-safety framing across codex/opencode/letta (their weaker wording predates this PR; migrating needs each lane's own live validation).

Likely also closes INT-623 (L4 Rehydration placeholder).

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Jul 21, 2026

Copy link
Copy Markdown

@AlexanderZ-Band AlexanderZ-Band changed the title fix(acp): replay Band room history when a remote session can't be restored [INT-1095] fix(integrations): replay Band room history when a remote ACP session can't be restored [INT-1095] Jul 21, 2026
AlexanderZ-Band and others added 5 commits July 21, 2026 19:50
…tored [INT-1095]

The ACP client adapter relied solely on ACP session/load to restore
conversation context; when the remote process was fresh (container
restart, fork-per-connection spawn, crash) the agent started with zero
context despite the room transcript being intact on the platform.

- Converter now carries the room's text transcript as replay lines via
  the shared build_replay_messages helper (the opencode/letta pattern);
  adapter narration events never replay
- On bootstrap, when no persisted session can be restored (missing id,
  failed load, or any session/load protocol error - now treated as a
  recoverable load-miss instead of killing the turn), the transcript is
  injected once into the fresh session's first prompt under a
  context-only header, with the current message attributed and last
- Codex's private history formatting consolidated onto the shared
  helper (drops the dead "message" type; whitespace-only lines now
  filtered for all four consumers)
- Live message now attributed via format_for_llm, matching every other
  adapter
- New deterministic e2e: fresh COPILOT_HOME per phase forces the
  session-load miss, pinning that recall flows through the replay
  (including agent-authored lines) in every lane

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Replay header reworded: affirmative "read-only background / already
  handled" framing over bare prohibitions, with an escape hatch so an
  explicit recall request is never refused
- Live message now sits under a nonce'd [New Message <nonce>] boundary
  marker the header names; the per-turn nonce defeats a replayed
  message spoofing the boundary (transcript content predates the turn)
- Dropped the preemptive flaky_infra on the new e2e (no observed
  transient to cite; a rerun would slow-surface no-reply product bugs)
- Converter tests refactored onto intent-named builders; non-obvious
  assertions now carry why-it-is-a-bug messages
- Stale docstrings brought up to date (load_session error contract,
  replay helper consumers)

Live-revalidated: session-load-miss e2e + offline cold-boot matrix cell
both green with the new wording.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Defense in depth alongside the uv lock --check gate that landed on main
in parallel: each job's install itself refuses a stale lockfile,
including the release build (publish-band), so drift cannot slip in
even when a job runs without the lint gate.

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

Two P1 review findings, both root-caused:

- Rehydration now keys off fresh-session creation, not the one-time
  bootstrap flag: _get_or_create_session reports created, and a session
  minted off-bootstrap (the previous runtime was torn down mid-run by a
  prompt failure) re-fetches the room transcript via
  tools.fetch_room_context so the respawned turn does not start
  amnesiac. _load_persisted_session's now-redundant bool contract is
  deleted.

- Bootstrap history stops strictly before the triggering message
  (messages_before in runtime/formatters.py, applied in the default
  preprocessor for every adapter): backlog messages that accumulated
  while the agent was offline are pending turns of their own; replaying
  them exposed future requests and duplicated them when their own turn
  arrived. Oneshot's swallow-if-seen drain is deliberately untouched
  (its seen_ids coupling depends on the LLM having seen the page).

Live-revalidated: session-load-miss e2e + offline cold-boot matrix cell.

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

Replaces the hand-rolled jq parse / fetch / switch / token-spliced push
URL with a conditional checkout of the release PR branch using the App
token: credentials flow through checkout's audited persistence, and the
refresh step shrinks to uv lock + commit + plain push (still
App-authored, so the PR's CI triggers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AlexanderZ-Band
AlexanderZ-Band force-pushed the fix/acp-client-adapter-doesnt-fall-back-to-band-histor-INT-1095 branch from 298c158 to d179cbd Compare July 21, 2026 16:53
@AlexanderZ-Band
AlexanderZ-Band requested a review from a team July 21, 2026 16:57
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