mirror of https://github.com/openclaw/openclaw.git
feat(tasks): add chat-native task board (#58828)
This commit is contained in:
parent
7cf8ccf9b3
commit
facdeb3432
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<TaskRecord["status"], string> = {
|
||||
queued: "🟡",
|
||||
running: "🟢",
|
||||
succeeded: "✅",
|
||||
failed: "🔴",
|
||||
timed_out: "⏱️",
|
||||
cancelled: "⚪️",
|
||||
lost: "⚠️",
|
||||
};
|
||||
|
||||
const TASK_RUNTIME_LABELS: Record<TaskRecord["runtime"], string> = {
|
||||
subagent: "Subagent",
|
||||
acp: "ACP",
|
||||
cli: "CLI",
|
||||
cron: "Cron",
|
||||
};
|
||||
|
||||
function formatTaskHeadline(snapshot: ReturnType<typeof buildTaskStatusSnapshot>): 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<ReplyPayload> {
|
||||
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 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (normalized !== "/tasks") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "Usage: /tasks" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: await buildTasksReply(params),
|
||||
};
|
||||
};
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue