Add orch and inbox CLI gap-fill tests
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestInboxLifecycleCommandsRejectMissingAgentWithoutRootFallback verifies lifecycle commands require an agent when root fallback is absent.
|
||||
func TestInboxLifecycleCommandsRejectMissingAgentWithoutRootFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "claim",
|
||||
args: []string{"claim", "--thread", "thr_missing"},
|
||||
},
|
||||
{
|
||||
name: "renew",
|
||||
args: []string{"renew", "--thread", "thr_missing"},
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
args: []string{"update", "--thread", "thr_missing", "--status", "blocked", "--summary", "Need leader input"},
|
||||
},
|
||||
{
|
||||
name: "done",
|
||||
args: []string{"done", "--thread", "thr_missing", "--summary", "Finished"},
|
||||
},
|
||||
{
|
||||
name: "fail",
|
||||
args: []string{"fail", "--thread", "thr_missing", "--summary", "Failed"},
|
||||
},
|
||||
{
|
||||
name: "cancel",
|
||||
args: []string{"cancel", "--thread", "thr_missing"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dbPath := initCommandTestDB(t)
|
||||
args := append([]string{"--db", dbPath, "--json"}, tc.args...)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(args...)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertInboxErrorMessageContains(t, stdout, "agent is required")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboxSendAndReplyRejectTerminalThreads verifies thread-append commands reject terminal threads.
|
||||
func TestInboxSendAndReplyRejectTerminalThreads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("send", func(t *testing.T) {
|
||||
dbPath := initCommandTestDB(t)
|
||||
threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Terminal send", "Create terminal thread")
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "cancel", "--agent", "leader", "--thread", threadID, "--reason", "Stop work")
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"send",
|
||||
"--from", "leader",
|
||||
"--to", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--summary", "Retry anyway",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_state")
|
||||
assertInboxErrorMessageContains(t, stdout, "already terminal")
|
||||
})
|
||||
|
||||
t.Run("reply", func(t *testing.T) {
|
||||
dbPath := initCommandTestDB(t)
|
||||
threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Terminal reply", "Create terminal thread")
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "cancel", "--agent", "leader", "--thread", threadID, "--reason", "Stop work")
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reply",
|
||||
"--from", "leader",
|
||||
"--to", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--summary", "Reply anyway",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_state")
|
||||
assertInboxErrorMessageContains(t, stdout, "already terminal")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCancelRejectsAdditionalInvalidArtifactCombinations verifies cancel covers the remaining artifact validation branches.
|
||||
func TestCancelRejectsAdditionalInvalidArtifactCombinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty artifact path",
|
||||
args: []string{"--artifact", " "},
|
||||
want: "artifact path cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "artifact kind count mismatch",
|
||||
args: []string{"--artifact", "decision.md", "--artifact-kind", "brief", "--artifact-kind", "log"},
|
||||
want: "artifact-kind must be specified once or once per artifact",
|
||||
},
|
||||
{
|
||||
name: "artifact metadata count mismatch",
|
||||
args: []string{"--artifact", "decision.md", "--artifact-metadata-json", `{"label":"one"}`, "--artifact-metadata-json", `{"label":"two"}`},
|
||||
want: "artifact-metadata-json must be specified once or once per artifact",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dbPath := initCommandTestDB(t)
|
||||
threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Cancel artifact validation", "Validate cancel artifacts")
|
||||
|
||||
args := []string{
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"cancel",
|
||||
"--agent", "leader",
|
||||
"--thread", threadID,
|
||||
}
|
||||
args = append(args, tc.args...)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(args...)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertInboxErrorMessageContains(t, stdout, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboxQueryCommandHelpContracts verifies the remaining query-style commands explain their operator contracts.
|
||||
func TestInboxQueryCommandHelpContracts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "send",
|
||||
args: []string{"send", "--help"},
|
||||
want: []string{"low-level thread creation primitive", "Constraints:", "body-file ./brief.md"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
args: []string{"reply", "--help"},
|
||||
want: []string{"append a directed answer, control note, or follow-up message", "Constraints:", "--kind control"},
|
||||
},
|
||||
{
|
||||
name: "fetch",
|
||||
args: []string{"fetch", "--help"},
|
||||
want: []string{"fetch -> claim -> show -> update/done/fail", "Constraints:", "--status pending,blocked --unread --limit 5"},
|
||||
},
|
||||
{
|
||||
name: "show",
|
||||
args: []string{"show", "--help"},
|
||||
want: []string{"exact message sequence inside one thread", "Constraints:", "--mark-read"},
|
||||
},
|
||||
{
|
||||
name: "watch",
|
||||
args: []string{"watch", "--help"},
|
||||
want: []string{"watch is broader than wait-reply", "Constraints:", "--after-event 100 --timeout-seconds 300"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stdout, stderr, exitCode := executeInboxCommand(tc.args...)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected help exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout)
|
||||
}
|
||||
|
||||
combined := stdout + stderr
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(combined, want) {
|
||||
t.Fatalf("expected %s help to contain %q, got:\n%s", tc.name, want, combined)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func releaseLeaseForLifecycleTest(t *testing.T, dbPath, threadID string) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, err := openInboxDB(context.Background(), dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if _, err := sqlDB.ExecContext(context.Background(), `UPDATE leases SET released_at = expires_at WHERE thread_id = ?`, threadID); err != nil {
|
||||
t.Fatalf("release lease: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertInboxErrorMessageContains(t *testing.T, raw string, want string) {
|
||||
t.Helper()
|
||||
|
||||
var payload map[string]any
|
||||
mustDecodeJSON(t, raw, &payload)
|
||||
errorValue, ok := payload["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected error object, got %#v", payload["error"])
|
||||
}
|
||||
message, _ := errorValue["message"].(string)
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("expected error message to contain %q, got %q", want, message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimRejectsNonPendingThread verifies claim rejects threads that are no longer pending.
|
||||
func TestClaimRejectsNonPendingThread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := initCommandTestDB(t)
|
||||
threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Claimed already", "Trigger invalid state")
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"claim",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
)
|
||||
releaseLeaseForLifecycleTest(t, dbPath, threadID)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"claim",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_state")
|
||||
assertInboxErrorMessageContains(t, stdout, "is not pending")
|
||||
}
|
||||
|
||||
// TestInboxLifecycleCommandsRespectRootAgentAndPlainTextOutput verifies lifecycle commands accept the root agent flag and render text output.
|
||||
func TestInboxLifecycleCommandsRespectRootAgentAndPlainTextOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("claim", func(t *testing.T) {
|
||||
dbPath := initCommandTestDB(t)
|
||||
threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Claim root agent", "Use root agent")
|
||||
|
||||
stdout, stderr, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--agent", "worker-a",
|
||||
"claim",
|
||||
"--thread", threadID,
|
||||
)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "claimed thread "+threadID) {
|
||||
t.Fatalf("expected claim output to mention thread %s, got %q", threadID, stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("renew", func(t *testing.T) {
|
||||
dbPath := initCommandTestDB(t)
|
||||
threadID := sendPendingThread(t, dbPath, "leader", "worker-b", "Renew root agent", "Use root agent")
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "claim", "--agent", "worker-b", "--thread", threadID)
|
||||
|
||||
stdout, stderr, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--agent", "worker-b",
|
||||
"renew",
|
||||
"--thread", threadID,
|
||||
)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "renewed lease on thread "+threadID) {
|
||||
t.Fatalf("expected renew output to mention thread %s, got %q", threadID, stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("update", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-c", "worker-c")
|
||||
|
||||
stdout, stderr, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--agent", "worker-c",
|
||||
"update",
|
||||
"--thread", threadID,
|
||||
"--status", "in_progress",
|
||||
"--summary", "Started from root agent",
|
||||
)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "updated thread "+threadID+" to in_progress") {
|
||||
t.Fatalf("expected update output to mention thread %s, got %q", threadID, stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("done", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-d", "worker-d")
|
||||
|
||||
stdout, stderr, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--agent", "worker-d",
|
||||
"done",
|
||||
"--thread", threadID,
|
||||
"--summary", "Finished via root agent",
|
||||
)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "done thread "+threadID) {
|
||||
t.Fatalf("expected done output to mention thread %s, got %q", threadID, stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fail", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-e", "worker-e")
|
||||
|
||||
stdout, stderr, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--agent", "worker-e",
|
||||
"fail",
|
||||
"--thread", threadID,
|
||||
"--summary", "Failed via root agent",
|
||||
)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "fail thread "+threadID) {
|
||||
t.Fatalf("expected fail output to mention thread %s, got %q", threadID, stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cancel", func(t *testing.T) {
|
||||
dbPath := initCommandTestDB(t)
|
||||
threadID := sendPendingThread(t, dbPath, "leader", "worker-f", "Cancel root agent", "Use root agent")
|
||||
|
||||
stdout, stderr, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--agent", "leader",
|
||||
"cancel",
|
||||
"--thread", threadID,
|
||||
"--reason", "Task superseded",
|
||||
)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "cancelled thread "+threadID) {
|
||||
t.Fatalf("expected cancel output to mention thread %s, got %q", threadID, stdout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestInboxLifecycleCommandHelpContracts verifies claim, renew, done, fail, and cancel help text explains their contracts.
|
||||
func TestInboxLifecycleCommandHelpContracts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "claim",
|
||||
args: []string{"claim", "--help"},
|
||||
want: []string{"Only one active lease may exist per thread at a time.", "Constraints:", "--lease-seconds 1800"},
|
||||
},
|
||||
{
|
||||
name: "renew",
|
||||
args: []string{"renew", "--help"},
|
||||
want: []string{"renew only applies when the caller already owns the active lease.", "Constraints:", "--lease-seconds 1800"},
|
||||
},
|
||||
{
|
||||
name: "done",
|
||||
args: []string{"done", "--help"},
|
||||
want: []string{"done is a terminal operation", "Constraints:", "--summary \"Implemented retry policy\""},
|
||||
},
|
||||
{
|
||||
name: "fail",
|
||||
args: []string{"fail", "--help"},
|
||||
want: []string{"fail is a terminal operation", "Constraints:", "retry, reassignment, or cancellation"},
|
||||
},
|
||||
{
|
||||
name: "cancel",
|
||||
args: []string{"cancel", "--help"},
|
||||
want: []string{"leader or operator action", "Constraints:", "--reason \"Task superseded by a new plan.\""},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stdout, stderr, exitCode := executeInboxCommand(tc.args...)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected help exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout)
|
||||
}
|
||||
|
||||
combined := stdout + stderr
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(combined, want) {
|
||||
t.Fatalf("expected help for %s to contain %q, got:\n%s", tc.name, want, combined)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenewRejectsMissingAndTerminalThread verifies renew reports not found and terminal-thread errors.
|
||||
func TestRenewRejectsMissingAndTerminalThread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("missing thread", func(t *testing.T) {
|
||||
dbPath := initCommandTestDB(t)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"renew",
|
||||
"--agent", "worker-a",
|
||||
"--thread", "thr_missing",
|
||||
)
|
||||
if exitCode != 40 {
|
||||
t.Fatalf("expected exit code 40, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "not_found")
|
||||
})
|
||||
|
||||
t.Run("terminal thread", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a")
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "done", "--agent", "worker-a", "--thread", threadID, "--summary", "Finished")
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"renew",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_state")
|
||||
assertInboxErrorMessageContains(t, stdout, "already terminal")
|
||||
})
|
||||
}
|
||||
|
||||
// TestUpdateRejectsMissingAndTerminalThread verifies update rejects unknown or terminal threads.
|
||||
func TestUpdateRejectsMissingAndTerminalThread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("missing thread", func(t *testing.T) {
|
||||
dbPath := initCommandTestDB(t)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"update",
|
||||
"--agent", "worker-a",
|
||||
"--thread", "thr_missing",
|
||||
"--status", "in_progress",
|
||||
"--summary", "Started",
|
||||
)
|
||||
if exitCode != 40 {
|
||||
t.Fatalf("expected exit code 40, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "not_found")
|
||||
})
|
||||
|
||||
t.Run("terminal thread", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a")
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "done", "--agent", "worker-a", "--thread", threadID, "--summary", "Finished")
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"update",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--status", "blocked",
|
||||
"--summary", "Need answer",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_state")
|
||||
assertInboxErrorMessageContains(t, stdout, "already terminal")
|
||||
})
|
||||
}
|
||||
|
||||
// TestUpdateRejectsInvalidBodyFileInputs verifies update rejects mutually exclusive or unreadable body-file inputs.
|
||||
func TestUpdateRejectsInvalidBodyFileInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a")
|
||||
|
||||
t.Run("body and body-file", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
bodyPath := filepath.Join(tempDir, "body.txt")
|
||||
if err := os.WriteFile(bodyPath, []byte("from file"), 0o644); err != nil {
|
||||
t.Fatalf("write body file: %v", err)
|
||||
}
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"update",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--status", "in_progress",
|
||||
"--summary", "Started",
|
||||
"--body", "inline",
|
||||
"--body-file", bodyPath,
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertInboxErrorMessageContains(t, stdout, "mutually exclusive")
|
||||
})
|
||||
|
||||
t.Run("unreadable body-file", func(t *testing.T) {
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"update",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--status", "in_progress",
|
||||
"--summary", "Started",
|
||||
"--body-file", filepath.Join(t.TempDir(), "missing.txt"),
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertInboxErrorMessageContains(t, stdout, "failed to read body-file")
|
||||
})
|
||||
}
|
||||
|
||||
// TestUpdateRejectsInvalidArtifactFlagCombinations verifies update validates artifact flag combinations before command execution.
|
||||
func TestUpdateRejectsInvalidArtifactFlagCombinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "kind without artifact",
|
||||
args: []string{"--artifact-kind", "log"},
|
||||
want: "require at least one artifact path",
|
||||
},
|
||||
{
|
||||
name: "empty artifact path",
|
||||
args: []string{"--artifact", " "},
|
||||
want: "artifact path cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "artifact-kind count mismatch",
|
||||
args: []string{"--artifact", "a.log", "--artifact", "b.log", "--artifact-kind", "log", "--artifact-kind", "brief", "--artifact-kind", "extra"},
|
||||
want: "artifact-kind must be specified once or once per artifact",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := []string{
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"update",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--status", "in_progress",
|
||||
"--summary", "Started",
|
||||
}
|
||||
args = append(args, tc.args...)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(args...)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertInboxErrorMessageContains(t, stdout, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoneAndFailRejectMissingThread verifies terminal commands reject unknown threads.
|
||||
func TestDoneAndFailRejectMissingThread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, command := range []string{"done", "fail"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
dbPath := initCommandTestDB(t)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
command,
|
||||
"--agent", "worker-a",
|
||||
"--thread", "thr_missing",
|
||||
"--summary", "Missing thread",
|
||||
)
|
||||
if exitCode != 40 {
|
||||
t.Fatalf("expected exit code 40, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "not_found")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoneAndFailRejectInvalidBodyFileInputs verifies terminal commands reject bad body-file inputs.
|
||||
func TestDoneAndFailRejectInvalidBodyFileInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
command string
|
||||
agent string
|
||||
}{
|
||||
{command: "done", agent: "worker-a"},
|
||||
{command: "fail", agent: "worker-b"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.command, func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", tc.agent, tc.agent)
|
||||
bodyPath := filepath.Join(t.TempDir(), "body.txt")
|
||||
if err := os.WriteFile(bodyPath, []byte("from file"), 0o644); err != nil {
|
||||
t.Fatalf("write body file: %v", err)
|
||||
}
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
tc.command,
|
||||
"--agent", tc.agent,
|
||||
"--thread", threadID,
|
||||
"--summary", "Finished",
|
||||
"--body", "inline",
|
||||
"--body-file", bodyPath,
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertInboxErrorMessageContains(t, stdout, "mutually exclusive")
|
||||
|
||||
stdout, _, exitCode = executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
tc.command,
|
||||
"--agent", tc.agent,
|
||||
"--thread", threadID,
|
||||
"--summary", "Finished",
|
||||
"--body-file", filepath.Join(t.TempDir(), "missing.txt"),
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertInboxErrorMessageContains(t, stdout, "failed to read body-file")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoneAndFailRejectInvalidArtifactFlagCombinations verifies terminal commands validate artifact flag combinations.
|
||||
func TestDoneAndFailRejectInvalidArtifactFlagCombinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
command string
|
||||
agent string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
command: "done",
|
||||
agent: "worker-a",
|
||||
args: []string{"--artifact-kind", "brief"},
|
||||
want: "require at least one artifact path",
|
||||
},
|
||||
{
|
||||
command: "fail",
|
||||
agent: "worker-b",
|
||||
args: []string{"--artifact", "a.log", "--artifact", "b.log", "--artifact-metadata-json", `{}`, "--artifact-metadata-json", `{}`, "--artifact-metadata-json", `{}`},
|
||||
want: "artifact-metadata-json must be specified once or once per artifact",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.command, func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", tc.agent, tc.agent)
|
||||
|
||||
args := []string{
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
tc.command,
|
||||
"--agent", tc.agent,
|
||||
"--thread", threadID,
|
||||
"--summary", "Finished",
|
||||
}
|
||||
args = append(args, tc.args...)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(args...)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertInboxErrorMessageContains(t, stdout, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelRejectsTerminalThread verifies cancel rejects threads that are already terminal.
|
||||
func TestCancelRejectsTerminalThread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a")
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "done", "--agent", "worker-a", "--thread", threadID, "--summary", "Finished")
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"cancel",
|
||||
"--agent", "leader",
|
||||
"--thread", threadID,
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_state")
|
||||
assertInboxErrorMessageContains(t, stdout, "already terminal")
|
||||
}
|
||||
|
||||
// TestCancelUsesDefaultReasonWhenOmitted verifies cancel falls back to the default summary when no reason is supplied.
|
||||
func TestCancelUsesDefaultReasonWhenOmitted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := initCommandTestDB(t)
|
||||
threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Default cancel reason", "Check default summary")
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"cancel",
|
||||
"--agent", "leader",
|
||||
"--thread", threadID,
|
||||
)
|
||||
|
||||
showOut := runInboxCommand(t, "--db", dbPath, "--json", "show", "--thread", threadID)
|
||||
var showResp map[string]any
|
||||
mustDecodeJSON(t, showOut, &showResp)
|
||||
|
||||
lastMessage := lastThreadMessageFromShow(t, showResp)
|
||||
if got := lastMessage["summary"]; got != "thread cancelled" {
|
||||
t.Fatalf("expected default cancel summary, got %#v", got)
|
||||
}
|
||||
if got := lastMessage["body"]; got != "" {
|
||||
t.Fatalf("expected empty cancel body when reason omitted, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelRejectsInvalidArtifactFlagCombinations verifies cancel validates artifact flag combinations.
|
||||
func TestCancelRejectsInvalidArtifactFlagCombinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := initCommandTestDB(t)
|
||||
threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Cancel invalid artifact", "Check artifact validation")
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"cancel",
|
||||
"--agent", "leader",
|
||||
"--thread", threadID,
|
||||
"--artifact-metadata-json", `{}`,
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertInboxErrorMessageContains(t, stdout, "require at least one artifact path")
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestSendRejectsMissingFromAgent verifies send requires a sender when no root agent is set.
|
||||
func TestSendRejectsMissingFromAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := initCommandTestDB(t)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"send",
|
||||
"--to", "worker-a",
|
||||
"--subject", "Investigate flaky test",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertQueryWaitErrorMessageContains(t, stdout, "from agent is required")
|
||||
}
|
||||
|
||||
// TestSendRejectsMissingSubjectWhenCreatingThread verifies send requires subject for a new thread.
|
||||
func TestSendRejectsMissingSubjectWhenCreatingThread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := initCommandTestDB(t)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"send",
|
||||
"--from", "leader",
|
||||
"--to", "worker-a",
|
||||
"--summary", "Missing subject",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertQueryWaitErrorMessageContains(t, stdout, "subject is required when creating a new thread")
|
||||
}
|
||||
|
||||
// TestSendRejectsBodyAndBodyFileTogether verifies send rejects mutually exclusive body inputs.
|
||||
func TestSendRejectsBodyAndBodyFileTogether(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "coord.db")
|
||||
bodyPath := filepath.Join(tempDir, "brief.md")
|
||||
if err := os.WriteFile(bodyPath, []byte("brief"), 0o644); err != nil {
|
||||
t.Fatalf("write body file: %v", err)
|
||||
}
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "init")
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"send",
|
||||
"--from", "leader",
|
||||
"--to", "worker-a",
|
||||
"--subject", "Investigate flaky test",
|
||||
"--body", "inline",
|
||||
"--body-file", bodyPath,
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertQueryWaitErrorMessageContains(t, stdout, "body and body-file are mutually exclusive")
|
||||
}
|
||||
|
||||
// TestSendRejectsUnreadableBodyFile verifies send reports unreadable body files.
|
||||
func TestSendRejectsUnreadableBodyFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := initCommandTestDB(t)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"send",
|
||||
"--from", "leader",
|
||||
"--to", "worker-a",
|
||||
"--subject", "Investigate flaky test",
|
||||
"--body-file", filepath.Join(t.TempDir(), "missing.md"),
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertQueryWaitErrorMessageContains(t, stdout, "failed to read body-file")
|
||||
}
|
||||
|
||||
// TestSendRejectsArtifactKindCountMismatch verifies send validates artifact flag cardinality.
|
||||
func TestSendRejectsArtifactKindCountMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := initCommandTestDB(t)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"send",
|
||||
"--from", "leader",
|
||||
"--to", "worker-a",
|
||||
"--subject", "Investigate flaky test",
|
||||
"--artifact", "/tmp/task.md",
|
||||
"--artifact-kind", "brief",
|
||||
"--artifact-kind", "log",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertQueryWaitErrorMessageContains(t, stdout, "artifact-kind must be specified once or once per artifact")
|
||||
}
|
||||
|
||||
// TestSendUsesRootAgentAndPlainTextOutput verifies send falls back to the root agent and renders plain text output.
|
||||
func TestSendUsesRootAgentAndPlainTextOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := initCommandTestDB(t)
|
||||
|
||||
stdout := runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--agent", "leader",
|
||||
"send",
|
||||
"--to", "worker-a",
|
||||
"--subject", "Investigate flaky test",
|
||||
"--summary", "Check latest failures",
|
||||
)
|
||||
threadID := parsePlainThreadID(t, stdout, "created thread ")
|
||||
|
||||
showOut := runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"show",
|
||||
"--thread", threadID,
|
||||
)
|
||||
|
||||
var showResp map[string]any
|
||||
mustDecodeJSON(t, showOut, &showResp)
|
||||
if got := nestedString(t, showResp, "data", "thread", "created_by"); got != "leader" {
|
||||
t.Fatalf("expected created_by leader, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplyRejectsMissingFromAgent verifies reply requires a sender when no root agent is set.
|
||||
func TestReplyRejectsMissingFromAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a")
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reply",
|
||||
"--to", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--summary", "Use stdout",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertQueryWaitErrorMessageContains(t, stdout, "from agent is required")
|
||||
}
|
||||
|
||||
// TestReplyRejectsBodyAndBodyFileTogether verifies reply rejects mutually exclusive body inputs.
|
||||
func TestReplyRejectsBodyAndBodyFileTogether(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "coord.db")
|
||||
bodyPath := filepath.Join(tempDir, "reply.md")
|
||||
if err := os.WriteFile(bodyPath, []byte("reply"), 0o644); err != nil {
|
||||
t.Fatalf("write body file: %v", err)
|
||||
}
|
||||
threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a")
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reply",
|
||||
"--from", "leader",
|
||||
"--to", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--summary", "Use stdout",
|
||||
"--body", "inline",
|
||||
"--body-file", bodyPath,
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertQueryWaitErrorMessageContains(t, stdout, "body and body-file are mutually exclusive")
|
||||
}
|
||||
|
||||
// TestReplyRejectsUnreadableBodyFile verifies reply reports unreadable body files.
|
||||
func TestReplyRejectsUnreadableBodyFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a")
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reply",
|
||||
"--from", "leader",
|
||||
"--to", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--summary", "Use stdout",
|
||||
"--body-file", filepath.Join(t.TempDir(), "missing.md"),
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertQueryWaitErrorMessageContains(t, stdout, "failed to read body-file")
|
||||
}
|
||||
|
||||
// TestReplyRejectsArtifactMetadataCountMismatch verifies reply validates artifact metadata cardinality.
|
||||
func TestReplyRejectsArtifactMetadataCountMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a")
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reply",
|
||||
"--from", "leader",
|
||||
"--to", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--summary", "Use stdout",
|
||||
"--artifact", "/tmp/decision.md",
|
||||
"--artifact-metadata-json", `{"label":"a"}`,
|
||||
"--artifact-metadata-json", `{"label":"b"}`,
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertQueryWaitErrorMessageContains(t, stdout, "artifact-metadata-json must be specified once or once per artifact")
|
||||
}
|
||||
|
||||
// TestReplyUsesRootAgentAndPlainTextOutput verifies reply falls back to the root agent and renders plain text output.
|
||||
func TestReplyUsesRootAgentAndPlainTextOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a")
|
||||
|
||||
stdout := runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--agent", "leader",
|
||||
"reply",
|
||||
"--to", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--summary", "Use stdout",
|
||||
)
|
||||
if got := strings.TrimSpace(stdout); got != "replied on thread "+threadID {
|
||||
t.Fatalf("expected plain text reply output for %q, got %q", threadID, got)
|
||||
}
|
||||
|
||||
showOut := runInboxCommand(t, "--db", dbPath, "--json", "show", "--thread", threadID)
|
||||
var showResp map[string]any
|
||||
mustDecodeJSON(t, showOut, &showResp)
|
||||
lastMessage := lastThreadMessageFromShow(t, showResp)
|
||||
if got, _ := lastMessage["from_agent"].(string); got != "leader" {
|
||||
t.Fatalf("expected last from_agent leader, got %#v", lastMessage["from_agent"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDefaultsToPendingWithRootAgentAndPlainText verifies fetch uses pending by default and respects the root agent.
|
||||
func TestFetchDefaultsToPendingWithRootAgentAndPlainText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "init")
|
||||
|
||||
pendingThreadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Pending work", "Check latest failures")
|
||||
blockedThreadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Blocked work", "Need product decision")
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "claim", "--agent", "worker-a", "--thread", blockedThreadID)
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"update",
|
||||
"--agent", "worker-a",
|
||||
"--thread", blockedThreadID,
|
||||
"--status", "blocked",
|
||||
"--summary", "Need product decision",
|
||||
)
|
||||
|
||||
stdout := runInboxCommand(t, "--db", dbPath, "--agent", "worker-a", "fetch")
|
||||
lines := splitNonEmptyLines(stdout)
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected one plain text fetch row, got %#v", lines)
|
||||
}
|
||||
if !strings.Contains(lines[0], pendingThreadID+"\tpending\tPending work") {
|
||||
t.Fatalf("expected pending thread %q in fetch output, got %q", pendingThreadID, lines[0])
|
||||
}
|
||||
if strings.Contains(lines[0], blockedThreadID) {
|
||||
t.Fatalf("did not expect blocked thread %q in default fetch output %q", blockedThreadID, lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestListReturnsNoMatchingWork verifies list returns no_matching_work when filters match nothing.
|
||||
func TestListReturnsNoMatchingWork(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "init")
|
||||
sendPendingThread(t, dbPath, "leader", "worker-a", "Pending work", "Check latest failures")
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"list",
|
||||
"--assigned-to", "worker-z",
|
||||
)
|
||||
if exitCode != 10 {
|
||||
t.Fatalf("expected no_matching_work exit code 10, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "no_matching_work")
|
||||
}
|
||||
|
||||
// TestListPlainTextOutput verifies list renders plain text rows for matching threads.
|
||||
func TestListPlainTextOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "init")
|
||||
threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Pending work", "Check latest failures")
|
||||
|
||||
stdout := runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"list",
|
||||
"--assigned-to", "worker-a",
|
||||
"--status", "pending",
|
||||
)
|
||||
lines := splitNonEmptyLines(stdout)
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected one plain text list row, got %#v", lines)
|
||||
}
|
||||
if !strings.Contains(lines[0], threadID+"\tpending\tworker-a\tPending work") {
|
||||
t.Fatalf("expected thread %q in list output, got %q", threadID, lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestShowRejectsMarkReadWithoutAgent verifies show requires an agent for --mark-read.
|
||||
func TestShowRejectsMarkReadWithoutAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a")
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"show",
|
||||
"--thread", threadID,
|
||||
"--mark-read",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_input")
|
||||
assertQueryWaitErrorMessageContains(t, stdout, "agent is required when using --mark-read")
|
||||
}
|
||||
|
||||
// TestShowPlainTextOutput verifies show renders plain text thread history including artifacts.
|
||||
func TestShowPlainTextOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "coord.db")
|
||||
artifactPath := filepath.Join(tempDir, "task.md")
|
||||
if err := os.WriteFile(artifactPath, []byte("task brief"), 0o644); err != nil {
|
||||
t.Fatalf("write artifact file: %v", err)
|
||||
}
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "init")
|
||||
|
||||
sendOut := runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"send",
|
||||
"--from", "leader",
|
||||
"--to", "worker-a",
|
||||
"--subject", "Artifact task",
|
||||
"--summary", "Attach brief",
|
||||
"--artifact", artifactPath,
|
||||
"--artifact-kind", "brief",
|
||||
)
|
||||
var sendResp map[string]any
|
||||
mustDecodeJSON(t, sendOut, &sendResp)
|
||||
threadID := nestedString(t, sendResp, "data", "thread", "thread_id")
|
||||
|
||||
stdout := runInboxCommand(t, "--db", dbPath, "show", "--thread", threadID)
|
||||
if !strings.Contains(stdout, threadID+"\tpending\tArtifact task") {
|
||||
t.Fatalf("expected thread header in show output, got %q", stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "- ") || !strings.Contains(stdout, "\ttask\tAttach brief") {
|
||||
t.Fatalf("expected message row in show output, got %q", stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, " artifact\tbrief\t"+artifactPath) {
|
||||
t.Fatalf("expected artifact row in show output, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWatchUsesAfterEventResumeAndPlainTextOutput verifies watch resumes from after-event and renders plain text output.
|
||||
func TestWatchUsesAfterEventResumeAndPlainTextOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "init")
|
||||
threadID := sendPendingThread(t, dbPath, "leader", "worker-c", "Pending work", "Check latest failures")
|
||||
|
||||
firstWatchOut := runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"watch",
|
||||
"--agent", "worker-c",
|
||||
"--status", "pending",
|
||||
"--after-event", "0",
|
||||
"--timeout-seconds", "1",
|
||||
)
|
||||
var firstWatchResp map[string]any
|
||||
mustDecodeJSON(t, firstWatchOut, &firstWatchResp)
|
||||
firstEventID := int64(nestedValue(t, firstWatchResp, "data", "next_event_id").(float64))
|
||||
|
||||
watchCh := make(chan watchCommandResult, 1)
|
||||
go func() {
|
||||
stdout, stderr, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--agent", "worker-c",
|
||||
"watch",
|
||||
"--status", "blocked",
|
||||
"--after-event", strconv.FormatInt(firstEventID, 10),
|
||||
"--timeout-seconds", "2",
|
||||
)
|
||||
watchCh <- watchCommandResult{stdout: stdout, stderr: stderr, exit: exitCode}
|
||||
}()
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "claim", "--agent", "worker-c", "--thread", threadID)
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"update",
|
||||
"--agent", "worker-c",
|
||||
"--thread", threadID,
|
||||
"--status", "blocked",
|
||||
"--summary", "Need product decision",
|
||||
)
|
||||
|
||||
select {
|
||||
case result := <-watchCh:
|
||||
if result.exit != 0 {
|
||||
t.Fatalf("watch failed with exit=%d\nstderr:\n%s\nstdout:\n%s", result.exit, result.stderr, result.stdout)
|
||||
}
|
||||
if got := strings.TrimSpace(result.stdout); got != "watch woke on thread "+threadID+" at event "+strconv.FormatInt(firstEventID+2, 10) && !strings.Contains(got, "watch woke on thread "+threadID+" at event ") {
|
||||
t.Fatalf("expected plain text wake output for %q, got %q", threadID, got)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("watch command did not return")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitReplyUsesRootAgentAndPlainTextOutput verifies wait-reply works with the root agent and plain text output.
|
||||
func TestWaitReplyUsesRootAgentAndPlainTextOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
threadID, blockedMessageID := seedBlockedThreadForWaitReply(t, dbPath)
|
||||
|
||||
waitCh := make(chan waitReplyCommandResult, 1)
|
||||
go func() {
|
||||
stdout, stderr, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--agent", "worker-c",
|
||||
"wait-reply",
|
||||
"--thread", threadID,
|
||||
"--after-message", blockedMessageID,
|
||||
"--timeout-seconds", "2",
|
||||
)
|
||||
waitCh <- waitReplyCommandResult{stdout: stdout, stderr: stderr, exit: exitCode}
|
||||
}()
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reply",
|
||||
"--from", "leader",
|
||||
"--to", "worker-c",
|
||||
"--thread", threadID,
|
||||
"--summary", "Use stdout",
|
||||
)
|
||||
|
||||
select {
|
||||
case result := <-waitCh:
|
||||
if result.exit != 0 {
|
||||
t.Fatalf("wait-reply failed with exit=%d\nstderr:\n%s\nstdout:\n%s", result.exit, result.stderr, result.stdout)
|
||||
}
|
||||
if got := strings.TrimSpace(result.stdout); !strings.Contains(got, "reply received on thread "+threadID+" at event ") {
|
||||
t.Fatalf("expected plain text wait-reply output for %q, got %q", threadID, got)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("wait-reply command did not return")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitReplyRejectsUnknownMessageCursor verifies wait-reply reports not_found for an unknown message cursor.
|
||||
func TestWaitReplyRejectsUnknownMessageCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := initCommandTestDB(t)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"wait-reply",
|
||||
"--thread", "thr_missing",
|
||||
"--after-message", "msg_missing",
|
||||
)
|
||||
if exitCode != 40 {
|
||||
t.Fatalf("expected not_found exit code 40, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "not_found")
|
||||
assertQueryWaitErrorMessageContains(t, stdout, "message msg_missing not found in thread thr_missing")
|
||||
}
|
||||
|
||||
func assertQueryWaitErrorMessageContains(t *testing.T, raw string, want string) {
|
||||
t.Helper()
|
||||
|
||||
var payload map[string]any
|
||||
mustDecodeJSON(t, raw, &payload)
|
||||
errorValue, ok := payload["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected error object, got %#v", payload["error"])
|
||||
}
|
||||
message, _ := errorValue["message"].(string)
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("expected error message to contain %q, got %q", want, message)
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlainThreadID(t *testing.T, stdout string, prefix string) string {
|
||||
t.Helper()
|
||||
|
||||
line := strings.TrimSpace(stdout)
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
t.Fatalf("expected prefix %q in output %q", prefix, stdout)
|
||||
}
|
||||
threadID := strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
||||
if threadID == "" {
|
||||
t.Fatalf("expected thread ID in output %q", stdout)
|
||||
}
|
||||
return threadID
|
||||
}
|
||||
|
||||
func splitNonEmptyLines(stdout string) []string {
|
||||
lines := strings.Split(strings.TrimSpace(stdout), "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user