Repository navigation
Description
Summary
With @agentclientprotocol/claude-agent-acp 0.64.0 and @anthropic-ai/claude-agent-sdk 0.3.220, an active _session/steering request can return injected and successfully change the model output, while the original session/prompt rejects before the steered response starts streaming.
The later response continues through session/update, but no pending prompt request remains to own its terminal response.
This is the active injected path, not the idle promptRequired fallback from #903.
Environment
- OS: Windows
- Node.js: 22.16.0
@agentclientprotocol/claude-agent-acp: 0.64.0 (v0.64.0, clean checkout)@anthropic-ai/claude-agent-sdk: 0.3.220@agentclientprotocol/sdk: 1.3.0- Provider: Anthropic-compatible endpoint configured through the normal Claude settings environment
A baseline request without steering completed normally with stopReason: end_turn.
Reproduction
- Build a clean
v0.64.0checkout withnpm ci && npm run build. - Initialize the agent and create a session.
- Start a long-running
session/prompt, but do not await it yet. - While that prompt is active, send
_session/steeringon the same session:
{
"sessionId": "<same session>",
"prompt": [
{
"type": "text",
"text": "Stop the previous task and reply with exactly: STEERED-REAL-OK"
}
],
"_meta": {
"steering": {
"idleBehavior": "promptRequired"
}
}
}- Continue listening for
session/updatenotifications even if the originalsession/promptsettles.
The repository's examples/steering.ts can be used for steps 2-4. To observe step 5, catch the original prompt rejection and keep the ACP connection open briefly instead of exiting immediately.
Actual result
Observed real-request ordering (UTC):
05:15:17.318 steering request sent
05:15:17.320 steering response: { outcome: "injected" }
05:15:17.327 original session/prompt rejected:
Internal error: [ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null
05:15:24.153 steered agent_thought_chunk updates begin
05:15:25.320 steered agent_message_chunk updates produce: STEERED-REAL-OK
05:15:25.364 steered usage_update arrives
05:15:25.371 session_info_update arrives
The connection remained open for another 60 seconds. No prompt-owned terminal response arrived for the steered output.
The active run reproduced the same immediate injected followed by the original prompt rejection in two attempts. The second attempt kept the connection open and captured the complete later response shown above.
Expected result
When _session/steering returns injected, the running prompt lifecycle should not settle before the steered response. The owning session/prompt should remain observable through the steered output and settle with its terminal result, error, cancellation, and usage.
Impact
Hosts that bind streaming, persistence, cancellation, usage, and UI completion to a pending session/prompt can close the turn before the steered response arrives. The model follows the steering instruction, but the later output has no request-owned terminal lifecycle.
Activity
xintaofei commentedon Aug 2, 2026
Independent reproduction on macOS against the standard Anthropic backend, with one important addition: the owning
session/promptcan also settle as a clean, successfulend_turn— not just reject as in the OP — which makes this variant undetectable for hosts.Environment
@agentclientprotocol/claude-agent-acp0.64.0 (plainnpm i -g)@anthropic-ai/claude-agent-sdk0.3.220 (the adapter's pin), bundled Claude Code CLI 2.1.220Timeline (host logs +
~/.claude/projects/**/<session>.jsonl, same UTC clock)Because the injection landed after several completed tool calls, the settle was a normal
end_turn; the host has no way to distinguish it from a genuine turn completion, so it closes the turn UI/persistence while the agent keeps working. (The OP's[ede_diagnostic] result_type=user ... stop_reason=nullrejection looks like the same underlying event — a result cycle closed by the injected user message — just observed before the original turn had produced any assistant content.)Session-file forensics
In the session
.jsonl, the pre-steer turn has no closing assistant message — the generation in flight at injection time was discarded — and the injected user record chains seamlessly onto the previoustool_result(parentUuidintact; the continuation's assistant records chain onto the injected message). So conversation context is preserved and the model does follow the steering, but stream-json-wise the CLI treats mid-turn input as: interrupt the current generation → close the current turn's result cycle → process the injected message as a new result cycle. That new cycle is what ends up with no owning request.priority: "now"appears to be a no-opsteer()stampsuserMessage.priority = STEER_PRIORITY("now"), but SDK 0.3.220 has no such field onSDKUserMessage— the onlypriorityoccurrences insdk.mjsare the MCP resource-annotation schema and an unrelated comment. So the injected message is effectively plain next-message streaming input, which matches the observed new-cycle handling.Suggested direction
When
steer()returnsinjected, keep the owningsession/promptpending until the continuation's result: don't let the result that closes the interrupted cycle settle the Turn — treat it like the held-open turns already used for background subagents (Turn.deferredSettle/isHeldOpen), so the steered output stays inside the original prompt lifecycle (streaming, usage, cancellation, terminal result). That would also restore the semantic distinction betweeninjectedandstartedNewTurnthat hosts key their UX on (cf. #903/#919 for the idle path).Happy to provide full logs or session-file excerpts. For now our host (codeg) has disabled its native-steering integration and fallen back to a pull channel, keyed to re-enable on a fixed release.
feat(acp): add a native steering push channel for live feedback
yangbancode commentedon Aug 5, 2026
Reproduced on
@agentclientprotocol/claude-agent-acp0.64.2 (latest) /@anthropic-ai/claude-agent-sdk0.3.220, macOS darwin-arm64, standard Anthropic backend, drivingdist/index.jsdirectly over stdio JSON-RPC. Three additions to what's already in the thread:1. The reject-vs-clean-settle split is timing-dependent — confirmed experimentally. @xintaofei's reading ("the OP's rejection looks like the same underlying event, just observed before the original turn had produced any assistant content") matches what we measured. Four runs, identical setup, only the injection timing varied:
session/promptoutcomeend_turn, real usage)injected:-32603 Internal error: [ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null— the OP's exact stringagent_message_chunk)injectedwithstopReason: "end_turn"Readtool callinjectedwithstopReason: "end_turn"In all three injected runs the steered continuation streamed afterwards with no pending request (usage/cost updates kept arriving after the settle).
2. The spurious
end_turncarries an all-zerousage. In both clean-settle runs the prompt response was{"stopReason":"end_turn","usage":{"inputTokens":0,"outputTokens":0,"cachedReadTokens":0,"cachedWriteTokens":0,"totalTokens":0}}, while the control run's genuine completion carried real numbers. If that holds generally, it gives hosts a detection heuristic for the silent variant (zero-usageend_turnarriving right after aninjectedsteering response) — though we've only verified it on these runs.3.
priority: "now"is not a no-op — it's honored by the bundled CLI, not the wrapper. It's true thatsdk.mjs0.3.220 never reads the field, but the wrapper serializes the message through to the bundled Claude Code CLI, and the CLI does handle it: a strings dump of the darwin-arm64 binary shows a subscription that firesabort(...("interrupt"))whenever pending streaming input contains apriority === "now"message (plus downgrade rules mapping other values to"later"/"next"). So the interrupt-then-new-cycle behavior is priority-driven rather than generic mid-turn-input handling — consistent with the fix direction in #937/#958 that the interrupted cycle's result is a boundary the adapter shouldn't settle the prompt on.Downstream color for impact assessment: our host records the steering success, sees the owning prompt die milliseconds later, requeues the "failed" task, and the model ends up generating twice for one user request (the ownerless steered continuation plus the requeued run).
The repro is ~100 lines of newline-delimited JSON-RPC over stdio (initialize →
session/new→session/promptwith a long text-only task → timed_session/steering); happy to share it if useful.benbrandt commentedon Sep 23, 2026
Closed by #958