Skip to content

refactor(acp): simplify the turn fold - #52826

Merged
nexxeln merged 16 commits into
v2from
acp-turn-fold
Oct 5, 2026
Merged

nexxeln merged 16 commits into
v2from
acp-turn-fold

Conversation

@nexxeln

@nexxeln nexxeln commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Why the change

The ACP turn fold mixed delivery policy into pure event folding and repeated root and child branches, so this PR separates those concerns without changing what the client sees.

Special things to note

  • No behaviour change. The existing wire tests pass unchanged. The background follow rules now live in turn.ts, so the wire test for child permissions after the turn checks them directly. After the prompt responds, the client gets no session/update, even for a background child's text. Once the last open child ends, a later ask from that child never reaches the client. The translate test drops the background case that copied the filter.
  • ACP maps the V1 tool aliases (bash, task, apply_patch) in one place, canonicalName in tool.ts. Tool names are canonicalized where they are matched, not when a tool arrives, because the raw name is still the title fallback (apply_patch and task titles would change otherwise).
  • I kept every tool alias case. context7_* names come from an MCP server named context7. context, shell cmd/cwd, and the external_directory title fields can still come from custom tools or untracked permission asks, so none of them are provably dead.

Change outline

The fold no longer knows about background delivery. It always emits its outputs. It finds the event's owner once, then hands the event to a root handler or a child handler. Both share one handler for streamed content.

-ACPTranslate.step(state, event, ctx: Context): Step
-  Context.mode: "turn" | "background"
-  if background and not a child: drop event
-  route(): drop SessionUpdate when background
-  FormAsk.toolCallSent = mode === "turn" && ...
-  switch (event.type)
-    step.started / step.ended / step.failed:  child ? ... : ...
-    execution.succeeded / interrupted / failed: child ? childEnded(..., terminal) : terminal
+ACPTranslate.fold(state, event, ctx: TurnContext): Folded
+  session.created            -> childCreated
+  asks and settled asks      -> shared
+  owner = tracked child ?? root session
+  child ? childEvent(...) : rootEvent(...)
+    rootEvent:  step usage, step error, terminal
+    childEvent: child status, child ended (no terminal)
+    both fall back to sessionEvent: retries, compaction, text, reasoning, tools
+ACPTranslate.fromTrackedChild(state, event)   # filter for the background follow

turn.ts now owns delivery policy. Each consumer gets one Turn record, and the background follow gets its own copy of the state instead of sharing the finished turn's Ref.

-type Subscription = { scope, events, asks, cancelled, settled }
+type Subscription = { scope, events, askQueue, settled }
+type Turn = { ctx, state, subscription, cancelled, background }

-reply(subscription, ctx, ask, settled)
-interpret(subscription, ctx, output)
-consume(subscription, ctx, state)
-windDown(subscription, ctx, state, events)
-execute(attached, prompt, ctx, state)
-handoff(attached, subscription, ctx, state, exit)
-settle(attached, state: Ref, exit)
+queueAsk(turn, id, ask, fallback, respond)   # races cancel, settled, then ask; responds uninterruptibly
+interpret(turn, output)          # drops SessionUpdate and computes toolCallSent when background
+advance(turn)                    # take, filter with fromTrackedChild when background, fold, interpret
+consume(turn)                    # ends on the root terminal
+followChildren(turn)             # ends when openChildren is empty
+windDown(turn, events)
+execute(attached, prompt, turn)
+handoff(attached, turn, exit)
+settle(attached, state: TurnState, exit)

Permission and form asks no longer carry their own race. Each module exports its ask and respond, and queueAsk runs the race once, next to the settled deferred, the turn's cancelled signal and the ask queue. A cancelled or failed ask still falls back to reject for permissions and a plain cancel for forms.

-ACPPermission.reply(input & { settled }, cancelled)
-ACPElicitation.reply(input & { settled }, cancelled)
+ACPPermission.ask(input)      ACPPermission.respond(input, decision)
+ACPElicitation.ask(input)     ACPElicitation.respond(input, outcome)

Prompt preparation moves into its own module.

 packages/cli/src/acp/
+├── prompt.ts      # ACPPrompt.prepare: image check, unreadable file links, slash and compaction detection
 ├── permission.ts  # exports ask and respond
 ├── elicitation.ts # exports ask and respond
 ├── tool.ts        # canonicalName replaces six lowercase alias switches
 ├── translate.ts   # fold, root and child handlers
 └── turn.ts        # Turn record, ask race, delivery policy, background follow

@nexxeln
nexxeln merged commit 88dd441 into v2 Oct 5, 2026
11 checks passed
@nexxeln
nexxeln deleted the acp-turn-fold branch October 5, 2026 09:37
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