From 6eaab90d9fe5e06cfa96b0600920397651ea847c Mon Sep 17 00:00:00 2001 From: nova28 Date: Thu, 6 Aug 2026 18:28:18 +0800 Subject: [PATCH] feat: add mid-turn steering for Claude ACP sessions (#2241) Co-authored-by: Carlos Florencio --- apps/backend/cmd/mock-agent/main.go | 17 + .../backend/cmd/mock-agent/mock_agent_test.go | 168 ++++++ apps/backend/cmd/mock-agent/scenarios.go | 38 ++ .../internal/agent/runtime/agentctl/agent.go | 26 +- .../agent/runtime/agentctl/client_stream.go | 10 + .../runtime/agentctl/prompt_steer_test.go | 200 +++++++ .../agent/runtime/lifecycle/event_types.go | 2 + .../agent/runtime/lifecycle/events.go | 1 + .../runtime/lifecycle/execution_store.go | 44 ++ .../runtime/lifecycle/execution_store_test.go | 54 ++ .../runtime/lifecycle/manager_interaction.go | 22 + .../agent/runtime/lifecycle/session.go | 232 +++++++- .../runtime/lifecycle/session_steer_test.go | 469 +++++++++++++++ .../internal/agent/runtime/lifecycle/types.go | 7 + apps/backend/internal/agentctl/AGENTS.md | 2 + .../agentctl/server/adapter/adapter.go | 22 + .../server/adapter/transport/acp/adapter.go | 14 +- .../adapter/transport/acp/adapter_prompt.go | 58 +- .../transport/acp/adapter_prompt_cancel.go | 46 +- .../adapter_updates_prompt_generation_test.go | 8 + .../acp/fold_handoff_fixture_test.go | 206 +++++++ .../transport/acp/prompt_handoff_test.go | 128 +++- .../adapter/transport/acp/prompt_queueing.go | 42 ++ .../transport/acp/prompt_steer_test.go | 395 +++++++++++++ .../testdata/acp-fold-handoff-background.json | 148 +++++ .../acp/testdata/acp-fold-handoff.json | 80 +++ .../transport/acp/wakeup_concurrency_test.go | 9 +- .../internal/agentctl/server/api/agent.go | 26 +- .../server/api/prompt_steer_routing_test.go | 119 ++++ .../internal/agentctl/types/streams/agent.go | 7 + apps/backend/internal/backendapp/adapters.go | 34 ++ .../internal/backendapp/orchestrator.go | 2 + apps/backend/internal/common/config/config.go | 9 + .../background_work_accounting_test.go | 1 + .../orchestrator/event_handlers_streaming.go | 13 +- .../event_handlers_streaming_test.go | 1 + .../orchestrator/event_handlers_test.go | 29 + .../executor/executor_interaction.go | 58 +- .../foreground_busy_signal_test.go | 81 +++ .../orchestrator/messagequeue/service.go | 118 +++- .../prompt_background_acceptance_test.go | 1 + apps/backend/internal/orchestrator/service.go | 10 + .../orchestrator/session_scope_matrix_test.go | 4 + apps/backend/internal/orchestrator/steer.go | 172 ++++++ .../internal/orchestrator/steer_test.go | 379 ++++++++++++ .../internal/orchestrator/turn_activity.go | 65 +- apps/backend/internal/profiles/profiles.yaml | 8 + .../internal/runtimeflags/config_test.go | 56 ++ .../backend/internal/runtimeflags/registry.go | 18 + .../internal/runtimeflags/registry_test.go | 19 + apps/backend/internal/task/dto/dto.go | 29 + .../task/dto/supports_steering_test.go | 80 +++ .../task/handlers/message_handlers.go | 82 ++- .../handlers/message_handlers_blocked_test.go | 212 ++++++- .../message_handlers_resume_readiness_test.go | 13 + .../task/chat/chat-input-container.tsx | 4 + .../chat/use-chat-input-container.test.ts | 25 + .../task/chat/use-chat-input-container.ts | 18 + .../task/chat/use-composer-props.ts | 2 + .../e2e/tests/chat/mid-turn-steering.spec.ts | 334 +++++++++++ .../session/session-input-mode.test.ts | 12 + .../domains/session/session-input-mode.ts | 14 +- .../domains/session/use-session-state.test.ts | 42 +- .../domains/session/use-session-state.ts | 15 +- apps/web/lib/state/slices/features/types.ts | 1 + apps/web/lib/types/backend.ts | 4 + apps/web/lib/types/http.ts | 7 + apps/web/lib/ws/handlers/agent-session.ts | 71 ++- apps/web/src/locales/en/chat.json | 1 + apps/web/src/locales/pseudo/chat.json | 1 + apps/web/src/locales/zh-cn/chat.json | 16 +- ...ine-grained-foreground-idle-busy-signal.md | 2 +- .../decisions/2026-08-04-mid-turn-steering.md | 126 ++++ docs/decisions/INDEX.md | 1 + .../assets/mid-turn-steering-composer.png | Bin 0 -> 146894 bytes docs/plans/mid-turn-steering/plan.md | 242 ++++++++ .../task-01-negotiate-steer-capability.md | 57 ++ .../task-02-runtime-toggle.md | 55 ++ .../task-03-fold-handoff-fixtures.md | 51 ++ .../task-04-adapter-steer-admission.md | 65 ++ .../task-05-orchestrator-steer-admission.md | 49 ++ .../task-06-session-steer-contract.md | 51 ++ .../task-07-composer-steer-affordance.md | 56 ++ .../task-08-mock-agent-and-e2e.md | 80 +++ .../task-09-adr-and-verification.md | 68 +++ docs/public/configuration.md | 2 + docs/specs/INDEX.md | 1 + .../platform/background-work-liveness.md | 6 +- docs/specs/platform/mid-turn-steering.md | 289 +++++++++ scripts/probe-acp-midturn-fold | 556 ++++++++++++++++++ scripts/probe-claude-midturn-fold | 441 ++++++++++++++ 91 files changed, 6765 insertions(+), 94 deletions(-) create mode 100644 apps/backend/internal/agent/runtime/agentctl/prompt_steer_test.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/session_steer_test.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/fold_handoff_fixture_test.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/prompt_queueing.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/prompt_steer_test.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-fold-handoff-background.json create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-fold-handoff.json create mode 100644 apps/backend/internal/agentctl/server/api/prompt_steer_routing_test.go create mode 100644 apps/backend/internal/orchestrator/steer.go create mode 100644 apps/backend/internal/orchestrator/steer_test.go create mode 100644 apps/backend/internal/task/dto/supports_steering_test.go create mode 100644 apps/web/e2e/tests/chat/mid-turn-steering.spec.ts create mode 100644 docs/decisions/2026-08-04-mid-turn-steering.md create mode 100644 docs/plans/mid-turn-steering/assets/mid-turn-steering-composer.png create mode 100644 docs/plans/mid-turn-steering/plan.md create mode 100644 docs/plans/mid-turn-steering/task-01-negotiate-steer-capability.md create mode 100644 docs/plans/mid-turn-steering/task-02-runtime-toggle.md create mode 100644 docs/plans/mid-turn-steering/task-03-fold-handoff-fixtures.md create mode 100644 docs/plans/mid-turn-steering/task-04-adapter-steer-admission.md create mode 100644 docs/plans/mid-turn-steering/task-05-orchestrator-steer-admission.md create mode 100644 docs/plans/mid-turn-steering/task-06-session-steer-contract.md create mode 100644 docs/plans/mid-turn-steering/task-07-composer-steer-affordance.md create mode 100644 docs/plans/mid-turn-steering/task-08-mock-agent-and-e2e.md create mode 100644 docs/plans/mid-turn-steering/task-09-adr-and-verification.md create mode 100644 docs/specs/platform/mid-turn-steering.md create mode 100755 scripts/probe-acp-midturn-fold create mode 100755 scripts/probe-claude-midturn-fold diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go index 58df0b8ead..8cbe5e5f01 100644 --- a/apps/backend/cmd/mock-agent/main.go +++ b/apps/backend/cmd/mock-agent/main.go @@ -80,16 +80,33 @@ func main() { } // Initialize handles the ACP initialize request, returning agent capabilities. +// +// The `_meta.claudeCode.promptQueueing` advertisement mirrors what +// `@agentclientprotocol/claude-agent-acp` sends. It is set deliberately rather +// than special-casing the mock downstream: prompt handoff and mid-turn steering +// are gated on this negotiated advertisement, so the mock must earn eligibility +// the same way a real bridge does or E2E would prove nothing about the gate. func (a *mockAgent) Initialize(_ context.Context, _ acp.InitializeRequest) (acp.InitializeResponse, error) { + var meta map[string]any + if mockPromptQueueingEnabled() { + meta = map[string]any{ + "claudeCode": map[string]any{"promptQueueing": true}, + } + } return acp.InitializeResponse{ ProtocolVersion: acp.ProtocolVersionNumber, AgentCapabilities: acp.AgentCapabilities{ LoadSession: true, McpCapabilities: acp.McpCapabilities{Sse: true}, + Meta: meta, }, }, nil } +func mockPromptQueueingEnabled() bool { + return strings.ToLower(strings.TrimSpace(os.Getenv("KANDEV_MOCK_AGENT_PROMPT_QUEUEING"))) != "false" +} + // NewSession creates a new conversation session. // MCP servers from the ACP request are registered so callMCPTool can use them. // The Modes field and the model-shaped entry in ConfigOptions advertise diff --git a/apps/backend/cmd/mock-agent/mock_agent_test.go b/apps/backend/cmd/mock-agent/mock_agent_test.go index dad7e30ba2..3c35a7129a 100644 --- a/apps/backend/cmd/mock-agent/mock_agent_test.go +++ b/apps/backend/cmd/mock-agent/mock_agent_test.go @@ -74,6 +74,174 @@ func TestMockAgentCancelStopsPrompt(t *testing.T) { } } +func TestInitializePromptQueueingCanBeDisabled(t *testing.T) { + t.Setenv("KANDEV_MOCK_AGENT_PROMPT_QUEUEING", "false") + + agent := &mockAgent{} + response, err := agent.Initialize(context.Background(), acp.InitializeRequest{}) + if err != nil { + t.Fatalf("initialize: %v", err) + } + if response.AgentCapabilities.Meta != nil { + if _, advertised := response.AgentCapabilities.Meta["claudeCode"]; advertised { + t.Fatal("prompt queueing capability advertised while disabled") + } + } +} + +// capturingUpdater records every SessionUpdate it receives and exposes two +// one-shot signals: anySeen (first notification of any kind — in practice the +// available_commands_update Prompt emits before handlePrompt runs) and +// textSeen (first agent_message_chunk). Tests for the steer replay setup +// scenarios use these to synchronize on real events instead of sleeping. +type capturingUpdater struct { + mu sync.Mutex + notes []acp.SessionNotification + anySeen chan struct{} + textSeen chan struct{} + anyOnce sync.Once + textOnce sync.Once +} + +func newCapturingUpdater() *capturingUpdater { + return &capturingUpdater{anySeen: make(chan struct{}), textSeen: make(chan struct{})} +} + +func (u *capturingUpdater) SessionUpdate(_ context.Context, n acp.SessionNotification) error { + u.mu.Lock() + u.notes = append(u.notes, n) + u.mu.Unlock() + u.anyOnce.Do(func() { close(u.anySeen) }) + if n.Update.AgentMessageChunk != nil { + u.textOnce.Do(func() { close(u.textSeen) }) + } + return nil +} + +func (u *capturingUpdater) RequestPermission(context.Context, acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) { + return acp.RequestPermissionResponse{}, nil +} + +func (u *capturingUpdater) textMessages() []string { + u.mu.Lock() + defer u.mu.Unlock() + var texts []string + for _, n := range u.notes { + if n.Update.AgentMessageChunk != nil && n.Update.AgentMessageChunk.Content.Text != nil { + texts = append(texts, n.Update.AgentMessageChunk.Content.Text.Text) + } + } + return texts +} + +func newSteerSetupAgent(sessionID acp.SessionId, conn sessionUpdater) *mockAgent { + return &mockAgent{ + model: "mock-fast", + conn: conn, + sessions: map[acp.SessionId]bool{sessionID: true}, + promptCancels: make(map[acp.SessionId]context.CancelFunc), + commandsEmitted: make(map[acp.SessionId]bool), + } +} + +// TestSteerFoldSetupEmitsNoAnswerUntilCancelled pins the "folded" replay mode +// (docs/plans/mid-turn-steering/task-08-mock-agent-and-e2e.md): the +// predecessor scenario must hold the turn open without ever answering on its +// own, so a mid-turn steer sent against it can only be answered by the +// steer's own successor turn. +func TestSteerFoldSetupEmitsNoAnswerUntilCancelled(t *testing.T) { + const sessionID = acp.SessionId("steer-fold-session") + updater := newCapturingUpdater() + agent := newSteerSetupAgent(sessionID, updater) + + result := make(chan acp.PromptResponse, 1) + go func() { + resp, err := agent.Prompt(context.Background(), acp.PromptRequest{ + SessionId: sessionID, + Prompt: []acp.ContentBlock{acp.TextBlock("/e2e:steer-fold-setup")}, + }) + if err != nil { + t.Errorf("prompt returned error: %v", err) + } + result <- resp + }() + + select { + case <-updater.anySeen: + // Only proves the RPC is dispatched (the available_commands_update + // fires before handlePrompt runs) — not that it produced an answer. + case <-time.After(time.Second): + t.Fatal("timed out waiting for the prompt to start") + } + + if err := agent.Cancel(context.Background(), acp.CancelNotification{SessionId: sessionID}); err != nil { + t.Fatalf("cancel prompt: %v", err) + } + + select { + case resp := <-result: + if resp.StopReason != acp.StopReasonCancelled { + t.Fatalf("stop reason = %q, want cancelled", resp.StopReason) + } + case <-time.After(time.Second): + t.Fatal("fold setup did not stop holding after cancellation") + } + if texts := updater.textMessages(); len(texts) != 0 { + t.Fatalf("fold setup emitted its own answer %v, want none: a folded predecessor must never answer", texts) + } +} + +// TestSteerDeferSetupAnswersBeforeHolding pins the "deferred" replay mode: the +// predecessor scenario answers its own prompt for real, then keeps the turn +// open so a mid-turn steer sent afterward runs as a genuinely separate turn. +func TestSteerDeferSetupAnswersBeforeHolding(t *testing.T) { + const sessionID = acp.SessionId("steer-defer-session") + const wantAnswer = "Predecessor turn's own answer, delivered before any steer arrives." + updater := newCapturingUpdater() + agent := newSteerSetupAgent(sessionID, updater) + + result := make(chan acp.PromptResponse, 1) + go func() { + resp, err := agent.Prompt(context.Background(), acp.PromptRequest{ + SessionId: sessionID, + Prompt: []acp.ContentBlock{acp.TextBlock("/e2e:steer-defer-setup")}, + }) + if err != nil { + t.Errorf("prompt returned error: %v", err) + } + result <- resp + }() + + select { + case <-updater.textSeen: + case <-time.After(time.Second): + t.Fatal("timed out waiting for the predecessor's own answer") + } + texts := updater.textMessages() + if len(texts) != 1 || texts[0] != wantAnswer { + t.Fatalf("predecessor answer = %v, want exactly [%q]", texts, wantAnswer) + } + + if err := agent.Cancel(context.Background(), acp.CancelNotification{SessionId: sessionID}); err != nil { + t.Fatalf("cancel prompt: %v", err) + } + + select { + case resp := <-result: + if resp.StopReason != acp.StopReasonCancelled { + t.Fatalf("stop reason = %q, want cancelled", resp.StopReason) + } + case <-time.After(time.Second): + t.Fatal("defer setup did not stop holding after cancellation") + } + // The hold must not itself produce a second answer — only the predecessor's + // own text, matching "predecessor answers, then the steer runs as its own + // turn" rather than the predecessor speaking twice. + if texts := updater.textMessages(); len(texts) != 1 { + t.Fatalf("defer setup emitted %v after its own answer, want exactly one message total", texts) + } +} + func TestParseModelFromArgs(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/cmd/mock-agent/scenarios.go b/apps/backend/cmd/mock-agent/scenarios.go index 20443362b8..6723627e25 100644 --- a/apps/backend/cmd/mock-agent/scenarios.go +++ b/apps/backend/cmd/mock-agent/scenarios.go @@ -44,6 +44,44 @@ var scenarioRegistry = map[string]func(e *emitter){ "markdown-table": scenarioMarkdownTable, "empty-turn": scenarioEmptyTurn, "push-current-branch": scenarioPushCurrentBranch, + "steer-fold-setup": scenarioSteerFoldSetup, + "steer-defer-setup": scenarioSteerDeferSetup, +} + +// steerSetupHoldMillis is how long steer-fold-setup and steer-defer-setup +// keep their prompt open after any setup output, so the session reads +// "generating" (steer-eligible) for long enough that an e2e test can reliably +// deliver a mid-turn steer against it. Cancellation (turn end, session +// cleanup) interrupts the hold immediately via waitForDelay's ctx.Done case. +const steerSetupHoldMillis = 30_000 + +// scenarioSteerFoldSetup holds the foreground turn open without ever emitting +// text of its own, so a mid-turn steer delivered while it runs can only be +// answered by the steer's own successor turn. This reproduces the "folded" +// outcome from the mid-turn steering spec's outcome taxonomy +// (docs/specs/platform/mid-turn-steering.md): the predecessor settles without +// having produced an answer, and the operator sees a single, combined reply. +func scenarioSteerFoldSetup(e *emitter) { + waitForDelay(e.ctx, steerSetupHoldMillis) +} + +// scenarioSteerDeferSetup answers its own prompt immediately, then continues +// to hold the turn open silently. A mid-turn steer delivered during the hold +// still reaches the agent as a concurrent prompt, but runs as a genuinely +// separate turn with its own answer -- the "deferred" outcome from the +// outcome taxonomy. The transcript therefore shows two independent answers in +// submission order, matching an installation whose agent CLI never folds. +func scenarioSteerDeferSetup(e *emitter) { + e.text("Predecessor turn's own answer, delivered before any steer arrives.") + // A tool-call boundary flushes the buffered text above into its own + // message row (cmd/mock-agent/AGENTS.md: "flushMessageBuffer only fires + // on a tool-call/turn boundary"). Without it the answer stays buffered + // server-side for as long as this turn holds open below, so an operator + // (or an e2e assertion) would see nothing until the turn eventually ends. + flushID := nextToolID() + e.startTool(flushID, "Acknowledge predecessor answer", acp.ToolKindOther, map[string]any{}) + e.completeTool(flushID, map[string]any{"result": "ok"}) + waitForDelay(e.ctx, steerSetupHoldMillis) } // scenarioEmptyTurn emits no content and no tool calls, so the turn ends diff --git a/apps/backend/internal/agent/runtime/agentctl/agent.go b/apps/backend/internal/agent/runtime/agentctl/agent.go index 5b4039893d..b075f36d6e 100644 --- a/apps/backend/internal/agent/runtime/agentctl/agent.go +++ b/apps/backend/internal/agent/runtime/agentctl/agent.go @@ -253,12 +253,36 @@ func (c *Client) Prompt( text string, attachments []v1.MessageAttachment, promptGeneration uint64, +) error { + return c.prompt(ctx, text, attachments, promptGeneration, false) +} + +// PromptSteer sends a prompt with the steer flag set, asking agentctl to deliver +// it into a turn that is still generating rather than serializing behind it. +// agentctl honors this only when its adapter can steer and the connected agent +// advertised the capability; otherwise it falls back to an ordinary prompt. +func (c *Client) PromptSteer( + ctx context.Context, + text string, + attachments []v1.MessageAttachment, + promptGeneration uint64, +) error { + return c.prompt(ctx, text, attachments, promptGeneration, true) +} + +func (c *Client) prompt( + ctx context.Context, + text string, + attachments []v1.MessageAttachment, + promptGeneration uint64, + steer bool, ) error { payload := struct { Text string `json:"text"` Attachments []v1.MessageAttachment `json:"attachments,omitempty"` PromptGeneration uint64 `json:"prompt_generation,omitempty"` - }{Text: text, Attachments: attachments, PromptGeneration: promptGeneration} + Steer bool `json:"steer,omitempty"` + }{Text: text, Attachments: attachments, PromptGeneration: promptGeneration, Steer: steer} resp, err := c.sendStreamRequest(ctx, "agent.prompt", payload) if err != nil { diff --git a/apps/backend/internal/agent/runtime/agentctl/client_stream.go b/apps/backend/internal/agent/runtime/agentctl/client_stream.go index dcf9d1ae93..48e00ee800 100644 --- a/apps/backend/internal/agent/runtime/agentctl/client_stream.go +++ b/apps/backend/internal/agent/runtime/agentctl/client_stream.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "time" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -67,7 +68,16 @@ func (c *Client) sendStreamRequest(ctx context.Context, action string, payload i } c.streamWriteMu.Lock() + // Honor a caller deadline for the write itself, not just the response wait: + // a stalled conn.WriteMessage (full send buffer to a half-open peer) would + // otherwise block uninterruptibly and, for a steer, pin the lifecycle lock the + // caller holds across this RPC. streamWriteMu serializes writers, so setting + // and clearing the shared deadline here cannot race a concurrent write. + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetWriteDeadline(deadline) + } writeErr := conn.WriteMessage(websocket.TextMessage, data) + _ = conn.SetWriteDeadline(time.Time{}) // clear so later writers are unbounded c.streamWriteMu.Unlock() if writeErr != nil { tracing.TraceWSResponse(span, "", writeErr) diff --git a/apps/backend/internal/agent/runtime/agentctl/prompt_steer_test.go b/apps/backend/internal/agent/runtime/agentctl/prompt_steer_test.go new file mode 100644 index 0000000000..09220d9a2e --- /dev/null +++ b/apps/backend/internal/agent/runtime/agentctl/prompt_steer_test.go @@ -0,0 +1,200 @@ +package client + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// promptFrame is the wire shape the client encodes for agent.prompt. It is +// declared here rather than reused from the production anonymous struct on +// purpose: this test asserts the *contract* agentctl parses, so a rename on +// either side must fail here rather than silently agree. +type promptFrame struct { + Text string `json:"text"` + Attachments []v1.MessageAttachment `json:"attachments"` + PromptGeneration uint64 `json:"prompt_generation"` + Steer bool `json:"steer"` +} + +type capturedPrompt struct { + frame promptFrame + raw map[string]json.RawMessage +} + +// promptCapture serves the agent stream endpoint, records every agent.prompt +// payload it receives, and answers each with a success response so the client's +// sendStreamRequest round-trip completes. +type promptCapture struct { + server *httptest.Server + + mu sync.Mutex + captured []capturedPrompt + arrived chan struct{} +} + +func startPromptCapture(t *testing.T) *promptCapture { + t.Helper() + + pc := &promptCapture{arrived: make(chan struct{}, 8)} + upgrader := websocket.Upgrader{} + pc.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + for { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + if !pc.record(data) { + continue + } + if err := pc.respond(conn, data); err != nil { + return + } + } + })) + t.Cleanup(pc.server.Close) + return pc +} + +func (pc *promptCapture) record(data []byte) bool { + var msg ws.Message + if err := json.Unmarshal(data, &msg); err != nil || msg.Action != "agent.prompt" { + return false + } + var entry capturedPrompt + if err := json.Unmarshal(msg.Payload, &entry.frame); err != nil { + return false + } + if err := json.Unmarshal(msg.Payload, &entry.raw); err != nil { + return false + } + pc.mu.Lock() + pc.captured = append(pc.captured, entry) + pc.mu.Unlock() + pc.arrived <- struct{}{} + return true +} + +func (pc *promptCapture) respond(conn *websocket.Conn, data []byte) error { + var msg ws.Message + if err := json.Unmarshal(data, &msg); err != nil { + return err + } + resp, err := ws.NewResponse(msg.ID, msg.Action, struct { + Success bool `json:"success"` + }{Success: true}) + if err != nil { + return err + } + out, err := json.Marshal(resp) + if err != nil { + return err + } + return conn.WriteMessage(websocket.TextMessage, out) +} + +// awaitOne blocks until exactly one prompt frame has been captured and returns +// it. Waiting on the arrival signal rather than sleeping keeps the assertion +// deterministic under -race. +func (pc *promptCapture) awaitOne(t *testing.T) capturedPrompt { + t.Helper() + select { + case <-pc.arrived: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for an agent.prompt frame") + } + pc.mu.Lock() + defer pc.mu.Unlock() + if len(pc.captured) != 1 { + t.Fatalf("expected exactly 1 captured prompt frame, got %d", len(pc.captured)) + } + return pc.captured[0] +} + +// connectedClient returns a client with a live agent stream against pc. +func (pc *promptCapture) connectedClient(t *testing.T, ctx context.Context) *Client { + t.Helper() + c := &Client{ + baseURL: pc.server.URL, + httpClient: &http.Client{Timeout: 5 * time.Second}, + logger: newTestLogger(), + pendingRequests: make(map[string]chan *ws.Message), + } + t.Cleanup(c.Close) + if err := c.StreamUpdates(ctx, func(AgentEvent) {}, nil, nil); err != nil { + t.Fatalf("connect agent stream: %v", err) + } + return c +} + +// TestPromptSteerEncodesSteerFlagAndPreservesPayload pins the client half of the +// steer contract: PromptSteer must set steer:true while carrying exactly the +// same text, attachments and prompt_generation an ordinary Prompt carries. The +// generation is load-bearing — a steer reuses the predecessor's live generation +// so the folded completion is attributed to the still-open waiter. +func TestPromptSteerEncodesSteerFlagAndPreservesPayload(t *testing.T) { + pc := startPromptCapture(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + client := pc.connectedClient(t, ctx) + + attachments := []v1.MessageAttachment{{ + Type: "image", + Data: "aGVsbG8=", + MimeType: "image/png", + Name: "shot.png", + }} + if err := client.PromptSteer(ctx, "stop and read the spec", attachments, 42); err != nil { + t.Fatalf("PromptSteer: %v", err) + } + + frame := pc.awaitOne(t).frame + if !frame.Steer { + t.Fatalf("PromptSteer must encode steer:true, got %+v", frame) + } + if frame.Text != "stop and read the spec" { + t.Fatalf("steer text not preserved: %q", frame.Text) + } + if frame.PromptGeneration != 42 { + t.Fatalf("steer must carry the caller's prompt_generation, got %d", frame.PromptGeneration) + } + if len(frame.Attachments) != 1 || frame.Attachments[0] != attachments[0] { + t.Fatalf("steer must preserve attachments, got %+v", frame.Attachments) + } +} + +// TestPromptDoesNotSetSteerFlag is the negative half: an ordinary Prompt must +// not carry the steer flag at all, so an agentctl that would honor it never +// sees one from the non-steering path. +func TestPromptDoesNotSetSteerFlag(t *testing.T) { + pc := startPromptCapture(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + client := pc.connectedClient(t, ctx) + + if err := client.Prompt(ctx, "carry on", nil, 7); err != nil { + t.Fatalf("Prompt: %v", err) + } + + got := pc.awaitOne(t) + if got.frame.Steer { + t.Fatalf("ordinary Prompt must not set steer, got %+v", got.frame) + } + // steer is `omitempty`, so a false value is absent from the wire entirely. + if _, present := got.raw["steer"]; present { + t.Fatalf("ordinary Prompt must omit the steer field, got %v", got.raw) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/event_types.go b/apps/backend/internal/agent/runtime/lifecycle/event_types.go index 04ec10cd13..077ff04eec 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/event_types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/event_types.go @@ -178,6 +178,7 @@ type AgentStreamEventData struct { SupportsImage bool `json:"supports_image"` SupportsAudio bool `json:"supports_audio"` SupportsEmbeddedContext bool `json:"supports_embedded_context"` + SupportsPromptQueueing bool `json:"supports_prompt_queueing"` AuthMethods []streams.AuthMethodInfo `json:"auth_methods,omitempty"` // Session models (from "session_models" event) @@ -509,6 +510,7 @@ type AgentCapabilitiesEventPayload struct { SupportsImage bool `json:"supports_image"` SupportsAudio bool `json:"supports_audio"` SupportsEmbeddedContext bool `json:"supports_embedded_context"` + SupportsPromptQueueing bool `json:"supports_prompt_queueing"` AuthMethods []streams.AuthMethodInfo `json:"auth_methods"` Timestamp string `json:"timestamp"` } diff --git a/apps/backend/internal/agent/runtime/lifecycle/events.go b/apps/backend/internal/agent/runtime/lifecycle/events.go index a4dcaccff8..fce23821c4 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/events.go +++ b/apps/backend/internal/agent/runtime/lifecycle/events.go @@ -198,6 +198,7 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even SupportsImage: event.SupportsImage, SupportsAudio: event.SupportsAudio, SupportsEmbeddedContext: event.SupportsEmbeddedContext, + SupportsPromptQueueing: event.SupportsPromptQueueing, AuthMethods: event.AuthMethods, CurrentModelID: event.CurrentModelID, SessionModels: event.SessionModels, diff --git a/apps/backend/internal/agent/runtime/lifecycle/execution_store.go b/apps/backend/internal/agent/runtime/lifecycle/execution_store.go index d572c7813b..eeb2229c41 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/execution_store.go +++ b/apps/backend/internal/agent/runtime/lifecycle/execution_store.go @@ -149,6 +149,28 @@ func (s *ExecutionStore) OwnsPromptGeneration(sessionID, executionID string, gen return exists && generation != 0 && execution.promptGeneration == generation } +// ActivePromptGeneration returns the generation of the prompt that is dispatched +// and still in flight for executionID, or 0 if there is none. A steer reuses this +// exact generation so the folded completion is attributed to the predecessor's +// still-open waiter rather than discarded as a mismatch. It deliberately returns +// 0 for a generation that is only admitted (not yet dispatched — its buffers are +// mid-reset) or already completed, so a steer never overtakes an about-to-start +// turn or attaches to a finished one; those cases fall back to an ordinary prompt. +func (s *ExecutionStore) ActivePromptGeneration(executionID string) uint64 { + s.mu.RLock() + defer s.mu.RUnlock() + + execution, exists := s.executions[executionID] + if !exists { + return 0 + } + gen := execution.promptGeneration + if gen == 0 || execution.dispatchedPromptGeneration != gen || execution.promptCompletionGeneration == gen { + return 0 + } + return gen +} + // GetByTaskEnvironmentID returns any execution associated with a task environment ID. func (s *ExecutionStore) GetByTaskEnvironmentID(taskEnvironmentID string) (*AgentExecution, bool) { s.mu.RLock() @@ -220,12 +242,34 @@ func (s *ExecutionStore) BeginPrompt(executionID string) (uint64, error) { func beginExecutionPrompt(execution *AgentExecution) uint64 { execution.promptGeneration++ execution.promptCompletionGeneration = 0 + // The new generation is not dispatched until its triggerPrompt succeeds; a + // steer must not reuse it until then (its buffers are still being reset). + execution.dispatchedPromptGeneration = 0 execution.ProviderError = nil execution.Status = v1.AgentStatusRunning execution.resetActiveTool() return execution.promptGeneration } +// MarkPromptDispatched records that generation reached agentctl and is in +// flight. Ignored if a newer generation has since begun or this one already +// completed, so a steer can only ever reuse a live dispatched turn. +func (s *ExecutionStore) MarkPromptDispatched(executionID string, generation uint64) { + if generation == 0 { + return + } + s.mu.Lock() + defer s.mu.Unlock() + execution, exists := s.executions[executionID] + if !exists { + return + } + if execution.promptGeneration != generation || execution.promptCompletionGeneration == generation { + return + } + execution.dispatchedPromptGeneration = generation +} + // UpdateError updates the error message of an agent execution and sets its status to failed. func (s *ExecutionStore) UpdateError(executionID string, errorMsg string) { s.mu.Lock() diff --git a/apps/backend/internal/agent/runtime/lifecycle/execution_store_test.go b/apps/backend/internal/agent/runtime/lifecycle/execution_store_test.go index b592caf291..2a00c15a4f 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/execution_store_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/execution_store_test.go @@ -39,6 +39,60 @@ func TestExecutionStore_AddRejectsDuplicateSession(t *testing.T) { } } +// TestExecutionStore_ActivePromptGenerationGating pins the predicate a mid-turn +// steer relies on: a generation is steerable only while it is dispatched and +// still in flight — never merely admitted (buffers mid-reset), completed, or +// superseded by a newer prompt. +func TestExecutionStore_ActivePromptGenerationGating(t *testing.T) { + store := NewExecutionStore() + exec := &AgentExecution{ID: "exec-1", SessionID: "session-1"} + if err := store.Add(exec); err != nil { + t.Fatalf("Add: %v", err) + } + + if got := store.ActivePromptGeneration("exec-1"); got != 0 { + t.Fatalf("no prompt begun: want 0, got %d", got) + } + + gen, err := store.BeginPrompt("exec-1") + if err != nil { + t.Fatalf("BeginPrompt: %v", err) + } + // Admitted but not dispatched yet — a steer must not reuse it (it could + // overtake the predecessor and race its buffer reset). + if got := store.ActivePromptGeneration("exec-1"); got != 0 { + t.Fatalf("admitted-not-dispatched: want 0, got %d", got) + } + + store.MarkPromptDispatched("exec-1", gen) + if got := store.ActivePromptGeneration("exec-1"); got != gen { + t.Fatalf("dispatched in-flight: want %d, got %d", gen, got) + } + + // Completion of that generation makes it non-steerable (the turn is over). + if err := store.WithLock("exec-1", func(e *AgentExecution) { e.promptCompletionGeneration = gen }); err != nil { + t.Fatalf("WithLock: %v", err) + } + if got := store.ActivePromptGeneration("exec-1"); got != 0 { + t.Fatalf("completed generation: want 0, got %d", got) + } + + // A newer generation that begins (and is not yet dispatched) is likewise not + // steerable, and MarkPromptDispatched for the stale generation is ignored. + newGen, err := store.BeginPrompt("exec-1") + if err != nil { + t.Fatalf("BeginPrompt (2): %v", err) + } + store.MarkPromptDispatched("exec-1", gen) // stale — must be ignored + if got := store.ActivePromptGeneration("exec-1"); got != 0 { + t.Fatalf("superseded, new gen not dispatched: want 0, got %d", got) + } + store.MarkPromptDispatched("exec-1", newGen) + if got := store.ActivePromptGeneration("exec-1"); got != newGen { + t.Fatalf("new gen dispatched: want %d, got %d", newGen, got) + } +} + func TestExecutionStore_AddSameExecutionTwiceIsIdempotent(t *testing.T) { store := NewExecutionStore() diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index dbe7bb9cca..dab9830699 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -109,6 +109,28 @@ func (m *Manager) PromptAgentWithDispatchCallback(ctx context.Context, execution return result, err } +// SteerAgentWithDispatchCallback delivers a steer: it hands the prompt into a +// turn that is still generating rather than serializing behind it. It mirrors +// PromptAgentWithDispatchCallback's activity accounting; only the underlying +// session-manager call differs. +func (m *Manager) SteerAgentWithDispatchCallback(ctx context.Context, executionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func()) (*PromptResult, error) { + execution, exists := m.executionStore.Get(executionID) + if !exists { + return nil, fmt.Errorf("execution %q not found: %w", executionID, ErrExecutionNotFound) + } + lease, err := m.acquireActivity(ctx, activity.KindExecutionRunning) + if err != nil { + return nil, err + } + key := executionActivityKey(executionID) + m.trackActivity(key, lease) + result, err := m.sessionManager.SendPromptSteerWithDispatchCallback(ctx, execution, prompt, true, attachments, dispatchOnly, onDispatched) + if err != nil || !dispatchOnly { + m.releaseActivity(key) + } + return result, err +} + // cancelWaitTimeout bounds how long CancelAgent waits for the in-flight SendPrompt // to exit after the in-flight session/prompt RPC has ended (cancel acknowledged). // Exposed as a var (not const) so tests can shorten it without fake clocks. diff --git a/apps/backend/internal/agent/runtime/lifecycle/session.go b/apps/backend/internal/agent/runtime/lifecycle/session.go index e5db82b464..f76cc22690 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session.go @@ -40,6 +40,16 @@ type SessionManager struct { historyManager *SessionHistoryManager attachmentReader AttachmentReader stopCh <-chan struct{} // For graceful shutdown coordination + // beforeSteerDispatchHook, when set (tests only), runs inside tryDispatchSteer + // after the active generation is selected but before the steer RPC, while + // promptLifecycleMu is held — used to drive the completion-vs-dispatch race + // deterministically. + beforeSteerDispatchHook func() + // beforePromptDispatchHook, when set (tests only), runs inside sendPrompt after + // the generation is begun but before it is dispatched — used to pin a + // predecessor in the admitted-but-undispatched window so a racing steer's + // ordering can be observed deterministically. + beforePromptDispatchHook func() } // NewSessionManager creates a new SessionManager @@ -861,7 +871,7 @@ func (sm *SessionManager) SendPrompt( attachments []v1.MessageAttachment, dispatchOnly bool, ) (*PromptResult, error) { - return sm.sendPrompt(ctx, execution, prompt, validateStatus, attachments, dispatchOnly, nil) + return sm.sendPrompt(ctx, execution, prompt, validateStatus, attachments, dispatchOnly, nil, false) } // SendPromptWithDispatchCallback reports the point at which agentctl accepted @@ -875,7 +885,186 @@ func (sm *SessionManager) SendPromptWithDispatchCallback( dispatchOnly bool, onDispatched func(), ) (*PromptResult, error) { - return sm.sendPrompt(ctx, execution, prompt, validateStatus, attachments, dispatchOnly, onDispatched) + return sm.sendPrompt(ctx, execution, prompt, validateStatus, attachments, dispatchOnly, onDispatched, false) +} + +// activePromptGeneration returns the dispatched, in-flight prompt generation a +// steer must reuse, or 0 when there is none to steer into. The execution store +// is the authoritative, lock-guarded source; tests that build a SessionManager +// without one apply the same predicate directly. +func (sm *SessionManager) activePromptGeneration(execution *AgentExecution) uint64 { + if sm.executionStore != nil { + return sm.executionStore.ActivePromptGeneration(execution.ID) + } + gen := execution.promptGeneration + if gen == 0 || execution.dispatchedPromptGeneration != gen || execution.promptCompletionGeneration == gen { + return 0 + } + return gen +} + +// markPromptDispatched records that an ordinary prompt's generation reached +// agentctl, making it eligible for a steer to reuse. Mirrors the store predicate +// for the no-store test path. +func (sm *SessionManager) markPromptDispatched(execution *AgentExecution, generation uint64) { + if sm.executionStore != nil { + sm.executionStore.MarkPromptDispatched(execution.ID, generation) + return + } + if generation != 0 && execution.promptGeneration == generation && + execution.promptCompletionGeneration != generation { + execution.dispatchedPromptGeneration = generation + } +} + +// SendPromptSteerWithDispatchCallback delivers a steer into a still-generating +// turn. It deliberately bypasses the serialized path in sendPrompt: taking +// execution.promptMu or waiting on the pending-dispatch barrier would hold the +// steer until the very turn it is meant to interrupt has ended, defeating the +// feature. Instead it issues the ACP PromptSteer against the *active* prompt +// generation, so agentctl folds the message into the running turn (or hands the +// turn off) while the predecessor's completion waiter and streaming buffers stay +// authoritative. +// +// Reusing the active generation is load-bearing: the single real completion the +// ACP adapter emits (the predecessor's early end_turn is suppressed there) then +// carries that generation and wakes the predecessor's waitForPromptDone. A fresh +// generation would be discarded as a mismatch and the predecessor would hang. +// This path therefore never begins a new generation, resets buffers, drains +// promptDoneCh, or opens a completion barrier — the ACP adapter's +// beginSteerHandoff owns the gate transfer and generation-keyed suppression. +// +// Delivery is opportunistic: with no live turn to fold into (idle, a turn that +// was admitted but not yet dispatched, one that already completed, or a +// disconnected stream) there is nothing to steer, so it falls back to an +// ordinary prompt delivered in submission order rather than dropping the message. +func (sm *SessionManager) SendPromptSteerWithDispatchCallback( + ctx context.Context, + execution *AgentExecution, + prompt string, + validateStatus bool, + attachments []v1.MessageAttachment, + dispatchOnly bool, + onDispatched func(), +) (*PromptResult, error) { + if execution.agentctl == nil { + return nil, fmt.Errorf("execution %q has no agentctl client", execution.ID) + } + if validateStatus && execution.Status != v1.AgentStatusRunning && execution.Status != v1.AgentStatusReady { + return nil, fmt.Errorf("execution %q is not ready for prompts (status: %s)", execution.ID, execution.Status) + } + + steered, err := sm.tryDispatchSteer(ctx, execution, prompt, attachments) + if err != nil { + return nil, err + } + if !steered { + return sm.sendPrompt(ctx, execution, prompt, validateStatus, attachments, dispatchOnly, onDispatched, false) + } + if onDispatched != nil { + onDispatched() + } + // A steer never owns turn completion — the predecessor's waiter does — so it + // always returns dispatched-and-continue regardless of the caller's + // dispatchOnly hint. + return &PromptResult{StopReason: PromptStopReasonDispatched}, nil +} + +// steerDispatchTimeout bounds how long tryDispatchSteer holds promptLifecycleMu +// waiting for agentctl to acknowledge the steer. The healthy path is a single WS +// round-trip (agentctl acks before it runs the prompt), but sendStreamRequest has +// no deadline of its own, so a half-open connection could otherwise pin the lock +// — wedging this execution's completion claims and new prompts — until the stream +// tears down. A timeout lands on the error path, which is send-safe under the +// no-resend fallback. Exposed as a var so tests can shorten it. +var steerDispatchTimeout = 5 * time.Second + +// tryDispatchSteer selects the active dispatched generation and issues the steer +// against it while holding promptLifecycleMu, so neither a completion +// (claimPromptCompletion) nor a new turn (beginExecutionPrompt) — both of which +// take the same lock — can invalidate the chosen generation between the read and +// the send. Without this the read-then-send gap is a TOCTOU: the turn could +// complete in the window and the steer would fire on a dead generation whose +// completion the manager then discards as already-claimed. +// +// Holding the lock across the RPC is deadlock-free: the ACP client resolves the +// prompt-ack on its read loop, while the event worker that takes this lock is a +// separate goroutine (see agent.go readUpdatesStream) — a completion event +// arriving first is enqueued, never inline, so it cannot backpressure the ack. +// The RPC is bounded by steerDispatchTimeout so the lock is never held on a +// stalled connection, and the history append is done after the lock is released. +// +// Returns (false, nil) when there is nothing to steer into — no live generation, +// or the stream is not connected — so the caller degrades to an ordinary prompt. +// It deliberately uses the single fast RPC, not dispatchPrompt's reconnect path: +// a disconnected stream means there is no live turn to fold into, and reconnect +// can block for seconds, which must not run under the lifecycle lock. +func (sm *SessionManager) tryDispatchSteer( + ctx context.Context, + execution *AgentExecution, + prompt string, + attachments []v1.MessageAttachment, +) (bool, error) { + if sessionSpan := trace.SpanFromContext(execution.SessionTraceContext()); sessionSpan.SpanContext().IsValid() { + ctx = trace.ContextWithSpan(ctx, sessionSpan) + } + dispatched, err := sm.dispatchSteerLocked(ctx, execution, prompt, attachments) + if err != nil { + if isAgentStreamNotConnectedErr(err) { + return false, nil + } + return false, err + } + if !dispatched { + return false, nil + } + // History I/O is done outside promptLifecycleMu so a slow history store never + // extends the lifecycle lock's hold. + sm.recordSteerActivity(execution, prompt) + return true, nil +} + +// dispatchSteerLocked performs the lock-guarded critical section of a steer: read +// the live generation and issue the bounded RPC under promptLifecycleMu. Returns +// (false, nil) when there is no live generation to steer into. +func (sm *SessionManager) dispatchSteerLocked( + ctx context.Context, + execution *AgentExecution, + prompt string, + attachments []v1.MessageAttachment, +) (bool, error) { + execution.promptLifecycleMu.Lock() + defer execution.promptLifecycleMu.Unlock() + + generation := sm.activePromptGeneration(execution) + if generation == 0 { + return false, nil + } + if sm.beforeSteerDispatchHook != nil { + sm.beforeSteerDispatchHook() + } + effectivePrompt := sm.buildEffectivePrompt(execution, prompt) + dispatchCtx, cancel := context.WithTimeout(ctx, steerDispatchTimeout) + defer cancel() + if err := sm.callAgentctlPrompt(dispatchCtx, execution, effectivePrompt, attachments, generation, true); err != nil { + return false, err + } + return true, nil +} + +// recordSteerActivity mirrors the ordinary prompt path's history append and +// activity-timestamp bump for a steer that actually dispatched. Kept off the +// fallback path so a steer that degrades to an ordinary prompt is not recorded +// twice. +func (sm *SessionManager) recordSteerActivity(execution *AgentExecution, prompt string) { + if sm.historyManager != nil && execution.historyEnabled && execution.SessionID != "" { + if err := sm.historyManager.AppendUserMessage(execution.SessionID, prompt); err != nil { + sm.logger.Warn("failed to store steer user message to history", zap.Error(err)) + } + } + execution.lastActivityAtMu.Lock() + execution.lastActivityAt = time.Now() + execution.lastActivityAtMu.Unlock() } func (sm *SessionManager) sendPrompt( @@ -886,6 +1075,7 @@ func (sm *SessionManager) sendPrompt( attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func(), + steer bool, ) (*PromptResult, error) { if execution.agentctl == nil { return nil, fmt.Errorf("execution %q has no agentctl client", execution.ID) @@ -929,9 +1119,15 @@ func (sm *SessionManager) sendPrompt( if err != nil { return nil, err } - if err := sm.triggerPrompt(preparedCtx, execution, effectivePrompt, materializedAttachments, promptGeneration); err != nil { + if sm.beforePromptDispatchHook != nil { + sm.beforePromptDispatchHook() + } + if err := sm.triggerPrompt(preparedCtx, execution, effectivePrompt, materializedAttachments, promptGeneration, steer); err != nil { return nil, err } + // The generation is now accepted by agentctl and in flight, so a concurrent + // steer may reuse it. (The steer path never marks — it reuses, not owns.) + sm.markPromptDispatched(execution, promptGeneration) return sm.finishAcceptedPrompt(preparedCtx, execution, dispatchOnly, onDispatched, promptGeneration) } @@ -1070,8 +1266,9 @@ func (sm *SessionManager) triggerPrompt( prompt string, attachments []v1.MessageAttachment, promptGeneration uint64, + steer bool, ) error { - err := sm.dispatchPrompt(ctx, execution, prompt, attachments, promptGeneration) + err := sm.dispatchPrompt(ctx, execution, prompt, attachments, promptGeneration, steer) if err == nil { return nil } @@ -1118,21 +1315,39 @@ func (sm *SessionManager) finishAcceptedPrompt( return sm.waitForPromptDone(ctx, execution, promptGeneration) } +// callAgentctlPrompt selects the steer or ordinary prompt RPC. Kept in one place +// so the initial dispatch and the post-reconnect retry cannot diverge on which +// one they send. +func (sm *SessionManager) callAgentctlPrompt( + ctx context.Context, + execution *AgentExecution, + prompt string, + attachments []v1.MessageAttachment, + promptGeneration uint64, + steer bool, +) error { + if steer { + return execution.agentctl.PromptSteer(ctx, prompt, attachments, promptGeneration) + } + return execution.agentctl.Prompt(ctx, prompt, attachments, promptGeneration) +} + func (sm *SessionManager) dispatchPrompt( ctx context.Context, execution *AgentExecution, prompt string, attachments []v1.MessageAttachment, promptGeneration uint64, + steer bool, ) error { - err := execution.agentctl.Prompt(ctx, prompt, attachments, promptGeneration) + err := sm.callAgentctlPrompt(ctx, execution, prompt, attachments, promptGeneration, steer) if err == nil || !isAgentStreamNotConnectedErr(err) || sm.streamManager == nil { return err } sm.logger.Warn("agent stream not connected, reconnecting and retrying prompt once", zap.String("execution_id", execution.ID)) - retryErr := sm.retryPromptAfterReconnect(ctx, execution, prompt, attachments, promptGeneration) + retryErr := sm.retryPromptAfterReconnect(ctx, execution, prompt, attachments, promptGeneration, steer) if retryErr == nil { return nil } @@ -1159,6 +1374,7 @@ func (sm *SessionManager) retryPromptAfterReconnect( prompt string, attachments []v1.MessageAttachment, promptGeneration uint64, + steer bool, ) error { reconnectCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() @@ -1180,8 +1396,8 @@ func (sm *SessionManager) retryPromptAfterReconnect( } if execution.agentctl.HasAgentStream() { - if err := execution.agentctl.Prompt( - reconnectCtx, prompt, attachments, promptGeneration, + if err := sm.callAgentctlPrompt( + reconnectCtx, execution, prompt, attachments, promptGeneration, steer, ); err == nil { return nil } else if !isAgentStreamNotConnectedErr(err) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/session_steer_test.go b/apps/backend/internal/agent/runtime/lifecycle/session_steer_test.go new file mode 100644 index 0000000000..b8556d7ec7 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/session_steer_test.go @@ -0,0 +1,469 @@ +package lifecycle + +import ( + "context" + "sync" + "testing" + "time" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agentctl/types/streams" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// These tests exercise the mid-turn steer dispatch path +// (SessionManager.SendPromptSteerWithDispatchCallback). The invariant under test: +// a steer reuses the *active dispatched* prompt generation and never serializes +// behind it — so the operator's input reaches the still-generating turn — while +// a steer with no live turn (idle, pre-dispatch, or already-completed) degrades +// to an ordinary prompt delivered in submission order. + +type capturedPrompt struct { + text string + generation uint64 + steer bool +} + +// promptProbe captures every agent.prompt frame the mock receives so a test can +// assert the wire-level generation and steer flag. +type promptProbe struct { + mu sync.Mutex + seen []capturedPrompt + ch chan capturedPrompt + dropSteerAck bool // when set, steer frames get no response (simulates a stalled ack) +} + +func newPromptProbe() *promptProbe { + return &promptProbe{ch: make(chan capturedPrompt, 8)} +} + +func (p *promptProbe) setDropSteerAck(drop bool) { + p.mu.Lock() + p.dropSteerAck = drop + p.mu.Unlock() +} + +func (p *promptProbe) install(mock *mockAgentServer) { + mock.handler = func(msg ws.Message) *ws.Message { + if msg.Action == "agent.prompt" { + var payload struct { + Text string `json:"text"` + PromptGeneration uint64 `json:"prompt_generation"` + Steer bool `json:"steer"` + } + _ = msg.ParsePayload(&payload) + cp := capturedPrompt{payload.Text, payload.PromptGeneration, payload.Steer} + p.mu.Lock() + drop := p.dropSteerAck && payload.Steer + p.seen = append(p.seen, cp) + p.mu.Unlock() + p.ch <- cp + if drop { + return nil // no response — the client's RPC waits until its deadline + } + } + return mock.defaultHandler(msg) + } +} + +func (p *promptProbe) next(t *testing.T) capturedPrompt { + t.Helper() + select { + case cp := <-p.ch: + return cp + case <-time.After(2 * time.Second): + t.Fatal("no agent.prompt frame reached the mock in time") + return capturedPrompt{} + } +} + +// steerHarness wires a manager, a connected agentctl client, and one tracked +// execution against the mock agent server. +type steerHarness struct { + mgr *Manager + exec *AgentExecution + probe *promptProbe + ctx context.Context +} + +func newSteerHarness(t *testing.T) *steerHarness { + t.Helper() + mock := newMockAgentServer(t) + t.Cleanup(mock.Close) + probe := newPromptProbe() + probe.install(mock) + + mgr, _ := createTestManagerWithTracking() + client := createTestClient(t, mock.server.URL) + t.Cleanup(client.Close) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + if err := client.StreamUpdates(ctx, func(agentctl.AgentEvent) {}, nil, nil); err != nil { + t.Fatalf("connect stream: %v", err) + } + waitForWSConnected(t, mock) + + exec := createTestExecution("exec-steer", "task-steer", "session-steer") + exec.agentctl = client + if err := mgr.executionStore.Add(exec); err != nil { + t.Fatalf("add execution: %v", err) + } + return &steerHarness{mgr: mgr, exec: exec, probe: probe, ctx: ctx} +} + +// startForegroundTurn dispatches an ordinary prompt and blocks it in flight +// (its SendPrompt holds promptMu and waits for completion), returning a channel +// that receives when that predecessor turn finally settles. It waits until the +// generation is marked dispatched so a steer can legitimately reuse it. +func (h *steerHarness) startForegroundTurn(t *testing.T, wantGen uint64) chan error { + t.Helper() + done := make(chan error, 1) + go func() { + _, err := h.mgr.sessionManager.SendPrompt(h.ctx, h.exec, "foreground", false, nil, false) + done <- err + }() + // The predecessor's frame reaches the mock, then markPromptDispatched runs. + h.probe.next(t) + waitForActiveGeneration(t, h.mgr, h.exec.ID, wantGen) + return done +} + +func waitForActiveGeneration(t *testing.T, mgr *Manager, execID string, want uint64) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if mgr.executionStore.ActivePromptGeneration(execID) == want { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatalf("active prompt generation never reached %d (got %d)", + want, mgr.executionStore.ActivePromptGeneration(execID)) +} + +// TestSendPromptSteer_ReusesActiveGenerationWithoutBlocking is the core proof: +// a steer delivered while the foreground turn is still generating must NOT wait +// on the predecessor's promptMu, must reach agentctl as a steer carrying the +// predecessor's generation, must not begin a new generation, and the single +// completion for that generation must wake the predecessor's waiter exactly once. +func TestSendPromptSteer_ReusesActiveGenerationWithoutBlocking(t *testing.T) { + h := newSteerHarness(t) + firstDone := h.startForegroundTurn(t, 1) + + steerRes := make(chan *PromptResult, 1) + steerErr := make(chan error, 1) + go func() { + res, err := h.mgr.sessionManager.SendPromptSteerWithDispatchCallback( + h.ctx, h.exec, "steer now", false, nil, true, nil, + ) + if err != nil { + steerErr <- err + return + } + steerRes <- res + }() + + // The predecessor still holds promptMu; the steer must return regardless. + select { + case res := <-steerRes: + if res == nil || res.StopReason != PromptStopReasonDispatched { + t.Fatalf("steer result = %+v, want dispatched", res) + } + case err := <-steerErr: + t.Fatalf("steer errored: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("steer blocked behind the predecessor's promptMu — mid-turn delivery defeated") + } + + // It reached agentctl as a steer on the predecessor's generation. + sp := h.probe.next(t) + if !sp.steer { + t.Fatalf("steer frame carried steer=%v, want true", sp.steer) + } + if sp.generation != 1 { + t.Fatalf("steer frame generation = %d, want 1 (reuse the predecessor)", sp.generation) + } + + // No new generation was begun by the steer. + if !h.mgr.executionStore.OwnsPromptGeneration(h.exec.SessionID, h.exec.ID, 1) { + t.Fatal("steer changed the active generation instead of reusing it") + } + + // One completion for generation 1 wakes the predecessor's waiter exactly once. + h.mgr.handleAgentEvent(h.exec, agentctl.AgentEvent{ + Type: streams.EventTypeComplete, + PromptGeneration: 1, + Data: map[string]any{"stop_reason": "end_turn"}, + }) + select { + case err := <-firstDone: + if err != nil { + t.Fatalf("predecessor turn returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("the single generation-1 completion did not wake the predecessor waiter") + } +} + +// TestSendPromptSteer_FallsBackWhenNoTurnInFlight proves an idle steer-capable +// session degrades to an ordinary prompt (a new generation, steer=false). +func TestSendPromptSteer_FallsBackWhenNoTurnInFlight(t *testing.T) { + h := newSteerHarness(t) + + if got := h.mgr.executionStore.ActivePromptGeneration(h.exec.ID); got != 0 { + t.Fatalf("precondition: active generation = %d, want 0", got) + } + + res, err := h.mgr.sessionManager.SendPromptSteerWithDispatchCallback( + h.ctx, h.exec, "no turn yet", false, nil, true, nil, + ) + if err != nil { + t.Fatalf("steer fallback errored: %v", err) + } + if res == nil || res.StopReason != PromptStopReasonDispatched { + t.Fatalf("steer fallback result = %+v, want dispatched", res) + } + sp := h.probe.next(t) + if sp.steer { + t.Fatal("fallback prompt must not carry steer=true") + } + if sp.generation != 1 { + t.Fatalf("fallback generation = %d, want 1 (a fresh ordinary prompt)", sp.generation) + } +} + +// TestSendPromptSteer_FallsBackAfterPredecessorCompleted proves a steer that +// arrives after the foreground turn already finished does not attach to that +// dead generation; it runs as a fresh ordinary prompt. +func TestSendPromptSteer_FallsBackAfterPredecessorCompleted(t *testing.T) { + h := newSteerHarness(t) + firstDone := h.startForegroundTurn(t, 1) + + h.mgr.handleAgentEvent(h.exec, agentctl.AgentEvent{ + Type: streams.EventTypeComplete, + PromptGeneration: 1, + Data: map[string]any{"stop_reason": "end_turn"}, + }) + select { + case <-firstDone: + case <-time.After(2 * time.Second): + t.Fatal("predecessor turn did not settle") + } + if got := h.mgr.executionStore.ActivePromptGeneration(h.exec.ID); got != 0 { + t.Fatalf("completed generation still reported active: %d", got) + } + + res, err := h.mgr.sessionManager.SendPromptSteerWithDispatchCallback( + h.ctx, h.exec, "after done", false, nil, true, nil, + ) + if err != nil { + t.Fatalf("steer-after-completion errored: %v", err) + } + if res == nil || res.StopReason != PromptStopReasonDispatched { + t.Fatalf("steer-after-completion result = %+v, want dispatched", res) + } + sp := h.probe.next(t) + if sp.steer { + t.Fatal("post-completion steer must fall back to an ordinary prompt (steer=false)") + } + if sp.generation != 2 { + t.Fatalf("post-completion fallback generation = %d, want 2", sp.generation) + } +} + +// TestSendPromptSteer_DoesNotOvertakeAdmittedButUndispatchedTurn proves the +// pre-dispatch race is closed with a real predecessor: while a genuine prompt is +// pinned in the admitted-but-undispatched window (generation begun, prompt not +// yet sent), a racing steer must not bind to it or overtake it. The steer falls +// back to an ordinary prompt that serializes behind the predecessor, so the wire +// order is predecessor-then-steer, both ordinary, with no steer frame at all. +func TestSendPromptSteer_DoesNotOvertakeAdmittedButUndispatchedTurn(t *testing.T) { + h := newSteerHarness(t) + + // Pin the first (predecessor) sendPrompt in the window between BeginPrompt and + // dispatch. Only the first call blocks; the steer's own fallback sendPrompt + // (the second) runs unhindered. + var once sync.Once + predecessorPinned := make(chan struct{}) + releasePredecessor := make(chan struct{}) + h.mgr.sessionManager.beforePromptDispatchHook = func() { + once.Do(func() { + close(predecessorPinned) + <-releasePredecessor + }) + } + + predecessorDone := make(chan error, 1) + go func() { + _, err := h.mgr.sessionManager.SendPrompt(h.ctx, h.exec, "predecessor", false, nil, true) + predecessorDone <- err + }() + <-predecessorPinned // generation 1 admitted, not yet dispatched, promptMu held + + // The steer arrives now. activePromptGeneration is 0 (gen 1 not marked + // dispatched), so it must fall back — and the fallback then blocks on promptMu + // behind the pinned predecessor. + steerDone := make(chan struct{}) + go func() { + _, _ = h.mgr.sessionManager.SendPromptSteerWithDispatchCallback( + h.ctx, h.exec, "racing steer", false, nil, true, nil, + ) + close(steerDone) + }() + + // No frame of any kind can reach the agent while the predecessor is pinned: + // the predecessor hasn't dispatched and the steer is blocked behind it. + select { + case cp := <-h.probe.ch: + t.Fatalf("a prompt reached the agent while the predecessor was pinned: %+v", cp) + case <-time.After(100 * time.Millisecond): + } + + close(releasePredecessor) + if err := <-predecessorDone; err != nil { + t.Fatalf("predecessor returned error: %v", err) + } + + // The predecessor dispatched its ordinary generation 1 first. + first := h.probe.next(t) + if first.steer || first.generation != 1 { + t.Fatalf("first frame = %+v, want ordinary generation 1 (predecessor)", first) + } + + // The predecessor left a pending dispatched prompt, so the steer's fallback + // waits for generation 1 to complete before it dispatches — driving that + // completion lets it proceed, in order. + h.mgr.handleAgentEvent(h.exec, agentctl.AgentEvent{ + Type: streams.EventTypeComplete, + PromptGeneration: 1, + Data: map[string]any{"stop_reason": "end_turn"}, + }) + <-steerDone + + // The steer fell back to an ordinary generation 2 — never a steer frame, and + // strictly after the predecessor. + second := h.probe.next(t) + if second.steer || second.generation != 2 { + t.Fatalf("second frame = %+v, want ordinary generation 2 (steer fell back, in order)", second) + } +} + +// TestSendPromptSteer_GenerationCannotCompleteBetweenReadAndDispatch closes the +// TOCTOU: while the steer holds promptLifecycleMu (after selecting the active +// generation, before the RPC), a completion for that generation must not be +// claimable — so the steer always dispatches against a still-live turn. +func TestSendPromptSteer_GenerationCannotCompleteBetweenReadAndDispatch(t *testing.T) { + h := newSteerHarness(t) + firstDone := h.startForegroundTurn(t, 1) + + gateEntered := make(chan struct{}) + release := make(chan struct{}) + h.mgr.sessionManager.beforeSteerDispatchHook = func() { + close(gateEntered) + <-release + } + + steerDone := make(chan struct{}) + go func() { + _, _ = h.mgr.sessionManager.SendPromptSteerWithDispatchCallback( + h.ctx, h.exec, "steer", false, nil, true, nil, + ) + close(steerDone) + }() + <-gateEntered // steer holds promptLifecycleMu with generation 1 selected + + // Drive a completion for generation 1 concurrently. It must block on + // promptLifecycleMu (via claimPromptCompletion) until the steer releases. + completionApplied := make(chan struct{}) + go func() { + h.mgr.handleAgentEvent(h.exec, agentctl.AgentEvent{ + Type: streams.EventTypeComplete, + PromptGeneration: 1, + Data: map[string]any{"stop_reason": "end_turn"}, + }) + close(completionApplied) + }() + select { + case <-completionApplied: + t.Fatal("completion was claimed while the steer held the lock — TOCTOU not closed") + case <-time.After(100 * time.Millisecond): + } + + close(release) // steer sends the RPC and releases the lock + <-steerDone + + sp := h.probe.next(t) + if !sp.steer || sp.generation != 1 { + t.Fatalf("steer frame = %+v, want steer=true gen=1 dispatched before completion", sp) + } + + <-completionApplied // now the completion proceeds + select { + case err := <-firstDone: + if err != nil { + t.Fatalf("predecessor returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("predecessor waiter did not wake after the completion") + } +} + +// TestSendPromptSteer_StalledAckReleasesLockWithinTimeout proves the steer never +// pins promptLifecycleMu on a stalled agent: with the agent never acknowledging +// the steer, the bounded dispatch returns within steerDispatchTimeout and the +// predecessor's completion (which needs the same lock) is still claimed after. +func TestSendPromptSteer_StalledAckReleasesLockWithinTimeout(t *testing.T) { + h := newSteerHarness(t) + firstDone := h.startForegroundTurn(t, 1) + + prev := steerDispatchTimeout + steerDispatchTimeout = 200 * time.Millisecond + t.Cleanup(func() { steerDispatchTimeout = prev }) + h.probe.setDropSteerAck(true) + + steerReturned := make(chan error, 1) + go func() { + _, err := h.mgr.sessionManager.SendPromptSteerWithDispatchCallback( + h.ctx, h.exec, "steer", false, nil, true, nil, + ) + steerReturned <- err + }() + + select { + case err := <-steerReturned: + if err == nil { + t.Fatal("stalled steer returned nil; expected the bounded dispatch to error") + } + case <-time.After(2 * time.Second): + t.Fatal("steer did not return within the dispatch timeout — lifecycle lock pinned") + } + + // The lock was released: the predecessor's completion can still be claimed. + h.mgr.handleAgentEvent(h.exec, agentctl.AgentEvent{ + Type: streams.EventTypeComplete, + PromptGeneration: 1, + Data: map[string]any{"stop_reason": "end_turn"}, + }) + select { + case err := <-firstDone: + if err != nil { + t.Fatalf("predecessor returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("predecessor completion wedged — steer did not release promptLifecycleMu") + } +} + +// TestSendPromptSteer_NilAgentctlClientErrors guards the dedicated path's +// precondition without a connected client. +func TestSendPromptSteer_NilAgentctlClientErrors(t *testing.T) { + sm := NewSessionManager(newSessionTestLogger(), make(chan struct{})) + exec := &AgentExecution{ID: "exec-1", promptDoneCh: make(chan PromptCompletionSignal, 1)} + _, err := sm.SendPromptSteerWithDispatchCallback( + context.Background(), exec, "x", false, nil, true, nil, + ) + if err == nil { + t.Fatal("expected an error when the execution has no agentctl client") + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index b2d4d859b6..275b82e476 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -67,6 +67,13 @@ type AgentExecution struct { // promptCompletionGeneration prevents duplicate terminal events for the // same prompt from replacing the first terminal outcome or provider error. promptCompletionGeneration uint64 + // dispatchedPromptGeneration is the generation of the prompt that has been + // accepted by agentctl and is still in flight. It is set only after the + // ordinary prompt's triggerPrompt succeeds and reset by beginExecutionPrompt. + // A mid-turn steer reuses this exact generation, so the getter must not hand + // out a generation that was merely admitted (not yet dispatched — its buffers + // are still being reset) or already completed. + dispatchedPromptGeneration uint64 promptLifecycleMu sync.Mutex // PrepareResult carries the environment preparation result back to the caller diff --git a/apps/backend/internal/agentctl/AGENTS.md b/apps/backend/internal/agentctl/AGENTS.md index 218e4a8d27..ced5a88ea8 100644 --- a/apps/backend/internal/agentctl/AGENTS.md +++ b/apps/backend/internal/agentctl/AGENTS.md @@ -39,6 +39,8 @@ Protocol adapters in `server/adapter/transport/` normalize different agent CLIs: The `acp` transport is split by concern across `adapter_*.go` files: `adapter.go` (core/lifecycle), `adapter_session.go` (initialize/new/load/resume), `adapter_prompt.go` (prompt/cancel), `adapter_updates.go` (`session/update` notification fan-out), `adapter_tools.go` (`convertToolCallUpdate` / `convertToolCallResultUpdate` -> normalized payloads), `adapter_permissions.go`, and `adapter_helpers.go`. Agent-specific ACP extensions use the package-private `acpDialect` function table in `dialect.go`; keep observed wire translation in `dialect_.go`. Dialect hooks return normalized data or request descriptions and never receive `*Adapter` or execute RPCs. Shared capability normalization used by both live sessions and utility probes belongs in `internal/agentctl/acpcompat/`. Tool-call conversion lives in `adapter_tools.go`, not `adapter.go`. See ADR-0043. +**Prompt handoff and steering are negotiated, not named.** Both the foreground-idle handoff (ADR-0049) and mid-turn steering (ADR-2026-08-04) gate on the agent's `initialize` advertisement `agentCapabilities._meta.claudeCode.promptQueueing`, read once in `prompt_queueing.go` and cached on the adapter — never on `agentID`. A steer transfers the existing prompt-gate token to a human successor while the predecessor `session/prompt` is still open (`adapter_prompt_cancel.go: handOffTurnLocked`), reusing the generation-keyed completion attribution and background-work protection built for handoff. Steering is exposed via the optional `adapter.SteerablePrompter` interface (`PromptSteer` / `SupportsSteering`), so non-steering transports need no change. Delivery is opportunistic: the advertisement asserts the agent accepts a concurrent prompt, not that it folds it — both outcomes must be correct. + ### Grok ACP dialect (`dialect_grok.go`) Grok exposes some model, config, and usage surfaces through implementation-specific ACP metadata. Its dialect normalizes them in the backend so shared ACP paths and the frontend stay provider-agnostic: diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go index 80ed72ec55..13e41e156a 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter.go @@ -325,3 +325,25 @@ func (c *Config) ToSharedConfig() *shared.Config { RequiresProcessKill: c.RequiresProcessKill, } } + +// SteerablePrompter is implemented by an adapter that can deliver a prompt into +// a turn that is still generating, instead of holding it until the turn ends. +// +// It is an optional interface rather than a method on AgentAdapter so transports +// that cannot steer need no change — the same shape the process manager uses for +// RequiresProcessKill. Callers must type-assert and must also check +// SupportsSteering(), which reflects the connected agent's negotiated +// advertisement and is therefore only known after initialize. +// +// Delivery is opportunistic: whether the agent folds the prompt into the running +// turn or runs it as the next turn is the agent's decision and is not advertised +// over the protocol. See docs/specs/platform/mid-turn-steering.md. +type SteerablePrompter interface { + SupportsSteering() bool + PromptSteer( + ctx context.Context, + message string, + attachments []v1.MessageAttachment, + promptGeneration uint64, + ) error +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index f4f9d0832b..e6b22ee263 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -128,6 +128,13 @@ type Adapter struct { agentInfo *AgentInfo capabilities acp.AgentCapabilities + // promptQueueing caches the negotiated prompt-queueing advertisement from the + // initialize response. It is derived once under `mu` rather than re-read from + // `capabilities` on each prompt because the prompt path reads it concurrently + // with nothing else holding the write side, and a plain read of the untyped + // `capabilities.Meta` map would race. + promptQueueing bool + // Update channel updatesCh chan AgentEvent @@ -436,22 +443,26 @@ func (a *Adapter) Initialize(ctx context.Context) error { a.agentInfo.Version = resp.AgentInfo.Version } a.capabilities = resp.AgentCapabilities + promptQueueing := agentAdvertisesPromptQueueing(resp.AgentCapabilities) span.SetAttributes( attribute.String("agent_name", a.agentInfo.Name), attribute.String("agent_version", a.agentInfo.Version), attribute.Bool("supports_load_session", a.capabilities.LoadSession), + attribute.Bool("supports_prompt_queueing", promptQueueing), ) a.logger.Info("ACP adapter initialized", zap.String("agent_name", a.agentInfo.Name), zap.String("agent_version", a.agentInfo.Version), - zap.Bool("supports_load_session", a.capabilities.LoadSession)) + zap.Bool("supports_load_session", a.capabilities.LoadSession), + zap.Bool("supports_prompt_queueing", promptQueueing)) // Cache auth methods so we can re-emit them on auth_required without re-running initialize. authMethods := convertAuthMethods(resp.AuthMethods) a.mu.Lock() a.availableAuthMethods = authMethods + a.promptQueueing = promptQueueing a.mu.Unlock() // Emit agent capabilities event with prompt capabilities and auth methods @@ -460,6 +471,7 @@ func (a *Adapter) Initialize(ctx context.Context) error { SupportsImage: a.capabilities.PromptCapabilities.Image, SupportsAudio: a.capabilities.PromptCapabilities.Audio, SupportsEmbeddedContext: a.capabilities.PromptCapabilities.EmbeddedContext, + SupportsPromptQueueing: promptQueueing, AuthMethods: authMethods, }) diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go index f541cf0fd1..592dc66430 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go @@ -28,7 +28,36 @@ func (a *Adapter) Prompt( promptGeneration uint64, ) error { // A user prompt always targets the current session, so it is not pinned. - return a.sendPrompt(ctx, message, attachments, "", promptGeneration) + return a.sendPrompt(ctx, message, attachments, "", promptGeneration, false) +} + +// SupportsSteering reports whether this adapter can deliver a prompt into a turn +// that is still generating. It is the same negotiated advertisement that gates +// prompt handoff — the agent must accept a concurrent session/prompt. +func (a *Adapter) SupportsSteering() bool { + return a.supportsPromptHandoff() +} + +// PromptSteer delivers a prompt without waiting for the in-flight turn to end. +// +// It differs from Prompt in exactly one way: instead of blocking on the prompt +// gate until the current turn releases it (or a provider foreground-idle event +// attests a handoff), the operator's send is itself the handoff trigger. The +// predecessor's `session/prompt` stays open and the token transfers to this +// call, so two prompts briefly overlap on one ACP session with one logical +// owner — the arrangement ADR 0049 already built for the foreground-idle case. +// +// Delivery is opportunistic. Whether the agent folds this prompt into the +// running turn or runs it as the next turn is the agent's decision and is not +// advertised over the protocol, so both outcomes must be correct. See +// docs/specs/platform/mid-turn-steering.md. +func (a *Adapter) PromptSteer( + ctx context.Context, + message string, + attachments []v1.MessageAttachment, + promptGeneration uint64, +) error { + return a.sendPrompt(ctx, message, attachments, "", promptGeneration, true) } // sendPrompt serializes session/prompt calls through promptGate and sends one @@ -45,6 +74,7 @@ func (a *Adapter) sendPrompt( attachments []v1.MessageAttachment, expectSession string, promptGeneration uint64, + steer bool, ) error { humanPrompt := expectSession == "" promptCtx, turn := newPromptTurnState( @@ -52,6 +82,20 @@ func (a *Adapter) sendPrompt( promptGeneration, humanPrompt && a.supportsPromptHandoff() && promptGeneration != 0, ) + // A steer initiates the handoff itself rather than waiting for a provider + // foreground-idle event. Best-effort by design: if there is no handoff-eligible + // turn in flight (idle session, or a synthetic wakeup holding the gate), this + // is a no-op and the call falls through to ordinary gate acquisition, which is + // exactly the specified behavior for those cases. + // + // Guarded on a live context: beginSteerHandoff protects the predecessor's + // background work and closes its handoff channel, which only pays off once a + // successor actually acquires the gate. If ctx is already cancelled the + // acquisition below will fail immediately, so triggering the handoff first + // would strand that protection with no successor to clear it. + if steer && humanPrompt && promptGeneration != 0 && a.supportsPromptHandoff() && ctx.Err() == nil { + a.beginSteerHandoff() + } if err := a.acquirePromptTurn(ctx, turn, humanPrompt); err != nil { return err } @@ -235,8 +279,14 @@ func (a *Adapter) sendPrompt( return nil } +// supportsPromptHandoff reports whether this adapter's connected agent may have +// its in-flight prompt handed off to a human successor. Gated on the negotiated +// prompt-queueing advertisement rather than the agent's id, per ADR 0049's +// rejection of a central agent-name whitelist. func (a *Adapter) supportsPromptHandoff() bool { - return a.agentID == claudeAgentID || a.agentID == mockAgentID + a.mu.RLock() + defer a.mu.RUnlock() + return a.promptQueueing } func normalizePromptErrorAfterCancel(promptCtx context.Context, err error) error { @@ -379,7 +429,9 @@ func (a *Adapter) fireWakeup(sessionID, prompt string) { defer cancel() // Pin to the scheduled session: if the active session changed while this // wakeup waited on the prompt gate, sendPrompt drops it. - if err := a.sendPrompt(ctx, prompt, nil, sessionID, 0); err != nil { + // Never a steer: a synthetic wakeup must stay serialized behind the owning + // prompt and must not consume a handoff meant for a human successor. + if err := a.sendPrompt(ctx, prompt, nil, sessionID, 0, false); err != nil { a.logger.Error("synthetic wakeup prompt failed", zap.String("session_id", sessionID), zap.Error(err)) diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel.go index 1bb370ea6e..741a092864 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel.go @@ -149,11 +149,47 @@ func (a *Adapter) markPromptHandoff(sessionID string, promptGeneration uint64) b a.promptTurnMu.Lock() defer a.promptTurnMu.Unlock() turn := a.promptTurn - if turn == nil || - turn.promptGeneration != promptGeneration || - !turn.allowHandoff || - !turn.gateOwned || - turn.finishing { + if turn == nil || turn.promptGeneration != promptGeneration { + return false + } + return a.handOffTurnLocked(turn, sessionID) +} + +// beginSteerHandoff hands the in-flight turn off at the operator's request +// rather than on a provider-attested foreground-idle event. It deliberately does +// not match a prompt generation: the trigger is the arriving successor, not a +// frame belonging to the predecessor, so there is no generation to compare. +// +// Reports whether a handoff is now pending, which lets a caller distinguish +// "steering" from "there was nothing to steer" for logging. Callers must treat +// false as benign: an idle session, or a synthetic wakeup holding the gate +// (allowHandoff is false for those), both land here and correctly fall through +// to ordinary gate acquisition. +func (a *Adapter) beginSteerHandoff() bool { + a.mu.RLock() + sessionID := a.sessionID + a.mu.RUnlock() + + a.promptTurnMu.Lock() + defer a.promptTurnMu.Unlock() + turn := a.promptTurn + if turn == nil { + return false + } + return a.handOffTurnLocked(turn, sessionID) +} + +// handOffTurnLocked marks turn handed off and wakes any waiting human successor. +// Callers hold promptTurnMu. +// +// It deliberately leaves the physical gate token in place so a synthetic wakeup +// cannot consume the handoff, and protects the background work that was live at +// the boundary so the successor's prompt-end sweeps cannot retire a predecessor's +// still-running workload. Both properties are load-bearing: a backgrounded shell +// has been observed outliving two prompt resolutions and reporting only much +// later, as assistant text with no tool update. +func (a *Adapter) handOffTurnLocked(turn *promptTurnState, sessionID string) bool { + if !turn.allowHandoff || !turn.gateOwned || turn.finishing { return false } if !turn.handedOff { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_updates_prompt_generation_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_updates_prompt_generation_test.go index 1e01fe280e..56da82d8d1 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_updates_prompt_generation_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_updates_prompt_generation_test.go @@ -12,6 +12,10 @@ import ( func TestEnqueueACPUpdateSnapshotsPromptGenerationBeforeWorkerConversion(t *testing.T) { a := newTestAdapter() a.agentID = claudeAgentID + // Foreground-idle delivery is gated on the negotiated prompt-queueing + // advertisement, which Initialize normally derives; these tests drive + // handleACPUpdate directly, so set it explicitly. + a.promptQueueing = true t.Cleanup(func() { _ = a.Close() }) var notification sdk.SessionNotification @@ -61,6 +65,10 @@ func TestEnqueueACPUpdateSnapshotsPromptGenerationBeforeWorkerConversion(t *test func TestHandleACPUpdate_HumanOriginDeliversLeadingContextWindowThenForegroundIdle(t *testing.T) { a := newTestAdapter() a.agentID = claudeAgentID + // Foreground-idle delivery is gated on the negotiated prompt-queueing + // advertisement, which Initialize normally derives; these tests drive + // handleACPUpdate directly, so set it explicitly. + a.promptQueueing = true t.Cleanup(func() { _ = a.Close() }) var notification sdk.SessionNotification diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/fold_handoff_fixture_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/fold_handoff_fixture_test.go new file mode 100644 index 0000000000..05a026e476 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/fold_handoff_fixture_test.go @@ -0,0 +1,206 @@ +package acp + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// foldFixture is a trimmed ACP transcript recorded against the real +// claude-agent-acp bridge by scripts/probe-acp-midturn-fold. Volatile values +// (timings, session ids, token totals for unrelated fields) are dropped so the +// fixture pins the SHAPE the adapter must survive rather than one recorded run. +// +// ADR 0049 admits prompt handoff only for a provider bridge that is +// fixture-tested to accept the next human prompt echo while the earlier RPC is +// still open. These fixtures are that evidence, made executable. +type foldFixture struct { + Kind string `json:"kind"` + Frames []foldFixtFrame `json:"frames"` +} + +type foldFixtFrame struct { + Dir string `json:"dir"` + Method string `json:"method,omitempty"` + Update string `json:"update,omitempty"` + Text string `json:"text,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` + Title string `json:"title,omitempty"` + Status string `json:"status,omitempty"` + StopReason string `json:"stopReason,omitempty"` + PromptResponseFor *int `json:"promptResponseFor,omitempty"` + Usage map[string]int `json:"usage,omitempty"` + ID int `json:"id,omitempty"` +} + +func loadFoldFixture(t *testing.T, name string) foldFixture { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + var fixture foldFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("decode fixture %s: %v", name, err) + } + if len(fixture.Frames) == 0 { + t.Fatalf("fixture %s has no frames", name) + } + return fixture +} + +func usageIsZero(usage map[string]int) bool { + for _, v := range usage { + if v != 0 { + return false + } + } + return true +} + +// TestFoldHandoffFixtureShape pins the observed contract that task 04's +// attribution work has to satisfy: the predecessor prompt resolves end_turn +// BEFORE it has produced any answer text and with zeroed usage, and the answer +// plus the real usage land on the successor prompt. An adapter that treats that +// early settlement as the predecessor's completion corrupts the transcript. +func TestFoldHandoffFixtureShape(t *testing.T) { + fixture := loadFoldFixture(t, "acp-fold-handoff.json") + if fixture.Kind != "fold_handoff" { + t.Fatalf("fixture kind = %q, want fold_handoff", fixture.Kind) + } + + var ( + sentPrompts []int + firstResponseIdx = -1 + firstResponseForID int + textBeforeFirst int + predecessorUsage map[string]int + successorUsage map[string]int + predecessorStop string + sleepToolCalls int + answer strings.Builder + ) + + for i, f := range fixture.Frames { + switch { + case f.Dir == "sent" && f.Method == "session/prompt": + sentPrompts = append(sentPrompts, f.ID) + case f.PromptResponseFor != nil: + if firstResponseIdx == -1 { + firstResponseIdx = i + firstResponseForID = *f.PromptResponseFor + predecessorUsage = f.Usage + predecessorStop = f.StopReason + } else { + successorUsage = f.Usage + } + case f.Update == "agent_message_chunk": + if firstResponseIdx == -1 { + textBeforeFirst++ + } + answer.WriteString(f.Text) + case f.Update == "tool_call" && strings.Contains(strings.ToLower(f.Title), "sleep"): + sleepToolCalls++ + case f.Update == "tool_call_update" && strings.Contains(strings.ToLower(f.Title), "sleep"): + sleepToolCalls++ + } + } + + if len(sentPrompts) != 2 { + t.Fatalf("sent prompts = %d, want 2 (predecessor + steer)", len(sentPrompts)) + } + // The steer must have been written while the predecessor was still open — + // otherwise this transcript proves nothing about concurrent prompts. + secondSentIdx := -1 + for i, f := range fixture.Frames { + if f.Dir == "sent" && f.Method == "session/prompt" && f.ID == sentPrompts[1] { + secondSentIdx = i + } + } + if secondSentIdx == -1 || secondSentIdx > firstResponseIdx { + t.Fatalf("steer was not sent before the predecessor resolved (sent idx %d, response idx %d)", + secondSentIdx, firstResponseIdx) + } + if firstResponseForID != sentPrompts[0] { + t.Fatalf("first response answered prompt %d, want the predecessor %d", + firstResponseForID, sentPrompts[0]) + } + if predecessorStop != "end_turn" { + t.Fatalf("predecessor stopReason = %q, want end_turn", predecessorStop) + } + if textBeforeFirst != 0 { + t.Fatalf("predecessor produced %d text chunk(s) before resolving; the fold "+ + "contract is that it resolves with no answer of its own", textBeforeFirst) + } + if !usageIsZero(predecessorUsage) { + t.Fatalf("predecessor usage = %v, want all zeros", predecessorUsage) + } + if usageIsZero(successorUsage) { + t.Fatalf("successor usage = %v, want the turn's real usage", successorUsage) + } + if got := answer.String(); !strings.Contains(got, "STEERED") { + t.Fatalf("answer = %q, want the steered reply", got) + } + if sleepToolCalls == 0 { + t.Fatal("fixture has no sleep tool frames; it cannot show the steer took effect") + } +} + +// TestBackgroundSurvivesHandoffFixtureShape pins two facts task 04 depends on: +// a launch tool card reports completed long before its workload does, and the +// workload's eventual completion arrives as assistant text with NO tool update, +// after every prompt has already resolved. That is why background-work +// protection is required at the handoff boundary and why liveness cannot be +// tracked on the tool channel. +func TestBackgroundSurvivesHandoffFixtureShape(t *testing.T) { + fixture := loadFoldFixture(t, "acp-fold-handoff-background.json") + if fixture.Kind != "background_survives_handoff" { + t.Fatalf("fixture kind = %q, want background_survives_handoff", fixture.Kind) + } + + responses := 0 + lastResponseIdx := -1 + launchCompletedIdx := -1 + for i, f := range fixture.Frames { + if f.PromptResponseFor != nil { + responses++ + lastResponseIdx = i + } + if f.Update == "tool_call_update" && f.Status == "completed" && launchCompletedIdx == -1 { + launchCompletedIdx = i + } + } + if responses != 2 { + t.Fatalf("prompt responses = %d, want 2", responses) + } + if launchCompletedIdx == -1 { + t.Fatal("no completed tool_call_update; fixture cannot show launch-card completion") + } + if launchCompletedIdx > lastResponseIdx { + t.Fatal("launch card completed after the last prompt resolved; fixture does not " + + "show the card settling ahead of the workload") + } + + // Everything after the final prompt response is orphan activity: a model + // cycle with no owning session/prompt RPC. + orphanText := 0 + orphanToolUpdates := 0 + for _, f := range fixture.Frames[lastResponseIdx+1:] { + switch f.Update { + case "agent_message_chunk": + orphanText++ + case "tool_call", "tool_call_update": + orphanToolUpdates++ + } + } + if orphanText == 0 { + t.Fatal("no assistant text after the last prompt resolved; fixture does not show " + + "background work outliving the handoff") + } + if orphanToolUpdates != 0 { + t.Fatalf("orphan cycle carried %d tool update(s); the observed contract is that a "+ + "settled background workload reports only as text", orphanToolUpdates) + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/prompt_handoff_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/prompt_handoff_test.go index 86c9b3b6a2..1843a7cb05 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/prompt_handoff_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/prompt_handoff_test.go @@ -379,21 +379,127 @@ func TestHumanFollowupWaitsWithoutForegroundHandoff(t *testing.T) { } } -func TestPromptHandoffCapabilityIsAdapterAttested(t *testing.T) { +// TestPromptHandoffCapabilityIsNegotiatedNotNamed pins the ADR-0049 rule that +// eligibility comes from the agent's advertisement, not its identity: a +// name-matching agent that does not advertise is ineligible, and an unnamed agent +// that does advertise is eligible. +func TestPromptHandoffCapabilityIsNegotiatedNotNamed(t *testing.T) { for _, test := range []struct { + name string agentID string + caps sdk.AgentCapabilities want bool }{ - {agentID: claudeAgentID, want: true}, - {agentID: mockAgentID, want: true}, - {agentID: codexAgentID, want: false}, - {agentID: "other-acp", want: false}, + { + name: "advertised", + agentID: claudeAgentID, + caps: promptQueueingCapabilities(true), + want: true, + }, + { + name: "claude that does not advertise is ineligible", + agentID: claudeAgentID, + caps: sdk.AgentCapabilities{}, + want: false, + }, + { + name: "mock that does not advertise is ineligible", + agentID: mockAgentID, + caps: sdk.AgentCapabilities{}, + want: false, + }, + { + name: "unnamed agent that advertises is eligible", + agentID: "other-acp", + caps: promptQueueingCapabilities(true), + want: true, + }, + { + name: "codex without the advertisement", + agentID: codexAgentID, + caps: sdk.AgentCapabilities{}, + want: false, + }, + { + name: "explicit false", + agentID: claudeAgentID, + caps: promptQueueingCapabilities(false), + want: false, + }, } { - a := newTestAdapter() - a.agentID = test.agentID - if got := a.supportsPromptHandoff(); got != test.want { - t.Fatalf("agent %q handoff support = %t, want %t", test.agentID, got, test.want) - } - _ = a.Close() + t.Run(test.name, func(t *testing.T) { + a := newTestAdapter() + a.agentID = test.agentID + a.promptQueueing = agentAdvertisesPromptQueueing(test.caps) + if got := a.supportsPromptHandoff(); got != test.want { + t.Fatalf("agent %q handoff support = %t, want %t", test.agentID, got, test.want) + } + _ = a.Close() + }) + } +} + +func promptQueueingCapabilities(queueing bool) sdk.AgentCapabilities { + return sdk.AgentCapabilities{ + Meta: map[string]any{ + "claudeCode": map[string]any{promptQueueingMetaKey: queueing}, + }, + } +} + +// TestAgentAdvertisesPromptQueueingFailsClosed covers every malformed shape the +// untyped `_meta` map can arrive in over the wire. +func TestAgentAdvertisesPromptQueueingFailsClosed(t *testing.T) { + for _, test := range []struct { + name string + caps sdk.AgentCapabilities + }{ + {name: "nil meta", caps: sdk.AgentCapabilities{}}, + { + name: "empty meta", + caps: sdk.AgentCapabilities{Meta: map[string]any{}}, + }, + { + name: "namespace missing", + caps: sdk.AgentCapabilities{Meta: map[string]any{"other": map[string]any{}}}, + }, + { + name: "namespace not an object", + caps: sdk.AgentCapabilities{Meta: map[string]any{"claudeCode": "yes"}}, + }, + { + name: "namespace nil", + caps: sdk.AgentCapabilities{Meta: map[string]any{"claudeCode": nil}}, + }, + { + name: "key missing", + caps: sdk.AgentCapabilities{ + Meta: map[string]any{"claudeCode": map[string]any{"toolName": "Monitor"}}, + }, + }, + { + name: "value is a string", + caps: sdk.AgentCapabilities{ + Meta: map[string]any{"claudeCode": map[string]any{promptQueueingMetaKey: "true"}}, + }, + }, + { + name: "value is a number", + caps: sdk.AgentCapabilities{ + Meta: map[string]any{"claudeCode": map[string]any{promptQueueingMetaKey: 1}}, + }, + }, + { + name: "value is nil", + caps: sdk.AgentCapabilities{ + Meta: map[string]any{"claudeCode": map[string]any{promptQueueingMetaKey: nil}}, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + if agentAdvertisesPromptQueueing(test.caps) { + t.Fatalf("%s: advertised prompt queueing, want fail-closed false", test.name) + } + }) } } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/prompt_queueing.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/prompt_queueing.go new file mode 100644 index 0000000000..b6f2a2703c --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/prompt_queueing.go @@ -0,0 +1,42 @@ +package acp + +import "github.com/coder/acp-go-sdk" + +// promptQueueingMetaKey is the vendor `_meta` key an agent sets on its ACP +// initialize response to advertise that it accepts a `session/prompt` while +// another prompt for the same session is still in flight. +// +// Observed on `@agentclientprotocol/claude-agent-acp` as +// `agentCapabilities._meta.claudeCode.promptQueueing: true`. +const promptQueueingMetaKey = "promptQueueing" + +// agentAdvertisesPromptQueueing reports whether the connected agent advertised +// that a second `session/prompt` may be issued while one is still in flight. +// This is the negotiated precondition for both prompt handoff and mid-turn +// steering. +// +// It is deliberately keyed off the advertisement rather than the agent's +// identity. ADR 0049 rejects a central agent-name whitelist because "an agent +// identity does not prove that the installed adapter/provider version emits the +// lifecycle frames Kandev expects" — a bridge too old to advertise the +// capability must be ineligible even when its name matches. +// +// Note carefully what this does and does not assert. It asserts the agent +// accepts the concurrent prompt. It does NOT assert the agent will fold that +// prompt into the running turn: that decision belongs to the agent CLI beneath +// the bridge, is not advertised over ACP, and its version is not observable +// here. Mid-turn delivery is therefore opportunistic, and a caller must be +// correct whether the agent folds the prompt or runs it as the next turn. See +// docs/specs/platform/mid-turn-steering.md. +// +// Every malformed shape fails closed to false: absent `_meta`, absent +// namespace, a namespace that is not an object, a missing key, or a value that +// is not a bool. +func agentAdvertisesPromptQueueing(caps acp.AgentCapabilities) bool { + cc := claudeCodeMeta(caps.Meta) + if cc == nil { + return false + } + queueing, ok := cc[promptQueueingMetaKey].(bool) + return ok && queueing +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/prompt_steer_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/prompt_steer_test.go new file mode 100644 index 0000000000..dc7ff0c779 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/prompt_steer_test.go @@ -0,0 +1,395 @@ +package acp + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// awaitPromptCall reads the next provider prompt call with a bound. A bare +// channel read here would hang the whole package for the go-test timeout when a +// regression stops the steer from transferring the token, instead of failing this +// one test fast. +func awaitPromptCall(t *testing.T, fake *handoffFakeAgent, what string) handoffPromptCall { + t.Helper() + select { + case call := <-fake.entered: + return call + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for %s to reach the provider", what) + return handoffPromptCall{} + } +} + +// awaitPromptErr reads a prompt goroutine's result with a bound. +func awaitPromptErr(t *testing.T, done <-chan error, what string) error { + t.Helper() + select { + case err := <-done: + return err + case <-time.After(3 * time.Second): + t.Fatalf("timed out waiting for %s to settle", what) + return nil + } +} + +// TestSteerReachesProviderWhileTurnGenerating is the core contract: a steer must +// reach the agent while the predecessor's session/prompt is still open, with no +// provider foreground-idle event involved. The handoff fake blocks its first +// prompt until a second one arrives, so the second prompt reaching it at all +// proves the token transferred mid-turn. +func TestSteerReachesProviderWhileTurnGenerating(t *testing.T) { + a, fake, _ := setupHandoffFakeAgent(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + if err := a.Initialize(ctx); err != nil { + t.Fatalf("initialize: %v", err) + } + if _, err := a.NewSession(ctx, nil); err != nil { + t.Fatalf("new session: %v", err) + } + if !a.SupportsSteering() { + t.Fatal("advertised agent does not report steering support") + } + + firstDone := make(chan error, 1) + go func() { firstDone <- a.Prompt(ctx, "long running work", nil, 71) }() + if call := <-fake.entered; call.number != 1 { + t.Fatalf("first provider prompt call = %d, want 1", call.number) + } + + steerDone := make(chan error, 1) + go func() { steerDone <- a.PromptSteer(ctx, "change of plan", nil, 72) }() + + select { + case call := <-fake.entered: + if call.number != 2 || call.text != "change of plan" { + t.Fatalf("steer provider prompt = %+v, want call 2 with the steer text", call) + } + case <-time.After(2 * time.Second): + t.Fatal("steer did not reach the provider while the predecessor turn was open") + } + + select { + case err := <-firstDone: + if err != nil { + t.Fatalf("predecessor prompt errored after steer handoff: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("predecessor RPC never released after the steer arrived") + } + + fake.releasePrompts() + select { + case err := <-steerDone: + if err != nil { + t.Fatalf("steer prompt returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("steer prompt did not complete") + } +} + +// TestSteerAttributesCompletionToSuccessor pins the attribution rule. The +// predecessor's early settlement must not emit a completion of its own, and the +// completion the client sees must carry the steer's generation. +func TestSteerAttributesCompletionToSuccessor(t *testing.T) { + a, fake, _ := setupHandoffFakeAgent(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + if err := a.Initialize(ctx); err != nil { + t.Fatalf("initialize: %v", err) + } + if _, err := a.NewSession(ctx, nil); err != nil { + t.Fatalf("new session: %v", err) + } + + firstDone := make(chan error, 1) + go func() { firstDone <- a.Prompt(ctx, "predecessor", nil, 81) }() + awaitPromptCall(t, fake, "predecessor prompt") + + steerDone := make(chan error, 1) + go func() { steerDone <- a.PromptSteer(ctx, "steer", nil, 82) }() + awaitPromptCall(t, fake, "steer") + if err := awaitPromptErr(t, firstDone, "predecessor prompt"); err != nil { + t.Fatalf("predecessor prompt errored after handoff: %v", err) + } + + fake.releasePrompts() + if err := <-steerDone; err != nil { + t.Fatalf("steer prompt: %v", err) + } + + complete := waitForEventType(t, a, streams.EventTypeComplete) + if complete.PromptGeneration != 82 { + t.Fatalf("completion generation = %d, want the steer's 82", complete.PromptGeneration) + } + for _, event := range drainEvents(a) { + if event.Type == streams.EventTypeComplete && event.PromptGeneration == 81 { + t.Fatal("predecessor emitted a completion for a turn the steer took over") + } + } +} + +// TestSteerUsageCountedOnceAcrossHandoff pins the spec's usage rule: the turn's +// usage is recorded once, on the successor, and the predecessor's early +// settlement never contributes a second usage-bearing completion. Because the +// predecessor emits no completion at all, its (zeroed) usage cannot be +// double-counted; the single completion the client sees carries the steer's +// generation and the turn's usage. +func TestSteerUsageCountedOnceAcrossHandoff(t *testing.T) { + a, fake, conn := setupHandoffFakeAgent(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + if err := a.Initialize(ctx); err != nil { + t.Fatalf("initialize: %v", err) + } + if _, err := a.NewSession(ctx, nil); err != nil { + t.Fatalf("new session: %v", err) + } + + firstDone := make(chan error, 1) + go func() { firstDone <- a.Prompt(ctx, "predecessor", nil, 91) }() + awaitPromptCall(t, fake, "predecessor prompt") + + steerDone := make(chan error, 1) + go func() { steerDone <- a.PromptSteer(ctx, "steer", nil, 92) }() + awaitPromptCall(t, fake, "steer") + if err := awaitPromptErr(t, firstDone, "predecessor prompt"); err != nil { + t.Fatalf("predecessor errored after handoff: %v", err) + } + + // The turn reports cumulative usage before it settles. + sendCapturedUpdate(t, conn, `{ + "sessionId":"session-handoff", + "update":{ + "sessionUpdate":"usage_update", + "size":1000000, + "used":5000, + "_meta":{"_claude/origin":{"kind":"human"}} + } + }`) + + fake.releasePrompts() + if err := <-steerDone; err != nil { + t.Fatalf("steer prompt: %v", err) + } + + complete := waitForEventType(t, a, streams.EventTypeComplete) + if complete.PromptGeneration != 92 { + t.Fatalf("completion generation = %d, want the steer's 92", complete.PromptGeneration) + } + // The turn's usage rides the single successor completion — recorded, and once. + if complete.Usage == nil { + t.Fatal("successor completion carried no usage; the turn's usage was dropped") + } + if complete.Usage.InputTokens != 5000 { + t.Fatalf("completion usage InputTokens = %d, want the turn's reported 5000", complete.Usage.InputTokens) + } + // Exactly one usage-bearing completion: the predecessor's suppressed + // settlement must not emit a second, which would double-count usage. + for _, event := range drainEvents(a) { + if event.Type == streams.EventTypeComplete { + t.Fatalf("a second completion was emitted (generation %d) — usage double-counted across the steer boundary", event.PromptGeneration) + } + } +} + +// TestSteerPreservesPredecessorBackgroundWork proves the boundary does not sweep +// work that was live when the turn was handed off. This is required, not +// cosmetic: a backgrounded workload has been observed outliving both prompts and +// reporting only afterwards. +func TestSteerPreservesPredecessorBackgroundWork(t *testing.T) { + a, fake, conn := setupHandoffFakeAgent(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + if err := a.Initialize(ctx); err != nil { + t.Fatalf("initialize: %v", err) + } + if _, err := a.NewSession(ctx, nil); err != nil { + t.Fatalf("new session: %v", err) + } + + firstDone := make(chan error, 1) + go func() { firstDone <- a.Prompt(ctx, "spawn a child then keep working", nil, 91) }() + awaitPromptCall(t, fake, "predecessor prompt") + + sendCapturedUpdate(t, conn, `{ + "sessionId":"session-handoff", + "update":{ + "sessionUpdate":"tool_call", + "toolCallId":"subagent-steer", + "title":"Agent", + "kind":"other", + "status":"in_progress", + "rawInput":{"description":"child","prompt":"keep working","subagent_type":"general-purpose"}, + "_meta":{"claudeCode":{"toolName":"Agent"}} + } + }`) + launch := waitForEventType(t, a, streams.EventTypeToolCall) + if launch.NormalizedPayload == nil || !launch.NormalizedPayload.IsActiveBackgroundWork() { + t.Fatal("child launch was not attested as live background work") + } + + steerDone := make(chan error, 1) + go func() { steerDone <- a.PromptSteer(ctx, "steer mid-turn", nil, 92) }() + awaitPromptCall(t, fake, "steer") + if err := awaitPromptErr(t, firstDone, "predecessor prompt"); err != nil { + t.Fatalf("predecessor prompt errored after handoff: %v", err) + } + + fake.releasePrompts() + if err := <-steerDone; err != nil { + t.Fatalf("steer prompt: %v", err) + } + waitForEventType(t, a, streams.EventTypeComplete) + + a.mu.RLock() + _, stillTracked := a.activeToolCalls["subagent-steer"] + a.mu.RUnlock() + if !stillTracked { + t.Fatal("steer handoff swept background work that was live at the boundary") + } +} + +// TestSteerOnIdleSessionActsAsOrdinaryPrompt covers the specified idle case: with +// nothing in flight there is nothing to hand off, and the steer must still be +// delivered rather than erroring or hanging. +func TestSteerOnIdleSessionActsAsOrdinaryPrompt(t *testing.T) { + a, fake, _ := setupHandoffFakeAgent(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + if err := a.Initialize(ctx); err != nil { + t.Fatalf("initialize: %v", err) + } + if _, err := a.NewSession(ctx, nil); err != nil { + t.Fatalf("new session: %v", err) + } + + done := make(chan error, 1) + go func() { done <- a.PromptSteer(ctx, "first thing said", nil, 101) }() + select { + case call := <-fake.entered: + if call.number != 1 || call.text != "first thing said" { + t.Fatalf("idle steer reached provider as %+v, want call 1", call) + } + case <-time.After(2 * time.Second): + t.Fatal("steer on an idle session never reached the provider") + } + fake.releasePrompts() + if err := <-done; err != nil { + t.Fatalf("idle steer returned error: %v", err) + } +} + +// TestSteerRequiresNegotiatedCapability proves an agent that never advertised +// prompt queueing cannot be steered: the steer must wait for the gate like any +// ordinary prompt instead of transferring the token. +func TestSteerRequiresNegotiatedCapability(t *testing.T) { + a, fake, _ := setupHandoffFakeAgent(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + if err := a.Initialize(ctx); err != nil { + t.Fatalf("initialize: %v", err) + } + if _, err := a.NewSession(ctx, nil); err != nil { + t.Fatalf("new session: %v", err) + } + + // Revoke the advertisement after initialize to isolate the gate from the + // fake's wire behavior. + a.mu.Lock() + a.promptQueueing = false + a.mu.Unlock() + if a.SupportsSteering() { + t.Fatal("adapter reports steering support without the advertisement") + } + + firstDone := make(chan error, 1) + go func() { firstDone <- a.Prompt(ctx, "predecessor", nil, 111) }() + awaitPromptCall(t, fake, "predecessor prompt") + + steerDone := make(chan error, 1) + go func() { steerDone <- a.PromptSteer(ctx, "should queue", nil, 112) }() + + select { + case call := <-fake.entered: + t.Fatalf("steer reached provider as %+v despite no advertisement; it must wait", call) + case <-time.After(300 * time.Millisecond): + } + + fake.releasePrompts() + if err := awaitPromptErr(t, firstDone, "predecessor prompt"); err != nil { + t.Fatalf("predecessor prompt: %v", err) + } + select { + case err := <-steerDone: + if err != nil { + t.Fatalf("queued steer returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("queued steer never ran after the predecessor released the gate") + } +} + +// TestSteerCancelReleasesGate covers the cancel scenario: cancelling during an +// in-flight steer must end both prompts and leave the gate free, so the session +// does not stay falsely busy. +func TestSteerCancelReleasesGate(t *testing.T) { + a, fake, _ := setupHandoffFakeAgent(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + if err := a.Initialize(ctx); err != nil { + t.Fatalf("initialize: %v", err) + } + if _, err := a.NewSession(ctx, nil); err != nil { + t.Fatalf("new session: %v", err) + } + + firstDone := make(chan error, 1) + go func() { firstDone <- a.Prompt(ctx, "predecessor", nil, 121) }() + awaitPromptCall(t, fake, "predecessor prompt") + + steerDone := make(chan error, 1) + go func() { steerDone <- a.PromptSteer(ctx, "steer", nil, 122) }() + awaitPromptCall(t, fake, "steer") + if err := awaitPromptErr(t, firstDone, "predecessor prompt"); err != nil { + t.Fatalf("predecessor prompt errored after handoff: %v", err) + } + + // Cancel while the steer owns the turn. + _ = a.Cancel(ctx) + fake.releasePrompts() + select { + case <-steerDone: + case <-time.After(3 * time.Second): + t.Fatal("steer prompt did not settle after cancel") + } + + // The gate must be free: a following prompt has to be admissible. + nextDone := make(chan error, 1) + go func() { nextDone <- a.Prompt(ctx, "after cancel", nil, 123) }() + select { + case call := <-fake.entered: + if call.text != "after cancel" { + t.Fatalf("post-cancel prompt = %+v, want the follow-up text", call) + } + case <-time.After(2 * time.Second): + t.Fatal("prompt gate was not released after cancelling a steer") + } + fake.releasePrompts() + select { + case <-nextDone: + case <-time.After(2 * time.Second): + t.Fatal("post-cancel prompt did not settle") + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-fold-handoff-background.json b/apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-fold-handoff-background.json new file mode 100644 index 0000000000..f9b9d576b0 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-fold-handoff-background.json @@ -0,0 +1,148 @@ +{ + "kind": "background_survives_handoff", + "frames": [ + { + "dir": "sent", + "method": "session/prompt", + "id": 3 + }, + { + "dir": "received", + "update": "tool_call", + "toolCallId": "toolu_01Rf1wdyaNZNt3po2LXdip5M", + "title": "Terminal", + "status": "pending" + }, + { + "dir": "received", + "update": "tool_call_update", + "toolCallId": "toolu_01Rf1wdyaNZNt3po2LXdip5M", + "title": "sleep 90", + "status": null + }, + { + "dir": "received", + "update": "tool_call_update", + "toolCallId": "toolu_01Rf1wdyaNZNt3po2LXdip5M", + "title": null, + "status": null + }, + { + "dir": "received", + "update": "tool_call_update", + "toolCallId": "toolu_01Rf1wdyaNZNt3po2LXdip5M", + "title": null, + "status": "completed" + }, + { + "dir": "sent", + "method": "session/prompt", + "id": 4 + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": "" + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": "Step 1" + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": " done. Now step 2: foreground sleep 15." + }, + { + "dir": "received", + "update": "tool_call", + "toolCallId": "toolu_012CQkHyzHB2iJoLHbvHtwsW", + "title": "Terminal", + "status": "pending" + }, + { + "dir": "received", + "update": "tool_call_update", + "toolCallId": "toolu_012CQkHyzHB2iJoLHbvHtwsW", + "title": "sleep 15", + "status": null + }, + { + "dir": "received", + "update": "tool_call_update", + "toolCallId": "toolu_012CQkHyzHB2iJoLHbvHtwsW", + "title": null, + "status": null + }, + { + "dir": "received", + "update": "tool_call_update", + "toolCallId": "toolu_012CQkHyzHB2iJoLHbvHtwsW", + "title": null, + "status": "completed" + }, + { + "dir": "received", + "promptResponseFor": 3, + "stopReason": "end_turn", + "usage": { + "inputTokens": 0, + "outputTokens": 0, + "cachedReadTokens": 0, + "cachedWriteTokens": 0 + } + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": "" + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": "The" + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": " foreground sleep completed. Now addressing your updated instruction.\n\nSTEER" + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": "ED" + }, + { + "dir": "received", + "promptResponseFor": 4, + "stopReason": "end_turn", + "usage": { + "inputTokens": 17972, + "outputTokens": 282, + "cachedReadTokens": 60788, + "cachedWriteTokens": 16319 + } + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": "" + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": "The" + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": " background command finished on its own (exit code 0). No action" + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": " needed \u2014 as instructed, I left it running and did not kill it." + } + ] +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-fold-handoff.json b/apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-fold-handoff.json new file mode 100644 index 0000000000..d879cfd937 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/acp-fold-handoff.json @@ -0,0 +1,80 @@ +{ + "kind": "fold_handoff", + "frames": [ + { + "dir": "sent", + "method": "session/prompt", + "id": 3 + }, + { + "dir": "received", + "update": "tool_call", + "toolCallId": "toolu_01Y5EzGN7EGhwesAU13HhkYd", + "title": "Terminal", + "status": "pending" + }, + { + "dir": "received", + "update": "tool_call_update", + "toolCallId": "toolu_01Y5EzGN7EGhwesAU13HhkYd", + "title": "sleep 20", + "status": null + }, + { + "dir": "sent", + "method": "session/prompt", + "id": 4 + }, + { + "dir": "received", + "update": "tool_call_update", + "toolCallId": "toolu_01Y5EzGN7EGhwesAU13HhkYd", + "title": null, + "status": null + }, + { + "dir": "received", + "update": "tool_call_update", + "toolCallId": "toolu_01Y5EzGN7EGhwesAU13HhkYd", + "title": null, + "status": "completed" + }, + { + "dir": "received", + "promptResponseFor": 3, + "stopReason": "end_turn", + "usage": { + "inputTokens": 0, + "outputTokens": 0, + "cachedReadTokens": 0, + "cachedWriteTokens": 0 + } + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": "" + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": "ST" + }, + { + "dir": "received", + "update": "agent_message_chunk", + "text": "EERED" + }, + { + "dir": "received", + "promptResponseFor": 4, + "stopReason": "end_turn", + "usage": { + "inputTokens": 13853, + "outputTokens": 118, + "cachedReadTokens": 30399, + "cachedWriteTokens": 15974 + } + } + ] +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/wakeup_concurrency_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/wakeup_concurrency_test.go index 3458e56729..b4b3733a4a 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/wakeup_concurrency_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/wakeup_concurrency_test.go @@ -38,11 +38,18 @@ func (f *concurrencyFakeAgent) Prompt(_ context.Context, _ acp.PromptRequest) (a return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil } +// Initialize advertises `_meta.claudeCode.promptQueueing`, mirroring the real +// claude-agent-acp bridge. Prompt handoff and mid-turn steering are gated on that +// negotiated advertisement rather than the agent's id, so a fake standing in for +// that bridge has to advertise it to be eligible. func (f *concurrencyFakeAgent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) { if f.initialized != nil { f.initialized <- params } - return acp.InitializeResponse{ProtocolVersion: params.ProtocolVersion}, nil + return acp.InitializeResponse{ + ProtocolVersion: params.ProtocolVersion, + AgentCapabilities: promptQueueingCapabilities(true), + }, nil } func (f *concurrencyFakeAgent) NewSession(_ context.Context, _ acp.NewSessionRequest) (acp.NewSessionResponse, error) { diff --git a/apps/backend/internal/agentctl/server/api/agent.go b/apps/backend/internal/agentctl/server/api/agent.go index da81dc0107..840893743c 100644 --- a/apps/backend/internal/agentctl/server/api/agent.go +++ b/apps/backend/internal/agentctl/server/api/agent.go @@ -82,6 +82,12 @@ type PromptRequest struct { Text string `json:"text"` // Simple text prompt Attachments []v1.MessageAttachment `json:"attachments,omitempty"` // Optional image attachments PromptGeneration uint64 `json:"prompt_generation,omitempty"` + // Steer asks for delivery into a turn that is still generating rather than + // waiting for it to end. Honored only when the adapter implements + // SteerablePrompter and the connected agent advertised the capability; + // otherwise it degrades silently to an ordinary prompt, which is the + // specified behavior for an agent that cannot steer. + Steer bool `json:"steer,omitempty"` } // PromptResponse is the response to a prompt call @@ -550,7 +556,7 @@ func (s *Server) handleWSPrompt(ctx context.Context, msg *ws.Message) *ws.Messag // The prompt completes naturally when the agent process exits (stdin/stdout close), // the user cancels, or agentctl shuts down. go func() { - if err := adapter.Prompt(context.Background(), req.Text, req.Attachments, req.PromptGeneration); err != nil { + if err := promptOrSteer(context.Background(), adapter, req); err != nil { if acptransport.IsPromptAbandonedAfterCancel(err) { s.logger.Info("async prompt abandoned after cancel; suppressing stale error event", zap.Error(err)) @@ -754,3 +760,21 @@ func (s *Server) handleWSResetSession(ctx context.Context, msg *ws.Message) *ws. }) return resp } + +// promptOrSteer routes a prompt to the steering path when the caller asked for it +// and the adapter can actually do it. An adapter that does not implement +// SteerablePrompter, or a connected agent that never advertised the capability, +// falls back to the ordinary prompt — the message still reaches the agent, just +// at the next turn boundary, which is exactly today's behavior. +func promptOrSteer( + ctx context.Context, + adpt adapter.AgentAdapter, + req PromptRequest, +) error { + if req.Steer { + if steerable, ok := adpt.(adapter.SteerablePrompter); ok && steerable.SupportsSteering() { + return steerable.PromptSteer(ctx, req.Text, req.Attachments, req.PromptGeneration) + } + } + return adpt.Prompt(ctx, req.Text, req.Attachments, req.PromptGeneration) +} diff --git a/apps/backend/internal/agentctl/server/api/prompt_steer_routing_test.go b/apps/backend/internal/agentctl/server/api/prompt_steer_routing_test.go new file mode 100644 index 0000000000..bdf731dda9 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/prompt_steer_routing_test.go @@ -0,0 +1,119 @@ +package api + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// recordingAdapter embeds the interface so the fake satisfies the whole +// AgentAdapter surface while implementing only the two calls promptOrSteer can +// route to. Any other method would panic on a nil embedded interface, which is +// what we want: this test must fail loudly if routing starts touching more. +type recordingAdapter struct { + adapter.AgentAdapter + + promptCalls []promptRecord + steerCalls []promptRecord +} + +type promptRecord struct { + text string + attachments []v1.MessageAttachment + generation uint64 +} + +func (r *recordingAdapter) Prompt( + _ context.Context, + message string, + attachments []v1.MessageAttachment, + promptGeneration uint64, +) error { + r.promptCalls = append(r.promptCalls, promptRecord{message, attachments, promptGeneration}) + return nil +} + +// steerableAdapter adds the optional SteerablePrompter half. supportsSteering +// mirrors the agent's negotiated capability advertisement. +type steerableAdapter struct { + *recordingAdapter + supportsSteering bool +} + +func (s *steerableAdapter) SupportsSteering() bool { return s.supportsSteering } + +func (s *steerableAdapter) PromptSteer( + _ context.Context, + message string, + attachments []v1.MessageAttachment, + promptGeneration uint64, +) error { + s.steerCalls = append(s.steerCalls, promptRecord{message, attachments, promptGeneration}) + return nil +} + +// TestPromptOrSteerRouting pins the server-side admission gate. The load-bearing +// property is the fallback: an adapter that cannot steer — because it does not +// implement SteerablePrompter at all, or because the connected agent never +// advertised the capability — must still deliver the message as an ordinary +// prompt. Dropping it, or erroring, would make a steer-eligible composer lose +// operator input on an agent install that is merely too old. +func TestPromptOrSteerRouting(t *testing.T) { + attachments := []v1.MessageAttachment{{Type: "image", Name: "shot.png"}} + + tests := []struct { + name string + steerable bool + advertised bool + reqSteer bool + wantSteered bool + }{ + {name: "steer request on a capable adapter steers", steerable: true, advertised: true, reqSteer: true, wantSteered: true}, + {name: "steer request without advertisement falls back to prompt", steerable: true, advertised: false, reqSteer: true}, + {name: "steer request on a non-steerable adapter falls back to prompt", reqSteer: true}, + {name: "ordinary prompt on a capable adapter never steers", steerable: true, advertised: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := &recordingAdapter{} + var adpt adapter.AgentAdapter = rec + if tt.steerable { + adpt = &steerableAdapter{recordingAdapter: rec, supportsSteering: tt.advertised} + } + + req := PromptRequest{ + Text: "read the spec first", + Attachments: attachments, + PromptGeneration: 9, + Steer: tt.reqSteer, + } + if err := promptOrSteer(context.Background(), adpt, req); err != nil { + t.Fatalf("promptOrSteer: %v", err) + } + + steered, prompted := len(rec.steerCalls), len(rec.promptCalls) + if tt.wantSteered && (steered != 1 || prompted != 0) { + t.Fatalf("expected exactly one steer, got steer=%d prompt=%d", steered, prompted) + } + if !tt.wantSteered && (prompted != 1 || steered != 0) { + t.Fatalf("expected exactly one ordinary prompt, got steer=%d prompt=%d", steered, prompted) + } + + // Whichever path ran must carry the request through unchanged: the + // generation especially, since a steer reuses the predecessor's. + got := rec.promptCalls + if tt.wantSteered { + got = rec.steerCalls + } + if got[0].text != req.Text || got[0].generation != req.PromptGeneration { + t.Fatalf("request not carried through: %+v", got[0]) + } + if len(got[0].attachments) != 1 || got[0].attachments[0] != attachments[0] { + t.Fatalf("attachments not carried through: %+v", got[0].attachments) + } + }) + } +} diff --git a/apps/backend/internal/agentctl/types/streams/agent.go b/apps/backend/internal/agentctl/types/streams/agent.go index 2a562f6cb1..2fdf25f4ac 100644 --- a/apps/backend/internal/agentctl/types/streams/agent.go +++ b/apps/backend/internal/agentctl/types/streams/agent.go @@ -273,6 +273,13 @@ type AgentEvent struct { // SupportsEmbeddedContext indicates the agent supports embedded context. SupportsEmbeddedContext bool `json:"supports_embedded_context"` + // SupportsPromptQueueing indicates the agent advertised that it accepts a + // prompt while another prompt for the same session is still in flight. It is + // the negotiated precondition for prompt handoff and mid-turn steering, and + // does not by itself promise the agent will fold that prompt into the running + // turn. See docs/specs/platform/mid-turn-steering.md. + SupportsPromptQueueing bool `json:"supports_prompt_queueing"` + // AuthMethods lists authentication methods from ACP initialize. AuthMethods []AuthMethodInfo `json:"auth_methods,omitempty"` diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index 6856f81643..ad78016f46 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -466,6 +466,32 @@ func (a *lifecycleAdapter) PromptAgentWithDispatchCallback(ctx context.Context, }, nil } +// Compile-time guard for the steer capability. The executor selects the steer +// path by asserting the agent manager to its unexported +// steerAgentWithDispatchCallback interface (internal/orchestrator/executor); that +// assertion fails silently at runtime if this method's signature drifts, +// disabling every production steer. Pin the exact shape here so drift is a build +// error, not a runtime regression. +var _ interface { + SteerAgentWithDispatchCallback(context.Context, string, string, []v1.MessageAttachment, bool, func()) (*executor.PromptResult, error) +} = (*lifecycleAdapter)(nil) + +// SteerAgentWithDispatchCallback forwards a mid-turn steer to the lifecycle +// manager. Without it the executor's steer path type-assertion fails and every +// eligible steer returns ErrPromptDispatchCallbackUnsupported, so the production +// agent-manager client must satisfy the steerAgentWithDispatchCallback capability +// exactly as it does the ordinary dispatch-callback prompt. +func (a *lifecycleAdapter) SteerAgentWithDispatchCallback(ctx context.Context, agentInstanceID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func()) (*executor.PromptResult, error) { + result, err := a.mgr.SteerAgentWithDispatchCallback(ctx, agentInstanceID, prompt, attachments, dispatchOnly, onDispatched) + if err != nil { + return nil, err + } + return &executor.PromptResult{ + StopReason: result.StopReason, + AgentMessage: result.AgentMessage, + }, nil +} + // CancelAgent interrupts the current agent turn without terminating the process. func (a *lifecycleAdapter) CancelAgent(ctx context.Context, sessionID string) error { return a.mgr.CancelAgentBySessionID(ctx, sessionID) @@ -739,6 +765,14 @@ func (w *orchestratorWrapper) ForegroundActivity(sessionID string) v1.Foreground return w.svc.ForegroundActivity(sessionID) } +func (w *orchestratorWrapper) SteerEligible(sessionID string, state models.TaskSessionState) bool { + return w.svc.SteerEligible(sessionID, state) +} + +func (w *orchestratorWrapper) SteerTask(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment) (*orchestrator.PromptResult, error) { + return w.svc.SteerTask(ctx, taskID, sessionID, prompt, model, planMode, attachments) +} + // messageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface type messageCreatorAdapter struct { svc *taskservice.Service diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index 14f3764f08..def49e523f 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -75,6 +75,8 @@ func provideOrchestrator( serviceCfg := orchestrator.DefaultServiceConfig() serviceCfg.ClaudeBackgroundPromptHandoff = cfg != nil && cfg.Features.ClaudeBackgroundPromptHandoff + serviceCfg.ClaudeMidTurnSteering = + cfg != nil && cfg.Features.ClaudeMidTurnSteering namespace := resolveEventNamespace(cfg) serviceCfg.QueueGroup = "orchestrator." + namespace busMode := "memory" diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index d079b2723b..daba595dc0 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -400,6 +400,15 @@ type FeaturesConfig struct { // foreground handoff while background work remains live. It is off in every // embedded profile and must fail closed for every other provider. ClaudeBackgroundPromptHandoff bool `mapstructure:"claude_background_prompt_handoff" json:"claudeBackgroundPromptHandoff"` + + // ClaudeMidTurnSteering gates the high-risk experiment that delivers operator + // input into a turn that is still generating, for an agent that advertised + // prompt queueing. It is independent of ClaudeBackgroundPromptHandoff: that + // flag covers the foreground-idle handoff, this one covers a foreground that + // has not yielded. Off in every embedded profile, and it remains the + // kill-switch after rollout because the agent-side fold is undocumented and + // can regress without notice. + ClaudeMidTurnSteering bool `mapstructure:"claude_mid_turn_steering" json:"claudeMidTurnSteering"` } // LoggingConfig holds logging configuration. diff --git a/apps/backend/internal/orchestrator/background_work_accounting_test.go b/apps/backend/internal/orchestrator/background_work_accounting_test.go index 57ef8b8598..b443066ec4 100644 --- a/apps/backend/internal/orchestrator/background_work_accounting_test.go +++ b/apps/backend/internal/orchestrator/background_work_accounting_test.go @@ -176,6 +176,7 @@ func TestBackgroundCompletion_EnabledClaudeSettledSessionPublishesBackground(t * svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) enableClaudeBackgroundPromptHandoffForTest(t, svc) setSessionAgentNameForTest(t, svc, sessionID, "claude-acp") + advertisePromptQueueingForTest(t, svc, sessionID) recorded := &recordingEventBus{} svc.eventBus = recorded svc.registerBackgroundWorkKind( diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index 71eb09d3b1..f8b129e9e4 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -1153,6 +1153,7 @@ func (s *Service) publishTaskSessionStateChanged( // previously-live busy signal during settlement or teardown. "foreground_activity": foregroundActivity, "active_subagent_count": s.ActiveSubagentCount(sessionID), + "supports_steering": s.SteerEligible(sessionID, nextState), } if stateUpdatedAt != nil && !stateUpdatedAt.IsZero() { eventData[metaKeyUpdatedAt] = stateUpdatedAt.Format(time.RFC3339Nano) @@ -2381,7 +2382,16 @@ func (s *Service) persistSessionMode(ctx context.Context, sessionID, modeID stri // handleAgentCapabilitiesEvent broadcasts agent_capabilities events to the WebSocket. func (s *Service) handleAgentCapabilitiesEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { sessionID := payload.SessionID - if sessionID == "" || s.eventBus == nil { + if sessionID == "" { + return + } + // Record the negotiated prompt-queueing advertisement before the event-bus + // guard. It gates prompt handoff and mid-turn steering, so it must land as + // soon as the agent advertises it — recording is admission state, whereas the + // bus below is only broadcast. Skipping it when no bus is configured would + // silently make a capable agent ineligible. + s.recordSessionPromptQueueing(sessionID, payload.Data.SupportsPromptQueueing) + if s.eventBus == nil { return } eventPayload := lifecycle.AgentCapabilitiesEventPayload{ @@ -2391,6 +2401,7 @@ func (s *Service) handleAgentCapabilitiesEvent(ctx context.Context, payload *lif SupportsImage: payload.Data.SupportsImage, SupportsAudio: payload.Data.SupportsAudio, SupportsEmbeddedContext: payload.Data.SupportsEmbeddedContext, + SupportsPromptQueueing: payload.Data.SupportsPromptQueueing, AuthMethods: payload.Data.AuthMethods, Timestamp: time.Now().UTC().Format(time.RFC3339), } diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go index 85a80c49c5..845547805c 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go @@ -357,6 +357,7 @@ func TestUpdateTaskSessionState_EnabledClaudePublishesSettledBackground(t *testi svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) enableClaudeBackgroundPromptHandoffForTest(t, svc) setSessionAgentNameForTest(t, svc, "session-state-claude", "claude-acp") + advertisePromptQueueingForTest(t, svc, "session-state-claude") svc.eventBus = eb svc.registerBackgroundTask("session-state-claude", "background-1") svc.markForegroundIdle("session-state-claude") diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 6d1e29363d..b63b9e9a86 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -248,6 +248,12 @@ type mockAgentManager struct { // the execution ID so callers can filter by the agent that received it. capturedPrompts []string capturedPromptCalls []promptCall + // Steer tracking. capturedSteerCalls records every SteerAgentWithDispatchCallback + // invocation; steerErr, when set, is returned instead of dispatching. Having + // this method also makes the mock satisfy the executor's optional + // steerAgentWithDispatchCallback capability. + capturedSteerCalls []promptCall + steerErr error // Optional: closed once on the first PromptAgent call so tests can wait // deterministically without polling. Tests opt in by initializing the channel. promptDone chan struct{} @@ -417,6 +423,29 @@ func (m *mockAgentManager) PromptAgentWithDispatchCallback(ctx context.Context, } return result, err } + +func (m *mockAgentManager) SteerAgentWithDispatchCallback(_ context.Context, executionID string, prompt string, _ []v1.MessageAttachment, dispatchOnly bool, onDispatched func()) (*executor.PromptResult, error) { + m.mu.Lock() + m.capturedSteerCalls = append(m.capturedSteerCalls, promptCall{ExecutionID: executionID, Prompt: prompt, DispatchOnly: dispatchOnly}) + steerErr := m.steerErr + m.mu.Unlock() + if steerErr != nil { + return nil, steerErr + } + if onDispatched != nil { + onDispatched() + } + return &executor.PromptResult{StopReason: "dispatched"}, nil +} + +func (m *mockAgentManager) getCapturedSteerCalls() []promptCall { + m.mu.Lock() + defer m.mu.Unlock() + return append([]promptCall(nil), m.capturedSteerCalls...) +} + +// CancelAgent keeps its named sessionID parameter: the body below forwards it +// to cancelAgentFunc, so the topic's `_ string` would not compile here. func (m *mockAgentManager) CancelAgent(ctx context.Context, sessionID string) error { m.cancelAgentCalls.Add(1) if m.cancelAgentEntered != nil { diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go index a85758a58e..bc7ae78c0d 100644 --- a/apps/backend/internal/orchestrator/executor/executor_interaction.go +++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go @@ -260,14 +260,57 @@ func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt // PromptWithDispatchCallback invokes onDispatched after agentctl accepts the // prompt but before waiting for the turn to complete. func (e *Executor) PromptWithDispatchCallback(ctx context.Context, taskID, sessionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func(), preloadedSession ...*models.TaskSession) (*PromptResult, error) { - return e.prompt(ctx, taskID, sessionID, prompt, attachments, dispatchOnly, onDispatched, preloadedSession...) + return e.prompt(ctx, taskID, sessionID, prompt, attachments, dispatchOnly, onDispatched, false, preloadedSession...) +} + +// SteerWithDispatchCallback delivers a steer into a still-generating turn. It +// always uses the dispatch-callback path: steering is a dispatch-and-continue +// action, so the caller keeps admission serialized until agentctl accepts it. +func (e *Executor) SteerWithDispatchCallback(ctx context.Context, taskID, sessionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func(), preloadedSession ...*models.TaskSession) (*PromptResult, error) { + return e.prompt(ctx, taskID, sessionID, prompt, attachments, dispatchOnly, onDispatched, true, preloadedSession...) } type promptAgentWithDispatchCallback interface { PromptAgentWithDispatchCallback(context.Context, string, string, []v1.MessageAttachment, bool, func()) (*PromptResult, error) } -func (e *Executor) prompt(ctx context.Context, taskID, sessionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func(), preloadedSession ...*models.TaskSession) (*PromptResult, error) { +// steerAgentWithDispatchCallback is the optional capability an agent manager +// implements to accept steers. It is a separate interface (like +// promptAgentWithDispatchCallback) so the core agentManager interface and its +// test mocks need no change when steering is unused. +type steerAgentWithDispatchCallback interface { + SteerAgentWithDispatchCallback(context.Context, string, string, []v1.MessageAttachment, bool, func()) (*PromptResult, error) +} + +// dispatchToAgent selects the agent-manager call for a prompt, a dispatch-callback +// prompt, or a steer. Extracted from prompt() to keep that function under the +// cyclomatic limit. +func (e *Executor) dispatchToAgent( + ctx context.Context, + executionID, prompt string, + attachments []v1.MessageAttachment, + dispatchOnly bool, + onDispatched func(), + steer bool, +) (*PromptResult, error) { + if steer { + steerer, ok := e.agentManager.(steerAgentWithDispatchCallback) + if !ok { + return nil, ErrPromptDispatchCallbackUnsupported + } + return steerer.SteerAgentWithDispatchCallback(ctx, executionID, prompt, attachments, dispatchOnly, onDispatched) + } + if onDispatched != nil { + notifier, ok := e.agentManager.(promptAgentWithDispatchCallback) + if !ok { + return nil, ErrPromptDispatchCallbackUnsupported + } + return notifier.PromptAgentWithDispatchCallback(ctx, executionID, prompt, attachments, dispatchOnly, onDispatched) + } + return e.agentManager.PromptAgent(ctx, executionID, prompt, attachments, dispatchOnly) +} + +func (e *Executor) prompt(ctx context.Context, taskID, sessionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func(), steer bool, preloadedSession ...*models.TaskSession) (*PromptResult, error) { var session *models.TaskSession if len(preloadedSession) > 0 && preloadedSession[0] != nil { session = preloadedSession[0] @@ -308,16 +351,7 @@ func (e *Executor) prompt(ctx context.Context, taskID, sessionID string, prompt return result, err } - var result *PromptResult - if onDispatched != nil { - notifier, ok := e.agentManager.(promptAgentWithDispatchCallback) - if !ok { - return nil, ErrPromptDispatchCallbackUnsupported - } - result, err = notifier.PromptAgentWithDispatchCallback(ctx, executionID, prompt, attachments, dispatchOnly, onDispatched) - } else { - result, err = e.agentManager.PromptAgent(ctx, executionID, prompt, attachments, dispatchOnly) - } + result, err := e.dispatchToAgent(ctx, executionID, prompt, attachments, dispatchOnly, onDispatched, steer) if err != nil { if errors.Is(err, lifecycle.ErrExecutionNotFound) { return nil, ErrExecutionNotFound diff --git a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go index a0e3030a46..8017a96f7e 100644 --- a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go +++ b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go @@ -16,6 +16,16 @@ func enableClaudeBackgroundPromptHandoffForTest(t *testing.T, svc *Service) { svc.config.ClaudeBackgroundPromptHandoff = true } +// advertisePromptQueueingForTest records the negotiated prompt-queueing +// advertisement for sessionID. This is what grants handoff eligibility now that +// the gate is the agent's advertisement rather than its persisted name — see +// ADR 0049's rejection of a central agent-name whitelist. A session with no +// recorded advertisement is ineligible, which is also the post-restart state. +func advertisePromptQueueingForTest(t *testing.T, svc *Service, sessionID string) { + t.Helper() + svc.recordSessionPromptQueueing(sessionID, true) +} + func setSessionAgentNameForTest( t *testing.T, svc *Service, @@ -87,6 +97,7 @@ func TestCheckSessionPromptable_ClaudeExperimentAcceptsBackgroundIdle(t *testing const sessionID = "session-claude-experiment" seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) setSessionAgentNameForTest(t, svc, sessionID, "claude-acp") + advertisePromptQueueingForTest(t, svc, sessionID) svc.registerBackgroundTask(sessionID, "tool-subagent-1") svc.markForegroundIdle(sessionID) @@ -788,3 +799,73 @@ func TestForegroundBusySignal_UpdateDoesNotReYieldAfterForeground(t *testing.T) t.Fatalf("a later background progress update must not re-open the gate after foreground resumed, got: %v", err) } } + +// TestClaudeHandoffGateIsNegotiatedNotNamed pins ADR 0049's rejected +// alternative: a session whose persisted agent_name still says "claude-acp" is +// ineligible unless the connected agent actually advertised prompt queueing. +// This is the version-accuracy case — a bridge too old to advertise must not be +// trusted on identity alone. +func TestClaudeHandoffGateIsNegotiatedNotNamed(t *testing.T) { + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + enableClaudeBackgroundPromptHandoffForTest(t, svc) + + const taskID = "task-negotiated" + const sessionID = "session-negotiated" + seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) + setSessionAgentNameForTest(t, svc, sessionID, "claude-acp") + + svc.registerBackgroundTask(sessionID, "tool-subagent-1") + svc.markForegroundIdle(sessionID) + + // Name matches, advertisement absent: fail closed. + if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating { + t.Fatalf("claude-named session without the advertisement = %q, want generating", got) + } + if err := svc.checkSessionPromptable( + taskID, + sessionID, + models.TaskSessionStateRunning, + ); !errors.Is(err, ErrAgentPromptInProgress) { + t.Fatalf("claude-named session without the advertisement must stay gated, got: %v", err) + } + + // Same session, now advertising: eligible. + advertisePromptQueueingForTest(t, svc, sessionID) + if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground { + t.Fatalf("advertised session activity = %q, want background", got) + } + if err := svc.checkSessionPromptable( + taskID, + sessionID, + models.TaskSessionStateRunning, + ); err != nil { + t.Fatalf("advertised background-idle session rejected: %v", err) + } +} + +// TestAgentCapabilitiesEventRecordsPromptQueueing proves the advertisement +// reaches the orchestrator over the existing agent_capabilities stream event +// rather than needing a new channel. +func TestAgentCapabilitiesEventRecordsPromptQueueing(t *testing.T) { + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + + const sessionID = "session-caps" + if svc.sessionAdvertisesPromptQueueing(sessionID) { + t.Fatal("session advertised prompt queueing before any capabilities frame") + } + + svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{ + TaskID: "task-caps", + SessionID: sessionID, + Data: &lifecycle.AgentStreamEventData{ + Type: "agent_capabilities", + SupportsPromptQueueing: true, + }, + }) + + if !svc.sessionAdvertisesPromptQueueing(sessionID) { + t.Fatal("agent_capabilities event did not record the prompt-queueing advertisement") + } +} diff --git a/apps/backend/internal/orchestrator/messagequeue/service.go b/apps/backend/internal/orchestrator/messagequeue/service.go index 7b2cf83863..567754c3af 100644 --- a/apps/backend/internal/orchestrator/messagequeue/service.go +++ b/apps/backend/internal/orchestrator/messagequeue/service.go @@ -13,6 +13,7 @@ import ( "context" "errors" "fmt" + "sync" "sync/atomic" "github.com/kandev/kandev/internal/common/logger" @@ -24,6 +25,20 @@ type Service struct { repo Repository maxPerSession atomic.Int64 logger *logger.Logger + admissionMu sync.Mutex + admissions map[string]*sessionAdmission +} + +type sessionAdmission struct { + mu sync.Mutex + refs int +} + +type sessionAdmissionContextKey struct{} + +type sessionAdmissionToken struct { + service *Service + sessionID string } // NewService creates a Service backed by the supplied repository. maxPerSession @@ -31,8 +46,9 @@ type Service struct { // pass 0 to disable the cap. func NewService(repo Repository, maxPerSession int, log *logger.Logger) *Service { service := &Service{ - repo: repo, - logger: log.WithFields(zap.String("component", "message-queue")), + repo: repo, + logger: log.WithFields(zap.String("component", "message-queue")), + admissions: make(map[string]*sessionAdmission), } service.SetMaxPerSession(maxPerSession) return service @@ -56,6 +72,45 @@ func (s *Service) SetMaxPerSession(maxPerSession int) { s.maxPerSession.Store(int64(maxPerSession)) } +// WithSessionAdmission runs fn under the per-session queue admission lock. +// All queue insertion paths use the same lock. The callback must complete +// synchronously; queue methods called with its context reuse the held lock. +func (s *Service) WithSessionAdmission(ctx context.Context, sessionID string, fn func(context.Context) error) error { + if fn == nil { + return errors.New("session admission callback is nil") + } + if token, ok := ctx.Value(sessionAdmissionContextKey{}).(sessionAdmissionToken); ok && + token.service == s && token.sessionID == sessionID { + return fn(ctx) + } + + s.admissionMu.Lock() + entry := s.admissions[sessionID] + if entry == nil { + entry = &sessionAdmission{} + s.admissions[sessionID] = entry + } + entry.refs++ + s.admissionMu.Unlock() + + entry.mu.Lock() + defer func() { + entry.mu.Unlock() + s.admissionMu.Lock() + entry.refs-- + if entry.refs == 0 && s.admissions[sessionID] == entry { + delete(s.admissions, sessionID) + } + s.admissionMu.Unlock() + }() + + admittedCtx := context.WithValue(ctx, sessionAdmissionContextKey{}, sessionAdmissionToken{ + service: s, + sessionID: sessionID, + }) + return fn(admittedCtx) +} + // QueueMessage appends a new entry to the session's FIFO queue. Returns // ErrQueueFull when the cap is exceeded. func (s *Service) QueueMessage(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []MessageAttachment) (*QueuedMessage, error) { @@ -73,6 +128,18 @@ func (s *Service) QueueMessageWithMetadata(ctx context.Context, sessionID, taskI } func (s *Service) queueMessageWithMetadata(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []MessageAttachment, metadata map[string]interface{}, maxPerSession int) (*QueuedMessage, error) { + var queued *QueuedMessage + err := s.WithSessionAdmission(ctx, sessionID, func(admittedCtx context.Context) error { + var err error + queued, err = s.insertQueueMessageWithMetadata( + admittedCtx, sessionID, taskID, content, model, userID, planMode, attachments, metadata, maxPerSession, + ) + return err + }) + return queued, err +} + +func (s *Service) insertQueueMessageWithMetadata(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []MessageAttachment, metadata map[string]interface{}, maxPerSession int) (*QueuedMessage, error) { metadataCopy := copyMessageMetadata(metadata, 0) msg := &QueuedMessage{ SessionID: sessionID, @@ -108,6 +175,16 @@ func (s *Service) RestoreMessage(ctx context.Context, msg *QueuedMessage) (*Queu if msg == nil { return nil, errors.New("queued message is nil") } + var restored *QueuedMessage + err := s.WithSessionAdmission(ctx, msg.SessionID, func(admittedCtx context.Context) error { + var err error + restored, err = s.restoreMessage(admittedCtx, msg) + return err + }) + return restored, err +} + +func (s *Service) restoreMessage(ctx context.Context, msg *QueuedMessage) (*QueuedMessage, error) { restored := *msg restored.Metadata = copyMessageMetadata(msg.Metadata, 0) if err := s.repo.Restore(ctx, &restored, 0); err != nil { @@ -133,6 +210,20 @@ func (s *Service) QueueMessageWithCoalesceKey(ctx context.Context, sessionID, ta } func (s *Service) queueMessageWithCoalesceKey(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []MessageAttachment, metadata map[string]interface{}, coalesceKey string, allowInsert bool, maxPerSession int) (*QueuedMessage, bool, error) { + var queued *QueuedMessage + var replaced bool + err := s.WithSessionAdmission(ctx, sessionID, func(admittedCtx context.Context) error { + var err error + queued, replaced, err = s.insertQueueMessageWithCoalesceKey( + admittedCtx, sessionID, taskID, content, model, userID, planMode, attachments, metadata, + coalesceKey, allowInsert, maxPerSession, + ) + return err + }) + return queued, replaced, err +} + +func (s *Service) insertQueueMessageWithCoalesceKey(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []MessageAttachment, metadata map[string]interface{}, coalesceKey string, allowInsert bool, maxPerSession int) (*QueuedMessage, bool, error) { metadataCopy := copyMessageMetadata(metadata, 1) metadataCopy[MetadataCoalesceKey] = coalesceKey msg := &QueuedMessage{ @@ -199,6 +290,20 @@ func (s *Service) RequeueLifecycleMessageWithCoalesceKey(ctx context.Context, se } func (s *Service) queueLifecycleMessageWithCoalesceKey(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []MessageAttachment, metadata map[string]interface{}, coalesceKey string, allowInsert, isRetry bool) (*QueuedMessage, bool, bool, error) { + var queued *QueuedMessage + var replaced, accepted bool + err := s.WithSessionAdmission(ctx, sessionID, func(admittedCtx context.Context) error { + var err error + queued, replaced, accepted, err = s.insertLifecycleMessageWithCoalesceKey( + admittedCtx, sessionID, taskID, content, model, userID, planMode, attachments, + metadata, coalesceKey, allowInsert, isRetry, + ) + return err + }) + return queued, replaced, accepted, err +} + +func (s *Service) insertLifecycleMessageWithCoalesceKey(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []MessageAttachment, metadata map[string]interface{}, coalesceKey string, allowInsert, isRetry bool) (*QueuedMessage, bool, bool, error) { metadataCopy := clearReservedMetadata(metadata) generation, err := s.repo.LifecycleGeneration(ctx, taskID) if err != nil { @@ -322,8 +427,13 @@ func copyMessageMetadata(metadata map[string]interface{}, extraCapacity int) map // queued_by matches userID. Otherwise inserts a new entry. Returns // ErrQueueFull when an insert would exceed the cap. func (s *Service) AppendContent(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []MessageAttachment) (*QueuedMessage, bool, error) { - maxPerSession := s.MaxPerSession() - msg, appended, err := s.repo.AppendOrInsertTail(ctx, sessionID, taskID, content, model, userID, planMode, attachments, nil, maxPerSession) + var msg *QueuedMessage + var appended bool + err := s.WithSessionAdmission(ctx, sessionID, func(admittedCtx context.Context) error { + var err error + msg, appended, err = s.repo.AppendOrInsertTail(admittedCtx, sessionID, taskID, content, model, userID, planMode, attachments, nil, s.MaxPerSession()) + return err + }) if err != nil { return nil, false, err } diff --git a/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go b/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go index 094a5b5b41..c3ea7a3e92 100644 --- a/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go +++ b/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go @@ -167,6 +167,7 @@ func TestPromptTask_ClaudeExperimentAdmitsRecognizedBackgroundWork(t *testing.T) ) seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) setSessionAgentNameForTest(t, svc, sessionID, "claude-acp") + advertisePromptQueueingForTest(t, svc, sessionID) session, err := repo.GetTaskSession(context.Background(), sessionID) if err != nil { t.Fatalf("load session: %v", err) diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index f56bc08ca7..2657f0f821 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -59,6 +59,11 @@ type ServiceConfig struct { QueueSize int QueueGroup string ClaudeBackgroundPromptHandoff bool + + // ClaudeMidTurnSteering enables delivering a prompt into a still-generating + // turn for an agent that advertised prompt queueing. Independent of + // ClaudeBackgroundPromptHandoff, which covers the foreground-idle handoff. + ClaudeMidTurnSteering bool } // AttachmentReader is the narrow attachment-store seam needed when the @@ -582,6 +587,11 @@ type Service struct { // completed-execution stream markers. executionTeardownClaims sync.Map + // steerInFlight tracks sessions with an unacknowledged mid-turn steer. + // The spec allows at most one in-flight steer per session; a second attempt + // while one is outstanding queues instead. Keyed by sessionID, cleared when + // the steer dispatch is accepted or fails. + steerInFlight sync.Map // Session reset flags: sessionID -> true while resetAgentContext is restarting process. // Used to suppress stale ready events and avoid draining queued prompts mid-reset. resetInProgressSessions sync.Map diff --git a/apps/backend/internal/orchestrator/session_scope_matrix_test.go b/apps/backend/internal/orchestrator/session_scope_matrix_test.go index afb1474465..0c7f2de1a0 100644 --- a/apps/backend/internal/orchestrator/session_scope_matrix_test.go +++ b/apps/backend/internal/orchestrator/session_scope_matrix_test.go @@ -89,6 +89,10 @@ func sessionScopeCases() []deniedCase { _, err := s.EnsureSession(context.Background(), taskID) return err }}, + {"SteerTask", func(s *Service) error { + _, err := s.SteerTask(context.Background(), taskID, sessionID, "steer", "", false, nil) + return err + }}, } } diff --git a/apps/backend/internal/orchestrator/steer.go b/apps/backend/internal/orchestrator/steer.go new file mode 100644 index 0000000000..d57c78dda1 --- /dev/null +++ b/apps/backend/internal/orchestrator/steer.go @@ -0,0 +1,172 @@ +package orchestrator + +import ( + "context" + "errors" + "fmt" + + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "go.uber.org/zap" +) + +// ErrSteerNotEligible means the session cannot be steered right now: the +// experiment is off, the connected agent did not advertise prompt queueing, or +// the session is not in a generating RUNNING state. The caller falls back to its +// ordinary prompt path (the session may since have become promptable). +var ErrSteerNotEligible = errors.New("session is not eligible for mid-turn steering") + +// steerQueuedStopReason marks a PromptResult whose steer was enqueued behind +// pending work rather than dispatched immediately. Callers treat it as success. +const steerQueuedStopReason = "steer_queued" + +// SteerEligible reports whether a prompt to sessionID right now would be +// delivered as a mid-turn steer rather than queued. It is a pure read used by +// the composer contract and by the message handler before it decides to steer. +// +// Eligibility requires all of: the runtime experiment enabled, the connected +// agent's negotiated prompt-queueing advertisement, and a RUNNING session whose +// foreground is generating (a background-idle session already accepts prompts +// through the handoff path and does not need steering). +func (s *Service) SteerEligible(sessionID string, state models.TaskSessionState) bool { + if !s.config.ClaudeMidTurnSteering { + return false + } + if state != models.TaskSessionStateRunning { + return false + } + if !s.sessionAdvertisesPromptQueueing(sessionID) { + return false + } + // A background-idle session is already promptable via the handoff path; + // steering is specifically for a foreground that is still generating. + // + // Use the raw foregroundActivityValue, not the public ForegroundActivity: + // the latter collapses to Generating whenever ClaudeBackgroundPromptHandoff + // is off, and that flag is independent of ClaudeMidTurnSteering. Reading it + // here would make a background-idle session steer-eligible with only + // mid-turn steering enabled, which is exactly the case steering must exclude. + return s.foregroundActivityValue(sessionID) == v1.ForegroundActivityGenerating +} + +// steerEligibleForSession resolves the session state and reports steer +// eligibility for the WS activity payload. Fails closed to false on any lookup +// error, matching the serialization-boundary derivation used by the DTOs. +func (s *Service) steerEligibleForSession(ctx context.Context, sessionID string) bool { + if s.repo == nil { + return false + } + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil || session == nil { + return false + } + return s.SteerEligible(sessionID, session.State) +} + +// SteerTask delivers a prompt into a still-generating turn. It enforces the two +// ordering invariants from the spec — steer only with an empty queue, and at +// most one in-flight steer per session — then dispatches without taking the +// foreground claim, which the predecessor turn still owns. +// +// When either invariant would be violated, the prompt is enqueued (joining the +// tail so submission order is preserved) and drained on the next turn boundary +// rather than dispatched now; the caller sees success. ErrSteerNotEligible is +// returned only when the session is no longer steer-eligible at all (e.g. its +// turn ended between the caller's check and here), so the caller can fall back +// to an ordinary prompt. Any other error is a genuine dispatch failure. +func (s *Service) SteerTask( + ctx context.Context, + taskID, sessionID, prompt, model string, + planMode bool, + attachments []v1.MessageAttachment, +) (*PromptResult, error) { + if err := s.authorizeTaskSessionPair(ctx, taskID, sessionID); err != nil { + return nil, err + } + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil || session == nil { + return nil, ErrSteerNotEligible + } + if !s.SteerEligible(sessionID, session.State) { + return nil, ErrSteerNotEligible + } + + var ( + admittedForDispatch bool + queueNonEmpty bool + steerOutstanding bool + admittedResult *PromptResult + ) + if err := s.messageQueue.WithSessionAdmission(ctx, sessionID, func(admittedCtx context.Context) error { + // Order rule: never jump ahead of an already-queued message, and never run + // two steers at once. Either way, enqueue behind whatever is pending so the + // message is delivered in order on the next turn boundary. + if status := s.messageQueue.GetStatus(admittedCtx, sessionID); status != nil && status.Count > 0 { + queueNonEmpty = true + } + _, steerOutstanding = s.steerInFlight.LoadOrStore(sessionID, struct{}{}) + if queueNonEmpty || steerOutstanding { + // LoadOrStore may have installed our marker when the queue was the reason + // we decline; only delete it if no genuine prior steer owns it. + if !steerOutstanding { + s.steerInFlight.Delete(sessionID) + } + _, qErr := s.messageQueue.QueueMessageWithMetadata( + admittedCtx, sessionID, taskID, prompt, model, messagequeue.QueuedByUser, + planMode, toQueuedAttachments(attachments), nil, + ) + if qErr != nil { + return fmt.Errorf("queue steer behind pending work: %w", qErr) + } + // SteerTask writes through the queue service directly, so it must + // publish the same authoritative snapshot as the WS queue handlers. + // Without this event, clients keep rendering the queue state from + // before the steer was accepted. + s.publishQueueStatusEvent(admittedCtx, sessionID) + admittedResult = &PromptResult{StopReason: steerQueuedStopReason} + return nil + } + admittedForDispatch = true + return nil + }); err != nil { + return nil, err + } + if !admittedForDispatch { + s.logger.Info("steer enqueued behind pending work", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Bool("queue_non_empty", queueNonEmpty), + zap.Bool("steer_outstanding", steerOutstanding)) + return admittedResult, nil + } + // We now own the single in-flight slot. The queue admission lock is released + // before the blocking dispatch; clear the slot when the dispatch completes. + defer s.steerInFlight.Delete(sessionID) + + effectivePrompt := s.effectivePromptForSession(sessionID, prompt, planMode, session) + s.logger.Info("dispatching mid-turn steer", + zap.String("task_id", taskID), + zap.String("session_id", sessionID)) + + // context.WithoutCancel: a WS request timeout must not abort the steer, same + // as the ordinary prompt path. + promptCtx := context.WithoutCancel(ctx) + // dispatchOnly=true: a steer is dispatch-and-continue. The predecessor turn + // owns foreground completion, so this call must not wait for a turn to end, + // and must not take the foreground claim. + result, err := s.executor.SteerWithDispatchCallback( + promptCtx, taskID, sessionID, effectivePrompt, attachments, true, func() {}, session, + ) + if err != nil { + s.logger.Warn("mid-turn steer dispatch failed", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + return nil, err + } + return &PromptResult{ + StopReason: result.StopReason, + AgentMessage: result.AgentMessage, + }, nil +} diff --git a/apps/backend/internal/orchestrator/steer_test.go b/apps/backend/internal/orchestrator/steer_test.go new file mode 100644 index 0000000000..5e007d65d1 --- /dev/null +++ b/apps/backend/internal/orchestrator/steer_test.go @@ -0,0 +1,379 @@ +package orchestrator + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type blockingQueueRepository struct { + messagequeue.Repository + insertStarted chan struct{} + releaseInsert chan struct{} + insertOnce sync.Once +} + +func (r *blockingQueueRepository) Insert(ctx context.Context, msg *messagequeue.QueuedMessage, maxPerSession int) error { + r.insertOnce.Do(func() { + close(r.insertStarted) + <-r.releaseInsert + }) + return r.Repository.Insert(ctx, msg, maxPerSession) +} + +// TestSteerEligible_GatedByFlagCapabilityAndState covers every condition the +// steer gate depends on. The matrix is the observable contract behind the +// composer's steer-vs-queue affordance. +func TestSteerEligible_GatedByFlagCapabilityAndState(t *testing.T) { + const sessionID = "session-steer-eligible" + + setGenerating := func(svc *Service) { + // A claimed foreground turn with no background work reads as generating. + svc.registerBackgroundTask(sessionID, "bg-1") + svc.markForegroundIdle(sessionID) + svc.completeBackgroundTask(sessionID, "bg-1") + } + + t.Run("flag off is never eligible", func(t *testing.T) { + svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) + advertisePromptQueueingForTest(t, svc, sessionID) + if svc.SteerEligible(sessionID, models.TaskSessionStateRunning) { + t.Fatal("steer eligible with the flag off") + } + }) + + t.Run("no advertisement is never eligible", func(t *testing.T) { + svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) + svc.config.ClaudeMidTurnSteering = true + if svc.SteerEligible(sessionID, models.TaskSessionStateRunning) { + t.Fatal("steer eligible without the negotiated advertisement") + } + }) + + t.Run("non-running state is never eligible", func(t *testing.T) { + svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) + svc.config.ClaudeMidTurnSteering = true + advertisePromptQueueingForTest(t, svc, sessionID) + for _, state := range []models.TaskSessionState{ + models.TaskSessionStateWaitingForInput, + models.TaskSessionStateCompleted, + models.TaskSessionStateIdle, + models.TaskSessionStateCreated, + } { + if svc.SteerEligible(sessionID, state) { + t.Fatalf("steer eligible in %s state", state) + } + } + }) + + t.Run("background-idle is not steered (handoff path owns it)", func(t *testing.T) { + svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) + svc.config.ClaudeMidTurnSteering = true + svc.config.ClaudeBackgroundPromptHandoff = true + advertisePromptQueueingForTest(t, svc, sessionID) + svc.registerBackgroundTask(sessionID, "bg-1") + svc.markForegroundIdle(sessionID) + if svc.ForegroundActivity(sessionID) != v1.ForegroundActivityBackground { + t.Fatal("precondition: expected background activity") + } + if svc.SteerEligible(sessionID, models.TaskSessionStateRunning) { + t.Fatal("background-idle session should be admitted via handoff, not steer") + } + }) + + t.Run("background-idle is not steered even with handoff off (flags independent)", func(t *testing.T) { + // Regression: SteerEligible must read the raw foreground activity, not the + // public ForegroundActivity that collapses to Generating whenever + // ClaudeBackgroundPromptHandoff is off. With steering on but handoff off, a + // background-idle session would otherwise look generating and be wrongly + // steer-eligible — the two flags are independent. + svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) + svc.config.ClaudeMidTurnSteering = true + svc.config.ClaudeBackgroundPromptHandoff = false + advertisePromptQueueingForTest(t, svc, sessionID) + svc.registerBackgroundTask(sessionID, "bg-1") + svc.markForegroundIdle(sessionID) + if svc.foregroundActivityValue(sessionID) != v1.ForegroundActivityBackground { + t.Fatal("precondition: raw activity should be background") + } + if svc.SteerEligible(sessionID, models.TaskSessionStateRunning) { + t.Fatal("background-idle session is steer-eligible with handoff off") + } + }) + + t.Run("generating running with capability and flag is eligible", func(t *testing.T) { + svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) + svc.config.ClaudeMidTurnSteering = true + advertisePromptQueueingForTest(t, svc, sessionID) + setGenerating(svc) + if !svc.SteerEligible(sessionID, models.TaskSessionStateRunning) { + t.Fatal("generating, advertised, flag-on session should be steer-eligible") + } + }) +} + +// TestSteerTask_OrderRuleQueuesBehindPending pins that a steer never jumps ahead +// of an already-queued message: it enqueues behind the pending one (preserving +// order) rather than dispatching a steer or erroring. +func TestSteerTask_OrderRuleQueuesBehindPending(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.config.ClaudeMidTurnSteering = true + eventBus := &recordingEventBus{} + svc.eventBus = eventBus + + const taskID = "task-order" + const sessionID = "session-order" + seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) + advertisePromptQueueingForTest(t, svc, sessionID) + svc.registerBackgroundTask(sessionID, "bg-1") + svc.markForegroundIdle(sessionID) + svc.completeBackgroundTask(sessionID, "bg-1") + + if _, err := svc.messageQueue.QueueMessage(ctx, sessionID, taskID, "queued first", "", "", false, nil); err != nil { + t.Fatalf("seed queued message: %v", err) + } + + result, err := svc.SteerTask(ctx, taskID, sessionID, "steer second", "", false, nil) + if err != nil { + t.Fatalf("SteerTask with a queued message errored: %v", err) + } + if result == nil || result.StopReason != steerQueuedStopReason { + t.Fatalf("SteerTask result = %+v, want a queued steer (%q)", result, steerQueuedStopReason) + } + // The steer must have joined the queue behind the pending message, in order. + status := svc.messageQueue.GetStatus(ctx, sessionID) + if status == nil || status.Count != 2 { + t.Fatalf("queue count = %v, want 2 (pending + enqueued steer)", status) + } + if status.Entries[0].Content != "queued first" || status.Entries[1].Content != "steer second" { + t.Fatalf("queue order = [%q, %q], want [queued first, steer second]", + status.Entries[0].Content, status.Entries[1].Content) + } + if len(eventBus.events) != 1 { + t.Fatalf("queue status event count = %d, want 1", len(eventBus.events)) + } + if eventBus.events[0].subject != events.MessageQueueStatusChanged { + t.Fatalf("queue status event subject = %q, want %q", eventBus.events[0].subject, events.MessageQueueStatusChanged) + } + eventData, ok := eventBus.events[0].event.Data.(map[string]interface{}) + if !ok { + t.Fatalf("queue status event data = %T, want map[string]interface{}", eventBus.events[0].event.Data) + } + if eventData["count"] != 2 { + t.Fatalf("queue status event count = %v, want 2", eventData["count"]) + } + if _, inFlight := svc.steerInFlight.Load(sessionID); inFlight { + t.Fatal("declined steer left an in-flight slot claimed") + } +} + +// TestSteerTask_DoesNotOvertakeQueueWriter pins the cross-entry-point ordering +// boundary: an ordinary queue writer that starts first must complete before a +// steer can decide that the queue is empty and dispatch. +func TestSteerTask_DoesNotOvertakeQueueWriter(t *testing.T) { + ctx := context.Background() + const taskID = "task-order-race" + const sessionID = "session-order-race" + + svc, agentMgr := steerDispatchService(t, taskID, sessionID) + queueRepo := &blockingQueueRepository{ + Repository: messagequeue.NewMemoryRepository(), + insertStarted: make(chan struct{}), + releaseInsert: make(chan struct{}), + } + svc.messageQueue = messagequeue.NewService(queueRepo, 10, testLogger()) + + ordinaryDone := make(chan error, 1) + go func() { + _, err := svc.messageQueue.QueueMessage(ctx, sessionID, taskID, "ordinary first", "", "user", false, nil) + ordinaryDone <- err + }() + <-queueRepo.insertStarted + + steerDone := make(chan struct { + result *PromptResult + err error + }, 1) + go func() { + result, err := svc.SteerTask(ctx, taskID, sessionID, "steer second", "", false, nil) + steerDone <- struct { + result *PromptResult + err error + }{result: result, err: err} + }() + + select { + case result := <-steerDone: + close(queueRepo.releaseInsert) + t.Fatalf("steer completed before the earlier queue write: result=%+v err=%v", result.result, result.err) + case <-time.After(100 * time.Millisecond): + } + + close(queueRepo.releaseInsert) + if err := <-ordinaryDone; err != nil { + t.Fatalf("ordinary queue write: %v", err) + } + result := <-steerDone + if result.err != nil { + t.Fatalf("steer: %v", result.err) + } + if result.result == nil || result.result.StopReason != steerQueuedStopReason { + t.Fatalf("steer result = %+v, want queued behind ordinary message", result.result) + } + if got := len(agentMgr.getCapturedSteerCalls()); got != 0 { + t.Fatalf("steer dispatch count = %d, want 0", got) + } + status := svc.messageQueue.GetStatus(ctx, sessionID) + if status == nil || status.Count != 2 || status.Entries[0].Content != "ordinary first" || status.Entries[1].Content != "steer second" { + t.Fatalf("queue after race = %+v, want ordinary first then steer second", status) + } +} + +// steerDispatchService builds a scheduler-backed service (real executor over a +// fake agent manager) with a generating, steer-eligible session so SteerTask +// reaches actual dispatch. +func steerDispatchService(t *testing.T, taskID, sessionID string) (*Service, *mockAgentManager) { + t.Helper() + repo := setupTestRepo(t) + agentMgr := &mockAgentManager{} + agentMgr.getExecutionIDForSessionFunc = func(context.Context, string) (string, error) { + return "exec-steer", nil + } + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.config.ClaudeMidTurnSteering = true + seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) + advertisePromptQueueingForTest(t, svc, sessionID) + svc.registerBackgroundTask(sessionID, "bg-1") + svc.markForegroundIdle(sessionID) + svc.completeBackgroundTask(sessionID, "bg-1") // generating + return svc, agentMgr +} + +// TestSteerTask_DispatchesAndReleasesInFlightSlot drives SteerTask all the way to +// dispatch (the branch no other test reaches) and pins the contract that keeps +// the feature working turn-after-turn: the steer dispatches with dispatchOnly, +// carrying the operator's prompt, and — critically — releases the single +// in-flight slot afterwards so the next steer dispatches rather than being +// permanently declined-and-enqueued. Deleting `defer s.steerInFlight.Delete` in +// SteerTask must fail this test. +func TestSteerTask_DispatchesAndReleasesInFlightSlot(t *testing.T) { + ctx := context.Background() + const taskID = "task-dispatch" + const sessionID = "session-dispatch" + + t.Run("dispatches then releases the slot for the next steer", func(t *testing.T) { + svc, agentMgr := steerDispatchService(t, taskID, sessionID) + + res, err := svc.SteerTask(ctx, taskID, sessionID, "steer one", "", false, nil) + if err != nil { + t.Fatalf("first steer dispatch errored: %v", err) + } + if res == nil || res.StopReason == steerQueuedStopReason { + t.Fatalf("first steer result = %+v, want a real dispatch (not enqueued)", res) + } + calls := agentMgr.getCapturedSteerCalls() + if len(calls) != 1 { + t.Fatalf("steer dispatch count = %d, want 1", len(calls)) + } + if calls[0].ExecutionID != "exec-steer" { + t.Fatalf("steer execution = %q, want exec-steer", calls[0].ExecutionID) + } + if !calls[0].DispatchOnly { + t.Fatal("steer must dispatch with dispatchOnly=true (dispatch-and-continue)") + } + if !strings.Contains(calls[0].Prompt, "steer one") { + t.Fatalf("steer prompt %q does not carry the operator's message", calls[0].Prompt) + } + if _, inFlight := svc.steerInFlight.Load(sessionID); inFlight { + t.Fatal("in-flight slot was not released after dispatch") + } + + // A second steer must dispatch too — proving the slot really released. + res2, err := svc.SteerTask(ctx, taskID, sessionID, "steer two", "", false, nil) + if err != nil { + t.Fatalf("second steer errored: %v", err) + } + if res2 == nil || res2.StopReason == steerQueuedStopReason { + t.Fatalf("second steer result = %+v, want dispatch — slot not released", res2) + } + if got := len(agentMgr.getCapturedSteerCalls()); got != 2 { + t.Fatalf("steer dispatch count = %d, want 2", got) + } + }) + + t.Run("releases the slot when dispatch fails", func(t *testing.T) { + svc, agentMgr := steerDispatchService(t, taskID, sessionID) + agentMgr.steerErr = errors.New("agent dispatch boom") + + if _, err := svc.SteerTask(ctx, taskID, sessionID, "steer", "", false, nil); err == nil { + t.Fatal("expected the dispatch error to propagate") + } + if _, inFlight := svc.steerInFlight.Load(sessionID); inFlight { + t.Fatal("in-flight slot was not released after a dispatch error") + } + }) +} + +// TestSteerTask_NotEligibleReturnsSentinel pins that an ineligible session yields +// the fall-back sentinel rather than dispatching. +func TestSteerTask_NotEligibleReturnsSentinel(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + // Flag deliberately off. + const taskID = "task-noteligible" + const sessionID = "session-noteligible" + seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) + advertisePromptQueueingForTest(t, svc, sessionID) + + _, err := svc.SteerTask(ctx, taskID, sessionID, "steer", "", false, nil) + if !errors.Is(err, ErrSteerNotEligible) { + t.Fatalf("SteerTask with flag off = %v, want ErrSteerNotEligible", err) + } +} + +// TestSteerTask_SingleInFlight pins the one-steer-per-session rule: while a steer +// holds the in-flight slot, a second attempt is enqueued (not dispatched) and +// does not disturb the prior steer's in-flight claim. +func TestSteerTask_SingleInFlight(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.config.ClaudeMidTurnSteering = true + + const taskID = "task-inflight" + const sessionID = "session-inflight" + seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) + advertisePromptQueueingForTest(t, svc, sessionID) + svc.registerBackgroundTask(sessionID, "bg-1") + svc.markForegroundIdle(sessionID) + svc.completeBackgroundTask(sessionID, "bg-1") + + // Occupy the in-flight slot as a prior steer dispatch would. + svc.steerInFlight.Store(sessionID, struct{}{}) + + result, err := svc.SteerTask(ctx, taskID, sessionID, "steer", "", false, nil) + if err != nil { + t.Fatalf("second concurrent steer errored: %v", err) + } + if result == nil || result.StopReason != steerQueuedStopReason { + t.Fatalf("second steer result = %+v, want a queued steer (%q)", result, steerQueuedStopReason) + } + // The prior steer's in-flight claim must survive the decline. + if _, inFlight := svc.steerInFlight.Load(sessionID); !inFlight { + t.Fatal("declining a second steer cleared the prior steer's in-flight slot") + } + if status := svc.messageQueue.GetStatus(ctx, sessionID); status == nil || status.Count != 1 { + t.Fatalf("queue count = %v, want 1 (enqueued second steer)", status) + } +} diff --git a/apps/backend/internal/orchestrator/turn_activity.go b/apps/backend/internal/orchestrator/turn_activity.go index 9bedaef24c..380325bba5 100644 --- a/apps/backend/internal/orchestrator/turn_activity.go +++ b/apps/backend/internal/orchestrator/turn_activity.go @@ -70,6 +70,14 @@ type turnActivity struct { hasPendingCompletionGeneration bool completedGeneration uint64 hasCompletedGeneration bool + + // promptQueueing records the connected agent's negotiated prompt-queueing + // advertisement, delivered over the agent_capabilities stream event. It + // replaces comparing the persisted agent_name: an identity does not prove the + // installed provider version advertises the capability (ADR 0049). It is + // execution-lifetime state and is deliberately never persisted — a restart + // with no connected execution must read as not capable. + promptQueueing bool } // backgroundWork binds a detached workload to the execution that launched it. @@ -1139,19 +1147,54 @@ const ( mockAgentProviderID = "mock-agent" ) +// sessionAdvertisesPromptQueueing reports the connected agent's negotiated +// prompt-queueing advertisement for sessionID, as delivered over the +// agent_capabilities stream event. +// +// This replaces comparing the persisted `agent_name`. ADR 0049 rejects a central +// agent-name whitelist because an agent identity does not prove the installed +// provider version advertises what Kandev expects — a bridge too old to advertise +// prompt queueing must be ineligible even though its name still matches. +// +// Fails closed whenever no live activity record exists, which is exactly the +// case after a restart with no connected execution: the capability is +// execution-lifetime state and is never persisted. +func (s *Service) sessionAdvertisesPromptQueueing(sessionID string) bool { + if sessionID == "" { + return false + } + ta := s.lockTurnActivity(sessionID, false) + if ta == nil { + return false + } + queueing := ta.promptQueueing + ta.mu.Unlock() + return queueing +} + +// recordSessionPromptQueueing stores the negotiated advertisement for sessionID. +// Creates the activity record if absent so a capabilities frame arriving before +// any prompt still lands. +func (s *Service) recordSessionPromptQueueing(sessionID string, queueing bool) { + if sessionID == "" { + return + } + ta := s.lockTurnActivity(sessionID, true) + if ta == nil { + return + } + ta.promptQueueing = queueing + ta.mu.Unlock() +} + // claudeBackgroundPromptHandoffEnabled reports whether sessionID is allowed to -// use the high-risk fine-grained policy. Provider identity comes from the -// persisted launch snapshot rather than a client-supplied activity value. A -// missing session, snapshot, or provider fails closed. +// use the high-risk fine-grained policy: the experiment must be enabled and the +// connected agent must have advertised prompt queueing. func (s *Service) claudeBackgroundPromptHandoffEnabled(sessionID string) bool { - if !s.config.ClaudeBackgroundPromptHandoff || s.repo == nil || sessionID == "" { + if !s.config.ClaudeBackgroundPromptHandoff { return false } - session, err := s.repo.GetTaskSession(context.Background(), sessionID) - if err != nil || session == nil { - return false - } - return s.claudeBackgroundPromptHandoffEnabledForSession(session) + return s.sessionAdvertisesPromptQueueing(sessionID) } func (s *Service) claudeBackgroundPromptHandoffEnabledForSession( @@ -1160,8 +1203,7 @@ func (s *Service) claudeBackgroundPromptHandoffEnabledForSession( if !s.config.ClaudeBackgroundPromptHandoff || session == nil { return false } - agentName, _ := session.AgentProfileSnapshot["agent_name"].(string) - return agentName == claudeACPProviderID || agentName == mockAgentProviderID + return s.sessionAdvertisesPromptQueueing(session.ID) } // ForegroundActivity exposes the conservative operator-facing busy signal by @@ -1252,6 +1294,7 @@ func (s *Service) publishForegroundActivityNow( metaKeySessionID: sessionID, "foreground_activity": s.publicForegroundActivityValue(ctx, sessionID, value), "active_subagent_count": activeSubagentCount, + "supports_steering": s.steerEligibleForSession(ctx, sessionID), } if err := s.eventBus.Publish(ctx, events.TaskSessionActivityChanged, bus.NewEvent(events.TaskSessionActivityChanged, "task-session", eventData)); err != nil { diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index 39f42d58fc..7062db83bd 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -68,6 +68,14 @@ features: dev: "false" e2e: "false" + # High-risk mid-turn steering experiment: deliver a prompt into a turn that is + # still generating. Off in every profile; e2e specs that need it set the env + # var per-spec so the default stays honest. + KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING: + prod: "false" + dev: "false" + e2e: "false" + # ── Mock providers ──────────────────────────────────────────────── # Swap real third-party services for in-memory fakes. Production # leaves all of these unset (real APIs). Dev opts into the mock agent diff --git a/apps/backend/internal/runtimeflags/config_test.go b/apps/backend/internal/runtimeflags/config_test.go index d05509fa5e..b830b6f3e8 100644 --- a/apps/backend/internal/runtimeflags/config_test.go +++ b/apps/backend/internal/runtimeflags/config_test.go @@ -170,3 +170,59 @@ func preserveEnv(t *testing.T, name string) { _ = os.Unsetenv(name) }) } + +func TestOptionsFromConfigParsesClaudeMidTurnSteeringEnv(t *testing.T) { + preserveEnv(t, "KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING") + t.Setenv("KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING", "TRUE") + + opts := OptionsFromConfig(&config.Config{}) + + if !opts.EnvValues["KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING"] { + t.Fatal("KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING TRUE parsed false, want true") + } +} + +func TestValuesFromConfigIncludesClaudeMidTurnSteering(t *testing.T) { + cfg := &config.Config{} + field := reflect.ValueOf(&cfg.Features).Elem().FieldByName("ClaudeMidTurnSteering") + if !field.IsValid() { + t.Fatal("FeaturesConfig.ClaudeMidTurnSteering field missing") + } + field.SetBool(true) + + values := ValuesFromConfig(cfg) + + if !values["features.claudeMidTurnSteering"] { + t.Fatal("ValuesFromConfig did not surface features.claudeMidTurnSteering = true") + } +} + +func TestApplyStatesToConfigSetsClaudeMidTurnSteering(t *testing.T) { + cfg := &config.Config{} + ApplyStatesToConfig(cfg, []RuntimeFlagState{{ + Key: "features.claudeMidTurnSteering", + EffectiveValue: true, + }}) + + field := reflect.ValueOf(&cfg.Features).Elem().FieldByName("ClaudeMidTurnSteering") + if !field.IsValid() || !field.Bool() { + t.Fatal("ApplyStatesToConfig did not set Features.ClaudeMidTurnSteering = true") + } +} + +// TestClaudeMidTurnSteeringIsIndependentOfBackgroundHandoff pins that the two +// experiments are separately killable: enabling one must not enable the other. +func TestClaudeMidTurnSteeringIsIndependentOfBackgroundHandoff(t *testing.T) { + cfg := &config.Config{} + ApplyStatesToConfig(cfg, []RuntimeFlagState{{ + Key: "features.claudeMidTurnSteering", + EffectiveValue: true, + }}) + + if !cfg.Features.ClaudeMidTurnSteering { + t.Fatal("mid-turn steering did not become enabled") + } + if cfg.Features.ClaudeBackgroundPromptHandoff { + t.Fatal("enabling mid-turn steering also enabled the background prompt handoff experiment") + } +} diff --git a/apps/backend/internal/runtimeflags/registry.go b/apps/backend/internal/runtimeflags/registry.go index ff48aea9bd..671614d152 100644 --- a/apps/backend/internal/runtimeflags/registry.go +++ b/apps/backend/internal/runtimeflags/registry.go @@ -96,6 +96,24 @@ var registrations = []runtimeFlagRegistration{ read: func(cfg *config.Config) bool { return cfg.Features.ClaudeBackgroundPromptHandoff }, apply: func(cfg *config.Config, value bool) { cfg.Features.ClaudeBackgroundPromptHandoff = value }, }, + { + definition: RuntimeFlagDefinition{ + Key: "features.claudeMidTurnSteering", + EnvVar: "KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING", + Kind: KindFeature, + Label: "Claude mid-turn steering", + Description: "Delivers a new prompt into a Claude turn that is still generating, instead of holding it until the turn ends.", + Stability: StabilityExperimental, + RiskLevel: RiskHigh, + RiskDescription: "Whether the agent folds the delivered prompt into the running turn is decided by the agent CLI and is not advertised over the protocol, " + + "so the message may instead run as the next turn. Enabling this also lets two prompts overlap on one session, which can misattribute a turn's " + + "completion, usage, or tool state. Use it for controlled testing and disable it if a session behaves unexpectedly.", + RestartRequired: true, + Mutable: true, + }, + read: func(cfg *config.Config) bool { return cfg.Features.ClaudeMidTurnSteering }, + apply: func(cfg *config.Config, value bool) { cfg.Features.ClaudeMidTurnSteering = value }, + }, { definition: RuntimeFlagDefinition{ Key: "debug.devMode", diff --git a/apps/backend/internal/runtimeflags/registry_test.go b/apps/backend/internal/runtimeflags/registry_test.go index 05799e3af9..0bb4349e9f 100644 --- a/apps/backend/internal/runtimeflags/registry_test.go +++ b/apps/backend/internal/runtimeflags/registry_test.go @@ -400,3 +400,22 @@ func assertFeatureConfigRoundTrips(t *testing.T, fields map[string]featureFieldB } } } + +func TestDefinitionsIncludeClaudeMidTurnSteeringMetadata(t *testing.T) { + def, ok := DefinitionByKey("features.claudeMidTurnSteering") + if !ok { + t.Fatal("features.claudeMidTurnSteering definition missing") + } + if def.EnvVar != "KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING" { + t.Fatalf("EnvVar = %q, want KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING", def.EnvVar) + } + if def.Stability != StabilityExperimental { + t.Fatalf("Stability = %q, want experimental", def.Stability) + } + if def.RiskLevel != RiskHigh { + t.Fatalf("RiskLevel = %q, want high", def.RiskLevel) + } + if !def.RestartRequired { + t.Fatal("RestartRequired = false, want true") + } +} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 709fd22544..6a09a82db7 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -285,6 +285,12 @@ type TaskSessionDTO struct { // generation that produced CancellationPending. It is always serialized so // clients can reject delayed snapshots from older generations. CancellationRevision uint64 `json:"cancellation_revision"` + // SupportsSteering is true when a send right now would be delivered into the + // still-generating turn (mid-turn steering) rather than blocked/queued. + // Derived live at serialization from the connected agent's negotiated + // capability plus the runtime flag; never persisted (see mid-turn-steering + // spec). The composer uses it to promise delivery, not folding. + SupportsSteering bool `json:"supports_steering,omitempty"` // PendingAction is the compact per-session projection used when the // session transcript is not loaded in the client. PendingAction *string `json:"pending_action,omitempty"` @@ -341,6 +347,8 @@ type TaskSessionSummaryDTO struct { // CancellationRevision identifies the process-local cancellation transition // generation represented by CancellationPending. CancellationRevision uint64 `json:"cancellation_revision"` + // SupportsSteering mirrors TaskSessionDTO.SupportsSteering for list endpoints. + SupportsSteering bool `json:"supports_steering,omitempty"` // PendingAction is the compact per-session projection used when the // session transcript is not loaded in the client. PendingAction *string `json:"pending_action"` @@ -858,6 +866,13 @@ type ActiveSubagentCountProvider interface { ActiveSubagentCount(sessionID string) int } +// SteerEligibleProvider reports whether a send to a session right now would be +// delivered as a mid-turn steer. Optional: a provider that does not implement it +// simply never advertises steering, which is the conservative default. +type SteerEligibleProvider interface { + SteerEligible(sessionID string, state models.TaskSessionState) bool +} + // EnrichForegroundActivity stamps the live fine-grained busy substate onto a full // session DTO. Generating is emitted only for RUNNING sessions; detached // background activity remains meaningful after the coarse state settles. @@ -869,6 +884,7 @@ func EnrichForegroundActivity(dto *TaskSessionDTO, provider ForegroundActivityPr dto.ForegroundActivity = activity } dto.ActiveSubagentCount = activeSubagentCount(dto.ID, provider) + dto.SupportsSteering = steerEligible(dto.ID, dto.State, provider) } // EnrichForegroundActivitySummary is EnrichForegroundActivity for the lightweight @@ -881,6 +897,7 @@ func EnrichForegroundActivitySummary(dto *TaskSessionSummaryDTO, provider Foregr dto.ForegroundActivity = activity } dto.ActiveSubagentCount = activeSubagentCount(dto.ID, provider) + dto.SupportsSteering = steerEligible(dto.ID, dto.State, provider) } func activeSubagentCount(sessionID string, provider ForegroundActivityProvider) int { @@ -891,6 +908,18 @@ func activeSubagentCount(sessionID string, provider ForegroundActivityProvider) return countProvider.ActiveSubagentCount(sessionID) } +// steerEligible reports whether the live provider would deliver a send to this +// session as a mid-turn steer. Derived here at the serialization boundary from +// the live in-memory provider and never persisted, so a restart with no +// connected execution serializes false. +func steerEligible(sessionID string, state models.TaskSessionState, provider ForegroundActivityProvider) bool { + steerProvider, ok := provider.(SteerEligibleProvider) + if !ok { + return false + } + return steerProvider.SteerEligible(sessionID, state) +} + // WorkflowStepDTO represents a workflow step for API responses type WorkflowStepDTO struct { ID string `json:"id"` diff --git a/apps/backend/internal/task/dto/supports_steering_test.go b/apps/backend/internal/task/dto/supports_steering_test.go new file mode 100644 index 0000000000..c6b1d61833 --- /dev/null +++ b/apps/backend/internal/task/dto/supports_steering_test.go @@ -0,0 +1,80 @@ +package dto + +import ( + "testing" + + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// steerProvider implements both the activity and the steer-eligible provider +// interfaces so the serialization layer can be exercised without importing the +// orchestrator. +type steerProvider struct { + activity v1.ForegroundActivity + steerable bool + steerQueried []string +} + +func (p *steerProvider) ForegroundActivity(string) v1.ForegroundActivity { return p.activity } + +func (p *steerProvider) SteerEligible(sessionID string, _ models.TaskSessionState) bool { + p.steerQueried = append(p.steerQueried, sessionID) + return p.steerable +} + +func TestEnrichForegroundActivity_StampsSupportsSteering(t *testing.T) { + for _, tc := range []struct { + name string + steerable bool + }{ + {name: "eligible", steerable: true}, + {name: "not eligible", steerable: false}, + } { + t.Run(tc.name, func(t *testing.T) { + provider := &steerProvider{activity: v1.ForegroundActivityGenerating, steerable: tc.steerable} + dto := &TaskSessionDTO{ID: "s1", State: models.TaskSessionStateRunning} + EnrichForegroundActivity(dto, provider) + if dto.SupportsSteering != tc.steerable { + t.Fatalf("SupportsSteering = %t, want %t", dto.SupportsSteering, tc.steerable) + } + if len(provider.steerQueried) != 1 || provider.steerQueried[0] != "s1" { + t.Fatalf("provider steer query = %v, want [s1]", provider.steerQueried) + } + }) + } +} + +func TestEnrichForegroundActivitySummary_StampsSupportsSteering(t *testing.T) { + provider := &steerProvider{activity: v1.ForegroundActivityGenerating, steerable: true} + dto := &TaskSessionSummaryDTO{ID: "s2", State: models.TaskSessionStateRunning} + EnrichForegroundActivitySummary(dto, provider) + if !dto.SupportsSteering { + t.Fatal("summary SupportsSteering = false, want true") + } +} + +// TestSupportsSteering_NotSetByFromTaskSession pins that the flag is a live +// serialization-boundary value, never derived from the persisted row. A restart +// with no connected provider must serialize false. +func TestSupportsSteering_NotSetByFromTaskSession(t *testing.T) { + session := &models.TaskSession{ID: "s3", State: models.TaskSessionStateRunning} + dto := FromTaskSession(session) + if dto.SupportsSteering { + t.Fatal("FromTaskSession set SupportsSteering; it must only be stamped by enrichment") + } +} + +// TestSupportsSteering_ProviderWithoutSteerInterface pins the conservative +// default: a provider that does not implement SteerEligibleProvider never +// advertises steering. +func TestSupportsSteering_ProviderWithoutSteerInterface(t *testing.T) { + // fakeForegroundActivityProvider (from foreground_activity_test.go) has no + // SteerEligible method. + provider := &fakeForegroundActivityProvider{value: v1.ForegroundActivityGenerating} + dto := &TaskSessionDTO{ID: "s4", State: models.TaskSessionStateRunning} + EnrichForegroundActivity(dto, provider) + if dto.SupportsSteering { + t.Fatal("provider without SteerEligible advertised steering") + } +} diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index fea01c0a94..d87b0631eb 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -37,6 +37,14 @@ type OrchestratorService interface { // and persisted provider identity. Background therefore means this exact // session is eligible for the experimental direct-admission path. ForegroundActivity(sessionID string) v1.ForegroundActivity + // SteerEligible reports whether a send to this generating RUNNING session + // would be delivered as a mid-turn steer rather than blocked/queued. Already + // gated by the mid-turn-steering runtime flag and the agent's negotiated + // capability, so a false is the conservative default. + SteerEligible(sessionID string, state models.TaskSessionState) bool + // SteerTask delivers a prompt into a still-generating turn. It returns a typed + // steer sentinel when the message must be queued/blocked instead. + SteerTask(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment) (*orchestrator.PromptResult, error) } type taskTitleSessionClaimer interface { @@ -379,8 +387,16 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w } req.TaskSessionID = sessionResp.Session.ID } - if wsErr := h.errorForBlockedMessageSession(msg, sessionResp.Session.ID, sessionResp.Session.State); wsErr != nil { - return wsErr, nil + // A generating RUNNING session is normally blocked here. When mid-turn + // steering is enabled and this session's agent advertised the capability, + // deliver the message into the running turn instead of blocking. Flag-off or + // an unadvertised agent leaves this false, so behavior is unchanged. + steer := h.orchestrator != nil && + h.orchestrator.SteerEligible(sessionResp.Session.ID, sessionResp.Session.State) + if !steer { + if wsErr := h.errorForBlockedMessageSession(msg, sessionResp.Session.ID, sessionResp.Session.State); wsErr != nil { + return wsErr, nil + } } isCreatedSession := sessionResp.Session.State == models.TaskSessionStateCreated startCreatedSession := isCreatedSession || wasCreatedSession @@ -468,7 +484,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w // This runs async so the WS request can respond immediately. // Use context.WithoutCancel so the prompt continues even if the WebSocket client disconnects. if h.orchestrator != nil { - h.dispatchPromptAsync(ctx, req, sessionResp.Session.AgentProfileID, startCreatedSession) + h.dispatchPromptAsync(ctx, req, sessionResp.Session.AgentProfileID, startCreatedSession, steer) } return response, nil @@ -596,6 +612,18 @@ func (h *MessageHandlers) checkSessionStateForMessage(ctx context.Context, msg * sessionDTO := dto.FromTaskSession(session) dto.EnrichCancellationPending(&sessionDTO, h.cancellationPending) resp := &dto.GetTaskSessionResponse{Session: sessionDTO} + // A steer-eligible generating RUNNING session must pass this first guard: + // otherwise the busy error is returned here, before the steer branch in + // wsAddMessage is ever reached, and the message is rejected instead of + // delivered into the running turn. The steer branch there re-checks + // eligibility (after on_turn_start) and remains the authoritative decision; + // this only widens the first gate for the sessions steering targets. Every + // other blocked state is unchanged. + if h.orchestrator != nil && + sessionDTO.State == models.TaskSessionStateRunning && + h.orchestrator.SteerEligible(sessionID, sessionDTO.State) { + return resp, nil + } if wsErr := h.errorForBlockedMessageSession(msg, sessionID, sessionDTO.State); wsErr != nil { if sessionDTO.State == models.TaskSessionStateRunning { h.logBlockedRunningSession(sessionID, sessionDTO.State) @@ -636,7 +664,7 @@ func (h *MessageHandlers) ensureTaskInProgress(ctx context.Context, taskID strin // background goroutine. The caller (wsAddMessage) is responsible for running // on_turn_start synchronously BEFORE wrapping the prompt, so this function // only handles the agent-facing dispatch. -func (h *MessageHandlers) dispatchPromptAsync(ctx context.Context, req wsAddMessageRequest, agentProfileID string, isCreatedSession bool) { +func (h *MessageHandlers) dispatchPromptAsync(ctx context.Context, req wsAddMessageRequest, agentProfileID string, isCreatedSession, steer bool) { taskID := req.TaskID sessionID := req.TaskSessionID content := req.Content @@ -645,6 +673,10 @@ func (h *MessageHandlers) dispatchPromptAsync(ctx context.Context, req wsAddMess attachments := req.Attachments go func() { promptCtx := context.WithoutCancel(ctx) + if steer { + h.forwardMessageAsSteer(promptCtx, taskID, sessionID, content, model, planMode, attachments) + return + } h.forwardMessageAsPrompt( promptCtx, taskID, sessionID, agentProfileID, content, model, planMode, attachments, req.EntityReferences, isCreatedSession, @@ -652,6 +684,48 @@ func (h *MessageHandlers) dispatchPromptAsync(ctx context.Context, req wsAddMess }() } +// forwardMessageAsSteer delivers a message into a still-generating turn. +// SteerTask returns nil whether it dispatched the steer or enqueued it behind +// pending work (both are success — order is preserved either way). +// +// ErrSteerNotEligible is returned by SteerTask's eligibility check *before* any +// dispatch, so nothing was sent: the session's turn ended between the handler's +// check and here, and the ordinary prompt path is correct (the session is now +// promptable). Falling back there cannot double-deliver. +// +// Any other error is a genuine dispatch failure, and it is NOT safe to re-send: +// the agentctl request can be written to the agent and then have its +// acknowledgement fail (a stream disconnect or context cancellation after the +// write, see agentctl client sendStreamRequest), so the steer may already be in +// flight. Re-running it as an ordinary prompt would deliver the operator's +// message twice. Surface the error instead — exactly as the ordinary prompt path +// does for its own dispatch failures — unless the agent itself reported it. +func (h *MessageHandlers) forwardMessageAsSteer( + ctx context.Context, + taskID, sessionID, content, model string, + planMode bool, + attachments []v1.MessageAttachment, +) { + _, err := h.orchestrator.SteerTask(ctx, taskID, sessionID, content, model, planMode, attachments) + if err == nil { + return + } + if errors.Is(err, orchestrator.ErrSteerNotEligible) { + h.logger.Debug("steer no longer eligible; using ordinary prompt path", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + // agentProfileID/references/startCreated are irrelevant: a steer only + // targets a RUNNING session, never a CREATED one, so this takes the + // ordinary prompt branch (PromptTask + resume + error handling). + h.forwardMessageAsPrompt(ctx, taskID, sessionID, "", content, model, planMode, attachments, nil, false) + return + } + if !isAgentReportedError(err) { + h.createPromptErrorMessage(ctx, taskID, sessionID, err) + } +} + // forwardMessageAsPrompt sends a user message to the agent as a prompt. // For CREATED sessions, it starts the agent; otherwise it prompts the running agent, // with automatic resume handling if the agent is not found. diff --git a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go index 6ca9965a4a..f32aff4a60 100644 --- a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go +++ b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go @@ -30,9 +30,12 @@ import ( type messageAddSwitchRepo struct { mockRepository - tasks map[string]*models.Task - sessions map[string]*models.TaskSession - primaryID string + tasks map[string]*models.Task + sessions map[string]*models.TaskSession + primaryID string + // messagesMu guards messages: async dispatch goroutines (e.g. a steer that + // falls back and writes an error message) can append while a test reads. + messagesMu sync.Mutex messages []*models.Message turns []*models.Turn idempotentMessage *models.Message @@ -41,6 +44,21 @@ type messageAddSwitchRepo struct { taskGetCalls int } +func (r *messageAddSwitchRepo) messageCount() int { + r.messagesMu.Lock() + defer r.messagesMu.Unlock() + return len(r.messages) +} + +func (r *messageAddSwitchRepo) firstMessageContent() string { + r.messagesMu.Lock() + defer r.messagesMu.Unlock() + if len(r.messages) == 0 { + return "" + } + return r.messages[0].Content +} + func (r *messageAddSwitchRepo) GetMessage(_ context.Context, id string) (*models.Message, error) { if r.idempotentMessage != nil && r.idempotentMessage.ID == id { return r.idempotentMessage, nil @@ -122,6 +140,16 @@ func (*firstTurnCaptureOrchestrator) ForegroundActivity(string) v1.ForegroundAct return "" } +func (*firstTurnCaptureOrchestrator) SteerEligible(string, models.TaskSessionState) bool { + return false +} + +func (*firstTurnCaptureOrchestrator) SteerTask( + context.Context, string, string, string, string, bool, []v1.MessageAttachment, +) (*orchestrator.PromptResult, error) { + return &orchestrator.PromptResult{}, nil +} + func TestWSAddMessage_CreatedSessionPreservesReferencesThroughCanonicalizationAndDispatch(t *testing.T) { now := time.Now().UTC() reference := v1.EntityReference{ @@ -320,7 +348,9 @@ func (r *messageAddSwitchRepo) GetPrimarySessionByTaskID(_ context.Context, task } func (r *messageAddSwitchRepo) CreateMessage(_ context.Context, message *models.Message) error { + r.messagesMu.Lock() r.messages = append(r.messages, message) + r.messagesMu.Unlock() return nil } @@ -525,6 +555,16 @@ func (o *switchingTurnStartOrchestrator) ForegroundActivity(string) v1.Foregroun return v1.ForegroundActivityGenerating } +func (*switchingTurnStartOrchestrator) SteerEligible(string, models.TaskSessionState) bool { + return false +} + +func (*switchingTurnStartOrchestrator) SteerTask( + context.Context, string, string, string, string, bool, []v1.MessageAttachment, +) (*orchestrator.PromptResult, error) { + return &orchestrator.PromptResult{}, nil +} + func (o *switchingTurnStartOrchestrator) StepRequiresCompletionSignal(context.Context, string) bool { return false } @@ -736,6 +776,14 @@ type recordingAdmissionOrchestrator struct { prompted chan string } +func (fgActivityOrchestrator) SteerEligible(string, models.TaskSessionState) bool { return false } + +func (fgActivityOrchestrator) SteerTask( + context.Context, string, string, string, string, bool, []v1.MessageAttachment, +) (*orchestrator.PromptResult, error) { + return &orchestrator.PromptResult{}, nil +} + func (o *recordingAdmissionOrchestrator) PromptTask(_ context.Context, _ string, sessionID string, _ string, _ string, _ bool, _ []v1.MessageAttachment, _ bool) (*orchestrator.PromptResult, error) { o.prompted <- sessionID return &orchestrator.PromptResult{}, nil @@ -756,6 +804,164 @@ func (o *recordingAdmissionOrchestrator) ForegroundActivity(string) v1.Foregroun return o.activity } +func (*recordingAdmissionOrchestrator) SteerEligible(string, models.TaskSessionState) bool { + return false +} + +func (*recordingAdmissionOrchestrator) SteerTask( + context.Context, string, string, string, string, bool, []v1.MessageAttachment, +) (*orchestrator.PromptResult, error) { + return &orchestrator.PromptResult{}, nil +} + +// steerRecordingOrchestrator advertises a generating, steer-eligible RUNNING +// session and records which dispatch path the handler took. steerErr lets a test +// force the not-eligible sentinel to exercise the prompt fallback. +type steerRecordingOrchestrator struct { + steerErr error + steered chan string + prompted chan string + mu sync.Mutex + steeredContent string +} + +func (o *steerRecordingOrchestrator) getSteeredContent() string { + o.mu.Lock() + defer o.mu.Unlock() + return o.steeredContent +} + +func (*steerRecordingOrchestrator) ResumeTaskSession(context.Context, string, string) error { + return nil +} +func (*steerRecordingOrchestrator) StartCreatedSession(context.Context, string, string, string, string, bool, bool, bool, []v1.MessageAttachment, []v1.EntityReference) error { + return nil +} +func (*steerRecordingOrchestrator) ProcessOnTurnStart(context.Context, string, string) error { + return nil +} +func (*steerRecordingOrchestrator) StepRequiresCompletionSignal(context.Context, string) bool { + return false +} +func (*steerRecordingOrchestrator) ForegroundActivity(string) v1.ForegroundActivity { + return v1.ForegroundActivityGenerating +} +func (*steerRecordingOrchestrator) SteerEligible(string, models.TaskSessionState) bool { + return true +} + +func (o *steerRecordingOrchestrator) SteerTask( + _ context.Context, _ string, sessionID string, content string, _ string, _ bool, _ []v1.MessageAttachment, +) (*orchestrator.PromptResult, error) { + o.mu.Lock() + o.steeredContent = content + o.mu.Unlock() + o.steered <- sessionID + if o.steerErr != nil { + return nil, o.steerErr + } + return &orchestrator.PromptResult{}, nil +} + +func (o *steerRecordingOrchestrator) PromptTask( + _ context.Context, _ string, sessionID string, _ string, _ string, _ bool, _ []v1.MessageAttachment, _ bool, +) (*orchestrator.PromptResult, error) { + o.prompted <- sessionID + return &orchestrator.PromptResult{}, nil +} + +// TestWSAddMessage_SteerEligibleDispatchesSteer proves the end-to-end steer path: +// a generating RUNNING session that is steer-eligible is admitted past the busy +// guard (the P1 regression) and dispatched via SteerTask, and a session that has +// since become ineligible falls back to PromptTask. +func TestWSAddMessage_SteerEligibleDispatchesSteer(t *testing.T) { + tests := []struct { + name string + steerErr error + wantSteered bool + wantPrompt bool + wantErrMsg bool + }{ + {name: "eligible steers", wantSteered: true}, + {name: "not-eligible falls back to prompt", steerErr: orchestrator.ErrSteerNotEligible, wantSteered: true, wantPrompt: true}, + // A genuine dispatch error may mean the steer was already written to the + // agent (ack failed after the write), so the handler must NOT re-send it as + // an ordinary prompt — that would double-deliver the operator's message. + // Instead it surfaces an operator-visible error. + {name: "dispatch error surfaces, does not re-send", steerErr: errors.New("stream disconnected while waiting for response"), wantSteered: true, wantPrompt: false, wantErrMsg: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now().UTC() + repo := &messageAddSwitchRepo{ + tasks: map[string]*models.Task{ + "t1": {ID: "t1", State: v1.TaskStateInProgress, UpdatedAt: now}, + }, + sessions: map[string]*models.TaskSession{ + "s1": {ID: "s1", TaskID: "t1", State: models.TaskSessionStateRunning, AgentProfileID: "profile-1", UpdatedAt: now}, + }, + primaryID: "s1", + } + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + orch := &steerRecordingOrchestrator{ + steerErr: tt.steerErr, + steered: make(chan string, 1), + prompted: make(chan string, 1), + } + h := NewMessageHandlers(svc, orch, log) + req, err := ws.NewRequest("req-steer", ws.ActionMessageAdd, map[string]interface{}{ + "task_id": "t1", "session_id": "s1", "content": "steer this", + }) + require.NoError(t, err) + + resp, err := h.wsAddMessage(t.Context(), req) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type, "steer-eligible RUNNING must be admitted, not blocked") + + if tt.wantSteered { + select { + case <-orch.steered: + case <-time.After(time.Second): + t.Fatal("steer-eligible message was not dispatched via SteerTask") + } + // The steer forwards the same canonicalized content the transcript + // row stored — references/context are not stripped on the steer path. + assert.Equal(t, repo.firstMessageContent(), orch.getSteeredContent(), + "steered content must match the persisted message (no divergence)") + } + if tt.wantPrompt { + select { + case <-orch.prompted: + case <-time.After(time.Second): + t.Fatal("ineligible steer did not fall back to PromptTask") + } + } else { + // A steer that succeeded or that hit a dispatch error must never + // also dispatch an ordinary prompt (a fallback would double-deliver). + select { + case <-orch.prompted: + t.Fatal("steer path must not also dispatch an ordinary prompt") + case <-time.After(100 * time.Millisecond): + } + } + if tt.wantErrMsg { + // The operator is still informed: the dispatch error surfaces as a + // second (error) message rather than being silently dropped. + require.Eventually(t, func() bool { return repo.messageCount() == 2 }, time.Second, 5*time.Millisecond, + "a dispatch error must surface an operator-visible error message") + } + }) + } +} + func TestWSAddMessage_ForegroundActivityAdmissionWiring(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/task/handlers/message_handlers_resume_readiness_test.go b/apps/backend/internal/task/handlers/message_handlers_resume_readiness_test.go index 7cc8cf8a17..d8008def15 100644 --- a/apps/backend/internal/task/handlers/message_handlers_resume_readiness_test.go +++ b/apps/backend/internal/task/handlers/message_handlers_resume_readiness_test.go @@ -83,6 +83,19 @@ func (o *resumeRetryOrchestrator) ForegroundActivity(string) v1.ForegroundActivi return "" } +// These tests cover the resume/retry path for a session that is *not* +// generating, so steering never applies. False is the interface's conservative +// default and keeps the prompt-with-resume path under test. +func (*resumeRetryOrchestrator) SteerEligible(string, models.TaskSessionState) bool { + return false +} + +func (*resumeRetryOrchestrator) SteerTask( + context.Context, string, string, string, string, bool, []v1.MessageAttachment, +) (*orchestrator.PromptResult, error) { + return &orchestrator.PromptResult{}, nil +} + // newTestMessageHandlersWithOrchestrator mirrors newTestMessageHandlers but // wires a real OrchestratorService fake, which forwardMessageAsPrompt/ // handlePromptWithResume require (they early-return when h.orchestrator is diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index 12163ac818..2b8fa0e18c 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -76,6 +76,9 @@ type ChatInputContainerProps = { mcpAttachmentHistory?: MCPAttachmentHistory; onPlanModeChange: (enabled: boolean) => void; isAgentBusy: boolean; + /** True when a send would be delivered into the running turn (mid-turn + * steering) rather than queued. Defaults to false. */ + supportsSteering?: boolean; isStarting: boolean; /** True only while a containerized executor is bootstrapping (Docker * prepare, Sprites sandbox spin-up). Distinct from the brief STARTING @@ -452,6 +455,7 @@ export const ChatInputContainer = forwardRef[0]> = {}) { return renderHook( () => @@ -19,6 +21,7 @@ function renderInputState(overrides: Partial { globalThis.fetch = originalFetch; } }); + + it("shows the steer affordance over a caller placeholder when the session can steer", () => { + const { result } = renderInputState({ + supportsSteering: true, + placeholder: callerPlaceholder, + }); + + // The steer label must win: a send here is delivered into the running turn, + // and the generic "Continue working…" prompt would mask that. Assert the + // resolved chat:composerSteerPlaceholder itself — asserting only that it + // differs from the caller's would also pass on any other generic label. + expect(result.current.inputPlaceholder).toBe("Send now — delivered to the running turn"); + }); + + it("keeps the caller placeholder when the session cannot steer", () => { + const { result } = renderInputState({ + supportsSteering: false, + placeholder: callerPlaceholder, + }); + + expect(result.current.inputPlaceholder).toBe(callerPlaceholder); + }); }); describe("shouldShowChatFocusHint", () => { diff --git a/apps/web/components/task/chat/use-chat-input-container.ts b/apps/web/components/task/chat/use-chat-input-container.ts index 2b6c4faef6..63556e59c8 100644 --- a/apps/web/components/task/chat/use-chat-input-container.ts +++ b/apps/web/components/task/chat/use-chat-input-container.ts @@ -2,6 +2,7 @@ import { useCallback, useRef, useState, useEffect, useImperativeHandle } from "react"; import type React from "react"; +import { useTranslation } from "react-i18next"; import { useResizableInput } from "@/hooks/use-resizable-input"; import { useChatInputState } from "./use-chat-input-state"; import type { TipTapInputHandle } from "./tiptap-input"; @@ -35,6 +36,10 @@ type UseChatInputContainerParams = { needsRecovery: boolean; executorUnavailable: boolean; isAgentBusy: boolean; + // supportsSteering is true when a send would be delivered into the running + // turn (mid-turn steering) instead of queued. Drives the composer's + // delivery-now affordance. + supportsSteering: boolean; hasAgentCommands: boolean; placeholder: string | undefined; contextItems: ContextItem[]; @@ -83,8 +88,16 @@ function getInputPlaceholder( isAgentBusy: boolean, hasAgentCommands: boolean, isStarting: boolean, + steerPlaceholder: string | undefined, ): string { if (isStarting) return t("task:preparingWorkspace"); + // The steer label wins over a caller-supplied placeholder: when a send would be + // delivered into the running turn, that is the one thing the operator most needs + // to know, and it must not be masked by a generic "Continue working…" prompt the + // task composer passes. It promises delivery, never that the agent will fold it. + // steerPlaceholder is set only when the session is steer-eligible, so this + // branch is inert for every non-steering composer. + if (steerPlaceholder) return steerPlaceholder; if (placeholder) return placeholder; if (isAgentBusy) return t("task:queueMoreInstructions"); if (hasAgentCommands) return t("task:askToMakeChangesWithCommands"); @@ -123,6 +136,7 @@ function computeDerivedState(params: { placeholder: string | undefined; isAgentBusy: boolean; hasAgentCommands: boolean; + steerPlaceholder: string | undefined; }) { const hasClarification = !!(params.pendingClarification && params.onClarificationResolved); // STARTING blocks regular messages until the session reaches RUNNING. An @@ -157,6 +171,7 @@ function computeDerivedState(params: { params.isAgentBusy, params.hasAgentCommands, params.isStarting && !hasClarification, + params.steerPlaceholder, ); return { isDisabled, @@ -171,8 +186,10 @@ function computeDerivedState(params: { } export function useChatInputContainer(params: UseChatInputContainerParams) { + const { t } = useTranslation("chat"); const { ref, sessionId, isSending, isStarting, isPreparingEnvironment, isMoving } = params; const { isFailed, needsRecovery, executorUnavailable, isAgentBusy, hasAgentCommands } = params; + const { supportsSteering } = params; const { placeholder, contextItems, pendingClarification, onClarificationResolved } = params; const { pendingCommentsByFile, showRequestChangesTooltip } = params; const { onRequestChangesTooltipDismiss, onSubmit } = params; @@ -249,6 +266,7 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { placeholder, isAgentBusy, hasAgentCommands, + steerPlaceholder: supportsSteering ? t("composerSteerPlaceholder") : undefined, }); return { diff --git a/apps/web/components/task/chat/use-composer-props.ts b/apps/web/components/task/chat/use-composer-props.ts index 830f06b49e..b6037d2e46 100644 --- a/apps/web/components/task/chat/use-composer-props.ts +++ b/apps/web/components/task/chat/use-composer-props.ts @@ -50,6 +50,7 @@ export function useComposerProps(args: ComposerPropsArgs) { hidePlanMode, } = args; const { resolvedSessionId, taskId, isAgentBusy, needsRecovery, planModeEnabled } = panelState; + const supportsSteering = panelState.supportsSteering; const hasContextComments = panelState.planComments.length > 0 || panelState.pendingPRFeedback.length > 0 || @@ -69,6 +70,7 @@ export function useComposerProps(args: ComposerPropsArgs) { mcpAttachmentHistory: panelState.mcpAttachmentHistory, onPlanModeChange: panelState.handlePlanModeChange, isAgentBusy, + supportsSteering, isStarting: panelState.isStarting, isPreparingEnvironment: panelState.isPreparingEnvironment, isMoving, diff --git a/apps/web/e2e/tests/chat/mid-turn-steering.spec.ts b/apps/web/e2e/tests/chat/mid-turn-steering.spec.ts new file mode 100644 index 0000000000..74b1f797e7 --- /dev/null +++ b/apps/web/e2e/tests/chat/mid-turn-steering.spec.ts @@ -0,0 +1,334 @@ +import { type Page } from "@playwright/test"; +import { test, expect } from "../../fixtures/test-base"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { waitForActiveSessionForegroundActivity } from "../../helpers/session-store"; +import { typeWhileBusy } from "../../helpers/type-while-busy"; +import { SessionPage } from "../../pages/session-page"; + +// End-to-end coverage for the mid-turn steering composer contract +// (docs/specs/platform/mid-turn-steering.md). CONTRIBUTING.md requires Playwright +// coverage for UI changes; this exercises the flag-gated decision and the real +// steer dispatch path through the mock agent, which advertises promptQueueing. +// +// The observable contract is delivered-vs-queued, not the placeholder text: the +// tiptap placeholder is a cursor-anchored decoration and only renders under +// focus, so asserting message delivery is the robust signal. + +interface SeedRunningGeneratingSessionOptions { + // Duration for the default `/sleep N` predecessor. Ignored when + // predecessorPrompt is set. + sleepSeconds?: number; + // Overrides the default `/sleep N` predecessor entirely — used by the + // folded/deferred tests to seed steer-fold-setup / steer-defer-setup + // instead. + predecessorPrompt?: string; +} + +async function seedRunningGeneratingSession( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, + title: string, + options: SeedRunningGeneratingSessionOptions = {}, +): Promise<{ session: SessionPage; taskId: string; sessionId: string }> { + const { sleepSeconds = 20, predecessorPrompt } = options; + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + // A no-tool sleep holds the foreground turn open and generating — the exact + // state that normally queues input, so it isolates the steering gate. + // steer-fold-setup/steer-defer-setup hold the same way, but differ in + // whether they answer on their own first (see the folded/deferred tests). + await session.sendMessage(predecessorPrompt ?? `/sleep ${sleepSeconds}`); + await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); + await waitForActiveSessionForegroundActivity(testPage, "generating"); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + return { session, taskId: task.id, sessionId: task.session_id }; +} + +test.describe.serial("Claude mid-turn steering experiment", () => { + test.describe.configure({ retries: 1 }); + + test.describe("enabled", () => { + test.beforeAll(async ({ backend }) => { + await backend.restart({ KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING: "true" }); + const response = await fetch(`${backend.baseUrl}/api/v1/features`); + expect(response.ok).toBeTruthy(); + expect(await response.json()).toMatchObject({ claudeMidTurnSteering: true }); + }); + + test.afterAll(async ({ backend }) => { + await backend.restart(); + }); + + test("delivers a message into a generating turn instead of queuing it", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const { session } = await seedRunningGeneratingSession( + testPage, + apiClient, + seedData, + "Mid-turn steering enabled", + ); + + // The session reports steer capability: the composer will deliver rather + // than queue. + await waitForActiveSessionSupportsSteering(testPage, true); + + const editor = session.activeChat().locator(".tiptap.ProseMirror:visible"); + await typeWhileBusy(testPage, editor, "steer: change course now"); + await testPage.getByTestId("submit-message-button").click(); + + // A steered send is delivered, not queued: it appears as a user message + // and must NOT produce a client-side queue chip. + // + // Scoped to the user bubble rather than a bare getByText: the steered text + // also reaches the agent, so a plain text match resolves to two elements + // (the user message and the mock's echo of the prompt) and trips strict + // mode. Which one renders first is a race, so the bare match failed only + // sometimes — it must assert the *user message* specifically anyway, since + // that is what distinguishes delivery from queuing. + await expect( + session + .activeChat() + .getByTestId("user-message-bubble") + .filter({ hasText: "steer: change course now" }), + ).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByText("steer: change course now", { exact: false })).toHaveCount( + 2, + { timeout: 15_000 }, + ); + await expect(testPage.getByTestId("queue-chip")).not.toBeVisible(); + }); + + test("keeps a steer behind a message that was already queued", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const { session, taskId, sessionId } = await seedRunningGeneratingSession( + testPage, + apiClient, + seedData, + "Mid-turn steering queue order", + { sleepSeconds: 60 }, + ); + + await waitForActiveSessionSupportsSteering(testPage, true); + await apiClient.queueMessage(taskId, sessionId, "already queued"); + + const chat = session.activeChat(); + await expect(chat.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 }); + + const editor = chat.locator(".tiptap.ProseMirror:visible"); + await typeWhileBusy(testPage, editor, "steer after queued"); + await testPage.getByTestId("submit-message-button").click(); + + await expect(chat.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 }); + await chat.getByTestId("queue-chip").click(); + const entries = chat.getByTestId("queued-ghost-list").getByTestId("queue-entry-text"); + await expect(entries).toHaveCount(2, { timeout: 10_000 }); + await expect(entries.nth(0)).toHaveText("already queued"); + await expect(entries.nth(1)).toHaveText("steer after queued"); + }); + + // The two outcomes below replay the mid-turn steering spec's "folded" vs + // "deferred" outcome taxonomy (docs/specs/platform/mid-turn-steering.md) + // via the dedicated mock-agent setup scenarios: steer-fold-setup never + // answers on its own (task 08's mock-replay acceptance for "folded" — + // pinned deterministically at the mock level by + // TestSteerFoldSetupEmitsNoAnswerUntilCancelled), steer-defer-setup + // answers immediately and then holds (mock-replay acceptance for + // "deferred", pinned by TestSteerDeferSetupAnswersBeforeHolding). Both + // outcomes are success, per spec — these E2E cases prove the full stack + // (not just the mock) delivers a steer against each predecessor shape + // without error. The steer's own reply text is intentionally free-form + // (routed through the mock's default responder, which mixes in random + // tool calls before its guaranteed final echo line) so message-count + // assertions here would be flaky by construction — only the delivered + // marker text is asserted, matching the "enabled" describe block's + // existing pattern above. + test("delivers a folded steer with no separate predecessor answer", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const { session } = await seedRunningGeneratingSession( + testPage, + apiClient, + seedData, + "Mid-turn steering folded", + { predecessorPrompt: "/e2e:steer-fold-setup" }, + ); + + await waitForActiveSessionSupportsSteering(testPage, true); + // The predecessor is silent by construction (see the unit test above), + // so nothing but the steer's own eventual answer should appear here. + await expect( + session.activeChat().getByText("Predecessor turn's own answer", { exact: false }), + ).not.toBeVisible(); + + const editor = session.activeChat().locator(".tiptap.ProseMirror:visible"); + await typeWhileBusy(testPage, editor, "steer: fold into the running turn"); + await testPage.getByTestId("submit-message-button").click(); + + await expect( + session + .activeChat() + .getByTestId("user-message-bubble") + .filter({ hasText: "steer: fold into the running turn" }), + ).toBeVisible({ timeout: 15_000 }); + await expect( + testPage.getByText("steer: fold into the running turn", { exact: false }), + ).toHaveCount(2, { timeout: 15_000 }); + await expect(testPage.getByTestId("queue-chip")).not.toBeVisible(); + }); + + test("delivers a deferred steer whose predecessor answers first, with no error or version warning", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const { session } = await seedRunningGeneratingSession( + testPage, + apiClient, + seedData, + "Mid-turn steering deferred", + { predecessorPrompt: "/e2e:steer-defer-setup" }, + ); + + await waitForActiveSessionSupportsSteering(testPage, true); + const predecessorAnswer = session + .activeChat() + .getByText("Predecessor turn's own answer, delivered before any steer arrives."); + await expect(predecessorAnswer).toBeVisible({ timeout: 15_000 }); + + const editor = session.activeChat().locator(".tiptap.ProseMirror:visible"); + await typeWhileBusy(testPage, editor, "steer: run as your own turn"); + await testPage.getByTestId("submit-message-button").click(); + + await expect( + session + .activeChat() + .getByTestId("user-message-bubble") + .filter({ hasText: "steer: run as your own turn" }), + ).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByText("steer: run as your own turn", { exact: false })).toHaveCount( + 2, + { timeout: 15_000 }, + ); + // Deferred: the predecessor's own answer must still be there, alongside + // the steer's own separate answer — neither turn clobbers the other. + await expect(predecessorAnswer).toBeVisible(); + await expect(testPage.getByTestId("last-agent-error-notice")).not.toBeVisible(); + await expect(testPage.getByTestId("toast-message")).not.toBeVisible(); + }); + }); + + test.describe("capability not advertised", () => { + test.beforeAll(async ({ backend }) => { + await backend.restart({ + KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING: "true", + KANDEV_MOCK_AGENT_PROMPT_QUEUEING: "false", + }); + }); + + test.afterAll(async ({ backend }) => { + await backend.restart(); + }); + + test("queues input when the agent does not advertise prompt queueing", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const { session } = await seedRunningGeneratingSession( + testPage, + apiClient, + seedData, + "Mid-turn steering capability absent", + ); + + await waitForActiveSessionSupportsSteering(testPage, false); + const editor = session.activeChat().locator(".tiptap.ProseMirror:visible"); + await typeWhileBusy(testPage, editor, "capability absent follow-up"); + await testPage.getByTestId("submit-message-button").click(); + await expect(testPage.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 }); + }); + }); + + test.describe("disabled by default", () => { + test("queues input for a generating turn when the flag is off", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const { session } = await seedRunningGeneratingSession( + testPage, + apiClient, + seedData, + "Mid-turn steering disabled", + ); + + // Default profile: the flag is off, so a generating session keeps today's + // queue behavior even though the mock agent advertises prompt queueing. + await waitForActiveSessionSupportsSteering(testPage, false); + + const editor = session.activeChat().locator(".tiptap.ProseMirror:visible"); + await typeWhileBusy(testPage, editor, "queue this follow-up"); + await testPage.getByTestId("submit-message-button").click(); + await expect(testPage.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 }); + }); + }); +}); + +async function waitForActiveSessionSupportsSteering(page: Page, expected: boolean): Promise { + await expect + .poll( + async () => + page.evaluate(() => { + const store = ( + window as Window & { + __KANDEV_E2E_STORE__?: { + getState: () => { + tasks: { activeSessionId: string | null }; + taskSessions: { items: Record }; + }; + }; + } + ).__KANDEV_E2E_STORE__; + // Report undefined — never false — until the session and its + // supports_steering field actually exist. Collapsing "not loaded yet" + // into false would let the flag-off assertion pass before the + // capability arrives, or when a regression drops the field entirely. + if (!store) return undefined; + const state = store.getState(); + const sid = state.tasks.activeSessionId; + if (!sid) return undefined; + return state.taskSessions.items[sid]?.supports_steering; + }), + { timeout: 15_000 }, + ) + .toBe(expected); +} diff --git a/apps/web/hooks/domains/session/session-input-mode.test.ts b/apps/web/hooks/domains/session/session-input-mode.test.ts index f19302dd4a..0ba2219231 100644 --- a/apps/web/hooks/domains/session/session-input-mode.test.ts +++ b/apps/web/hooks/domains/session/session-input-mode.test.ts @@ -11,12 +11,14 @@ import { deriveSessionInputMode } from "./session-input-mode"; function session( state: TaskSessionState, foregroundActivity?: ForegroundActivity | null, + supportsSteering?: boolean, ): TaskSession { return { id: sessionId("selected-session"), task_id: taskId("task-1"), state, foreground_activity: foregroundActivity, + supports_steering: supportsSteering, started_at: "2026-07-22T00:00:00Z", updated_at: "2026-07-22T00:00:00Z", }; @@ -44,6 +46,16 @@ describe("deriveSessionInputMode", () => { expect(deriveSessionInputMode(selected)).toBe("queue"); }); + it("delivers directly for a generating session that supports steering", () => { + const selected = session("RUNNING", "generating", true); + expect(deriveSessionInputMode(selected)).toBe("direct"); + }); + + it("still queues a generating session when steering is not supported", () => { + const selected = session("RUNNING", "generating", false); + expect(deriveSessionInputMode(selected)).toBe("queue"); + }); + it("returns unavailable when the selected session is missing", () => { expect(deriveSessionInputMode(null)).toBe("unavailable"); expect(deriveSessionInputMode(undefined)).toBe("unavailable"); diff --git a/apps/web/hooks/domains/session/session-input-mode.ts b/apps/web/hooks/domains/session/session-input-mode.ts index a02dcb793b..2d2d675ec3 100644 --- a/apps/web/hooks/domains/session/session-input-mode.ts +++ b/apps/web/hooks/domains/session/session-input-mode.ts @@ -9,7 +9,10 @@ export type SessionInputMode = "direct" | "queue" | "unavailable"; * must never force this session's prompt into the queue. */ export function deriveSessionInputMode( - session: Pick | null | undefined, + session: + | Pick + | null + | undefined, ): SessionInputMode { if (!session) return "unavailable"; if ( @@ -21,5 +24,12 @@ export function deriveSessionInputMode( } if (session.state === "STARTING") return "queue"; if (session.state !== "RUNNING") return "unavailable"; - return session.foreground_activity === "background" ? "direct" : "queue"; + if (session.foreground_activity === "background") return "direct"; + // A generating RUNNING session normally queues. When the connected agent can + // steer (supports_steering), the send is instead delivered into the running + // turn, so it is direct rather than queued. Whether the agent folds it or runs + // it next is the agent's call and not distinguishable here — the composer copy + // promises delivery, not folding (see mid-turn-steering spec). + if (session.supports_steering) return "direct"; + return "queue"; } diff --git a/apps/web/hooks/domains/session/use-session-state.test.ts b/apps/web/hooks/domains/session/use-session-state.test.ts index 98c910b2f0..83f219ff5f 100644 --- a/apps/web/hooks/domains/session/use-session-state.test.ts +++ b/apps/web/hooks/domains/session/use-session-state.test.ts @@ -34,7 +34,7 @@ vi.mock("@/hooks/use-task", () => ({ useTask: (id: string | null) => (id ? mockTask : null), })); -import { useSessionState } from "./use-session-state"; +import { deriveSessionFlags, useSessionState } from "./use-session-state"; const createMockSession = ( id: string, @@ -254,3 +254,43 @@ describe("useSessionState — fine-grained busy signal (foreground_activity)", ( expect(result.current.isAgentBusy).toBe(false); }); }); + +// deriveSessionFlags is the pure source of the composer's steer contract; these +// exercise supportsSteering directly (the hook exposes the same value) so it +// cannot drift from inputMode. +describe("deriveSessionFlags — supportsSteering", () => { + const running = ( + foreground: TaskSession["foreground_activity"], + supports: boolean | undefined, + ): TaskSession => + ({ + ...createMockSession("session-1", "task-1", "RUNNING"), + foreground_activity: foreground, + supports_steering: supports, + }) as TaskSession; + + it("is true only for RUNNING + generating + advertised capability", () => { + const flags = deriveSessionFlags(running("generating", true)); + expect(flags.supportsSteering).toBe(true); + // A steer-eligible session sends directly rather than queueing. + expect(flags.inputMode).toBe("direct"); + expect(flags.isAgentBusy).toBe(false); + }); + + it("is false when generating but the capability was not advertised", () => { + const flags = deriveSessionFlags(running("generating", false)); + expect(flags.supportsSteering).toBe(false); + // Without steering a generating turn still gates the composer. + expect(flags.inputMode).toBe("queue"); + }); + + it("is false for RUNNING + background even when advertised (handoff owns it)", () => { + const flags = deriveSessionFlags(running("background", true)); + expect(flags.supportsSteering).toBe(false); + expect(flags.isWorking).toBe(true); + }); + + it("is false for RUNNING + background without the capability", () => { + expect(deriveSessionFlags(running("background", false)).supportsSteering).toBe(false); + }); +}); diff --git a/apps/web/hooks/domains/session/use-session-state.ts b/apps/web/hooks/domains/session/use-session-state.ts index 56b6f3738d..4ae7db6ac5 100644 --- a/apps/web/hooks/domains/session/use-session-state.ts +++ b/apps/web/hooks/domains/session/use-session-state.ts @@ -20,12 +20,25 @@ export function deriveSessionFlags(session: TaskSession | null | undefined) { const hasBackgroundWork = session?.foreground_activity === "background"; const inputMode = deriveSessionInputMode(session); const isAgentBusy = inputMode === "queue"; + // supportsSteering is true only when a send would be delivered into a + // still-generating turn: RUNNING, generating (so it would otherwise queue), + // and the connected agent advertised the capability. The composer uses it to + // promise delivery-now rather than a queued follow-up. + const supportsSteering = isRunning && !hasBackgroundWork && !!session?.supports_steering; // `isWorking` drives the spinner/affordance: any live turn (generating OR // background-idle) plus STARTING — it must stay up through (b). const isWorking = isStarting || isRunning || hasBackgroundWork; const isFailed = state === "FAILED" || state === "CANCELLED"; const needsRecovery = state === "WAITING_FOR_INPUT" && !!errorMessage; - return { inputMode, isStarting, isWorking, isAgentBusy, isFailed, needsRecovery }; + return { + inputMode, + isStarting, + isWorking, + isAgentBusy, + supportsSteering, + isFailed, + needsRecovery, + }; } type UseSessionStateOptions = { diff --git a/apps/web/lib/state/slices/features/types.ts b/apps/web/lib/state/slices/features/types.ts index 4c63b6d5f5..18afdc4a5e 100644 --- a/apps/web/lib/state/slices/features/types.ts +++ b/apps/web/lib/state/slices/features/types.ts @@ -11,6 +11,7 @@ export const defaultFeatureFlags = { appStatusBar: false, auth: false, claudeBackgroundPromptHandoff: false, + claudeMidTurnSteering: false, } as const; export type FeatureName = keyof typeof defaultFeatureFlags; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index fe3e0cc442..17bca0cd89 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -290,6 +290,8 @@ export type TaskSessionStateChangedPayload = { cancellation_pending?: boolean; /** Process-local cancellation transition generation carried by state snapshots. */ cancellation_revision?: number; + /** True when a send right now would steer the running turn; see http.ts. */ + supports_steering?: boolean; }; /** @@ -302,6 +304,8 @@ export type TaskSessionActivityChangedPayload = { session_id: string; foreground_activity: ForegroundActivity | null; active_subagent_count: number; + /** True when a send right now would steer the running turn; see http.ts. */ + supports_steering?: boolean; }; export type TaskSessionCancellationChangedPayload = { diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 62242684b9..0bf4c115f7 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -449,6 +449,13 @@ export type TaskSession = ActiveSubagentCountFields & { cancellation_revision?: number; /** Fine-grained busy substate; background may outlive the foreground turn (ADR-0049). */ foreground_activity?: ForegroundActivity | null; + /** + * True when a send right now would be delivered into the still-generating turn + * (mid-turn steering) rather than blocked/queued. Live, derived from the + * connected agent's negotiated capability plus the runtime flag; never + * persisted. The composer uses it to promise delivery, not folding. + */ + supports_steering?: boolean; /** Compact pending-input projection used when this session's messages are unloaded. */ pending_action?: TaskPendingAction | null; error_message?: string; diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index 03bbc3078f..4f101abed8 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -213,34 +213,48 @@ export function isStaleSessionStateEvent( return payloadTime < existingTime; } +// Fields carried onto the update object verbatim whenever the payload defines +// them (undefined = key omitted so it never clobbers live client state). +// `name` is present here so a rename event's cleared label ("") still applies; +// foreground_activity/active_subagent_count carry the ADR-0049 activity +// substate; supports_steering carries the live steer-eligibility flip so the +// composer can switch affordance without a refetch; cancellation_* carry the +// backend-owned cancellation projection. +const CARRIED_WHEN_DEFINED = [ + "review_status", + "error_message", + "is_passthrough", + "name", + "foreground_activity", + "active_subagent_count", + "supports_steering", + "cancellation_pending", + "cancellation_revision", +] as const; + +/** Copy each CARRIED_WHEN_DEFINED field onto `update` only when the payload defines it. */ +function carryDefinedFields( + update: Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload: any, +): void { + for (const key of CARRIED_WHEN_DEFINED) { + if (payload[key] !== undefined) update[key] = payload[key]; + } +} + /** Build a session update object from the state_changed payload. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function buildSessionUpdate(payload: any): Record { const update: Record = {}; if (payload.new_state) update.state = payload.new_state; if (payload.agent_profile_id) update.agent_profile_id = payload.agent_profile_id; - if (payload.review_status !== undefined) update.review_status = payload.review_status; - if (payload.error_message !== undefined) update.error_message = payload.error_message; if (payload.agent_profile_snapshot) update.agent_profile_snapshot = payload.agent_profile_snapshot; - if (payload.is_passthrough !== undefined) update.is_passthrough = payload.is_passthrough; if (payload.session_metadata !== undefined) update.metadata = payload.session_metadata; - // Apply only when the key is present: rename events always carry `name` - // (including "" for a cleared label); other session events omit it. - if (payload.name !== undefined) update.name = payload.name; if (payload.task_environment_id) update.task_environment_id = payload.task_environment_id; if (payload.updated_at) update.updated_at = payload.updated_at; - // Carry the authoritative activity value across coarse transitions. A new - // foreground turn resets it to generating; settled detached work may remain - // background (ADR-0049). - if (payload.foreground_activity !== undefined) - update.foreground_activity = payload.foreground_activity; - if (payload.active_subagent_count !== undefined) - update.active_subagent_count = payload.active_subagent_count; - if (payload.cancellation_pending !== undefined) - update.cancellation_pending = payload.cancellation_pending; - if (payload.cancellation_revision !== undefined) - update.cancellation_revision = payload.cancellation_revision; + carryDefinedFields(update, payload); return update; } @@ -570,10 +584,8 @@ function applyForegroundActivity( started_at: existing.started_at ?? "", updated_at: existing.updated_at ?? "", foreground_activity: payload.foreground_activity ?? null, - active_subagent_count: - payload.active_subagent_count !== undefined - ? payload.active_subagent_count - : (existing.active_subagent_count ?? 0), + active_subagent_count: pickActiveSubagentCount(payload, existing), + supports_steering: pickSupportsSteering(payload, existing), }); } @@ -603,6 +615,23 @@ function applyCancellationPending( }); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function pickActiveSubagentCount(payload: any, existing: TaskSession): number { + return payload.active_subagent_count !== undefined + ? payload.active_subagent_count + : (existing.active_subagent_count ?? 0); +} + +function pickSupportsSteering( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload: any, + existing: TaskSession, +): boolean | undefined { + return payload.supports_steering !== undefined + ? payload.supports_steering + : existing.supports_steering; +} + function handleWorkspaceSourcesUpdated( store: StoreApi, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/web/src/locales/en/chat.json b/apps/web/src/locales/en/chat.json index 7c102dcfa7..ee358e0dc4 100644 --- a/apps/web/src/locales/en/chat.json +++ b/apps/web/src/locales/en/chat.json @@ -105,6 +105,7 @@ "thisActionCannotBeUndone": "This action cannot be undone.", "turnGroupToolCalls_one": "{{count}} tool call", "turnGroupToolCalls_other": "{{count}} tool calls", + "composerSteerPlaceholder": "Send now — delivered to the running turn", "unknownError": "Unknown error", "useWorkspaceDefault": "Use workspace default...", "useWorkspaceDefaultOption": "Use workspace default" diff --git a/apps/web/src/locales/pseudo/chat.json b/apps/web/src/locales/pseudo/chat.json index 8e61593253..87cebb26df 100644 --- a/apps/web/src/locales/pseudo/chat.json +++ b/apps/web/src/locales/pseudo/chat.json @@ -105,6 +105,7 @@ "thisActionCannotBeUndone": "Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.", "turnGroupToolCalls_one": "{{count}} ţōōĺ ćàĺĺ", "turnGroupToolCalls_other": "{{count}} ţōōĺ ćàĺĺś", + "composerSteerPlaceholder": "Śēńď ńōŵ — ďēĺĩvēŕēď ţō ţĥē ŕũńńĩńĝ ţũŕń", "unknownError": "Ũńķńōŵń ēŕŕōŕ", "useWorkspaceDefault": "Ũśē ŵōŕķśƥàćē ďēƒàũĺţ...", "useWorkspaceDefaultOption": "Ũśē ŵōŕķśƥàćē ďēƒàũĺţ" diff --git a/apps/web/src/locales/zh-cn/chat.json b/apps/web/src/locales/zh-cn/chat.json index 9ddd4cd732..b3da9817bc 100644 --- a/apps/web/src/locales/zh-cn/chat.json +++ b/apps/web/src/locales/zh-cn/chat.json @@ -52,5 +52,19 @@ "providerQuotaResetUnknown": "请在提供商恢复可用容量后重试。", "providerQuotaProviderFallback": "提供商", "turnGroupToolCalls_one": "{{count}} 次工具调用", - "turnGroupToolCalls_other": "{{count}} 次工具调用" + "turnGroupToolCalls_other": "{{count}} 次工具调用", + "attachmentUploadPending": "正在上传附件…", + "attachmentUploadPendingSubmit": "请等待附件上传完成后再发送。", + "attachmentUploadFailed": "附件上传失败", + "retryAttachmentUpload": "重试上传附件", + "attachmentPreviewAlt": "预览", + "attachmentMessageAlt": "附件 {{index}}", + "attachmentFallbackName": "附件", + "attachmentPreviewFullAlt": "全尺寸预览", + "attachmentDeliveryMode": "附件传递方式", + "attachmentDeliveryPrompt": "提示词", + "attachmentDeliveryFile": "文件", + "attachmentDeliveryPromptDescription": "作为提示词上下文发送,供智能体识别图像内容。智能体不会获得文件路径。", + "attachmentDeliveryPathDescription": "上传到工作区,使智能体可以读取或编辑该文件。", + "composerSteerPlaceholder": "立即发送 — 将送入正在进行的回合" } diff --git a/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md index e1ec4bd0dc..f54b8536b3 100644 --- a/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md +++ b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md @@ -17,7 +17,7 @@ Because both read as `RUNNING`, a session that kicked off a long background job Upstream idle-turn completion narrows but does not close this window: a synthetic turn-complete only fires after async content has been idle for a debounce interval, it never arms while the foreground prompt exchange is still in flight (a genuinely held-open subagent turn), and a chatty Monitor re-extends the debounce on every event burst. The residual lockout windows are real. -Mid-turn steering — delivering a message *into* a turn while the model is actively generating — is explicitly out of scope here; it needs ACP concurrent-prompt support and per-agent capability gating and is tracked separately. +Mid-turn steering — delivering a message *into* a turn while the model is actively generating — is explicitly out of scope here; it needs ACP concurrent-prompt support and per-agent capability gating and is tracked separately. **(Superseded for the steer-capable case by [ADR-2026-08-04-mid-turn-steering](2026-08-04-mid-turn-steering.md): the concurrent-prompt precondition is satisfied by the shipped Claude bridge, and the negotiated capability gate is implemented there.)** ## Decision diff --git a/docs/decisions/2026-08-04-mid-turn-steering.md b/docs/decisions/2026-08-04-mid-turn-steering.md new file mode 100644 index 0000000000..fc2e116a0d --- /dev/null +++ b/docs/decisions/2026-08-04-mid-turn-steering.md @@ -0,0 +1,126 @@ +# ADR-2026-08-04-mid-turn-steering: Deliver Operator Input Into a Generating Turn + +**Status:** accepted +**Date:** 2026-08-04 +**Area:** backend, frontend, protocol + +## Context + +While a Claude session is genuinely generating, an operator who spots a wrong +turn can only queue a message (it lands after the decision it would have +prevented) or cancel (losing the turn's work). Claude Code's own terminal and +desktop surfaces accept typed input during generation and act on it at the next +tool boundary. Kandev could not, so operators arriving from those surfaces +experienced it as worse at the moment correction matters most. + +Two prior decisions bracketed this. [ADR 0049](0049-fine-grained-foreground-idle-busy-signal.md) +recorded that true mid-turn steering "needs ACP concurrent-prompt support and +per-agent capability gating and is tracked separately." The +[coarse-running ADR](2026-07-28-coarse-running-busy-signal.md) then made every +`RUNNING` session busy-by-default behind the `claudeBackgroundPromptHandoff` +experiment. + +Direct investigation of the shipped stack changed the premise. The Claude CLI +folds a stream-json message pushed mid-turn into the running turn at the next +tool boundary (one `result` answered two prompts; the model obeyed the second). +The `@agentclientprotocol/claude-agent-acp` bridge accepts a concurrent +`session/prompt` and hands the turn off: the predecessor RPC resolves `end_turn` +before the steered answer exists and with zeroed usage, while the answer and the +real usage land on the successor. Background work launched in the predecessor +survives the boundary and can later wake a model cycle with no prompt in flight. +All three are captured as wire fixtures under +`apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/`. + +So the concurrent-prompt precondition ADR 0049 named is already satisfied by the +shipped bridge, and the missing piece is a *trigger* that fires while the +foreground is still generating — `markPromptHandoff` previously fired only on +`EventTypeForegroundIdle`. + +## Decision + +Add mid-turn steering as an additive, flag-gated delivery path. + +- **Trigger.** The operator's send is itself the handoff trigger. A steer + transfers the existing prompt-gate token to the successor while the + predecessor `session/prompt` is still open, reusing the ADR-0049 machinery + (`tryTransferPromptTurn`, `claimPromptTurnCompletion`, + `protectActiveBackgroundWorkForHandoff`) rather than adding a second + concurrency path. Synthetic `ScheduleWakeup` prompts can neither trigger nor + consume a steer. + +- **Attribution.** The predecessor's early `end_turn` must not be presented as + its own answer or emit a completion, and must not sweep tool ownership, + consume the successor's usage, or clear its trace. This is the same + generation-keyed suppression ADR 0049 specified, now reused for the + steer trigger. + +- **Capability gate is negotiated, not named.** Eligibility comes from the ACP + `initialize` advertisement `agentCapabilities._meta.claudeCode.promptQueueing`, + replacing the two shipped agent-name comparisons + (`supportsPromptHandoff`, `claudeBackgroundPromptHandoffEnabledForSession`). + This aligns the code with ADR 0049's own rejected alternative — "an agent + identity does not prove that the installed adapter/provider version emits the + lifecycle frames Kandev expects" — and it re-gates the shipped + foreground-idle experiment on the same signal, narrowing eligibility rather + than widening it. + +- **Delivery is opportunistic; deferral is a first-class success.** The + advertisement asserts only that the agent accepts a concurrent prompt. Whether + the agent *folds* the prompt into the running turn is decided by the agent CLI + beneath the bridge, is not advertised over the protocol, and — critically — + the CLI's version is not observable over ACP (`agentInfo.version` is the + bridge's). An installation whose bridge is current but whose CLI is too old to + fold reports itself steer-capable and defers every steer. That is a supported + configuration: it degrades to exactly today's queue behavior. Kandev must not + gate on, infer, or warn about a CLI version it cannot see, and the composer + affordance therefore promises *delivery*, never folding. + +- **Ordering.** A steer is admitted only when the session has no queued messages + (it never jumps the queue) and at most one steer is in flight per session; a + second attempt queues. A steer does not take the foreground claim, which the + predecessor still owns. + +- **Flag.** Ships behind the default-off, high-risk, restart-required + `features.claudeMidTurnSteering` runtime toggle + (`KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING`), independent of + `claudeBackgroundPromptHandoff`, and retained as a kill-switch after rollout. + +This supersedes the mid-turn-steering non-goal in ADR 0049 for the +steer-capable case. The full behavior is specified in +[docs/specs/platform/mid-turn-steering.md](../specs/platform/mid-turn-steering.md). + +## Consequences + +Operators on a steer-capable, flag-enabled session can correct a running turn +without cancelling it. The composer switches from "queue" to a delivery +affordance, and the message reaches the agent immediately. + +The risk is real and is why the feature stays behind a kill-switch: the fold is +undocumented, marked `@internal` in the CLI, and not advertised over ACP, so it +can regress without a changelog entry. The mitigations are that the negotiated +gate asserts only the concurrent-prompt precondition, both outcomes (folded and +deferred) are specified as correct, the observed wire shapes are pinned as +fixtures, and two overlapping RPCs reuse the generation-keyed attribution that +already shipped for foreground-idle handoff. + +Public docs record the runtime flag: `docs/public/configuration.md` carries a +`claudeMidTurnSteering` row in both config-key tables, matching how +`claudeBackgroundPromptHandoff` is documented. The row states the default-off, +high-risk, experimental nature and that folding is the agent's decision. + +## Alternatives Considered + +- **Add a second gate slot / concurrency path.** Rejected: the bridge already + tolerates two overlapping prompts with one logical owner, so the work is + attribution and triggering, not concurrency. +- **Gate steering on a Claude CLI version floor.** Rejected: the CLI version is + not observable over ACP, and gating on the bridge version gates the wrong + component. Opportunistic delivery with a first-class deferred outcome is the + only honest contract. +- **Adopt an agent-specific steer primitive (e.g. Codex `turn/steer`).** + Rejected for this iteration: it would add a provider-specific protocol + surface, whereas the fold uses only the existing prompt method. Left open for + a future agent-specific build. +- **Keep the agent-name whitelist and just add steering.** Rejected: it would + perpetuate the exact anti-pattern ADR 0049 rejected and would trust a + name-matching bridge too old to advertise the capability. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 5cd1fd87a8..9d40f915d6 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -120,6 +120,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-04-file-backed-prompt-attachments | [File-backed prompt attachments](2026-08-04-file-backed-prompt-attachments.md) | accepted | backend, frontend, protocol, infra | 2026-08-04 | | 2026-08-04-plugin-contribution-lifecycle-authority | [Make Plugin Contribution Lifecycle Authoritative](2026-08-04-plugin-contribution-lifecycle-authority.md) | accepted | frontend, security | 2026-08-04 | | 2026-08-04-navigation-manifest-boundaries | [Centralize Navigation and Namespace Plugin Destinations](2026-08-04-navigation-manifest-boundaries.md) | accepted | frontend | 2026-08-04 | +| 2026-08-04-mid-turn-steering | [Deliver Operator Input Into a Generating Turn](2026-08-04-mid-turn-steering.md) | accepted | backend, frontend, protocol | 2026-08-04 | | 2026-08-04-remote-contribution-bindings | [Bind Remote Contributions to Target Repositories](2026-08-04-remote-contribution-bindings.md) | accepted | backend, protocol, security, GitHub, GitLab | 2026-08-04 | | 2026-08-03-scope-and-merge-repository-secrets | [Scope and Merge Repository Secrets](2026-08-03-scope-and-merge-repository-secrets.md) | accepted | backend, frontend, security, protocol | 2026-08-03 | | 2026-08-05-server-owned-quick-terminal-descriptors | [Server-Owned Quick Terminal Descriptors](2026-08-05-server-owned-quick-terminal-descriptors.md) | accepted | backend, frontend, protocol, security | 2026-08-05 | diff --git a/docs/plans/mid-turn-steering/assets/mid-turn-steering-composer.png b/docs/plans/mid-turn-steering/assets/mid-turn-steering-composer.png new file mode 100644 index 0000000000000000000000000000000000000000..4ea989ff4a08aca9a1a3e83ed8fbb9f30bf0b40f GIT binary patch literal 146894 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�K*h`$Z^34cqQssW}Z3(+KQlM(e$# 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 UIcrVpCXcVBp{t&)cKWyf0Vm@_3jhEB literal 0 HcmV?d00001 diff --git a/docs/plans/mid-turn-steering/plan.md b/docs/plans/mid-turn-steering/plan.md new file mode 100644 index 0000000000..295d73c505 --- /dev/null +++ b/docs/plans/mid-turn-steering/plan.md @@ -0,0 +1,242 @@ +--- +spec: docs/specs/platform/mid-turn-steering.md +created: 2026-08-04 +status: complete +--- + +# Implementation Plan: Mid-turn steering + +## Overview + +Deliver operator input into a turn that is still generating, for agents that +negotiate the capability, by extending the prompt-handoff machinery that already +exists for the foreground-idle case rather than adding a new concurrency path. + +The design rests on three observed protocol facts, each reproducible with the +probes committed alongside this plan: + +1. **The Claude CLI folds a queued stream-json message into a running turn.** On + 2.1.220, a message pushed mid-turn is acknowledged `queued` immediately, then + drains into the *running* turn at the next tool boundary (`started` 27.3s in, + before the single `result` at 37.5s). One `result` answered two prompts and + the model obeyed the second — it skipped the tool the first prompt had asked + for. Reproduce: `scripts/probe-claude-midturn-fold`. +2. **The ACP bridge accepts the concurrent `session/prompt` and hands the turn + off.** The predecessor's request resolves `end_turn` **3 ms after the tool + completes and 1.9 s before the steered answer exists**, with zeroed usage; + the answer and the real usage land on the successor. Reproduce: + `scripts/probe-acp-midturn-fold`. +3. **Background work survives the handoff boundary.** A `run_in_background` + shell outlived both prompt resolutions and, 64 s after the second resolved, + woke a cost-bearing model cycle with no prompt in flight. Its launch tool card + had reported `completed` 92 s before the workload actually finished, and the + workload's completion produced **no** `tool_call_update` — only assistant + text. Reproduce: the `--prompt-a` variant documented in that probe's header. + +Fact 2 is why this is primarily an *attribution* change, not a concurrency one. +Kandev already tolerates two overlapping `session/prompt` RPCs with one logical +owner: `promptGate` token transfer, `claimPromptTurnCompletion`, and +`protectActiveBackgroundWorkForHandoff` were built for exactly this in ADR-0049. +What is missing is a trigger that fires while the foreground is *generating* — +today `markPromptHandoff` only fires on `EventTypeForegroundIdle`. + +## Capability negotiation correction + +ADR-0049 rejects "a central agent-name whitelist" because "an agent identity +does not prove that the installed adapter/provider version emits the lifecycle +frames Kandev expects". Two shipped call sites are exactly that whitelist: + +- `adapter_prompt.go:supportsPromptHandoff` — `agentID == claudeAgentID || mockAgentID` +- `turn_activity.go:claudeBackgroundPromptHandoffEnabledForSession` — `agent_name == "claude-acp" || "mock-agent"` + +The ACP `initialize` response carries the correct negotiated signal, and the +adapter already retains it: `agentCapabilities._meta.claudeCode.promptQueueing: +true`, reachable as `a.capabilities.Meta` (the pinned `acp-go-sdk` exposes +`AgentCapabilities.Meta map[string]any`). Verified present on bridge 0.49.0. + +Note what this signal does and does not assert. It asserts the bridge accepts a +prompt while one is in flight — the necessary precondition. It does **not** +assert mid-turn folding, which is version-dependent, undocumented, marked +`@internal`, and **not visible over ACP at all** (`msg_lifecycle_v1` appears +zero times in both bridge logs; it is consumed inside the bridge). Therefore +delivery is opportunistic and both outcomes must be correct, per the spec. + +Replacing the whitelist is a prerequisite for this feature and simultaneously +brings the shipped foreground-idle experiment into line with its own ADR. It +narrows eligibility correctly rather than widening it: a bridge too old to +advertise the capability becomes ineligible instead of being trusted by name. + +### Why there is no compatibility branch + +The capability is advertised by the component that does not decide the +behavior, and the deciding component's version is invisible: + +| Component | Controlled by | Decides | Version visible to Kandev | +|---|---|---|---| +| Bridge `@agentclientprotocol/claude-agent-acp` | Kandev (`npx --yes --prefer-offline`, agent-runtime-update) | advertises `promptQueueing` | yes — ACP `agentInfo.version` (observed `0.49.0`) | +| CLI `claude` | the user's own PATH install (`Detect(WithCommand("claude"))`) | whether it folds mid-turn | **no** — `claude_code_version` appears zero times in both bridge transcripts | + +So a current bridge over a 2.1.175 CLI reports itself steer-capable and defers +every steer, and Kandev cannot detect that. The conclusion is not "add a +compatibility path" but "make the deferred outcome a first-class success", which +is what the spec requires. Old and new installations run the same code; only the +observed outcome differs. Two consequences bind the implementation: + +- **No version gate, floor, or warning.** Gating on the CLI is impossible and + gating on the bridge gates the wrong component. +- **The composer must promise delivery, not folding.** Any copy claiming the + message will steer the running turn is false on an old CLI. Task 07 owns this. + +If an installation's CLI version is ever wanted for diagnostics, that belongs in +agent discovery or `/doctor` as informational only, and is out of scope here. + +## Backend + +### Capability plumbing + +- Read `_meta.claudeCode.promptQueueing` in a focused helper beside + `supportsPromptHandoff` in + `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go`; + keep the mock provider eligible by having `apps/backend/cmd/mock-agent` + advertise the same `_meta`, so E2E exercises the negotiated path rather than a + bypass. +- Extend the existing `streams.EventTypeAgentCapabilities` event + (`apps/backend/internal/agentctl/types/streams/agent.go`, emitted at + `adapter.go:459`) with the negotiated steer capability, so the orchestrator + learns it over the existing channel instead of a new one. +- Record it on the in-memory session activity record in + `apps/backend/internal/orchestrator/turn_activity.go` and use it in place of + the `agent_name` comparison. + +### Steer admission + +- Add a steer intent to the prompt path + (`adapter_prompt.go` / `adapter_prompt_cancel.go`). When the connected agent + is steer-capable, a human prompt with a matching generation may transfer the + existing gate token while the predecessor RPC is still open — the same + transfer `tryTransferPromptTurn` performs today, triggered by the operator's + send instead of by a `ForegroundIdle` event. Reuse + `protectActiveBackgroundWorkForHandoff` unchanged: fact 3 makes it required, + not merely useful. +- Keep synthetic wakeups excluded. They already cannot observe `handoffCh`; the + steer path must not weaken that. +- Preserve `claimPromptTurnCompletion` semantics so the predecessor's early + `end_turn` cannot emit completion, sweep live tool ownership, consume the + successor's usage, or clear the successor's trace. + +### Orchestrator admission + +- Admit a steer while `RUNNING` in `checkSessionPromptable` / `PromptTask` + (`apps/backend/internal/orchestrator/`), keeping ADR-0049's check-and-claim + atomicity: a steer must not take the ordinary foreground claim, because the + predecessor still owns it. +- Enforce the order rule: steer only when the session has no queued messages; + otherwise queue. At most one unacknowledged steer per session. + +### Runtime toggle + +- `features.claudeMidTurnSteering` / + `KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING` through + `apps/backend/internal/runtimeflags/registry.go`, + `apps/backend/internal/common/config/config.go`, and + `apps/backend/internal/profiles/profiles.yaml` (`"false"` in prod, dev, e2e), + following `/runtime-feature-flags`. Registry completeness tests cover the + registry/profile/frontend contract. + +## Frontend + +- Surface `supports_steering` through the session DTO, boot payload, and the WS + session events; add it to the store slice and to + `apps/web/lib/state/slices/features/types.ts`. +- Composer distinguishes steer from queue before the operator sends. All new + copy goes through `t()` per the root i18n rule; add keys rather than reusing + an approximate existing key. + +## Fixtures and E2E + +- Commit the three probe transcripts as protocol fixtures and add an adapter + test that replays the fold-handoff sequence (early `end_turn` with zeroed + usage and no answer text, successor carrying the answer and the usage). + ADR-0049 admits handoff only for bridges that are *fixture-tested*; the probes + are the evidence but are not yet fixtures. +- Extend `apps/backend/cmd/mock-agent` to replay that sequence so E2E can drive + steer-vs-queue deterministically without a paid model call. +- E2E covers: steer delivered while generating; queue when the toggle is off; + queue when the agent does not advertise; order preserved when a message is + already queued. + +## Decision record + +Create an ADR for the steer-admission trigger and the attribution contract. It +supersedes the mid-turn-steering non-goal in ADR-0049 and records that the +capability gate is negotiated rather than name-based. + +## Risks + +- **The fold is undocumented and `@internal`.** It can regress without a + changelog entry; upstream docs still describe strictly sequential queueing. + Mitigation: the negotiated gate asserts only the concurrent-prompt + precondition, both outcomes are specified as correct, fixtures pin the + observed shape, and the toggle stays a kill-switch. +- **Version-pinned upstream claims age badly.** A contributor measured this as + impossible on claude 2.1.175; it works on 2.1.218–2.1.220. Mitigation: never + gate on a version string; gate on the negotiated capability and degrade. +- **Changing `supportsPromptHandoff` touches shipped experiment behavior.** + Mitigation: the change only narrows eligibility, and the foreground-idle path + keeps its own tests. +- **Attribution is the sharp edge.** A predecessor settlement mistaken for a + real completion corrupts transcript, usage, and tool state. Mitigation: the + generation-keyed suppression ADR-0049 already specifies, plus a fixture test + asserting no completion is emitted for the predecessor. +- **Two overlapping RPCs raise cancel complexity.** Mitigation: explicit cancel + scenario in the spec and a test that the gate is released. + +## Verification + +Run once per fresh worktree before anything else, per the root guide: + +```bash +cd apps && pnpm install --frozen-lockfile +``` + +- Backend: `cd apps/backend && go test -race ./internal/agentctl/... ./internal/orchestrator/... ./internal/runtimeflags/... ./internal/profiles/... ./internal/common/config/...` +- Backend lint: `make -C apps/backend lint` +- Web unit: `cd apps/web && pnpm test` +- Web types: `cd apps/web && pnpm run typecheck` +- Web lint: `cd apps/web && pnpm lint` +- i18n: `cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet` +- E2E: `cd apps/web && pnpm e2e` + +## Tasks + +| Task | Wave | Depends on | Status | +|---|---|---|---| +| [task-01-negotiate-steer-capability](task-01-negotiate-steer-capability.md) | 1 | — | done | +| [task-02-runtime-toggle](task-02-runtime-toggle.md) | 1 | — | done | +| [task-03-fold-handoff-fixtures](task-03-fold-handoff-fixtures.md) | 1 | — | done | +| [task-04-adapter-steer-admission](task-04-adapter-steer-admission.md) | 2 | 01, 03 | done | +| [task-05-orchestrator-steer-admission](task-05-orchestrator-steer-admission.md) | 3 | 01, 02, 04 | done | +| [task-06-session-steer-contract](task-06-session-steer-contract.md) | 3 | 01, 05 | done | +| [task-07-composer-steer-affordance](task-07-composer-steer-affordance.md) | 4 | 06 | done | +| [task-08-mock-agent-and-e2e](task-08-mock-agent-and-e2e.md) | 5 | 04, 05, 07 | done | +| [task-09-adr-and-verification](task-09-adr-and-verification.md) | 6 | all | done | + +## Assets + +- [`assets/mid-turn-steering-composer.png`](assets/mid-turn-steering-composer.png) — the + composer showing the steer-delivery affordance on a steer-eligible session. + +## Validation Results + +Re-run on 2026-08-04 against the branch merged with `main`. Per-task command +results are recorded in each task file's own `## Validation Results` section. + +- `cd apps/backend && go test -race ./internal/...`: passed for every package + this plan touches; three unrelated failures on this host are itemized and + attributed in `task-09-adr-and-verification.md`. +- `make -C apps/backend lint`: `0 issues.` +- `cd apps/web && pnpm run typecheck && pnpm lint && pnpm run i18n:check && pnpm run i18n:ratchet`: all passed. +- `cd apps/web && pnpm test`: 8431 passed; the 6 failures are pre-existing on + clean `main` in files this plan does not touch. +- `cd apps/web && pnpm e2e`: CI-owned for this branch; green on head `33a4dc3` + across all 14 E2E shards and all 6 container shards. diff --git a/docs/plans/mid-turn-steering/task-01-negotiate-steer-capability.md b/docs/plans/mid-turn-steering/task-01-negotiate-steer-capability.md new file mode 100644 index 0000000000..b76978cd59 --- /dev/null +++ b/docs/plans/mid-turn-steering/task-01-negotiate-steer-capability.md @@ -0,0 +1,57 @@ +--- +id: "01-negotiate-steer-capability" +title: "Negotiate steer capability instead of matching agent names" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/platform/mid-turn-steering.md" +--- + +# Task 01: Negotiate steer capability instead of matching agent names + +- **Acceptance:** Steer/handoff eligibility is derived from the ACP `initialize` + response (`agentCapabilities._meta.claudeCode.promptQueueing == true`), not + from an agent id or persisted `agent_name`. An agent that does not advertise + it is ineligible even when its name matches; an agent that advertises it is + eligible without being named. The capability reaches the orchestrator over the + existing `EventTypeAgentCapabilities` event and is recorded on the in-memory + session activity record. +- **Acceptance:** A malformed or absent `_meta` (nil map, wrong types, non-bool + value) fails closed to ineligible without panicking. +- **Verification:** `cd apps/backend && go test -race ./internal/agentctl/server/adapter/transport/acp/... ./internal/agentctl/types/streams/... ./internal/orchestrator/...` +- **Files likely touched:** + `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go` + (replace `supportsPromptHandoff`'s `agentID ==` comparison), + `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go` + (capability already stored at `a.capabilities`; emit the new field near :459), + `apps/backend/internal/agentctl/types/streams/agent.go`, + `apps/backend/internal/orchestrator/turn_activity.go` (replace + `claudeBackgroundPromptHandoffEnabledForSession`'s `agent_name` comparison), + `apps/backend/cmd/mock-agent/` (advertise the same `_meta`), plus tests. +- **Dependencies:** None. +- **Inputs:** Spec "API surface" (agent capability) and the plan's "Capability + negotiation correction". ADR-0049's rejected alternative "Maintain a central + agent-name whitelist" is the rationale. The pinned `acp-go-sdk` exposes + `AgentCapabilities.Meta map[string]any`, so no SDK change is required; + `promptQueueing: true` is confirmed present on bridge 0.49.0. +- **Risks:** This also re-gates the shipped `claudeBackgroundPromptHandoff` + experiment. It only ever narrows eligibility, but the foreground-idle tests + must keep passing unchanged — treat any change in their behavior as a defect + in this task, not an expected consequence. +- **Output contract:** Report the capability-read helper's contract, the + fail-closed cases covered, that both former name-comparison sites are gone, + exact commands/results, and update only this task's status. + +## Validation Results + +Re-run on 2026-08-04 against the branch merged with `main`. + +- `cd apps/backend && go test -race ./internal/agentctl/server/adapter/transport/acp/... ./internal/agentctl/types/streams/... ./internal/orchestrator/...`: passed. +- Capability read: `acp/prompt_queueing.go` reads + `agentCapabilities._meta.claudeCode.promptQueueing` once at `initialize` and + caches it on the adapter. +- Fail-closed cases covered by test: nil `_meta`, wrong-typed `claudeCode`, + non-bool `promptQueueing`, and absent key — all resolve to ineligible without + panicking. +- Both former `agentID ==` name comparisons are gone; `grep -rn 'agentID ==' internal/agentctl/server/adapter/transport/acp/` returns no capability gate. diff --git a/docs/plans/mid-turn-steering/task-02-runtime-toggle.md b/docs/plans/mid-turn-steering/task-02-runtime-toggle.md new file mode 100644 index 0000000000..b8a77e9139 --- /dev/null +++ b/docs/plans/mid-turn-steering/task-02-runtime-toggle.md @@ -0,0 +1,55 @@ +--- +id: "02-runtime-toggle" +title: "Add the mid-turn steering runtime toggle" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/platform/mid-turn-steering.md" +--- + +# Task 02: Add the mid-turn steering runtime toggle + +- **Acceptance:** `features.claudeMidTurnSteering` exists in the typed runtime + flag registry with env var `KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING`, + experimental stability, high risk, restart-required, and is `"false"` in the + prod, dev, and e2e profiles. Effective-value precedence is explicit env > + SQLite override > profile default. +- **Acceptance:** The registry/profile/frontend completeness tests recognise the + new flag, and the frontend feature slice carries it defaulted to `false`. +- **Verification:** `cd apps/backend && go test -race ./internal/runtimeflags/... ./internal/profiles/... ./internal/common/config/...` then `cd apps/web && pnpm test` +- **Files likely touched:** + `apps/backend/internal/runtimeflags/registry.go`, + `apps/backend/internal/common/config/config.go`, + `apps/backend/internal/profiles/profiles.yaml`, + `apps/backend/internal/backendapp/orchestrator.go` (wire into the service + config alongside `ClaudeBackgroundPromptHandoff`), + `apps/web/lib/state/slices/features/types.ts`, plus tests. +- **Dependencies:** None. +- **Inputs:** Spec "API surface" (runtime toggle) and `/runtime-feature-flags` + for the file-by-file checklist. `features.claudeBackgroundPromptHandoff` is the + reference registration to mirror, including its risk-description style. +- **Risks:** Do not reuse or rename the existing handoff flag; the two triggers + are independent and must be independently killable. Never remove a registry + entry to make a build pass. +- **Output contract:** Report the registration, the three profile values, the + precedence test evidence, exact commands/results, and update only this task's + status. + +## Validation Results + +Re-run on 2026-08-04 against the branch merged with `main`. + +- `cd apps/backend && go test -race ./internal/runtimeflags/... ./internal/profiles/... ./internal/common/config/...`: passed. +- `cd apps/web && pnpm test`: passed for every steering-related suite. The full + run reports 8431 passed / 6 failed, and all 6 failures are pre-existing on + clean `main` in files this PR does not touch + (`lib/http-git-server.test.ts`, which needs a running Docker bridge, and + `hooks/domains/settings/use-automation-runs.test.ts`). +- Registration: `features.claudeMidTurnSteering` in + `runtimeflags/registry.go`, env `KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING`, + high-risk, restart-required. +- Profile values: `prod: "false"`, `dev: "false"`, `e2e: "false"` — off in all + three shipped profiles. +- Precedence (explicit env > SQLite override > profile default) is covered by + the existing registry/config tests, which pass unchanged. diff --git a/docs/plans/mid-turn-steering/task-03-fold-handoff-fixtures.md b/docs/plans/mid-turn-steering/task-03-fold-handoff-fixtures.md new file mode 100644 index 0000000000..f07499fbf4 --- /dev/null +++ b/docs/plans/mid-turn-steering/task-03-fold-handoff-fixtures.md @@ -0,0 +1,51 @@ +--- +id: "03-fold-handoff-fixtures" +title: "Capture fold-handoff wire fixtures" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/platform/mid-turn-steering.md" +--- + +# Task 03: Capture fold-handoff wire fixtures + +- **Acceptance:** The observed fold-handoff sequence exists as a committed + fixture and an adapter test replays it: predecessor `session/prompt` resolves + `end_turn` with zeroed usage and no answer text, the answer and the real usage + arrive on the successor prompt, and only one `sleep` tool call occurs. +- **Acceptance:** A second fixture covers the background variant — a + `run_in_background` launch card reporting `completed` long before the workload + ends, and the workload's completion arriving as assistant text with **no** + `tool_call_update` while no prompt is in flight. +- **Verification:** `cd apps/backend && go test -race ./internal/agentctl/server/adapter/transport/acp/...` +- **Files likely touched:** + `apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/` + (new fixture files alongside `acp-messages.jsonl`), a focused replay test in + the same package. +- **Dependencies:** None. This task is evidence capture only and must not change + adapter behavior. +- **Inputs:** `scripts/probe-acp-midturn-fold` produces both transcripts; its + header documents the background invocation. Recorded runs are available and + reproducible. ADR-0049 admits handoff only for bridges that are + *fixture-tested* — the probes are evidence, these fixtures are the contract. + Existing `testdata/acp-messages.jsonl` uses a `{key, input, expected}` shape + for normalization cases; a raw frame transcript is a different shape, so add a + new fixture form rather than forcing it into that one. +- **Risks:** Fixtures pin an undocumented, `@internal` upstream behavior that can + change without a changelog entry. Assert the *shape* the adapter must survive + (early settle, zeroed usage, later text) rather than exact timings, token + counts, tool ids, or session ids. +- **Output contract:** Report the fixture paths and shape, which spec failure + modes each one pins, exact commands/results, and update only this task's + status. + +## Validation Results + +Re-run on 2026-08-04 against the branch merged with `main`. + +- `cd apps/backend && go test -race ./internal/agentctl/server/adapter/transport/acp/...`: passed. +- Fixtures live under + `apps/backend/internal/agentctl/server/adapter/transport/acp/testdata/` and are + replayed by `fold_handoff_fixture_test.go`, pinning the observed fold-handoff + wire shape and background-work survival across the turn boundary. diff --git a/docs/plans/mid-turn-steering/task-04-adapter-steer-admission.md b/docs/plans/mid-turn-steering/task-04-adapter-steer-admission.md new file mode 100644 index 0000000000..02739fd601 --- /dev/null +++ b/docs/plans/mid-turn-steering/task-04-adapter-steer-admission.md @@ -0,0 +1,65 @@ +--- +id: "04-adapter-steer-admission" +title: "Admit a steer while the foreground is generating" +status: done +wave: 2 +depends_on: ["01-negotiate-steer-capability", "03-fold-handoff-fixtures"] +plan: "plan.md" +spec: "../../specs/platform/mid-turn-steering.md" +--- + +# Task 04: Admit a steer while the foreground is generating + +- **Acceptance:** A human prompt carrying a steer intent transfers the existing + prompt-gate token to itself while the predecessor `session/prompt` is still + open, for a steer-capable agent, without waiting for an + `EventTypeForegroundIdle` event and without cancelling the predecessor. A + synthetic (`ScheduleWakeup`, generation 0) prompt can neither trigger nor + consume that transfer. +- **Acceptance:** When the predecessor then settles early, it emits no + completion, does not sweep tool ownership that was live at the boundary, does + not consume the successor's usage, and does not clear the successor's trace. + Usage is recorded once, against the turn the agent reported it on. +- **Acceptance:** Cancelling during an in-flight steer ends both the predecessor + and the steer and releases the gate; a session replaced by + `session/new`/`session/load` drops a waiting steer instead of delivering it. +- **Verification:** `cd apps/backend && go test -race ./internal/agentctl/server/adapter/transport/acp/...` +- **Files likely touched:** + `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go`, + `adapter_prompt_cancel.go` (extend `newPromptTurnState` / + `acquirePromptTurn` / `tryTransferPromptTurn`; reuse + `claimPromptTurnCompletion`, `finishPromptTurn`, + `invalidatePromptTurnOwnership`, `protectActiveBackgroundWorkForHandoff` + unchanged), `adapter.go`, `prompt_handoff_test.go` and new focused tests. +- **Dependencies:** Task 01 for the negotiated gate; task 03 for the replay + fixture this task's tests assert against. +- **Inputs:** Spec "State machine" (`sent` → `folded` | `deferred`) and "Failure + modes". Plan facts 2 and 3: the bridge settles the predecessor 3 ms after the + tool completes and 1.9 s before the answer exists, and background work + survives the boundary — so tool-ownership protection is required, not + optional. ADR-0049 already specifies the generation-keyed suppression rule to + reuse verbatim. +- **Risks:** This is the sharpest edge in the feature. A predecessor settlement + mistaken for a real completion corrupts transcript, usage, and tool state. + Keep the existing foreground-idle handoff tests passing without modification; + if they need edits, the abstraction is wrong. Two overlapping RPCs also make + cancel ordering subtle — assert the gate is released in every terminal path. +- **Output contract:** Report the steer transfer trigger, how predecessor + finalization is suppressed, the cancel and session-replacement behavior, + `-race` results, and update only this task's status. + +## Validation Results + +Re-run on 2026-08-04 against the branch merged with `main`. + +- `cd apps/backend && go test -race ./internal/agentctl/server/adapter/transport/acp/...`: passed. +- Steer transfer trigger: `beginSteerHandoff` (`adapter_prompt_cancel.go`) hands + the in-flight turn off at the operator's send; the successor then inherits the + gate token via `tryTransferPromptTurn`. Synthetic (generation 0) prompts set + `allowHandoff=false` and can neither trigger nor consume the transfer. +- Predecessor finalization is suppressed by `claimPromptTurnCompletion`, which + refuses a turn that no longer owns the gate, and by + `protectActiveBackgroundWorkForHandoff`, which keeps the predecessor's live + background work out of the successor's prompt-end sweep. +- Cancel and session-replacement behavior is covered by the adapter tests; + `finishPromptTurn` releases the gate on every exit path, handed off or not. diff --git a/docs/plans/mid-turn-steering/task-05-orchestrator-steer-admission.md b/docs/plans/mid-turn-steering/task-05-orchestrator-steer-admission.md new file mode 100644 index 0000000000..f091aabc87 --- /dev/null +++ b/docs/plans/mid-turn-steering/task-05-orchestrator-steer-admission.md @@ -0,0 +1,49 @@ +--- +id: "05-orchestrator-steer-admission" +title: "Admit and order steers in the orchestrator" +status: done +wave: 3 +depends_on: ["01-negotiate-steer-capability", "02-runtime-toggle", "04-adapter-steer-admission"] +plan: "plan.md" +spec: "../../specs/platform/mid-turn-steering.md" +--- + +# Task 05: Admit and order steers in the orchestrator + +- **Acceptance:** A `RUNNING`, generating session accepts a direct prompt as a + steer when the agent is steer-capable and the toggle is enabled; every other + coarse state follows the existing admission table unchanged. With the toggle + off, or an agent that does not advertise the capability, admission is exactly + today's queue behavior. +- **Acceptance:** Order is preserved — while the session has any queued message, + new input queues rather than steering. At most one unacknowledged steer exists + per session; a second attempt queues. +- **Acceptance:** Admission stays check-and-claim under one lock, and a steer + does not take the ordinary foreground claim that the predecessor still holds. + Two concurrent steer attempts result in exactly one steer. +- **Verification:** `cd apps/backend && go test -race ./internal/orchestrator/...` +- **Files likely touched:** + `apps/backend/internal/orchestrator/turn_activity.go`, + `task_operations.go` (prompt admission and the queue/drain path), + `service.go` (config field), plus focused tests including a concurrency test. +- **Dependencies:** Tasks 01, 02, 04. +- **Inputs:** Spec "What" (order rule, single in-flight steer), "State machine", + and the admission table in + `../../specs/platform/background-work-liveness.md`. ADR-0049's + check-and-claim requirement and its `ErrAgentPromptInProgress` loser behavior + are the precedent for the concurrency rule. +- **Risks:** The existing `QueueAndInterruptForPeerMessage` path + (`task_operations.go`) is a *different* mechanism — parent→child MCP interrupt + delivery — and must not be conflated with steering or altered here. +- **Output contract:** Report the admission predicate, the order and + single-steer rules, the concurrency test evidence, `-race` results, and update + only this task's status. + +## Validation Results + +Re-run on 2026-08-04 against the branch merged with `main`. + +- `cd apps/backend && go test -race ./internal/orchestrator/...`: passed. +- Admission predicate, order rule (never jumps a non-empty queue) and the + single-in-flight-steer rule are covered by `orchestrator/steer_test.go`, + including its concurrent-submission case, under `-race`. diff --git a/docs/plans/mid-turn-steering/task-06-session-steer-contract.md b/docs/plans/mid-turn-steering/task-06-session-steer-contract.md new file mode 100644 index 0000000000..b1a8664440 --- /dev/null +++ b/docs/plans/mid-turn-steering/task-06-session-steer-contract.md @@ -0,0 +1,51 @@ +--- +id: "06-session-steer-contract" +title: "Expose supports_steering on the session contract" +status: done +wave: 3 +depends_on: ["01-negotiate-steer-capability", "05-orchestrator-steer-admission"] +plan: "plan.md" +spec: "../../specs/platform/mid-turn-steering.md" +--- + +# Task 06: Expose supports_steering on the session contract + +- **Acceptance:** Session REST DTOs, the boot payload, `session.state_changed`, + and `session.activity_changed` all carry `supports_steering` as a boolean. It + is true only when the connected agent advertised the capability, the toggle is + enabled, and the session is promptable-or-generating; false or absent + otherwise. +- **Acceptance:** The value is derived at serialization time from live + connection state and is never persisted; after a restart with no connected + execution it is false. +- **Verification:** `cd apps/backend && go test -race ./internal/orchestrator/... ./internal/backendapp/...` then `cd apps/web && pnpm run typecheck && pnpm test` +- **Files likely touched:** the session DTO/serializer and WS event payloads + under `apps/backend/internal/orchestrator/` and + `apps/backend/internal/backendapp/`, the generated/shared types consumed by + the web app, and the session store slice under `apps/web/lib/state/slices/`. +- **Dependencies:** Tasks 01 and 05. +- **Inputs:** Spec "API surface" and "Persistence guarantees". Mirror how + `foreground_activity` and `active_subagent_count` are surfaced across the same + four payloads in `../../specs/platform/background-work-liveness.md` — same + shape, same publication points, so clients cannot observe an inconsistent + pair. +- **Risks:** Do not persist the flag or derive it from the persisted agent + profile snapshot; that would reintroduce the name-based gate task 01 removes + and would survive a restart as a false positive. +- **Output contract:** Report the field name and type on each of the four + payloads, the derivation predicate, restart behavior, exact commands/results, + and update only this task's status. + +## Validation Results + +Re-run on 2026-08-04 against the branch merged with `main`. + +- `cd apps/backend && go test -race ./internal/orchestrator/... ./internal/backendapp/...`: passed. +- `cd apps/web && pnpm run typecheck`: passed. +- `cd apps/web && pnpm test`: passed for every steering-related suite; the 6 + failures in the full run are pre-existing on clean `main` in untouched files + (see task-02's record). +- Contract: `supports_steering` (bool) is carried on the session DTO and the + session-scoped WS payloads, derived from flag ∧ negotiated capability ∧ + RUNNING-and-generating. It is a runtime projection, never persisted, so a + restart re-derives it rather than resurrecting a stale value. diff --git a/docs/plans/mid-turn-steering/task-07-composer-steer-affordance.md b/docs/plans/mid-turn-steering/task-07-composer-steer-affordance.md new file mode 100644 index 0000000000..90bf661998 --- /dev/null +++ b/docs/plans/mid-turn-steering/task-07-composer-steer-affordance.md @@ -0,0 +1,56 @@ +--- +id: "07-composer-steer-affordance" +title: "Show a delivery affordance in the composer" +status: done +wave: 4 +depends_on: ["06-session-steer-contract"] +plan: "plan.md" +spec: "../../specs/platform/mid-turn-steering.md" +--- + +# Task 07: Show a delivery affordance in the composer + +- **Acceptance:** While a session reports `supports_steering` and is generating + with an empty queue, the composer indicates the message will be **delivered + now rather than held**, and submitting sends it. When `supports_steering` is + false, or a message is already queued, the composer keeps today's queue + affordance and today's behavior byte-for-byte. +- **Acceptance:** The copy commits only to delivery, never to folding into the + running turn — the agent CLI underneath may defer it, and that outcome renders + as an ordinary queued message arriving, with no error and no version warning. +- **Acceptance:** All new copy goes through `t()` / `` with new keys; no + hardcoded literal, no `t()` at module scope, no English plural ending passed + as a value, and no translated string compared with `===`. +- **Verification:** `cd apps/web && pnpm run typecheck && pnpm lint && pnpm test && pnpm run i18n:check && pnpm run i18n:ratchet` +- **Files likely touched:** the chat composer component and its hook under + `apps/web/components/`, the session store selector under + `apps/web/lib/state/slices/`, and the relevant locale resource files. +- **Dependencies:** Task 06. +- **Inputs:** Spec "What" (the delivery-not-folding promise) and the plan's "Why + there is no compatibility branch" — the honesty constraint is the reason this + task exists as its own step. Root `CLAUDE.md` i18n rules are enforced by + pre-commit and CI, not advisory. +- **Risks:** A SCREAMING_CASE config table of labels passes the i18n lint rule + silently — review any such table by eye. Check the result in the pseudo-locale + (Settings → General → Appearance in a dev build); a clean lint is not proof the + strings are localized. +- **Output contract:** Report the affordance states and their conditions, the new + i18n keys, the pseudo-locale check, exact commands/results, and update only + this task's status. + +## Validation Results + +Re-run on 2026-08-04 against the branch merged with `main`. + +- `cd apps/web && pnpm run typecheck`: passed. +- `cd apps/web && pnpm lint`: passed (`eslint --max-warnings 0`). +- `cd apps/web && pnpm test`: passed for every steering-related suite — + `use-chat-input-container.test.ts` (7/7, including the steer-placeholder + precedence case) and `session-input-mode.test.ts`. The 6 failures in the full + run are pre-existing on clean `main` in untouched files (see task-02's record). +- `cd apps/web && pnpm run i18n:check`: passed — keys OK, `` indices OK, + no inline English plurals, no module-scope `t()`. +- `cd apps/web && pnpm run i18n:ratchet`: passed — 0 added + 9 modified files + clean, guard allowlist intact (243 entries). +- New key: `chat:composerSteerPlaceholder`, present in `en` and in sync in the + pseudo-locale (confirmed by `i18n:check`). diff --git a/docs/plans/mid-turn-steering/task-08-mock-agent-and-e2e.md b/docs/plans/mid-turn-steering/task-08-mock-agent-and-e2e.md new file mode 100644 index 0000000000..d6bd729b42 --- /dev/null +++ b/docs/plans/mid-turn-steering/task-08-mock-agent-and-e2e.md @@ -0,0 +1,80 @@ +--- +id: "08-mock-agent-and-e2e" +title: "Replay steering in the mock agent and cover it end to end" +status: done +wave: 5 +depends_on: ["04-adapter-steer-admission", "05-orchestrator-steer-admission", "07-composer-steer-affordance"] +plan: "plan.md" +spec: "../../specs/platform/mid-turn-steering.md" +--- + +# Task 08: Replay steering in the mock agent and cover it end to end + +- **Acceptance:** The mock agent can replay both outcomes on demand — `folded` + (predecessor settles `end_turn` with zeroed usage and no answer text, successor + carries the answer) and `deferred` (predecessor answers, then the steer runs as + its own turn) — with no paid model call. +- **Acceptance:** E2E covers four scenarios from the spec: steer delivered while + generating; queue when the toggle is off; queue when the agent does not + advertise the capability; order preserved when a message is already queued. +- **Acceptance:** The `deferred` path asserts the operator sees no error and no + version warning. +- **Verification:** `cd apps/backend && go test -race ./cmd/mock-agent/...` then `cd apps/web && pnpm e2e` +- **Files likely touched:** `apps/backend/cmd/mock-agent/` (script/emitter, which + already models `claude-agent-acp` wire shapes including Monitor and subagent + metadata), a new spec under `apps/web/e2e/tests/chat/`, and the e2e backend + fixture if the toggle needs setting per spec. +- **Dependencies:** Tasks 04, 05, 07. +- **Inputs:** Spec "Scenarios". Task 03's fixtures define the wire shape to + replay. `apps/web/e2e/tests/chat/busy-signal.spec.ts` already sets + `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` per spec and is the pattern + for toggling a runtime flag in E2E. `apps/web/e2e/README.md` documents project + selection. +- **Risks:** The mock is a dev/E2E-only trusted path and must not widen + production agent support. Keep the negotiated capability real in the mock + (advertise the `_meta`) rather than bypassing the gate, or the E2E proves + nothing about task 01. +- **Output contract:** Report the mock's replay modes, the four E2E scenarios and + their assertions, exact commands/results, and update only this task's status. + +## Validation Results + +Re-run on 2026-08-06 against the branch merged with `main`, after adding the +`steer-fold-setup` / `steer-defer-setup` mock scenarios this task's status was +blocked on. + +- `cd apps/backend && go test -race ./cmd/mock-agent/...`: passed, including + the two new scenario-pinning tests + `TestSteerFoldSetupEmitsNoAnswerUntilCancelled` and + `TestSteerDeferSetupAnswersBeforeHolding`, which assert directly on the + mock's own replay contract (no answer text vs. one real answer, in both + cases honoring context cancellation) without any live backend or paid model + call. +- `cd apps/backend && golangci-lint run ./cmd/mock-agent/... --new-from-rev=e381ed9884873ac924fe6cc75543271ad8be06e6 --timeout=5m`: 0 issues. +- `cd apps/web && pnpm run typecheck`: passed. +- `cd apps/web && npx playwright test --config e2e/playwright.config.ts tests/chat/mid-turn-steering.spec.ts --project=chromium --workers=1`: + **run locally**, all 6 tests passed — the four scenarios from the spec + (delivered while generating, queued behind an existing message, queued when + the capability isn't advertised, queued when the toggle is off) plus the two + new folded/deferred outcome tests added by this pass. `pnpm e2e` (the full + suite) remains CI-owned; this file's local run is scoped to the one spec. +- The mock agent advertises `_meta.claudeCode.promptQueueing` by default and can + suppress it with `KANDEV_MOCK_AGENT_PROMPT_QUEUEING=false`, so the capability + gate is exercised through the same negotiation path as a real bridge. + +**Replay modes (first acceptance item), now implemented:** +`scenarioSteerFoldSetup` (`/e2e:steer-fold-setup`) holds the foreground turn +open and never emits text of its own, so the predecessor produces no answer at +all and the transcript carries only the steer's own successor turn — the +`folded` outcome. `scenarioSteerDeferSetup` (`/e2e:steer-defer-setup`) emits a +real answer immediately (flushed via a trivial tool-call boundary, since text +otherwise stays buffered until a tool call or turn end — see +`cmd/mock-agent/AGENTS.md`), then holds silently; a steer sent during the hold +runs as a genuinely separate turn with its own answer — the `deferred` +outcome. Both are driven purely by mock-side scripting (which predecessor +scenario the E2E test seeds), not by racing real wall-clock timing against the +adapter's `beginSteerHandoff`/`claimPromptTurnCompletion` transfer, which is +what makes the replay deterministic "on demand" rather than flaky. +The deferred E2E test additionally asserts `last-agent-error-notice` and +`toast-message` are not visible, covering the "no error and no version +warning" acceptance item (third). diff --git a/docs/plans/mid-turn-steering/task-09-adr-and-verification.md b/docs/plans/mid-turn-steering/task-09-adr-and-verification.md new file mode 100644 index 0000000000..c5979827e4 --- /dev/null +++ b/docs/plans/mid-turn-steering/task-09-adr-and-verification.md @@ -0,0 +1,68 @@ +--- +id: "09-adr-and-verification" +title: "Record the decision and verify the whole feature" +status: done +wave: 6 +depends_on: ["01-negotiate-steer-capability", "02-runtime-toggle", "03-fold-handoff-fixtures", "04-adapter-steer-admission", "05-orchestrator-steer-admission", "06-session-steer-contract", "07-composer-steer-affordance", "08-mock-agent-and-e2e"] +plan: "plan.md" +spec: "../../specs/platform/mid-turn-steering.md" +--- + +# Task 09: Record the decision and verify the whole feature + +- **Acceptance:** An ADR under `docs/decisions/` records the steer-admission + trigger, the attribution contract for an early predecessor settlement, and the + negotiated-capability gate replacing the agent-name whitelist. It states + explicitly that the fold is undocumented and unadvertised, that the deciding + component's version is unobservable, and that `deferred` is therefore a + first-class success. `docs/decisions/INDEX.md` is updated, and the + mid-turn-steering non-goal in ADR-0049 is marked as superseded for the + steer-capable case. +- **Acceptance:** Every spec scenario maps to a test that exists and passes, or + is explicitly listed as not covered with a reason. Any scenario left uncovered + is reported, not quietly dropped. +- **Acceptance:** Public docs are updated if composer behavior or terminology + changed for users; if not, say so and why. +- **Verification:** `cd apps/backend && go test -race ./internal/... && make -C apps/backend lint` then `cd apps/web && pnpm run typecheck && pnpm lint && pnpm test && pnpm run i18n:check && pnpm e2e` +- **Files likely touched:** `docs/decisions/` (new ADR), + `docs/decisions/INDEX.md`, + `docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md` (supersession + note), `docs/specs/platform/mid-turn-steering.md` (status → `building` or + `shipped`), `docs/plans/mid-turn-steering/plan.md` (status), and + `docs/public/**` only if user-facing behavior changed. +- **Dependencies:** All prior tasks. +- **Inputs:** Spec "Decision record" names what this ADR supersedes. `/record + decision` owns the ADR format; `/docs-maintainer` decides the public-docs + question. Root `CLAUDE.md` requires updating scoped `AGENTS.md` when + conventions change — check `apps/backend/AGENTS.md` and + `apps/web/AGENTS.md` for anything this feature invalidates. +- **Risks:** Do not let a green run stand in for scenario coverage: the spec's + Scenarios section is the conformance surface, so verify each one maps to a real + assertion rather than assuming the suite covers it. +- **Output contract:** Report the ADR path, the scenario→test coverage table with + any gaps named, the public-docs decision, exact commands/results, and update + only this task's status plus the plan's overall status. + +## Validation Results + +Re-run on 2026-08-04 against the branch merged with `main`. + +- `cd apps/backend && go test -race ./internal/...`: every package this feature + touches passed. Three unrelated packages failed on this host and none is + touched by the PR: + - `internal/agentctl/server/config` — `TestCollectAgentEnvGitHubCLIShimSurvivesLoginShell` + reproduces identically on clean `main`; it resolves the host's real + `gh` (`/opt/homebrew/bin/gh`), so it is host-environment dependent. + - `internal/agent/runtime/routingerr` and `internal/repoclone` — both pass when + re-run in isolation; they are parallel-load flakes under the full `-race` run. +- `make -C apps/backend lint`: `0 issues.` +- `cd apps/web && pnpm run typecheck`: passed. +- `cd apps/web && pnpm lint`: passed. +- `cd apps/web && pnpm test`: 8431 passed. The 6 failures are pre-existing on + clean `main` in files this PR does not touch (see task-02's record). +- `cd apps/web && pnpm run i18n:check`: passed. +- `cd apps/web && pnpm e2e`: not run locally (see task-08) — CI on head `33a4dc3` + passed all 14 E2E shards and all 6 container shards. +- ADR: `docs/decisions/2026-08-04-mid-turn-steering.md`, indexed in + `docs/decisions/INDEX.md`, superseding ADR-0049's mid-turn-steering non-goal + for the steer-capable case. diff --git a/docs/public/configuration.md b/docs/public/configuration.md index 113396cab1..962feceb7c 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -128,6 +128,7 @@ The launcher starts `agentctl`, performs a one-time nonce handshake, and supplie | `features.app_status_bar` | `KANDEV_FEATURES_APP_STATUS_BAR` | `false` in production | Opt-in global connection/host-metrics/plugin surface: bottom bar on tablet/desktop and Status drawer on phones. Disabling it does not suppress active WebSocket connectivity warnings. | | `features.auth` | `KANDEV_FEATURES_AUTH` | `false` in production | Opt-in authentication and per-user workspaces. The first visitor after enabling completes setup and becomes the admin. | | `features.claude_background_prompt_handoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | `false` | High-risk experiment that lets Claude Code accept a new prompt after its foreground yields while adapter-attested background work remains active. Other providers keep the coarse busy gate. | +| `features.claude_mid_turn_steering` | `KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING` | `false` | High-risk experiment that delivers a new prompt into a Claude turn that is still generating (mid-turn steering) instead of queuing it, for agents that advertise prompt queueing. Whether the agent folds the prompt into the running turn or runs it next is the agent's decision; other providers keep the coarse busy gate. | Do not infer security from `auth.jwtSecret`: setting it currently does not turn the local server into an authenticated public service. Office's JWT key has a narrower, active purpose. Store both active secrets and third-party API keys in your deployment secret manager; never commit them in `config.yaml`. @@ -298,6 +299,7 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym | `features.appStatusBar` | `KANDEV_FEATURES_APP_STATUS_BAR` | off | Global status bar on tablet/desktop and Status drawer entry on phones. Enabling changes visibility only; urgent WebSocket connectivity warnings remain visible when it is off. | | `features.auth` | `KANDEV_FEATURES_AUTH` | off | Authentication and per-user workspaces for the whole install. | | `features.claudeBackgroundPromptHandoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | off | High-risk Claude Code experiment that exposes recognized background-only activity and admits a successor prompt. | +| `features.claudeMidTurnSteering` | `KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING` | off | High-risk Claude Code experiment that delivers a prompt into a still-generating turn (mid-turn steering) instead of queuing it, for agents advertising prompt queueing. | | `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` (also locked by explicit legacy/debug-message vars) | off | High-risk diagnostic endpoints and ACP frame logging. | UI changes are persisted in the database and require a restart. An explicitly set environment value wins and locks the UI control. Otherwise a database override wins over the embedded profile/default. Resetting a toggle removes its database override. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index dcb9af19e0..458ff174c2 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -41,6 +41,7 @@ Product-wide capabilities that are not tied to a single feature area. | [background-work-liveness](platform/background-work-liveness.md) | shipped | | [task-sleep-inhibition](platform/task-sleep-inhibition.md) | building | | [i18n](platform/i18n.md) | building | +| [mid-turn-steering](platform/mid-turn-steering.md) | shipped | | [plugins](plugins/spec.md) | draft | | [plugins — authoring experience](plugins/authoring-experience.md) | draft | | [plugins — marketplace](plugins/marketplace.md) | building | diff --git a/docs/specs/platform/background-work-liveness.md b/docs/specs/platform/background-work-liveness.md index 1a13f4eb0e..b7626058bb 100644 --- a/docs/specs/platform/background-work-liveness.md +++ b/docs/specs/platform/background-work-liveness.md @@ -129,7 +129,11 @@ truth for prompt admission and operator-visible activity. ## Out of scope -- Mid-turn steering for agents without concurrent-prompt capability. +- Mid-turn steering, including the steer-capable case. Delivering input into a + turn that is still generating is specified separately in + [mid-turn-steering](mid-turn-steering.md); this spec covers only the + foreground-idle handoff, where the foreground has already yielded to + recognized background work. - Reconstructing detached-work liveness after restart. - Rendering active subagent counts or individual subagent details in the UI. - Renaming the generic `WAITING_FOR_INPUT` lifecycle state or redesigning status diff --git a/docs/specs/platform/mid-turn-steering.md b/docs/specs/platform/mid-turn-steering.md new file mode 100644 index 0000000000..74b8342860 --- /dev/null +++ b/docs/specs/platform/mid-turn-steering.md @@ -0,0 +1,289 @@ +--- +status: shipped +created: 2026-08-04 +owner: kandev +--- + +# Mid-Turn Steering + +## Why + +While a Claude session is genuinely generating, an operator who spots a wrong +turn has only two bad options: queue a message that lands after the agent has +already made the decision the message would have prevented, or cancel and lose +the turn's work. Claude Code's own terminal and desktop surfaces accept typed +input during generation and act on it at the next tool boundary, so operators +arriving from those surfaces experience Kandev as strictly worse at the moment +correction matters most. + +## What + +- While a steer-capable session is generating, operator input SHALL reach the + running turn rather than waiting for the turn to end. +- Steering is additive, not destructive: it MUST NOT cancel the running turn, + discard completed tool work, or drop the turn's accumulated context. +- Steer eligibility is **negotiated from the connected agent**, never inferred + from an agent name or a normalized message shape. An agent that does not + advertise the capability keeps exactly today's queue behavior. +- Delivery is **opportunistic**. Kandev delivers the steer to an agent that + advertises it can accept a prompt while one is in flight; whether the agent + folds that prompt into the live turn or runs it as the next turn is the + agent's decision and is not advertised over the protocol. Both outcomes MUST + produce correct transcripts, correct attribution, and no operator-visible + error. +- A steered turn's completion SHALL be attributed to the work that actually + produced it. When an agent settles the predecessor's prompt early to hand the + turn to the steer, that early settlement MUST NOT be presented to the + operator as the predecessor's answer, and MUST NOT trigger predecessor-owned + cleanup that would sweep live tool work, consume the successor's usage, or + clear the successor's trace. +- Background work that was live when the turn was handed off SHALL stay live and + continue to report, including work whose completion arrives with no prompt + in flight. +- Message order is preserved. While a session still has queued messages, new + input continues to queue; steering applies only when nothing is already + queued ahead of it. +- At most one steer is in flight per session. A second steer attempt while one + is unacknowledged queues instead. +- Synthetic prompts (`ScheduleWakeup`) are never steers and can neither consume + nor race a steer. +- The operator can tell that input will be **delivered now rather than held**, + which is the only promise Kandev can honestly make: the capability is + advertised by the bridge, while whether the message folds into the live turn is + decided by the separately-versioned agent CLI underneath it. The composer + affordance therefore commits to delivery, never to folding, and the deferred + outcome MUST be indistinguishable from an ordinary queued message arriving. +- The behavior ships behind a default-off runtime toggle and remains a + kill-switch after rollout. + +## API surface + +Reuses the existing session contracts in +[background-work-liveness](background-work-liveness.md); this feature adds: + +- **Agent capability (inbound, ACP `initialize`)** — + `agentCapabilities._meta.claudeCode.promptQueueing: true` is the negotiated + precondition that the agent accepts `session/prompt` while another is in + flight. Kandev already retains `agentCapabilities` including its `_meta` map. + No new protocol method is introduced and no new ACP RPC is required. +- **Session record / boot payload / `session.state_changed` / + `session.activity_changed`** — a boolean `supports_steering`. True only when + the session's connected agent advertised the capability, the runtime toggle is + enabled, and the session is promptable-or-generating. Absent or false means + the composer keeps queue behavior. +- **Prompt admission** — the existing prompt path accepts a steer intent. A + steer is admitted while the durable session state is `RUNNING`; every other + coarse state follows the existing admission table. +- **Runtime toggle** — `features.claudeMidTurnSteering`, environment variable + `KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING`, experimental, high risk, + restart-required, `"false"` in every embedded profile. + +## State machine + +Per-session steer lifecycle, additive to the coarse session lifecycle: + +| State | Meaning | Transition trigger | +|---|---|---| +| `none` | No steer outstanding | initial, or terminal steer settled | +| `offered` | Session is generating and steer-capable | capability + toggle + generating | +| `sent` | Steer delivered to the agent, unacknowledged | operator submits while `offered` | +| `folded` | Agent took the steer into the running turn | predecessor settles without having produced its own answer | +| `deferred` | Agent ran it as its own turn instead | predecessor settles after producing its own answer | +| `none` | | successor turn completes, or session cancelled | + +`folded` and `deferred` are both success. They differ only in how completion is +attributed, and the operator sees a correct transcript either way. + +The coarse admission table from +[background-work-liveness](background-work-liveness.md) is otherwise unchanged: +a steer is the single new reason a `RUNNING` session accepts direct input. + +## Failure modes + +- The agent does not advertise the capability: input queues exactly as today. No + steer affordance is shown. +- The toggle is off: input queues exactly as today, for every agent. +- The agent advertises the capability but rejects the concurrent prompt: the + steer is reported as queued, the operator sees no error, and the message runs + as the next turn. +- The agent accepts the steer but never folds it: `deferred`. Correct + transcript, no error. +- **The advertised capability and the actual behavior come from independently + versioned components.** The bridge advertises `promptQueueing`; the agent CLI + beneath it decides whether to fold, and its version is not observable over the + protocol (the ACP `agentInfo.version` is the bridge's). An installation whose + bridge is current but whose CLI is too old to fold therefore reports itself + steer-capable and produces `deferred` on every steer. This is a supported + configuration, not an error: it degrades to exactly the queue behavior that + installation has today. Kandev MUST NOT gate on, infer, or warn about a CLI + version it cannot see. +- The predecessor's early settlement arrives while the successor is producing + output: the predecessor's settlement is stale by generation and MUST be + suppressed before it can emit completion, sweep tool ownership, consume usage, + or clear the successor's trace. +- Usage arrives attributed entirely to one of the two turns (observed: the + predecessor's early settlement reports zeroed usage and the real usage lands + on the successor). Usage MUST be recorded once, against whichever turn the + agent reports it on, and never double-counted across the boundary. +- The operator cancels during a steer: both the predecessor and the steer are + cancelled, the prompt gate is released, and the session does not remain + falsely busy. +- The agent connection dies mid-steer: the steer fails like any in-flight + prompt, and the session returns to a promptable coarse state. +- Background work whose completion produces a model cycle with no prompt in + flight MUST still be surfaced and MUST still settle the session's activity; + the steer boundary does not orphan it. +- A steer whose session was replaced (new/loaded session) while it waited is + dropped rather than delivered to the new session. +- **The steer must attach to a live, dispatched foreground turn, never a + half-started or finished one.** A steer reuses the predecessor turn's identity + so the folded completion is attributed to the predecessor's still-open waiter. + Therefore a steer that arrives after the foreground turn was admitted but + before its prompt actually reached the agent MUST NOT overtake it (its stream + buffers are still being reset); and a steer that arrives after the foreground + turn has already completed MUST NOT attach to that finished turn. Both cases + degrade to an ordinary prompt delivered in submission order — never a steer + bound to a non-live turn, which would strand the operator's message on a + completion that never arrives. +- **A steer dispatch error is never silently re-sent.** The agentctl request can + be written to the agent and then have its acknowledgement fail (a stream + disconnect or context cancellation after the write), so a steer that returns an + error may already be in flight. The handler therefore surfaces the error rather + than re-running the message as an ordinary prompt, which could deliver it twice. + Only a steer that provably never reached the agent — the session became + ineligible before dispatch, or there was no live turn to fold into — degrades to + an ordinary prompt. + +### Outcome taxonomy + +Four distinct outcomes are specified above, and only the last one is an error. +Collapsing them is the mistake this table exists to prevent — in particular, +"the agent refused the concurrent prompt" is a **success**, not a failure. + +| Outcome | When | Operator sees | +|---|---|---| +| **Steered** | The agent accepted the concurrent prompt and folded it into the running turn. | The message acts on the running turn. | +| **Deferred** | The agent accepted the concurrent prompt but ran it as the next turn — including an agent that refuses concurrency outright, and an installation whose CLI is too old to fold. | Reported as queued. Correct transcript, **no error**. | +| **Degraded to an ordinary prompt** | The steer provably never reached the agent: the session became ineligible before dispatch, or there was no live dispatched turn to fold into (admitted-but-not-dispatched, or already completed). | Ordinary queue behavior, in submission order. **No error.** | +| **Errored** | The write or its acknowledgement failed *ambiguously* — delivery to the agent cannot be ruled out. | The error surfaces. The message is **never** re-sent, because it may already be in flight. | + +## Known residuals + +These are narrow, accepted windows inherent to opportunistic steering over an +asynchronous agent boundary. All are gated behind the default-off toggle. + +- **Acknowledgement is not delivery.** agentctl acknowledges a steer, then runs + the concurrent `session/prompt` on a goroutine. Once the request has been + accepted no client-side fallback can fire, so if the concurrent prompt then + fails on an agent that advertised prompt queueing yet rejects it (abnormal), + the message can be stranded with success already reported. +- **The fold is decided agent-side, and the predecessor may settle first.** The + lifecycle lock closes the backend-side selection/dispatch race, but the + acknowledgement proves acceptance, not a fold: the predecessor turn is + suppressed only once the successor actually acquires (transfers) the prompt + gate, which happens when the async steer runs — not when it is accepted. If the + predecessor settles agent-side before that transfer (it naturally ends, or the + agent runs the steer as a fresh turn instead of folding), the steer's turn is + tagged with the reused generation and its completion bookkeeping + (on_turn_complete, usage) is discarded as a duplicate. The operator's message + is still delivered; only that turn's second completion is lost. This window is + inherent to opportunistic delivery over an async boundary and is not closable + from the Kandev side. +- **Steer history ordering.** A dispatched steer's user message is appended to + the agent's resume history after the lifecycle lock is released (so a slow + history store never extends the lock). A completion can settle in that narrow + window, so on resume the steer message may order after the turn it folded into. + The durable DB transcript is unaffected; this is a resume-fidelity residual. +- **Session replacement during a fall-through wait.** A steer that finds no + handoff-eligible turn (e.g. a synthetic wakeup holds the gate) falls through to + ordinary gate acquisition. If the ACP session is replaced (`session/new` / + `session/load`) while it waits there, it is delivered to the replacement rather + than dropped. The drop guarantee above holds for the wakeup-pinned path; this + fall-through path is the residual. + +## Persistence guarantees + +Steer state is in-memory and authoritative only while the owning agent +execution is connected. A restart does not reconstruct an in-flight steer, a +`sent` steer's acknowledgement, or the fold-vs-defer outcome. Durable coarse +session state remains the source of truth for prompt admission after restart. +`supports_steering` is derived at serialization time from the live connection +and is never persisted. + +## Scenarios + +- **GIVEN** the toggle is off, **WHEN** the operator submits input to a + generating session, **THEN** Kandev queues it and shows no steer affordance. +- **GIVEN** a steer-capable generating session with an empty queue, **WHEN** the + operator submits input, **THEN** Kandev delivers it to the running turn + without cancelling that turn. +- **GIVEN** an agent that does not advertise `promptQueueing`, **WHEN** the same + input is submitted to a generating session, **THEN** Kandev queues it and the + session's `supports_steering` is false. +- **GIVEN** a delivered steer that the agent folded, **WHEN** the agent settles + the predecessor's prompt before that prompt produced any answer, **THEN** + Kandev does not present that settlement as the predecessor's answer and does + not emit a completion for it. +- **GIVEN** a folded steer whose predecessor had live background work at the + boundary, **WHEN** the predecessor's prompt settles, **THEN** that background + work stays live and its later completion is still reported. +- **GIVEN** a folded steer, **WHEN** both turns have settled, **THEN** the + session's recorded usage equals what the agent reported and no token or cost + is counted twice. +- **GIVEN** a delivered steer that the agent ran as its own turn instead, + **WHEN** both turns settle, **THEN** the transcript shows both answers in + submission order and no error is surfaced. +- **GIVEN** a steer-capable session whose underlying agent CLI is too old to + fold, **WHEN** the operator steers a generating session, **THEN** the message + runs as the next turn, the transcript is correct, and the operator sees no + error and no version warning. +- **GIVEN** a session with one already-queued message, **WHEN** the operator + submits again while generating, **THEN** the new message queues behind the + first rather than steering. +- **GIVEN** a steer that has been sent and not acknowledged, **WHEN** the + operator submits again, **THEN** the second message queues. +- **GIVEN** a scheduled wakeup due while a steer is in flight, **WHEN** the + wakeup fires, **THEN** it waits for the owning prompt rather than consuming + the steer's handoff. +- **GIVEN** an in-flight steer, **WHEN** the operator cancels the session, + **THEN** both the predecessor turn and the steer end and the session becomes + promptable. +- **GIVEN** an in-flight steer, **WHEN** the active session is replaced by a new + or loaded session, **THEN** the steer is dropped rather than delivered to the + replacement. +- **GIVEN** a steer-capable session that is idle rather than generating, + **WHEN** the operator submits input, **THEN** it dispatches as an ordinary + prompt. + +## Out of scope + +- Adding a steer RPC to ACP, or adopting an agent-specific steer primitive such + as Codex `turn/steer`. This feature uses only the existing prompt method. +- Making the fold itself detectable in advance. The agent does not advertise it + and Kandev must not infer it from a version string. +- A minimum agent-CLI version floor, a version warning, or any version-derived + gate. The CLI version is not observable over the protocol, and gating on the + bridge version would gate on the wrong component. +- Backward-compatibility branches for older agents. There is nothing to branch + on: an agent that cannot fold produces `deferred`, which is a specified + success path, so old and new installations run the same code. +- Editing, reordering, or withdrawing an already-queued message. +- Steering a subagent's turn rather than the session's foreground turn. +- Changing the coarse busy signal or the activity tiers owned by + [background-work-liveness](background-work-liveness.md). +- Reconstructing steer state across a restart. + +## Decision record + +Supersedes the mid-turn-steering non-goal in +[background-work-liveness](background-work-liveness.md) and the out-of-scope +note in +[ADR 0049 — Fine-grained foreground-idle busy signal](../../decisions/0049-fine-grained-foreground-idle-busy-signal.md), +which recorded that mid-turn steering "needs ACP concurrent-prompt support and +per-agent capability gating". The concurrent-prompt precondition is now +observed to be satisfied by the shipped Claude bridge; the capability gating +requirement stands and is specified above. + +## Implementation plan + +[Mid-turn steering plan](../../plans/mid-turn-steering/plan.md) diff --git a/scripts/probe-acp-midturn-fold b/scripts/probe-acp-midturn-fold new file mode 100755 index 0000000000..ab4b8108d9 --- /dev/null +++ b/scripts/probe-acp-midturn-fold @@ -0,0 +1,556 @@ +#!/usr/bin/env python3 +# probe-acp-midturn-fold — what does the ACP bridge do with a second +# session/prompt issued while the first one is still in flight? +# +# Companion to scripts/probe-claude-midturn-fold, which proved the Claude CLI +# folds a stream-json message into a running turn (one `result` for two prompts, +# and the model obeyed the second one). That probe bypassed the bridge. This one +# adds the layer Kandev actually talks to: it speaks raw ACP JSON-RPC to +# `npx @agentclientprotocol/claude-agent-acp`, the same command +# agents/claude_acp.go builds. +# +# What we are trying to distinguish: +# +# FOLD_HANDOFF Prompt A's session/prompt resolves `end_turn` EARLY — at the +# moment B's echo reaches the bridge, before the model has said +# anything final — and the steered answer is then attributed to +# B's request. This is the shape acp-agent.js's `user` handler +# implies: on a replayed user message that matches a queued +# turn, it settles the active turn and activates the new one. +# Kandev's promptGate/promptTurn would see a completion for A +# that is not really A's. +# SERIALIZED A resolves only after its own final text, then B runs as its +# own turn. This is what issue #1607 assumes today. +# REJECTED The bridge refuses the concurrent session/prompt outright. +# +# The tell is WHEN A's response arrives relative to the assistant text, not just +# the order of the two responses. Prompt A asks for two sequential sleeps and +# the word DONE; prompt B says skip the second sleep and say STEERED. So: +# - final text STEERED and no second sleep => the model was steered mid-turn +# - A resolved before any final text => the bridge handed the turn off +# +# Usage: +# scripts/probe-acp-midturn-fold [options] +# +# --out JSONL frame log (default: ./acp-fold-.jsonl) +# --sleep Length of each sleep in prompt A (default: 20) +# --delay Wait after A's first tool_call before sending B (default: 3) +# --linger How long to keep reading after the first prompt response +# resolves (default: 90) +# --timeout Hard kill (default: 360; npx cold start is slow) +# --cwd Session cwd sent in session/new (default: a fresh temp dir) +# --deny Reject permission requests instead of allowing them +# --prompt-a Override prompt A +# --prompt-b Override prompt B +# --dry-run Print the command and prompts, spend nothing. +# +# Permission requests are auto-answered with the first allow_* option the bridge +# offers, so a Bash call does not stall the probe. Any other client-side request +# (fs/*, terminal/*) is refused with -32601, and the probe declares no fs or +# terminal capability so the bridge should not ask. +# +# Output: the JSONL log plus a verdict block. One paid turn per run. + +import argparse +import json +import os +import queue +import re +import shlex +import signal +import subprocess +import sys +import tempfile +import threading +import time + + +def _kill_process_group(proc): + """SIGKILL the child's whole process group, falling back to the direct + child. The bridge spawns a CLI and tool subprocesses that outlive a plain + proc.kill(); signalling the group reaps the entire tree.""" + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError, OSError): + proc.kill() + + +PROMPT_A = ( + "Run exactly this shell command with the Bash tool: sleep {s}\n" + "When it finishes, run exactly this one: sleep {s}\n" + "Then reply with the single word DONE. Do not run anything else and do not " + "combine the two commands." +) + +PROMPT_B = ( + "Change of plan: skip the second sleep and reply with the single word " + "STEERED instead." +) + +BRIDGE_CMD = [ + "npx", "--yes", "--prefer-offline", + "@agentclientprotocol/claude-agent-acp", +] + +ID_INIT, ID_NEW, ID_PROMPT_A, ID_PROMPT_B = 1, 2, 3, 4 + + +def parse_args(): + p = argparse.ArgumentParser(add_help=True) + p.add_argument("--out") + p.add_argument("--sleep", type=int, default=20) + p.add_argument("--delay", type=float, default=3.0) + p.add_argument("--linger", type=float, default=90.0) + p.add_argument("--watch-after", dest="watch_after", type=float, default=0.0, + help="After BOTH prompts resolve, keep reading this many " + "seconds. Needed to see whether a predecessor's " + "backgrounded tool calls survive the handoff boundary.") + p.add_argument("--timeout", type=float, default=360.0) + p.add_argument("--cwd") + p.add_argument("--exec", dest="exec_cmd", + help="Run this command instead of the bridge (probe self-test).") + p.add_argument("--deny", action="store_true") + p.add_argument("--prompt-a") + p.add_argument("--prompt-b") + p.add_argument("--dry-run", action="store_true") + return p.parse_args() + + +class Recorder: + def __init__(self): + self.t0 = time.monotonic() + self.entries = [] + self.lock = threading.Lock() + + def ms(self): + return round((time.monotonic() - self.t0) * 1000) + + def add(self, direction, frame=None, raw=None, **extra): + entry = {"t_ms": self.ms(), "direction": direction, **extra} + if frame is not None: + entry["frame"] = frame + if raw is not None: + entry["raw"] = raw + with self.lock: + self.entries.append(entry) + return entry + + def note(self, event, **fields): + return self.add("meta", frame={"probe_event": event, **fields}) + + def write(self, path): + with open(path, "w", encoding="utf-8") as fh: + with self.lock: + for e in self.entries: + fh.write(json.dumps(e, ensure_ascii=False) + "\n") + + +class StreamEOF: + """One reader thread reached EOF. + + Carries whether it was the protocol (JSON) stream. A bare sentinel would be + indistinguishable between stdout and stderr, and the bridge routinely closes + stderr first — breaking the main loop there would end the probe while stdout + can still deliver the very frames the verdict is read from. + """ + + __slots__ = ("direction", "is_protocol") + + def __init__(self, direction, is_protocol): + self.direction = direction + self.is_protocol = is_protocol + + +def reader(stream, direction, rec, events, is_json): + # readline(), not iteration: iteration fills a read-ahead buffer and + # destroys the arrival timing this probe measures. + while True: + line = stream.readline() + if line == "": + break + line = line.strip() + if not line: + continue + if not is_json: + rec.add(direction, raw=line) + continue + try: + frame = json.loads(line) + except json.JSONDecodeError: + rec.add(direction, raw=line) + continue + entry = rec.add(direction, frame=frame) + events.put((entry["t_ms"], frame)) + events.put(StreamEOF(direction, is_json)) + + +def main(): + args = parse_args() + prompt_a = args.prompt_a or PROMPT_A.format(s=args.sleep) + prompt_b = args.prompt_b or PROMPT_B + cmd = shlex.split(args.exec_cmd) if args.exec_cmd else list(BRIDGE_CMD) + + if args.dry_run: + print(" ".join(cmd)) + print("\n--- prompt A ---\n" + prompt_a) + print("\n--- prompt B ---\n" + prompt_b) + return 0 + + cwd = args.cwd or tempfile.mkdtemp(prefix="acp-fold-probe-") + os.makedirs(cwd, exist_ok=True) + out_path = args.out or os.path.abspath(f"acp-fold-{int(time.time())}.jsonl") + + rec = Recorder() + rec.note("start", cmd=cmd, session_cwd=cwd) + + # start_new_session puts the bridge in its own process group so a forced + # cleanup can signal the whole tree (bridge + CLI + tool subprocesses), not + # just the direct child. + proc = subprocess.Popen( + cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, bufsize=1, cwd=cwd, + start_new_session=True, + ) + events = queue.Queue() + readers = [ + threading.Thread(target=reader, args=(proc.stdout, "received", rec, events, True), + daemon=True), + threading.Thread(target=reader, args=(proc.stderr, "stderr", rec, events, False), + daemon=True), + ] + for t in readers: + t.start() + + write_lock = threading.Lock() + + def send(frame, label=None): + with write_lock: + rec.add("sent", frame=frame) + if label: + rec.note(label) + try: + proc.stdin.write(json.dumps(frame) + "\n") + proc.stdin.flush() + return True + except (BrokenPipeError, ValueError): + rec.note("send_failed", label=label) + return False + + def request(rid, method, params, label=None): + return send({"jsonrpc": "2.0", "id": rid, "method": method, + "params": params}, label) + + def reply(rid, result): + return send({"jsonrpc": "2.0", "id": rid, "result": result}) + + def reply_error(rid, code, message): + return send({"jsonrpc": "2.0", "id": rid, + "error": {"code": code, "message": message}}) + + request(ID_INIT, "initialize", { + "protocolVersion": 1, + # Declare nothing we cannot serve, so the bridge uses its own fs and + # never asks us for terminals. + "clientCapabilities": {"fs": {"readTextFile": False, "writeTextFile": False}}, + "clientInfo": {"name": "kandev-acp-fold-probe", "version": "0.1.0"}, + }, "sent_initialize") + + # --- observation state ------------------------------------------------- + init_result = None + session_id = None + sent_b_at = None + first_tool_at = None + responses = {} # rid -> {t_ms, stopReason|error} + texts = [] # (t_ms, text, in_flight_after_a_resolved) + tool_calls = [] # (t_ms, title, status) + tool_timeline = {} # toolCallId -> lifecycle + post-handoff activity + permissions = 0 + b_echo_at = None + a_resolved_before_any_text = None + verdict_deadline = None + hard_deadline = rec.t0 + args.timeout + + def settle(label): + """Both prompts are done. Exit unless asked to keep watching the + session, which is how background survival gets observed.""" + nonlocal verdict_deadline + if args.watch_after > 0: + rec.note("watching_after_resolution", trigger=label, + seconds=args.watch_after) + verdict_deadline = time.monotonic() + args.watch_after + return False + return True + + def maybe_send_b(): + nonlocal sent_b_at + if sent_b_at is not None or session_id is None or first_tool_at is None: + return + if rec.ms() >= first_tool_at + args.delay * 1000: + if request(ID_PROMPT_B, "session/prompt", + {"sessionId": session_id, + "prompt": [{"type": "text", "text": prompt_b}]}, + "sent_prompt_b"): + sent_b_at = rec.ms() + + while True: + if time.monotonic() > hard_deadline: + rec.note("hard_timeout") + break + try: + item = events.get(timeout=0.25) + except queue.Empty: + maybe_send_b() + if verdict_deadline and time.monotonic() > verdict_deadline: + rec.note("linger_expired") + break + continue + if isinstance(item, StreamEOF): + rec.note("stream_eof", stream=item.direction) + if item.is_protocol: + break + continue + t_ms, frame = item + + # --- agent -> client requests we must answer ----------------------- + method = frame.get("method") + if method and frame.get("id") is not None: + params = frame.get("params") or {} + if method == "session/request_permission": + permissions += 1 + opts = params.get("options") or [] + want = "reject" if args.deny else "allow" + chosen = next( + (o.get("optionId") for o in opts + if str(o.get("kind", "")).startswith(want)), + None, + ) + # Never fall back to an arbitrary option when denying: opts[0] + # may be an allow_* option, which would grant a permission the + # operator asked to reject. Cancel instead. The allow path may + # still use opts[0] as a benign last resort. + if chosen is None and not args.deny and opts: + chosen = opts[0].get("optionId") + rec.note("permission_answered", option_id=chosen, + kinds=[o.get("kind") for o in opts]) + if chosen is None: + reply(frame["id"], {"outcome": {"outcome": "cancelled"}}) + else: + reply(frame["id"], + {"outcome": {"outcome": "selected", "optionId": chosen}}) + else: + rec.note("client_request_refused", method=method) + reply_error(frame["id"], -32601, f"method not found: {method}") + maybe_send_b() + continue + + # --- responses to our requests ------------------------------------- + if "id" in frame and method is None: + rid = frame["id"] + if "error" in frame: + responses[rid] = {"t_ms": t_ms, "error": frame["error"]} + rec.note("response_error", rid=rid, error=frame["error"]) + else: + result = frame.get("result") or {} + responses[rid] = {"t_ms": t_ms, "stopReason": result.get("stopReason")} + if rid == ID_INIT: + init_result = result + request(ID_NEW, "session/new", {"cwd": cwd, "mcpServers": []}, + "sent_session_new") + elif rid == ID_NEW: + session_id = result.get("sessionId") + rec.note("session_created", session_id=session_id) + request(ID_PROMPT_A, "session/prompt", + {"sessionId": session_id, + "prompt": [{"type": "text", "text": prompt_a}]}, + "sent_prompt_a") + elif rid == ID_PROMPT_A: + # The load-bearing observation: had the model produced any + # final text yet when A's own request resolved? + a_resolved_before_any_text = not texts + rec.note("prompt_a_resolved", + stop_reason=result.get("stopReason"), + texts_so_far=len(texts), + before_any_text=a_resolved_before_any_text) + if ID_PROMPT_B in responses: + if settle("prompt_a_last"): + break + else: + verdict_deadline = time.monotonic() + args.linger + elif rid == ID_PROMPT_B: + rec.note("prompt_b_resolved", stop_reason=result.get("stopReason")) + if ID_PROMPT_A in responses: + if settle("prompt_b_last"): + break + else: + verdict_deadline = time.monotonic() + args.linger + maybe_send_b() + continue + + # --- session/update notifications ---------------------------------- + if method == "session/update": + upd = (frame.get("params") or {}).get("update") or {} + kind = upd.get("sessionUpdate") + if kind == "agent_message_chunk": + content = upd.get("content") or {} + if content.get("type") == "text": + txt = content.get("text", "") + texts.append((t_ms, txt)) + elif kind == "user_message_chunk": + content = upd.get("content") or {} + txt = content.get("text", "") if isinstance(content, dict) else "" + if prompt_b[:24] in txt and b_echo_at is None: + b_echo_at = t_ms + rec.note("b_echo_observed", + before_a_resolved=ID_PROMPT_A not in responses) + elif kind in ("tool_call", "tool_call_update"): + tool_calls.append((t_ms, upd.get("title") or upd.get("toolCallId"), + upd.get("status"))) + tcid = upd.get("toolCallId") or "?" + rowe = tool_timeline.setdefault(tcid, { + "first_ms": t_ms, "last_ms": t_ms, "title": None, + "status": None, "after_a": 0, "after_b": 0, "updates": 0, + }) + rowe["last_ms"] = t_ms + rowe["updates"] += 1 + if upd.get("title"): + rowe["title"] = upd["title"] + if upd.get("status"): + rowe["status"] = upd["status"] + # Activity arriving after a prompt resolved is the survival + # evidence: the boundary did not kill this tool call. + if ID_PROMPT_A in responses: + rowe["after_a"] += 1 + if ID_PROMPT_B in responses: + rowe["after_b"] += 1 + if first_tool_at is None and kind == "tool_call": + first_tool_at = t_ms + rec.note("first_tool_call_observed", title=upd.get("title")) + maybe_send_b() + if verdict_deadline and time.monotonic() > verdict_deadline: + rec.note("linger_expired") + break + + # --- shutdown ---------------------------------------------------------- + try: + proc.stdin.close() + except (BrokenPipeError, ValueError): + pass + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + rec.note("kill_after_stdin_close") + _kill_process_group(proc) + proc.wait(timeout=10) + + # Drain the readers before the log is written: frames that arrived between + # the loop breaking and the process exiting are still being appended, and a + # probe that silently drops its last frames is worse than no probe. + for t in readers: + t.join(timeout=5) + + ra, rb = responses.get(ID_PROMPT_A), responses.get(ID_PROMPT_B) + # Assistant text streams as chunks ("ST" + "EERED"), so concatenate with no + # separator — joining on a space hides the sentinel word. + all_text = "".join(t for _, t in texts) + steered = "STEERED" in all_text + + def text_after(ms): + return "".join(txt for t, txt in texts if ms is not None and t > ms) + + def text_before(ms): + return "".join(txt for t, txt in texts if ms is None or t <= ms) + + def says(word, blob): + # Standalone uppercase token only. Prompt A's narration legitimately + # contains lowercase "done" ("Step 1 done. Now step 2..."), which must + # not be mistaken for A delivering its own final answer. + return re.search(r"\b" + word + r"\b", blob) is not None + + a_ms = ra["t_ms"] if ra and "t_ms" in ra else None + b_ms = rb["t_ms"] if rb and "t_ms" in rb else None + # The load-bearing attribution question: did the answer that responds to B + # stream in AFTER A's own request had already resolved end_turn? A prompt + # that narrates its steps produces text before the handoff too, so "any text + # at all" is too crude a test — look for the steered answer specifically. + steered_after_a = says("STEERED", text_after(a_ms)) + # Did A deliver its own final answer before resolving? If it did, this is an + # ordinary serialized pair, not a handoff. + done_before_a = says("DONE", text_before(a_ms)) + said_done = says("DONE", all_text) + # Background survival shows up on the TEXT channel, not the tool channel: a + # settled background workload wakes a task-notification model cycle with no + # owning prompt RPC. Count activity after BOTH prompts resolved. + orphan_ms = max([m for m in (a_ms, b_ms) if m is not None], default=None) + orphan_texts = [(t, txt) for t, txt in texts + if orphan_ms is not None and t > orphan_ms] + sleeps = sum(1 for _, title, _ in tool_calls + if title and "sleep" in str(title).lower()) + + if sent_b_at is None: + verdict = "INCONCLUSIVE — prompt B was never sent (no tool_call seen)" + elif rb and "error" in rb: + verdict = "REJECTED — the bridge refused the concurrent session/prompt" + elif not ra: + verdict = "INCONCLUSIVE — prompt A never resolved; walk the log" + elif steered and steered_after_a and not done_before_a: + verdict = ("FOLD_HANDOFF — the model was steered mid-turn and the steered " + "answer streamed AFTER A resolved end_turn: the bridge handed " + "the turn off and A's completion is not A's") + elif steered and done_before_a: + verdict = ("SERIALIZED — A delivered its own answer before resolving, then " + "B ran as its own turn") + elif steered: + verdict = ("FOLD — the model was steered mid-turn, but the steered answer " + "preceded A's response; check attribution in the log") + elif said_done and steered: + verdict = "SERIALIZED — A completed on its own terms, then B ran as its own turn" + elif said_done: + verdict = "SERIALIZED — A completed; B produced no steered answer" + else: + verdict = "INCONCLUSIVE — neither DONE nor STEERED observed" + + rec.note("verdict", text=verdict) + rec.write(out_path) + + caps = (init_result or {}).get("agentCapabilities") or {} + meta = (caps.get("_meta") or {}).get("claudeCode") or {} + print(f"\nverdict: {verdict}\n") + print(f" bridge protocolVersion {(init_result or {}).get('protocolVersion')}") + print(f" promptQueueing {meta.get('promptQueueing')}") + print(f" agent _meta.claudeCode {json.dumps(meta)}") + print(f" session {session_id}") + print(f" permission requests {permissions}") + print(f" first tool_call at {first_tool_at} ms") + print(f" B sent at {sent_b_at} ms") + print(f" B echo seen at {b_echo_at} ms") + print(f" prompt A resolved {ra}") + print(f" before any text? {a_resolved_before_any_text}") + print(f" steered text after A? {steered_after_a}") + print(f" A answered on its own? {done_before_a}") + print(f" prompt B resolved {rb}") + print(f" orphan text after both {len(orphan_texts)} chunk(s)" + + (f", first at {orphan_texts[0][0]} ms" if orphan_texts else "")) + if orphan_texts: + print(" -> background work outlived both prompts and woke a model " + "cycle with no owning session/prompt RPC") + print(f" {''.join(t for _, t in orphan_texts)[:150]!r}") + print(f" sleep tool_calls {sleeps} (2 = not steered, 1 = steered)") + print(f" assistant text {all_text[:160]!r}") + print(" text timeline " + + ", ".join(f"{t}ms:{txt.strip()[:24]!r}" for t, txt in texts[:8])) + if tool_timeline: + survivors = [k for k, v in tool_timeline.items() if v["after_a"] > 0] + print("\n tool calls (updates after a prompt resolved = survived it):") + print(f" {'toolCallId':<26} {'title':<22} {'status':<10} " + f"{'first':>7} {'last':>7} {'upd':>4} {'aftA':>5} {'aftB':>5}") + for tcid, v in sorted(tool_timeline.items(), key=lambda kv: kv[1]["first_ms"]): + print(f" {tcid[:26]:<26} {str(v['title'])[:22]:<22} " + f"{str(v['status'])[:10]:<10} {v['first_ms']:>7} {v['last_ms']:>7} " + f"{v['updates']:>4} {v['after_a']:>5} {v['after_b']:>5}") + print(f" survived A's resolution: {len(survivors)} tool call(s)") + print(f"\n frames: {out_path}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/probe-claude-midturn-fold b/scripts/probe-claude-midturn-fold new file mode 100755 index 0000000000..7312f72258 --- /dev/null +++ b/scripts/probe-claude-midturn-fold @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +# probe-claude-midturn-fold — does the Claude CLI fold a stream-json message +# into a turn that is already running? +# +# Context: docs/specs/platform/background-work-liveness.md scopes out "mid-turn +# steering for agents without concurrent-prompt capability", and ADR-0049 admits +# a prompt handoff only for adapters "fixture-tested to accept the next human +# prompt echo while the earlier RPC remains open". Issue #1607 assumes the +# capability is absent for Claude over ACP. This probe settles it against the +# real binary, with no Kandev and no ACP bridge in the path: raw stream-json on +# stdin/stdout. +# +# Method: prompt A runs two sequential `sleep`s. While the first one is +# executing, prompt B is written to stdin with a client-supplied uuid. Whether +# the CLI folds B into the running turn or holds it for the next turn is decided +# by three independent observations, in descending order of trust: +# +# 1. result count — a fold answers TWO prompts with ONE `result` frame. +# 2. B's replayed echo — arrives before the first `result` iff B was folded +# (requires --replay-user-messages). +# 3. command_lifecycle — B's uuid reaching `started` before the first `result`. +# Corroboration only: the frame is marked @internal and +# may be gated or absent on older CLIs. +# +# Observation 3 is raw CLI observation only. It is NOT Kandev's admission gate: +# Kandev negotiates steering over ACP, on the `initialize` advertisement +# `agentCapabilities._meta.claudeCode.promptQueueing` (read in +# acp/prompt_queueing.go). `msg_lifecycle_v1` on the stream-json init frame is +# not advertised over ACP at all, so it can corroborate the fold here without +# ever being something Kandev could gate on. +# +# Usage: +# scripts/probe-claude-midturn-fold [options] +# +# --out JSONL frame log (default: ./midturn-fold-.jsonl) +# --model Model for the probe session (default: sonnet) +# --sleep Length of each sleep in prompt A (default: 20) +# --delay Wait after seeing A's first tool_use before sending B +# (default: 3) +# --after-seconds Send B n seconds after start instead of waiting for a +# tool_use. Use when A is not tool-shaped. +# --linger After the first result, how long to wait for B to run +# as its own turn (default: 90) +# --timeout Hard kill (default: 300) +# --budget Passed to --max-budget-usd (default: 1.00) +# --bypass Use --permission-mode bypassPermissions instead of the +# narrow Bash(sleep:*) allowlist. Only for a sandbox. +# --prompt-a Override prompt A (e.g. to probe run_in_background) +# --prompt-b Override prompt B +# --claude claude binary (default: first on PATH) +# --dry-run Print the command and the two prompts, spend nothing. +# +# Output: the JSONL log plus a verdict block on stdout. One paid turn per run. +# +# To probe the background-work half of the question instead — do a predecessor's +# backgrounded tool calls survive the boundary? — keep the same machinery and +# swap the prompt: +# +# scripts/probe-claude-midturn-fold \ +# --prompt-a 'Start `sleep 45` with run_in_background, then tell me its task id.' \ +# --after-seconds 12 --bypass +# +# then read the log for tool frames on that task id arriving after B's echo. + +import argparse +import json +import os +import queue +import shutil +import signal +import subprocess +import sys +import threading +import time +import uuid + + +def _kill_process_group(proc): + """SIGKILL the CLI's whole process group, falling back to the direct child. + The Claude CLI spawns tool subprocesses that outlive a plain proc.kill(); + signalling the group reaps the entire tree.""" + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError, OSError): + proc.kill() + + +PROMPT_A = ( + "Run exactly this shell command with the Bash tool: sleep {s}\n" + "When it finishes, run exactly this one: sleep {s}\n" + "Then reply with the single word DONE. Do not run anything else and do not " + "combine the two commands." +) + +PROMPT_B = ( + "Change of plan: skip the second sleep and reply with the single word " + "STEERED instead." +) + + +def parse_args(): + p = argparse.ArgumentParser(add_help=True) + p.add_argument("--out") + p.add_argument("--model", default="sonnet") + p.add_argument("--sleep", type=int, default=20) + p.add_argument("--delay", type=float, default=3.0) + p.add_argument("--after-seconds", type=float, default=None) + p.add_argument("--linger", type=float, default=90.0) + p.add_argument("--timeout", type=float, default=300.0) + p.add_argument("--budget", default="1.00") + p.add_argument("--bypass", action="store_true") + p.add_argument("--prompt-a") + p.add_argument("--prompt-b") + p.add_argument("--claude") + p.add_argument("--dry-run", action="store_true") + return p.parse_args() + + +def build_command(args, session_id): + claude = args.claude or shutil.which("claude") + if not claude: + sys.exit("probe: no `claude` on PATH; pass --claude ") + cmd = [ + claude, + "--print", + "--input-format", "stream-json", + "--output-format", "stream-json", + "--replay-user-messages", + "--verbose", + # Asks for a minimal session and writes nothing to session history. + # Note: an observed 2.1.220 run still listed skills and plugins on the + # init frame, so treat this as best-effort isolation, not a guarantee — + # check the init frame in the log if a customization could skew a result. + "--safe-mode", + "--no-session-persistence", + "--session-id", session_id, + "--model", args.model, + "--tools", "Bash", + "--max-budget-usd", str(args.budget), + ] + if args.bypass: + cmd += ["--permission-mode", "bypassPermissions"] + else: + # Narrow enough that `sleep N` runs unattended and nothing else does. + cmd += ["--allowedTools", "Bash(sleep:*)"] + return cmd + + +def user_frame(text, session_id, msg_uuid): + return { + "type": "user", + "message": {"role": "user", "content": [{"type": "text", "text": text}]}, + "parent_tool_use_id": None, + "session_id": session_id, + "uuid": msg_uuid, + } + + +class Recorder: + """Frame log with a monotonic clock shared by both directions.""" + + def __init__(self): + self.t0 = time.monotonic() + self.frames = [] + self.lock = threading.Lock() + + def ms(self): + return round((time.monotonic() - self.t0) * 1000) + + def add(self, direction, frame, raw=None): + entry = {"t_ms": self.ms(), "dir": direction} + if frame is not None: + entry["frame"] = frame + if raw is not None: + entry["raw"] = raw + with self.lock: + self.frames.append(entry) + return entry + + def note(self, event, **fields): + return self.add("probe", {"probe_event": event, **fields}) + + def write(self, path): + with open(path, "w", encoding="utf-8") as fh: + with self.lock: + for entry in self.frames: + fh.write(json.dumps(entry, ensure_ascii=False) + "\n") + + +class StreamEOF: + """One reader thread reached EOF. + + Carries whether it was the protocol (JSON) stream. A bare sentinel would be + indistinguishable between stdout and stderr, and the CLI routinely closes + stderr first — breaking the main loop there would end the probe while stdout + can still deliver the very frames the verdict is read from. + """ + + __slots__ = ("direction", "is_protocol") + + def __init__(self, direction, is_protocol): + self.direction = direction + self.is_protocol = is_protocol + + +def reader(stream, direction, rec, events, is_json): + # readline(), not `for line in stream`: iteration fills an 8KB read-ahead + # buffer, which arrives as one burst and flattens the timing this probe is + # measuring. readline returns as soon as a newline lands. + while True: + line = stream.readline() + if line == "": + break + line = line.rstrip("\n") + if not line: + continue + if not is_json: + rec.add(direction, None, raw=line) + continue + try: + frame = json.loads(line) + except json.JSONDecodeError: + rec.add(direction, None, raw=line) + continue + entry = rec.add(direction, frame) + events.put((entry["t_ms"], frame)) + events.put(StreamEOF(direction, is_json)) + + +def tool_use_names(frame): + if frame.get("type") != "assistant": + return [] + content = frame.get("message", {}).get("content") or [] + if not isinstance(content, list): + return [] + return [b.get("name") for b in content if isinstance(b, dict) and b.get("type") == "tool_use"] + + +def main(): + args = parse_args() + session_id = str(uuid.uuid4()) + uuid_a, uuid_b = str(uuid.uuid4()), str(uuid.uuid4()) + prompt_a = args.prompt_a or PROMPT_A.format(s=args.sleep) + prompt_b = args.prompt_b or PROMPT_B + cmd = build_command(args, session_id) + + if args.dry_run: + print(" ".join(cmd)) + print("\n--- prompt A ---\n" + prompt_a) + print("\n--- prompt B ---\n" + prompt_b) + return 0 + + out_path = args.out or os.path.abspath(f"midturn-fold-{int(time.time())}.jsonl") + rec = Recorder() + rec.note("start", cmd=cmd, session_id=session_id, + uuid_a=uuid_a, uuid_b=uuid_b, cli_cwd=os.getcwd()) + + # start_new_session puts the CLI in its own process group so a forced + # cleanup can signal the whole tree (CLI + tool subprocesses), not just the + # direct child. + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + start_new_session=True, + ) + events = queue.Queue() + readers = [ + threading.Thread(target=reader, args=(proc.stdout, "out", rec, events, True), + daemon=True), + threading.Thread(target=reader, args=(proc.stderr, "err", rec, events, False), + daemon=True), + ] + for t in readers: + t.start() + + def send(frame, label): + rec.add("in", frame) + rec.note(label) + try: + proc.stdin.write(json.dumps(frame) + "\n") + proc.stdin.flush() + return True + except (BrokenPipeError, ValueError): + rec.note("send_failed", label=label) + return False + + send(user_frame(prompt_a, session_id, uuid_a), "sent_a") + + # --- observation state ------------------------------------------------- + init = None + sent_b_at = None + first_tool_at = None + result_times = [] + b_echo_at = None + b_lifecycle = [] # [(t_ms, state)] + tool_results_after_b = 0 + verdict_deadline = None + hard_deadline = rec.t0 + args.timeout + # Arrival order, not elapsed milliseconds, decides fold vs queue: a fold can + # put B's echo and the result in the same millisecond, and order is what the + # protocol actually guarantees. + pre_result_echo = False + pre_result_start = False + + def maybe_send_b(now_ms): + nonlocal sent_b_at + if sent_b_at is not None: + return + if init is None: + # Before the init frame the session does not exist yet, so B would + # not be landing mid-turn — it would be racing startup. + return + if args.after_seconds is not None: + if now_ms >= args.after_seconds * 1000: + if send(user_frame(prompt_b, session_id, uuid_b), "sent_b"): + sent_b_at = rec.ms() + return + if first_tool_at is not None and now_ms >= first_tool_at + args.delay * 1000: + if send(user_frame(prompt_b, session_id, uuid_b), "sent_b"): + sent_b_at = rec.ms() + + while True: + if time.monotonic() > hard_deadline: + rec.note("hard_timeout") + break + try: + item = events.get(timeout=0.25) + except queue.Empty: + maybe_send_b(rec.ms()) + if verdict_deadline is not None and time.monotonic() > verdict_deadline: + rec.note("linger_expired") + break + continue + if isinstance(item, StreamEOF): + rec.note("stream_eof", stream=item.direction) + if item.is_protocol: + break + continue + t_ms, frame = item + ftype = frame.get("type") + + if ftype == "system" and frame.get("subtype") == "init": + init = frame + elif ftype == "result": + result_times.append(t_ms) + if len(result_times) == 1: + # Either B already landed inside this turn (fold) or it has to + # run as its own turn (queue). A fold is already decided, so + # only drain briefly; a queue needs the full second-turn window. + folded = pre_result_echo or pre_result_start + verdict_deadline = time.monotonic() + (3.0 if folded else args.linger) + else: + rec.note("second_result") + break + elif ftype == "command_lifecycle" and frame.get("command_uuid") == uuid_b: + b_lifecycle.append((t_ms, frame.get("state"))) + if frame.get("state") == "started" and not result_times: + pre_result_start = True + elif ftype == "user": + if frame.get("uuid") == uuid_b and b_echo_at is None: + b_echo_at = t_ms + pre_result_echo = not result_times + rec.note("b_echo_observed", before_first_result=pre_result_echo) + content = frame.get("message", {}).get("content") or [] + if b_echo_at is not None and isinstance(content, list): + if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in content): + tool_results_after_b += 1 + elif ftype == "assistant": + if first_tool_at is None and tool_use_names(frame): + first_tool_at = t_ms + rec.note("first_tool_use_observed", tools=tool_use_names(frame)) + + maybe_send_b(rec.ms()) + if verdict_deadline is not None and time.monotonic() > verdict_deadline: + rec.note("linger_expired") + break + + # --- shutdown ---------------------------------------------------------- + try: + proc.stdin.close() + except (BrokenPipeError, ValueError): + pass + try: + proc.wait(timeout=20) + except subprocess.TimeoutExpired: + rec.note("kill_after_stdin_close") + _kill_process_group(proc) + proc.wait(timeout=10) + + # Drain the readers before the log is written: frames that arrived between + # the loop breaking and the process exiting are still being appended, and a + # probe that silently drops its last frames is worse than no probe. + for t in readers: + t.join(timeout=5) + + first_result = result_times[0] if result_times else None + started = [t for t, s in b_lifecycle if s == "started"] + b_started = started[0] if started else None + + if sent_b_at is None: + verdict = "INCONCLUSIVE — prompt B was never sent (no tool_use seen; try --after-seconds)" + elif not result_times: + verdict = "INCONCLUSIVE — no result frame; check the log for auth, permission or budget errors" + elif pre_result_echo or pre_result_start: + verdict = "FOLD — B entered the running turn before it ended (mid-turn steering works)" + elif len(result_times) >= 2 or b_started is not None or b_echo_at is not None: + verdict = "QUEUE — B ran only after the first turn ended (turn-boundary drain)" + elif b_lifecycle: + verdict = "DISCARDED — B was acknowledged but never ran; see lifecycle states" + else: + verdict = "NO_LIFECYCLE — no echo and no lifecycle for B; it was dropped, or this CLI does not report either" + + rec.note("verdict", text=verdict) + rec.write(out_path) + + caps = (init or {}).get("capabilities") + print(f"\nverdict: {verdict}\n") + version = (init or {}).get("claude_code_version") or (init or {}).get("version", "?") + print(f" cli version {version}") + print(f" permission mode {(init or {}).get('permissionMode', '?')}") + print(f" capabilities {caps if caps is not None else 'not advertised'}") + print(f" msg_lifecycle_v1 {'yes' if caps and 'msg_lifecycle_v1' in caps else 'no'}") + print(f" result frames {len(result_times)} (1 = one turn answered both prompts)") + print(f" B sent at {sent_b_at} ms") + print(f" first tool_use at {first_tool_at} ms") + print(f" first result at {first_result} ms") + print(f" B echo at " + f" {b_echo_at} ms" + (" (before first result)" if pre_result_echo else "")) + print(f" B lifecycle {b_lifecycle or 'none'}") + print(f" tool_results after B {tool_results_after_b} " + f"(>0 = predecessor tool work continued past the boundary)") + print(f"\n frames: {out_path}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main())