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:
parent
5b68fccbd0
commit
799760ae25
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue