Skip to content

feat(cli): auto-steer ping_peer messages into an active turn - #1708

Open
KevinFun wants to merge 2 commits into
tiann:mainfrom
KevinFun:feat/ping-peer-auto-steer
Open

feat(cli): auto-steer ping_peer messages into an active turn#1708
KevinFun wants to merge 2 commits into
tiann:mainfrom
KevinFun:feat/ping-peer-auto-steer

Conversation

@KevinFun

Copy link
Copy Markdown

Summary

ping_peer messages to a peer that is mid-turn were stored and forwarded immediately, but the peer CLI silently held them until the current turn ended — the nudge looked "sent" yet was ignored by the working agent. This PR delivers peer messages into a running turn automatically (no manual Steer click), while keeping the ordinary queue as the fallback everywhere else.

Changes

  • ping_peer (MCP tool + hapi ping-peer): sends a generated localId (row becomes queue-shaped: visible in the peer's waiting bar, steerable, cancellable) and deliveryMode: 'steer' — both are existing protocol fields of SendMessageRequestSchema. Tool description documents the steer semantics.
  • Hub (messageService.getNormalizedDeliveryMode): persists steer provenance for every steering-capable flavor via the shared isSteeringSupportedForSession predicate (previously pi-only). Claude and unknown flavors still downgrade to an ordinary queue row.
  • Codex CLI (codexRemoteLauncher): the SteerQueuedMessage RPC handler is extracted verbatim into steerQueuedByLocalId (the web Steer button now delegates to it), and a queue-arrival hook injects a steer-tagged arrival into the active turn via app-server turn/steer. Every refusal path restores the queued row, so a refused auto-steer simply delivers at the next turn boundary.
  • Queue (MessageQueue2): push() accepts an optional steerHint and the onMessage handler now receives the queued item.

Behavior

  • Peer idle → unchanged (message is consumed immediately as the next turn).
  • Peer mid-turn on codex / pi → message is injected into the running turn; the chat shows the existing "steered" badge (messages-consumed { steered: true }). Pi gets this for free: its CLI already honors meta.deliveryMode === 'steer'.
  • Peer mid-turn on claude / other flavors → unchanged queue-until-turn-end (hub downgrades, CLI unchanged).
  • Web composer, Telegram, slash/control commands → unaffected: they never request steer, and the steer routine additionally refuses control commands and mode-mismatched rows.
  • Wire protocol unchanged; no fixture-affecting web pipeline changes.

Testing

  • bun typecheck (cli / hub / web / relay)
  • Full bun run test (cli + hub + web + shared + relay)
  • New/updated tests:
    • hub: steer provenance stored for codex, downgraded for claude (and flavor-less sessions)
    • cli: ping_peer request body carries localId (ping-peer-<uuid>) + deliveryMode: 'steer'
    • cli: MessageQueue2 passes steerHint through to the arrival handler; isolated/clear pushes stay unhinted

ping_peer messages to a busy peer waited silently for the next turn
boundary. Tag them with deliveryMode 'steer' and a generated localId so
the peer CLI can inject them into the running turn (codex turn/steer,
pi native steer), falling back to the ordinary queue when the peer is
idle or its flavor cannot steer.

- ping_peer/MCP: send localId + deliveryMode 'steer'; document steer
  semantics in the tool description
- hub: persist steer provenance for every steering-capable flavor
  (isSteeringSupportedForSession) instead of pi only
- codex: extract the SteerQueuedMessage RPC handler and reuse it from a
  queue-arrival hook; every refusal path restores the queued row
- queue: push() carries an optional steerHint and passes the item to
  the onMessage handler

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] Cursor ACP peer nudges are classified as auto-steerable but never auto-steered — the new hub gate preserves deliveryMode: "steer" for Cursor ACP, and the tool description promises mid-turn injection, but Cursor’s inbound path drops message.meta.deliveryMode and calls messageQueue.push(...) without an arrival hint or hook (cli/src/cursor/runCursor.ts:100, cli/src/cursor/cursorUserMessageQueue.ts:20). During an active Cursor turn, ping_peer therefore remains queued until the turn finishes. Evidence: hub/src/sync/messageService.ts:130.
    Suggested fix:
    const flavor = isObject(metadata) ? metadata.flavor : null
    return flavor === "pi" || flavor === "codex" ? "steer" : "queue"
    Keep Cursor downgraded (and remove it from the new tool-description claim) until its receive path carries the steer hint and invokes the existing Cursor ACP steer routine on arrival.

Summary

Review mode: initial. One Major finding: automatic peer steering is advertised and persisted for Cursor ACP, but only Codex and Pi currently consume the live steer intent.

Testing

Not run locally (automation; PR code was not executed). GitHub CI integration check is failing in cli/src/runner/runner.integration.test.ts; relation to this diff is not established. The main test job was still running at review time. Add a Cursor ACP busy-turn test if Cursor remains in the capability gate, plus a Codex launcher-level test proving a steer-tagged arrival calls the extracted steer path and falls back to the queue on refusal.

HAPI Bot

Comment thread hub/src/sync/messageService.ts Outdated
// Steer provenance is only meaningful for flavors whose CLI can act on it
// (codex turn/steer, pi native steer, cursor ACP soft-send). Everything
// else (claude, unknown flavors) stores an ordinary queue row.
return isObject(metadata) && isSteeringSupportedForSession(metadata as Parameters<typeof isSteeringSupportedForSession>[0])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] Cursor ACP is included here, but its inbound path never auto-steers this intent

