From e1c7d99c81c8b4e6ae86b5b1dccc55381f979e4c Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Thu, 12 Feb 2026 12:24:58 +0200 Subject: [PATCH] feat(opencode): add list_tasks tool to query all background tasks (#184) --- .fork-features/manifest.json | 40 +++++- packages/opencode/src/tool/list_tasks.ts | 129 ++++++++++++++++++ packages/opencode/src/tool/list_tasks.txt | 17 +++ packages/opencode/src/tool/registry.ts | 2 + .../opencode/test/tool/list_tasks.test.ts | 58 ++++++++ 5 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/tool/list_tasks.ts create mode 100644 packages/opencode/src/tool/list_tasks.txt create mode 100644 packages/opencode/test/tool/list_tasks.test.ts diff --git a/.fork-features/manifest.json b/.fork-features/manifest.json index 36e09a1fc2e1..dba1d86afe24 100644 --- a/.fork-features/manifest.json +++ b/.fork-features/manifest.json @@ -17,9 +17,15 @@ "packages/opencode/src/tool/task.ts", "packages/opencode/src/tool/task.txt", "packages/opencode/src/tool/check_task.ts", - "packages/opencode/src/tool/check_task.txt" + "packages/opencode/src/tool/check_task.txt", + "packages/opencode/src/tool/list_tasks.ts", + "packages/opencode/src/tool/list_tasks.txt" + ], + "modifiedFiles": [ + "packages/opencode/src/session/index.ts", + "packages/opencode/src/session/prompt.ts", + "packages/opencode/src/tool/registry.ts" ], - "modifiedFiles": ["packages/opencode/src/session/index.ts", "packages/opencode/src/session/prompt.ts"], "criticalCode": [ "backgroundTaskResults", "trackBackgroundTask", @@ -27,13 +33,19 @@ "formatCompletedTasksForInjection", "TaskTool", "CheckTaskTool", - "Agent.get(task.agent)", + "ListTasksTool", "parent_session_id", + "session ownership", + "filter to caller's session", "sync", "sync mode execution", "parameters.sync" ], - "tests": ["packages/opencode/test/tool/check_task.test.ts", "packages/opencode/test/session/async-tasks.test.ts"], + "tests": [ + "packages/opencode/test/tool/check_task.test.ts", + "packages/opencode/test/tool/list_tasks.test.ts", + "packages/opencode/test/session/async-tasks.test.ts" + ], "upstreamTracking": { "relatedPRs": ["anomalyco/opencode#7206"], "relatedIssues": ["anomalyco/opencode#5887"], @@ -129,7 +141,7 @@ "modifiedFiles": [], "criticalCode": [], "tests": [], - "upstreamTracking": { + "upstream": { "absorptionSignals": [] } }, @@ -189,6 +201,24 @@ "task.*delegation.*restrict" ] } + }, + "list-tasks": { + "status": "active", + "description": "New tool to list all background tasks with their status. Exposes the internal listBackgroundTasks() function as a user-facing tool. Supports filtering by limit and including/excluding completed tasks.", + "issue": "https://github.com/randomm/opencode/issues/184", + "newFiles": [ + "packages/opencode/src/tool/list_tasks.ts", + "packages/opencode/src/tool/list_tasks.txt", + "packages/opencode/test/tool/list_tasks.test.ts" + ], + "modifiedFiles": ["packages/opencode/src/tool/registry.ts"], + "criticalCode": ["ListTasksTool", "listBackgroundTasks", "list_tasks"], + "tests": ["packages/opencode/test/tool/list_tasks.test.ts"], + "upstreamTracking": { + "relatedPRs": [], + "relatedIssues": [], + "absorptionSignals": ["list.*tasks", "ListTasksTool", "listBackgroundTasks"] + } } } } diff --git a/packages/opencode/src/tool/list_tasks.ts b/packages/opencode/src/tool/list_tasks.ts new file mode 100644 index 000000000000..5c99ef09f144 --- /dev/null +++ b/packages/opencode/src/tool/list_tasks.ts @@ -0,0 +1,129 @@ +import { Tool } from "./tool" +import DESCRIPTION from "./list_tasks.txt" +import z from "zod" +import { listBackgroundTasks, getBackgroundTaskResult, getBackgroundTaskMetadata } from "../session/async-tasks" + +interface TaskInfo { + task_id: string + status: "running" | "completed" | "failed" + agent?: string + description?: string + started_at?: string + completed_at?: string +} + +interface ListTasksResult { + pending: TaskInfo[] + completed: TaskInfo[] + total_count: number +} + +// Helper: Timestamp validation (>= 2000-01-01, sane range) +function isValidTimestamp(timestamp: number): boolean { + if (!timestamp || !Number.isFinite(timestamp)) { + return false + } + const date = new Date(timestamp) + return date.getFullYear() >= 2000 && date.getFullYear() <= 2100 +} + +export const ListTasksTool = Tool.define("list_tasks", { + description: DESCRIPTION, + parameters: z.object({ + limit: z.number().optional().describe("Maximum number of tasks to return (default: 50)"), + include_completed: z.boolean().optional().describe("Include completed tasks (default: true)"), + }), + async execute(params, ctx) { + // CRITICAL #1: Session ownership filtering - only return tasks owned by caller's session + const callerSessionId = ctx.sessionID! + + const tasks = listBackgroundTasks() + const limit = params.limit ?? 50 + + // Filter and map pending tasks + const pending: TaskInfo[] = [] + for (const id of tasks.pending.slice(0, limit)) { + const metadata = getBackgroundTaskMetadata(id) + const result = getBackgroundTaskResult(id) + + // Ownership check: filter to caller's session + const ownedByCaller = + metadata?.parent_session_id === callerSessionId || result?.metadata?.parent_session_id === callerSessionId + + if (!ownedByCaller) { + continue + } + + // CRITICAL #2: Timestamp validation before Date() calls + let started_at: string | undefined + if (metadata?.start_time && isValidTimestamp(metadata.start_time)) { + started_at = new Date(metadata.start_time).toISOString() + } else if (result?.time.started && isValidTimestamp(result.time.started)) { + started_at = new Date(result.time.started).toISOString() + } + + pending.push({ + task_id: id, + status: "running", + agent: metadata?.agent_type, + description: metadata?.description, + started_at, + }) + + if (pending.length >= limit) { + break + } + } + + // Filter and map completed tasks + const completed: TaskInfo[] = [] + if (params.include_completed !== false) { + // CRITICAL #4: Clamp slice end to prevent negative values (limit=0 case) + const completedLimit = Math.max(0, limit - pending.length) + + // CRITICAL #3: Guard against undefined values from Object.entries + for (const [id, result] of Object.entries(tasks.results)) { + if (!result) { + continue + } + + // Ownership check: only return tasks owned by caller's session + if (result.metadata?.parent_session_id !== callerSessionId) { + continue + } + + // CRITICAL #5: Timestamp validation before Date() calls + const has_valid_started = isValidTimestamp(result.time.started) + const has_valid_completed = result.time.completed && isValidTimestamp(result.time.completed) + + completed.push({ + task_id: id, + status: result.status, + agent: result.metadata?.agent_type, + description: result.metadata?.description, + started_at: has_valid_started ? new Date(result.time.started).toISOString() : undefined, + completed_at: has_valid_completed ? new Date(result.time.completed).toISOString() : undefined, + }) + + if (completed.length >= completedLimit) { + break + } + } + } + + const taskResult: ListTasksResult = { + pending, + completed, + total_count: pending.length + completed.length, + } + + return { + title: `List tasks: ${taskResult.total_count} total`, + output: JSON.stringify(taskResult, null, 2), + metadata: { + pending_count: pending.length, + completed_count: completed.length, + }, + } + }, +}) diff --git a/packages/opencode/src/tool/list_tasks.txt b/packages/opencode/src/tool/list_tasks.txt new file mode 100644 index 000000000000..36701419223e --- /dev/null +++ b/packages/opencode/src/tool/list_tasks.txt @@ -0,0 +1,17 @@ +List all background tasks with their status. + +Use this tool to get an overview of all running and completed tasks without needing to know their individual IDs. + +Parameters: +- limit: Maximum number of tasks to return (default: 50) +- include_completed: Whether to include completed tasks (default: true) + +Returns: +- pending: Array of running tasks with task_id, status, agent, description, started_at +- completed: Array of completed/failed tasks with task_id, status, agent, description, started_at, completed_at +- total_count: Total number of tasks returned + +Example usage: +List all tasks: /list_tasks +List only pending tasks: /list_tasks {"include_completed": false} +List up to 10 tasks: /list_tasks {"limit": 10} \ No newline at end of file diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 2467ede83fc7..a298ed9f09c4 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -27,6 +27,7 @@ import { Truncate } from "./truncation" import { PlanExitTool, PlanEnterTool } from "./plan" import { ApplyPatchTool } from "./apply_patch" import { CheckTaskTool } from "./check_task" +import { ListTasksTool } from "./list_tasks" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -109,6 +110,7 @@ export namespace ToolRegistry { WriteTool, TaskTool, CheckTaskTool, + ListTasksTool, WebFetchTool, TodoWriteTool, // TodoReadTool, diff --git a/packages/opencode/test/tool/list_tasks.test.ts b/packages/opencode/test/tool/list_tasks.test.ts new file mode 100644 index 000000000000..07c0c4718465 --- /dev/null +++ b/packages/opencode/test/tool/list_tasks.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { ListTasksTool } from "../../src/tool/list_tasks" +import { Instance } from "../../src/project/instance" + +describe("tool.list_tasks", () => { + const ctx = { + sessionID: "test-session", + messageID: "", + callID: "", + agent: "test", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + extra: { bypassAgentCheck: true }, + } + + test("returns empty list when no tasks", async () => { + await Instance.provide({ + directory: "/tmp/test", + fn: async () => { + const tool = await ListTasksTool.init() + const result = await tool.execute({}, ctx) + + const output = JSON.parse(result.output) + expect(output.pending).toEqual([]) + expect(output.completed).toEqual([]) + expect(output.total_count).toBe(0) + }, + }) + }) + + test("respects limit parameter", async () => { + await Instance.provide({ + directory: "/tmp/test", + fn: async () => { + const tool = await ListTasksTool.init() + const result = await tool.execute({ limit: 5 }, ctx) + + const output = JSON.parse(result.output) + expect(output.total_count).toBeLessThanOrEqual(5) + }, + }) + }) + + test("can exclude completed tasks", async () => { + await Instance.provide({ + directory: "/tmp/test", + fn: async () => { + const tool = await ListTasksTool.init() + const result = await tool.execute({ include_completed: false }, ctx) + + const output = JSON.parse(result.output) + expect(output.completed).toEqual([]) + }, + }) + }) +})