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
1300 lines
52 KiB
Go
1300 lines
52 KiB
Go
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)
|
|
}
|