isSteeringSupportedForSession() returns true for Cursor ACP, so this preserves deliveryMode: "steer". However, cli/src/cursor/runCursor.ts:100-108 ignores message.meta.deliveryMode, enqueueCursorUserMessage() calls messageQueue.push(...) without a steer hint, and the Cursor launcher only steers when the explicit SteerQueuedMessage RPC is invoked. A ping_peer message sent during an active Cursor turn therefore waits for turn completion despite the new tool description promising immediate injection.

Suggested fix (until Cursor gets equivalent arrival-hook plumbing):

const flavor = isObject(metadata) ? metadata.flavor : null
return flavor === "pi" || flavor === "codex" ? "steer" : "queue"

Also remove Cursor from PING_PEER_TOOL_DESCRIPTION; alternatively, pass the delivery mode through Cursor’s queue and reuse its existing soft-steer handler from an arrival hook.

Cursor ACP's inbound path does not consume the steer hint yet, so
keep its rows queued and drop it from the ping_peer tool description
until its receive path is wired; codex and pi remain auto-steerable.
@KevinFun

Copy link
Copy Markdown
Author

Thanks for the review. Addressed the Major finding in 73630bb:

  • Hub gate: steer provenance is now persisted only for flavors whose CLI consumes the hint on arrival (pi, codex). Cursor ACP is downgraded to an ordinary queue row until its receive path is wired to the arrival hook — happy to land that wiring as a follow-up if maintainers want Cursor auto-steer.
  • Tool description: dropped Cursor from the mid-turn injection claim (codex and pi only).
  • Regression test: the hub test now asserts cursor + deliveryMode: 'steer' is stored as queue, alongside codex (steer) and claude (queue).

On the launcher-level test suggestion: codexRemoteLauncher has no test harness today (the class boots the app-server client, display, and process spawn), so that is a larger lift than this PR — noted as possible follow-up. The arrival hook is a thin guard (steerHint + turnInFlight) over the same steerQueuedByLocalId routine the existing RPC path uses, and the hint plumbing itself is unit-tested in MessageQueue2.

Re: the failing integration check — I ran the full runner integration suite locally on this exact branch: 13 passed | 1 skipped, including the four failing cases (should spawn & stop a session via HTTP, should track both runner-spawned and terminal sessions, should update session metadata when webhook is called, should handle concurrent session operations). The diff touches no runner-spawn paths, so this looks like a CI-environment flake rather than a regression — happy to help dig if it reproduces on other PRs.

@KevinFun

Copy link
Copy Markdown
Author

Additional data point on the integration failure: it predates this PR and affects every recent one. The test workflow's last 10 runs (across #1706, #1707, #1695, and this PR) are all failure, and the root cause looks like #1696 — the CI runner has no local Claude install, so the runner integration suite cannot spawn real sessions. The same suite passes locally on this branch (13 passed | 1 skipped). Once #1696 or equivalent lands, this check should go green here without any change on my side.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] First-turn auto-steer is rejected when Codex resolves an inherited model — the new arrival hook delegates to a mode hash captured before model resolution; see the inline comment at cli/src/codex/codexRemoteLauncher.ts:2253.

Summary

Review mode: follow-up after new commits. The prior Cursor ACP finding is no longer present in the latest head. One Major finding remains: on a common default/inherited-model launch, peer nudges sent during the initial active Codex turn fall back to the queue instead of being auto-steered.

Testing

Not run (automation; PR code was not executed). At review time, drift-gate passed, integration was failing, and the main test job was pending; failure logs were unavailable while the workflow was still running, so relation to this diff is not established. Add a launcher-level regression test with an initial mode whose model is omitted, a mocked thread/start response that resolves the model, and a steer-tagged arrival during that active turn.

HAPI Bot

session.queue.setOnMessage((_message, _mode, item) => {
if (!item.steerHint || !item.localId) return;
if (!turnInFlight || this.abortInProgress || this.shouldExit) return;
void steerQueuedByLocalId(item.localId).catch((error) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] Inherited-model turns reject this auto-steer path

On a normal default-model launch, the first queued prompt is hashed with model: undefined in cli/src/codex/runCodex.ts:273-289. Starting/resuming the thread then calls applyResolvedModel() (cli/src/codex/codexRemoteLauncher.ts:441-448), so syncCurrentConfigFromSession() builds this arriving peer nudge with the resolved model. The delegated handler compares that new hash against the original activeMessage.hash at cli/src/codex/codexRemoteLauncher.ts:2122-2124, treats the modes as different, and restores the row. Therefore peer nudges during the initial active turn silently wait for the next turn instead of auto-steering.

Suggested fix: normalize the active message to the effective mode used for turn/start, while that mode is captured, so later genuine mode changes still remain blocked.

const mode = {
    ...message.mode,
    model: session.getModel() ?? message.mode.model
};
activeMessage = {
    ...message,
    mode,
    hash: session.queue.modeHasher(mode)
};

Add a regression test where the initial message omits model, startThread resolves it, and a steerHint arrival with that resolved model must call turn/steer and emit messages-consumed { steered: true }.

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