fix: address Bugbot review — thread errors, PR fallback, activity mapping

- Thread errors: `.catch(() => null)` instead of `.catch(() => [])` so
  failed getPendingComments shows `-` not `0`
- PR fallback: extract PR number from metadata URL when SCM lookup fails
- Activity fallback: use metadata `status` when agent introspection is
  unavailable (working→active, idle→idle, stuck/errored→blocked)
- Activity mapping: treat `assistant`/`result` as idle, only `tool_use`
  and `user` as active — avoids marking completed responses as working

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-14 17:31:33 +05:30
parent 5b68fccbd0
commit 799760ae25
2 changed files with 139 additions and 4 deletions

View File

@ -405,4 +405,119 @@ describe("status command", () => {
expect(parsed[0].reviewDecision).toBe("pending");
expect(parsed[0].pendingThreads).toBe(0);
});
it("falls back to PR number from metadata URL when SCM fails", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"worktree=/tmp/wt\nbranch=feat/pr-meta\nstatus=working\npr=https://github.com/org/repo/pull/99\n",
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
if (args[0] === "display-message") return null;
return null;
});
mockGit.mockResolvedValue("feat/pr-meta");
// SCM detectPR fails
mockDetectPR.mockRejectedValue(new Error("gh failed"));
await program.parseAsync(["node", "test", "status"]);
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
expect(output).toContain("#99");
});
it("shows null pendingThreads when getPendingComments fails", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"worktree=/tmp/wt\nbranch=feat/thr-err\nstatus=working\n",
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
if (args[0] === "display-message") return String(Math.floor(Date.now() / 1000));
return null;
});
mockGit.mockResolvedValue("feat/thr-err");
mockDetectPR.mockResolvedValue({
number: 5,
url: "https://github.com/org/repo/pull/5",
title: "Thread err PR",
owner: "org",
repo: "repo",
branch: "feat/thr-err",
baseBranch: "main",
isDraft: false,
});
mockGetCISummary.mockResolvedValue("passing");
mockGetReviewDecision.mockResolvedValue("none");
// getPendingComments rejects — should result in null, not 0
mockGetPendingComments.mockRejectedValue(new Error("graphql failed"));
await program.parseAsync(["node", "test", "status", "--json"]);
const jsonCalls = consoleSpy.mock.calls.map((c) => c[0]).join("");
const parsed = JSON.parse(jsonCalls);
expect(parsed[0].pendingThreads).toBeNull();
});
it("falls back to metadata status for activity when introspection unavailable", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"worktree=/tmp/wt\nbranch=feat/meta-act\nstatus=working\n",
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
if (args[0] === "display-message") return String(Math.floor(Date.now() / 1000));
return null;
});
mockGit.mockResolvedValue("feat/meta-act");
// Introspection returns null (no session info)
mockIntrospect.mockResolvedValue(null);
await program.parseAsync(["node", "test", "status", "--json"]);
const jsonCalls = consoleSpy.mock.calls.map((c) => c[0]).join("");
const parsed = JSON.parse(jsonCalls);
// status=working should fall back to activity=active
expect(parsed[0].activity).toBe("active");
});
it("treats assistant lastMessageType as idle, not active", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"worktree=/tmp/wt\nbranch=feat/asst\nstatus=working\n",
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
if (args[0] === "display-message") return String(Math.floor(Date.now() / 1000));
return null;
});
mockGit.mockResolvedValue("feat/asst");
mockIntrospect.mockResolvedValue({
summary: "Working on feature",
agentSessionId: "abc",
lastMessageType: "assistant",
});
await program.parseAsync(["node", "test", "status", "--json"]);
const jsonCalls = consoleSpy.mock.calls.map((c) => c[0]).join("");
const parsed = JSON.parse(jsonCalls);
expect(parsed[0].activity).toBe("idle");
});
});

View File

@ -112,21 +112,41 @@ async function gatherSessionInfo(
claudeSummary = introspection?.summary ?? null;
// Detect activity from agent's last message type
if (introspection?.lastMessageType === "summary") {
const msgType = introspection?.lastMessageType;
if (msgType === "summary" || msgType === "assistant" || msgType === "result") {
activity = "idle";
} else if (introspection?.lastMessageType) {
} else if (msgType === "tool_use" || msgType === "user") {
activity = "active";
}
} catch {
// Introspection failed — not critical
}
// Fall back to metadata status for activity when introspection is unavailable
if (activity === null && status) {
const statusToActivity: Record<string, ActivityState> = {
working: "active",
idle: "idle",
stuck: "blocked",
errored: "blocked",
};
activity = statusToActivity[status] ?? null;
}
// Fetch PR, CI, and review data from SCM
let prNumber: number | null = null;
let ciStatus: CIStatus | null = null;
let reviewDecision: ReviewDecision | null = null;
let pendingThreads: number | null = null;
// Extract PR number from metadata URL as fallback
if (prUrl) {
const prMatch = /\/pull\/(\d+)/.exec(prUrl);
if (prMatch) {
prNumber = parseInt(prMatch[1], 10);
}
}
if (branch) {
try {
const session = buildSessionForIntrospect(sessionName, worktree, branch);
@ -138,12 +158,12 @@ async function gatherSessionInfo(
const [ci, review, threads] = await Promise.all([
scm.getCISummary(prInfo).catch(() => null),
scm.getReviewDecision(prInfo).catch(() => null),
scm.getPendingComments(prInfo).catch(() => []),
scm.getPendingComments(prInfo).catch(() => null),
]);
ciStatus = ci;
reviewDecision = review;
pendingThreads = threads ? threads.length : null;
pendingThreads = threads !== null ? threads.length : null;
}
} catch {
// SCM lookup failed — not critical, we still show what we have