package inbox import ( "os" "path/filepath" "testing" ) func seedThreadForInboxTests(t *testing.T, dbPath, from, to string) string { t.Helper() runInboxCommand(t, "--db", dbPath, "--json", "init") sendOut := runInboxCommand( t, "--db", dbPath, "--json", "send", "--from", from, "--to", to, "--subject", "Implement feature X", "--summary", "Add retry policy", ) var sendResp map[string]any mustDecodeJSON(t, sendOut, &sendResp) return nestedString(t, sendResp, "data", "thread", "thread_id") } func seedClaimedThreadForInboxTests(t *testing.T, dbPath, from, to, claimer string) string { t.Helper() threadID := seedThreadForInboxTests(t, dbPath, from, to) runInboxCommand( t, "--db", dbPath, "--json", "claim", "--agent", claimer, "--thread", threadID, "--lease-seconds", "300", ) return threadID } func lastThreadMessageFromShow(t *testing.T, showResp map[string]any) map[string]any { t.Helper() messagesValue := nestedValue(t, showResp, "data", "messages") messages, ok := messagesValue.([]any) if !ok || len(messages) == 0 { t.Fatalf("expected non-empty messages, got %#v", messagesValue) } lastMessage, ok := messages[len(messages)-1].(map[string]any) if !ok { t.Fatalf("expected message object, got %#v", messages[len(messages)-1]) } return lastMessage } func TestUpdateMovesThreadToInProgress(t *testing.T) { t.Parallel() dbPath := filepath.Join(t.TempDir(), "coord.db") threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") updateOut := runInboxCommand( t, "--db", dbPath, "--json", "update", "--agent", "worker-a", "--thread", threadID, "--status", "in_progress", "--summary", "Implementation started", "--body", "Scanning current HTTP client usage.", ) var updateResp map[string]any mustDecodeJSON(t, updateOut, &updateResp) if status := nestedString(t, updateResp, "data", "thread", "status"); status != "in_progress" { t.Fatalf("expected in_progress thread status, got %q", status) } if kind := nestedString(t, updateResp, "data", "message", "kind"); kind != "progress" { t.Fatalf("expected progress message kind, got %q", kind) } if toAgent := nestedString(t, updateResp, "data", "message", "to_agent"); toAgent != "leader" { t.Fatalf("expected message to_agent leader, got %q", toAgent) } } func TestUpdateMovesThreadToBlockedWithPayload(t *testing.T) { t.Parallel() dbPath := filepath.Join(t.TempDir(), "coord.db") threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") updateOut := runInboxCommand( t, "--db", dbPath, "--json", "update", "--agent", "worker-a", "--thread", threadID, "--status", "blocked", "--summary", "Need timeout decision", "--payload-json", `{"question":"Should retries apply to read timeouts?"}`, ) var updateResp map[string]any mustDecodeJSON(t, updateOut, &updateResp) if status := nestedString(t, updateResp, "data", "thread", "status"); status != "blocked" { t.Fatalf("expected blocked thread status, got %q", status) } if kind := nestedString(t, updateResp, "data", "message", "kind"); kind != "question" { t.Fatalf("expected question message kind, got %q", kind) } payload, ok := nestedValue(t, updateResp, "data", "message", "payload_json").(map[string]any) if !ok { t.Fatalf("expected payload_json object, got %#v", nestedValue(t, updateResp, "data", "message", "payload_json")) } if got := payload["question"]; got != "Should retries apply to read timeouts?" { t.Fatalf("expected payload question, got %#v", got) } } func TestUpdateAcceptsBodyFileAndArtifact(t *testing.T) { t.Parallel() tempDir := t.TempDir() dbPath := filepath.Join(tempDir, "coord.db") progressPath := filepath.Join(tempDir, "progress.md") body := "Progress update from file." if err := os.WriteFile(progressPath, []byte(body), 0o644); err != nil { t.Fatalf("write progress file: %v", err) } threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") runInboxCommand( t, "--db", dbPath, "--json", "update", "--agent", "worker-a", "--thread", threadID, "--status", "in_progress", "--summary", "Implementation started", "--body-file", progressPath, "--artifact", progressPath, "--artifact-kind", "note", ) showOut := runInboxCommand( t, "--db", dbPath, "--json", "show", "--thread", threadID, ) var showResp map[string]any mustDecodeJSON(t, showOut, &showResp) lastMessage := lastThreadMessageFromShow(t, showResp) if gotBody, _ := lastMessage["body"].(string); gotBody != body { t.Fatalf("expected body %q, got %#v", body, lastMessage["body"]) } artifacts, ok := lastMessage["artifacts"].([]any) if !ok || len(artifacts) != 1 { t.Fatalf("expected one artifact, got %#v", lastMessage["artifacts"]) } artifact, ok := artifacts[0].(map[string]any) if !ok { t.Fatalf("expected artifact object, got %#v", artifacts[0]) } if gotPath, _ := artifact["path"].(string); gotPath != progressPath { t.Fatalf("expected artifact path %q, got %#v", progressPath, artifact["path"]) } if gotKind, _ := artifact["kind"].(string); gotKind != "note" { t.Fatalf("expected artifact kind note, got %#v", artifact["kind"]) } } func TestUpdateRejectsInvalidPayloadJSON(t *testing.T) { t.Parallel() dbPath := filepath.Join(t.TempDir(), "coord.db") threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") stdout, _, exitCode := executeInboxCommand( "--db", dbPath, "--json", "update", "--agent", "worker-a", "--thread", threadID, "--status", "blocked", "--summary", "Need timeout decision", "--payload-json", "not-json", ) if exitCode != 30 { t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) } assertErrorJSON(t, stdout, "invalid_input") } func TestUpdateRejectsNonOwner(t *testing.T) { t.Parallel() dbPath := filepath.Join(t.TempDir(), "coord.db") threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") stdout, _, exitCode := executeInboxCommand( "--db", dbPath, "--json", "update", "--agent", "worker-b", "--thread", threadID, "--status", "in_progress", "--summary", "Implementation started", ) if exitCode != 20 { t.Fatalf("expected exit code 20, got %d with output %s", exitCode, stdout) } assertErrorJSON(t, stdout, "lease_conflict") }