Files
tutti/apps/cli/internal/app/tutti_mode_plan_contract_test.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

200 lines
8.3 KiB
Go

package app
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const tuttiModePlanCapabilitiesFixture = `{"commands":[
{"id":"tutti-mode-plan.plan.propose","path":["plan","propose"],"summary":"Propose a Tutti Mode plan","inputSchema":{"type":"object","required":["file","request-id"],"properties":{"file":{"type":"string","description":"Markdown proposal file."},"request-id":{"type":"string"}}},"output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}},
{"id":"tutti-mode-plan.plan.revise","path":["plan","revise"],"summary":"Revise a Tutti Mode plan","inputSchema":{"type":"object","required":["workflow-id","file","request-id"],"properties":{"workflow-id":{"type":"string"},"file":{"type":"string"},"request-id":{"type":"string"}}},"output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}},
{"id":"tutti-mode-plan.plan.get","path":["plan","get"],"summary":"Get a Tutti Mode plan","inputSchema":{"type":"object","required":["workflow-id"],"properties":{"workflow-id":{"type":"string"}}},"output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}},
{"id":"tutti-mode-plan.plan.issue.schedule","path":["plan","issue","schedule"],"summary":"Schedule exact Tutti Mode Issue tasks","inputSchema":{"type":"object","required":["issue-id","checkpoint-id","expected-graph-revision","task-ids-json","request-id"],"properties":{"issue-id":{"type":"string"},"checkpoint-id":{"type":"string"},"expected-graph-revision":{"type":"integer"},"task-ids-json":{"type":"string"},"request-id":{"type":"string"}}},"output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}}
]}`
func TestRunTuttiModePlanCommandsUseDynamicDaemonCapabilityProtocol(t *testing.T) {
tests := []struct {
name string
commandID string
args []string
wantInput map[string]any
}{
{
name: "propose",
commandID: "tutti-mode-plan.plan.propose",
args: []string{"plan", "propose", "--file", "/tmp/proposal.md", "--request-id", "proposal-request-1"},
wantInput: map[string]any{"file": "/tmp/proposal.md", "request-id": "proposal-request-1"},
},
{
name: "revise",
commandID: "tutti-mode-plan.plan.revise",
args: []string{"plan", "revise", "--workflow-id", "WF-1", "--file", "/tmp/revision.md", "--request-id", "revision-request-1"},
wantInput: map[string]any{"workflow-id": "WF-1", "file": "/tmp/revision.md", "request-id": "revision-request-1"},
},
{
name: "get",
commandID: "tutti-mode-plan.plan.get",
args: []string{"plan", "get", "--workflow-id", "WF-1"},
wantInput: map[string]any{"workflow-id": "WF-1"},
},
{
// The checkpoint-fenced commands carry an integer revision fence.
// The daemon validates invocation input against the advertised
// schema before binding it, so the fence has to reach the wire as
// a JSON number instead of a quoted terminal argument.
name: "issue schedule",
commandID: "tutti-mode-plan.plan.issue.schedule",
args: []string{
"plan", "issue", "schedule",
"--issue-id", "ISSUE-1",
"--checkpoint-id", "CP-1",
"--expected-graph-revision", "1",
"--task-ids-json", `["task-1"]`,
"--request-id", "schedule-request-1",
},
wantInput: map[string]any{
"issue-id": "ISSUE-1",
"checkpoint-id": "CP-1",
"expected-graph-revision": 1,
"task-ids-json": `["task-1"]`,
"request-id": "schedule-request-1",
},
},
{
name: "issue schedule with inline flag values",
commandID: "tutti-mode-plan.plan.issue.schedule",
args: []string{
"plan", "issue", "schedule",
"--issue-id=ISSUE-1",
"--checkpoint-id=CP-1",
"--expected-graph-revision=12",
`--task-ids-json=["task-1"]`,
"--request-id=schedule-request-2",
},
wantInput: map[string]any{
"issue-id": "ISSUE-1",
"checkpoint-id": "CP-1",
"expected-graph-revision": 12,
"task-ids-json": `["task-1"]`,
"request-id": "schedule-request-2",
},
},
{
// A value the declared type cannot hold stays untouched so the
// daemon owns the rejection wording instead of the CLI.
name: "issue schedule with non-integer revision",
commandID: "tutti-mode-plan.plan.issue.schedule",
args: []string{
"plan", "issue", "schedule",
"--issue-id", "ISSUE-1",
"--checkpoint-id", "CP-1",
"--expected-graph-revision", "1.5",
"--task-ids-json", `["task-1"]`,
"--request-id", "schedule-request-3",
},
wantInput: map[string]any{
"issue-id": "ISSUE-1",
"checkpoint-id": "CP-1",
"expected-graph-revision": "1.5",
"task-ids-json": `["task-1"]`,
"request-id": "schedule-request-3",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var invokedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/v1/cli/capabilities":
_, _ = w.Write([]byte(tuttiModePlanCapabilitiesFixture))
case "/v1/cli/commands/" + tt.commandID + "/invoke":
if err := json.NewDecoder(r.Body).Decode(&invokedBody); err != nil {
t.Fatalf("decode body: %v", err)
}
_, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"json","value":{"workflowId":"WF-1","checkpoint":{"id":"CP-1","status":"pending"}}}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
writeEndpoint(t, server.URL, "token-1")
t.Setenv("TUTTI_WORKSPACE_ID", "workspace-1")
t.Setenv("TUTTI_AGENT_SESSION_ID", "session-1")
t.Setenv("TUTTI_APP_CLI_PARENT_COMMAND_ID", "tool-call-1")
t.Setenv("TUTTI_AGENT_CWD", "/workspace/project/worktree")
t.Setenv("TUTTI_AGENT_RAIL_PLACEMENT", `{"version":1,"kind":"project","projectPath":"/workspace/project","sectionKey":"project:/workspace/project"}`)
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := runDefaultProgram(t, tt.args, &stdout, &stderr); code != 0 {
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
}
input, ok := invokedBody["input"].(map[string]any)
if !ok {
t.Fatalf("input = %#v", invokedBody["input"])
}
if encoded, _ := json.Marshal(input); string(encoded) != mustJSON(t, tt.wantInput) {
t.Fatalf("input = %s, want %s", encoded, mustJSON(t, tt.wantInput))
}
contextValue, ok := invokedBody["context"].(map[string]any)
if !ok || contextValue["source"] != "cli" || contextValue["workspaceID"] != "workspace-1" || contextValue["agentSessionId"] != "session-1" || contextValue["parentCommandId"] != "tool-call-1" || contextValue["agentCwd"] != "/workspace/project/worktree" || contextValue["agentRailPlacementJSON"] != `{"version":1,"kind":"project","projectPath":"/workspace/project","sectionKey":"project:/workspace/project"}` {
t.Fatalf("context = %#v", invokedBody["context"])
}
if !strings.Contains(stdout.String(), `"workflowId": "WF-1"`) {
t.Fatalf("stdout = %q", stdout.String())
}
})
}
}
func TestRunTuttiModePlanHelpAdvertisesNoWaitAndNoDecisionMutation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/v1/cli/capabilities" {
_, _ = w.Write([]byte(tuttiModePlanCapabilitiesFixture))
return
}
http.NotFound(w, r)
}))
defer server.Close()
writeEndpoint(t, server.URL, "token-1")
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := runDefaultProgram(t, []string{"plan", "--help"}, &stdout, &stderr); code != 0 {
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
}
output := stdout.String()
for _, expected := range []string{"propose", "revise", "get"} {
if !strings.Contains(output, expected) {
t.Fatalf("help is missing %q:\n%s", expected, output)
}
}
// The review decision reaches the agent as a new user message; the CLI
// must neither block on it (wait) nor make it (decide).
for _, forbidden := range []string{"wait", "decide"} {
if strings.Contains(output, forbidden) {
t.Fatalf("agent-facing CLI must not advertise %q:\n%s", forbidden, output)
}
}
}
func mustJSON(t *testing.T, value any) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal value: %v", err)
}
return string(encoded)
}