diff --git a/docs/architecture/control-plane.md b/docs/architecture/control-plane.md index fc830635..e692a48e 100644 --- a/docs/architecture/control-plane.md +++ b/docs/architecture/control-plane.md @@ -126,11 +126,20 @@ Single entry for subagents + background tasks: ### ACP - `initialize` advertises `hawkCapabilities` (work modes, isolation, lazy tools, …) - `session/new` returns `hawk` snapshot (`workMode`, `isolation`, `autoCommit`) and defaults act mode +- `session/setMode` — switch work mode (plan|act|review) +- `session/setIsolation` — apply isolation profile +- `session/status` — control-plane snapshot (mode, isolation, autoCommit, message count) + +### Deprecations +- `BackgroundAgentPool` / `NewBackgroundAgentPool*` / `FormatResults` marked Deprecated + in favor of `Session.SpawnController()` (same taskruntime.Registry). Retained + for compatibility; no production callers found. ## Not done yet (next iterations) - True 60s binary install path (packaging/CI) - Deeper ACP (session/setMode, client fs routing) + - Public Terminal-Bench scorecard - Optional: deprecate BackgroundAgentPool reexports diff --git a/internal/acp/acp_extra_test.go b/internal/acp/acp_extra_test.go index 7486147f..706426ac 100644 --- a/internal/acp/acp_extra_test.go +++ b/internal/acp/acp_extra_test.go @@ -50,6 +50,114 @@ func TestHandleSessionNew_FactoryError(t *testing.T) { } } +// --- handleSetMode / setIsolation / status tests --- + +func newSrvWithSession(t *testing.T, id string) (*Server, *bytes.Buffer) { + t.Helper() + srv := NewServer(testFactory) + var buf bytes.Buffer + srv.w = &buf + sess, err := testFactory() + if err != nil { + t.Fatalf("testFactory: %v", err) + } + srv.mu.Lock() + srv.sessions[id] = &acpSession{sess: sess} + srv.mu.Unlock() + return srv, &buf +} + +func TestHandleSetMode_Plan(t *testing.T) { + srv, buf := newSrvWithSession(t, "s1") + msg := rpcMessage{ + ID: []byte(`1`), Method: "session/setMode", + Params: []byte(`{"sessionId":"s1","mode":"plan"}`), + } + srv.handleSetMode(msg) + var resp struct { + Result struct { + SessionID string `json:"sessionId"` + WorkMode string `json:"workMode"` + } `json:"result"` + } + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("parse: %v (%s)", err, buf.String()) + } + if resp.Result.WorkMode != "plan" { + t.Fatalf("workMode = %q, want plan", resp.Result.WorkMode) + } +} + +func TestHandleSetMode_InvalidMode(t *testing.T) { + srv, buf := newSrvWithSession(t, "s1") + msg := rpcMessage{ + ID: []byte(`1`), Method: "session/setMode", + Params: []byte(`{"sessionId":"s1","mode":"banana"}`), + } + srv.handleSetMode(msg) + var resp rpcMessage + _ = json.Unmarshal(buf.Bytes(), &resp) + if resp.Error == nil { + t.Fatal("expected error for invalid mode") + } +} + +func TestHandleSetMode_UnknownSession(t *testing.T) { + srv, buf := newSrvWithSession(t, "s1") + msg := rpcMessage{ + ID: []byte(`1`), Method: "session/setMode", + Params: []byte(`{"sessionId":"nope","mode":"plan"}`), + } + srv.handleSetMode(msg) + var resp rpcMessage + _ = json.Unmarshal(buf.Bytes(), &resp) + if resp.Error == nil { + t.Fatal("expected error for unknown session") + } +} + +func TestHandleSetIsolation_Workspace(t *testing.T) { + srv, buf := newSrvWithSession(t, "s1") + msg := rpcMessage{ + ID: []byte(`1`), Method: "session/setIsolation", + Params: []byte(`{"sessionId":"s1","profile":"workspace"}`), + } + srv.handleSetIsolation(msg) + var resp struct { + Result struct { + Isolation string `json:"isolation"` + } `json:"result"` + } + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("parse: %v (%s)", err, buf.String()) + } + if resp.Result.Isolation != "workspace" { + t.Fatalf("isolation = %q, want workspace", resp.Result.Isolation) + } +} + +func TestHandleStatus_Snapshot(t *testing.T) { + srv, buf := newSrvWithSession(t, "s1") + msg := rpcMessage{ + ID: []byte(`1`), Method: "session/status", + Params: []byte(`{"sessionId":"s1"}`), + } + srv.handleStatus(msg) + var resp struct { + Result struct { + WorkMode string `json:"workMode"` + Isolation string `json:"isolation"` + AutoCommit bool `json:"autoCommit"` + } `json:"result"` + } + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("parse: %v (%s)", err, buf.String()) + } + if resp.Result.WorkMode == "" { + t.Fatal("expected non-empty workMode in status") + } +} + // --- handleCancel tests --- func TestHandleCancel_ValidSession(t *testing.T) { diff --git a/internal/acp/server.go b/internal/acp/server.go index 4d767409..db5bfa65 100644 --- a/internal/acp/server.go +++ b/internal/acp/server.go @@ -178,6 +178,12 @@ func (s *Server) handle(ctx context.Context, msg rpcMessage) { }) case "session/new": s.handleSessionNew(msg) + case "session/setMode": + s.handleSetMode(msg) + case "session/setIsolation": + s.handleSetIsolation(msg) + case "session/status": + s.handleStatus(msg) case "session/prompt": s.handlePrompt(ctx, msg) case "session/cancel": @@ -189,6 +195,94 @@ func (s *Server) handle(ctx context.Context, msg rpcMessage) { } } +type setModeParams struct { + SessionID string `json:"sessionId"` + Mode string `json:"mode"` +} + +// handleSetMode switches the session's work mode (plan|act|review). +func (s *Server) handleSetMode(msg rpcMessage) { + var p setModeParams + if err := json.Unmarshal(msg.Params, &p); err != nil { + s.writeError(msg.ID, errCodeInvalidParams, "invalid params") + return + } + as := s.lookupSession(p.SessionID) + if as == nil { + s.writeError(msg.ID, errCodeInvalidParams, "unknown sessionId") + return + } + if err := as.sess.SetWorkMode(engine.WorkMode(p.Mode)); err != nil { + s.writeError(msg.ID, errCodeInvalidParams, err.Error()) + return + } + s.reply(msg.ID, map[string]any{ + "sessionId": p.SessionID, + "workMode": string(as.sess.WorkMode()), + }) +} + +type setIsolationParams struct { + SessionID string `json:"sessionId"` + Profile string `json:"profile"` +} + +// handleSetIsolation applies an IsolationProfile (dev|workspace|strict|container or key=value). +func (s *Server) handleSetIsolation(msg rpcMessage) { + var p setIsolationParams + if err := json.Unmarshal(msg.Params, &p); err != nil { + s.writeError(msg.ID, errCodeInvalidParams, "invalid params") + return + } + as := s.lookupSession(p.SessionID) + if as == nil { + s.writeError(msg.ID, errCodeInvalidParams, "unknown sessionId") + return + } + prof, err := engine.ParseIsolationProfile(p.Profile) + if err != nil { + s.writeError(msg.ID, errCodeInvalidParams, err.Error()) + return + } + as.sess.ApplyIsolationProfile(prof) + s.reply(msg.ID, map[string]any{ + "sessionId": p.SessionID, + "isolation": as.sess.Isolation().String(), + }) +} + +type statusParams struct { + SessionID string `json:"sessionId"` +} + +// handleStatus returns the control-plane snapshot for a session. +func (s *Server) handleStatus(msg rpcMessage) { + var p statusParams + if err := json.Unmarshal(msg.Params, &p); err != nil { + s.writeError(msg.ID, errCodeInvalidParams, "invalid params") + return + } + as := s.lookupSession(p.SessionID) + if as == nil { + s.writeError(msg.ID, errCodeInvalidParams, "unknown sessionId") + return + } + s.reply(msg.ID, map[string]any{ + "sessionId": p.SessionID, + "workMode": string(as.sess.WorkMode()), + "isolation": as.sess.Isolation().String(), + "autoCommit": as.sess.AutoCommit(), + "messages": as.sess.MessageCount(), + }) +} + +// lookupSession returns the acpSession for id, or nil. +func (s *Server) lookupSession(id string) *acpSession { + s.mu.Lock() + defer s.mu.Unlock() + return s.sessions[id] +} + func (s *Server) handleSessionNew(msg rpcMessage) { sess, err := s.factory() if err != nil { diff --git a/internal/engine/agent/background_agent.go b/internal/engine/agent/background_agent.go index b191d07e..77f4bf7f 100644 --- a/internal/engine/agent/background_agent.go +++ b/internal/engine/agent/background_agent.go @@ -13,6 +13,11 @@ import ( // BackgroundAgentPool manages async sub-agents that run in the background. // PACK-02: backed by taskruntime.Registry (shared with tool.BackgroundAgentManager). +// +// Deprecated: new code should use Session.SpawnController() (Spawn / +// SpawnBackground / Tasks) which shares the same taskruntime.Registry via +// ToolService.EnsureBackgroundManager. This pool is retained for older +// callers and tests. type BackgroundAgentPool struct { mu sync.Mutex reg *taskruntime.Registry diff --git a/internal/engine/agent_reexports.go b/internal/engine/agent_reexports.go index fbcb26eb..41caf109 100644 --- a/internal/engine/agent_reexports.go +++ b/internal/engine/agent_reexports.go @@ -10,9 +10,12 @@ import ( ) type ( - SubAgentMode = agent.SubAgentMode - SubAgentConfig = agent.SubAgentConfig - SubAgentBudget = agent.SubAgentBudget + SubAgentMode = agent.SubAgentMode + SubAgentConfig = agent.SubAgentConfig + SubAgentBudget = agent.SubAgentBudget + // Deprecated: use Session.SpawnController() and BackgroundAgentManager + // (taskruntime-backed) instead. BackgroundAgentPool is retained for + // compatibility with older callers and tests. BackgroundAgentPool = agent.BackgroundAgentPool BackgroundResult = agent.BackgroundResult ) @@ -41,10 +44,18 @@ func NewSubAgentBudget(mode SubAgentMode, cfg SubAgentConfig) *SubAgentBudget { func FilterToolsForMode(mode SubAgentMode, available []string) []string { return agent.FilterToolsForMode(mode, available) } -func DefaultTurnsForMode(mode SubAgentMode) int { return agent.DefaultTurnsForMode(mode) } -func IsReadOnlyMode(mode SubAgentMode) bool { return agent.IsReadOnlyMode(mode) } +func DefaultTurnsForMode(mode SubAgentMode) int { return agent.DefaultTurnsForMode(mode) } +func IsReadOnlyMode(mode SubAgentMode) bool { return agent.IsReadOnlyMode(mode) } + +// Deprecated: prefer Session.SpawnController().SpawnBackground for async +// sub-agents. Retained for compatibility. func NewBackgroundAgentPool() *BackgroundAgentPool { return agent.NewBackgroundAgentPool() } + +// Deprecated: prefer Session.SpawnController().SpawnBackground for async +// sub-agents. Retained for compatibility. func NewBackgroundAgentPoolWithContext(ctx context.Context) *BackgroundAgentPool { return agent.NewBackgroundAgentPoolWithContext(ctx) } + +// Deprecated: prefer SpawnController for background result formatting. func FormatResults(results []BackgroundResult) string { return agent.FormatResults(results) }