Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
59ae12813a fix(agents): neutral label for issue-less tasks + regression test
Review feedback on #1453: not every task without a linked issue is an
autopilot run. `ListAgentTasks` returns the agent's full queue; both
autopilot `run_only` runs and chat-spawned tasks persist with NULL
issue_id, which arrives here as "". Labeling both as "Autopilot run"
mislabels chat tasks.

Swap the label to the neutral "Task without linked issue" and update
surrounding comments. A follow-up will surface the real task source
once the server populates it on AgentTaskResponse.

Adds a regression test that empty issue_id rows render the neutral
label, aren't wrapped in an anchor, and don't trigger a detail fetch.
2026-04-21 21:02:48 +08:00
Jiang Bohan
4c0259fa76 fix(agents): tasks tab crashes when agent has autopilot run_only tasks
Autopilot `run_only` tasks have no linked issue; the server serializes
that as `issue_id: ""` (not null) via `uuidToString` on an invalid
pgtype.UUID. The agent detail Tasks tab assumed every task had a real
issue id and fed `""` into `api.getIssue(id)` → `/api/issues/` and into
`paths.issueDetail("")`, crashing the whole tab as soon as one such
task existed on the agent.

Handle the empty-issue case explicitly:

- Filter empty ids out of `issueIds` so `useQueries` doesn't fire
  `/api/issues/` for a nonexistent issue.
- Render run_only rows as non-link `<div>`s labeled "Autopilot run"
  instead of clickable issue links.

No server-side change — the `""` serialization stays as-is; callers
just need to treat it as "no issue".
2026-04-21 20:49:05 +08:00
2 changed files with 55 additions and 3 deletions

View File

@@ -169,4 +169,37 @@ describe("TasksTab", () => {
expect(link?.getAttribute("href")).toBe("/test/issues/12345678-fallback");
});
it("renders tasks with empty issue_id as inert rows and does not fetch issue detail", async () => {
// Tasks persisted with NULL issue_id — autopilot run_only runs and
// chat-spawned tasks — arrive here with issue_id === "". The tab used
// to feed that empty id into `/api/issues/`, which crashed the whole
// page after the list-cache paginate refactor (#1422). It must now:
// - skip the detail fetch entirely,
// - render a neutral label instead of an "Issue ..." stub, and
// - NOT wrap the row in an anchor.
renderTasksTab(
[
{
id: "task-no-issue",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "",
status: "completed",
priority: 1,
dispatched_at: null,
started_at: null,
completed_at: "2026-04-16T01:00:00Z",
result: null,
error: null,
created_at: "2026-04-16T00:00:00Z",
},
],
[],
);
const label = await screen.findByText("Task without linked issue");
expect(label.closest("a")).toBeNull();
expect(mockGetIssue).not.toHaveBeenCalled();
});
});

View File

@@ -31,8 +31,15 @@ export function TasksTab({ agent }: { agent: Agent }) {
// issue may or may not be in the paginated issue-list cache, so going
// through `issueDetailOptions` is the reliable lookup path (and it shares
// the same cache as the issue detail page).
//
// Not every task has a linked issue — autopilot `run_only` runs and
// chat-spawned tasks both persist with NULL issue_id, which the server
// serializes as "". Filter those out before issuing detail queries so we
// don't hit `/api/issues/` with an empty id (which was crashing the
// whole tab). The UI treats the two cases the same here; a follow-up
// will surface the task source once the server exposes it.
const issueIds = useMemo(
() => Array.from(new Set(tasks.map((t) => t.issue_id))),
() => Array.from(new Set(tasks.map((t) => t.issue_id).filter((id) => id !== ""))),
[tasks],
);
const issueQueries = useQueries({
@@ -99,7 +106,11 @@ export function TasksTab({ agent }: { agent: Agent }) {
{sortedTasks.map((task) => {
const config = taskStatusConfig[task.status] ?? taskStatusConfig.queued!;
const Icon = config.icon;
const issue = issueMap.get(task.issue_id);
// Tasks without a linked issue (autopilot run_only, chat-spawned,
// etc.) carry issue_id = "" — skip the lookup and render them
// as non-link rows.
const hasIssue = task.issue_id !== "";
const issue = hasIssue ? issueMap.get(task.issue_id) : undefined;
const isActive = task.status === "running" || task.status === "dispatched";
const isRunning = task.status === "running";
const rowClassName = `flex items-center gap-3 rounded-lg border px-4 py-3 transition-shadow hover:shadow-sm ${
@@ -125,7 +136,7 @@ export function TasksTab({ agent }: { agent: Agent }) {
</span>
)}
<span className={`text-sm truncate ${isActive ? "font-medium" : ""}`}>
{issue?.title ?? `Issue ${task.issue_id.slice(0, 8)}...`}
{issue?.title ?? (hasIssue ? `Issue ${task.issue_id.slice(0, 8)}...` : "Task without linked issue")}
</span>
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
@@ -146,6 +157,14 @@ export function TasksTab({ agent }: { agent: Agent }) {
</>
);
if (!hasIssue) {
return (
<div key={task.id} className={rowClassName}>
{content}
</div>
);
}
return (
<AppLink
key={task.id}