Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/architecture/control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
108 changes: 108 additions & 0 deletions internal/acp/acp_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
94 changes: 94 additions & 0 deletions internal/acp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions internal/engine/agent/background_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 16 additions & 5 deletions internal/engine/agent_reexports.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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) }
Loading