package app import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "reflect" "runtime" "strings" "testing" "time" "github.com/tutti-os/tutti/apps/cli/internal/daemon" ) func runDefaultProgram(t *testing.T, args []string, stdout *bytes.Buffer, stderr *bytes.Buffer) int { t.Helper() return RunWithProgram(t.Context(), "tutti", args, stdout, stderr) } func TestCliInvokeContextFromEnvIncludesAgentRuntimeContext(t *testing.T) { t.Setenv("TUTTI_APP_ID", " automation-app ") t.Setenv("TUTTI_WORKSPACE_ID", " workspace-1 ") t.Setenv("TUTTI_APP_CLI_PARENT_COMMAND_ID", " parent-1 ") t.Setenv("TUTTI_AGENT_SESSION_ID", " session-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"} `) context := cliInvokeContextFromEnv() if context.AppID != "automation-app" || context.Source != "cli" || context.WorkspaceID != "workspace-1" || context.ParentCommandID != "parent-1" || context.AgentSessionID != "session-1" || context.AgentCWD != "/workspace/project/worktree" || context.AgentRailPlacementJSON != `{"version":1,"kind":"project","projectPath":"/workspace/project","sectionKey":"project:/workspace/project"}` { t.Fatalf("context = %#v", context) } } func TestHydrateDynamicStdinInputForwardsRawJSON(t *testing.T) { previous := dynamicInputReader dynamicInputReader = func() io.Reader { return strings.NewReader(`{"scope":"desktop","path":"C:\\Temp\\示例.png"}`) } t.Cleanup(func() { dynamicInputReader = previous }) command := daemon.Capability{InputSchema: map[string]any{ "properties": map[string]any{ "arguments-json": map[string]any{"type": "string"}, }, }} input, err := hydrateDynamicStdinInput(command, map[string]any{ "name": "click", "arguments-json": "-", }) if err != nil { t.Fatalf("hydrate stdin: %v", err) } if input["arguments-json"] != `{"scope":"desktop","path":"C:\\Temp\\示例.png"}` { t.Fatalf("input = %#v", input) } } func TestRunDynamicComputerToolCallReadsArgumentsFromStdin(t *testing.T) { previous := dynamicInputReader dynamicInputReader = func() io.Reader { return strings.NewReader(`{"scope":"desktop","path":"C:\\Temp\\示例.png"}`) } t.Cleanup(func() { dynamicInputReader = previous }) var invoked daemon.InvokeRequest 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(`{"commands":[{"id":"computer.tool.call","path":["computer","tool","call"],"summary":"Call tool","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"arguments-json":{"type":"string"}},"required":["name"]},"output":{"defaultMode":"json","json":true}}]}`)) case "/v1/cli/commands/computer.tool.call/invoke": if err := json.NewDecoder(r.Body).Decode(&invoked); err != nil { t.Fatalf("decode invoke: %v", err) } _, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"json","value":{"ok":true}}}`)) default: http.NotFound(w, r) } })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer code := runDefaultProgram(t, []string{ "computer", "tool", "call", "--name", "click", "--arguments-json", "-", "--json", }, &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } if invoked.Input["arguments-json"] != `{"scope":"desktop","path":"C:\\Temp\\示例.png"}` { t.Fatalf("invoke input = %#v", invoked.Input) } } func TestWindowsPowerShellUTF8PipelinePreservesDynamicInput(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows PowerShell encoding contract") } powershell, err := exec.LookPath("powershell.exe") if err != nil { t.Skip("Windows PowerShell is unavailable") } executable := strings.ReplaceAll(os.Args[0], "'", "''") script := "$utf8 = [Text.UTF8Encoding]::new($false); " + "$OutputEncoding = $utf8; [Console]::OutputEncoding = $utf8; " + `'{"path":"C:\\Temp\\示例.png"}' | & '` + executable + `' '-test.run=^TestDynamicStdinUTF8Helper$'` command := exec.Command(powershell, "-NoProfile", "-NonInteractive", "-Command", script) command.Env = append(os.Environ(), "TUTTI_TEST_STDIN_UTF8_HELPER=1") output, err := command.CombinedOutput() if err != nil { t.Fatalf("PowerShell pipeline: %v: %s", err, output) } got := strings.TrimPrefix(strings.TrimSpace(string(output)), "\ufeff") if want := `{"path":"C:\\Temp\\示例.png"}`; got != want { t.Fatalf("stdin = %q, want %q", got, want) } } func TestDynamicStdinUTF8Helper(_ *testing.T) { if os.Getenv("TUTTI_TEST_STDIN_UTF8_HELPER") != "1" { return } _, _ = io.Copy(os.Stdout, os.Stdin) os.Exit(0) } func TestWriteDynamicJSONKeepsCommandWarningsOutOfAppValue(t *testing.T) { output := daemon.CommandOutput{ Kind: "json", Value: map[string]any{ "ok": true, "warnings": "app-defined", }, Warnings: []daemon.CommandWarning{{ Code: "unknown_input_ignored", Message: "Unknown input ignored.", }}, } var stdout bytes.Buffer var stderr bytes.Buffer if code := writeCommandOutput(&stdout, &stderr, output); code != 0 { t.Fatalf("code = %d stderr = %q", code, stderr.String()) } var envelope map[string]any if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { t.Fatalf("decode stdout: %v\n%s", err, stdout.String()) } value := envelope["value"].(map[string]any) if value["warnings"] != "app-defined" { t.Fatalf("app warnings field was clobbered: %#v", value["warnings"]) } warnings := envelope["warnings"].([]any) if len(warnings) != 1 || warnings[0].(map[string]any)["code"] != "unknown_input_ignored" { t.Fatalf("warnings = %#v", envelope["warnings"]) } } func TestRunHelpIncludesIntegrationCapabilitiesInsideAppRuntime(t *testing.T) { var sawCapabilitiesRequest bool 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" { http.NotFound(w, r) return } sawCapabilitiesRequest = true if r.URL.Query().Get("workspaceID") != "ws-1" { t.Fatalf("workspaceID query = %q", r.URL.RawQuery) } if r.URL.Query().Get("includeIntegration") != "true" { t.Fatalf("includeIntegration query = %q", r.URL.RawQuery) } if r.URL.Query().Get("includeHidden") != "" { t.Fatalf("includeHidden query = %q", r.URL.RawQuery) } _, _ = w.Write([]byte(`{"commands":[{"id":"workspace-apps.app.open","path":["app","open"],"summary":"Open app","visibility":"integration","output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}}]}`)) })) defer server.Close() writeEndpoint(t, server.URL, "token-1") t.Setenv("TUTTI_APP_ID", "automation-app") t.Setenv("TUTTI_CLI", "/tmp/tutti") t.Setenv("TUTTI_WORKSPACE_ID", "ws-1") var stdout bytes.Buffer var stderr bytes.Buffer if code := runDefaultProgram(t, []string{"--help"}, &stdout, &stderr); code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } if !sawCapabilitiesRequest { t.Fatal("capabilities request was not sent") } if !strings.Contains(stdout.String(), "integration-only") || !strings.Contains(stdout.String(), "Do not expose or forward") { t.Fatalf("stdout = %q", stdout.String()) } } func TestRunHelpDoesNotIncludeIntegrationCapabilitiesWithoutAppCLIContract(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" { http.NotFound(w, r) return } if r.URL.Query().Get("includeIntegration") != "" { t.Fatalf("includeIntegration query = %q", r.URL.RawQuery) } if r.URL.Query().Get("includeHidden") != "" { t.Fatalf("includeHidden query = %q", r.URL.RawQuery) } _, _ = w.Write([]byte(`{"commands":[]}`)) })) defer server.Close() writeEndpoint(t, server.URL, "token-1") t.Setenv("TUTTI_APP_ID", "draft-app") var stdout bytes.Buffer var stderr bytes.Buffer if code := runDefaultProgram(t, []string{"--help"}, &stdout, &stderr); code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } } func TestAgentSessionDiscoversAndInvokesProjectedIntegrationCapability(t *testing.T) { var invoked bool 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": if r.URL.Query().Get("workspaceID") != "workspace-1" || r.URL.Query().Get("agentSessionID") != "review-session-1" { t.Fatalf("capability query = %q", r.URL.RawQuery) } _, _ = w.Write([]byte(`{"commands":[{"id":"tutti-goal-review.goal-review.verdict","path":["goal-review","verdict"],"summary":"Submit verdict","visibility":"integration","inputSchema":{"type":"object","properties":{"issue-id":{"type":"string"}},"required":["issue-id"]},"output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}}]}`)) case "/v1/cli/commands/tutti-goal-review.goal-review.verdict/invoke": invoked = true _, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"json","value":{"verdict":"goal_satisfied"}}}`)) 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", "review-session-1") var stdout bytes.Buffer var stderr bytes.Buffer if code := runDefaultProgram(t, []string{ "--json", "goal-review", "verdict", "--issue-id", "issue-1", }, &stdout, &stderr); code != 0 { t.Fatalf("code = %d stderr = %q", code, stderr.String()) } if !invoked || !strings.Contains(stdout.String(), "goal_satisfied") { t.Fatalf("invoked = %v stdout = %q", invoked, stdout.String()) } } func TestRunExactLegacyAgentCommandRetriesIntegrationDiscovery(t *testing.T) { capabilityRequests := 0 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": capabilityRequests++ if r.URL.Query().Get("includeIntegration") != "true" { _, _ = w.Write([]byte(`{"commands":[]}`)) return } _, _ = w.Write([]byte(`{"commands":[{"id":"agent-context.codex.start","path":["codex","start"],"summary":"Legacy start","visibility":"integration","inputSchema":{"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"]},"output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}}]}`)) case "/v1/cli/commands/agent-context.codex.start/invoke": _, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"json","value":{"agentSessionId":"SESSION-1"}}}`)) default: 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{"--json", "codex", "start", "--prompt", "review"}, &stdout, &stderr); code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } if capabilityRequests != 2 || !strings.Contains(stdout.String(), "SESSION-1") { t.Fatalf("requests = %d, stdout = %q", capabilityRequests, stdout.String()) } } func TestRunLegacySessionSummaryPreservesTopLevelJSONShape(t *testing.T) { 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": if r.URL.Query().Get("includeIntegration") != "true" { _, _ = w.Write([]byte(`{"commands":[]}`)) return } _, _ = w.Write([]byte(`{"commands":[{"id":"agent-context.agent.session-summary","path":["agent","session-summary"],"summary":"Deprecated summary","visibility":"integration","inputSchema":{"type":"object","properties":{"session-id":{"type":"string"}},"required":["session-id"]},"output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}}]}`)) case "/v1/cli/commands/agent-context.agent.session-summary/invoke": _, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"json","value":{"session":{"agentSessionId":"SESSION-1"},"messages":[],"latestVersion":9,"hasMore":false}}}`)) default: 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{"--json", "agent", "session-summary", "--session-id", "SESSION-1"}, &stdout, &stderr); code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } var output map[string]any if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("decode stdout: %v\n%s", err, stdout.String()) } if output["session"].(map[string]any)["agentSessionId"] != "SESSION-1" || output["latestVersion"] != float64(9) { t.Fatalf("output = %#v", output) } if _, wrapped := output["value"]; wrapped { t.Fatalf("legacy JSON was wrapped: %#v", output) } if _, warned := output["warnings"]; warned || stderr.Len() != 0 { t.Fatalf("legacy JSON emitted runtime warning: output=%#v stderr=%q", output, stderr.String()) } } func TestLegacyAgentCompatibilityInvocationIsExactAllowlist(t *testing.T) { for _, args := range [][]string{ {"agent", "providers"}, {"agent", "cancel", "--session-id", "SESSION-1"}, {"agent", "session-summary", "--session-id", "SESSION-1"}, {"codex", "start", "--prompt", "review"}, {"claude", "start", "--prompt", "review"}, } { if !legacyAgentCompatibilityInvocation(args) { t.Fatalf("expected compatibility path for %#v", args) } } for _, args := range [][]string{ {"agent", "start"}, {"agent", "tutti-cli-skill-bundle"}, {"diagnostics", "ping"}, {"codex"}, } { if legacyAgentCompatibilityInvocation(args) { t.Fatalf("unexpected compatibility path for %#v", args) } } } func TestRunStatusJSON(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/health" { t.Fatalf("path = %q", r.URL.Path) } if got := r.Header.Get("Authorization"); got != "Bearer token-1" { t.Fatalf("authorization = %q", got) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"service":"tuttid","status":"ok"}`)) })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer if code := runDefaultProgram(t, []string{"status", "--json"}, &stdout, &stderr); code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } var payload struct { Service string `json:"service"` Status string `json:"status"` } if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { t.Fatalf("unmarshal stdout: %v\n%s", err, stdout.String()) } if payload.Service != "tuttid" || payload.Status != "ok" { t.Fatalf("payload = %#v", payload) } } func TestRunStatusAuthFailure(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "nope", http.StatusUnauthorized) })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer if code := runDefaultProgram(t, []string{"status"}, &stdout, &stderr); code != 1 { t.Fatalf("code = %d, want 1", code) } if !strings.Contains(stderr.String(), "daemon authentication failed") { t.Fatalf("stderr = %q", stderr.String()) } } func TestRunStatusJSONAuthFailureIsStructured(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "nope", http.StatusUnauthorized) })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer if code := runDefaultProgram(t, []string{"status", "--json"}, &stdout, &stderr); code != 1 { t.Fatalf("code = %d, want 1", code) } assertCLIJSONError(t, stdout.Bytes(), "unauthorized", "daemon authentication failed") if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestRunStatusJSONDaemonUnavailableIsStructured(t *testing.T) { t.Setenv("TUTTID_LISTENER_INFO_PATH", filepath.Join(t.TempDir(), "missing-listener.json")) var stdout bytes.Buffer var stderr bytes.Buffer if code := runDefaultProgram(t, []string{"status", "--json"}, &stdout, &stderr); code != 1 { t.Fatalf("code = %d, want 1", code) } assertCLIJSONError(t, stdout.Bytes(), "daemon_unavailable", "daemon endpoint is not available") if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestRunDynamicCommandRendersTable(t *testing.T) { 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(`{"commands":[{"id":"issue.list","path":["issue","list"],"summary":"List issues","output":{"defaultMode":"table","json":true,"table":{"columns":[{"key":"id","label":"ID"},{"key":"title","label":"Title"}]}}}]}`)) case "/v1/cli/commands/issue.list/invoke": _, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"table","columns":[{"key":"id","label":"ID"},{"key":"title","label":"Title"}],"rows":[{"id":"ISS-1","title":"Fix startup"}]}}`)) default: 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{"issue", "list", "--status", "open"}, &stdout, &stderr); code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } if !strings.Contains(stdout.String(), "ISS-1") || !strings.Contains(stdout.String(), "Fix startup") { t.Fatalf("stdout = %q", stdout.String()) } } func TestRunDynamicCommandRendersJSONRows(t *testing.T) { 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(`{"commands":[{"id":"issue.list","path":["issue","list"],"summary":"List issues","output":{"defaultMode":"table","json":true}}]}`)) case "/v1/cli/commands/issue.list/invoke": _, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"table","rows":[{"id":"ISS-1"}]}}`)) default: 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{"--json", "issue", "list"}, &stdout, &stderr); code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } if !strings.Contains(stdout.String(), `"id": "ISS-1"`) { t.Fatalf("stdout = %q", stdout.String()) } } func TestRunWaitCommandRepeatsPendingInvocationsAndPrintsOnlyFinalJSON(t *testing.T) { invokeCount := 0 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(`{"commands":[{"id":"app.workflow.runs.wait","path":["workflow","runs","wait"],"summary":"Wait for run","inputSchema":{"type":"object","properties":{"run-id":{"type":"string"}},"required":["run-id"]},"output":{"defaultMode":"json","json":true},"execution":{"mode":"wait"},"handlerTimeoutMs":30000,"source":{"kind":"app"}}]}`)) case "/v1/cli/commands/app.workflow.runs.wait/invoke": invokeCount++ var body daemon.InvokeRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if body.Input["run-id"] != "RUN-1" { t.Fatalf("input = %#v", body.Input) } if invokeCount < 3 { _, _ = fmt.Fprintf(w, `{"ok":true,"output":{"kind":"json","value":{"status":"running","attempt":%d},"continuation":{"state":"pending","retryAfterMs":250}}}`, invokeCount) return } _, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"json","value":{"status":"completed","result":"done"}}}`)) default: 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{"--json", "workflow", "runs", "wait", "--run-id", "RUN-1"}, &stdout, &stderr); code != 0 { t.Fatalf("code = %d stderr = %q", code, stderr.String()) } if invokeCount != 3 { t.Fatalf("invokeCount = %d", invokeCount) } var output map[string]any if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("decode stdout: %v\n%s", err, stdout.String()) } if output["status"] != "completed" || output["result"] != "done" || strings.Contains(stdout.String(), "attempt") { t.Fatalf("output = %#v", output) } } func TestRunWaitCommandTotalTimeoutReturnsExecutionContinues(t *testing.T) { invokeCount := 0 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(`{"commands":[{"id":"app.workflow.runs.wait","path":["workflow","runs","wait"],"summary":"Wait for run","inputSchema":{"type":"object","properties":{"run-id":{"type":"string"}},"required":["run-id"]},"output":{"defaultMode":"json","json":true},"execution":{"mode":"wait"},"source":{"kind":"app"}}]}`)) case "/v1/cli/commands/app.workflow.runs.wait/invoke": invokeCount++ var body daemon.InvokeRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if _, leaked := body.Input["timeout-ms"]; leaked { t.Fatalf("total timeout leaked into app input: %#v", body.Input) } _, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"json","value":{"run":{"status":"running"}},"continuation":{"state":"pending","retryAfterMs":250}}}`)) default: 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{"--json", "workflow", "runs", "wait", "--run-id", "RUN-1", "--timeout-ms", "50"}, &stdout, &stderr); code != 0 { t.Fatalf("code = %d stderr = %q", code, stderr.String()) } var output map[string]any if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("decode stdout: %v\n%s", err, stdout.String()) } if output["reason"] != "wait_timeout" || output["timedOut"] != true || output["executionContinues"] != true { t.Fatalf("output = %#v", output) } last := output["lastResult"].(map[string]any) if last["run"].(map[string]any)["status"] != "running" || invokeCount != 1 { t.Fatalf("output = %#v invokeCount = %d", output, invokeCount) } } func TestRunWaitCommandHelpDescribesTotalTimeoutWithoutFollowFlag(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" { http.NotFound(w, r) return } _, _ = w.Write([]byte(`{"commands":[{"id":"app.workflow.runs.wait","path":["workflow","runs","wait"],"summary":"Wait for run","output":{"defaultMode":"json","json":true},"execution":{"mode":"wait"},"source":{"kind":"app"}}]}`)) })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer if code := runDefaultProgram(t, []string{"workflow", "runs", "wait", "--help"}, &stdout, &stderr); code != 0 { t.Fatalf("code = %d stderr = %q", code, stderr.String()) } if !strings.Contains(stdout.String(), "--timeout-ms") || !strings.Contains(stdout.String(), "Maximum total wait") || strings.Contains(stdout.String(), "--follow") { t.Fatalf("stdout = %q", stdout.String()) } } func TestDynamicHelpOnlyAdvertisesJSONForCommandsThatSupportIt(t *testing.T) { var stdout bytes.Buffer plainCommand := daemon.Capability{ Path: []string{"browser", "navigate"}, Output: daemon.CapabilityOutput{DefaultMode: "plain"}, Summary: "Navigate", } printDynamicCommandHelp(&stdout, "tutti", plainCommand) if strings.Contains(stdout.String(), "--json") { t.Fatalf("plain-only command help advertises JSON:\n%s", stdout.String()) } stdout.Reset() jsonCommand := plainCommand jsonCommand.Output.JSON = true printDynamicCommandHelp(&stdout, "tutti", jsonCommand) if !strings.Contains(stdout.String(), "--json") { t.Fatalf("JSON-capable command help omits JSON:\n%s", stdout.String()) } } func TestCommandPrefixHelpDoesNotPromiseJSONForEveryChild(t *testing.T) { var stdout bytes.Buffer if !printCommandPrefixHelp(&stdout, "tutti", []string{"browser"}, []daemon.Capability{{ Path: []string{"browser", "navigate"}, Output: daemon.CapabilityOutput{DefaultMode: "plain"}, Summary: "Navigate", }}) { t.Fatal("browser command prefix was not found") } if strings.Contains(strings.SplitN(stdout.String(), "\n", 2)[0], "--json") { t.Fatalf("prefix help promises JSON for every child:\n%s", stdout.String()) } } func TestRunWaitCommandTotalTimeoutDuringInvokeReturnsObservationNotTransportError(t *testing.T) { 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(`{"commands":[{"id":"app.workflow.runs.wait","path":["workflow","runs","wait"],"summary":"Wait for run","output":{"defaultMode":"json","json":true},"execution":{"mode":"wait"},"handlerTimeoutMs":30000,"source":{"kind":"app"}}]}`)) case "/v1/cli/commands/app.workflow.runs.wait/invoke": time.Sleep(150 * time.Millisecond) _, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"json","value":{"status":"completed"}}}`)) default: 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{"--json", "workflow", "runs", "wait", "--timeout-ms=40"}, &stdout, &stderr); code != 0 { t.Fatalf("code = %d stderr = %q stdout = %q", code, stderr.String(), stdout.String()) } var output map[string]any if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("decode stdout: %v\n%s", err, stdout.String()) } if output["reason"] != "wait_timeout" || output["executionContinues"] != true { t.Fatalf("output = %#v", output) } if _, hasLastResult := output["lastResult"]; hasLastResult { t.Fatalf("output unexpectedly has lastResult: %#v", output) } } func TestRunWaitCommandCancellationStopsLocalWaitWithoutBusinessCancel(t *testing.T) { invoked := make(chan struct{}, 1) var cancelRequests int 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(`{"commands":[{"id":"app.workflow.runs.wait","path":["workflow","runs","wait"],"summary":"Wait for run","output":{"defaultMode":"json","json":true},"execution":{"mode":"wait"},"source":{"kind":"app"}}]}`)) case "/v1/cli/commands/app.workflow.runs.wait/invoke": invoked <- struct{}{} _, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"json","value":{"status":"running"},"continuation":{"state":"pending","retryAfterMs":1000}}}`)) case "/v1/cli/commands/app.workflow.runs.cancel/invoke": cancelRequests++ http.Error(w, "unexpected cancel", http.StatusInternalServerError) default: http.NotFound(w, r) } })) defer server.Close() writeEndpoint(t, server.URL, "token-1") ctx, cancel := context.WithCancel(context.Background()) done := make(chan int, 1) var stdout bytes.Buffer var stderr bytes.Buffer go func() { done <- RunWithProgram(ctx, "tutti", []string{"--json", "workflow", "runs", "wait"}, &stdout, &stderr) }() select { case <-invoked: cancel() case <-time.After(time.Second): t.Fatal("wait invocation did not start") } select { case code := <-done: if code == 0 { t.Fatalf("code = 0 stdout = %q", stdout.String()) } case <-time.After(time.Second): t.Fatal("local wait did not stop after cancellation") } if cancelRequests != 0 { t.Fatalf("business cancel requests = %d", cancelRequests) } } func TestRunDynamicCommandMatchesMultiSegmentPath(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(`{"commands":[{"id":"issue-manager.issue.task.run.complete","path":["issue","task","run","complete"],"summary":"Complete run","output":{"defaultMode":"json","json":true}}]}`)) case "/v1/cli/commands/issue-manager.issue.task.run.complete/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":{"run":{"runId":"RUN-1"}}}}`)) default: http.NotFound(w, r) } })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer code := runDefaultProgram(t, []string{"--json", "issue", "task", "run", "complete", "--issue-id", "ISS-1", "--task-id=TASK-1", "--run-id", "RUN-1", "--status", "completed"}, &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } input := invokedBody["input"].(map[string]any) if input["issue-id"] != "ISS-1" || input["task-id"] != "TASK-1" || input["status"] != "completed" { t.Fatalf("input = %#v", input) } if !strings.Contains(stdout.String(), `"runId": "RUN-1"`) { t.Fatalf("stdout = %q", stdout.String()) } } func TestRunDynamicCommandAggregatesRepeatedFlags(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(`{"commands":[{"id":"workspace-apps.app.open","path":["app","open"],"summary":"Open app","inputSchema":{"type":"object","required":["app-id"],"properties":{"app-id":{"type":"string"},"param":{"type":"string"},"route":{"type":"string"}}},"output":{"defaultMode":"json","json":true}}]}`)) case "/v1/cli/commands/workspace-apps.app.open/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":{"ok":true}}}`)) default: http.NotFound(w, r) } })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer code := runDefaultProgram(t, []string{"app", "open", "--app-id", "docs", "--route", "/files", "--param", "path=/tmp/a", "--param", "mode=preview"}, &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } input := invokedBody["input"].(map[string]any) params, ok := input["param"].([]any) if !ok || len(params) != 2 || params[0] != "path=/tmp/a" || params[1] != "mode=preview" { t.Fatalf("param input = %#v", input["param"]) } } func TestRunDynamicCommandSendsSchemaDeclaredScalarTypes(t *testing.T) { const capabilities = `{"commands":[{"id":"agent-context.agent.messages","path":["agent","messages"],"summary":"Read messages","inputSchema":{"type":"object","required":["session-id"],"properties":{"session-id":{"type":"string"},"limit":{"type":"integer"},"waterline-percent":{"type":"number"},"follow":{"type":"boolean"}}},"output":{"defaultMode":"json","json":true}}]}` tests := []struct { name string args []string wantInput map[string]any }{ { name: "scalar flags reach the daemon as declared JSON types", args: []string{"agent", "messages", "--session-id", "S-1", "--limit", "25", "--waterline-percent", "12.5", "--follow", "yes"}, wantInput: map[string]any{"session-id": "S-1", "limit": float64(25), "waterline-percent": 12.5, "follow": true}, }, { name: "a bare boolean flag stays boolean", args: []string{"agent", "messages", "--session-id", "S-1", "--follow"}, wantInput: map[string]any{"session-id": "S-1", "follow": true}, }, { // Terminal text the declared type cannot hold is forwarded as-is: // the daemon owns input rejection wording, not the CLI. name: "unparseable values are forwarded unchanged", args: []string{"agent", "messages", "--session-id", "S-1", "--limit", "many"}, wantInput: map[string]any{"session-id": "S-1", "limit": "many"}, }, } 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(capabilities)) case "/v1/cli/commands/agent-context.agent.messages/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":{"ok":true}}}`)) default: 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, 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 !reflect.DeepEqual(input, tt.wantInput) { t.Fatalf("input = %#v, want %#v", input, tt.wantInput) } }) } } func TestRunDynamicAgentSendAggregatesRepeatedImageFlags(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(`{"commands":[{"id":"agent-context.agent.send","path":["agent","send"],"summary":"Send input","inputSchema":{"type":"object","required":["session-id","prompt"],"properties":{"session-id":{"type":"string"},"prompt":{"type":"string"},"image":{"type":"array","items":{"type":"string"}}}},"output":{"defaultMode":"json","json":true}}]}`)) case "/v1/cli/commands/agent-context.agent.send/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":{"ok":true}}}`)) default: http.NotFound(w, r) } })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer code := runDefaultProgram(t, []string{"agent", "send", "SESSION-1", "--prompt", "look", "--image", "/tmp/a.png", "--image", "/tmp/b.jpg"}, &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } input := invokedBody["input"].(map[string]any) if input["session-id"] != "SESSION-1" || input["prompt"] != "look" { t.Fatalf("input = %#v", input) } images, ok := input["image"].([]any) if !ok || len(images) != 2 || images[0] != "/tmp/a.png" || images[1] != "/tmp/b.jpg" { t.Fatalf("image input = %#v", input["image"]) } } func TestRunDynamicAgentSendSplitsPositionalPromptBeforeImageFlags(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(`{"commands":[{"id":"agent-context.agent.send","path":["agent","send"],"summary":"Send input","inputSchema":{"type":"object","required":["session-id","prompt"],"properties":{"session-id":{"type":"string"},"prompt":{"type":"string"},"image":{"type":"array","items":{"type":"string"}}}},"output":{"defaultMode":"json","json":true}}]}`)) case "/v1/cli/commands/agent-context.agent.send/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":{"ok":true}}}`)) default: http.NotFound(w, r) } })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer code := runDefaultProgram(t, []string{"agent", "send", "SESSION-1", "look", "here", "--image", "/tmp/a.png"}, &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } input := invokedBody["input"].(map[string]any) if input["session-id"] != "SESSION-1" || input["prompt"] != "look here" { t.Fatalf("input = %#v", input) } images, ok := input["image"].([]any) if !ok || len(images) != 1 || images[0] != "/tmp/a.png" { t.Fatalf("image input = %#v", input["image"]) } } func TestRunDynamicAgentSendSplitsPositionalPromptBeforeGuidanceFlag(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(`{"commands":[{"id":"agent-context.agent.send","path":["agent","send"],"summary":"Send input","inputSchema":{"type":"object","required":["session-id","prompt"],"properties":{"session-id":{"type":"string"},"prompt":{"type":"string"},"guidance":{"type":"boolean"},"image":{"type":"array","items":{"type":"string"}}}},"output":{"defaultMode":"json","json":true}}]}`)) case "/v1/cli/commands/agent-context.agent.send/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":{"ok":true}}}`)) default: http.NotFound(w, r) } })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer code := runDefaultProgram(t, []string{"agent", "send", "SESSION-1", "guide", "current", "turn", "--guidance"}, &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } input := invokedBody["input"].(map[string]any) if input["session-id"] != "SESSION-1" || input["prompt"] != "guide current turn" || input["guidance"] != true { t.Fatalf("input = %#v", input) } } func TestRunDynamicAgentSendKeepsFlagLikeTokensInPositionalPrompt(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(`{"commands":[{"id":"agent-context.agent.send","path":["agent","send"],"summary":"Send input","inputSchema":{"type":"object","required":["session-id","prompt"],"properties":{"session-id":{"type":"string"},"prompt":{"type":"string"},"image":{"type":"array","items":{"type":"string"}}}},"output":{"defaultMode":"json","json":true}}]}`)) case "/v1/cli/commands/agent-context.agent.send/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":{"ok":true}}}`)) default: http.NotFound(w, r) } })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer code := runDefaultProgram(t, []string{"agent", "send", "SESSION-1", "please", "run", "--help"}, &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } input := invokedBody["input"].(map[string]any) if input["session-id"] != "SESSION-1" || input["prompt"] != "please run --help" { t.Fatalf("input = %#v", input) } } func TestRunDynamicScopeHelpLimitsGroupedCommandPreview(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(`{"commands":[ {"id":"issue-manager.issue.task.a","path":["issue","task","a"],"summary":"A","output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}}, {"id":"issue-manager.issue.task.b","path":["issue","task","b"],"summary":"B","output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}}, {"id":"issue-manager.issue.task.c","path":["issue","task","c"],"summary":"C","output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}}, {"id":"issue-manager.issue.task.d","path":["issue","task","d"],"summary":"D","output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}}, {"id":"issue-manager.issue.task.e","path":["issue","task","e"],"summary":"E","output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}}, {"id":"issue-manager.issue.task.f","path":["issue","task","f"],"summary":"F","output":{"defaultMode":"json","json":true},"source":{"kind":"builtin"}} ]}`)) return } http.NotFound(w, r) })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer code := runDefaultProgram(t, []string{"issue", "--help"}, &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } output := stdout.String() for _, expected := range []string{ "task 6 commands", "a A", "e E", "... 1 more commands", } { if !strings.Contains(output, expected) { t.Fatalf("stdout missing %q:\n%s", expected, output) } } if strings.Contains(output, "f F") { t.Fatalf("stdout includes command beyond preview limit:\n%s", output) } } func TestRunRootHelpListsDynamicCommandScopes(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(`{"commands":[ {"id":"app.automation.automation.list","path":["automation","list"],"summary":"List automations","description":"List automation definitions.","output":{"defaultMode":"table","json":true},"source":{"kind":"app","appId":"automation","appName":"Automation","cliDescription":"Manage automations.","appDescription":"Create and run automations."}}, {"id":"agent-context.agent.list","path":["agent","list"],"summary":"List available agents","output":{"defaultMode":"table","json":true},"source":{"kind":"builtin"}} ]}`)) return } http.NotFound(w, r) })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer code := runDefaultProgram(t, []string{"--help"}, &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } output := stdout.String() for _, expected := range []string{ "status Show local tuttid status", "agent 1 commands", "automation Manage automations. 1 commands", } { if !strings.Contains(output, expected) { t.Fatalf("stdout missing %q:\n%s", expected, output) } } } func TestRunDynamicCommandPrefersLongestMatchingPath(t *testing.T) { var invokedPath string 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(`{"commands":[{"id":"agent-context.agent","path":["agent"],"summary":"Agent","output":{"defaultMode":"json","json":true}},{"id":"agent-context.agent.session.messages","path":["agent","session","messages"],"summary":"Messages","output":{"defaultMode":"json","json":true}}]}`)) case "/v1/cli/commands/agent-context.agent/invoke", "/v1/cli/commands/agent-context.agent.session.messages/invoke": invokedPath = r.URL.Path _, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"json","value":{"ok":true}}}`)) default: http.NotFound(w, r) } })) defer server.Close() writeEndpoint(t, server.URL, "token-1") var stdout bytes.Buffer var stderr bytes.Buffer code := runDefaultProgram(t, []string{"agent", "session", "messages", "--session-id", "SESSION-1", "--json"}, &stdout, &stderr) if code != 0 { t.Fatalf("code = %d, stderr = %s", code, stderr.String()) } if invokedPath != "/v1/cli/commands/agent-context.agent.session.messages/invoke" { t.Fatalf("invokedPath = %q", invokedPath) } } func TestRunDynamicCommandRejectsUnexpectedPositionalArgument(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(`{"commands":[{"id":"issue-manager.issue.get","path":["issue","get"],"summary":"Get issue","output":{"defaultMode":"json","json":true}}]}`)) 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{"issue", "get", "ISS-1"}, &stdout, &stderr); code != 2 { t.Fatalf("code = %d, want 2", code) } if !strings.Contains(stderr.String(), `unexpected argument "ISS-1"`) { t.Fatalf("stderr = %q", stderr.String()) } } func TestRunDynamicJSONRejectsInvalidInputWithStructuredError(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(`{"commands":[{"id":"issue-manager.issue.get","path":["issue","get"],"summary":"Get issue","output":{"defaultMode":"json","json":true}}]}`)) 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{"--json", "issue", "get", "ISS-1"}, &stdout, &stderr); code != 2 { t.Fatalf("code = %d, want 2", code) } assertCLIJSONError(t, stdout.Bytes(), "invalid_input", `unexpected argument "ISS-1"`) if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestRunDynamicJSONPreservesDaemonReasonCode(t *testing.T) { 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(`{"commands":[{"id":"agent-context.agent.get","path":["agent","get"],"summary":"Get agent session","output":{"defaultMode":"json","json":true}}]}`)) case "/v1/cli/commands/agent-context.agent.get/invoke": w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":{"code":"workspace_not_found","reason":"workspace_agent_session_not_found","developerMessage":"agent session was not found"}}`)) default: 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{"--json", "agent", "get"}, &stdout, &stderr); code != 1 { t.Fatalf("code = %d, want 1", code) } assertCLIJSONError(t, stdout.Bytes(), "workspace_agent_session_not_found", "agent session was not found") if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestRunDynamicJSONMapsDaemonInvalidInputToExitCodeTwo(t *testing.T) { 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(`{"commands":[{"id":"agent-context.agent.get","path":["agent","get"],"summary":"Get agent session","output":{"defaultMode":"json","json":true}}]}`)) case "/v1/cli/commands/agent-context.agent.get/invoke": w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":{"code":"invalid_request","reason":"malformed_request","developerMessage":"session id is required"}}`)) default: 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{"--json", "agent", "get"}, &stdout, &stderr); code != 2 { t.Fatalf("code = %d, want 2", code) } assertCLIJSONError(t, stdout.Bytes(), "malformed_request", "session id is required") if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestRunDynamicJSONPreservesUnsupportedPermissionReasonAndOmitsFalseRetryable(t *testing.T) { 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(`{"commands":[{"id":"agent-context.agent.start","path":["agent","start"],"summary":"Start agent","output":{"defaultMode":"json","json":true}}]}`)) case "/v1/cli/commands/agent-context.agent.start/invoke": w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":{"code":"invalid_request","reason":"unsupported_permission_mode_id","developerMessage":"refresh Composer Options and use an advertised permission id","retryable":false}}`)) default: 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{"--json", "agent", "start"}, &stdout, &stderr); code != 2 { t.Fatalf("code = %d, want 2; stderr = %s", code, stderr.String()) } assertCLIJSONError(t, stdout.Bytes(), "unsupported_permission_mode_id", "refresh Composer Options") var envelope map[string]map[string]any if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { t.Fatalf("decode CLI error: %v", err) } if _, present := envelope["error"]["retryable"]; present { t.Fatalf("stdout = %s, want false retryable omitted by JSON contract", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestRunDynamicJSONUnknownCommandIsStructured(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(`{"commands":[]}`)) 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{"--json", "unknown", "command"}, &stdout, &stderr); code != 2 { t.Fatalf("code = %d, want 2", code) } assertCLIJSONError(t, stdout.Bytes(), "command_not_found", "unknown command: unknown command") if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func TestRunManagedModelJSONInvalidInputIsStructured(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer if code := runDefaultProgram(t, []string{"--json", "managed-model", "unknown"}, &stdout, &stderr); code != 2 { t.Fatalf("code = %d, want 2", code) } assertCLIJSONError(t, stdout.Bytes(), "invalid_input", "expected grant exchange, models, credential, or revoke") if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } } func assertCLIJSONError(t *testing.T, content []byte, reasonCode string, message string) { t.Helper() var envelope cliErrorEnvelope if err := json.Unmarshal(content, &envelope); err != nil { t.Fatalf("decode CLI error: %v\n%s", err, content) } if envelope.Error.ReasonCode != reasonCode || !strings.Contains(envelope.Error.Message, message) { t.Fatalf("error = %#v, want reasonCode %q message containing %q", envelope.Error, reasonCode, message) } } func writeEndpoint(t *testing.T, addr string, token string) { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "tuttid.listener.json") body := `{"version":1,"addr":` + quoteJSON(addr) + `,"auth":{"scheme":"bearer","token":` + quoteJSON(token) + `}}` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("write endpoint: %v", err) } t.Setenv("TUTTID_LISTENER_INFO_PATH", path) } func quoteJSON(value string) string { encoded, _ := json.Marshal(value) return string(encoded) }