From facdeb3432741ce970749f2168cfd456aa6ccae1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 1 Apr 2026 16:48:36 +0900 Subject: [PATCH] feat(tasks): add chat-native task board (#58828) --- CHANGELOG.md | 1 + src/auto-reply/commands-registry.shared.ts | 7 + src/auto-reply/commands-registry.test.ts | 2 + .../reply/commands-handlers.runtime.ts | 2 + src/auto-reply/reply/commands-tasks.test.ts | 129 ++++++++++++++++ src/auto-reply/reply/commands-tasks.ts | 143 ++++++++++++++++++ src/auto-reply/status.tools.test.ts | 1 + src/auto-reply/status.ts | 2 +- 8 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 src/auto-reply/reply/commands-tasks.test.ts create mode 100644 src/auto-reply/reply/commands-tasks.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 740938e7446..d6e0471263b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Channels/session routing: move provider-specific session conversation grammar into plugin-owned session-key surfaces, preserving Telegram topic routing and Feishu scoped inheritance across bootstrap, model override, restart, and tool-policy paths. - WhatsApp/reactions: add `reactionLevel` guidance for agent reactions. Thanks @mcaxtr. - Feishu/comments: add a dedicated Drive comment-event flow with comment-thread context resolution, in-thread replies, and `feishu_drive` comment actions for document collaboration workflows. (#58497) thanks @wittam-01. +- Tasks/chat: add `/tasks` as a chat-native background task board for the current session, with recent task details and agent-local fallback counts when no linked tasks are visible. Related #54226. Thanks @vincentkoc. ### Fixes diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index 41f49cd3ff2..747526a0b64 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -173,6 +173,13 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { textAlias: "/status", category: "status", }), + defineChatCommand({ + key: "tasks", + nativeName: "tasks", + description: "List background tasks for this session.", + textAlias: "/tasks", + category: "status", + }), defineChatCommand({ key: "allowlist", description: "List/add/remove allowlist entries.", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 1a9be5ecf7a..2a179a0ac6d 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -30,6 +30,7 @@ afterEach(() => { describe("commands registry", () => { it("builds command text with args", () => { expect(buildCommandText("status")).toBe("/status"); + expect(buildCommandText("tasks")).toBe("/tasks"); expect(buildCommandText("model", "gpt-5")).toBe("/model gpt-5"); expect(buildCommandText("models")).toBe("/models"); }); @@ -39,6 +40,7 @@ describe("commands registry", () => { expect(specs.find((spec) => spec.name === "help")).toBeTruthy(); expect(specs.find((spec) => spec.name === "stop")).toBeTruthy(); expect(specs.find((spec) => spec.name === "skill")).toBeTruthy(); + expect(specs.find((spec) => spec.name === "tasks")).toBeTruthy(); expect(specs.find((spec) => spec.name === "whoami")).toBeTruthy(); expect(specs.find((spec) => spec.name === "compact")).toBeTruthy(); }); diff --git a/src/auto-reply/reply/commands-handlers.runtime.ts b/src/auto-reply/reply/commands-handlers.runtime.ts index c9c10b9718f..0133748f45f 100644 --- a/src/auto-reply/reply/commands-handlers.runtime.ts +++ b/src/auto-reply/reply/commands-handlers.runtime.ts @@ -29,6 +29,7 @@ import { handleUsageCommand, } from "./commands-session.js"; import { handleSubagentsCommand } from "./commands-subagents.js"; +import { handleTasksCommand } from "./commands-tasks.js"; import { handleTtsCommands } from "./commands-tts.js"; import type { CommandHandler } from "./commands-types.js"; @@ -48,6 +49,7 @@ export function loadCommandHandlers(): CommandHandler[] { handleCommandsListCommand, handleToolsCommand, handleStatusCommand, + handleTasksCommand, handleAllowlistCommand, handleApproveCommand, handleContextCommand, diff --git a/src/auto-reply/reply/commands-tasks.test.ts b/src/auto-reply/reply/commands-tasks.test.ts new file mode 100644 index 00000000000..c52c4844f5d --- /dev/null +++ b/src/auto-reply/reply/commands-tasks.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + completeTaskRunByRunId, + createQueuedTaskRun, + createRunningTaskRun, + failTaskRunByRunId, +} from "../../tasks/task-executor.js"; +import { resetTaskRegistryForTests } from "../../tasks/task-registry.js"; +import { buildTasksReply, handleTasksCommand } from "./commands-tasks.js"; +import { buildCommandTestParams } from "./commands.test-harness.js"; + +const baseCfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { mainKey: "main", scope: "per-sender" }, +} as OpenClawConfig; + +async function buildTasksReplyForTest(params: { sessionKey?: string } = {}) { + const commandParams = buildCommandTestParams("/tasks", baseCfg); + return await buildTasksReply({ + ...commandParams, + sessionKey: params.sessionKey ?? commandParams.sessionKey, + }); +} + +describe("buildTasksReply", () => { + beforeEach(() => { + resetTaskRegistryForTests(); + }); + + afterEach(() => { + resetTaskRegistryForTests(); + }); + + it("lists active and recent tasks for the current session", async () => { + createRunningTaskRun({ + runtime: "subagent", + requesterSessionKey: "agent:main:main", + childSessionKey: "agent:main:subagent:tasks-running", + runId: "run-tasks-running", + task: "active background task", + progressSummary: "still working", + }); + createQueuedTaskRun({ + runtime: "cron", + requesterSessionKey: "agent:main:main", + childSessionKey: "agent:main:subagent:tasks-queued", + runId: "run-tasks-queued", + task: "queued background task", + }); + createRunningTaskRun({ + runtime: "acp", + requesterSessionKey: "agent:main:main", + childSessionKey: "agent:main:acp:tasks-failed", + runId: "run-tasks-failed", + task: "failed background task", + }); + failTaskRunByRunId({ + runId: "run-tasks-failed", + endedAt: Date.now(), + error: "approval denied", + }); + + const reply = await buildTasksReplyForTest(); + + expect(reply.text).toContain("📋 Tasks"); + expect(reply.text).toContain("Current session: 2 active · 3 total"); + expect(reply.text).toContain("🟢 active background task"); + expect(reply.text).toContain("🟡 queued background task"); + expect(reply.text).toContain("🔴 failed background task"); + expect(reply.text).toContain("approval denied"); + }); + + it("hides stale completed tasks from the task board", async () => { + createQueuedTaskRun({ + runtime: "cron", + requesterSessionKey: "agent:main:main", + childSessionKey: "agent:main:subagent:tasks-stale", + runId: "run-tasks-stale", + task: "stale completed task", + }); + completeTaskRunByRunId({ + runId: "run-tasks-stale", + endedAt: Date.now() - 10 * 60_000, + terminalSummary: "done a while ago", + }); + + const reply = await buildTasksReplyForTest(); + + expect(reply.text).toContain("All clear - nothing linked to this session right now."); + expect(reply.text).not.toContain("stale completed task"); + expect(reply.text).not.toContain("done a while ago"); + }); + + it("falls back to agent-local counts when the current session has no visible tasks", async () => { + createRunningTaskRun({ + runtime: "subagent", + requesterSessionKey: "agent:main:other-session", + childSessionKey: "agent:main:subagent:tasks-agent-fallback", + runId: "run-tasks-agent-fallback", + agentId: "main", + task: "hidden background task", + progressSummary: "hidden progress detail", + }); + + const reply = await buildTasksReplyForTest({ + sessionKey: "agent:main:empty-session", + }); + + expect(reply.text).toContain("All clear - nothing linked to this session right now."); + expect(reply.text).toContain("Agent-local: 1 active · 1 total"); + expect(reply.text).not.toContain("hidden background task"); + expect(reply.text).not.toContain("hidden progress detail"); + }); +}); + +describe("handleTasksCommand", () => { + it("returns usage for unsupported args", async () => { + const params = buildCommandTestParams("/tasks extra", baseCfg); + + const result = await handleTasksCommand(params, true); + + expect(result).toEqual({ + shouldContinue: false, + reply: { text: "Usage: /tasks" }, + }); + }); +}); diff --git a/src/auto-reply/reply/commands-tasks.ts b/src/auto-reply/reply/commands-tasks.ts new file mode 100644 index 00000000000..be0343e23ab --- /dev/null +++ b/src/auto-reply/reply/commands-tasks.ts @@ -0,0 +1,143 @@ +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { logVerbose } from "../../globals.js"; +import { formatDurationCompact } from "../../infra/format-time/format-duration.ts"; +import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; +import { listTasksForAgentId, listTasksForSessionKey } from "../../tasks/task-registry.js"; +import type { TaskRecord } from "../../tasks/task-registry.types.js"; +import { buildTaskStatusSnapshot } from "../../tasks/task-status.js"; +import type { ReplyPayload } from "../types.js"; +import type { CommandHandler, HandleCommandsParams } from "./commands-types.js"; + +const MAX_VISIBLE_TASKS = 5; + +const TASK_STATUS_ICONS: Record = { + queued: "🟡", + running: "🟢", + succeeded: "✅", + failed: "🔴", + timed_out: "⏱️", + cancelled: "⚪️", + lost: "⚠️", +}; + +const TASK_RUNTIME_LABELS: Record = { + subagent: "Subagent", + acp: "ACP", + cli: "CLI", + cron: "Cron", +}; + +function formatTaskHeadline(snapshot: ReturnType): string { + if (snapshot.totalCount === 0) { + return "All clear - nothing linked to this session right now."; + } + return `Current session: ${snapshot.activeCount} active · ${snapshot.totalCount} total`; +} + +function formatAgentFallbackLine(agentId: string): string | undefined { + const snapshot = buildTaskStatusSnapshot(listTasksForAgentId(agentId)); + if (snapshot.totalCount === 0) { + return undefined; + } + return `Agent-local: ${snapshot.activeCount} active · ${snapshot.totalCount} total`; +} + +function formatTaskTiming(task: TaskRecord): string | undefined { + if (task.status === "running") { + const startedAt = task.startedAt ?? task.createdAt; + return `elapsed ${formatDurationCompact(Date.now() - startedAt, { spaced: true }) ?? "0s"}`; + } + if (task.status === "queued") { + return `queued ${formatTimeAgo(Date.now() - task.createdAt)}`; + } + const endedAt = task.endedAt ?? task.lastEventAt ?? task.createdAt; + return `finished ${formatTimeAgo(Date.now() - endedAt)}`; +} + +function formatTaskDetail(task: TaskRecord): string | undefined { + if (task.status === "running" || task.status === "queued") { + return task.progressSummary?.trim(); + } + return task.error?.trim() || task.terminalSummary?.trim(); +} + +function formatVisibleTask(task: TaskRecord, index: number): string { + const title = task.label?.trim() || task.task.trim(); + const status = task.status.replaceAll("_", " "); + const timing = formatTaskTiming(task); + const detail = formatTaskDetail(task); + const meta = [TASK_RUNTIME_LABELS[task.runtime], status, timing].filter(Boolean).join(" · "); + const lines = [`${index + 1}. ${TASK_STATUS_ICONS[task.status]} ${title}`, ` ${meta}`]; + if (detail) { + lines.push(` ${detail}`); + } + return lines.join("\n"); +} + +export function buildTasksText(params: { sessionKey: string; agentId: string }): string { + const sessionSnapshot = buildTaskStatusSnapshot(listTasksForSessionKey(params.sessionKey)); + const lines = ["📋 Tasks", formatTaskHeadline(sessionSnapshot)]; + + if (sessionSnapshot.totalCount > 0) { + const visible = sessionSnapshot.visible.slice(0, MAX_VISIBLE_TASKS); + lines.push(""); + for (const [index, task] of visible.entries()) { + lines.push(formatVisibleTask(task, index)); + if (index < visible.length - 1) { + lines.push(""); + } + } + const hiddenCount = sessionSnapshot.visible.length - visible.length; + if (hiddenCount > 0) { + lines.push("", `+${hiddenCount} more recent task${hiddenCount === 1 ? "" : "s"}`); + } + return lines.join("\n"); + } + + const agentFallback = formatAgentFallbackLine(params.agentId); + if (agentFallback) { + lines.push(agentFallback); + } + return lines.join("\n"); +} + +export async function buildTasksReply(params: HandleCommandsParams): Promise { + const agentId = + params.agentId ?? + resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + }); + return { + text: buildTasksText({ + sessionKey: params.sessionKey, + agentId, + }), + }; +} + +export const handleTasksCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) { + return null; + } + const normalized = params.command.commandBodyNormalized; + if (normalized !== "/tasks" && !normalized.startsWith("/tasks ")) { + return null; + } + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /tasks from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + if (normalized !== "/tasks") { + return { + shouldContinue: false, + reply: { text: "Usage: /tasks" }, + }; + } + return { + shouldContinue: false, + reply: await buildTasksReply(params), + }; +}; diff --git a/src/auto-reply/status.tools.test.ts b/src/auto-reply/status.tools.test.ts index ea022c80e5f..c513baa9588 100644 --- a/src/auto-reply/status.tools.test.ts +++ b/src/auto-reply/status.tools.test.ts @@ -15,6 +15,7 @@ describe("tools product copy", () => { expect(buildCommandsMessage(cfg)).toContain("/tools - List available runtime tools."); expect(buildCommandsMessage(cfg)).toContain("More: /tools for available capabilities"); expect(buildHelpMessage(cfg)).toContain("/tools for available capabilities"); + expect(buildHelpMessage(cfg)).toContain("/tasks"); }); it("formats built-in and plugin tools for end users", () => { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 256efc6ebe2..a66c6e887aa 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -887,7 +887,7 @@ export function buildHelpMessage(cfg?: OpenClawConfig): string { lines.push(""); lines.push("Status"); - lines.push(" /status | /whoami | /context"); + lines.push(" /status | /tasks | /whoami | /context"); lines.push(""); lines.push("Skills");