diff --git a/internal/acp/handlers/handlers.go b/internal/acp/handlers/handlers.go index 3851160..6ee012f 100644 --- a/internal/acp/handlers/handlers.go +++ b/internal/acp/handlers/handlers.go @@ -49,6 +49,3 @@ func NewFsHandler() *FsHandler { return &FsHandler{Read: NewFsReadHandler(), Write: NewFsWriteHandler()} } -// PermissionHandler is the placeholder for session/request_permission. -// Fields and methods are added in Task D5. -type PermissionHandler struct{} diff --git a/internal/acp/handlers/server_permission.go b/internal/acp/handlers/server_permission.go new file mode 100644 index 0000000..7fc100c --- /dev/null +++ b/internal/acp/handlers/server_permission.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "context" + "encoding/json" + "strings" +) + +// PermissionHandler handles session/request_permission (spec §5.1). +// +// MVP behaviour (decision §1 #8 + §10 misc #1): fs.* tool calls are answered +// directly by the server (so they never reach this handler). Other tool calls +// default to "reject_once" — fully automated, no human-in-the-loop. +type PermissionHandler struct{} + +// NewPermissionHandler constructs a PermissionHandler. +func NewPermissionHandler() *PermissionHandler { return &PermissionHandler{} } + +type permissionParams struct { + SessionID string `json:"sessionId"` + ToolCall json.RawMessage `json:"toolCall"` + Options []permOption `json:"options"` +} + +type permOption struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` +} + +type permOutcomeSelected struct { + ID string `json:"id"` +} + +type permOutcome struct { + Selected *permOutcomeSelected `json:"selected,omitempty"` +} + +type permResult struct { + Outcome permOutcome `json:"outcome"` +} + +// Handle picks the first option whose ID looks like a "reject" choice; if none +// matches, falls back to the first option. Aligns with spec §10 misc #4 +// ("default reject"). +func (h *PermissionHandler) Handle(_ context.Context, _ SessionContext, paramsJSON json.RawMessage) FsResult { + var p permissionParams + if err := json.Unmarshal(paramsJSON, &p); err != nil { + return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}} + } + chosen := "" + for _, opt := range p.Options { + lc := strings.ToLower(opt.ID) + if strings.Contains(lc, "reject") || strings.Contains(lc, "deny") || strings.Contains(lc, "no") { + chosen = opt.ID + break + } + } + if chosen == "" && len(p.Options) > 0 { + chosen = p.Options[0].ID + } + if chosen == "" { + out, _ := json.Marshal(permResult{Outcome: permOutcome{}}) + return FsResult{OK: out} + } + out, _ := json.Marshal(permResult{Outcome: permOutcome{Selected: &permOutcomeSelected{ID: chosen}}}) + return FsResult{OK: out} +} diff --git a/internal/acp/handlers/server_permission_test.go b/internal/acp/handlers/server_permission_test.go new file mode 100644 index 0000000..70289a8 --- /dev/null +++ b/internal/acp/handlers/server_permission_test.go @@ -0,0 +1,68 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" +) + +func TestPermission_AutoReject(t *testing.T) { + t.Parallel() + h := handlers.NewPermissionHandler() + params := mustJSON(t, map[string]any{ + "sessionId": "x", + "toolCall": map[string]any{"name": "shell.exec"}, + "options": []any{ + map[string]any{"id": "allow_once"}, + map[string]any{"id": "reject_once"}, + }, + }) + res := h.Handle(context.Background(), handlers.SessionContext{}, params) + require.Nil(t, res.Err) + + var out struct { + Outcome struct { + Selected struct { + ID string `json:"id"` + } `json:"selected"` + } `json:"outcome"` + } + require.NoError(t, json.Unmarshal(res.OK, &out)) + assert.Equal(t, "reject_once", out.Outcome.Selected.ID) +} + +func TestPermission_NoRejectOption_FallbackFirst(t *testing.T) { + t.Parallel() + h := handlers.NewPermissionHandler() + params := mustJSON(t, map[string]any{ + "sessionId": "x", + "options": []any{ + map[string]any{"id": "allow_once"}, + map[string]any{"id": "allow_session"}, + }, + }) + res := h.Handle(context.Background(), handlers.SessionContext{}, params) + var out struct { + Outcome struct { + Selected struct { + ID string `json:"id"` + } `json:"selected"` + } `json:"outcome"` + } + require.NoError(t, json.Unmarshal(res.OK, &out)) + assert.Equal(t, "allow_once", out.Outcome.Selected.ID) +} + +func TestPermission_NoOptions_EmptyOutcome(t *testing.T) { + t.Parallel() + h := handlers.NewPermissionHandler() + params := mustJSON(t, map[string]any{"sessionId": "x"}) + res := h.Handle(context.Background(), handlers.SessionContext{}, params) + require.Nil(t, res.Err) + assert.JSONEq(t, `{"outcome":{}}`, string(res.OK)) +}