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 0000000000..4ea989ff4a Binary files /dev/null and b/docs/plans/mid-turn-steering/assets/mid-turn-steering-composer.png differ 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())