Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
b02f4c461b feat(agents): surface task source on AgentTaskResponse + use it in Tasks tab
Follow-up to #1453. That PR fixed the Tasks tab crash by filtering empty
issue_id out of the detail lookup and rendering a neutral "Task without
linked issue" label, but every issue-less task — chat-spawned or
autopilot-spawned — looked the same. The server already stores the
origin in `agent_task_queue.chat_session_id` / `autopilot_run_id`; only
the HTTP serializer was dropping them.

Server:
- `taskToResponse` now populates `ChatSessionID` and the new
  `AutopilotRunID` on `AgentTaskResponse`. Backward compatible: both
  omit when UUID is invalid, and existing clients ignore unknown
  fields.

Types:
- `AgentTask` (TS) gains `chat_session_id?` + `autopilot_run_id?` and a
  comment clarifying when `issue_id` is empty.

Tasks tab:
- Row label for issue-less tasks is picked from the populated source
  field: "Chat session" for chat tasks, "Autopilot run" for autopilot
  tasks, "Task without linked issue" as the neutral fallback. Rows stay
  inert (no anchor) in all three cases; existing issue-linked path is
  unchanged.

Tests:
- Two new regression tests assert the chat and autopilot labels render
  correctly and neither row becomes an anchor. Existing neutral-label
  test stays as the "neither source populated" case.
2026-04-21 21:10:19 +08:00
4 changed files with 76 additions and 4 deletions

View File

@@ -27,6 +27,9 @@ export interface AgentTask {
id: string;
agent_id: string;
runtime_id: string;
// Empty string ("") when the task has no linked issue — either chat- or
// autopilot-spawned. Check chat_session_id / autopilot_run_id to tell
// which source produced it.
issue_id: string;
status: "queued" | "dispatched" | "running" | "completed" | "failed" | "cancelled";
priority: number;
@@ -36,6 +39,10 @@ export interface AgentTask {
result: unknown;
error: string | null;
created_at: string;
/** Non-empty when the task was spawned from a chat session. */
chat_session_id?: string;
/** Non-empty when the task was spawned by an autopilot run. */
autopilot_run_id?: string;
}
export interface Agent {

View File

@@ -202,4 +202,56 @@ describe("TasksTab", () => {
expect(label.closest("a")).toBeNull();
expect(mockGetIssue).not.toHaveBeenCalled();
});
it("labels chat-spawned tasks as 'Chat session'", async () => {
renderTasksTab(
[
{
id: "task-chat",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "",
chat_session_id: "chat-42",
status: "running",
priority: 1,
dispatched_at: "2026-04-16T00:30:00Z",
started_at: "2026-04-16T00:31:00Z",
completed_at: null,
result: null,
error: null,
created_at: "2026-04-16T00:00:00Z",
},
],
[],
);
const label = await screen.findByText("Chat session");
expect(label.closest("a")).toBeNull();
});
it("labels autopilot-spawned tasks as 'Autopilot run'", async () => {
renderTasksTab(
[
{
id: "task-autopilot",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "",
autopilot_run_id: "run-7",
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("Autopilot run");
expect(label.closest("a")).toBeNull();
});
});

View File

@@ -106,11 +106,18 @@ export function TasksTab({ agent }: { agent: Agent }) {
{sortedTasks.map((task) => {
const config = taskStatusConfig[task.status] ?? taskStatusConfig.queued!;
const Icon = config.icon;
// Tasks without a linked issue (autopilot run_only, chat-spawned,
// etc.) carry issue_id = "" — skip the lookup and render them
// as non-link rows.
// Tasks without a linked issue carry issue_id = "" — skip the
// detail lookup and render them as non-link rows. The source
// label is picked from chat_session_id / autopilot_run_id,
// which the server populates for chat- and autopilot-spawned
// tasks respectively.
const hasIssue = task.issue_id !== "";
const issue = hasIssue ? issueMap.get(task.issue_id) : undefined;
const sourcelessLabel = task.chat_session_id
? "Chat session"
: task.autopilot_run_id
? "Autopilot run"
: "Task without linked issue";
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 ${
@@ -136,7 +143,7 @@ export function TasksTab({ agent }: { agent: Agent }) {
</span>
)}
<span className={`text-sm truncate ${isActive ? "font-medium" : ""}`}>
{issue?.title ?? (hasIssue ? `Issue ${task.issue_id.slice(0, 8)}...` : "Task without linked issue")}
{issue?.title ?? (hasIssue ? `Issue ${task.issue_id.slice(0, 8)}...` : sourcelessLabel)}
</span>
</div>
<div className="mt-0.5 text-xs text-muted-foreground">

View File

@@ -134,6 +134,7 @@ type AgentTaskResponse struct {
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks
}
// TaskAgentData holds agent info included in claim responses so the daemon
@@ -168,6 +169,11 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
Error: textToPtr(t.Error),
CreatedAt: timestampToString(t.CreatedAt),
TriggerCommentID: uuidToPtr(t.TriggerCommentID),
// Surface task source so the UI can distinguish issue-linked tasks
// from chat-spawned or autopilot-spawned ones; all three may arrive
// with issue_id = "" once a task has no linked issue.
ChatSessionID: uuidToString(t.ChatSessionID),
AutopilotRunID: uuidToString(t.AutopilotRunID),
}
}