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
200 lines
8.3 KiB
Go
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)
|
|
}
|