Skip to content

Injected steering can outlive its owning session/prompt #934

Closed

Description

@ran411285752

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

  1. Build a clean v0.64.0 checkout with npm ci && npm run build.
  2. Initialize the agent and create a session.
  3. Start a long-running session/prompt, but do not await it yet.
  4. While that prompt is active, send _session/steering on 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"
    }
  }
}
  1. Continue listening for session/update notifications even if the original session/prompt settles.

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

  1. xintaofei commented on Aug 2, 2026

    @xintaofei

    Independent reproduction on macOS against the standard Anthropic backend, with one important addition: the owning session/prompt can also settle as a clean, successful end_turn — not just reject as in the OP — which makes this variant undetectable for hosts.

    Environment

    • macOS (Darwin 25.5), Node.js 24.18.0
    • @agentclientprotocol/claude-agent-acp 0.64.0 (plain npm i -g)
    • @anthropic-ai/claude-agent-sdk 0.3.220 (the adapter's pin), bundled Claude Code CLI 2.1.220
    • Standard Anthropic API (no compatible-endpoint indirection)

    Timeline (host logs + ~/.claude/projects/**/<session>.jsonl, same UTC clock)

    08:45:19.835  session/prompt starts an agentic task; several Bash tool
                  calls stream normally for ~30s
    08:45:51.37   _session/steering sent mid-generation (with the
                  promptRequired opt-in) → response { outcome: "injected" }
    08:45:51.371  CLI queue-operation "enqueue"
    08:45:51.373  CLI queue-operation "dequeue"
    08:45:51.374  original session/prompt RESOLVES with stopReason
                  "end_turn" — 3 ms after the injection, no error
    08:46:05+     steered continuation streams via session/update with no
                  owning request (thoughts, tool calls, MCP calls — it kept
                  working for ~5.5 more minutes)
    

    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=null rejection 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 previous tool_result (parentUuid intact; 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-op

    steer() stamps userMessage.priority = STEER_PRIORITY ("now"), but SDK 0.3.220 has no such field on SDKUserMessage — the only priority occurrences in sdk.mjs are 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() returns injected, keep the owning session/prompt pending 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 between injected and startedNewTurn that 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.

  2. yangbancode commented on Aug 5, 2026

    @yangbancode

    Reproduced on @agentclientprotocol/claude-agent-acp 0.64.2 (latest) / @anthropic-ai/claude-agent-sdk 0.3.220, macOS darwin-arm64, standard Anthropic backend, driving dist/index.js directly 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:

    Injection timing Original session/prompt outcome
    none (control) completes normally (end_turn, real usage)
    ~5 s into the request, before the first text chunk rejected 7 ms after injected: -32603 Internal error: [ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null — the OP's exact string
    during text streaming (after the 5th agent_message_chunk) resolved 9 ms after injected with stopReason: "end_turn"
    immediately after the first completed Read tool call resolved 2 ms after injected with stopReason: "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_turn carries an all-zero usage. 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-usage end_turn arriving right after an injected steering 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 that sdk.mjs 0.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 fires abort(...("interrupt")) whenever pending streaming input contains a priority === "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/prompt with a long text-only task → timed _session/steering); happy to share it if useful.

  3. benbrandt commented on Sep 23, 2026

    @benbrandt
    Member

    Closed by #958

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions