mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
✨ feat: support agent tasks system (#13289)
* ✨ feat: agent task system — CLI, review rubrics, workspace, comments, brief tool split support import md Major changes: - Split task CLI into modular files (task/, lifecycle, topic, doc, review, checkpoint, dep) - Split builtin-tool-task into task + brief tools (conditional injection) - Task review uses EvalBenchmarkRubric from @lobechat/eval-rubric - Task workspace: documents auto-pin via Notebook, tree view with folders - Task comments system (task_comments table) - Task topics: dedicated TaskTopicModel with userId, handoff fields, review results - Heartbeat timeout auto-detection in detail API - Run idempotency (reject duplicate runs) + error rollback - Topic cancel/delete by topicId only (no taskId needed) - Integration tests for task router (13 tests) - interruptOperation fix (string param, not object) - Global TRPC error handler in CLI Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> task document workflow task handoff loop 🗃️ chore: consolidate task system migrations into single 0095 Merged 7 separate migrations (0095-0101) into one: - tasks, briefs, task_comments, task_dependencies, task_documents, task_topics tables - All fields including sort_order, resolved_action/comment, review fields - Idempotent CREATE TABLE IF NOT EXISTS, DROP/ADD CONSTRAINT, CREATE INDEX IF NOT EXISTS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> fix interruptOperation topic auto review workflow topic handoff workflow finish run topic and brief workflow support task tool improve task schema update ✨ feat: add onComplete hook to task.run for completion callbacks When agent execution completes, the hook: - Updates task heartbeat - Creates a result Brief (on success) with assistant content summary - Creates an error Brief (on failure) with error message - Supports both local (handler) and production (webhook) modes Uses the new Agent Runtime Hooks system instead of raw stepCallbacks. LOBE-6160 LOBE-6208 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> ✨ feat: add Review system — LLM-as-Judge automated review Task review uses an independent LLM call to evaluate topic output quality against configurable criteria with pass/fail thresholds. - TaskReviewService: structured LLM review via generateObject, auto-resolves model/provider from user's system agent defaults - Model: getReviewConfig, updateReviewConfig on TaskModel - Router: getReview, updateReview, runReview procedures - CLI: `task review set/view/run` commands - Auto-creates Brief with review results LOBE-6165 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> ✨ feat: add TaskScheduler, multi-topic execution, and handoff context - TaskScheduler: interface + Local implementation (setTimeout-based), following QueueService dual-mode pattern - Multi-topic execution: `task run --topics N --delay S` runs N topics in sequence with optional delay between them - Handoff context: buildTaskPrompt() queries previous topics by metadata.taskId and injects handoff summaries into the next topic's prompt (sliding window: latest full, older summaries only) - Heartbeat auto-update between topics LOBE-6161 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> ✨ feat: add Heartbeat watchdog + heartbeat CLI Watchdog scans running tasks with expired heartbeats, marks them as failed, and creates urgent error Briefs. Heartbeat CLI allows manual heartbeat reporting for testing. - Model: refactored to use Drizzle operators (isNull, isNotNull, ne) instead of raw SQL where possible; fixed findStuckTasks to skip tasks without heartbeat data - Router: heartbeat (manual report), watchdog (scan + fail + brief) - Router: updateSchema now includes heartbeatInterval, heartbeatTimeout - CLI: `task heartbeat <id>`, `task watchdog`, `task edit` with --heartbeat-timeout, --heartbeat-interval, --description LOBE-6161 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> ♻️ refactor: move CheckpointConfig to @lobechat/types Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> ✨ feat: add task run — trigger agent execution for tasks Task.run creates a topic, triggers AiAgentService.execAgent with task context, and streams results via SSE. Supports both agentId and slug. - Service: added taskId to ExecAgentParams, included in topic metadata - Router: task.run procedure — resolves agent, builds prompt, calls execAgent, updates topic count and heartbeat - CLI: `task run <id>` command with SSE streaming, --prompt, --verbose LOBE-6160 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> ✨ feat: add Checkpoint system for task review gates Checkpoint allows configuring pause points in task execution flow. Supports beforeIds (pause before subtask starts) and afterIds (pause after subtask completes) on parent tasks. - Model: CheckpointConfig type, getCheckpointConfig, updateCheckpointConfig, shouldPauseBeforeStart, shouldPauseAfterComplete - Router: getCheckpoint, updateCheckpoint procedures; integrated with updateStatus for automatic checkpoint triggering - CLI: `task checkpoint view/set` commands with --before, --after, --topic-before, --topic-after, --on-agent-request options - Tests: 3 new checkpoint tests (37 total) LOBE-6162 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> ✨ feat: add dependency unlocking on task completion When a task completes, automatically check and unlock blocked tasks whose dependencies are all satisfied (backlog → running). Also notify when all subtasks of a parent are completed. - Model: getUnlockedTasks, areAllSubtasksCompleted (Drizzle, no raw SQL) - Router: updateStatus hook triggers unlocking on completion - CLI: shows unlocked tasks and parent completion notification - Tests: 3 new tests (34 total) LOBE-6164 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> ✨ feat: add Brief system — schema, model, router, CLI Brief is a universal Agent-to-User reporting mechanism, not limited to Tasks. CronJobs, Agents, and future systems can all produce Briefs. - Schema: briefs table with polymorphic source (taskId, cronJobId, agentId) - Model: BriefModel with CRUD, listUnresolved (Daily Brief), markRead, resolve - Router: TRPC brief router with taskId identifier resolution - CLI: `lh brief` command (list/view/read/resolve) - Tests: 11 model tests - Migration: 0096_add_briefs_table.sql LOBE-6163 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> ✨ feat: add Task system — schema, model, router, CLI Implement the foundational Task system for managing long-running, multi-topic agent tasks with subtask trees and dependency chains. - Schema: tasks, task_dependencies, task_documents tables - Model: TaskModel with CRUD, tree queries, heartbeat, dependencies, document pinning - Router: TRPC task router with identifier/id resolution - CLI: `lh task` command (list/view/create/edit/delete/start/pause/resume/complete/cancel/tree/dep) - Tests: 31 model tests - Migration: 0095_add_task_tables.sql LOBE-6036 LOBE-6054 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * update * 🐛 fix: update brief model import path and add raw-md vitest plugin Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: eslint import sort in vitest config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: brief ID validation, auto-review retry, and continueTopicId operationId Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: task integration tests — create test agent for FK, fix children spread Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: task integration tests — correct identifier prefix and agent ID Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: remove unused toolsActivatorRuntime import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: create real topic in task integration tests to satisfy FK constraint Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: type errors in task prompt tests, handoff schema, and activity mapping Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: create real agent/topic/brief records in database model tests for FK constraints Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aa48b856fb
commit
093fa7bcae
72 changed files with 9018 additions and 36 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.1-canary.12",
|
||||
"version": "0.0.1-canary.14",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
|
|
|||
211
apps/cli/src/commands/brief.ts
Normal file
211
apps/cli/src/commands/brief.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerBriefCommand(program: Command) {
|
||||
const brief = program.command('brief').description('Manage briefs (Agent reports)');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
brief
|
||||
.command('list')
|
||||
.description('List briefs')
|
||||
.option('--unresolved', 'Only show unresolved briefs (default)')
|
||||
.option('--all', 'Show all briefs')
|
||||
.option('--type <type>', 'Filter by type (decision/result/insight/error)')
|
||||
.option('-L, --limit <n>', 'Page size', '50')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
all?: boolean;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
type?: string;
|
||||
unresolved?: boolean;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let items: any[];
|
||||
|
||||
if (options.all) {
|
||||
const input: Record<string, any> = {};
|
||||
if (options.type) input.type = options.type;
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
const result = await client.brief.list.query(input as any);
|
||||
items = result.data;
|
||||
} else {
|
||||
const result = await client.brief.listUnresolved.query();
|
||||
items = result.data;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
log.info('No briefs found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items.map((b: any) => [
|
||||
typeBadge(b.type, b.priority),
|
||||
truncate(b.title, 40),
|
||||
truncate(b.summary, 50),
|
||||
b.taskId ? pc.dim(b.taskId) : b.cronJobId ? pc.dim(b.cronJobId) : '-',
|
||||
b.resolvedAt ? pc.green('resolved') : b.readAt ? pc.dim('read') : 'new',
|
||||
timeAgo(b.createdAt),
|
||||
]);
|
||||
|
||||
printTable(rows, ['TYPE', 'TITLE', 'SUMMARY', 'SOURCE', 'STATUS', 'CREATED']);
|
||||
},
|
||||
);
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
brief
|
||||
.command('view <id>')
|
||||
.description('View brief details (auto marks as read)')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.brief.find.query({ id });
|
||||
const b = result.data;
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(b, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!b) {
|
||||
log.error('Brief not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto mark as read
|
||||
if (!b.readAt) {
|
||||
await client.brief.markRead.mutate({ id });
|
||||
}
|
||||
|
||||
const resolvedLabel = b.resolvedAt
|
||||
? (() => {
|
||||
const actions = (b.actions as any[]) || [];
|
||||
const matched = actions.find((a: any) => a.key === (b as any).resolvedAction);
|
||||
return pc.green(` ${matched?.label || '✓ resolved'}`);
|
||||
})()
|
||||
: '';
|
||||
|
||||
console.log(`\n${typeBadge(b.type, b.priority)} ${pc.bold(b.title)}${resolvedLabel}`);
|
||||
console.log(`${pc.dim('Type:')} ${b.type} ${pc.dim('Created:')} ${timeAgo(b.createdAt)}`);
|
||||
if (b.agentId) console.log(`${pc.dim('Agent:')} ${b.agentId}`);
|
||||
if (b.taskId) console.log(`${pc.dim('Task:')} ${b.taskId}`);
|
||||
if (b.cronJobId) console.log(`${pc.dim('CronJob:')} ${b.cronJobId}`);
|
||||
if (b.topicId) console.log(`${pc.dim('Topic:')} ${b.topicId}`);
|
||||
console.log(`\n${b.summary}`);
|
||||
|
||||
if (b.artifacts && (b.artifacts as string[]).length > 0) {
|
||||
console.log(`\n${pc.dim('Artifacts:')}`);
|
||||
for (const a of b.artifacts as string[]) {
|
||||
console.log(` 📎 ${a}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
if (!b.resolvedAt) {
|
||||
const actions = (b.actions as any[]) || [];
|
||||
if (actions.length > 0) {
|
||||
console.log('Actions:');
|
||||
for (const a of actions) {
|
||||
const cmd =
|
||||
a.type === 'comment'
|
||||
? `lh brief resolve ${b.id} --action ${a.key} -m "内容"`
|
||||
: `lh brief resolve ${b.id} --action ${a.key}`;
|
||||
console.log(` ${a.label} ${pc.dim(cmd)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(pc.dim('Actions:'));
|
||||
console.log(pc.dim(` lh brief resolve ${b.id} # 确认通过`));
|
||||
console.log(pc.dim(` lh brief resolve ${b.id} --reply "修改意见" # 反馈修改`));
|
||||
}
|
||||
} else if ((b as any).resolvedComment) {
|
||||
console.log(`${pc.dim('Comment:')} ${(b as any).resolvedComment}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── resolve ──────────────────────────────────────────────
|
||||
|
||||
brief
|
||||
.command('resolve <id>')
|
||||
.description('Resolve a brief (approve, reply, or custom action)')
|
||||
.option('--action <key>', 'Execute a specific action (e.g. approve, feedback)')
|
||||
.option('--reply <text>', 'Reply with feedback')
|
||||
.option('-m, --message <text>', 'Message for comment-type actions')
|
||||
.action(async (id: string, options: { action?: string; message?: string; reply?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const actionKey = options.action || (options.reply ? 'feedback' : 'approve');
|
||||
const actionMessage = options.message || options.reply;
|
||||
|
||||
const briefResult = await client.brief.find.query({ id });
|
||||
const b = briefResult.data;
|
||||
|
||||
// For comment-type actions, add comment to task
|
||||
if (actionMessage && b?.taskId) {
|
||||
await client.task.addComment.mutate({
|
||||
briefId: id,
|
||||
content: actionMessage,
|
||||
id: b.taskId,
|
||||
});
|
||||
}
|
||||
|
||||
await client.brief.resolve.mutate({
|
||||
action: actionKey,
|
||||
comment: actionMessage,
|
||||
id,
|
||||
});
|
||||
|
||||
const actions = (b?.actions as any[]) || [];
|
||||
const matchedAction = actions.find((a: any) => a.key === actionKey);
|
||||
const label = matchedAction?.label || actionKey;
|
||||
|
||||
log.info(`${label} — Brief ${pc.dim(id)} resolved.`);
|
||||
});
|
||||
|
||||
// ── delete ──────────────────────────────────────────────
|
||||
|
||||
brief
|
||||
.command('delete <id>')
|
||||
.description('Delete a brief')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.brief.delete.mutate({ id });
|
||||
log.info(`Brief ${pc.dim(id)} deleted.`);
|
||||
});
|
||||
}
|
||||
|
||||
function typeBadge(type: string, priority?: string): string {
|
||||
if (priority === 'urgent') {
|
||||
return pc.red('🔴');
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'decision': {
|
||||
return pc.yellow('🟡');
|
||||
}
|
||||
case 'result': {
|
||||
return pc.green('✅');
|
||||
}
|
||||
case 'insight': {
|
||||
return '💬';
|
||||
}
|
||||
case 'error': {
|
||||
return pc.red('❌');
|
||||
}
|
||||
default: {
|
||||
return '·';
|
||||
}
|
||||
}
|
||||
}
|
||||
95
apps/cli/src/commands/task/checkpoint.ts
Normal file
95
apps/cli/src/commands/task/checkpoint.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerCheckpointCommands(task: Command) {
|
||||
// ── checkpoint ──────────────────────────────────────────────
|
||||
|
||||
const cp = task.command('checkpoint').description('Manage task checkpoints');
|
||||
|
||||
cp.command('view <id>')
|
||||
.description('View checkpoint config for a task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getCheckpoint.query({ id });
|
||||
const c = result.data as any;
|
||||
|
||||
console.log(`\n${pc.bold('Checkpoint config:')}`);
|
||||
console.log(` onAgentRequest: ${c.onAgentRequest ?? pc.dim('not set (default: true)')}`);
|
||||
if (c.topic) {
|
||||
console.log(` topic.before: ${c.topic.before ?? false}`);
|
||||
console.log(` topic.after: ${c.topic.after ?? false}`);
|
||||
}
|
||||
if (c.tasks?.beforeIds?.length > 0) {
|
||||
console.log(` tasks.beforeIds: ${c.tasks.beforeIds.join(', ')}`);
|
||||
}
|
||||
if (c.tasks?.afterIds?.length > 0) {
|
||||
console.log(` tasks.afterIds: ${c.tasks.afterIds.join(', ')}`);
|
||||
}
|
||||
if (
|
||||
!c.topic &&
|
||||
!c.tasks?.beforeIds?.length &&
|
||||
!c.tasks?.afterIds?.length &&
|
||||
c.onAgentRequest === undefined
|
||||
) {
|
||||
console.log(` ${pc.dim('(no checkpoints configured)')}`);
|
||||
}
|
||||
console.log();
|
||||
});
|
||||
|
||||
cp.command('set <id>')
|
||||
.description('Configure checkpoints')
|
||||
.option('--on-agent-request <bool>', 'Allow agent to request review (true/false)')
|
||||
.option('--topic-before <bool>', 'Pause before each topic (true/false)')
|
||||
.option('--topic-after <bool>', 'Pause after each topic (true/false)')
|
||||
.option('--before <ids>', 'Pause before these subtask identifiers (comma-separated)')
|
||||
.option('--after <ids>', 'Pause after these subtask identifiers (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
after?: string;
|
||||
before?: string;
|
||||
onAgentRequest?: string;
|
||||
topicAfter?: string;
|
||||
topicBefore?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Get current config first
|
||||
const current = (await client.task.getCheckpoint.query({ id })).data as any;
|
||||
const checkpoint: any = { ...current };
|
||||
|
||||
if (options.onAgentRequest !== undefined) {
|
||||
checkpoint.onAgentRequest = options.onAgentRequest === 'true';
|
||||
}
|
||||
if (options.topicBefore !== undefined || options.topicAfter !== undefined) {
|
||||
checkpoint.topic = { ...checkpoint.topic };
|
||||
if (options.topicBefore !== undefined)
|
||||
checkpoint.topic.before = options.topicBefore === 'true';
|
||||
if (options.topicAfter !== undefined)
|
||||
checkpoint.topic.after = options.topicAfter === 'true';
|
||||
}
|
||||
if (options.before !== undefined) {
|
||||
checkpoint.tasks = { ...checkpoint.tasks };
|
||||
checkpoint.tasks.beforeIds = options.before
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (options.after !== undefined) {
|
||||
checkpoint.tasks = { ...checkpoint.tasks };
|
||||
checkpoint.tasks.afterIds = options.after
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
await client.task.updateCheckpoint.mutate({ checkpoint, id });
|
||||
log.info('Checkpoint updated.');
|
||||
},
|
||||
);
|
||||
}
|
||||
56
apps/cli/src/commands/task/dep.ts
Normal file
56
apps/cli/src/commands/task/dep.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { Command } from 'commander';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { outputJson, printTable, timeAgo } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerDepCommands(task: Command) {
|
||||
// ── dep ──────────────────────────────────────────────
|
||||
|
||||
const dep = task.command('dep').description('Manage task dependencies');
|
||||
|
||||
dep
|
||||
.command('add <taskId> <dependsOnId>')
|
||||
.description('Add dependency (taskId blocks on dependsOnId)')
|
||||
.option('--type <type>', 'Dependency type (blocks/relates)', 'blocks')
|
||||
.action(async (taskId: string, dependsOnId: string, options: { type?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.addDependency.mutate({
|
||||
dependsOnId,
|
||||
taskId,
|
||||
type: (options.type || 'blocks') as any,
|
||||
});
|
||||
log.info(`Dependency added: ${taskId} ${options.type || 'blocks'} on ${dependsOnId}`);
|
||||
});
|
||||
|
||||
dep
|
||||
.command('rm <taskId> <dependsOnId>')
|
||||
.description('Remove dependency')
|
||||
.action(async (taskId: string, dependsOnId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.removeDependency.mutate({ dependsOnId, taskId });
|
||||
log.info(`Dependency removed.`);
|
||||
});
|
||||
|
||||
dep
|
||||
.command('list <taskId>')
|
||||
.description('List dependencies for a task')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (taskId: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getDependencies.query({ id: taskId });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
log.info('No dependencies.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = result.data.map((d: any) => [d.type, d.dependsOnId, timeAgo(d.createdAt)]);
|
||||
printTable(rows, ['TYPE', 'DEPENDS ON', 'CREATED']);
|
||||
});
|
||||
}
|
||||
102
apps/cli/src/commands/task/doc.ts
Normal file
102
apps/cli/src/commands/task/doc.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerDocCommands(task: Command) {
|
||||
// ── doc ──────────────────────────────────────────────
|
||||
|
||||
const dc = task.command('doc').description('Manage task workspace documents');
|
||||
|
||||
dc.command('create <id>')
|
||||
.description('Create a document and pin it to the task')
|
||||
.requiredOption('-t, --title <title>', 'Document title')
|
||||
.option('-b, --body <content>', 'Document content')
|
||||
.option('--parent <docId>', 'Parent document/folder ID')
|
||||
.option('--folder', 'Create as folder')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: { body?: string; folder?: boolean; parent?: string; title: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Create document
|
||||
const fileType = options.folder ? 'custom/folder' : undefined;
|
||||
const content = options.body || '';
|
||||
const result = await client.document.createDocument.mutate({
|
||||
content,
|
||||
editorData: options.folder ? undefined : JSON.stringify({ content, type: 'doc' }),
|
||||
fileType,
|
||||
parentId: options.parent,
|
||||
title: options.title,
|
||||
});
|
||||
|
||||
// Pin to task
|
||||
await client.task.pinDocument.mutate({
|
||||
documentId: result.id,
|
||||
pinnedBy: 'user',
|
||||
taskId: id,
|
||||
});
|
||||
|
||||
const icon = options.folder ? '📁' : '📄';
|
||||
log.info(`${icon} Created & pinned: ${pc.bold(options.title)} ${pc.dim(result.id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
dc.command('pin <id> <documentId>')
|
||||
.description('Pin an existing document to a task')
|
||||
.action(async (id: string, documentId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.pinDocument.mutate({ documentId, pinnedBy: 'user', taskId: id });
|
||||
log.info(`Pinned ${pc.dim(documentId)} to ${pc.bold(id)}.`);
|
||||
});
|
||||
|
||||
dc.command('unpin <id> <documentId>')
|
||||
.description('Unpin a document from a task')
|
||||
.action(async (id: string, documentId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.unpinDocument.mutate({ documentId, taskId: id });
|
||||
log.info(`Unpinned ${pc.dim(documentId)} from ${pc.bold(id)}.`);
|
||||
});
|
||||
|
||||
dc.command('mv <id> <documentId> <folder>')
|
||||
.description('Move a document into a folder (auto-creates folder if not found)')
|
||||
.action(async (id: string, documentId: string, folder: string) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Check if folder is a document ID or a folder name
|
||||
let folderId = folder;
|
||||
if (!folder.startsWith('docs_')) {
|
||||
// folder is a name, find or create it
|
||||
const detail = await client.task.detail.query({ id });
|
||||
const folders = detail.data.workspace || [];
|
||||
|
||||
// Search for existing folder by name
|
||||
const existingFolder = folders.find((f) => f.title === folder);
|
||||
|
||||
if (existingFolder) {
|
||||
folderId = existingFolder.documentId;
|
||||
} else {
|
||||
// Create folder and pin to task
|
||||
const result = await client.document.createDocument.mutate({
|
||||
content: '',
|
||||
fileType: 'custom/folder',
|
||||
title: folder,
|
||||
});
|
||||
await client.task.pinDocument.mutate({
|
||||
documentId: result.id,
|
||||
pinnedBy: 'user',
|
||||
taskId: id,
|
||||
});
|
||||
folderId = result.id;
|
||||
log.info(`📁 Created folder: ${pc.bold(folder)} ${pc.dim(folderId)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Move document into folder
|
||||
await client.document.updateDocument.mutate({ id: documentId, parentId: folderId });
|
||||
log.info(`Moved ${pc.dim(documentId)} → 📁 ${pc.bold(folder)}`);
|
||||
});
|
||||
}
|
||||
74
apps/cli/src/commands/task/helpers.ts
Normal file
74
apps/cli/src/commands/task/helpers.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import pc from 'picocolors';
|
||||
|
||||
export function statusBadge(status: string): string {
|
||||
const pad = (s: string) => s.padEnd(9);
|
||||
switch (status) {
|
||||
case 'backlog': {
|
||||
return pc.dim(`○ ${pad('backlog')}`);
|
||||
}
|
||||
case 'blocked': {
|
||||
return pc.red(`◉ ${pad('blocked')}`);
|
||||
}
|
||||
case 'running': {
|
||||
return pc.blue(`● ${pad('running')}`);
|
||||
}
|
||||
case 'paused': {
|
||||
return pc.yellow(`◐ ${pad('paused')}`);
|
||||
}
|
||||
case 'completed': {
|
||||
return pc.green(`✓ ${pad('completed')}`);
|
||||
}
|
||||
case 'failed': {
|
||||
return pc.red(`✗ ${pad('failed')}`);
|
||||
}
|
||||
case 'timeout': {
|
||||
return pc.red(`⏱ ${pad('timeout')}`);
|
||||
}
|
||||
case 'canceled': {
|
||||
return pc.dim(`⊘ ${pad('canceled')}`);
|
||||
}
|
||||
default: {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function briefIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'decision': {
|
||||
return '📋';
|
||||
}
|
||||
case 'result': {
|
||||
return '✅';
|
||||
}
|
||||
case 'insight': {
|
||||
return '💡';
|
||||
}
|
||||
case 'error': {
|
||||
return '❌';
|
||||
}
|
||||
default: {
|
||||
return '📌';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function priorityLabel(priority: number | null | undefined): string {
|
||||
switch (priority) {
|
||||
case 1: {
|
||||
return pc.red('urgent');
|
||||
}
|
||||
case 2: {
|
||||
return pc.yellow('high');
|
||||
}
|
||||
case 3: {
|
||||
return 'normal';
|
||||
}
|
||||
case 4: {
|
||||
return pc.dim('low');
|
||||
}
|
||||
default: {
|
||||
return pc.dim('-');
|
||||
}
|
||||
}
|
||||
}
|
||||
624
apps/cli/src/commands/task/index.ts
Normal file
624
apps/cli/src/commands/task/index.ts
Normal file
|
|
@ -0,0 +1,624 @@
|
|||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import {
|
||||
confirm,
|
||||
displayWidth,
|
||||
outputJson,
|
||||
printTable,
|
||||
timeAgo,
|
||||
truncate,
|
||||
} from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
import { registerCheckpointCommands } from './checkpoint';
|
||||
import { registerDepCommands } from './dep';
|
||||
import { registerDocCommands } from './doc';
|
||||
import { briefIcon, priorityLabel, statusBadge } from './helpers';
|
||||
import { registerLifecycleCommands } from './lifecycle';
|
||||
import { registerReviewCommands } from './review';
|
||||
import { registerTopicCommands } from './topic';
|
||||
|
||||
export function registerTaskCommand(program: Command) {
|
||||
const task = program.command('task').description('Manage agent tasks');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('list')
|
||||
.description('List tasks')
|
||||
.option(
|
||||
'--status <status>',
|
||||
'Filter by status (pending/running/paused/completed/failed/canceled)',
|
||||
)
|
||||
.option('--root', 'Only show root tasks (no parent)')
|
||||
.option('--parent <id>', 'Filter by parent task ID')
|
||||
.option('--agent <id>', 'Filter by assignee agent ID')
|
||||
.option('-L, --limit <n>', 'Page size', '50')
|
||||
.option('--offset <n>', 'Offset', '0')
|
||||
.option('--tree', 'Display as tree structure')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agent?: string;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
parent?: string;
|
||||
root?: boolean;
|
||||
status?: string;
|
||||
tree?: boolean;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {};
|
||||
if (options.status) input.status = options.status;
|
||||
if (options.root) input.parentTaskId = null;
|
||||
if (options.parent) input.parentTaskId = options.parent;
|
||||
if (options.agent) input.assigneeAgentId = options.agent;
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
|
||||
|
||||
// For tree mode, fetch all tasks (no pagination limit)
|
||||
if (options.tree) {
|
||||
input.limit = 100;
|
||||
delete input.offset;
|
||||
}
|
||||
|
||||
const result = await client.task.list.query(input as any);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
log.info('No tasks found.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.tree) {
|
||||
// Build tree display
|
||||
const taskMap = new Map<string, any>();
|
||||
for (const t of result.data) taskMap.set(t.id, t);
|
||||
|
||||
const roots = result.data.filter((t: any) => !t.parentTaskId);
|
||||
const children = new Map<string, any[]>();
|
||||
for (const t of result.data) {
|
||||
if (t.parentTaskId) {
|
||||
const list = children.get(t.parentTaskId) || [];
|
||||
list.push(t);
|
||||
children.set(t.parentTaskId, list);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort children by sortOrder first, then seq
|
||||
for (const [, list] of children) {
|
||||
list.sort(
|
||||
(a: any, b: any) =>
|
||||
(a.sortOrder ?? 0) - (b.sortOrder ?? 0) || (a.seq ?? 0) - (b.seq ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
const printNode = (t: any, prefix: string, isLast: boolean, isRoot: boolean) => {
|
||||
const connector = isRoot ? '' : isLast ? '└── ' : '├── ';
|
||||
const name = truncate(t.name || t.instruction, 40);
|
||||
console.log(
|
||||
`${prefix}${connector}${pc.dim(t.identifier)} ${statusBadge(t.status)} ${name}`,
|
||||
);
|
||||
const childList = children.get(t.id) || [];
|
||||
const newPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ ');
|
||||
childList.forEach((child: any, i: number) => {
|
||||
printNode(child, newPrefix, i === childList.length - 1, false);
|
||||
});
|
||||
};
|
||||
|
||||
for (const root of roots) {
|
||||
printNode(root, '', true, true);
|
||||
}
|
||||
log.info(`Total: ${result.total}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = result.data.map((t: any) => [
|
||||
pc.dim(t.identifier),
|
||||
truncate(t.name || t.instruction, 40),
|
||||
statusBadge(t.status),
|
||||
priorityLabel(t.priority),
|
||||
t.assigneeAgentId ? pc.dim(t.assigneeAgentId) : '-',
|
||||
t.parentTaskId ? pc.dim('↳ subtask') : '',
|
||||
timeAgo(t.createdAt),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'NAME', 'STATUS', 'PRI', 'AGENT', 'TYPE', 'CREATED']);
|
||||
log.info(`Total: ${result.total}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('view <id>')
|
||||
.description('View task details (by ID or identifier like T-1)')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// ── Auto-detect by id prefix ──
|
||||
|
||||
// docs_ → show document content
|
||||
if (id.startsWith('docs_')) {
|
||||
const doc = await client.document.getDocumentDetail.query({ id });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(doc, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!doc) {
|
||||
log.error('Document not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n📄 ${pc.bold(doc.title || 'Untitled')} ${pc.dim(doc.id)}`);
|
||||
if (doc.fileType) console.log(`${pc.dim('Type:')} ${doc.fileType}`);
|
||||
if (doc.totalCharCount) console.log(`${pc.dim('Size:')} ${doc.totalCharCount} chars`);
|
||||
console.log(`${pc.dim('Updated:')} ${timeAgo(doc.updatedAt)}`);
|
||||
console.log();
|
||||
if (doc.content) {
|
||||
console.log(doc.content);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// tpc_ → show topic messages
|
||||
if (id.startsWith('tpc_')) {
|
||||
const messages = await client.message.getMessages.query({ topicId: id });
|
||||
const items = Array.isArray(messages) ? messages : [];
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(items, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
log.info('No messages in this topic.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
for (const msg of items) {
|
||||
const role =
|
||||
msg.role === 'assistant'
|
||||
? pc.green('Assistant')
|
||||
: msg.role === 'user'
|
||||
? pc.blue('User')
|
||||
: pc.dim(msg.role);
|
||||
|
||||
console.log(`${pc.bold(role)} ${pc.dim(timeAgo(msg.createdAt))}`);
|
||||
if (msg.content) {
|
||||
console.log(msg.content);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: task detail
|
||||
const result = await client.task.detail.query({ id });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
const t = result.data;
|
||||
|
||||
// ── Header ──
|
||||
console.log(`\n${pc.bold(t.identifier)} ${t.name || ''}`);
|
||||
console.log(
|
||||
`${pc.dim('Status:')} ${statusBadge(t.status)} ${pc.dim('Priority:')} ${priorityLabel(t.priority)}`,
|
||||
);
|
||||
console.log(`${pc.dim('Instruction:')} ${t.instruction}`);
|
||||
if (t.description) console.log(`${pc.dim('Description:')} ${t.description}`);
|
||||
if (t.agentId) console.log(`${pc.dim('Agent:')} ${t.agentId}`);
|
||||
if (t.userId) console.log(`${pc.dim('User:')} ${t.userId}`);
|
||||
if (t.parent) {
|
||||
console.log(`${pc.dim('Parent:')} ${t.parent.identifier} ${t.parent.name || ''}`);
|
||||
}
|
||||
const topicInfo = t.topicCount ? `${t.topicCount}` : '0';
|
||||
const createdInfo = t.createdAt ? timeAgo(t.createdAt) : '-';
|
||||
console.log(`${pc.dim('Topics:')} ${topicInfo} ${pc.dim('Created:')} ${createdInfo}`);
|
||||
if (t.heartbeat?.timeout && t.heartbeat.lastAt) {
|
||||
const hb = timeAgo(t.heartbeat.lastAt);
|
||||
const interval = t.heartbeat.interval ? `${t.heartbeat.interval}s` : '-';
|
||||
const elapsed = (Date.now() - new Date(t.heartbeat.lastAt).getTime()) / 1000;
|
||||
const isStuck = t.status === 'running' && elapsed > t.heartbeat.timeout;
|
||||
console.log(
|
||||
`${pc.dim('Heartbeat:')} ${isStuck ? pc.red(hb) : hb} ${pc.dim('interval:')} ${interval} ${pc.dim('timeout:')} ${t.heartbeat.timeout}s${isStuck ? pc.red(' ⚠ TIMEOUT') : ''}`,
|
||||
);
|
||||
}
|
||||
if (t.error) console.log(`${pc.red('Error:')} ${t.error}`);
|
||||
|
||||
// ── Subtasks ──
|
||||
if (t.subtasks && t.subtasks.length > 0) {
|
||||
// Build lookup: which subtasks are completed
|
||||
const completedIdentifiers = new Set(
|
||||
t.subtasks.filter((s) => s.status === 'completed').map((s) => s.identifier),
|
||||
);
|
||||
|
||||
console.log(`\n${pc.bold('Subtasks:')}`);
|
||||
for (const s of t.subtasks) {
|
||||
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
|
||||
// Show 'blocked' instead of 'backlog' if task has unresolved dependencies
|
||||
const isBlocked = s.blockedBy && !completedIdentifiers.has(s.blockedBy);
|
||||
const displayStatus = s.status === 'backlog' && isBlocked ? 'blocked' : s.status;
|
||||
console.log(
|
||||
` ${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dependencies ──
|
||||
if (t.dependencies && t.dependencies.length > 0) {
|
||||
console.log(`\n${pc.bold('Dependencies:')}`);
|
||||
for (const d of t.dependencies) {
|
||||
const depName = d.name ? ` ${d.name}` : '';
|
||||
console.log(` ${pc.dim(d.type || 'blocks')}: ${d.dependsOn}${depName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Checkpoint ──
|
||||
{
|
||||
const cp = t.checkpoint || {};
|
||||
console.log(`\n${pc.bold('Checkpoint:')}`);
|
||||
const hasConfig =
|
||||
cp.onAgentRequest !== undefined ||
|
||||
cp.topic?.before ||
|
||||
cp.topic?.after ||
|
||||
cp.tasks?.beforeIds?.length ||
|
||||
cp.tasks?.afterIds?.length;
|
||||
|
||||
if (hasConfig) {
|
||||
if (cp.onAgentRequest !== undefined)
|
||||
console.log(` onAgentRequest: ${cp.onAgentRequest}`);
|
||||
if (cp.topic?.before) console.log(` topic.before: ${cp.topic.before}`);
|
||||
if (cp.topic?.after) console.log(` topic.after: ${cp.topic.after}`);
|
||||
if (cp.tasks?.beforeIds?.length)
|
||||
console.log(` tasks.before: ${cp.tasks.beforeIds.join(', ')}`);
|
||||
if (cp.tasks?.afterIds?.length)
|
||||
console.log(` tasks.after: ${cp.tasks.afterIds.join(', ')}`);
|
||||
} else {
|
||||
console.log(` ${pc.dim('(not configured, default: onAgentRequest=true)')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Review ──
|
||||
{
|
||||
const rv = t.review as any;
|
||||
console.log(`\n${pc.bold('Review:')}`);
|
||||
if (rv && rv.enabled) {
|
||||
console.log(
|
||||
` judge: ${rv.judge?.model || 'default'}${rv.judge?.provider ? ` (${rv.judge.provider})` : ''}`,
|
||||
);
|
||||
console.log(` maxIterations: ${rv.maxIterations} autoRetry: ${rv.autoRetry}`);
|
||||
if (rv.rubrics?.length > 0) {
|
||||
for (let i = 0; i < rv.rubrics.length; i++) {
|
||||
const rb = rv.rubrics[i];
|
||||
const threshold = rb.threshold ? ` ≥ ${Math.round(rb.threshold * 100)}%` : '';
|
||||
const typeTag = pc.dim(`[${rb.type}]`);
|
||||
let configInfo = '';
|
||||
if (rb.type === 'llm-rubric') configInfo = rb.config?.criteria || '';
|
||||
else if (rb.type === 'contains' || rb.type === 'equals')
|
||||
configInfo = `value="${rb.config?.value}"`;
|
||||
else if (rb.type === 'regex') configInfo = `pattern="${rb.config?.pattern}"`;
|
||||
console.log(` ${i + 1}. ${rb.name} ${typeTag}${threshold} ${pc.dim(configInfo)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(` ${pc.dim('(not configured)')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workspace ──
|
||||
{
|
||||
const nodes = t.workspace || [];
|
||||
if (nodes.length === 0) {
|
||||
console.log(`\n${pc.bold('Workspace:')}`);
|
||||
console.log(` ${pc.dim('No documents yet.')}`);
|
||||
} else {
|
||||
const countNodes = (list: typeof nodes): number =>
|
||||
list.reduce((sum, n) => sum + 1 + (n.children ? countNodes(n.children) : 0), 0);
|
||||
console.log(`\n${pc.bold(`Workspace (${countNodes(nodes)}):`)}`);
|
||||
|
||||
const formatSize = (chars: number | null | undefined) => {
|
||||
if (!chars) return '';
|
||||
if (chars >= 10_000) return `${(chars / 1000).toFixed(1)}k`;
|
||||
return `${chars}`;
|
||||
};
|
||||
|
||||
const LEFT_COL = 56;
|
||||
const FROM_WIDTH = 10;
|
||||
|
||||
const renderNodes = (list: typeof nodes, indent: string, isChild: boolean) => {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const node = list[i];
|
||||
const isFolder = node.fileType === 'custom/folder';
|
||||
const isLast = i === list.length - 1;
|
||||
const icon = isFolder ? '📁' : '📄';
|
||||
const connector = isChild ? (isLast ? '└── ' : '├── ') : '';
|
||||
const prefix = `${indent}${connector}${icon} `;
|
||||
const titleStr = truncate(node.title || 'Untitled', LEFT_COL - displayWidth(prefix));
|
||||
const titlePad = ' '.repeat(
|
||||
Math.max(1, LEFT_COL - displayWidth(prefix) - displayWidth(titleStr)),
|
||||
);
|
||||
|
||||
const fromStr = node.sourceTaskIdentifier ? `← ${node.sourceTaskIdentifier}` : '';
|
||||
const fromPad = ' '.repeat(Math.max(1, FROM_WIDTH - fromStr.length + 1));
|
||||
const size =
|
||||
!isFolder && node.size
|
||||
? formatSize(node.size).padStart(6) + ' chars'
|
||||
: ''.padStart(12);
|
||||
|
||||
const ago = node.createdAt ? ` ${timeAgo(node.createdAt)}` : '';
|
||||
|
||||
console.log(
|
||||
`${prefix}${titleStr}${titlePad}${pc.dim(`(${node.documentId})`)} ${fromStr}${fromPad}${pc.dim(size)}${pc.dim(ago)}`,
|
||||
);
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
const childIndent = isChild ? indent + (isLast ? ' ' : '│ ') : indent;
|
||||
renderNodes(node.children, childIndent, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
renderNodes(nodes, ' ', false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Activities (already sorted desc by service) ──
|
||||
{
|
||||
console.log(`\n${pc.bold('Activities:')}`);
|
||||
const acts = t.activities || [];
|
||||
if (acts.length === 0) {
|
||||
console.log(` ${pc.dim('No activities yet.')}`);
|
||||
} else {
|
||||
for (const act of acts) {
|
||||
const ago = act.time ? timeAgo(act.time) : '';
|
||||
const idSuffix = act.id ? ` ${pc.dim(act.id)}` : '';
|
||||
if (act.type === 'topic') {
|
||||
const sBadge = statusBadge(act.status || 'running');
|
||||
console.log(
|
||||
` 💬 ${pc.dim(ago.padStart(7))} Topic #${act.seq || '?'} ${act.title || 'Untitled'} ${sBadge}${idSuffix}`,
|
||||
);
|
||||
} else if (act.type === 'brief') {
|
||||
const icon = briefIcon(act.briefType || '');
|
||||
const pri =
|
||||
act.priority === 'urgent'
|
||||
? pc.red(' [urgent]')
|
||||
: act.priority === 'normal'
|
||||
? pc.yellow(' [normal]')
|
||||
: '';
|
||||
const resolved = act.resolvedAction ? pc.green(` ✏️ ${act.resolvedAction}`) : '';
|
||||
const typeLabel = pc.dim(`[${act.briefType}]`);
|
||||
console.log(
|
||||
` ${icon} ${pc.dim(ago.padStart(7))} Brief ${typeLabel} ${act.title}${pri}${resolved}${idSuffix}`,
|
||||
);
|
||||
} else if (act.type === 'comment') {
|
||||
const author = act.agentId ? `🤖 ${act.agentId}` : '👤 user';
|
||||
console.log(` 💭 ${pc.dim(ago.padStart(7))} ${pc.cyan(author)} ${act.content}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
});
|
||||
|
||||
// ── create ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('create')
|
||||
.description('Create a new task')
|
||||
.requiredOption('-i, --instruction <text>', 'Task instruction')
|
||||
.option('-n, --name <name>', 'Task name')
|
||||
.option('--agent <id>', 'Assign to agent')
|
||||
.option('--parent <id>', 'Parent task ID')
|
||||
.option('--priority <n>', 'Priority (0=none, 1=urgent, 2=high, 3=normal, 4=low)', '0')
|
||||
.option('--prefix <prefix>', 'Identifier prefix', 'T')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agent?: string;
|
||||
instruction: string;
|
||||
json?: string | boolean;
|
||||
name?: string;
|
||||
parent?: string;
|
||||
prefix?: string;
|
||||
priority?: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {
|
||||
instruction: options.instruction,
|
||||
};
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.agent) input.assigneeAgentId = options.agent;
|
||||
if (options.parent) input.parentTaskId = options.parent;
|
||||
if (options.priority) input.priority = Number.parseInt(options.priority, 10);
|
||||
if (options.prefix) input.identifierPrefix = options.prefix;
|
||||
|
||||
const result = await client.task.create.mutate(input as any);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Task created: ${pc.bold(result.data.identifier)} ${result.data.name || ''}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('edit <id>')
|
||||
.description('Update a task')
|
||||
.option('-n, --name <name>', 'Task name')
|
||||
.option('-i, --instruction <text>', 'Task instruction')
|
||||
.option('--agent <id>', 'Assign to agent')
|
||||
.option('--priority <n>', 'Priority (0-4)')
|
||||
.option('--heartbeat-interval <n>', 'Heartbeat interval in seconds')
|
||||
.option('--heartbeat-timeout <n>', 'Heartbeat timeout in seconds (0 to disable)')
|
||||
.option('--description <text>', 'Task description')
|
||||
.option(
|
||||
'--status <status>',
|
||||
'Set status (backlog, running, paused, completed, failed, canceled)',
|
||||
)
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
agent?: string;
|
||||
description?: string;
|
||||
heartbeatInterval?: string;
|
||||
heartbeatTimeout?: string;
|
||||
instruction?: string;
|
||||
json?: string | boolean;
|
||||
name?: string;
|
||||
priority?: string;
|
||||
status?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Handle --status separately (uses updateStatus API)
|
||||
if (options.status) {
|
||||
const valid = ['backlog', 'running', 'paused', 'completed', 'failed', 'canceled'];
|
||||
if (!valid.includes(options.status)) {
|
||||
log.error(`Invalid status "${options.status}". Must be one of: ${valid.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
const result = await client.task.updateStatus.mutate({ id, status: options.status });
|
||||
log.info(`${pc.bold(result.data.identifier)} → ${options.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const input: Record<string, any> = { id };
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.instruction) input.instruction = options.instruction;
|
||||
if (options.description) input.description = options.description;
|
||||
if (options.agent) input.assigneeAgentId = options.agent;
|
||||
if (options.priority) input.priority = Number.parseInt(options.priority, 10);
|
||||
if (options.heartbeatInterval)
|
||||
input.heartbeatInterval = Number.parseInt(options.heartbeatInterval, 10);
|
||||
if (options.heartbeatTimeout !== undefined) {
|
||||
const val = Number.parseInt(options.heartbeatTimeout, 10);
|
||||
input.heartbeatTimeout = val === 0 ? null : val;
|
||||
}
|
||||
|
||||
const result = await client.task.update.mutate(input as any);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Task updated: ${pc.bold(result.data.identifier)}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── delete ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('delete <id>')
|
||||
.description('Delete a task')
|
||||
.option('-y, --yes', 'Skip confirmation')
|
||||
.action(async (id: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const ok = await confirm(`Delete task ${pc.bold(id)}?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.task.delete.mutate({ id });
|
||||
log.info(`Task ${pc.bold(id)} deleted.`);
|
||||
});
|
||||
|
||||
// ── clear ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('clear')
|
||||
.description('Delete all tasks')
|
||||
.option('-y, --yes', 'Skip confirmation')
|
||||
.action(async (options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const ok = await confirm(`Delete ${pc.red('ALL')} tasks? This cannot be undone.`);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.clearAll.mutate()) as any;
|
||||
log.info(`${result.count} task(s) deleted.`);
|
||||
});
|
||||
|
||||
// ── tree ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('tree <id>')
|
||||
.description('Show task tree (subtasks + dependencies)')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getTaskTree.query({ id });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
log.info('No tasks found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build tree display (raw SQL returns snake_case)
|
||||
const taskMap = new Map<string, any>();
|
||||
for (const t of result.data) taskMap.set(t.id, t);
|
||||
|
||||
const printNode = (taskId: string, indent: number) => {
|
||||
const t = taskMap.get(taskId);
|
||||
if (!t) return;
|
||||
|
||||
const prefix = indent === 0 ? '' : ' '.repeat(indent) + '├── ';
|
||||
const name = t.name || t.identifier || '';
|
||||
const status = t.status || 'pending';
|
||||
const identifier = t.identifier || t.id;
|
||||
console.log(`${prefix}${pc.dim(identifier)} ${statusBadge(status)} ${name}`);
|
||||
|
||||
// Print children (handle both camelCase and snake_case)
|
||||
for (const child of result.data) {
|
||||
const childParent = child.parentTaskId || child.parent_task_id;
|
||||
if (childParent === taskId) {
|
||||
printNode(child.id, indent + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Find root - resolve identifier first
|
||||
const resolved = await client.task.find.query({ id });
|
||||
const rootId = resolved.data.id;
|
||||
const root = result.data.find((t: any) => t.id === rootId);
|
||||
if (root) printNode(root.id, 0);
|
||||
else log.info('Root task not found in tree.');
|
||||
});
|
||||
|
||||
// Register subcommand groups
|
||||
registerLifecycleCommands(task);
|
||||
registerCheckpointCommands(task);
|
||||
registerReviewCommands(task);
|
||||
registerDepCommands(task);
|
||||
registerTopicCommands(task);
|
||||
registerDocCommands(task);
|
||||
}
|
||||
303
apps/cli/src/commands/task/lifecycle.ts
Normal file
303
apps/cli/src/commands/task/lifecycle.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { getAuthInfo } from '../../api/http';
|
||||
import { streamAgentEvents } from '../../utils/agentStream';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerLifecycleCommands(task: Command) {
|
||||
// ── start ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('start <id>')
|
||||
.description('Start a task (pending → running)')
|
||||
.option('--no-run', 'Only update status, do not trigger agent execution')
|
||||
.option('-p, --prompt <text>', 'Additional context for the agent')
|
||||
.option('-f, --follow', 'Follow agent output in real-time (default: run in background)')
|
||||
.option('--json', 'Output full JSON event stream')
|
||||
.option('-v, --verbose', 'Show detailed tool call info')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
follow?: boolean;
|
||||
json?: boolean;
|
||||
prompt?: string;
|
||||
run?: boolean;
|
||||
verbose?: boolean;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Check if already running
|
||||
const taskDetail = await client.task.find.query({ id });
|
||||
|
||||
if (taskDetail.data.status === 'running') {
|
||||
log.info(`Task ${pc.bold(taskDetail.data.identifier)} is already running.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const statusResult = await client.task.updateStatus.mutate({ id, status: 'running' });
|
||||
log.info(`Task ${pc.bold(statusResult.data.identifier)} started.`);
|
||||
|
||||
// Auto-run unless --no-run
|
||||
if (options.run === false) return;
|
||||
|
||||
// Default agent to inbox if not assigned
|
||||
if (!taskDetail.data.assigneeAgentId) {
|
||||
await client.task.update.mutate({ assigneeAgentId: 'inbox', id });
|
||||
log.info(`Assigned default agent: ${pc.dim('inbox')}`);
|
||||
}
|
||||
|
||||
const result = (await client.task.run.mutate({
|
||||
id,
|
||||
...(options.prompt && { prompt: options.prompt }),
|
||||
})) as any;
|
||||
|
||||
if (!result.success) {
|
||||
log.error(`Failed to run task: ${result.error || result.message || 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Operation: ${pc.dim(result.operationId)} · Topic: ${pc.dim(result.topicId || 'n/a')}`,
|
||||
);
|
||||
|
||||
if (!options.follow) {
|
||||
log.info(
|
||||
`Agent running in background. Use ${pc.dim(`lh task view ${id}`)} to check status.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(result.operationId)}`;
|
||||
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
|
||||
// Send heartbeat after completion
|
||||
try {
|
||||
await client.task.heartbeat.mutate({ id });
|
||||
} catch {
|
||||
// ignore heartbeat errors
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── run ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('run <id>')
|
||||
.description('Run a task — trigger agent execution')
|
||||
.option('-p, --prompt <text>', 'Additional context for the agent')
|
||||
.option('-c, --continue <topicId>', 'Continue running on an existing topic')
|
||||
.option('-f, --follow', 'Follow agent output in real-time (default: run in background)')
|
||||
.option('--topics <n>', 'Run N topics in sequence (default: 1, implies --follow)', '1')
|
||||
.option('--delay <s>', 'Delay between topics in seconds', '0')
|
||||
.option('--json', 'Output full JSON event stream')
|
||||
.option('-v, --verbose', 'Show detailed tool call info')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
continue?: string;
|
||||
delay?: string;
|
||||
follow?: boolean;
|
||||
json?: boolean;
|
||||
prompt?: string;
|
||||
topics?: string;
|
||||
verbose?: boolean;
|
||||
},
|
||||
) => {
|
||||
const topicCount = Number.parseInt(options.topics || '1', 10);
|
||||
const delaySec = Number.parseInt(options.delay || '0', 10);
|
||||
|
||||
// --topics > 1 implies --follow
|
||||
const shouldFollow = options.follow || topicCount > 1;
|
||||
|
||||
for (let i = 0; i < topicCount; i++) {
|
||||
if (i > 0) {
|
||||
log.info(`\n${'─'.repeat(60)}`);
|
||||
log.info(`Topic ${i + 1}/${topicCount}`);
|
||||
if (delaySec > 0) {
|
||||
log.info(`Waiting ${delaySec}s before next topic...`);
|
||||
await new Promise((r) => setTimeout(r, delaySec * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Auto-assign inbox agent on first topic if not assigned
|
||||
if (i === 0) {
|
||||
const taskDetail = await client.task.find.query({ id });
|
||||
if (!taskDetail.data.assigneeAgentId) {
|
||||
await client.task.update.mutate({ assigneeAgentId: 'inbox', id });
|
||||
log.info(`Assigned default agent: ${pc.dim('inbox')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Only pass extra prompt and continue on first topic
|
||||
const result = (await client.task.run.mutate({
|
||||
id,
|
||||
...(i === 0 && options.prompt && { prompt: options.prompt }),
|
||||
...(i === 0 && options.continue && { continueTopicId: options.continue }),
|
||||
})) as any;
|
||||
|
||||
if (!result.success) {
|
||||
log.error(`Failed to run task: ${result.error || result.message || 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const operationId = result.operationId;
|
||||
if (i === 0) {
|
||||
log.info(`Task ${pc.bold(result.taskIdentifier)} running`);
|
||||
}
|
||||
log.info(`Operation: ${pc.dim(operationId)} · Topic: ${pc.dim(result.topicId || 'n/a')}`);
|
||||
|
||||
if (!shouldFollow) {
|
||||
log.info(
|
||||
`Agent running in background. Use ${pc.dim(`lh task view ${id}`)} to check status.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to SSE stream and wait for completion
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
|
||||
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
|
||||
// Update heartbeat after each topic
|
||||
try {
|
||||
await client.task.heartbeat.mutate({ id });
|
||||
} catch {
|
||||
// ignore heartbeat errors
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── comment ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('comment <id>')
|
||||
.description('Add a comment to a task')
|
||||
.requiredOption('-m, --message <text>', 'Comment content')
|
||||
.action(async (id: string, options: { message: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.addComment.mutate({ content: options.message, id });
|
||||
log.info('Comment added.');
|
||||
});
|
||||
|
||||
// ── pause ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('pause <id>')
|
||||
.description('Pause a running task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.updateStatus.mutate({ id, status: 'paused' });
|
||||
log.info(`Task ${pc.bold(result.data.identifier)} paused.`);
|
||||
});
|
||||
|
||||
// ── resume ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('resume <id>')
|
||||
.description('Resume a paused task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.updateStatus.mutate({ id, status: 'running' });
|
||||
log.info(`Task ${pc.bold(result.data.identifier)} resumed.`);
|
||||
});
|
||||
|
||||
// ── complete ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('complete <id>')
|
||||
.description('Mark a task as completed')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.updateStatus.mutate({ id, status: 'completed' })) as any;
|
||||
log.info(`Task ${pc.bold(result.data.identifier)} completed.`);
|
||||
if (result.unlocked?.length > 0) {
|
||||
log.info(`Unlocked: ${result.unlocked.map((id: string) => pc.bold(id)).join(', ')}`);
|
||||
}
|
||||
if (result.paused?.length > 0) {
|
||||
log.info(
|
||||
`Paused (checkpoint): ${result.paused.map((id: string) => pc.yellow(id)).join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (result.checkpointTriggered) {
|
||||
log.info(`${pc.yellow('Checkpoint triggered')} — parent task paused for review.`);
|
||||
}
|
||||
if (result.allSubtasksDone) {
|
||||
log.info(`All subtasks of parent task completed.`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── cancel ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('cancel <id>')
|
||||
.description('Cancel a task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.updateStatus.mutate({ id, status: 'canceled' });
|
||||
log.info(`Task ${pc.bold(result.data.identifier)} canceled.`);
|
||||
});
|
||||
|
||||
// ── sort ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('sort <id> <identifiers...>')
|
||||
.description('Reorder subtasks (e.g. lh task sort T-1 T-2 T-4 T-3)')
|
||||
.action(async (id: string, identifiers: string[]) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.reorderSubtasks.mutate({
|
||||
id,
|
||||
order: identifiers,
|
||||
})) as any;
|
||||
|
||||
log.info('Subtasks reordered:');
|
||||
for (const item of result.data) {
|
||||
console.log(` ${pc.dim(`#${item.sortOrder}`)} ${item.identifier}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── heartbeat ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('heartbeat <id>')
|
||||
.description('Manually send heartbeat for a running task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.heartbeat.mutate({ id });
|
||||
log.info(`Heartbeat sent for ${pc.bold(id)}.`);
|
||||
});
|
||||
|
||||
// ── watchdog ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('watchdog')
|
||||
.description('Run watchdog check — detect and fail stuck tasks')
|
||||
.action(async () => {
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.watchdog.mutate()) as any;
|
||||
|
||||
if (result.failed?.length > 0) {
|
||||
log.info(
|
||||
`${pc.red('Stuck tasks failed:')} ${result.failed.map((id: string) => pc.bold(id)).join(', ')}`,
|
||||
);
|
||||
} else {
|
||||
log.info('No stuck tasks found.');
|
||||
}
|
||||
});
|
||||
}
|
||||
306
apps/cli/src/commands/task/review.ts
Normal file
306
apps/cli/src/commands/task/review.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { printTable, truncate } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerReviewCommands(task: Command) {
|
||||
// ── review ──────────────────────────────────────────────
|
||||
|
||||
const rv = task.command('review').description('Manage task review (LLM-as-Judge)');
|
||||
|
||||
rv.command('view <id>')
|
||||
.description('View review config for a task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getReview.query({ id });
|
||||
const r = result.data as any;
|
||||
|
||||
if (!r || !r.enabled) {
|
||||
log.info('Review not configured for this task.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${pc.bold('Review config:')}`);
|
||||
console.log(` enabled: ${r.enabled}`);
|
||||
if (r.judge?.model)
|
||||
console.log(` judge: ${r.judge.model}${r.judge.provider ? ` (${r.judge.provider})` : ''}`);
|
||||
console.log(` maxIterations: ${r.maxIterations}`);
|
||||
console.log(` autoRetry: ${r.autoRetry}`);
|
||||
if (r.rubrics?.length > 0) {
|
||||
console.log(` rubrics:`);
|
||||
for (let i = 0; i < r.rubrics.length; i++) {
|
||||
const rb = r.rubrics[i];
|
||||
const threshold = rb.threshold ? ` ≥ ${Math.round(rb.threshold * 100)}%` : '';
|
||||
const typeTag = pc.dim(`[${rb.type}]`);
|
||||
let configInfo = '';
|
||||
if (rb.type === 'llm-rubric') configInfo = rb.config?.criteria || '';
|
||||
else if (rb.type === 'contains' || rb.type === 'equals')
|
||||
configInfo = `value="${rb.config?.value}"`;
|
||||
else if (rb.type === 'regex') configInfo = `pattern="${rb.config?.pattern}"`;
|
||||
console.log(` ${i + 1}. ${rb.name} ${typeTag}${threshold} ${pc.dim(configInfo)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` rubrics: ${pc.dim('(none)')}`);
|
||||
}
|
||||
console.log();
|
||||
});
|
||||
|
||||
rv.command('set <id>')
|
||||
.description('Enable review and configure judge settings')
|
||||
.option('--model <model>', 'Judge model')
|
||||
.option('--provider <provider>', 'Judge provider')
|
||||
.option('--max-iterations <n>', 'Max review iterations', '3')
|
||||
.option('--no-auto-retry', 'Disable auto retry on failure')
|
||||
.option('--recursive', 'Apply to all subtasks as well')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
autoRetry?: boolean;
|
||||
maxIterations?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
recursive?: boolean;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Read current review config to preserve rubrics
|
||||
const current = (await client.task.getReview.query({ id })).data as any;
|
||||
const existingRubrics = current?.rubrics || [];
|
||||
|
||||
const review = {
|
||||
autoRetry: options.autoRetry !== false,
|
||||
enabled: true,
|
||||
judge: {
|
||||
...(options.model && { model: options.model }),
|
||||
...(options.provider && { provider: options.provider }),
|
||||
},
|
||||
maxIterations: Number.parseInt(options.maxIterations || '3', 10),
|
||||
rubrics: existingRubrics,
|
||||
};
|
||||
|
||||
await client.task.updateReview.mutate({ id, review });
|
||||
|
||||
if (options.recursive) {
|
||||
const subtasks = await client.task.getSubtasks.query({ id });
|
||||
for (const s of subtasks.data || []) {
|
||||
const subCurrent = (await client.task.getReview.query({ id: s.id })).data as any;
|
||||
await client.task.updateReview.mutate({
|
||||
id: s.id,
|
||||
review: { ...review, rubrics: subCurrent?.rubrics || existingRubrics },
|
||||
});
|
||||
}
|
||||
log.info(
|
||||
`Review enabled for ${pc.bold(id)} + ${(subtasks.data || []).length} subtask(s).`,
|
||||
);
|
||||
} else {
|
||||
log.info('Review enabled.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── review criteria ──────────────────────────────────────
|
||||
|
||||
const rc = rv.command('criteria').description('Manage review rubrics');
|
||||
|
||||
rc.command('list <id>')
|
||||
.description('List review rubrics for a task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getReview.query({ id });
|
||||
const r = result.data as any;
|
||||
const rubrics = r?.rubrics || [];
|
||||
|
||||
if (rubrics.length === 0) {
|
||||
log.info('No rubrics configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = rubrics.map((r: any, i: number) => {
|
||||
const config = r.config || {};
|
||||
const configStr =
|
||||
r.type === 'llm-rubric'
|
||||
? config.criteria || ''
|
||||
: r.type === 'contains' || r.type === 'equals'
|
||||
? `value: "${config.value}"`
|
||||
: r.type === 'regex'
|
||||
? `pattern: "${config.pattern}"`
|
||||
: JSON.stringify(config);
|
||||
|
||||
return [
|
||||
String(i + 1),
|
||||
r.name,
|
||||
r.type,
|
||||
r.threshold ? `≥ ${Math.round(r.threshold * 100)}%` : '-',
|
||||
String(r.weight ?? 1),
|
||||
truncate(configStr, 40),
|
||||
];
|
||||
});
|
||||
|
||||
printTable(rows, ['#', 'NAME', 'TYPE', 'THRESHOLD', 'WEIGHT', 'CONFIG']);
|
||||
});
|
||||
|
||||
rc.command('add <id>')
|
||||
.description('Add a review rubric')
|
||||
.requiredOption('-n, --name <name>', 'Rubric name (e.g. "内容准确性")')
|
||||
.option('--type <type>', 'Rubric type (default: llm-rubric)', 'llm-rubric')
|
||||
.option('-t, --threshold <n>', 'Pass threshold 0-100 (converted to 0-1)')
|
||||
.option('-d, --description <text>', 'Criteria description (for llm-rubric type)')
|
||||
.option('--value <value>', 'Expected value (for contains/equals type)')
|
||||
.option('--pattern <pattern>', 'Regex pattern (for regex type)')
|
||||
.option('-w, --weight <n>', 'Weight for scoring (default: 1)')
|
||||
.option('--recursive', 'Add to all subtasks as well')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
description?: string;
|
||||
name: string;
|
||||
pattern?: string;
|
||||
recursive?: boolean;
|
||||
threshold?: string;
|
||||
type: string;
|
||||
value?: string;
|
||||
weight?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Build rubric config based on type
|
||||
const buildConfig = (): Record<string, any> | null => {
|
||||
switch (options.type) {
|
||||
case 'llm-rubric': {
|
||||
return { criteria: options.description || options.name };
|
||||
}
|
||||
case 'contains':
|
||||
case 'equals':
|
||||
case 'starts-with':
|
||||
case 'ends-with': {
|
||||
if (!options.value) {
|
||||
log.error(`--value is required for type "${options.type}"`);
|
||||
return null;
|
||||
}
|
||||
return { value: options.value };
|
||||
}
|
||||
case 'regex': {
|
||||
if (!options.pattern) {
|
||||
log.error('--pattern is required for type "regex"');
|
||||
return null;
|
||||
}
|
||||
return { pattern: options.pattern };
|
||||
}
|
||||
default: {
|
||||
return { criteria: options.description || options.name };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
|
||||
const rubric: Record<string, any> = {
|
||||
config,
|
||||
id: `rubric-${Date.now()}`,
|
||||
name: options.name,
|
||||
type: options.type,
|
||||
weight: options.weight ? Number.parseFloat(options.weight) : 1,
|
||||
};
|
||||
if (options.threshold) {
|
||||
rubric.threshold = Number.parseInt(options.threshold, 10) / 100;
|
||||
}
|
||||
|
||||
const addToTask = async (taskId: string) => {
|
||||
const current = (await client.task.getReview.query({ id: taskId })).data as any;
|
||||
const rubrics = current?.rubrics || [];
|
||||
|
||||
// Replace if same name exists, otherwise append
|
||||
const filtered = rubrics.filter((r: any) => r.name !== options.name);
|
||||
filtered.push(rubric);
|
||||
|
||||
await client.task.updateReview.mutate({
|
||||
id: taskId,
|
||||
review: {
|
||||
autoRetry: current?.autoRetry ?? true,
|
||||
enabled: current?.enabled ?? true,
|
||||
judge: current?.judge ?? {},
|
||||
maxIterations: current?.maxIterations ?? 3,
|
||||
rubrics: filtered,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await addToTask(id);
|
||||
|
||||
if (options.recursive) {
|
||||
const subtasks = await client.task.getSubtasks.query({ id });
|
||||
for (const s of subtasks.data || []) {
|
||||
await addToTask(s.id);
|
||||
}
|
||||
log.info(
|
||||
`Rubric "${options.name}" [${options.type}] added to ${pc.bold(id)} + ${(subtasks.data || []).length} subtask(s).`,
|
||||
);
|
||||
} else {
|
||||
log.info(`Rubric "${options.name}" [${options.type}] added.`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
rc.command('rm <id>')
|
||||
.description('Remove a review rubric')
|
||||
.requiredOption('-n, --name <name>', 'Rubric name to remove')
|
||||
.option('--recursive', 'Remove from all subtasks as well')
|
||||
.action(async (id: string, options: { name: string; recursive?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const removeFromTask = async (taskId: string) => {
|
||||
const current = (await client.task.getReview.query({ id: taskId })).data as any;
|
||||
if (!current) return;
|
||||
|
||||
const rubrics = (current.rubrics || []).filter((r: any) => r.name !== options.name);
|
||||
|
||||
await client.task.updateReview.mutate({
|
||||
id: taskId,
|
||||
review: { ...current, rubrics },
|
||||
});
|
||||
};
|
||||
|
||||
await removeFromTask(id);
|
||||
|
||||
if (options.recursive) {
|
||||
const subtasks = await client.task.getSubtasks.query({ id });
|
||||
for (const s of subtasks.data || []) {
|
||||
await removeFromTask(s.id);
|
||||
}
|
||||
log.info(
|
||||
`Rubric "${options.name}" removed from ${pc.bold(id)} + ${(subtasks.data || []).length} subtask(s).`,
|
||||
);
|
||||
} else {
|
||||
log.info(`Rubric "${options.name}" removed.`);
|
||||
}
|
||||
});
|
||||
|
||||
rv.command('run <id>')
|
||||
.description('Manually run review on content')
|
||||
.requiredOption('--content <text>', 'Content to review')
|
||||
.action(async (id: string, options: { content: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.runReview.mutate({
|
||||
content: options.content,
|
||||
id,
|
||||
})) as any;
|
||||
const r = result.data;
|
||||
|
||||
console.log(
|
||||
`\n${r.passed ? pc.green('✓ Review passed') : pc.red('✗ Review failed')} (${r.overallScore}%)`,
|
||||
);
|
||||
for (const s of r.rubricResults || []) {
|
||||
const icon = s.passed ? pc.green('✓') : pc.red('✗');
|
||||
const pct = Math.round(s.score * 100);
|
||||
console.log(` ${icon} ${s.rubricId}: ${pct}%${s.reason ? ` — ${s.reason}` : ''}`);
|
||||
}
|
||||
console.log();
|
||||
});
|
||||
}
|
||||
117
apps/cli/src/commands/task/topic.ts
Normal file
117
apps/cli/src/commands/task/topic.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
import { statusBadge } from './helpers';
|
||||
|
||||
export function registerTopicCommands(task: Command) {
|
||||
// ── topic ──────────────────────────────────────────────
|
||||
|
||||
const tp = task.command('topic').description('Manage task topics');
|
||||
|
||||
tp.command('list <id>')
|
||||
.description('List topics for a task')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getTopics.query({ id });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
log.info('No topics found for this task.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = result.data.map((t: any) => [
|
||||
`#${t.seq}`,
|
||||
t.id,
|
||||
statusBadge(t.status || 'running'),
|
||||
truncate(t.title || 'Untitled', 40),
|
||||
t.operationId ? pc.dim(truncate(t.operationId, 20)) : '-',
|
||||
timeAgo(t.createdAt),
|
||||
]);
|
||||
|
||||
printTable(rows, ['SEQ', 'TOPIC ID', 'STATUS', 'TITLE', 'OPERATION', 'CREATED']);
|
||||
});
|
||||
|
||||
tp.command('view <id> <topicId>')
|
||||
.description('View messages of a topic (topicId can be a seq number like "1")')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, topicId: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let resolvedTopicId = topicId;
|
||||
|
||||
// If it's a number, treat as seq index
|
||||
const seqNum = Number.parseInt(topicId, 10);
|
||||
if (!Number.isNaN(seqNum) && String(seqNum) === topicId) {
|
||||
const topicsResult = await client.task.getTopics.query({ id });
|
||||
const match = (topicsResult.data || []).find((t: any) => t.seq === seqNum);
|
||||
if (!match) {
|
||||
log.error(`Topic #${seqNum} not found for this task.`);
|
||||
return;
|
||||
}
|
||||
resolvedTopicId = match.id;
|
||||
log.info(
|
||||
`Topic #${seqNum}: ${pc.bold(match.title || 'Untitled')} ${pc.dim(resolvedTopicId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const messages = await client.message.getMessages.query({ topicId: resolvedTopicId });
|
||||
const items = Array.isArray(messages) ? messages : [];
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(items, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
log.info('No messages in this topic.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
for (const msg of items) {
|
||||
const role =
|
||||
msg.role === 'assistant'
|
||||
? pc.green('Assistant')
|
||||
: msg.role === 'user'
|
||||
? pc.blue('User')
|
||||
: pc.dim(msg.role);
|
||||
|
||||
console.log(`${pc.bold(role)} ${pc.dim(timeAgo(msg.createdAt))}`);
|
||||
if (msg.content) {
|
||||
console.log(msg.content);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
|
||||
tp.command('cancel <topicId>')
|
||||
.description('Cancel a running topic and pause the task')
|
||||
.action(async (topicId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.cancelTopic.mutate({ topicId });
|
||||
log.info(`Topic ${pc.bold(topicId)} canceled. Task paused.`);
|
||||
});
|
||||
|
||||
tp.command('delete <topicId>')
|
||||
.description('Delete a topic and its messages')
|
||||
.option('-y, --yes', 'Skip confirmation')
|
||||
.action(async (topicId: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const ok = await confirm(`Delete topic ${pc.bold(topicId)} and all its messages?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.task.deleteTopic.mutate({ topicId });
|
||||
log.info(`Topic ${pc.bold(topicId)} deleted.`);
|
||||
});
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ function stripAnsi(s: string): string {
|
|||
* Calculate the display width of a string in the terminal.
|
||||
* CJK characters and fullwidth symbols occupy 2 columns.
|
||||
*/
|
||||
function displayWidth(s: string): number {
|
||||
export function displayWidth(s: string): number {
|
||||
const plain = stripAnsi(s);
|
||||
let width = 0;
|
||||
for (const char of plain) {
|
||||
|
|
|
|||
|
|
@ -2029,4 +2029,4 @@ ref: topic_documents.document_id > documents.id
|
|||
|
||||
ref: topic_documents.topic_id > topics.id
|
||||
|
||||
ref: topics.session_id - sessions.id
|
||||
ref: topics.session_id - sessions.id
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@
|
|||
"@lobechat/builtin-tool-agent-builder": "workspace:*",
|
||||
"@lobechat/builtin-tool-agent-documents": "workspace:*",
|
||||
"@lobechat/builtin-tool-agent-management": "workspace:*",
|
||||
"@lobechat/builtin-tool-brief": "workspace:*",
|
||||
"@lobechat/builtin-tool-calculator": "workspace:*",
|
||||
"@lobechat/builtin-tool-cloud-sandbox": "workspace:*",
|
||||
"@lobechat/builtin-tool-creds": "workspace:*",
|
||||
|
|
@ -219,6 +220,7 @@
|
|||
"@lobechat/builtin-tool-remote-device": "workspace:*",
|
||||
"@lobechat/builtin-tool-skill-store": "workspace:*",
|
||||
"@lobechat/builtin-tool-skills": "workspace:*",
|
||||
"@lobechat/builtin-tool-task": "workspace:*",
|
||||
"@lobechat/builtin-tool-topic-reference": "workspace:*",
|
||||
"@lobechat/builtin-tool-web-browsing": "workspace:*",
|
||||
"@lobechat/builtin-tools": "workspace:*",
|
||||
|
|
@ -498,6 +500,7 @@
|
|||
"openapi-typescript": "^7.10.1",
|
||||
"p-map": "^7.0.4",
|
||||
"prettier": "^3.8.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"remark-cli": "^12.0.1",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-mdx": "^3.1.1",
|
||||
|
|
|
|||
|
|
@ -3,14 +3,17 @@ import type { BuiltinSkill } from '@lobechat/types';
|
|||
import { AgentBrowserSkill } from './agent-browser';
|
||||
import { ArtifactsSkill } from './artifacts';
|
||||
import { LobeHubSkill } from './lobehub';
|
||||
import { TaskSkill } from './task';
|
||||
|
||||
export { AgentBrowserIdentifier } from './agent-browser';
|
||||
export { ArtifactsIdentifier } from './artifacts';
|
||||
export { LobeHubIdentifier } from './lobehub';
|
||||
export { TaskIdentifier } from './task';
|
||||
|
||||
export const builtinSkills: BuiltinSkill[] = [
|
||||
AgentBrowserSkill,
|
||||
ArtifactsSkill,
|
||||
LobeHubSkill,
|
||||
TaskSkill,
|
||||
// FindSkillsSkill
|
||||
];
|
||||
|
|
|
|||
4
packages/builtin-skills/src/raw.d.ts
vendored
Normal file
4
packages/builtin-skills/src/raw.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module '*.md' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
53
packages/builtin-skills/src/task/SKILL.md
Normal file
53
packages/builtin-skills/src/task/SKILL.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
\<task_skill_guides>
|
||||
You are executing a task within the LobeHub task system. Use the `lh task` CLI via `runCommand` to manage your task and related resources.
|
||||
|
||||
# Task Lifecycle
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------- | ----------------------------------------------------- |
|
||||
| `lh task view <id>` | View task details, instruction, workspace, activities |
|
||||
| `lh task edit <id>` | Update task name, instruction, status, priority |
|
||||
| `lh task complete <id>` | Mark task as completed |
|
||||
| `lh task comment <id> -m "..."` | Add a progress comment |
|
||||
| `lh task tree <id>` | View subtask tree with dependencies |
|
||||
|
||||
# Working with Subtasks
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------------------- | ----------------- |
|
||||
| `lh task create -i "..." --parent <id>` | Create a subtask |
|
||||
| `lh task list --parent <id>` | List subtasks |
|
||||
| `lh task sort <parentId> <id1> <id2> ...` | Reorder subtasks |
|
||||
| `lh task dep add <id> <dependsOnId>` | Add dependency |
|
||||
| `lh task dep rm <id> <dependsOnId>` | Remove dependency |
|
||||
|
||||
# Task Workspace (Documents)
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------------------------- | ------------------------- |
|
||||
| `lh task doc create <id> -t "title" -b "content"` | Create and pin a document |
|
||||
| `lh task doc pin <id> <docId>` | Pin existing document |
|
||||
| `lh task doc unpin <id> <docId>` | Unpin document |
|
||||
|
||||
# Task Topics (Conversations)
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------------- | ------------------------ |
|
||||
| `lh task topic list <id>` | List conversation topics |
|
||||
| `lh task topic view <id> <topicId>` | View topic messages |
|
||||
|
||||
# Usage Pattern
|
||||
|
||||
1. Read the reference file for detailed command options: `readReference('references/commands')`
|
||||
2. Run commands via `runCommand` — the `lh` prefix is automatically handled
|
||||
3. Use `--json` flag on any command for structured output
|
||||
4. Use `lh task <subcommand> --help` for full command-line help
|
||||
|
||||
# Task Execution Guidelines
|
||||
|
||||
- **Check your task first**: Use `lh task view` to understand the full instruction and context
|
||||
- **Use workspace documents**: Store outputs and deliverables as task documents
|
||||
- **Report progress**: Use `lh task comment` to log key milestones
|
||||
- **Respect dependencies**: Check `lh task tree` to understand task ordering
|
||||
- **Complete when done**: Use `lh task complete` when all deliverables are ready
|
||||
\</task_skill_guides>
|
||||
18
packages/builtin-skills/src/task/index.ts
Normal file
18
packages/builtin-skills/src/task/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { BuiltinSkill } from '@lobechat/types';
|
||||
|
||||
import { toResourceMeta } from '../lobehub/helpers';
|
||||
import commands from './references/commands.md';
|
||||
import content from './SKILL.md';
|
||||
|
||||
export const TaskIdentifier = 'task';
|
||||
|
||||
export const TaskSkill: BuiltinSkill = {
|
||||
content,
|
||||
description: 'Task management and execution — create, track, review, and complete tasks via CLI.',
|
||||
identifier: TaskIdentifier,
|
||||
name: 'Task',
|
||||
resources: toResourceMeta({
|
||||
'references/commands': commands,
|
||||
}),
|
||||
source: 'builtin',
|
||||
};
|
||||
80
packages/builtin-skills/src/task/references/commands.md
Normal file
80
packages/builtin-skills/src/task/references/commands.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# lh task - Complete Command Reference
|
||||
|
||||
## Core Commands
|
||||
|
||||
- `lh task list [--status <status>] [--root] [--parent <id>] [--agent <id>] [-L <limit>] [--tree]` - List tasks
|
||||
- `--status`: pending, running, paused, completed, failed, canceled
|
||||
- `--root`: Only root tasks (no parent)
|
||||
- `--tree`: Display as tree structure
|
||||
- `lh task view <id>` - View task details (instruction, workspace, activities)
|
||||
- `lh task create -i <instruction> [-n <name>] [--agent <id>] [--parent <id>] [--priority <0-4>]` - Create task
|
||||
- Priority: 0=none, 1=urgent, 2=high, 3=normal, 4=low
|
||||
- `lh task edit <id> [-n <name>] [-i <instruction>] [--status <status>] [--priority <0-4>] [--agent <id>]` - Update task
|
||||
- `lh task delete <id> [--yes]` - Delete task
|
||||
- `lh task clear [--yes]` - Delete all tasks
|
||||
- `lh task tree <id>` - Show subtask tree with dependencies
|
||||
|
||||
## Lifecycle Commands
|
||||
|
||||
- `lh task start <id> [--no-run] [-p <prompt>] [-f] [-v]` - Start task (pending → running)
|
||||
- `--no-run`: Only update status, skip agent execution
|
||||
- `-f, --follow`: Follow agent output in real-time
|
||||
- `-v, --verbose`: Show detailed tool call info
|
||||
- `lh task run <id> [-p <prompt>] [-c <topicId>] [-f] [--topics <n>] [--delay <s>]` - Run/re-run agent execution
|
||||
- `-c, --continue`: Continue on existing topic
|
||||
- `--topics <n>`: Run N topics in sequence
|
||||
- `lh task pause <id>` - Pause running task
|
||||
- `lh task resume <id>` - Resume paused task
|
||||
- `lh task complete <id>` - Mark as completed
|
||||
- `lh task cancel <id>` - Cancel task
|
||||
- `lh task comment <id> -m <message>` - Add comment
|
||||
- `lh task sort <parentId> <id1> <id2> ...` - Reorder subtasks
|
||||
- `lh task heartbeat <id>` - Send manual heartbeat
|
||||
- `lh task watchdog` - Detect and fail stuck tasks
|
||||
|
||||
## Checkpoint Commands
|
||||
|
||||
- `lh task checkpoint view <id>` - View checkpoint config
|
||||
- `lh task checkpoint set <id> [--on-agent-request <bool>] [--topic-before <bool>] [--topic-after <bool>] [--before <ids>] [--after <ids>]` - Configure checkpoints
|
||||
- `--on-agent-request`: Allow agent to request review
|
||||
- `--topic-before/after`: Pause before/after each topic
|
||||
- `--before/after <ids>`: Pause before/after specific subtask identifiers
|
||||
|
||||
## Review Commands (LLM-as-Judge)
|
||||
|
||||
- `lh task review view <id>` - View review config
|
||||
- `lh task review set <id> [--model <model>] [--provider <provider>] [--max-iterations <n>] [--no-auto-retry] [--recursive]` - Configure review
|
||||
- `lh task review criteria list <id>` - List review rubrics
|
||||
- `lh task review criteria add <id> -n <name> [--type <type>] [-t <threshold>] [-d <description>] [--value <value>] [--pattern <pattern>] [-w <weight>] [--recursive]` - Add rubric
|
||||
- Types: llm-rubric, contains, equals, starts-with, ends-with, regex
|
||||
- Threshold: 0-100
|
||||
- `lh task review criteria rm <id> -n <name> [--recursive]` - Remove rubric
|
||||
- `lh task review run <id> --content <text>` - Manually run review
|
||||
|
||||
## Dependency Commands
|
||||
|
||||
- `lh task dep add <taskId> <dependsOnId> [--type <blocks|relates>]` - Add dependency
|
||||
- `lh task dep rm <taskId> <dependsOnId>` - Remove dependency
|
||||
- `lh task dep list <taskId>` - List dependencies
|
||||
|
||||
## Topic Commands
|
||||
|
||||
- `lh task topic list <id>` - List topics for task
|
||||
- `lh task topic view <id> <topicId>` - View topic messages (topicId can be seq number like "1")
|
||||
- `lh task topic cancel <topicId>` - Cancel running topic and pause task
|
||||
- `lh task topic delete <topicId> [--yes]` - Delete topic and messages
|
||||
|
||||
## Document Commands (Workspace)
|
||||
|
||||
- `lh task doc create <id> -t <title> [-b <content>] [--parent <docId>] [--folder]` - Create and pin document
|
||||
- `lh task doc pin <id> <documentId>` - Pin existing document
|
||||
- `lh task doc unpin <id> <documentId>` - Unpin document
|
||||
- `lh task doc mv <id> <documentId> <folder>` - Move document into folder (auto-creates folder)
|
||||
|
||||
## Tips
|
||||
|
||||
- All commands support `--json [fields]` for structured output
|
||||
- Task identifiers use format like TASK-1, TASK-2, etc.
|
||||
- Use `lh task tree` to visualize full task hierarchy before planning work
|
||||
- Use `lh task comment` to log progress — comments appear in task activities
|
||||
- Documents in workspace are accessible to the agent during execution
|
||||
12
packages/builtin-tool-brief/package.json
Normal file
12
packages/builtin-tool-brief/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "@lobechat/builtin-tool-brief",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"devDependencies": {
|
||||
"@lobechat/types": "workspace:*"
|
||||
}
|
||||
}
|
||||
2
packages/builtin-tool-brief/src/index.ts
Normal file
2
packages/builtin-tool-brief/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { BriefIdentifier, BriefManifest } from './manifest';
|
||||
export { BriefApiName } from './types';
|
||||
83
packages/builtin-tool-brief/src/manifest.ts
Normal file
83
packages/builtin-tool-brief/src/manifest.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { BuiltinToolManifest } from '@lobechat/types';
|
||||
|
||||
import { systemPrompt } from './systemRole';
|
||||
import { BriefApiName } from './types';
|
||||
|
||||
export const BriefIdentifier = 'lobe-brief';
|
||||
|
||||
export const BriefManifest: BuiltinToolManifest = {
|
||||
api: [
|
||||
{
|
||||
description:
|
||||
"Create a brief to report progress, deliver results, or request decisions from the user. Use type 'decision' when you need user input, 'result' for deliverables, 'insight' for observations. Default actions are auto-generated based on type, but you can customize them.",
|
||||
name: BriefApiName.createBrief,
|
||||
parameters: {
|
||||
properties: {
|
||||
actions: {
|
||||
description:
|
||||
'Custom action buttons for the user. If omitted, defaults are generated based on type. Each action has key (identifier), label (display text), and type ("resolve" to close, "comment" to prompt feedback).',
|
||||
items: {
|
||||
properties: {
|
||||
key: { description: 'Action identifier, e.g. "approve", "split"', type: 'string' },
|
||||
label: { description: 'Display label, e.g. "✅ 同意拆分"', type: 'string' },
|
||||
type: {
|
||||
description: '"resolve" closes the brief, "comment" prompts for text input',
|
||||
enum: ['resolve', 'comment'],
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['key', 'label', 'type'],
|
||||
type: 'object',
|
||||
},
|
||||
type: 'array',
|
||||
},
|
||||
priority: {
|
||||
description: "Priority of the brief. Default is 'normal'.",
|
||||
enum: ['urgent', 'normal', 'info'],
|
||||
type: 'string',
|
||||
},
|
||||
summary: {
|
||||
description: 'Detailed summary content of the brief.',
|
||||
type: 'string',
|
||||
},
|
||||
title: {
|
||||
description: 'A short title for the brief.',
|
||||
type: 'string',
|
||||
},
|
||||
type: {
|
||||
description:
|
||||
"The type of brief: 'decision' for user input needed, 'result' for deliverables, 'insight' for observations.",
|
||||
enum: ['decision', 'result', 'insight'],
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['type', 'title', 'summary'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Pause execution and request the user to review your work before continuing. Use at natural review points.',
|
||||
humanIntervention: 'required',
|
||||
name: BriefApiName.requestCheckpoint,
|
||||
parameters: {
|
||||
properties: {
|
||||
reason: {
|
||||
description: 'The reason for requesting a checkpoint.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['reason'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
],
|
||||
identifier: BriefIdentifier,
|
||||
meta: {
|
||||
avatar: '📋',
|
||||
description: 'Report progress, deliver results, and request user decisions',
|
||||
title: 'Brief Tools',
|
||||
},
|
||||
systemRole: systemPrompt,
|
||||
type: 'builtin',
|
||||
};
|
||||
9
packages/builtin-tool-brief/src/systemRole.ts
Normal file
9
packages/builtin-tool-brief/src/systemRole.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const systemPrompt = `You have access to Brief communication tools. Use them to interact with the user:
|
||||
|
||||
- **createBrief**: Report progress, deliver results, or request decisions from the user. Use type 'decision' when you need user input, 'result' for deliverables, 'insight' for observations. You can define custom action buttons for the user to respond with
|
||||
- **requestCheckpoint**: Pause execution and ask the user to review your work before continuing. Use at natural review points
|
||||
|
||||
When communicating:
|
||||
1. Use createBrief to deliver results and request feedback at key milestones
|
||||
2. Use requestCheckpoint when you need explicit approval before proceeding
|
||||
3. For decision briefs, provide clear action options (e.g. approve, reject, modify)`;
|
||||
9
packages/builtin-tool-brief/src/types.ts
Normal file
9
packages/builtin-tool-brief/src/types.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const BriefApiName = {
|
||||
/** Create a brief to report progress, results, or request decisions */
|
||||
createBrief: 'createBrief',
|
||||
|
||||
/** Pause execution and request user review */
|
||||
requestCheckpoint: 'requestCheckpoint',
|
||||
} as const;
|
||||
|
||||
export type BriefApiNameType = (typeof BriefApiName)[keyof typeof BriefApiName];
|
||||
|
|
@ -27,6 +27,11 @@ interface DocumentServiceResult {
|
|||
}
|
||||
|
||||
interface NotebookService {
|
||||
/**
|
||||
* Associate a document with a task (optional, for task execution context)
|
||||
*/
|
||||
associateDocumentWithTask?: (documentId: string, taskId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Associate a document with a topic
|
||||
*/
|
||||
|
|
@ -115,11 +120,18 @@ export class NotebookExecutionRuntime {
|
|||
*/
|
||||
async createDocument(
|
||||
args: CreateDocumentArgs,
|
||||
options?: { topicId?: string | null },
|
||||
options?: { taskId?: string | null; topicId?: string | null },
|
||||
): Promise<BuiltinServerRuntimeOutput> {
|
||||
try {
|
||||
const { title, content, type = 'markdown' } = args;
|
||||
|
||||
if (!content) {
|
||||
return {
|
||||
content: 'Error: Missing content. The document content is required.',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!options?.topicId) {
|
||||
return {
|
||||
content: 'Error: No topic context. Documents must be created within a topic.',
|
||||
|
|
@ -141,6 +153,11 @@ export class NotebookExecutionRuntime {
|
|||
// Associate with topic
|
||||
await this.notebookService.associateDocumentWithTopic(doc.id, options.topicId);
|
||||
|
||||
// Associate with task if in task execution context
|
||||
if (options.taskId && this.notebookService.associateDocumentWithTask) {
|
||||
await this.notebookService.associateDocumentWithTask(doc.id, options.taskId);
|
||||
}
|
||||
|
||||
const notebookDoc = toNotebookDocument(doc);
|
||||
const state: CreateDocumentState = { document: notebookDoc };
|
||||
|
||||
|
|
|
|||
12
packages/builtin-tool-task/package.json
Normal file
12
packages/builtin-tool-task/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "@lobechat/builtin-tool-task",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"devDependencies": {
|
||||
"@lobechat/types": "workspace:*"
|
||||
}
|
||||
}
|
||||
2
packages/builtin-tool-task/src/index.ts
Normal file
2
packages/builtin-tool-task/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { TaskIdentifier, TaskManifest } from './manifest';
|
||||
export { TaskApiName } from './types';
|
||||
209
packages/builtin-tool-task/src/manifest.ts
Normal file
209
packages/builtin-tool-task/src/manifest.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import type { BuiltinToolManifest } from '@lobechat/types';
|
||||
|
||||
import { systemPrompt } from './systemRole';
|
||||
import { TaskApiName } from './types';
|
||||
|
||||
export const TaskIdentifier = 'lobe-task';
|
||||
|
||||
export const TaskManifest: BuiltinToolManifest = {
|
||||
api: [
|
||||
// ==================== Task CRUD ====================
|
||||
{
|
||||
description:
|
||||
'Create a new task. Optionally attach it as a subtask by specifying parentIdentifier. Review config is inherited from parent task by default.',
|
||||
name: TaskApiName.createTask,
|
||||
parameters: {
|
||||
properties: {
|
||||
instruction: {
|
||||
description: 'Detailed instruction for what the task should accomplish.',
|
||||
type: 'string',
|
||||
},
|
||||
name: {
|
||||
description: 'A short, descriptive name for the task.',
|
||||
type: 'string',
|
||||
},
|
||||
parentIdentifier: {
|
||||
description:
|
||||
'Identifier of the parent task (e.g. "TASK-1"). If provided, the new task becomes a subtask. Defaults to the current task if omitted.',
|
||||
type: 'string',
|
||||
},
|
||||
priority: {
|
||||
description: 'Priority level: 0=none, 1=urgent, 2=high, 3=normal, 4=low. Default is 0.',
|
||||
type: 'number',
|
||||
},
|
||||
sortOrder: {
|
||||
description:
|
||||
'Sort order within parent task. Lower values appear first. Use to control display order (e.g. chapter 1=0, chapter 2=1, etc.).',
|
||||
type: 'number',
|
||||
},
|
||||
review: {
|
||||
description:
|
||||
'Review config. If omitted, inherits from parent task. Set to configure LLM-as-Judge auto-review.',
|
||||
properties: {
|
||||
autoRetry: {
|
||||
description: 'Auto-retry on failure. Default true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
criteria: {
|
||||
description: 'Review criteria with name and threshold (0-100).',
|
||||
items: {
|
||||
properties: {
|
||||
name: { description: 'Criterion name, e.g. "内容准确性"', type: 'string' },
|
||||
threshold: { description: 'Pass threshold (0-100)', type: 'number' },
|
||||
},
|
||||
required: ['name', 'threshold'],
|
||||
type: 'object',
|
||||
},
|
||||
type: 'array',
|
||||
},
|
||||
enabled: { description: 'Enable review. Default false.', type: 'boolean' },
|
||||
maxIterations: {
|
||||
description: 'Max review iterations. Default 3.',
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
required: ['name', 'instruction'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description:
|
||||
'List tasks with optional filters. Without filters, lists subtasks of the current task.',
|
||||
name: TaskApiName.listTasks,
|
||||
parameters: {
|
||||
properties: {
|
||||
parentIdentifier: {
|
||||
description:
|
||||
'List subtasks of a specific parent task. Defaults to the current task if omitted.',
|
||||
type: 'string',
|
||||
},
|
||||
status: {
|
||||
description: 'Filter by status.',
|
||||
enum: ['backlog', 'running', 'paused', 'completed', 'failed', 'canceled'],
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description:
|
||||
'View details of a specific task. If no identifier is provided, returns the current task.',
|
||||
name: TaskApiName.viewTask,
|
||||
parameters: {
|
||||
properties: {
|
||||
identifier: {
|
||||
description:
|
||||
'The task identifier to view (e.g. "TASK-1"). Defaults to the current task if omitted.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Edit a task's name, instruction, priority, or dependencies. Use addDependency/removeDependency to manage execution order.",
|
||||
name: TaskApiName.editTask,
|
||||
parameters: {
|
||||
properties: {
|
||||
addDependency: {
|
||||
description:
|
||||
'Add a dependency — this task will block until the specified task completes. Provide the identifier (e.g. "TASK-2").',
|
||||
type: 'string',
|
||||
},
|
||||
identifier: {
|
||||
description: 'The identifier of the task to edit.',
|
||||
type: 'string',
|
||||
},
|
||||
instruction: {
|
||||
description: 'Updated instruction for the task.',
|
||||
type: 'string',
|
||||
},
|
||||
name: {
|
||||
description: 'Updated name for the task.',
|
||||
type: 'string',
|
||||
},
|
||||
priority: {
|
||||
description: 'Updated priority level: 0=none, 1=urgent, 2=high, 3=normal, 4=low.',
|
||||
type: 'number',
|
||||
},
|
||||
removeDependency: {
|
||||
description: 'Remove a dependency. Provide the identifier of the dependency to remove.',
|
||||
type: 'string',
|
||||
},
|
||||
review: {
|
||||
description: 'Update review config.',
|
||||
properties: {
|
||||
autoRetry: { type: 'boolean' },
|
||||
criteria: {
|
||||
items: {
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
threshold: { type: 'number' },
|
||||
},
|
||||
required: ['name', 'threshold'],
|
||||
type: 'object',
|
||||
},
|
||||
type: 'array',
|
||||
},
|
||||
enabled: { type: 'boolean' },
|
||||
maxIterations: { type: 'number' },
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
required: ['identifier'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Update a task's status. Use to mark tasks as completed, canceled, or change lifecycle state. Defaults to the current task if no identifier provided.",
|
||||
name: TaskApiName.updateTaskStatus,
|
||||
parameters: {
|
||||
properties: {
|
||||
identifier: {
|
||||
description:
|
||||
'The task identifier (e.g. "TASK-1"). Defaults to the current task if omitted.',
|
||||
type: 'string',
|
||||
},
|
||||
status: {
|
||||
description: 'New status for the task.',
|
||||
enum: ['backlog', 'running', 'paused', 'completed', 'failed', 'canceled'],
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['status'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'Delete a task by identifier.',
|
||||
name: TaskApiName.deleteTask,
|
||||
parameters: {
|
||||
properties: {
|
||||
identifier: {
|
||||
description: 'The identifier of the task to delete.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['identifier'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
],
|
||||
identifier: TaskIdentifier,
|
||||
meta: {
|
||||
avatar: '\uD83D\uDCCB',
|
||||
description: 'Create, list, edit, delete tasks with dependencies and review config',
|
||||
title: 'Task Tools',
|
||||
},
|
||||
systemRole: systemPrompt,
|
||||
type: 'builtin',
|
||||
};
|
||||
14
packages/builtin-tool-task/src/systemRole.ts
Normal file
14
packages/builtin-tool-task/src/systemRole.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export const systemPrompt = `You have access to Task management tools. Use them to:
|
||||
|
||||
- **createTask**: Create a new task. Use parentIdentifier to make it a subtask. Review config is inherited from parent by default, or specify custom review criteria
|
||||
- **listTasks**: List tasks, optionally filtered by parent or status
|
||||
- **viewTask**: View details of a specific task (defaults to your current task)
|
||||
- **editTask**: Modify a task's name, instruction, priority, dependencies (addDependency/removeDependency), or review config
|
||||
- **updateTaskStatus**: Change a task's status (e.g. mark as completed when done, or cancel if no longer needed)
|
||||
- **deleteTask**: Delete a task
|
||||
|
||||
When planning work:
|
||||
1. Create tasks for each major piece of work (use parentIdentifier to organize as subtasks)
|
||||
2. Use editTask with addDependency to control execution order
|
||||
3. Configure review criteria on tasks that need quality gates
|
||||
4. Use updateTaskStatus to mark the current task as completed when you finish all work`;
|
||||
21
packages/builtin-tool-task/src/types.ts
Normal file
21
packages/builtin-tool-task/src/types.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export const TaskApiName = {
|
||||
/** Create a new task, optionally as a subtask of another task */
|
||||
createTask: 'createTask',
|
||||
|
||||
/** Delete a task */
|
||||
deleteTask: 'deleteTask',
|
||||
|
||||
/** Edit a task's name, instruction, priority, dependencies, or review config */
|
||||
editTask: 'editTask',
|
||||
|
||||
/** List tasks with optional filters */
|
||||
listTasks: 'listTasks',
|
||||
|
||||
/** Update a task's status (e.g. complete, cancel) */
|
||||
updateTaskStatus: 'updateTaskStatus',
|
||||
|
||||
/** View details of a specific task */
|
||||
viewTask: 'viewTask',
|
||||
} as const;
|
||||
|
||||
export type TaskApiNameType = (typeof TaskApiName)[keyof typeof TaskApiName];
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
"@lobechat/builtin-tool-activator": "workspace:*",
|
||||
"@lobechat/builtin-tool-agent-builder": "workspace:*",
|
||||
"@lobechat/builtin-tool-agent-documents": "workspace:*",
|
||||
"@lobechat/builtin-tool-brief": "workspace:*",
|
||||
"@lobechat/builtin-tool-cloud-sandbox": "workspace:*",
|
||||
"@lobechat/builtin-tool-creds": "workspace:*",
|
||||
"@lobechat/builtin-tool-group-agent-builder": "workspace:*",
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
"@lobechat/builtin-tool-remote-device": "workspace:*",
|
||||
"@lobechat/builtin-tool-skill-store": "workspace:*",
|
||||
"@lobechat/builtin-tool-skills": "workspace:*",
|
||||
"@lobechat/builtin-tool-task": "workspace:*",
|
||||
"@lobechat/builtin-tool-topic-reference": "workspace:*",
|
||||
"@lobechat/builtin-tool-web-browsing": "workspace:*",
|
||||
"@lobechat/const": "workspace:*"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { LobeActivatorManifest } from '@lobechat/builtin-tool-activator';
|
|||
import { AgentBuilderManifest } from '@lobechat/builtin-tool-agent-builder';
|
||||
import { AgentDocumentsManifest } from '@lobechat/builtin-tool-agent-documents';
|
||||
import { AgentManagementManifest } from '@lobechat/builtin-tool-agent-management';
|
||||
import { BriefManifest } from '@lobechat/builtin-tool-brief';
|
||||
import { CalculatorManifest } from '@lobechat/builtin-tool-calculator';
|
||||
import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox';
|
||||
import { CredsManifest } from '@lobechat/builtin-tool-creds';
|
||||
|
|
@ -16,6 +17,7 @@ import { PageAgentManifest } from '@lobechat/builtin-tool-page-agent';
|
|||
import { RemoteDeviceManifest } from '@lobechat/builtin-tool-remote-device';
|
||||
import { SkillStoreManifest } from '@lobechat/builtin-tool-skill-store';
|
||||
import { SkillsManifest } from '@lobechat/builtin-tool-skills';
|
||||
import { TaskManifest } from '@lobechat/builtin-tool-task';
|
||||
import { TopicReferenceManifest } from '@lobechat/builtin-tool-topic-reference';
|
||||
import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing';
|
||||
import { isDesktop, RECOMMENDED_SKILLS, RecommendedSkillType } from '@lobechat/const';
|
||||
|
|
@ -182,6 +184,20 @@ export const builtinTools: LobeBuiltinTool[] = [
|
|||
manifest: TopicReferenceManifest,
|
||||
type: 'builtin',
|
||||
},
|
||||
{
|
||||
discoverable: false,
|
||||
hidden: true,
|
||||
identifier: TaskManifest.identifier,
|
||||
manifest: TaskManifest,
|
||||
type: 'builtin',
|
||||
},
|
||||
{
|
||||
discoverable: false,
|
||||
hidden: true,
|
||||
identifier: BriefManifest.identifier,
|
||||
manifest: BriefManifest,
|
||||
type: 'builtin',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@ const log = debug('context-engine:provider:SkillContextProvider');
|
|||
* Compatible with the SkillMeta that will be added in @lobechat/types (Phase 3.2)
|
||||
*/
|
||||
export interface SkillMeta {
|
||||
/**
|
||||
* When true, the skill's content is directly injected into the system prompt
|
||||
* instead of only appearing in the <available_skills> list.
|
||||
*/
|
||||
activated?: boolean;
|
||||
/**
|
||||
* Full skill content to inject when activated.
|
||||
* Only used when `activated` is true.
|
||||
*/
|
||||
content?: string;
|
||||
description: string;
|
||||
identifier: string;
|
||||
location?: string;
|
||||
|
|
@ -58,28 +68,50 @@ export class SkillContextProvider extends BaseProvider {
|
|||
return this.markAsExecuted(clonedContext);
|
||||
}
|
||||
|
||||
const skills: SkillItem[] = enabledSkills.map((skill) => ({
|
||||
description: skill.description,
|
||||
identifier: skill.identifier,
|
||||
location: skill.location,
|
||||
name: skill.name,
|
||||
}));
|
||||
// Separate activated skills (inject content directly) from available skills (list only)
|
||||
const activatedSkills = enabledSkills.filter((s) => s.activated && s.content);
|
||||
const availableSkills = enabledSkills.filter((s) => !s.activated);
|
||||
|
||||
const skillContent = skillsPrompts(skills);
|
||||
const contentParts: string[] = [];
|
||||
|
||||
if (!skillContent) {
|
||||
// Inject activated skill content directly into system prompt
|
||||
for (const skill of activatedSkills) {
|
||||
contentParts.push(skill.content!);
|
||||
log('Auto-activated skill: %s', skill.identifier);
|
||||
}
|
||||
|
||||
// Generate <available_skills> list for non-activated skills
|
||||
if (availableSkills.length > 0) {
|
||||
const skills: SkillItem[] = availableSkills.map((skill) => ({
|
||||
description: skill.description,
|
||||
identifier: skill.identifier,
|
||||
location: skill.location,
|
||||
name: skill.name,
|
||||
}));
|
||||
|
||||
const availableSkillsContent = skillsPrompts(skills);
|
||||
if (availableSkillsContent) {
|
||||
contentParts.push(availableSkillsContent);
|
||||
}
|
||||
}
|
||||
|
||||
if (contentParts.length === 0) {
|
||||
log('No skill content generated, skipping injection');
|
||||
return this.markAsExecuted(clonedContext);
|
||||
}
|
||||
|
||||
this.injectSkillContext(clonedContext, skillContent);
|
||||
this.injectSkillContext(clonedContext, contentParts.join('\n\n'));
|
||||
|
||||
clonedContext.metadata.skillContext = {
|
||||
injected: true,
|
||||
skillsCount: enabledSkills.length,
|
||||
};
|
||||
|
||||
log(`Skill context injected, skills count: ${enabledSkills.length}`);
|
||||
log(
|
||||
'Skill context injected: %d activated, %d available',
|
||||
activatedSkills.length,
|
||||
availableSkills.length,
|
||||
);
|
||||
return this.markAsExecuted(clonedContext);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,87 @@ describe('SkillContextProvider', () => {
|
|||
expect(systemMessage!.content).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should directly inject content for activated skills', async () => {
|
||||
const skills: SkillMeta[] = [
|
||||
{
|
||||
activated: true,
|
||||
content: '<task_guides>\nUse `lh task` to manage tasks.\n</task_guides>',
|
||||
description: 'Task management via CLI',
|
||||
identifier: 'task',
|
||||
name: 'Task',
|
||||
},
|
||||
{
|
||||
description: 'Generate interactive UI components',
|
||||
identifier: 'artifacts',
|
||||
name: 'Artifacts',
|
||||
},
|
||||
];
|
||||
const provider = new SkillContextProvider({ enabledSkills: skills });
|
||||
|
||||
const messages = [{ content: 'Hello', id: 'u1', role: 'user' }];
|
||||
const ctx = createContext(messages);
|
||||
const result = await provider.process(ctx);
|
||||
|
||||
const systemMessage = result.messages.find((msg) => msg.role === 'system');
|
||||
expect(systemMessage!.content).toMatchSnapshot();
|
||||
|
||||
// Activated skill should NOT appear in <available_skills> list
|
||||
expect(systemMessage!.content).not.toContain('<skill name="Task">');
|
||||
|
||||
expect(result.metadata.skillContext).toEqual({
|
||||
injected: true,
|
||||
skillsCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle all skills activated (no available_skills list)', async () => {
|
||||
const skills: SkillMeta[] = [
|
||||
{
|
||||
activated: true,
|
||||
content: 'Task skill content here',
|
||||
description: 'Task management',
|
||||
identifier: 'task',
|
||||
name: 'Task',
|
||||
},
|
||||
];
|
||||
const provider = new SkillContextProvider({ enabledSkills: skills });
|
||||
|
||||
const messages = [{ content: 'Hello', id: 'u1', role: 'user' }];
|
||||
const ctx = createContext(messages);
|
||||
const result = await provider.process(ctx);
|
||||
|
||||
const systemMessage = result.messages.find((msg) => msg.role === 'system');
|
||||
expect(systemMessage!.content).toMatchSnapshot();
|
||||
expect(systemMessage!.content).not.toContain('<available_skills>');
|
||||
});
|
||||
|
||||
it('should skip activated skill without content', async () => {
|
||||
const skills: SkillMeta[] = [
|
||||
{
|
||||
activated: true,
|
||||
// no content provided
|
||||
description: 'Broken skill',
|
||||
identifier: 'broken',
|
||||
name: 'Broken',
|
||||
},
|
||||
{
|
||||
description: 'Working skill',
|
||||
identifier: 'working',
|
||||
name: 'Working',
|
||||
},
|
||||
];
|
||||
const provider = new SkillContextProvider({ enabledSkills: skills });
|
||||
|
||||
const messages = [{ content: 'Hello', id: 'u1', role: 'user' }];
|
||||
const ctx = createContext(messages);
|
||||
const result = await provider.process(ctx);
|
||||
|
||||
const systemMessage = result.messages.find((msg) => msg.role === 'system');
|
||||
// Broken skill (activated but no content) should fall through to available list
|
||||
expect(systemMessage!.content).toContain('<available_skills>');
|
||||
expect(systemMessage!.content).toContain('Working');
|
||||
});
|
||||
|
||||
it('should only inject lightweight metadata without content field', async () => {
|
||||
const skills: SkillMeta[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,19 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SkillContextProvider > should directly inject content for activated skills 1`] = `
|
||||
"<task_guides>
|
||||
Use \`lh task\` to manage tasks.
|
||||
</task_guides>
|
||||
|
||||
<available_skills>
|
||||
<skill name="Artifacts">Generate interactive UI components</skill>
|
||||
</available_skills>
|
||||
|
||||
Use the runSkill tool to activate a skill when needed."
|
||||
`;
|
||||
|
||||
exports[`SkillContextProvider > should handle all skills activated (no available_skills list) 1`] = `"Task skill content here"`;
|
||||
|
||||
exports[`SkillContextProvider > should inject skill metadata when skills are provided 1`] = `
|
||||
"<available_skills>
|
||||
<skill name="Artifacts" location="/path/to/skills/artifacts/SKILL.md">Generate interactive UI components</skill>
|
||||
|
|
|
|||
184
packages/database/src/models/__tests__/brief.test.ts
Normal file
184
packages/database/src/models/__tests__/brief.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '../../core/getTestDB';
|
||||
import { users } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { BriefModel } from '../brief';
|
||||
|
||||
const serverDB: LobeChatDatabase = await getTestDB();
|
||||
|
||||
const userId = 'brief-test-user-id';
|
||||
const userId2 = 'brief-test-user-id-2';
|
||||
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: userId2 }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
});
|
||||
|
||||
describe('BriefModel', () => {
|
||||
describe('create', () => {
|
||||
it('should create a brief', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const brief = await model.create({
|
||||
summary: 'Outline is ready for review',
|
||||
title: 'Outline completed',
|
||||
type: 'decision',
|
||||
});
|
||||
|
||||
expect(brief).toBeDefined();
|
||||
expect(brief.id).toBeDefined();
|
||||
expect(brief.userId).toBe(userId);
|
||||
expect(brief.type).toBe('decision');
|
||||
expect(brief.priority).toBe('info');
|
||||
expect(brief.readAt).toBeNull();
|
||||
expect(brief.resolvedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should create a brief with all fields', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const brief = await model.create({
|
||||
actions: [{ label: 'Approve', type: 'approve' }],
|
||||
agentId: 'agent-1',
|
||||
artifacts: ['doc-1', 'doc-2'],
|
||||
priority: 'urgent',
|
||||
summary: 'Chapter too long, suggest splitting',
|
||||
taskId: null,
|
||||
title: 'Chapter 4 needs split',
|
||||
topicId: 'topic-1',
|
||||
type: 'decision',
|
||||
});
|
||||
|
||||
expect(brief.priority).toBe('urgent');
|
||||
expect(brief.agentId).toBe('agent-1');
|
||||
expect(brief.actions).toEqual([{ label: 'Approve', type: 'approve' }]);
|
||||
expect(brief.artifacts).toEqual(['doc-1', 'doc-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find brief by id', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const created = await model.create({
|
||||
summary: 'Test',
|
||||
title: 'Test brief',
|
||||
type: 'result',
|
||||
});
|
||||
|
||||
const found = await model.findById(created.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it('should not find brief owned by another user', async () => {
|
||||
const model1 = new BriefModel(serverDB, userId);
|
||||
const model2 = new BriefModel(serverDB, userId2);
|
||||
|
||||
const brief = await model1.create({
|
||||
summary: 'Test',
|
||||
title: 'Test',
|
||||
type: 'result',
|
||||
});
|
||||
|
||||
const found = await model2.findById(brief.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('should list briefs for user', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
await model.create({ summary: 'A', title: 'Brief 1', type: 'result' });
|
||||
await model.create({ summary: 'B', title: 'Brief 2', type: 'decision' });
|
||||
|
||||
const { briefs, total } = await model.list();
|
||||
expect(total).toBe(2);
|
||||
expect(briefs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should filter by type', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
await model.create({ summary: 'A', title: 'Brief 1', type: 'result' });
|
||||
await model.create({ summary: 'B', title: 'Brief 2', type: 'decision' });
|
||||
|
||||
const { briefs } = await model.list({ type: 'decision' });
|
||||
expect(briefs).toHaveLength(1);
|
||||
expect(briefs[0].type).toBe('decision');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listUnresolved', () => {
|
||||
it('should return unresolved briefs sorted by priority', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
await model.create({ priority: 'info', summary: 'Low', title: 'Info', type: 'result' });
|
||||
await model.create({
|
||||
priority: 'urgent',
|
||||
summary: 'High',
|
||||
title: 'Urgent',
|
||||
type: 'decision',
|
||||
});
|
||||
await model.create({
|
||||
priority: 'normal',
|
||||
summary: 'Mid',
|
||||
title: 'Normal',
|
||||
type: 'insight',
|
||||
});
|
||||
|
||||
const unresolved = await model.listUnresolved();
|
||||
expect(unresolved).toHaveLength(3);
|
||||
expect(unresolved[0].priority).toBe('urgent');
|
||||
expect(unresolved[1].priority).toBe('normal');
|
||||
expect(unresolved[2].priority).toBe('info');
|
||||
});
|
||||
|
||||
it('should exclude resolved briefs', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const b1 = await model.create({ summary: 'A', title: 'Brief 1', type: 'result' });
|
||||
await model.create({ summary: 'B', title: 'Brief 2', type: 'result' });
|
||||
|
||||
await model.resolve(b1.id);
|
||||
|
||||
const unresolved = await model.listUnresolved();
|
||||
expect(unresolved).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markRead', () => {
|
||||
it('should mark brief as read', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const brief = await model.create({ summary: 'A', title: 'Test', type: 'result' });
|
||||
|
||||
const updated = await model.markRead(brief.id);
|
||||
expect(updated!.readAt).toBeDefined();
|
||||
expect(updated!.resolvedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolve', () => {
|
||||
it('should mark brief as resolved and read', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const brief = await model.create({ summary: 'A', title: 'Test', type: 'decision' });
|
||||
|
||||
const updated = await model.resolve(brief.id);
|
||||
expect(updated!.readAt).toBeDefined();
|
||||
expect(updated!.resolvedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete brief', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const brief = await model.create({ summary: 'A', title: 'Test', type: 'result' });
|
||||
|
||||
const deleted = await model.delete(brief.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const found = await model.findById(brief.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
859
packages/database/src/models/__tests__/task.test.ts
Normal file
859
packages/database/src/models/__tests__/task.test.ts
Normal file
|
|
@ -0,0 +1,859 @@
|
|||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '../../core/getTestDB';
|
||||
import { agents, briefs, documents, topics, users } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { TaskModel } from '../task';
|
||||
|
||||
const serverDB: LobeChatDatabase = await getTestDB();
|
||||
|
||||
const userId = 'task-test-user-id';
|
||||
const userId2 = 'task-test-user-id-2';
|
||||
|
||||
const createAgent = async (id: string, uid = userId) => {
|
||||
await serverDB.insert(agents).values({ id, slug: id, userId: uid }).onConflictDoNothing();
|
||||
return id;
|
||||
};
|
||||
|
||||
const createTopic = async (id: string, uid = userId) => {
|
||||
await serverDB.insert(topics).values({ id, userId: uid }).onConflictDoNothing();
|
||||
return id;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: userId2 }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
});
|
||||
|
||||
describe('TaskModel', () => {
|
||||
describe('constructor', () => {
|
||||
it('should create model with db and userId', () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
expect(model).toBeInstanceOf(TaskModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a task with auto-generated identifier', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const result = await model.create({
|
||||
instruction: 'Write a book about AI agents',
|
||||
name: 'Write AI Book',
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.identifier).toBe('T-1');
|
||||
expect(result.seq).toBe(1);
|
||||
expect(result.name).toBe('Write AI Book');
|
||||
expect(result.instruction).toBe('Write a book about AI agents');
|
||||
expect(result.status).toBe('backlog');
|
||||
expect(result.createdByUserId).toBe(userId);
|
||||
});
|
||||
|
||||
it('should auto-increment seq for same user', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
|
||||
const task1 = await model.create({ instruction: 'Task 1' });
|
||||
const task2 = await model.create({ instruction: 'Task 2' });
|
||||
const task3 = await model.create({ instruction: 'Task 3' });
|
||||
|
||||
expect(task1.seq).toBe(1);
|
||||
expect(task2.seq).toBe(2);
|
||||
expect(task3.seq).toBe(3);
|
||||
expect(task1.identifier).toBe('T-1');
|
||||
expect(task2.identifier).toBe('T-2');
|
||||
expect(task3.identifier).toBe('T-3');
|
||||
});
|
||||
|
||||
it('should support custom identifier prefix', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const result = await model.create({
|
||||
identifierPrefix: 'PROJ',
|
||||
instruction: 'Build WAKE system',
|
||||
});
|
||||
|
||||
expect(result.identifier).toBe('PROJ-1');
|
||||
});
|
||||
|
||||
it('should create task with all optional fields', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
await createAgent('agent-1');
|
||||
const result = await model.create({
|
||||
assigneeAgentId: 'agent-1',
|
||||
assigneeUserId: userId,
|
||||
description: 'A detailed description',
|
||||
instruction: 'Do something',
|
||||
name: 'Full Task',
|
||||
priority: 2,
|
||||
});
|
||||
|
||||
expect(result.assigneeAgentId).toBe('agent-1');
|
||||
expect(result.assigneeUserId).toBe(userId);
|
||||
expect(result.priority).toBe(2);
|
||||
});
|
||||
|
||||
it('should create subtask with parentTaskId', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const parent = await model.create({ instruction: 'Parent task' });
|
||||
const child = await model.create({
|
||||
instruction: 'Child task',
|
||||
parentTaskId: parent.id,
|
||||
});
|
||||
|
||||
expect(child.parentTaskId).toBe(parent.id);
|
||||
});
|
||||
|
||||
it('should isolate seq between users', async () => {
|
||||
const model1 = new TaskModel(serverDB, userId);
|
||||
const model2 = new TaskModel(serverDB, userId2);
|
||||
|
||||
const task1 = await model1.create({ instruction: 'User 1 task' });
|
||||
const task2 = await model2.create({ instruction: 'User 2 task' });
|
||||
|
||||
expect(task1.seq).toBe(1);
|
||||
expect(task2.seq).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle concurrent creates without seq collision', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
|
||||
// Create 5 tasks concurrently (simulates parallel tool calls)
|
||||
const results = await Promise.all([
|
||||
model.create({ instruction: 'Concurrent 1' }),
|
||||
model.create({ instruction: 'Concurrent 2' }),
|
||||
model.create({ instruction: 'Concurrent 3' }),
|
||||
model.create({ instruction: 'Concurrent 4' }),
|
||||
model.create({ instruction: 'Concurrent 5' }),
|
||||
]);
|
||||
|
||||
// All should succeed with unique seqs
|
||||
const seqs = results.map((r) => r.seq);
|
||||
const uniqueSeqs = new Set(seqs);
|
||||
expect(uniqueSeqs.size).toBe(5);
|
||||
|
||||
// All identifiers should be unique
|
||||
const identifiers = results.map((r) => r.identifier);
|
||||
const uniqueIdentifiers = new Set(identifiers);
|
||||
expect(uniqueIdentifiers.size).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find task by id', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const created = await model.create({ instruction: 'Test task' });
|
||||
|
||||
const found = await model.findById(created.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it('should not find task owned by another user', async () => {
|
||||
const model1 = new TaskModel(serverDB, userId);
|
||||
const model2 = new TaskModel(serverDB, userId2);
|
||||
|
||||
const task = await model1.create({ instruction: 'User 1 task' });
|
||||
const found = await model2.findById(task.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIdentifier', () => {
|
||||
it('should find task by identifier', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
await model.create({ instruction: 'Test task' });
|
||||
|
||||
const found = await model.findByIdentifier('T-1');
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.identifier).toBe('T-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update task fields', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Original' });
|
||||
|
||||
const updated = await model.update(task.id, {
|
||||
instruction: 'Updated instruction',
|
||||
name: 'Updated name',
|
||||
});
|
||||
|
||||
expect(updated!.instruction).toBe('Updated instruction');
|
||||
expect(updated!.name).toBe('Updated name');
|
||||
});
|
||||
|
||||
it('should not update task owned by another user', async () => {
|
||||
const model1 = new TaskModel(serverDB, userId);
|
||||
const model2 = new TaskModel(serverDB, userId2);
|
||||
|
||||
const task = await model1.create({ instruction: 'User 1 task' });
|
||||
const result = await model2.update(task.id, { name: 'Hacked' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete task', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'To be deleted' });
|
||||
|
||||
const deleted = await model.delete(task.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const found = await model.findById(task.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should not delete task owned by another user', async () => {
|
||||
const model1 = new TaskModel(serverDB, userId);
|
||||
const model2 = new TaskModel(serverDB, userId2);
|
||||
|
||||
const task = await model1.create({ instruction: 'User 1 task' });
|
||||
const deleted = await model2.delete(task.id);
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('should list tasks for user', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
await model.create({ instruction: 'Task 1' });
|
||||
await model.create({ instruction: 'Task 2' });
|
||||
|
||||
const { tasks, total } = await model.list();
|
||||
expect(total).toBe(2);
|
||||
expect(tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Task 1' });
|
||||
await model.updateStatus(task.id, 'running', { startedAt: new Date() });
|
||||
await model.create({ instruction: 'Task 2' });
|
||||
|
||||
const { tasks } = await model.list({ status: 'running' });
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].status).toBe('running');
|
||||
});
|
||||
|
||||
it('should filter root tasks only', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const parent = await model.create({ instruction: 'Parent' });
|
||||
await model.create({ instruction: 'Child', parentTaskId: parent.id });
|
||||
|
||||
const { tasks } = await model.list({ parentTaskId: null });
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].parentTaskId).toBeNull();
|
||||
});
|
||||
|
||||
it('should paginate results', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await model.create({ instruction: `Task ${i}` });
|
||||
}
|
||||
|
||||
const { tasks, total } = await model.list({ limit: 2, offset: 0 });
|
||||
expect(total).toBe(5);
|
||||
expect(tasks).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSubtasks', () => {
|
||||
it('should find direct subtasks', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const parent = await model.create({ instruction: 'Parent' });
|
||||
await model.create({ instruction: 'Child 1', parentTaskId: parent.id });
|
||||
await model.create({ instruction: 'Child 2', parentTaskId: parent.id });
|
||||
|
||||
const subtasks = await model.findSubtasks(parent.id);
|
||||
expect(subtasks).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTaskTree', () => {
|
||||
it('should return full task tree recursively', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const root = await model.create({ instruction: 'Root' });
|
||||
const child = await model.create({ instruction: 'Child', parentTaskId: root.id });
|
||||
await model.create({ instruction: 'Grandchild', parentTaskId: child.id });
|
||||
|
||||
const tree = await model.getTaskTree(root.id);
|
||||
expect(tree).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('should update status with timestamps', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
const startedAt = new Date();
|
||||
const updated = await model.updateStatus(task.id, 'running', { startedAt });
|
||||
expect(updated!.status).toBe('running');
|
||||
expect(updated!.startedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('heartbeat', () => {
|
||||
it('should update heartbeat timestamp', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.updateHeartbeat(task.id);
|
||||
const found = await model.findById(task.id);
|
||||
expect(found!.lastHeartbeatAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dependencies', () => {
|
||||
it('should add and query dependencies', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'Task A' });
|
||||
const taskB = await model.create({ instruction: 'Task B' });
|
||||
|
||||
await model.addDependency(taskB.id, taskA.id);
|
||||
|
||||
const deps = await model.getDependencies(taskB.id);
|
||||
expect(deps).toHaveLength(1);
|
||||
expect(deps[0].dependsOnId).toBe(taskA.id);
|
||||
});
|
||||
|
||||
it('should check all dependencies completed', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'Task A' });
|
||||
const taskB = await model.create({ instruction: 'Task B' });
|
||||
const taskC = await model.create({ instruction: 'Task C' });
|
||||
|
||||
await model.addDependency(taskC.id, taskA.id);
|
||||
await model.addDependency(taskC.id, taskB.id);
|
||||
|
||||
// Neither completed
|
||||
let allDone = await model.areAllDependenciesCompleted(taskC.id);
|
||||
expect(allDone).toBe(false);
|
||||
|
||||
// Complete A only
|
||||
await model.updateStatus(taskA.id, 'completed');
|
||||
allDone = await model.areAllDependenciesCompleted(taskC.id);
|
||||
expect(allDone).toBe(false);
|
||||
|
||||
// Complete B too
|
||||
await model.updateStatus(taskB.id, 'completed');
|
||||
allDone = await model.areAllDependenciesCompleted(taskC.id);
|
||||
expect(allDone).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove dependency', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'Task A' });
|
||||
const taskB = await model.create({ instruction: 'Task B' });
|
||||
|
||||
await model.addDependency(taskB.id, taskA.id);
|
||||
await model.removeDependency(taskB.id, taskA.id);
|
||||
|
||||
const deps = await model.getDependencies(taskB.id);
|
||||
expect(deps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should get dependents (reverse lookup)', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'Task A' });
|
||||
const taskB = await model.create({ instruction: 'Task B' });
|
||||
const taskC = await model.create({ instruction: 'Task C' });
|
||||
|
||||
await model.addDependency(taskB.id, taskA.id);
|
||||
await model.addDependency(taskC.id, taskA.id);
|
||||
|
||||
const dependents = await model.getDependents(taskA.id);
|
||||
expect(dependents).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should find unlocked tasks after dependency completes', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'Task A' });
|
||||
const taskB = await model.create({ instruction: 'Task B' });
|
||||
const taskC = await model.create({ instruction: 'Task C' });
|
||||
|
||||
// C blocks on A and B
|
||||
await model.addDependency(taskC.id, taskA.id);
|
||||
await model.addDependency(taskC.id, taskB.id);
|
||||
|
||||
// Complete A — C still blocked by B
|
||||
await model.updateStatus(taskA.id, 'completed');
|
||||
let unlocked = await model.getUnlockedTasks(taskA.id);
|
||||
expect(unlocked).toHaveLength(0);
|
||||
|
||||
// Complete B — C now unlocked
|
||||
await model.updateStatus(taskB.id, 'completed');
|
||||
unlocked = await model.getUnlockedTasks(taskB.id);
|
||||
expect(unlocked).toHaveLength(1);
|
||||
expect(unlocked[0].id).toBe(taskC.id);
|
||||
});
|
||||
|
||||
it('should not unlock tasks that are not in backlog', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'Task A' });
|
||||
const taskB = await model.create({ instruction: 'Task B' });
|
||||
|
||||
await model.addDependency(taskB.id, taskA.id);
|
||||
// Move B to running manually (not backlog)
|
||||
await model.updateStatus(taskB.id, 'running', { startedAt: new Date() });
|
||||
|
||||
await model.updateStatus(taskA.id, 'completed');
|
||||
const unlocked = await model.getUnlockedTasks(taskA.id);
|
||||
expect(unlocked).toHaveLength(0); // B is already running, not unlocked
|
||||
});
|
||||
|
||||
it('should check all subtasks completed', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const parent = await model.create({ instruction: 'Parent' });
|
||||
const child1 = await model.create({ instruction: 'Child 1', parentTaskId: parent.id });
|
||||
const child2 = await model.create({ instruction: 'Child 2', parentTaskId: parent.id });
|
||||
|
||||
expect(await model.areAllSubtasksCompleted(parent.id)).toBe(false);
|
||||
|
||||
await model.updateStatus(child1.id, 'completed');
|
||||
expect(await model.areAllSubtasksCompleted(parent.id)).toBe(false);
|
||||
|
||||
await model.updateStatus(child2.id, 'completed');
|
||||
expect(await model.areAllSubtasksCompleted(parent.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('documents', () => {
|
||||
it('should pin and get documents', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
// Create a test document
|
||||
const [doc] = await serverDB
|
||||
.insert(documents)
|
||||
.values({
|
||||
content: '',
|
||||
fileType: 'text/plain',
|
||||
source: 'test',
|
||||
sourceType: 'file',
|
||||
title: 'Test Doc',
|
||||
totalCharCount: 0,
|
||||
totalLineCount: 0,
|
||||
userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await model.pinDocument(task.id, doc.id);
|
||||
|
||||
const pinned = await model.getPinnedDocuments(task.id);
|
||||
expect(pinned).toHaveLength(1);
|
||||
expect(pinned[0].documentId).toBe(doc.id);
|
||||
});
|
||||
|
||||
it('should unpin document', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
const [doc] = await serverDB
|
||||
.insert(documents)
|
||||
.values({
|
||||
content: '',
|
||||
fileType: 'text/plain',
|
||||
source: 'test',
|
||||
sourceType: 'file',
|
||||
title: 'Test Doc',
|
||||
totalCharCount: 0,
|
||||
totalLineCount: 0,
|
||||
userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await model.pinDocument(task.id, doc.id);
|
||||
await model.unpinDocument(task.id, doc.id);
|
||||
|
||||
const pinned = await model.getPinnedDocuments(task.id);
|
||||
expect(pinned).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not duplicate pin', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
const [doc] = await serverDB
|
||||
.insert(documents)
|
||||
.values({
|
||||
content: '',
|
||||
fileType: 'text/plain',
|
||||
source: 'test',
|
||||
sourceType: 'file',
|
||||
title: 'Test Doc',
|
||||
totalCharCount: 0,
|
||||
totalLineCount: 0,
|
||||
userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await model.pinDocument(task.id, doc.id);
|
||||
await model.pinDocument(task.id, doc.id); // duplicate
|
||||
|
||||
const pinned = await model.getPinnedDocuments(task.id);
|
||||
expect(pinned).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkpoint', () => {
|
||||
it('should get and update checkpoint config', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
// Initially empty
|
||||
const empty = model.getCheckpointConfig(task);
|
||||
expect(empty).toEqual({});
|
||||
|
||||
// Set checkpoint
|
||||
const updated = await model.updateCheckpointConfig(task.id, {
|
||||
onAgentRequest: true,
|
||||
tasks: { afterIds: ['T-2'], beforeIds: ['T-3'] },
|
||||
topic: { after: true },
|
||||
});
|
||||
|
||||
const config = model.getCheckpointConfig(updated!);
|
||||
expect(config.onAgentRequest).toBe(true);
|
||||
expect(config.topic?.after).toBe(true);
|
||||
expect(config.tasks?.beforeIds).toEqual(['T-3']);
|
||||
expect(config.tasks?.afterIds).toEqual(['T-2']);
|
||||
});
|
||||
|
||||
it('should check shouldPauseBeforeStart', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const parent = await model.create({ instruction: 'Parent' });
|
||||
|
||||
await model.updateCheckpointConfig(parent.id, {
|
||||
tasks: { beforeIds: ['T-5'] },
|
||||
});
|
||||
|
||||
const parentUpdated = (await model.findById(parent.id))!;
|
||||
expect(model.shouldPauseBeforeStart(parentUpdated, 'T-5')).toBe(true);
|
||||
expect(model.shouldPauseBeforeStart(parentUpdated, 'T-6')).toBe(false);
|
||||
});
|
||||
|
||||
it('should pause on topic complete by default (no config)', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
// No checkpoint configured → should pause (default behavior)
|
||||
expect(model.shouldPauseOnTopicComplete(task)).toBe(true);
|
||||
});
|
||||
|
||||
it('should pause on topic complete when topic.after is true', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.updateCheckpointConfig(task.id, {
|
||||
topic: { after: true },
|
||||
});
|
||||
|
||||
const updated = (await model.findById(task.id))!;
|
||||
expect(model.shouldPauseOnTopicComplete(updated)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not pause on topic complete when only onAgentRequest is set', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.updateCheckpointConfig(task.id, {
|
||||
onAgentRequest: true,
|
||||
});
|
||||
|
||||
const updated = (await model.findById(task.id))!;
|
||||
// Has explicit config but topic.after is not true → don't auto-pause
|
||||
expect(model.shouldPauseOnTopicComplete(updated)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not pause on topic complete when topic.after is false', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.updateCheckpointConfig(task.id, {
|
||||
topic: { after: false },
|
||||
});
|
||||
|
||||
const updated = (await model.findById(task.id))!;
|
||||
expect(model.shouldPauseOnTopicComplete(updated)).toBe(false);
|
||||
});
|
||||
|
||||
it('should check shouldPauseAfterComplete', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const parent = await model.create({ instruction: 'Parent' });
|
||||
|
||||
await model.updateCheckpointConfig(parent.id, {
|
||||
tasks: { afterIds: ['T-2', 'T-3'] },
|
||||
});
|
||||
|
||||
const parentUpdated = (await model.findById(parent.id))!;
|
||||
expect(model.shouldPauseAfterComplete(parentUpdated, 'T-2')).toBe(true);
|
||||
expect(model.shouldPauseAfterComplete(parentUpdated, 'T-3')).toBe(true);
|
||||
expect(model.shouldPauseAfterComplete(parentUpdated, 'T-4')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('topic management', () => {
|
||||
it('should increment topic count', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.incrementTopicCount(task.id);
|
||||
await model.incrementTopicCount(task.id);
|
||||
|
||||
const found = await model.findById(task.id);
|
||||
expect(found!.totalTopics).toBe(2);
|
||||
});
|
||||
|
||||
it('should update current topic', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
await createTopic('topic-123');
|
||||
|
||||
await model.updateCurrentTopic(task.id, 'topic-123');
|
||||
|
||||
const found = await model.findById(task.id);
|
||||
expect(found!.currentTopicId).toBe('topic-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAll', () => {
|
||||
it('should delete all tasks for user', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
await model.create({ instruction: 'Task 1' });
|
||||
await model.create({ instruction: 'Task 2' });
|
||||
await model.create({ instruction: 'Task 3' });
|
||||
|
||||
const count = await model.deleteAll();
|
||||
expect(count).toBe(3);
|
||||
|
||||
const { total } = await model.list();
|
||||
expect(total).toBe(0);
|
||||
});
|
||||
|
||||
it('should not delete tasks of other users', async () => {
|
||||
const model1 = new TaskModel(serverDB, userId);
|
||||
const model2 = new TaskModel(serverDB, userId2);
|
||||
|
||||
await model1.create({ instruction: 'User 1 task' });
|
||||
await model2.create({ instruction: 'User 2 task' });
|
||||
|
||||
await model1.deleteAll();
|
||||
|
||||
const { total: total1 } = await model1.list();
|
||||
const { total: total2 } = await model2.list();
|
||||
expect(total1).toBe(0);
|
||||
expect(total2).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDependenciesByTaskIds', () => {
|
||||
it('should get dependencies for multiple tasks', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'A' });
|
||||
const taskB = await model.create({ instruction: 'B' });
|
||||
const taskC = await model.create({ instruction: 'C' });
|
||||
|
||||
await model.addDependency(taskB.id, taskA.id);
|
||||
await model.addDependency(taskC.id, taskB.id);
|
||||
|
||||
const deps = await model.getDependenciesByTaskIds([taskB.id, taskC.id]);
|
||||
expect(deps).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return empty for empty input', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const deps = await model.getDependenciesByTaskIds([]);
|
||||
expect(deps).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('comments', () => {
|
||||
it('should add and get comments', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.addComment({
|
||||
authorUserId: userId,
|
||||
content: 'First comment',
|
||||
taskId: task.id,
|
||||
userId,
|
||||
});
|
||||
await model.addComment({
|
||||
authorUserId: userId,
|
||||
content: 'Second comment',
|
||||
taskId: task.id,
|
||||
userId,
|
||||
});
|
||||
|
||||
const comments = await model.getComments(task.id);
|
||||
expect(comments).toHaveLength(2);
|
||||
expect(comments[0].content).toBe('First comment');
|
||||
expect(comments[1].content).toBe('Second comment');
|
||||
});
|
||||
|
||||
it('should add comment with briefId and topicId', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_abc');
|
||||
const [brief] = await serverDB
|
||||
.insert(briefs)
|
||||
.values({ id: 'brf_test1', summary: 'test', title: 'test', type: 'decision', userId })
|
||||
.returning();
|
||||
|
||||
const comment = await model.addComment({
|
||||
authorUserId: userId,
|
||||
briefId: brief.id,
|
||||
content: 'Reply to brief',
|
||||
taskId: task.id,
|
||||
topicId: 'tpc_abc',
|
||||
userId,
|
||||
});
|
||||
|
||||
expect(comment.briefId).toBe(brief.id);
|
||||
expect(comment.topicId).toBe('tpc_abc');
|
||||
});
|
||||
|
||||
it('should add comment from agent', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
await createAgent('agt_xxx');
|
||||
|
||||
const comment = await model.addComment({
|
||||
authorAgentId: 'agt_xxx',
|
||||
content: 'Agent observation',
|
||||
taskId: task.id,
|
||||
userId,
|
||||
});
|
||||
|
||||
expect(comment.authorAgentId).toBe('agt_xxx');
|
||||
expect(comment.authorUserId).toBeNull();
|
||||
});
|
||||
|
||||
it('should delete own comment', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
const comment = await model.addComment({
|
||||
authorUserId: userId,
|
||||
content: 'To be deleted',
|
||||
taskId: task.id,
|
||||
userId,
|
||||
});
|
||||
|
||||
const deleted = await model.deleteComment(comment.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const comments = await model.getComments(task.id);
|
||||
expect(comments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not delete comment from another user', async () => {
|
||||
const model1 = new TaskModel(serverDB, userId);
|
||||
const model2 = new TaskModel(serverDB, userId2);
|
||||
const task = await model1.create({ instruction: 'Test' });
|
||||
|
||||
const comment = await model1.addComment({
|
||||
authorUserId: userId,
|
||||
content: 'User 1 comment',
|
||||
taskId: task.id,
|
||||
userId,
|
||||
});
|
||||
|
||||
const deleted = await model2.deleteComment(comment.id);
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
|
||||
it('should return comments ordered by createdAt', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.addComment({ authorUserId: userId, content: 'First', taskId: task.id, userId });
|
||||
await model.addComment({ authorUserId: userId, content: 'Second', taskId: task.id, userId });
|
||||
await model.addComment({ authorUserId: userId, content: 'Third', taskId: task.id, userId });
|
||||
|
||||
const comments = await model.getComments(task.id);
|
||||
expect(comments).toHaveLength(3);
|
||||
expect(comments[0].content).toBe('First');
|
||||
expect(comments[2].content).toBe('Third');
|
||||
});
|
||||
});
|
||||
|
||||
describe('review rubrics', () => {
|
||||
it('should store EvalBenchmarkRubric format in config', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({
|
||||
config: {
|
||||
review: {
|
||||
enabled: true,
|
||||
maxIterations: 3,
|
||||
rubrics: [
|
||||
{
|
||||
config: { criteria: '技术概念是否准确' },
|
||||
id: 'r1',
|
||||
name: '内容准确性',
|
||||
threshold: 0.8,
|
||||
type: 'llm-rubric',
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
config: { value: '```' },
|
||||
id: 'r2',
|
||||
name: '包含代码示例',
|
||||
type: 'contains',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
instruction: 'Test with rubrics',
|
||||
});
|
||||
|
||||
const review = model.getReviewConfig(task);
|
||||
expect(review).toBeDefined();
|
||||
expect(review!.enabled).toBe(true);
|
||||
expect(review!.rubrics).toHaveLength(2);
|
||||
expect(review!.rubrics[0].type).toBe('llm-rubric');
|
||||
expect(review!.rubrics[0].threshold).toBe(0.8);
|
||||
expect(review!.rubrics[1].type).toBe('contains');
|
||||
expect(review!.rubrics[1].config.value).toBe('```');
|
||||
});
|
||||
|
||||
it('should inherit rubrics from parent when creating subtask', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const rubrics = [
|
||||
{
|
||||
config: { criteria: '准确性检查' },
|
||||
id: 'r1',
|
||||
name: '准确性',
|
||||
threshold: 0.8,
|
||||
type: 'llm-rubric',
|
||||
weight: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const parent = await model.create({
|
||||
config: { review: { enabled: true, rubrics } },
|
||||
instruction: 'Parent with rubrics',
|
||||
});
|
||||
|
||||
const parentConfig = parent.config as Record<string, any>;
|
||||
const child = await model.create({
|
||||
config: parentConfig?.review ? { review: parentConfig.review } : undefined,
|
||||
instruction: 'Child task',
|
||||
parentTaskId: parent.id,
|
||||
});
|
||||
|
||||
const childReview = model.getReviewConfig(child);
|
||||
expect(childReview).toBeDefined();
|
||||
expect(childReview!.rubrics).toHaveLength(1);
|
||||
expect(childReview!.rubrics[0].type).toBe('llm-rubric');
|
||||
});
|
||||
});
|
||||
});
|
||||
183
packages/database/src/models/__tests__/taskTopic.test.ts
Normal file
183
packages/database/src/models/__tests__/taskTopic.test.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '../../core/getTestDB';
|
||||
import { topics, users } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { TaskModel } from '../task';
|
||||
import { TaskTopicModel } from '../taskTopic';
|
||||
|
||||
const serverDB: LobeChatDatabase = await getTestDB();
|
||||
|
||||
const userId = 'task-topic-test-user-id';
|
||||
const userId2 = 'task-topic-test-user-id-2';
|
||||
|
||||
const createTopic = async (id: string, uid = userId) => {
|
||||
await serverDB.insert(topics).values({ id, userId: uid }).onConflictDoNothing();
|
||||
return id;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: userId2 }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
});
|
||||
|
||||
describe('TaskTopicModel', () => {
|
||||
describe('add and findByTaskId', () => {
|
||||
it('should add topic and get topics', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_aaa');
|
||||
await createTopic('tpc_bbb');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_aaa', { operationId: 'op_1', seq: 1 });
|
||||
await topicModel.add(task.id, 'tpc_bbb', { operationId: 'op_2', seq: 2 });
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
expect(topics).toHaveLength(2);
|
||||
expect(topics[0].seq).toBe(2); // ordered by seq desc
|
||||
expect(topics[1].seq).toBe(1);
|
||||
expect(topics[0].operationId).toBe('op_2');
|
||||
expect(topics[0].userId).toBe(userId);
|
||||
});
|
||||
|
||||
it('should not duplicate topic (onConflictDoNothing)', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_aaa');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
|
||||
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 }); // duplicate
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
expect(topics).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('should update topic status', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_aaa');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
|
||||
await topicModel.updateStatus(task.id, 'tpc_aaa', 'completed');
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
expect(topics[0].status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeoutRunning', () => {
|
||||
it('should timeout running topics only', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_aaa');
|
||||
await createTopic('tpc_bbb');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
|
||||
await topicModel.add(task.id, 'tpc_bbb', { seq: 2 });
|
||||
await topicModel.updateStatus(task.id, 'tpc_aaa', 'completed');
|
||||
|
||||
const count = await topicModel.timeoutRunning(task.id);
|
||||
expect(count).toBe(1);
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
const tpcA = topics.find((t) => t.topicId === 'tpc_aaa');
|
||||
const tpcB = topics.find((t) => t.topicId === 'tpc_bbb');
|
||||
expect(tpcA!.status).toBe('completed');
|
||||
expect(tpcB!.status).toBe('timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateHandoff', () => {
|
||||
it('should store handoff data', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_aaa');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
|
||||
await topicModel.updateHandoff(task.id, 'tpc_aaa', {
|
||||
keyFindings: ['Finding 1', 'Finding 2'],
|
||||
nextAction: 'Continue writing',
|
||||
summary: 'Completed chapter 1',
|
||||
title: '第1章完成',
|
||||
});
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
const handoff = topics[0].handoff as any;
|
||||
expect(handoff.title).toBe('第1章完成');
|
||||
expect(handoff.summary).toBe('Completed chapter 1');
|
||||
expect(handoff.nextAction).toBe('Continue writing');
|
||||
expect(handoff.keyFindings).toEqual(['Finding 1', 'Finding 2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateReview', () => {
|
||||
it('should store review results', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_review');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_review', { seq: 1 });
|
||||
await topicModel.updateReview(task.id, 'tpc_review', {
|
||||
iteration: 1,
|
||||
passed: true,
|
||||
score: 85,
|
||||
scores: [
|
||||
{ passed: true, reason: 'Good accuracy', rubricId: 'r1', score: 0.88 },
|
||||
{ passed: true, reason: 'Code found', rubricId: 'r2', score: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
expect(topics[0].reviewPassed).toBe(1);
|
||||
expect(topics[0].reviewScore).toBe(85);
|
||||
expect(topics[0].reviewIteration).toBe(1);
|
||||
expect(topics[0].reviewedAt).toBeDefined();
|
||||
|
||||
const scores = topics[0].reviewScores as any[];
|
||||
expect(scores).toHaveLength(2);
|
||||
expect(scores[0].rubricId).toBe('r1');
|
||||
expect(scores[1].score).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should remove topic association', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_aaa');
|
||||
|
||||
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
|
||||
const removed = await topicModel.remove(task.id, 'tpc_aaa');
|
||||
expect(removed).toBe(true);
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
expect(topics).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not remove topics of other users', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel1 = new TaskTopicModel(serverDB, userId);
|
||||
const topicModel2 = new TaskTopicModel(serverDB, userId2);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
await createTopic('tpc_aaa');
|
||||
|
||||
await topicModel1.add(task.id, 'tpc_aaa', { seq: 1 });
|
||||
const removed = await topicModel2.remove(task.id, 'tpc_aaa');
|
||||
expect(removed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
131
packages/database/src/models/brief.ts
Normal file
131
packages/database/src/models/brief.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
import type { BriefItem, NewBrief } from '../schemas/task';
|
||||
import { briefs } from '../schemas/task';
|
||||
import type { LobeChatDatabase } from '../type';
|
||||
|
||||
export class BriefModel {
|
||||
private readonly userId: string;
|
||||
private readonly db: LobeChatDatabase;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
async create(data: Omit<NewBrief, 'id' | 'userId'>): Promise<BriefItem> {
|
||||
const result = await this.db
|
||||
.insert(briefs)
|
||||
.values({ ...data, userId: this.userId })
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BriefItem | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(briefs)
|
||||
.where(and(eq(briefs.id, id), eq(briefs.userId, this.userId)))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async list(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
type?: string;
|
||||
}): Promise<{ briefs: BriefItem[]; total: number }> {
|
||||
const { type, limit = 50, offset = 0 } = options || {};
|
||||
|
||||
const conditions = [eq(briefs.userId, this.userId)];
|
||||
if (type) conditions.push(eq(briefs.type, type));
|
||||
|
||||
const where = and(...conditions);
|
||||
|
||||
const countResult = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(briefs)
|
||||
.where(where);
|
||||
|
||||
const items = await this.db
|
||||
.select()
|
||||
.from(briefs)
|
||||
.where(where)
|
||||
.orderBy(desc(briefs.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return { briefs: items, total: Number(countResult[0].count) };
|
||||
}
|
||||
|
||||
// For Daily Brief homepage — unresolved briefs sorted by priority
|
||||
async listUnresolved(): Promise<BriefItem[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(briefs)
|
||||
.where(and(eq(briefs.userId, this.userId), isNull(briefs.resolvedAt)))
|
||||
.orderBy(
|
||||
sql`CASE
|
||||
WHEN ${briefs.priority} = 'urgent' THEN 0
|
||||
WHEN ${briefs.priority} = 'normal' THEN 1
|
||||
ELSE 2
|
||||
END`,
|
||||
desc(briefs.createdAt),
|
||||
);
|
||||
}
|
||||
|
||||
async findByTaskId(taskId: string): Promise<BriefItem[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(briefs)
|
||||
.where(and(eq(briefs.taskId, taskId), eq(briefs.userId, this.userId)))
|
||||
.orderBy(desc(briefs.createdAt));
|
||||
}
|
||||
|
||||
async findByCronJobId(cronJobId: string): Promise<BriefItem[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(briefs)
|
||||
.where(and(eq(briefs.cronJobId, cronJobId), eq(briefs.userId, this.userId)))
|
||||
.orderBy(desc(briefs.createdAt));
|
||||
}
|
||||
|
||||
async markRead(id: string): Promise<BriefItem | null> {
|
||||
const result = await this.db
|
||||
.update(briefs)
|
||||
.set({ readAt: new Date() })
|
||||
.where(and(eq(briefs.id, id), eq(briefs.userId, this.userId)))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async resolve(
|
||||
id: string,
|
||||
options?: { action?: string; comment?: string },
|
||||
): Promise<BriefItem | null> {
|
||||
const result = await this.db
|
||||
.update(briefs)
|
||||
.set({
|
||||
readAt: new Date(),
|
||||
resolvedAction: options?.action,
|
||||
resolvedAt: new Date(),
|
||||
resolvedComment: options?.comment,
|
||||
})
|
||||
.where(and(eq(briefs.id, id), eq(briefs.userId, this.userId)))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(briefs)
|
||||
.where(and(eq(briefs.id, id), eq(briefs.userId, this.userId)))
|
||||
.returning();
|
||||
|
||||
return result.length > 0;
|
||||
}
|
||||
}
|
||||
523
packages/database/src/models/task.ts
Normal file
523
packages/database/src/models/task.ts
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
import type {
|
||||
CheckpointConfig,
|
||||
WorkspaceData,
|
||||
WorkspaceDocNode,
|
||||
WorkspaceTreeNode,
|
||||
} from '@lobechat/types';
|
||||
import { and, desc, eq, inArray, isNotNull, isNull, ne, sql } from 'drizzle-orm';
|
||||
|
||||
import type { NewTask, NewTaskComment, TaskCommentItem, TaskItem } from '../schemas/task';
|
||||
import { taskComments, taskDependencies, taskDocuments, tasks } from '../schemas/task';
|
||||
import type { LobeChatDatabase } from '../type';
|
||||
|
||||
export class TaskModel {
|
||||
private readonly userId: string;
|
||||
private readonly db: LobeChatDatabase;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
// ========== CRUD ==========
|
||||
|
||||
async create(
|
||||
data: Omit<NewTask, 'id' | 'identifier' | 'seq' | 'createdByUserId'> & {
|
||||
identifierPrefix?: string;
|
||||
},
|
||||
): Promise<TaskItem> {
|
||||
const { identifierPrefix = 'T', ...rest } = data;
|
||||
|
||||
// Retry loop to handle concurrent creates (parallel tool calls)
|
||||
const maxRetries = 5;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
// Get next seq for this user
|
||||
const seqResult = await this.db
|
||||
.select({ maxSeq: sql<number>`COALESCE(MAX(${tasks.seq}), 0)` })
|
||||
.from(tasks)
|
||||
.where(eq(tasks.createdByUserId, this.userId));
|
||||
|
||||
const nextSeq = Number(seqResult[0].maxSeq) + 1;
|
||||
const identifier = `${identifierPrefix}-${nextSeq}`;
|
||||
|
||||
const result = await this.db
|
||||
.insert(tasks)
|
||||
.values({
|
||||
...rest,
|
||||
createdByUserId: this.userId,
|
||||
identifier,
|
||||
seq: nextSeq,
|
||||
} as NewTask)
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
} catch (error: any) {
|
||||
// Retry on unique constraint violation (concurrent seq conflict)
|
||||
// Check error itself, cause, and stringified message for PG error code 23505
|
||||
const errStr =
|
||||
String(error?.message || '') +
|
||||
String(error?.cause?.code || '') +
|
||||
String(error?.code || '');
|
||||
const isUniqueViolation =
|
||||
errStr.includes('23505') || errStr.includes('unique') || errStr.includes('duplicate');
|
||||
if (isUniqueViolation && attempt < maxRetries - 1) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to create task after max retries');
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<TaskItem | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), eq(tasks.createdByUserId, this.userId)))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByIds(ids: string[]): Promise<TaskItem[]> {
|
||||
if (ids.length === 0) return [];
|
||||
return this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(inArray(tasks.id, ids), eq(tasks.createdByUserId, this.userId)));
|
||||
}
|
||||
|
||||
// Resolve id or identifier (e.g. 'T-1') to a task
|
||||
async resolve(idOrIdentifier: string): Promise<TaskItem | null> {
|
||||
if (idOrIdentifier.startsWith('task_')) return this.findById(idOrIdentifier);
|
||||
return this.findByIdentifier(idOrIdentifier.toUpperCase());
|
||||
}
|
||||
|
||||
async findByIdentifier(identifier: string): Promise<TaskItem | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.identifier, identifier), eq(tasks.createdByUserId, this.userId)))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Omit<NewTask, 'id' | 'identifier' | 'seq' | 'createdByUserId'>>,
|
||||
): Promise<TaskItem | null> {
|
||||
const result = await this.db
|
||||
.update(tasks)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(tasks.id, id), eq(tasks.createdByUserId, this.userId)))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(tasks)
|
||||
.where(and(eq(tasks.id, id), eq(tasks.createdByUserId, this.userId)))
|
||||
.returning();
|
||||
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
async deleteAll(): Promise<number> {
|
||||
const result = await this.db
|
||||
.delete(tasks)
|
||||
.where(eq(tasks.createdByUserId, this.userId))
|
||||
.returning();
|
||||
|
||||
return result.length;
|
||||
}
|
||||
|
||||
// ========== Query ==========
|
||||
|
||||
async list(options?: {
|
||||
assigneeAgentId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
parentTaskId?: string | null;
|
||||
status?: string;
|
||||
}): Promise<{ tasks: TaskItem[]; total: number }> {
|
||||
const { status, parentTaskId, assigneeAgentId, limit = 50, offset = 0 } = options || {};
|
||||
|
||||
const conditions = [eq(tasks.createdByUserId, this.userId)];
|
||||
|
||||
if (status) conditions.push(eq(tasks.status, status));
|
||||
if (assigneeAgentId) conditions.push(eq(tasks.assigneeAgentId, assigneeAgentId));
|
||||
|
||||
if (parentTaskId === null) {
|
||||
conditions.push(isNull(tasks.parentTaskId));
|
||||
} else if (parentTaskId) {
|
||||
conditions.push(eq(tasks.parentTaskId, parentTaskId));
|
||||
}
|
||||
|
||||
const where = and(...conditions);
|
||||
|
||||
const countResult = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tasks)
|
||||
.where(where);
|
||||
|
||||
const taskList = await this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(where)
|
||||
.orderBy(desc(tasks.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return { tasks: taskList, total: Number(countResult[0].count) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update sortOrder for multiple tasks.
|
||||
* @param order Array of { id, sortOrder } pairs
|
||||
*/
|
||||
async reorder(order: Array<{ id: string; sortOrder: number }>): Promise<void> {
|
||||
for (const item of order) {
|
||||
await this.db
|
||||
.update(tasks)
|
||||
.set({ sortOrder: item.sortOrder, updatedAt: new Date() })
|
||||
.where(and(eq(tasks.id, item.id), eq(tasks.createdByUserId, this.userId)));
|
||||
}
|
||||
}
|
||||
|
||||
async findSubtasks(parentTaskId: string): Promise<TaskItem[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.parentTaskId, parentTaskId), eq(tasks.createdByUserId, this.userId)))
|
||||
.orderBy(tasks.sortOrder, tasks.seq);
|
||||
}
|
||||
|
||||
// Recursive query to get full task tree
|
||||
async getTaskTree(rootTaskId: string): Promise<TaskItem[]> {
|
||||
const result = await this.db.execute(sql`
|
||||
WITH RECURSIVE task_tree AS (
|
||||
SELECT * FROM tasks WHERE id = ${rootTaskId} AND created_by_user_id = ${this.userId}
|
||||
UNION ALL
|
||||
SELECT t.* FROM tasks t
|
||||
JOIN task_tree tt ON t.parent_task_id = tt.id
|
||||
)
|
||||
SELECT * FROM task_tree
|
||||
`);
|
||||
|
||||
return result.rows as TaskItem[];
|
||||
}
|
||||
|
||||
// ========== Status ==========
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: string,
|
||||
extra?: { completedAt?: Date; error?: string | null; startedAt?: Date },
|
||||
): Promise<TaskItem | null> {
|
||||
return this.update(id, { status, ...extra });
|
||||
}
|
||||
|
||||
async batchUpdateStatus(ids: string[], status: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.update(tasks)
|
||||
.set({ status, updatedAt: new Date() })
|
||||
.where(and(inArray(tasks.id, ids), eq(tasks.createdByUserId, this.userId)))
|
||||
.returning();
|
||||
|
||||
return result.length;
|
||||
}
|
||||
|
||||
// ========== Checkpoint ==========
|
||||
|
||||
getCheckpointConfig(task: TaskItem): CheckpointConfig {
|
||||
return (task.config as Record<string, any>)?.checkpoint || {};
|
||||
}
|
||||
|
||||
async updateCheckpointConfig(id: string, checkpoint: CheckpointConfig): Promise<TaskItem | null> {
|
||||
const task = await this.findById(id);
|
||||
if (!task) return null;
|
||||
|
||||
const config = { ...(task.config as Record<string, any>), checkpoint };
|
||||
return this.update(id, { config });
|
||||
}
|
||||
|
||||
// ========== Review Config ==========
|
||||
|
||||
getReviewConfig(task: TaskItem): Record<string, any> | undefined {
|
||||
return (task.config as Record<string, any>)?.review;
|
||||
}
|
||||
|
||||
async updateReviewConfig(id: string, review: Record<string, any>): Promise<TaskItem | null> {
|
||||
const task = await this.findById(id);
|
||||
if (!task) return null;
|
||||
|
||||
const config = { ...(task.config as Record<string, any>), review };
|
||||
return this.update(id, { config });
|
||||
}
|
||||
|
||||
// Check if a task should pause after a topic completes
|
||||
// Default: pause (when no checkpoint config is set)
|
||||
// Explicit: pause only if topic.after is true
|
||||
shouldPauseOnTopicComplete(task: TaskItem): boolean {
|
||||
const checkpoint = this.getCheckpointConfig(task);
|
||||
const hasAnyConfig = Object.keys(checkpoint).length > 0;
|
||||
return hasAnyConfig ? !!checkpoint.topic?.after : true;
|
||||
}
|
||||
|
||||
// Check if a task should be paused before starting (parent's tasks.beforeIds)
|
||||
shouldPauseBeforeStart(parentTask: TaskItem, childIdentifier: string): boolean {
|
||||
const checkpoint = this.getCheckpointConfig(parentTask);
|
||||
return checkpoint.tasks?.beforeIds?.includes(childIdentifier) ?? false;
|
||||
}
|
||||
|
||||
// Check if a task should be paused after completing (parent's tasks.afterIds)
|
||||
shouldPauseAfterComplete(parentTask: TaskItem, childIdentifier: string): boolean {
|
||||
const checkpoint = this.getCheckpointConfig(parentTask);
|
||||
return checkpoint.tasks?.afterIds?.includes(childIdentifier) ?? false;
|
||||
}
|
||||
|
||||
// ========== Heartbeat ==========
|
||||
|
||||
async updateHeartbeat(id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(tasks)
|
||||
.set({ lastHeartbeatAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(tasks.id, id));
|
||||
}
|
||||
|
||||
// Find stuck tasks (running but heartbeat timed out)
|
||||
// Only checks tasks that have both lastHeartbeatAt and heartbeatTimeout set
|
||||
static async findStuckTasks(db: LobeChatDatabase): Promise<TaskItem[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(
|
||||
and(
|
||||
eq(tasks.status, 'running'),
|
||||
isNotNull(tasks.lastHeartbeatAt),
|
||||
isNotNull(tasks.heartbeatTimeout),
|
||||
sql`${tasks.lastHeartbeatAt} < now() - make_interval(secs => ${tasks.heartbeatTimeout})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Dependencies ==========
|
||||
|
||||
async addDependency(taskId: string, dependsOnId: string, type: string = 'blocks'): Promise<void> {
|
||||
await this.db
|
||||
.insert(taskDependencies)
|
||||
.values({ dependsOnId, taskId, type, userId: this.userId })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async removeDependency(taskId: string, dependsOnId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(taskDependencies)
|
||||
.where(
|
||||
and(eq(taskDependencies.taskId, taskId), eq(taskDependencies.dependsOnId, dependsOnId)),
|
||||
);
|
||||
}
|
||||
|
||||
async getDependencies(taskId: string) {
|
||||
return this.db.select().from(taskDependencies).where(eq(taskDependencies.taskId, taskId));
|
||||
}
|
||||
|
||||
async getDependenciesByTaskIds(taskIds: string[]) {
|
||||
if (taskIds.length === 0) return [];
|
||||
return this.db.select().from(taskDependencies).where(inArray(taskDependencies.taskId, taskIds));
|
||||
}
|
||||
|
||||
async getDependents(taskId: string) {
|
||||
return this.db.select().from(taskDependencies).where(eq(taskDependencies.dependsOnId, taskId));
|
||||
}
|
||||
|
||||
// Check if all dependencies of a task are completed
|
||||
async areAllDependenciesCompleted(taskId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(taskDependencies)
|
||||
.innerJoin(tasks, eq(taskDependencies.dependsOnId, tasks.id))
|
||||
.where(
|
||||
and(
|
||||
eq(taskDependencies.taskId, taskId),
|
||||
eq(taskDependencies.type, 'blocks'),
|
||||
ne(tasks.status, 'completed'),
|
||||
),
|
||||
);
|
||||
|
||||
return Number(result[0].count) === 0;
|
||||
}
|
||||
|
||||
// Find tasks that are now unblocked after a dependency completes
|
||||
async getUnlockedTasks(completedTaskId: string): Promise<TaskItem[]> {
|
||||
// Find all tasks that depend on the completed task
|
||||
const dependents = await this.getDependents(completedTaskId);
|
||||
const unlocked: TaskItem[] = [];
|
||||
|
||||
for (const dep of dependents) {
|
||||
if (dep.type !== 'blocks') continue;
|
||||
|
||||
// Check if ALL dependencies of this task are now completed
|
||||
const allDone = await this.areAllDependenciesCompleted(dep.taskId);
|
||||
if (!allDone) continue;
|
||||
|
||||
// Get the task itself — only unlock if it's in backlog
|
||||
const task = await this.findById(dep.taskId);
|
||||
if (task && task.status === 'backlog') {
|
||||
unlocked.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
// Check if all subtasks of a parent task are completed
|
||||
async areAllSubtasksCompleted(parentTaskId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tasks)
|
||||
.where(
|
||||
and(
|
||||
eq(tasks.parentTaskId, parentTaskId),
|
||||
ne(tasks.status, 'completed'),
|
||||
eq(tasks.createdByUserId, this.userId),
|
||||
),
|
||||
);
|
||||
|
||||
return Number(result[0].count) === 0;
|
||||
}
|
||||
|
||||
// ========== Documents (MVP Workspace) ==========
|
||||
|
||||
async pinDocument(taskId: string, documentId: string, pinnedBy: string = 'agent'): Promise<void> {
|
||||
await this.db
|
||||
.insert(taskDocuments)
|
||||
.values({ documentId, pinnedBy, taskId, userId: this.userId })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async unpinDocument(taskId: string, documentId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(taskDocuments)
|
||||
.where(and(eq(taskDocuments.taskId, taskId), eq(taskDocuments.documentId, documentId)));
|
||||
}
|
||||
|
||||
async getPinnedDocuments(taskId: string) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(taskDocuments)
|
||||
.where(eq(taskDocuments.taskId, taskId))
|
||||
.orderBy(taskDocuments.createdAt);
|
||||
}
|
||||
|
||||
// Get all pinned docs from a task tree (recursive), returns nodeMap + tree structure
|
||||
async getTreePinnedDocuments(rootTaskId: string): Promise<WorkspaceData> {
|
||||
const result = await this.db.execute(sql`
|
||||
WITH RECURSIVE task_tree AS (
|
||||
SELECT id, identifier FROM tasks WHERE id = ${rootTaskId}
|
||||
UNION ALL
|
||||
SELECT t.id, t.identifier FROM tasks t
|
||||
JOIN task_tree tt ON t.parent_task_id = tt.id
|
||||
)
|
||||
SELECT td.*, tt.id as source_task_id, tt.identifier as source_task_identifier,
|
||||
d.title as document_title, d.file_type as document_file_type, d.parent_id as document_parent_id,
|
||||
d.total_char_count as document_char_count, d.updated_at as document_updated_at
|
||||
FROM task_documents td
|
||||
JOIN task_tree tt ON td.task_id = tt.id
|
||||
LEFT JOIN documents d ON td.document_id = d.id
|
||||
ORDER BY td.created_at
|
||||
`);
|
||||
|
||||
// Build nodeMap
|
||||
const nodeMap: Record<string, WorkspaceDocNode> = {};
|
||||
|
||||
const docIds = new Set<string>();
|
||||
|
||||
for (const row of result.rows as any[]) {
|
||||
const docId = row.document_id;
|
||||
docIds.add(docId);
|
||||
nodeMap[docId] = {
|
||||
charCount: row.document_char_count,
|
||||
createdAt: row.created_at,
|
||||
fileType: row.document_file_type,
|
||||
parentId: row.document_parent_id,
|
||||
pinnedBy: row.pinned_by,
|
||||
sourceTaskIdentifier: row.source_task_id !== rootTaskId ? row.source_task_identifier : null,
|
||||
title: row.document_title || 'Untitled',
|
||||
updatedAt: row.document_updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// Build tree (children as id references)
|
||||
type TreeNode = WorkspaceTreeNode;
|
||||
|
||||
const childrenMap = new Map<string | null, TreeNode[]>();
|
||||
for (const docId of docIds) {
|
||||
const node = nodeMap[docId];
|
||||
const parentId = node.parentId && docIds.has(node.parentId) ? node.parentId : null;
|
||||
const list = childrenMap.get(parentId) || [];
|
||||
list.push({ children: [], id: docId });
|
||||
childrenMap.set(parentId, list);
|
||||
}
|
||||
|
||||
const buildTree = (parentId: string | null): TreeNode[] => {
|
||||
const nodes = childrenMap.get(parentId) || [];
|
||||
for (const node of nodes) {
|
||||
node.children = buildTree(node.id);
|
||||
}
|
||||
return nodes;
|
||||
};
|
||||
|
||||
return { nodeMap, tree: buildTree(null) };
|
||||
}
|
||||
|
||||
// ========== Topic Management ==========
|
||||
|
||||
async incrementTopicCount(id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(tasks)
|
||||
.set({
|
||||
totalTopics: sql`${tasks.totalTopics} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tasks.id, id));
|
||||
}
|
||||
|
||||
async updateCurrentTopic(id: string, topicId: string): Promise<void> {
|
||||
await this.db
|
||||
.update(tasks)
|
||||
.set({ currentTopicId: topicId, updatedAt: new Date() })
|
||||
.where(eq(tasks.id, id));
|
||||
}
|
||||
|
||||
// ========== Comments ==========
|
||||
|
||||
async addComment(data: Omit<NewTaskComment, 'id'>): Promise<TaskCommentItem> {
|
||||
const [comment] = await this.db.insert(taskComments).values(data).returning();
|
||||
return comment;
|
||||
}
|
||||
|
||||
// ========== Comments ==========
|
||||
|
||||
async getComments(taskId: string): Promise<TaskCommentItem[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(taskComments)
|
||||
.where(eq(taskComments.taskId, taskId))
|
||||
.orderBy(taskComments.createdAt);
|
||||
}
|
||||
|
||||
async deleteComment(id: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(taskComments)
|
||||
.where(and(eq(taskComments.id, id), eq(taskComments.userId, this.userId)))
|
||||
|
||||
.returning();
|
||||
return result.length > 0;
|
||||
}
|
||||
}
|
||||
197
packages/database/src/models/taskTopic.ts
Normal file
197
packages/database/src/models/taskTopic.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import type { TaskTopicHandoff } from '@lobechat/types';
|
||||
import { and, desc, eq, sql } from 'drizzle-orm';
|
||||
|
||||
import type { TaskTopicItem } from '../schemas/task';
|
||||
import { tasks, taskTopics } from '../schemas/task';
|
||||
import type { LobeChatDatabase } from '../type';
|
||||
|
||||
export class TaskTopicModel {
|
||||
private readonly userId: string;
|
||||
private readonly db: LobeChatDatabase;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
async add(
|
||||
taskId: string,
|
||||
topicId: string,
|
||||
params: { operationId?: string; seq: number },
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.insert(taskTopics)
|
||||
.values({
|
||||
operationId: params.operationId,
|
||||
seq: params.seq,
|
||||
taskId,
|
||||
topicId,
|
||||
userId: this.userId,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async updateStatus(taskId: string, topicId: string, status: string): Promise<void> {
|
||||
await this.db
|
||||
.update(taskTopics)
|
||||
.set({ status })
|
||||
.where(
|
||||
and(
|
||||
eq(taskTopics.taskId, taskId),
|
||||
eq(taskTopics.topicId, topicId),
|
||||
eq(taskTopics.userId, this.userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateOperationId(taskId: string, topicId: string, operationId?: string): Promise<void> {
|
||||
await this.db
|
||||
.update(taskTopics)
|
||||
.set({ operationId })
|
||||
.where(
|
||||
and(
|
||||
eq(taskTopics.taskId, taskId),
|
||||
eq(taskTopics.topicId, topicId),
|
||||
eq(taskTopics.userId, this.userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateHandoff(taskId: string, topicId: string, handoff: TaskTopicHandoff): Promise<void> {
|
||||
await this.db
|
||||
.update(taskTopics)
|
||||
.set({ handoff })
|
||||
.where(
|
||||
and(
|
||||
eq(taskTopics.taskId, taskId),
|
||||
eq(taskTopics.topicId, topicId),
|
||||
eq(taskTopics.userId, this.userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateReview(
|
||||
taskId: string,
|
||||
topicId: string,
|
||||
review: {
|
||||
iteration: number;
|
||||
passed: boolean;
|
||||
score: number;
|
||||
scores: any[];
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.update(taskTopics)
|
||||
.set({
|
||||
reviewIteration: review.iteration,
|
||||
reviewPassed: review.passed ? 1 : 0,
|
||||
reviewScore: review.score,
|
||||
reviewScores: review.scores,
|
||||
reviewedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(taskTopics.taskId, taskId),
|
||||
eq(taskTopics.topicId, topicId),
|
||||
eq(taskTopics.userId, this.userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async timeoutRunning(taskId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.update(taskTopics)
|
||||
.set({ status: 'timeout' })
|
||||
.where(
|
||||
and(
|
||||
eq(taskTopics.taskId, taskId),
|
||||
eq(taskTopics.status, 'running'),
|
||||
eq(taskTopics.userId, this.userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return result.length;
|
||||
}
|
||||
|
||||
async findByTopicId(topicId: string): Promise<TaskTopicItem | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(taskTopics)
|
||||
.where(and(eq(taskTopics.topicId, topicId), eq(taskTopics.userId, this.userId)))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByTaskId(taskId: string): Promise<TaskTopicItem[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(taskTopics)
|
||||
.where(and(eq(taskTopics.taskId, taskId), eq(taskTopics.userId, this.userId)))
|
||||
.orderBy(desc(taskTopics.seq));
|
||||
}
|
||||
|
||||
async findWithDetails(taskId: string) {
|
||||
const { topics } = await import('../schemas/topic');
|
||||
return this.db
|
||||
.select({
|
||||
createdAt: topics.createdAt,
|
||||
handoff: taskTopics.handoff,
|
||||
id: topics.id,
|
||||
metadata: topics.metadata,
|
||||
operationId: taskTopics.operationId,
|
||||
reviewIteration: taskTopics.reviewIteration,
|
||||
reviewPassed: taskTopics.reviewPassed,
|
||||
reviewScore: taskTopics.reviewScore,
|
||||
reviewScores: taskTopics.reviewScores,
|
||||
reviewedAt: taskTopics.reviewedAt,
|
||||
seq: taskTopics.seq,
|
||||
status: taskTopics.status,
|
||||
title: topics.title,
|
||||
updatedAt: topics.updatedAt,
|
||||
})
|
||||
.from(taskTopics)
|
||||
.innerJoin(topics, eq(taskTopics.topicId, topics.id))
|
||||
.where(and(eq(taskTopics.taskId, taskId), eq(taskTopics.userId, this.userId)))
|
||||
.orderBy(desc(taskTopics.seq));
|
||||
}
|
||||
|
||||
async findWithHandoff(taskId: string, limit = 4) {
|
||||
return this.db
|
||||
.select({
|
||||
createdAt: taskTopics.createdAt,
|
||||
handoff: taskTopics.handoff,
|
||||
seq: taskTopics.seq,
|
||||
status: taskTopics.status,
|
||||
topicId: taskTopics.topicId,
|
||||
})
|
||||
.from(taskTopics)
|
||||
.where(and(eq(taskTopics.taskId, taskId), eq(taskTopics.userId, this.userId)))
|
||||
.orderBy(desc(taskTopics.seq))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async remove(taskId: string, topicId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(taskTopics)
|
||||
.where(
|
||||
and(
|
||||
eq(taskTopics.taskId, taskId),
|
||||
eq(taskTopics.topicId, topicId),
|
||||
eq(taskTopics.userId, this.userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (result.length > 0) {
|
||||
await this.db
|
||||
.update(tasks)
|
||||
.set({
|
||||
totalTopics: sql`GREATEST(${tasks.totalTopics} - 1, 0)`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tasks.id, taskId));
|
||||
}
|
||||
|
||||
return result.length > 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,4 +10,5 @@ export * from './summaryGenerationTitle';
|
|||
export * from './summaryHistory';
|
||||
export * from './summaryTags';
|
||||
export * from './summaryTitle';
|
||||
export * from './taskTopicHandoff';
|
||||
export * from './translate';
|
||||
|
|
|
|||
57
packages/prompts/src/chains/taskTopicHandoff.ts
Normal file
57
packages/prompts/src/chains/taskTopicHandoff.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { ChatStreamPayload } from '@lobechat/types';
|
||||
|
||||
/**
|
||||
* Generate a handoff summary for a completed task topic.
|
||||
* Returns both a title and structured handoff data for the next topic.
|
||||
*
|
||||
* Input: the last assistant message content from the topic.
|
||||
* Output: JSON with { title, summary, keyFindings?, nextAction? }
|
||||
*/
|
||||
export const chainTaskTopicHandoff = (params: {
|
||||
lastAssistantContent: string;
|
||||
taskInstruction: string;
|
||||
taskName: string;
|
||||
}): Partial<ChatStreamPayload> => ({
|
||||
messages: [
|
||||
{
|
||||
content: `You are a task execution summarizer. A topic (one round of agent execution) has just completed within a task. Generate a handoff summary for the next topic to read.
|
||||
|
||||
Output a JSON object with these fields:
|
||||
- "title": A concise title summarizing what this topic accomplished (max 50 chars, same language as content)
|
||||
- "summary": A 1-3 sentence summary of what was done and the key outcome
|
||||
- "keyFindings": An array of key findings or decisions made (optional, max 5 items)
|
||||
- "nextAction": What the next topic should do (optional, 1 sentence)
|
||||
|
||||
Rules:
|
||||
- Focus on WHAT WAS ACCOMPLISHED, not what was asked
|
||||
- Use the same language as the content
|
||||
- Keep title short and specific (e.g. "制定8章书籍大纲" not "完成任务")
|
||||
- summary should capture the essential outcome a new topic needs to know
|
||||
- Output ONLY the JSON object, no markdown fences or explanations`,
|
||||
role: 'system',
|
||||
},
|
||||
{
|
||||
content: `Task: ${params.taskName}
|
||||
Task instruction: ${params.taskInstruction}
|
||||
|
||||
Last assistant response:
|
||||
${params.lastAssistantContent}`,
|
||||
role: 'user',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const TASK_TOPIC_HANDOFF_SCHEMA = {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
keyFindings: {
|
||||
items: { type: 'string' },
|
||||
type: 'array',
|
||||
},
|
||||
nextAction: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
},
|
||||
required: ['title', 'summary'],
|
||||
type: 'object' as const,
|
||||
};
|
||||
|
|
@ -16,5 +16,6 @@ export * from './search';
|
|||
export * from './skills';
|
||||
export * from './speaker';
|
||||
export * from './systemRole';
|
||||
export * from './task';
|
||||
export * from './toolDiscovery';
|
||||
export * from './userMemory';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`buildTaskRunPrompt > should build prompt with only task instruction 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 帮我写一本 AI Agent 技术书籍
|
||||
|
||||
Review: (not configured)
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should build prompt with task description + instruction 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 帮我写一本 AI Agent 技术书籍,目标 8 章
|
||||
Description: 面向开发者的技术书籍
|
||||
|
||||
Review: (not configured)
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should handle empty activities gracefully 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Review: (not configured)
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should handle full scenario with all sections 1`] = `
|
||||
"<high_priority_instruction>
|
||||
这次直接开始写第1章
|
||||
</high_priority_instruction>
|
||||
|
||||
<user_feedback>
|
||||
<comment time="3h ago">第5章后移,增加评测章节</comment>
|
||||
</user_feedback>
|
||||
|
||||
<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 写一本 AI Agent 书,目标 8 章
|
||||
Description: 面向开发者的 AI Agent 技术书籍
|
||||
Topics: 1
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💬 19h ago Topic #1 制定大纲 ✓ completed tpc_001
|
||||
📋 18h ago Brief [decision] 大纲完成 ✅ approve brief_001
|
||||
💭 3h ago 👤 user 第5章后移,增加评测章节
|
||||
💭 2h ago 🤖 agent 已调整大纲
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should include activity history with topics and briefs in CLI style 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 写书
|
||||
Topics: 2
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💬 19h ago Topic #1 制定大纲 ✓ completed tpc_aaa
|
||||
📋 18h ago Brief [decision] 大纲完成 [urgent] ✅ approve brief_abc123
|
||||
💬 18h ago Topic #2 修订大纲 ✓ completed tpc_bbb
|
||||
📋 18h ago Brief [decision] 建议拆分第4章 [normal] brief_def456
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should include agent comments with author label and time 1`] = `
|
||||
"<user_feedback>
|
||||
<comment time="2h ago">确认,开始写</comment>
|
||||
</user_feedback>
|
||||
|
||||
<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💭 3h ago 🤖 agent 大纲已完成,请确认
|
||||
💭 2h ago 👤 user 确认,开始写
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should include parentTask context for subtasks 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-4 第2章 核心架构
|
||||
Status: ● running Priority: -
|
||||
Instruction: 撰写第2章
|
||||
Parent: TASK-1
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
<parentTask identifier="TASK-1" name="写一本书">
|
||||
Instruction: 写一本 AI Agent 书,目标 8 章
|
||||
Subtasks (3):
|
||||
TASK-2 ✓ completed 第1章 概述
|
||||
TASK-4 ● running 第2章 核心架构 ← blocks: TASK-2 ◀ current
|
||||
TASK-6 ○ backlog 第3章 手写 Agent ← blocks: TASK-4
|
||||
</parentTask>
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should include subtasks in task section 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Subtasks:
|
||||
TASK-2 ✓ completed 第1章 Agent 概述
|
||||
TASK-3 ○ backlog 第2章 快速上手 ← blocks: TASK-2
|
||||
|
||||
Review: (not configured)
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should include subtasks in task tag when provided 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 写一本 AI Agent 书,目标 8 章
|
||||
|
||||
Subtasks:
|
||||
TASK-2 ● running 第1章 AI Agent 概述
|
||||
TASK-3 ○ backlog 第2章 核心架构
|
||||
TASK-4 ○ backlog 第3章 手写 Agent
|
||||
|
||||
Review: (not configured)
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should only include user comments in user_feedback, not agent comments 1`] = `
|
||||
"<user_feedback>
|
||||
<comment time="2h ago">用户反馈</comment>
|
||||
</user_feedback>
|
||||
|
||||
<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💭 2h ago 👤 user 用户反馈
|
||||
💭 1h ago 🤖 agent Agent 回复
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should place high_priority_instruction first, then feedback 1`] = `
|
||||
"<high_priority_instruction>
|
||||
这次重点关注第3章
|
||||
</high_priority_instruction>
|
||||
|
||||
<user_feedback>
|
||||
<comment time="1h ago">用户反馈</comment>
|
||||
</user_feedback>
|
||||
|
||||
<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💭 1h ago 👤 user 用户反馈
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should prioritize user feedback at the top 1`] = `
|
||||
"<user_feedback>
|
||||
<comment time="2h ago">第2章改为先上手再讲原理</comment>
|
||||
<comment time="1h ago">增加评测章节</comment>
|
||||
</user_feedback>
|
||||
|
||||
<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💭 2h ago 👤 user 第2章改为先上手再讲原理
|
||||
💭 1h ago 👤 user 增加评测章节
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should show resolved action and comment on briefs 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-2 第2章
|
||||
Status: ● running Priority: -
|
||||
Instruction: 写第2章
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
✅ 19h ago Brief [result] 第2章完成 ✏️ 第2章需要更多实例
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should truncate comments to 80 chars in activities but keep full in user_feedback 1`] = `
|
||||
"<user_feedback>
|
||||
<comment time="1h ago">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA — this part should be truncated in activities</comment>
|
||||
</user_feedback>
|
||||
|
||||
<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💭 1h ago 👤 user AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...
|
||||
</task>"
|
||||
`;
|
||||
465
packages/prompts/src/prompts/task/index.test.ts
Normal file
465
packages/prompts/src/prompts/task/index.test.ts
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTaskRunPrompt } from './index';
|
||||
|
||||
// Fixed reference time for stable timeAgo output
|
||||
const NOW = new Date('2026-03-22T12:00:00Z');
|
||||
|
||||
const baseTask = { id: 'task_test', status: 'running' };
|
||||
|
||||
describe('buildTaskRunPrompt', () => {
|
||||
it('should build prompt with only task instruction', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
task: {
|
||||
...baseTask,
|
||||
identifier: 'TASK-1',
|
||||
instruction: '帮我写一本 AI Agent 技术书籍',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should build prompt with task description + instruction', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
task: {
|
||||
...baseTask,
|
||||
description: '面向开发者的技术书籍',
|
||||
identifier: 'TASK-1',
|
||||
instruction: '帮我写一本 AI Agent 技术书籍,目标 8 章',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should prioritize user feedback at the top', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
comments: [
|
||||
{ content: '第2章改为先上手再讲原理', createdAt: '2026-03-22T10:00:00Z' },
|
||||
{ content: '增加评测章节', createdAt: '2026-03-22T10:30:00Z' },
|
||||
],
|
||||
},
|
||||
task: {
|
||||
...baseTask,
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
// Verify feedback comes before task
|
||||
const feedbackIdx = result.indexOf('<user_feedback>');
|
||||
const taskIdx = result.indexOf('<task');
|
||||
expect(feedbackIdx).toBeLessThan(taskIdx);
|
||||
});
|
||||
|
||||
it('should include agent comments with author label and time', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
comments: [
|
||||
{
|
||||
agentId: 'agt_xxx',
|
||||
content: '大纲已完成,请确认',
|
||||
createdAt: '2026-03-22T09:00:00Z',
|
||||
},
|
||||
{ content: '确认,开始写', createdAt: '2026-03-22T10:00:00Z' },
|
||||
],
|
||||
},
|
||||
task: {
|
||||
...baseTask,
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(result).toContain('🤖 agent');
|
||||
expect(result).toContain('👤 user');
|
||||
expect(result).toContain('3h ago');
|
||||
expect(result).toContain('2h ago');
|
||||
});
|
||||
|
||||
it('should place high_priority_instruction first, then feedback', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
comments: [{ content: '用户反馈', createdAt: '2026-03-22T11:00:00Z' }],
|
||||
},
|
||||
extraPrompt: '这次重点关注第3章',
|
||||
task: {
|
||||
...baseTask,
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
const feedbackIdx = result.indexOf('<user_feedback>');
|
||||
const extraIdx = result.indexOf('<high_priority_instruction>');
|
||||
const taskIdx = result.indexOf('<task');
|
||||
expect(extraIdx).toBeLessThan(feedbackIdx);
|
||||
expect(feedbackIdx).toBeLessThan(taskIdx);
|
||||
});
|
||||
|
||||
it('should include activity history with topics and briefs in CLI style', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
briefs: [
|
||||
{
|
||||
createdAt: '2026-03-21T17:05:00Z',
|
||||
id: 'brief_abc123',
|
||||
priority: 'urgent',
|
||||
resolvedAction: 'approve',
|
||||
resolvedAt: '2026-03-21T17:30:00Z',
|
||||
summary: '8章大纲已制定完成',
|
||||
title: '大纲完成',
|
||||
type: 'decision',
|
||||
},
|
||||
{
|
||||
createdAt: '2026-03-21T18:00:00Z',
|
||||
id: 'brief_def456',
|
||||
priority: 'normal',
|
||||
resolvedAt: null,
|
||||
summary: '第4章内容过多,建议拆分',
|
||||
title: '建议拆分第4章',
|
||||
type: 'decision',
|
||||
},
|
||||
],
|
||||
topics: [
|
||||
{
|
||||
createdAt: '2026-03-21T17:00:00Z',
|
||||
handoff: { summary: '完成了大纲制定' },
|
||||
id: 'tpc_aaa',
|
||||
seq: 1,
|
||||
status: 'completed',
|
||||
title: '制定大纲',
|
||||
},
|
||||
{
|
||||
createdAt: '2026-03-21T17:31:00Z',
|
||||
handoff: { summary: '修订了大纲并拆分子任务' },
|
||||
id: 'tpc_bbb',
|
||||
seq: 2,
|
||||
status: 'completed',
|
||||
title: '修订大纲',
|
||||
},
|
||||
],
|
||||
},
|
||||
task: {
|
||||
...baseTask,
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
// Verify timeline is sorted chronologically (oldest first)
|
||||
// Data: topic1(17:00), brief1(17:05), topic2(17:31), brief2(18:00)
|
||||
const taskSection = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
|
||||
const topic1Idx = taskSection.indexOf('Topic #1');
|
||||
const brief1Idx = taskSection.indexOf('brief_abc123');
|
||||
const topic2Idx = taskSection.indexOf('Topic #2');
|
||||
const brief2Idx = taskSection.indexOf('brief_def456');
|
||||
expect(topic1Idx).toBeLessThan(brief1Idx);
|
||||
expect(brief1Idx).toBeLessThan(topic2Idx);
|
||||
expect(topic2Idx).toBeLessThan(brief2Idx);
|
||||
});
|
||||
|
||||
it('should show resolved action and comment on briefs', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
briefs: [
|
||||
{
|
||||
createdAt: '2026-03-21T17:00:00Z',
|
||||
resolvedAction: 'feedback',
|
||||
resolvedAt: '2026-03-21T18:00:00Z',
|
||||
resolvedComment: '第2章需要更多实例',
|
||||
summary: '第2章初稿完成',
|
||||
title: '第2章完成',
|
||||
type: 'result',
|
||||
},
|
||||
],
|
||||
},
|
||||
task: {
|
||||
...baseTask,
|
||||
identifier: 'TASK-2',
|
||||
instruction: '写第2章',
|
||||
name: '第2章',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(result).toContain('第2章需要更多实例');
|
||||
});
|
||||
|
||||
it('should handle full scenario with all sections', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
briefs: [
|
||||
{
|
||||
createdAt: '2026-03-21T17:05:00Z',
|
||||
id: 'brief_001',
|
||||
resolvedAction: 'approve',
|
||||
resolvedAt: '2026-03-21T17:30:00Z',
|
||||
summary: '大纲已完成',
|
||||
title: '大纲完成',
|
||||
type: 'decision',
|
||||
},
|
||||
],
|
||||
comments: [
|
||||
{ content: '第5章后移,增加评测章节', createdAt: '2026-03-22T09:00:00Z' },
|
||||
{ agentId: 'agt_inbox', content: '已调整大纲', createdAt: '2026-03-22T09:05:00Z' },
|
||||
],
|
||||
topics: [
|
||||
{
|
||||
createdAt: '2026-03-21T17:00:00Z',
|
||||
handoff: { summary: '完成大纲' },
|
||||
id: 'tpc_001',
|
||||
seq: 1,
|
||||
status: 'completed',
|
||||
title: '制定大纲',
|
||||
},
|
||||
],
|
||||
},
|
||||
extraPrompt: '这次直接开始写第1章',
|
||||
task: {
|
||||
...baseTask,
|
||||
description: '面向开发者的 AI Agent 技术书籍',
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写一本 AI Agent 书,目标 8 章',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
|
||||
// Verify order: instruction → feedback → task (activities now inside task)
|
||||
const tags = ['<high_priority_instruction>', '<user_feedback>', '<task>'];
|
||||
let lastIdx = -1;
|
||||
for (const tag of tags) {
|
||||
const idx = result.indexOf(tag);
|
||||
expect(idx).toBeGreaterThan(lastIdx);
|
||||
lastIdx = idx;
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty activities gracefully', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
briefs: [],
|
||||
comments: [],
|
||||
topics: [],
|
||||
},
|
||||
task: {
|
||||
...baseTask,
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(result).not.toContain('<user_feedback>');
|
||||
expect(result).not.toContain('<activities>');
|
||||
});
|
||||
|
||||
it('should include subtasks in task section', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
task: {
|
||||
id: 'task_root',
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
status: 'running',
|
||||
subtasks: [
|
||||
{ identifier: 'TASK-2', name: '第1章 Agent 概述', priority: 3, status: 'completed' },
|
||||
{
|
||||
blockedBy: 'TASK-2',
|
||||
identifier: 'TASK-3',
|
||||
name: '第2章 快速上手',
|
||||
priority: 3,
|
||||
status: 'backlog',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(result).toContain('TASK-2');
|
||||
expect(result).toContain('TASK-3');
|
||||
expect(result).toContain('← blocks: TASK-2');
|
||||
});
|
||||
|
||||
it('should truncate comments to 80 chars in activities but keep full in user_feedback', () => {
|
||||
const longContent = 'A'.repeat(90) + ' — this part should be truncated in activities';
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
comments: [{ content: longContent, createdAt: '2026-03-22T11:00:00Z' }],
|
||||
},
|
||||
task: {
|
||||
...baseTask,
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
// user_feedback should have full content
|
||||
const feedbackSection = result.split('<user_feedback>')[1]?.split('</user_feedback>')[0] || '';
|
||||
expect(feedbackSection).toContain(longContent);
|
||||
// activities comment should be truncated
|
||||
const taskSection = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
|
||||
expect(taskSection).toContain('...');
|
||||
expect(taskSection).not.toContain(longContent);
|
||||
});
|
||||
|
||||
it('should include subtasks in task tag when provided', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
task: {
|
||||
id: 'task_001',
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写一本 AI Agent 书,目标 8 章',
|
||||
name: '写一本书',
|
||||
status: 'running',
|
||||
subtasks: [
|
||||
{ identifier: 'TASK-2', name: '第1章 AI Agent 概述', priority: 3, status: 'running' },
|
||||
{ identifier: 'TASK-3', name: '第2章 核心架构', priority: 3, status: 'backlog' },
|
||||
{ identifier: 'TASK-4', name: '第3章 手写 Agent', priority: 2, status: 'backlog' },
|
||||
],
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
// Verify subtasks appear between <task> and </task>
|
||||
const taskMatch = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
|
||||
expect(taskMatch).toContain('Subtasks:');
|
||||
expect(taskMatch).toContain('TASK-2');
|
||||
expect(taskMatch).toContain('TASK-3');
|
||||
expect(taskMatch).toContain('TASK-4');
|
||||
// Verify hint is present
|
||||
expect(taskMatch).toContain('Do NOT call viewTask');
|
||||
});
|
||||
|
||||
it('should include parentTask context for subtasks', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
parentTask: {
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写一本 AI Agent 书,目标 8 章',
|
||||
name: '写一本书',
|
||||
subtasks: [
|
||||
{ identifier: 'TASK-2', name: '第1章 概述', priority: 3, status: 'completed' },
|
||||
{
|
||||
blockedBy: 'TASK-2',
|
||||
identifier: 'TASK-4',
|
||||
name: '第2章 核心架构',
|
||||
priority: 3,
|
||||
status: 'running',
|
||||
},
|
||||
{
|
||||
blockedBy: 'TASK-4',
|
||||
identifier: 'TASK-6',
|
||||
name: '第3章 手写 Agent',
|
||||
priority: 2,
|
||||
status: 'backlog',
|
||||
},
|
||||
],
|
||||
},
|
||||
task: {
|
||||
id: 'task_004',
|
||||
identifier: 'TASK-4',
|
||||
instruction: '撰写第2章',
|
||||
name: '第2章 核心架构',
|
||||
parentIdentifier: 'TASK-1',
|
||||
status: 'running',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
// Verify parentTask block exists inside <task>
|
||||
const taskSection = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
|
||||
expect(taskSection).toContain('<parentTask');
|
||||
expect(taskSection).toContain('TASK-1');
|
||||
expect(taskSection).toContain('写一本 AI Agent 书');
|
||||
// Current task should be marked
|
||||
expect(taskSection).toContain('TASK-4');
|
||||
expect(taskSection).toContain('◀ current');
|
||||
// Dependency info
|
||||
expect(taskSection).toContain('← blocks: TASK-2');
|
||||
expect(taskSection).toContain('← blocks: TASK-4');
|
||||
});
|
||||
|
||||
it('should only include user comments in user_feedback, not agent comments', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
comments: [
|
||||
{ content: '用户反馈', createdAt: '2026-03-22T10:00:00Z' },
|
||||
{ agentId: 'agt_xxx', content: 'Agent 回复', createdAt: '2026-03-22T10:05:00Z' },
|
||||
],
|
||||
},
|
||||
task: {
|
||||
...baseTask,
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
const feedbackSection = result.split('<user_feedback>')[1]?.split('</user_feedback>')[0] || '';
|
||||
expect(feedbackSection).toContain('用户反馈');
|
||||
expect(feedbackSection).not.toContain('Agent 回复');
|
||||
// But activities should have both
|
||||
const taskSection = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
|
||||
expect(taskSection).toContain('👤 user');
|
||||
expect(taskSection).toContain('🤖 agent');
|
||||
});
|
||||
});
|
||||
570
packages/prompts/src/prompts/task/index.ts
Normal file
570
packages/prompts/src/prompts/task/index.ts
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
import type { TaskDetailData, TaskDetailWorkspaceNode } from '@lobechat/types';
|
||||
|
||||
// ── Formatting helpers for Task tool responses ──
|
||||
|
||||
const priorityLabel = (p?: number | null): string => {
|
||||
switch (p) {
|
||||
case 1: {
|
||||
return 'urgent';
|
||||
}
|
||||
case 2: {
|
||||
return 'high';
|
||||
}
|
||||
case 3: {
|
||||
return 'normal';
|
||||
}
|
||||
case 4: {
|
||||
return 'low';
|
||||
}
|
||||
default: {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const statusIcon = (s: string): string => {
|
||||
switch (s) {
|
||||
case 'backlog': {
|
||||
return '○';
|
||||
}
|
||||
case 'running': {
|
||||
return '●';
|
||||
}
|
||||
case 'paused': {
|
||||
return '◐';
|
||||
}
|
||||
case 'completed': {
|
||||
return '✓';
|
||||
}
|
||||
case 'failed': {
|
||||
return '✗';
|
||||
}
|
||||
case 'canceled': {
|
||||
return '⊘';
|
||||
}
|
||||
default: {
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export interface TaskSummary {
|
||||
identifier: string;
|
||||
name?: string | null;
|
||||
priority?: number | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// Re-export shared types from @lobechat/types for backward compatibility
|
||||
export type {
|
||||
TaskDetailActivity,
|
||||
TaskDetailData,
|
||||
TaskDetailSubtask,
|
||||
TaskDetailWorkspaceNode,
|
||||
} from '@lobechat/types';
|
||||
|
||||
/**
|
||||
* Format a single task as a one-line summary
|
||||
*/
|
||||
export const formatTaskLine = (t: TaskSummary): string =>
|
||||
`${t.identifier} ${statusIcon(t.status)} ${t.status} ${t.name || '(unnamed)'} [${priorityLabel(t.priority)}]`;
|
||||
|
||||
/**
|
||||
* Format createTask response
|
||||
*/
|
||||
export const formatTaskCreated = (
|
||||
t: TaskSummary & { instruction: string; parentLabel?: string },
|
||||
): string => {
|
||||
const lines = [
|
||||
`Task created: ${t.identifier} "${t.name}"`,
|
||||
` Status: ${statusIcon(t.status)} ${t.status}`,
|
||||
` Priority: ${priorityLabel(t.priority)}`,
|
||||
];
|
||||
if (t.parentLabel) lines.push(` Parent: ${t.parentLabel}`);
|
||||
lines.push(` Instruction: ${t.instruction}`);
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Format task list response
|
||||
*/
|
||||
export const formatTaskList = (
|
||||
tasks: TaskSummary[],
|
||||
parentLabel: string,
|
||||
filter?: string,
|
||||
): string => {
|
||||
if (tasks.length === 0) {
|
||||
const filterNote = filter ? ` with status "${filter}"` : '';
|
||||
return `No subtasks found under ${parentLabel}${filterNote}.`;
|
||||
}
|
||||
|
||||
return [
|
||||
`${tasks.length} task(s) under ${parentLabel}:`,
|
||||
...tasks.map((t) => ` ${formatTaskLine(t)}`),
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Format viewTask response
|
||||
*/
|
||||
export const formatTaskDetail = (t: TaskDetailData): string => {
|
||||
const lines = [
|
||||
`${t.identifier} ${t.name || '(unnamed)'}`,
|
||||
`Status: ${statusIcon(t.status)} ${t.status} Priority: ${priorityLabel(t.priority)}`,
|
||||
`Instruction: ${t.instruction}`,
|
||||
];
|
||||
|
||||
if (t.agentId) lines.push(`Agent: ${t.agentId}`);
|
||||
if (t.parent) lines.push(`Parent: ${t.parent.identifier}`);
|
||||
if (t.topicCount) lines.push(`Topics: ${t.topicCount}`);
|
||||
if (t.createdAt) lines.push(`Created: ${t.createdAt}`);
|
||||
|
||||
if (t.dependencies && t.dependencies.length > 0) {
|
||||
lines.push(
|
||||
`Dependencies: ${t.dependencies.map((d) => `${d.type}: ${d.dependsOn}`).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Subtasks
|
||||
if (t.subtasks && t.subtasks.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Subtasks:');
|
||||
for (const s of t.subtasks) {
|
||||
const dep = s.blockedBy ? ` ← blocks: ${s.blockedBy}` : '';
|
||||
lines.push(
|
||||
` ${s.identifier} ${statusIcon(s.status)} ${s.status} ${s.name || '(unnamed)'}${dep}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Checkpoint
|
||||
lines.push('');
|
||||
if (t.checkpoint && Object.keys(t.checkpoint).length > 0) {
|
||||
lines.push(`Checkpoint: ${JSON.stringify(t.checkpoint)}`);
|
||||
} else {
|
||||
lines.push('Checkpoint: (not configured, default: onAgentRequest=true)');
|
||||
}
|
||||
|
||||
// Review
|
||||
lines.push('');
|
||||
if (t.review && Object.keys(t.review).length > 0) {
|
||||
const rubrics = (t.review as any).rubrics as
|
||||
| Array<{ name: string; threshold?: number; type: string }>
|
||||
| undefined;
|
||||
lines.push(`Review (maxIterations: ${(t.review as any).maxIterations || 3}):`);
|
||||
if (rubrics) {
|
||||
for (const r of rubrics) {
|
||||
lines.push(
|
||||
` - ${r.name} [${r.type}]${r.threshold ? ` ≥ ${Math.round(r.threshold * 100)}%` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push('Review: (not configured)');
|
||||
}
|
||||
|
||||
// Workspace
|
||||
if (t.workspace && t.workspace.length > 0) {
|
||||
const countNodes = (nodes: TaskDetailWorkspaceNode[]): number =>
|
||||
nodes.reduce((sum, n) => sum + 1 + (n.children ? countNodes(n.children) : 0), 0);
|
||||
const total = countNodes(t.workspace);
|
||||
lines.push('');
|
||||
lines.push(`Workspace (${total}):`);
|
||||
|
||||
const renderNodes = (nodes: TaskDetailWorkspaceNode[], indent: string, isChild: boolean) => {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
const isFolder = node.fileType === 'custom/folder';
|
||||
const isLast = i === nodes.length - 1;
|
||||
const icon = isFolder ? '📁' : '📄';
|
||||
const connector = isChild ? (isLast ? '└── ' : '├── ') : '';
|
||||
const source = node.sourceTaskIdentifier ? ` ← ${node.sourceTaskIdentifier}` : '';
|
||||
const sizeStr = !isFolder && node.size ? ` ${node.size} chars` : '';
|
||||
lines.push(
|
||||
`${indent}${connector}${icon} ${node.title || 'Untitled'} (${node.documentId})${source}${sizeStr}`,
|
||||
);
|
||||
if (node.children) {
|
||||
const childIndent = isChild ? indent + (isLast ? ' ' : '│ ') : indent;
|
||||
renderNodes(node.children, childIndent, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
renderNodes(t.workspace, ' ', false);
|
||||
}
|
||||
|
||||
// Activities (already sorted desc by service)
|
||||
if (t.activities && t.activities.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Activities:');
|
||||
for (const act of t.activities) {
|
||||
const idSuffix = act.id ? ` ${act.id}` : '';
|
||||
if (act.type === 'topic') {
|
||||
const status = act.status || 'completed';
|
||||
lines.push(
|
||||
` 💬 ${act.time || ''} Topic #${act.seq || '?'} ${act.title || 'Untitled'} ${statusIcon(status)} ${status}${idSuffix}`,
|
||||
);
|
||||
} else if (act.type === 'brief') {
|
||||
const resolved = act.resolvedAction ? ` ✏️ ${act.resolvedAction}` : '';
|
||||
const priStr = act.priority ? ` [${act.priority}]` : '';
|
||||
lines.push(
|
||||
` ${briefIcon(act.briefType || '')} ${act.time || ''} Brief [${act.briefType}] ${act.title}${priStr}${resolved}${idSuffix}`,
|
||||
);
|
||||
} else if (act.type === 'comment') {
|
||||
const author = act.agentId ? '🤖 agent' : '👤 user';
|
||||
const content = act.content || '';
|
||||
const truncated = content.length > 80 ? content.slice(0, 80) + '...' : content;
|
||||
lines.push(` 💭 ${act.time || ''} ${author} ${truncated}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Format editTask response
|
||||
*/
|
||||
export const formatTaskEdited = (identifier: string, changes: string[]): string =>
|
||||
`Task ${identifier} updated:\n ${changes.join('\n ')}`;
|
||||
|
||||
/**
|
||||
* Format dependency change response
|
||||
*/
|
||||
export const formatDependencyAdded = (task: string, dependsOn: string): string =>
|
||||
`Dependency added: ${task} now blocks on ${dependsOn}.\n${task} will not start until ${dependsOn} is completed.`;
|
||||
|
||||
export const formatDependencyRemoved = (task: string, dependsOn: string): string =>
|
||||
`Dependency removed: ${task} no longer blocks on ${dependsOn}.`;
|
||||
|
||||
/**
|
||||
* Format brief created response
|
||||
*/
|
||||
export const formatBriefCreated = (args: {
|
||||
id: string;
|
||||
priority: string;
|
||||
summary: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}): string =>
|
||||
`Brief created (${args.type}, ${args.priority}):\n "${args.title}"\n ${args.summary}\n\nBrief ID: ${args.id}`;
|
||||
|
||||
/**
|
||||
* Format checkpoint response
|
||||
*/
|
||||
export const formatCheckpointCreated = (reason: string): string =>
|
||||
`Checkpoint created. Task is now paused and waiting for user review.\n\nReason: ${reason}\n\nThe user will see this as a "decision" brief and can resume the task after review.`;
|
||||
|
||||
// ── Task Run Prompt Builder ──
|
||||
|
||||
export interface TaskRunPromptComment {
|
||||
agentId?: string | null;
|
||||
content: string;
|
||||
createdAt?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface TaskRunPromptTopic {
|
||||
createdAt: string;
|
||||
handoff?: {
|
||||
keyFindings?: string[];
|
||||
nextAction?: string;
|
||||
summary?: string;
|
||||
title?: string;
|
||||
} | null;
|
||||
id?: string;
|
||||
seq?: number | null;
|
||||
status?: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskRunPromptBrief {
|
||||
createdAt: string;
|
||||
id?: string;
|
||||
priority?: string | null;
|
||||
resolvedAction?: string | null;
|
||||
resolvedAt?: string | null;
|
||||
resolvedComment?: string | null;
|
||||
summary: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface TaskRunPromptSubtask {
|
||||
createdAt?: string;
|
||||
id?: string;
|
||||
identifier: string;
|
||||
name?: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TaskRunPromptWorkspaceNode {
|
||||
children?: TaskRunPromptWorkspaceNode[];
|
||||
createdAt?: string;
|
||||
documentId: string;
|
||||
fileType?: string;
|
||||
size?: number;
|
||||
sourceTaskIdentifier?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface TaskRunPromptInput {
|
||||
/** Activity data (all optional) */
|
||||
activities?: {
|
||||
briefs?: TaskRunPromptBrief[];
|
||||
comments?: TaskRunPromptComment[];
|
||||
subtasks?: TaskRunPromptSubtask[];
|
||||
topics?: TaskRunPromptTopic[];
|
||||
};
|
||||
/** --prompt flag content */
|
||||
extraPrompt?: string;
|
||||
/** Parent task context (when current task is a subtask) */
|
||||
parentTask?: {
|
||||
identifier: string;
|
||||
instruction: string;
|
||||
name?: string | null;
|
||||
subtasks?: Array<TaskSummary & { blockedBy?: string }>;
|
||||
};
|
||||
/** Task data */
|
||||
task: {
|
||||
assigneeAgentId?: string | null;
|
||||
dependencies?: Array<{ dependsOn: string; type: string }>;
|
||||
description?: string | null;
|
||||
id: string;
|
||||
identifier: string;
|
||||
instruction: string;
|
||||
name?: string | null;
|
||||
parentIdentifier?: string | null;
|
||||
priority?: number | null;
|
||||
review?: {
|
||||
enabled?: boolean;
|
||||
maxIterations?: number;
|
||||
rubrics?: Array<{ name: string; threshold?: number; type: string }>;
|
||||
} | null;
|
||||
status: string;
|
||||
subtasks?: Array<TaskSummary & { blockedBy?: string }>;
|
||||
};
|
||||
/** Pinned documents (workspace) */
|
||||
workspace?: TaskRunPromptWorkspaceNode[];
|
||||
}
|
||||
|
||||
// ── Relative time helper ──
|
||||
|
||||
const timeAgo = (dateStr: string, now?: Date): string => {
|
||||
const date = new Date(dateStr);
|
||||
const ref = now || new Date();
|
||||
const diffMs = ref.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
if (diffMin < 1) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
return `${diffDay}d ago`;
|
||||
};
|
||||
|
||||
// ── Brief icon ──
|
||||
|
||||
const briefIcon = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'decision': {
|
||||
return '📋';
|
||||
}
|
||||
case 'result': {
|
||||
return '✅';
|
||||
}
|
||||
case 'insight': {
|
||||
return '💡';
|
||||
}
|
||||
case 'error': {
|
||||
return '❌';
|
||||
}
|
||||
default: {
|
||||
return '📌';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the prompt for task.run — injected as user message to the Agent.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. High Priority Instruction (--prompt) — the most important directive for this run
|
||||
* 2. User Feedback (user comments only, full content) — what the user wants
|
||||
* 3. Activities (topics + briefs + comments + subtasks, chronological) — full timeline
|
||||
* 4. Original Task (instruction + description) — the base requirement
|
||||
*/
|
||||
export const buildTaskRunPrompt = (input: TaskRunPromptInput, now?: Date): string => {
|
||||
const { task, activities, extraPrompt, workspace, parentTask } = input;
|
||||
const sections: string[] = [];
|
||||
|
||||
// ── 1. High Priority Instruction ──
|
||||
if (extraPrompt) {
|
||||
sections.push(`<high_priority_instruction>\n${extraPrompt}\n</high_priority_instruction>`);
|
||||
}
|
||||
|
||||
// ── 2. User Feedback (user comments only, full content) ──
|
||||
const userComments = activities?.comments?.filter((c) => !c.agentId);
|
||||
if (userComments && userComments.length > 0) {
|
||||
const lines = userComments.map((c) => {
|
||||
const ago = c.createdAt ? timeAgo(c.createdAt, now) : '';
|
||||
const timeAttr = ago ? ` time="${ago}"` : '';
|
||||
const idAttr = c.id ? ` id="${c.id}"` : '';
|
||||
return `<comment${idAttr}${timeAttr}>${c.content}</comment>`;
|
||||
});
|
||||
sections.push(`<user_feedback>\n${lines.join('\n')}\n</user_feedback>`);
|
||||
}
|
||||
|
||||
// ── 3. Task context (full detail so agent doesn't need to call viewTask) ──
|
||||
const taskLines = [
|
||||
`<task>`,
|
||||
`<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>`,
|
||||
`${task.identifier} ${task.name || task.identifier}`,
|
||||
`Status: ${statusIcon(task.status)} ${task.status} Priority: ${priorityLabel(task.priority)}`,
|
||||
`Instruction: ${task.instruction}`,
|
||||
];
|
||||
if (task.description) taskLines.push(`Description: ${task.description}`);
|
||||
if (task.assigneeAgentId) taskLines.push(`Agent: ${task.assigneeAgentId}`);
|
||||
if (task.parentIdentifier) taskLines.push(`Parent: ${task.parentIdentifier}`);
|
||||
|
||||
const topicCount = activities?.topics?.length ?? 0;
|
||||
if (topicCount > 0) taskLines.push(`Topics: ${topicCount}`);
|
||||
|
||||
if (task.dependencies && task.dependencies.length > 0) {
|
||||
taskLines.push(
|
||||
`Dependencies: ${task.dependencies.map((d) => `${d.type}: ${d.dependsOn}`).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Subtasks
|
||||
if (task.subtasks && task.subtasks.length > 0) {
|
||||
taskLines.push('');
|
||||
taskLines.push('Subtasks:');
|
||||
for (const s of task.subtasks) {
|
||||
const dep = s.blockedBy ? ` ← blocks: ${s.blockedBy}` : '';
|
||||
taskLines.push(
|
||||
` ${s.identifier} ${statusIcon(s.status)} ${s.status} ${s.name || '(unnamed)'}${dep}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Review
|
||||
taskLines.push('');
|
||||
if (task.review?.enabled && task.review.rubrics && task.review.rubrics.length > 0) {
|
||||
taskLines.push(`Review (maxIterations: ${task.review.maxIterations || 3}):`);
|
||||
for (const r of task.review.rubrics) {
|
||||
taskLines.push(
|
||||
` - ${r.name} [${r.type}]${r.threshold ? ` ≥ ${Math.round(r.threshold * 100)}%` : ''}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
taskLines.push('Review: (not configured)');
|
||||
}
|
||||
|
||||
// Workspace
|
||||
if (workspace && workspace.length > 0) {
|
||||
const countNodes = (nodes: TaskRunPromptWorkspaceNode[]): number =>
|
||||
nodes.reduce((sum, n) => sum + 1 + (n.children ? countNodes(n.children) : 0), 0);
|
||||
const total = countNodes(workspace);
|
||||
taskLines.push('');
|
||||
taskLines.push(`Workspace (${total}):`);
|
||||
|
||||
const renderNodes = (nodes: TaskRunPromptWorkspaceNode[], indent: string, isChild: boolean) => {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
const isFolder = node.fileType === 'custom/folder';
|
||||
const isLast = i === nodes.length - 1;
|
||||
const icon = isFolder ? '📁' : '📄';
|
||||
const connector = isChild ? (isLast ? '└── ' : '├── ') : '';
|
||||
const source = node.sourceTaskIdentifier ? ` ← ${node.sourceTaskIdentifier}` : '';
|
||||
const sizeStr = !isFolder && node.size ? ` ${node.size} chars` : '';
|
||||
const ago = node.createdAt ? ` ${timeAgo(node.createdAt, now)}` : '';
|
||||
taskLines.push(
|
||||
`${indent}${connector}${icon} ${node.title || 'Untitled'} (${node.documentId})${source}${sizeStr}${ago}`,
|
||||
);
|
||||
if (node.children) {
|
||||
const childIndent = isChild ? indent + (isLast ? ' ' : '│ ') : indent;
|
||||
renderNodes(node.children, childIndent, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
renderNodes(workspace, ' ', false);
|
||||
}
|
||||
|
||||
// Activities (chronological, flat list)
|
||||
const timelineEntries: { text: string; time: number }[] = [];
|
||||
|
||||
if (activities?.topics) {
|
||||
for (const t of activities.topics) {
|
||||
const ago = timeAgo(t.createdAt, now);
|
||||
const status = t.status || 'completed';
|
||||
const title = t.title || t.handoff?.title || 'Untitled';
|
||||
const idSuffix = t.id ? ` ${t.id}` : '';
|
||||
timelineEntries.push({
|
||||
text: ` 💬 ${ago} Topic #${t.seq || '?'} ${title} ${statusIcon(status)} ${status}${idSuffix}`,
|
||||
time: new Date(t.createdAt).getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (activities?.briefs) {
|
||||
for (const b of activities.briefs) {
|
||||
const ago = timeAgo(b.createdAt, now);
|
||||
let resolved = '';
|
||||
if (b.resolvedAt && b.resolvedAction) {
|
||||
resolved = b.resolvedComment ? ` ✏️ ${b.resolvedComment}` : ` ✅ ${b.resolvedAction}`;
|
||||
}
|
||||
const priStr = b.priority ? ` [${b.priority}]` : '';
|
||||
const idSuffix = b.id ? ` ${b.id}` : '';
|
||||
timelineEntries.push({
|
||||
text: ` ${briefIcon(b.type)} ${ago} Brief [${b.type}] ${b.title}${priStr}${resolved}${idSuffix}`,
|
||||
time: new Date(b.createdAt).getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (activities?.comments) {
|
||||
for (const c of activities.comments) {
|
||||
const author = c.agentId ? '🤖 agent' : '👤 user';
|
||||
const ago = c.createdAt ? timeAgo(c.createdAt, now) : '';
|
||||
const truncated = c.content.length > 80 ? c.content.slice(0, 80) + '...' : c.content;
|
||||
timelineEntries.push({
|
||||
text: ` 💭 ${ago} ${author} ${truncated}`,
|
||||
time: c.createdAt ? new Date(c.createdAt).getTime() : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (timelineEntries.length > 0) {
|
||||
timelineEntries.sort((a, b) => a.time - b.time);
|
||||
taskLines.push('');
|
||||
taskLines.push('Activities:');
|
||||
taskLines.push(...timelineEntries.map((e) => e.text));
|
||||
}
|
||||
|
||||
// Parent task context
|
||||
if (parentTask) {
|
||||
taskLines.push('');
|
||||
taskLines.push(
|
||||
`<parentTask identifier="${parentTask.identifier}" name="${parentTask.name || parentTask.identifier}">`,
|
||||
);
|
||||
taskLines.push(` Instruction: ${parentTask.instruction}`);
|
||||
if (parentTask.subtasks && parentTask.subtasks.length > 0) {
|
||||
taskLines.push(` Subtasks (${parentTask.subtasks.length}):`);
|
||||
for (const s of parentTask.subtasks) {
|
||||
const dep = s.blockedBy ? ` ← blocks: ${s.blockedBy}` : '';
|
||||
const marker = s.identifier === task.identifier ? ' ◀ current' : '';
|
||||
taskLines.push(
|
||||
` ${s.identifier} ${statusIcon(s.status)} ${s.status} ${s.name || '(unnamed)'}${dep}${marker}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
taskLines.push('</parentTask>');
|
||||
}
|
||||
|
||||
taskLines.push('</task>');
|
||||
sections.push(taskLines.join('\n'));
|
||||
|
||||
return sections.join('\n\n');
|
||||
};
|
||||
|
||||
export { priorityLabel, statusIcon };
|
||||
32
packages/types/src/brief/index.ts
Normal file
32
packages/types/src/brief/index.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export interface BriefAction {
|
||||
/** Action identifier, e.g. 'approve', 'reject', 'feedback' */
|
||||
key: string;
|
||||
/** Display label, e.g. "✅ 确认开始", "💬 修改意见" */
|
||||
label: string;
|
||||
/**
|
||||
* Action type:
|
||||
* - 'resolve': directly mark brief as resolved
|
||||
* - 'comment': prompt for text input, then resolve
|
||||
* - 'link': navigate to a URL (no resolution)
|
||||
*/
|
||||
type: 'resolve' | 'comment' | 'link';
|
||||
/** URL for 'link' type actions */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/** Default actions by brief type */
|
||||
export const DEFAULT_BRIEF_ACTIONS: Record<string, BriefAction[]> = {
|
||||
decision: [
|
||||
{ key: 'approve', label: '✅ 确认', type: 'resolve' },
|
||||
{ key: 'feedback', label: '💬 修改意见', type: 'comment' },
|
||||
],
|
||||
error: [
|
||||
{ key: 'retry', label: '🔄 重试', type: 'resolve' },
|
||||
{ key: 'feedback', label: '💬 反馈', type: 'comment' },
|
||||
],
|
||||
insight: [{ key: 'acknowledge', label: '👍 知悉', type: 'resolve' }],
|
||||
result: [
|
||||
{ key: 'approve', label: '✅ 通过', type: 'resolve' },
|
||||
{ key: 'feedback', label: '💬 修改意见', type: 'comment' },
|
||||
],
|
||||
};
|
||||
|
|
@ -6,6 +6,7 @@ export * from './aiProvider';
|
|||
export * from './artifact';
|
||||
export * from './asyncTask';
|
||||
export * from './auth';
|
||||
export * from './brief';
|
||||
export * from './chunk';
|
||||
export * from './clientDB';
|
||||
export * from './conversation';
|
||||
|
|
@ -32,6 +33,7 @@ export * from './service';
|
|||
export * from './session';
|
||||
export * from './skill';
|
||||
export * from './stepContext';
|
||||
export * from './task';
|
||||
export * from './tool';
|
||||
export * from './topic';
|
||||
export * from './user';
|
||||
|
|
|
|||
100
packages/types/src/task/index.ts
Normal file
100
packages/types/src/task/index.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
export interface CheckpointConfig {
|
||||
onAgentRequest?: boolean;
|
||||
tasks?: {
|
||||
afterIds?: string[];
|
||||
beforeIds?: string[];
|
||||
};
|
||||
topic?: {
|
||||
after?: boolean;
|
||||
before?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceDocNode {
|
||||
charCount: number | null;
|
||||
createdAt: string;
|
||||
fileType: string;
|
||||
parentId: string | null;
|
||||
pinnedBy: string;
|
||||
sourceTaskIdentifier: string | null;
|
||||
title: string;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceTreeNode {
|
||||
children: WorkspaceTreeNode[];
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceData {
|
||||
nodeMap: Record<string, WorkspaceDocNode>;
|
||||
tree: WorkspaceTreeNode[];
|
||||
}
|
||||
|
||||
export interface TaskTopicHandoff {
|
||||
keyFindings?: string[];
|
||||
nextAction?: string;
|
||||
summary?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// ── Task Detail (shared across CLI, viewTask tool, task.detail router) ──
|
||||
|
||||
export interface TaskDetailSubtask {
|
||||
blockedBy?: string;
|
||||
identifier: string;
|
||||
name?: string | null;
|
||||
priority?: number | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TaskDetailWorkspaceNode {
|
||||
children?: TaskDetailWorkspaceNode[];
|
||||
createdAt?: string;
|
||||
documentId: string;
|
||||
fileType?: string;
|
||||
size?: number | null;
|
||||
sourceTaskIdentifier?: string | null;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface TaskDetailActivity {
|
||||
agentId?: string | null;
|
||||
briefType?: string;
|
||||
content?: string;
|
||||
id?: string;
|
||||
priority?: string | null;
|
||||
resolvedAction?: string | null;
|
||||
seq?: number | null;
|
||||
status?: string | null;
|
||||
summary?: string;
|
||||
time?: string;
|
||||
title?: string;
|
||||
type: 'brief' | 'comment' | 'topic';
|
||||
}
|
||||
|
||||
export interface TaskDetailData {
|
||||
activities?: TaskDetailActivity[];
|
||||
agentId?: string | null;
|
||||
checkpoint?: CheckpointConfig;
|
||||
createdAt?: string;
|
||||
dependencies?: Array<{ dependsOn: string; type: string }>;
|
||||
description?: string | null;
|
||||
error?: string | null;
|
||||
heartbeat?: {
|
||||
interval?: number | null;
|
||||
lastAt?: string | null;
|
||||
timeout?: number | null;
|
||||
};
|
||||
identifier: string;
|
||||
instruction: string;
|
||||
name?: string | null;
|
||||
parent?: { identifier: string; name: string | null } | null;
|
||||
priority?: number | null;
|
||||
review?: Record<string, any> | null;
|
||||
status: string;
|
||||
subtasks?: TaskDetailSubtask[];
|
||||
topicCount?: number;
|
||||
userId?: string | null;
|
||||
workspace?: TaskDetailWorkspaceNode[];
|
||||
}
|
||||
|
|
@ -364,12 +364,18 @@ export function defineConfig(config: CustomNextConfig) {
|
|||
|
||||
transpilePackages: ['mermaid', 'better-auth-harmony'],
|
||||
turbopack: {
|
||||
rules: isTest
|
||||
? void 0
|
||||
: codeInspectorPlugin({
|
||||
bundler: 'turbopack',
|
||||
hotKeys: ['altKey', 'ctrlKey'],
|
||||
}),
|
||||
rules: {
|
||||
...(isTest
|
||||
? void 0
|
||||
: codeInspectorPlugin({
|
||||
bundler: 'turbopack',
|
||||
hotKeys: ['altKey', 'ctrlKey'],
|
||||
})),
|
||||
'*.md': {
|
||||
as: '*.js',
|
||||
loaders: ['raw-loader'],
|
||||
},
|
||||
},
|
||||
...config.turbopack,
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -981,6 +981,7 @@ export const createRuntimeExecutors = (
|
|||
agentId: state.metadata?.agentId,
|
||||
memoryToolPermission: agentConfig?.chatConfig?.memory?.toolPermission,
|
||||
serverDB: ctx.serverDB,
|
||||
taskId: state.metadata?.taskId,
|
||||
toolManifestMap: effectiveManifestMap,
|
||||
toolResultMaxLength,
|
||||
topicId: ctx.topicId,
|
||||
|
|
@ -1198,6 +1199,7 @@ export const createRuntimeExecutors = (
|
|||
agentId: state.metadata?.agentId,
|
||||
memoryToolPermission: batchAgentConfig?.chatConfig?.memory?.toolPermission,
|
||||
serverDB: ctx.serverDB,
|
||||
taskId: state.metadata?.taskId,
|
||||
toolManifestMap: batchManifestMap,
|
||||
toolResultMaxLength: batchAgentConfig?.chatConfig?.toolResultMaxLength,
|
||||
topicId: ctx.topicId,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,38 @@ export const createTestUser = async (serverDB: LobeChatDatabase, userId?: string
|
|||
return id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建测试 Agent
|
||||
*/
|
||||
export const createTestAgent = async (
|
||||
serverDB: LobeChatDatabase,
|
||||
userId: string,
|
||||
agentId?: string,
|
||||
) => {
|
||||
const id = agentId || `agt_${uuid()}`;
|
||||
const { agents } = await import('@/database/schemas');
|
||||
|
||||
await serverDB.insert(agents).values({ id, slug: id, userId }).onConflictDoNothing();
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建测试 Topic
|
||||
*/
|
||||
export const createTestTopic = async (
|
||||
serverDB: LobeChatDatabase,
|
||||
userId: string,
|
||||
topicId?: string,
|
||||
) => {
|
||||
const id = topicId || `tpc_${uuid()}`;
|
||||
const { topics } = await import('@/database/schemas');
|
||||
|
||||
await serverDB.insert(topics).values({ id, userId }).onConflictDoNothing();
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理测试用户及其所有关联数据
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,385 @@
|
|||
// @vitest-environment node
|
||||
import { type LobeChatDatabase } from '@lobechat/database';
|
||||
import { getTestDB } from '@lobechat/database/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { taskRouter } from '../../task';
|
||||
import {
|
||||
cleanupTestUser,
|
||||
createTestAgent,
|
||||
createTestContext,
|
||||
createTestTopic,
|
||||
createTestUser,
|
||||
} from './setup';
|
||||
|
||||
// Mock getServerDB
|
||||
let testDB: LobeChatDatabase;
|
||||
vi.mock('@/database/core/db-adaptor', () => ({
|
||||
getServerDB: vi.fn(() => testDB),
|
||||
}));
|
||||
|
||||
// Mock AiAgentService
|
||||
const mockExecAgent = vi.fn().mockResolvedValue({
|
||||
operationId: 'op_test',
|
||||
success: true,
|
||||
topicId: 'tpc_test',
|
||||
});
|
||||
const mockInterruptTask = vi.fn().mockResolvedValue({ success: true });
|
||||
vi.mock('@/server/services/aiAgent', () => ({
|
||||
AiAgentService: vi.fn().mockImplementation(() => ({
|
||||
execAgent: mockExecAgent,
|
||||
interruptTask: mockInterruptTask,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock TaskLifecycleService
|
||||
vi.mock('@/server/services/taskLifecycle', () => ({
|
||||
TaskLifecycleService: vi.fn().mockImplementation(() => ({
|
||||
onTopicComplete: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock TaskReviewService
|
||||
vi.mock('@/server/services/taskReview', () => ({
|
||||
TaskReviewService: vi.fn().mockImplementation(() => ({
|
||||
review: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock initModelRuntimeFromDB
|
||||
vi.mock('@/server/modules/ModelRuntime', () => ({
|
||||
initModelRuntimeFromDB: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Task Router Integration', () => {
|
||||
let serverDB: LobeChatDatabase;
|
||||
let userId: string;
|
||||
let testAgentId: string;
|
||||
let testTopicId: string;
|
||||
let caller: ReturnType<typeof taskRouter.createCaller>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
serverDB = await getTestDB();
|
||||
testDB = serverDB;
|
||||
userId = await createTestUser(serverDB);
|
||||
testAgentId = await createTestAgent(serverDB, userId, 'agt_test');
|
||||
testTopicId = await createTestTopic(serverDB, userId, 'tpc_test');
|
||||
// Update mock to return the real topic ID
|
||||
mockExecAgent.mockResolvedValue({
|
||||
operationId: 'op_test',
|
||||
success: true,
|
||||
topicId: testTopicId,
|
||||
});
|
||||
caller = taskRouter.createCaller(createTestContext(userId));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestUser(serverDB, userId);
|
||||
});
|
||||
|
||||
describe('create + find + detail', () => {
|
||||
it('should create a task and retrieve it', async () => {
|
||||
const result = await caller.create({
|
||||
instruction: 'Write a book',
|
||||
name: 'Write Book',
|
||||
});
|
||||
|
||||
expect(result.data.identifier).toBe('T-1');
|
||||
expect(result.data.name).toBe('Write Book');
|
||||
expect(result.data.status).toBe('backlog');
|
||||
|
||||
// find
|
||||
const found = await caller.find({ id: 'T-1' });
|
||||
expect(found.data.id).toBe(result.data.id);
|
||||
|
||||
// detail
|
||||
const detail = await caller.detail({ id: 'T-1' });
|
||||
expect(detail.data.identifier).toBe('T-1');
|
||||
expect(detail.data.subtasks).toHaveLength(0);
|
||||
expect(detail.data.activities).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('subtasks + dependencies', () => {
|
||||
it('should create subtasks and set dependencies', async () => {
|
||||
const parent = await caller.create({
|
||||
instruction: 'Write a book',
|
||||
name: 'Book',
|
||||
});
|
||||
|
||||
const ch1 = await caller.create({
|
||||
instruction: 'Write chapter 1',
|
||||
name: 'Chapter 1',
|
||||
parentTaskId: parent.data.id,
|
||||
});
|
||||
const ch2 = await caller.create({
|
||||
instruction: 'Write chapter 2',
|
||||
name: 'Chapter 2',
|
||||
parentTaskId: parent.data.id,
|
||||
});
|
||||
|
||||
// Add dependency: ch2 blocks on ch1
|
||||
await caller.addDependency({
|
||||
dependsOnId: ch1.data.id,
|
||||
taskId: ch2.data.id,
|
||||
});
|
||||
|
||||
const detail = await caller.detail({ id: parent.data.identifier });
|
||||
expect(detail.data.subtasks).toHaveLength(2);
|
||||
// ch2 should have blockedBy pointing to ch1's identifier
|
||||
const ch2Sub = detail.data.subtasks!.find((s) => s.name === 'Chapter 2');
|
||||
expect(ch2Sub?.blockedBy).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status transitions', () => {
|
||||
it('should transition backlog → running → paused → completed', async () => {
|
||||
const task = await caller.create({ instruction: 'Test' });
|
||||
|
||||
// backlog → running
|
||||
const running = await caller.updateStatus({
|
||||
id: task.data.id,
|
||||
status: 'running',
|
||||
});
|
||||
expect(running.data.status).toBe('running');
|
||||
|
||||
// running → paused
|
||||
const paused = await caller.updateStatus({
|
||||
id: task.data.id,
|
||||
status: 'paused',
|
||||
});
|
||||
expect(paused.data.status).toBe('paused');
|
||||
|
||||
// paused → completed
|
||||
const completed = await caller.updateStatus({
|
||||
id: task.data.id,
|
||||
status: 'completed',
|
||||
});
|
||||
expect(completed.data.status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('comments', () => {
|
||||
it('should add and retrieve comments', async () => {
|
||||
const task = await caller.create({ instruction: 'Test' });
|
||||
|
||||
await caller.addComment({
|
||||
content: 'First comment',
|
||||
id: task.data.id,
|
||||
});
|
||||
await caller.addComment({
|
||||
content: 'Second comment',
|
||||
id: task.data.id,
|
||||
});
|
||||
|
||||
const detail = await caller.detail({ id: task.data.identifier });
|
||||
const commentActivities = detail.data.activities?.filter((a) => a.type === 'comment');
|
||||
expect(commentActivities).toHaveLength(2);
|
||||
expect(commentActivities?.[0].content).toBe('First comment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('review config', () => {
|
||||
it('should set and retrieve review rubrics', async () => {
|
||||
const task = await caller.create({ instruction: 'Test' });
|
||||
|
||||
await caller.updateReview({
|
||||
id: task.data.id,
|
||||
review: {
|
||||
autoRetry: true,
|
||||
enabled: true,
|
||||
maxIterations: 3,
|
||||
rubrics: [
|
||||
{
|
||||
config: { criteria: '内容准确性' },
|
||||
id: 'r1',
|
||||
name: '准确性',
|
||||
threshold: 0.8,
|
||||
type: 'llm-rubric',
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
config: { value: '```' },
|
||||
id: 'r2',
|
||||
name: '包含代码',
|
||||
type: 'contains',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const review = await caller.getReview({ id: task.data.id });
|
||||
expect(review.data!.enabled).toBe(true);
|
||||
expect(review.data!.rubrics).toHaveLength(2);
|
||||
expect(review.data!.rubrics[0].type).toBe('llm-rubric');
|
||||
});
|
||||
});
|
||||
|
||||
describe('run idempotency', () => {
|
||||
it('should reject run when a topic is already running', async () => {
|
||||
const task = await caller.create({
|
||||
assigneeAgentId: testAgentId,
|
||||
instruction: 'Test',
|
||||
});
|
||||
|
||||
// First run succeeds
|
||||
await caller.run({ id: task.data.id });
|
||||
|
||||
// Second run should fail with CONFLICT
|
||||
await expect(caller.run({ id: task.data.id })).rejects.toThrow(/already has a running topic/);
|
||||
});
|
||||
|
||||
it('should reject continue on already running topic', async () => {
|
||||
const task = await caller.create({
|
||||
assigneeAgentId: testAgentId,
|
||||
instruction: 'Test',
|
||||
});
|
||||
|
||||
const result = await caller.run({ id: task.data.id });
|
||||
|
||||
await expect(caller.run({ continueTopicId: 'tpc_test', id: task.data.id })).rejects.toThrow(
|
||||
/already running/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run error rollback', () => {
|
||||
it('should rollback task status to paused on run failure', async () => {
|
||||
mockExecAgent.mockRejectedValueOnce(new Error('LLM failed'));
|
||||
|
||||
const task = await caller.create({
|
||||
assigneeAgentId: testAgentId,
|
||||
instruction: 'Test',
|
||||
});
|
||||
|
||||
await expect(caller.run({ id: task.data.id })).rejects.toThrow();
|
||||
|
||||
// Task should be rolled back to paused with error
|
||||
const found = await caller.find({ id: task.data.id });
|
||||
expect(found.data.status).toBe('paused');
|
||||
expect(found.data.error).toContain('LLM failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAll', () => {
|
||||
it('should delete all tasks for user', async () => {
|
||||
await caller.create({ instruction: 'Task 1' });
|
||||
await caller.create({ instruction: 'Task 2' });
|
||||
await caller.create({ instruction: 'Task 3' });
|
||||
|
||||
const result = await caller.clearAll();
|
||||
expect(result.count).toBe(3);
|
||||
|
||||
const list = await caller.list({});
|
||||
expect(list.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelTopic', () => {
|
||||
it('should cancel a running topic and pause task', async () => {
|
||||
const task = await caller.create({
|
||||
assigneeAgentId: testAgentId,
|
||||
instruction: 'Test',
|
||||
});
|
||||
|
||||
await caller.run({ id: task.data.id });
|
||||
|
||||
// Cancel the topic
|
||||
await caller.cancelTopic({ topicId: 'tpc_test' });
|
||||
|
||||
// Task should be paused
|
||||
const found = await caller.find({ id: task.data.id });
|
||||
expect(found.data.status).toBe('paused');
|
||||
});
|
||||
|
||||
it('should reject cancel on non-running topic', async () => {
|
||||
const task = await caller.create({
|
||||
assigneeAgentId: testAgentId,
|
||||
instruction: 'Test',
|
||||
});
|
||||
|
||||
await caller.run({ id: task.data.id });
|
||||
await caller.cancelTopic({ topicId: 'tpc_test' });
|
||||
|
||||
// Try to cancel again — should fail
|
||||
await expect(caller.cancelTopic({ topicId: 'tpc_test' })).rejects.toThrow(/not running/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace documents', () => {
|
||||
it('should pin and show documents in detail', async () => {
|
||||
const task = await caller.create({ instruction: 'Test' });
|
||||
|
||||
// Create a document via the documents table directly
|
||||
const { documents } = await import('@/database/schemas');
|
||||
const [doc] = await serverDB
|
||||
.insert(documents)
|
||||
.values({
|
||||
content: 'Test content',
|
||||
fileType: 'markdown',
|
||||
source: 'test',
|
||||
sourceType: 'api',
|
||||
title: 'Test Doc',
|
||||
totalCharCount: 12,
|
||||
totalLineCount: 1,
|
||||
userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Pin to task
|
||||
await caller.pinDocument({
|
||||
documentId: doc.id,
|
||||
pinnedBy: 'user',
|
||||
taskId: task.data.id,
|
||||
});
|
||||
|
||||
// Check detail workspace
|
||||
const detail = await caller.detail({ id: task.data.identifier });
|
||||
expect(detail.data.workspace).toBeDefined();
|
||||
// Document should appear somewhere in the workspace tree
|
||||
const allDocs = detail.data.workspace!.flatMap((f) => [
|
||||
{ documentId: f.documentId, title: f.title },
|
||||
...(f.children ?? []),
|
||||
]);
|
||||
expect(allDocs.find((d) => d.documentId === doc.id)?.title).toBe('Test Doc');
|
||||
|
||||
// Unpin
|
||||
await caller.unpinDocument({
|
||||
documentId: doc.id,
|
||||
taskId: task.data.id,
|
||||
});
|
||||
|
||||
const detail2 = await caller.detail({ id: task.data.identifier });
|
||||
expect(detail2.data.workspace).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('heartbeat timeout detection', () => {
|
||||
it('should auto-detect timeout on detail and pause task', async () => {
|
||||
const task = await caller.create({
|
||||
assigneeAgentId: testAgentId,
|
||||
instruction: 'Test',
|
||||
});
|
||||
|
||||
// Start running with very short timeout
|
||||
await caller.update({
|
||||
heartbeatTimeout: 1,
|
||||
id: task.data.id,
|
||||
});
|
||||
|
||||
await caller.run({ id: task.data.id });
|
||||
|
||||
// Wait for timeout
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
// detail should auto-detect timeout and pause
|
||||
const detail = await caller.detail({ id: task.data.identifier });
|
||||
expect(detail.data.status).toBe('paused');
|
||||
// Verify stale timeout error gets cleared via find
|
||||
const found = await caller.find({ id: task.data.id });
|
||||
expect(found.data.error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
183
src/server/routers/lambda/brief.ts
Normal file
183
src/server/routers/lambda/brief.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BriefModel } from '@/database/models/brief';
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
|
||||
const briefProcedure = authedProcedure.use(serverDatabase);
|
||||
|
||||
const idInput = z.object({ id: z.string() });
|
||||
|
||||
const createSchema = z.object({
|
||||
actions: z.array(z.record(z.unknown())).optional(),
|
||||
agentId: z.string().optional(),
|
||||
artifacts: z.array(z.string()).optional(),
|
||||
cronJobId: z.string().optional(),
|
||||
priority: z.enum(['urgent', 'normal', 'info']).default('info'),
|
||||
summary: z.string().min(1),
|
||||
taskId: z.string().optional(),
|
||||
title: z.string().min(1),
|
||||
topicId: z.string().optional(),
|
||||
type: z.enum(['decision', 'result', 'insight', 'error']),
|
||||
});
|
||||
|
||||
const listSchema = z.object({
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
offset: z.number().min(0).default(0),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
export const briefRouter = router({
|
||||
create: briefProcedure.input(createSchema).mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const createData = { ...input };
|
||||
|
||||
// Resolve taskId if it's an identifier
|
||||
if (createData.taskId) {
|
||||
const taskModel = new TaskModel(ctx.serverDB, ctx.userId);
|
||||
const task = await taskModel.resolve(createData.taskId);
|
||||
if (task) createData.taskId = task.id;
|
||||
}
|
||||
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const brief = await model.create(createData);
|
||||
return { data: brief, message: 'Brief created', success: true };
|
||||
} catch (error) {
|
||||
console.error('[brief:create]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to create brief',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
delete: briefProcedure.input(idInput).mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const deleted = await model.delete(input.id);
|
||||
if (!deleted) throw new TRPCError({ code: 'NOT_FOUND', message: 'Brief not found' });
|
||||
return { message: 'Brief deleted', success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[brief:delete]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to delete brief',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
find: briefProcedure.input(idInput).query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const brief = await model.findById(input.id);
|
||||
if (!brief) throw new TRPCError({ code: 'NOT_FOUND', message: 'Brief not found' });
|
||||
return { data: brief, success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[brief:find]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to find brief',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
findByTaskId: briefProcedure
|
||||
.input(z.object({ taskId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const items = await model.findByTaskId(input.taskId);
|
||||
return { data: items, success: true };
|
||||
} catch (error) {
|
||||
console.error('[brief:findByTaskId]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to find briefs',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
list: briefProcedure.input(listSchema).query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const result = await model.list(input);
|
||||
return { data: result.briefs, success: true, total: result.total };
|
||||
} catch (error) {
|
||||
console.error('[brief:list]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to list briefs',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
listUnresolved: briefProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const items = await model.listUnresolved();
|
||||
return { data: items, success: true };
|
||||
} catch (error) {
|
||||
console.error('[brief:listUnresolved]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to list unresolved briefs',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
markRead: briefProcedure.input(idInput).mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const brief = await model.markRead(input.id);
|
||||
if (!brief) throw new TRPCError({ code: 'NOT_FOUND', message: 'Brief not found' });
|
||||
return { data: brief, message: 'Brief marked as read', success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[brief:markRead]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to mark brief as read',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
resolve: briefProcedure
|
||||
.input(
|
||||
idInput.merge(
|
||||
z.object({
|
||||
action: z.string().optional(),
|
||||
comment: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const brief = await model.resolve(input.id, {
|
||||
action: input.action,
|
||||
comment: input.comment,
|
||||
});
|
||||
if (!brief) throw new TRPCError({ code: 'NOT_FOUND', message: 'Brief not found' });
|
||||
return { data: brief, message: 'Brief resolved', success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[brief:resolve]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to resolve brief',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
|
@ -27,7 +27,7 @@ export const documentRouter = router({
|
|||
.input(
|
||||
z.object({
|
||||
content: z.string().optional(),
|
||||
editorData: z.string(),
|
||||
editorData: z.string().optional(),
|
||||
fileType: z.string().optional(),
|
||||
knowledgeBaseId: z.string().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
|
|
@ -47,7 +47,7 @@ export const documentRouter = router({
|
|||
}
|
||||
|
||||
// Parse editorData from JSON string to object
|
||||
const editorData = JSON.parse(input.editorData);
|
||||
const editorData = input.editorData ? JSON.parse(input.editorData) : undefined;
|
||||
return ctx.documentService.createDocument({
|
||||
...input,
|
||||
editorData,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { aiChatRouter } from './aiChat';
|
|||
import { aiModelRouter } from './aiModel';
|
||||
import { aiProviderRouter } from './aiProvider';
|
||||
import { apiKeyRouter } from './apiKey';
|
||||
import { briefRouter } from './brief';
|
||||
import { chunkRouter } from './chunk';
|
||||
import { comfyuiRouter } from './comfyui';
|
||||
import { configRouter } from './config';
|
||||
|
|
@ -47,6 +48,7 @@ import { searchRouter } from './search';
|
|||
import { sessionRouter } from './session';
|
||||
import { sessionGroupRouter } from './sessionGroup';
|
||||
import { shareRouter } from './share';
|
||||
import { taskRouter } from './task';
|
||||
import { threadRouter } from './thread';
|
||||
import { topicRouter } from './topic';
|
||||
import { uploadRouter } from './upload';
|
||||
|
|
@ -64,6 +66,8 @@ export const lambdaRouter = router({
|
|||
agentEval: agentEvalRouter,
|
||||
agentEvalExternal: agentEvalExternalRouter,
|
||||
agentSkills: agentSkillsRouter,
|
||||
task: taskRouter,
|
||||
brief: briefRouter,
|
||||
aiAgent: aiAgentRouter,
|
||||
aiChat: aiChatRouter,
|
||||
aiModel: aiModelRouter,
|
||||
|
|
|
|||
1249
src/server/routers/lambda/task.ts
Normal file
1249
src/server/routers/lambda/task.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -184,12 +184,7 @@ export class AgentRuntimeService {
|
|||
if (impl instanceof LocalQueueServiceImpl) {
|
||||
log('Setting up local execution callback');
|
||||
impl.setExecutionCallback(async (operationId, stepIndex, context) => {
|
||||
log('[%s][%d] Local step executing...', operationId, stepIndex);
|
||||
await this.executeStep({
|
||||
context,
|
||||
operationId,
|
||||
stepIndex,
|
||||
});
|
||||
await this.executeStep({ context, operationId, stepIndex });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { type AgentRuntimeContext, type AgentState } from '@lobechat/agent-runtime';
|
||||
import { type LobeToolManifest } from '@lobechat/context-engine';
|
||||
import { type LobeToolManifest, type SkillMeta } from '@lobechat/context-engine';
|
||||
import { type UserInterventionConfig } from '@lobechat/types';
|
||||
|
||||
import { type ServerUserMemoryConfig } from '@/server/modules/Mecha/ContextEngineering/types';
|
||||
|
|
@ -135,6 +135,7 @@ export interface OperationCreationParams {
|
|||
appContext: {
|
||||
agentId?: string;
|
||||
groupId?: string | null;
|
||||
taskId?: string;
|
||||
threadId?: string | null;
|
||||
topicId?: string | null;
|
||||
trigger?: string;
|
||||
|
|
@ -169,7 +170,7 @@ export interface OperationCreationParams {
|
|||
/** Abort startup before the first step is scheduled */
|
||||
signal?: AbortSignal;
|
||||
/** Skill metas for <available_skills> prompt injection */
|
||||
skillMetas?: Array<{ description: string; identifier: string; name: string }>;
|
||||
skillMetas?: SkillMeta[];
|
||||
/**
|
||||
* Step lifecycle callbacks
|
||||
* Used to inject custom logic at different stages of step execution
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from '@lobechat/builtin-tool-remote-device';
|
||||
import { builtinTools, manualModeExcludeToolIds } from '@lobechat/builtin-tools';
|
||||
import { LOADING_FLAT } from '@lobechat/const';
|
||||
import type { LobeToolManifest } from '@lobechat/context-engine';
|
||||
import type { LobeToolManifest, SkillMeta } from '@lobechat/context-engine';
|
||||
import type { LobeChatDatabase } from '@lobechat/database';
|
||||
import type {
|
||||
ChatTopicBotContext,
|
||||
|
|
@ -82,6 +82,8 @@ function formatErrorForMetadata(error: unknown): Record<string, any> | undefined
|
|||
* This extends the public ExecAgentParams with server-side only options
|
||||
*/
|
||||
interface InternalExecAgentParams extends ExecAgentParams {
|
||||
/** Additional plugin IDs to inject (e.g., task tool during task execution) */
|
||||
additionalPluginIds?: string[];
|
||||
/** Bot context for topic metadata (platform, applicationId, platformThreadId) */
|
||||
botContext?: ChatTopicBotContext;
|
||||
/** Bot platform context for injecting platform capabilities (e.g. markdown support) */
|
||||
|
|
@ -128,13 +130,15 @@ interface InternalExecAgentParams extends ExecAgentParams {
|
|||
* Defaults to true. Set to false for non-streaming scenarios (e.g., bot integrations).
|
||||
*/
|
||||
stream?: boolean;
|
||||
/** Task ID that triggered this execution (if trigger is 'task') */
|
||||
taskId?: string;
|
||||
/**
|
||||
* Custom title for the topic.
|
||||
* When provided (including empty string), overrides the default prompt-based title.
|
||||
* When undefined, falls back to prompt.slice(0, 50).
|
||||
*/
|
||||
title?: string;
|
||||
/** Topic creation trigger source ('cron' | 'chat' | 'api') */
|
||||
/** Topic creation trigger source ('cron' | 'chat' | 'api' | 'task') */
|
||||
trigger?: string;
|
||||
/**
|
||||
* User intervention configuration
|
||||
|
|
@ -203,6 +207,7 @@ export class AiAgentService {
|
|||
*/
|
||||
async execAgent(params: InternalExecAgentParams): Promise<ExecAgentResult> {
|
||||
const {
|
||||
additionalPluginIds,
|
||||
agentId,
|
||||
slug,
|
||||
prompt,
|
||||
|
|
@ -220,6 +225,7 @@ export class AiAgentService {
|
|||
title,
|
||||
trigger,
|
||||
cronJobId,
|
||||
taskId,
|
||||
evalContext,
|
||||
maxSteps,
|
||||
signal,
|
||||
|
|
@ -325,10 +331,10 @@ export class AiAgentService {
|
|||
// 3. Handle topic creation: if no topicId provided, create a new topic; otherwise reuse existing
|
||||
let topicId = appContext?.topicId;
|
||||
if (!topicId) {
|
||||
// Prepare metadata with cronJobId and botContext if provided
|
||||
// Prepare metadata with cronJobId, taskId, and botContext if provided
|
||||
const metadata =
|
||||
cronJobId || botContext
|
||||
? { bot: botContext, cronJobId: cronJobId || undefined }
|
||||
cronJobId || taskId || botContext
|
||||
? { bot: botContext, cronJobId: cronJobId || undefined, taskId: taskId || undefined }
|
||||
: undefined;
|
||||
|
||||
const newTopic = await this.topicModel.create({
|
||||
|
|
@ -437,6 +443,7 @@ export class AiAgentService {
|
|||
const hasTopicReference = /refer_topic/.test(prompt ?? '');
|
||||
const agentPlugins = [
|
||||
...(agentConfig?.plugins ?? []),
|
||||
...(additionalPluginIds || []),
|
||||
...(hasTopicReference ? ['lobe-topic-reference'] : []),
|
||||
];
|
||||
|
||||
|
|
@ -475,6 +482,7 @@ export class AiAgentService {
|
|||
// Include device tool IDs so ToolsEngine can process them via enableChecker
|
||||
const pluginIds = [
|
||||
...(agentConfig.plugins || []),
|
||||
...(additionalPluginIds || []),
|
||||
LocalSystemManifest.identifier,
|
||||
RemoteDeviceManifest.identifier,
|
||||
];
|
||||
|
|
@ -833,9 +841,13 @@ export class AiAgentService {
|
|||
|
||||
// 18. Build skill metas for <available_skills> prompt injection
|
||||
// Combine builtin skills + user DB skills so AI can discover all installed skills
|
||||
let skillMetas: Array<{ description: string; identifier: string; name: string }> = [];
|
||||
// Skills whose identifier is in the agent's enabled plugins are auto-activated (content injected directly)
|
||||
const enabledPluginIds = new Set(agentPlugins);
|
||||
let skillMetas: SkillMeta[] = [];
|
||||
try {
|
||||
const builtinMetas = builtinSkills.map((s) => ({
|
||||
activated: enabledPluginIds.has(s.identifier),
|
||||
content: s.content,
|
||||
description: s.description,
|
||||
identifier: s.identifier,
|
||||
name: s.name,
|
||||
|
|
@ -864,6 +876,7 @@ export class AiAgentService {
|
|||
appContext: {
|
||||
agentId: resolvedAgentId,
|
||||
groupId: appContext?.groupId,
|
||||
taskId,
|
||||
threadId: appContext?.threadId,
|
||||
topicId,
|
||||
trigger,
|
||||
|
|
|
|||
167
src/server/services/task/index.ts
Normal file
167
src/server/services/task/index.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import type {
|
||||
TaskDetailActivity,
|
||||
TaskDetailData,
|
||||
TaskDetailWorkspaceNode,
|
||||
TaskTopicHandoff,
|
||||
WorkspaceData,
|
||||
} from '@lobechat/types';
|
||||
|
||||
import { BriefModel } from '@/database/models/brief';
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { TaskTopicModel } from '@/database/models/taskTopic';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
|
||||
const emptyWorkspace: WorkspaceData = { nodeMap: {}, tree: [] };
|
||||
|
||||
export class TaskService {
|
||||
private briefModel: BriefModel;
|
||||
private taskModel: TaskModel;
|
||||
private taskTopicModel: TaskTopicModel;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.taskModel = new TaskModel(db, userId);
|
||||
this.taskTopicModel = new TaskTopicModel(db, userId);
|
||||
this.briefModel = new BriefModel(db, userId);
|
||||
}
|
||||
|
||||
async getTaskDetail(taskIdOrIdentifier: string): Promise<TaskDetailData | null> {
|
||||
const task = await this.taskModel.resolve(taskIdOrIdentifier);
|
||||
if (!task) return null;
|
||||
|
||||
const [subtasks, dependencies, topics, briefs, comments, workspace] = await Promise.all([
|
||||
this.taskModel.findSubtasks(task.id),
|
||||
this.taskModel.getDependencies(task.id),
|
||||
this.taskTopicModel.findWithHandoff(task.id).catch(() => []),
|
||||
this.briefModel.findByTaskId(task.id).catch(() => []),
|
||||
this.taskModel.getComments(task.id).catch(() => []),
|
||||
this.taskModel.getTreePinnedDocuments(task.id).catch(() => emptyWorkspace),
|
||||
]);
|
||||
|
||||
// Build subtask dependency map
|
||||
const subtaskIds = subtasks.map((s) => s.id);
|
||||
const subtaskDeps =
|
||||
subtaskIds.length > 0
|
||||
? await this.taskModel.getDependenciesByTaskIds(subtaskIds).catch(() => [])
|
||||
: [];
|
||||
const idToIdentifier = new Map(subtasks.map((s) => [s.id, s.identifier]));
|
||||
const depMap = new Map<string, string>();
|
||||
for (const dep of subtaskDeps) {
|
||||
const depId = idToIdentifier.get(dep.dependsOnId);
|
||||
if (depId) depMap.set(dep.taskId, depId);
|
||||
}
|
||||
|
||||
// Resolve dependency task identifiers
|
||||
const depTaskIds = [...new Set(dependencies.map((d) => d.dependsOnId))];
|
||||
const depTasks = await this.taskModel.findByIds(depTaskIds);
|
||||
const depIdToInfo = new Map(
|
||||
depTasks.map((t) => [t.id, { identifier: t.identifier, name: t.name }]),
|
||||
);
|
||||
|
||||
// Resolve parent
|
||||
let parent: { identifier: string; name: string | null } | null = null;
|
||||
if (task.parentTaskId) {
|
||||
const parentTask = await this.taskModel.findById(task.parentTaskId);
|
||||
if (parentTask) {
|
||||
parent = { identifier: parentTask.identifier, name: parentTask.name };
|
||||
}
|
||||
}
|
||||
|
||||
// Build workspace tree (recursive)
|
||||
const buildWorkspaceNodes = (treeNodes: typeof workspace.tree): TaskDetailWorkspaceNode[] =>
|
||||
treeNodes.map((node) => {
|
||||
const doc = workspace.nodeMap[node.id];
|
||||
return {
|
||||
children: node.children.length > 0 ? buildWorkspaceNodes(node.children) : undefined,
|
||||
createdAt: doc?.createdAt ? new Date(doc.createdAt).toISOString() : undefined,
|
||||
documentId: node.id,
|
||||
fileType: doc?.fileType,
|
||||
size: doc?.charCount,
|
||||
sourceTaskIdentifier: doc?.sourceTaskIdentifier,
|
||||
title: doc?.title,
|
||||
};
|
||||
});
|
||||
|
||||
const workspaceFolders = buildWorkspaceNodes(workspace.tree);
|
||||
|
||||
// Build activities (merged & sorted desc by time)
|
||||
const toISO = (d: Date | string | null | undefined) =>
|
||||
d ? new Date(d).toISOString() : undefined;
|
||||
|
||||
const activities: TaskDetailActivity[] = [
|
||||
...topics.map((t) => ({
|
||||
id: t.topicId ?? undefined,
|
||||
seq: t.seq,
|
||||
status: t.status,
|
||||
time: toISO(t.createdAt),
|
||||
title: (t.handoff as TaskTopicHandoff | null)?.title || 'Untitled',
|
||||
type: 'topic' as const,
|
||||
})),
|
||||
...briefs.map((b) => ({
|
||||
briefType: b.type,
|
||||
id: b.id,
|
||||
priority: b.priority,
|
||||
resolvedAction: b.resolvedAction
|
||||
? b.resolvedComment
|
||||
? `${b.resolvedAction}: ${b.resolvedComment}`
|
||||
: b.resolvedAction
|
||||
: undefined,
|
||||
summary: b.summary,
|
||||
time: toISO(b.createdAt),
|
||||
title: b.title,
|
||||
type: 'brief' as const,
|
||||
})),
|
||||
...comments.map((c) => ({
|
||||
agentId: c.authorAgentId,
|
||||
content: c.content,
|
||||
time: toISO(c.createdAt),
|
||||
type: 'comment' as const,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
if (!a.time) return 1;
|
||||
if (!b.time) return -1;
|
||||
return a.time.localeCompare(b.time);
|
||||
});
|
||||
|
||||
return {
|
||||
agentId: task.assigneeAgentId,
|
||||
checkpoint: this.taskModel.getCheckpointConfig(task),
|
||||
createdAt: task.createdAt ? new Date(task.createdAt).toISOString() : undefined,
|
||||
dependencies: dependencies.map((d) => {
|
||||
const info = depIdToInfo.get(d.dependsOnId);
|
||||
return {
|
||||
dependsOn: info?.identifier ?? d.dependsOnId,
|
||||
name: info?.name,
|
||||
type: d.type,
|
||||
};
|
||||
}),
|
||||
description: task.description,
|
||||
error: task.error,
|
||||
heartbeat:
|
||||
task.heartbeatTimeout || task.lastHeartbeatAt
|
||||
? {
|
||||
interval: task.heartbeatInterval,
|
||||
lastAt: task.lastHeartbeatAt ? new Date(task.lastHeartbeatAt).toISOString() : null,
|
||||
timeout: task.heartbeatTimeout,
|
||||
}
|
||||
: undefined,
|
||||
identifier: task.identifier,
|
||||
instruction: task.instruction,
|
||||
name: task.name,
|
||||
parent,
|
||||
priority: task.priority,
|
||||
review: this.taskModel.getReviewConfig(task),
|
||||
status: task.status,
|
||||
userId: task.assigneeUserId,
|
||||
subtasks: subtasks.map((s) => ({
|
||||
blockedBy: depMap.get(s.id),
|
||||
identifier: s.identifier,
|
||||
name: s.name,
|
||||
priority: s.priority,
|
||||
status: s.status,
|
||||
})),
|
||||
activities: activities.length > 0 ? activities : undefined,
|
||||
topicCount: topics.length > 0 ? topics.length : undefined,
|
||||
workspace: workspaceFolders.length > 0 ? workspaceFolders : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
264
src/server/services/taskLifecycle/index.ts
Normal file
264
src/server/services/taskLifecycle/index.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { chainTaskTopicHandoff, TASK_TOPIC_HANDOFF_SCHEMA } from '@lobechat/prompts';
|
||||
import { DEFAULT_BRIEF_ACTIONS } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { BriefModel } from '@/database/models/brief';
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { TaskTopicModel } from '@/database/models/taskTopic';
|
||||
import { TopicModel } from '@/database/models/topic';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
import { SystemAgentService } from '@/server/services/systemAgent';
|
||||
import { TaskReviewService } from '@/server/services/taskReview';
|
||||
|
||||
const log = debug('task-lifecycle');
|
||||
|
||||
export interface TopicCompleteParams {
|
||||
errorMessage?: string;
|
||||
lastAssistantContent?: string;
|
||||
operationId: string;
|
||||
reason: string; // 'done' | 'error' | 'interrupted' | ...
|
||||
taskId: string;
|
||||
taskIdentifier: string;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskLifecycleService handles task state transitions triggered by topic completion.
|
||||
* Used by both local onComplete hooks and production webhook callbacks.
|
||||
*/
|
||||
export class TaskLifecycleService {
|
||||
private briefModel: BriefModel;
|
||||
private db: LobeChatDatabase;
|
||||
private systemAgentService: SystemAgentService;
|
||||
private taskModel: TaskModel;
|
||||
private taskTopicModel: TaskTopicModel;
|
||||
private topicModel: TopicModel;
|
||||
private userId: string;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
this.taskModel = new TaskModel(db, userId);
|
||||
this.taskTopicModel = new TaskTopicModel(db, userId);
|
||||
this.briefModel = new BriefModel(db, userId);
|
||||
this.topicModel = new TopicModel(db, userId);
|
||||
this.systemAgentService = new SystemAgentService(db, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle topic completion — the core lifecycle method.
|
||||
*
|
||||
* Flow: updateHeartbeat → updateTopicStatus → handoff → review → checkpoint
|
||||
*/
|
||||
async onTopicComplete(params: TopicCompleteParams): Promise<void> {
|
||||
const { taskId, taskIdentifier, topicId, reason, lastAssistantContent, errorMessage } = params;
|
||||
|
||||
log('onTopicComplete: task=%s topic=%s reason=%s', taskIdentifier, topicId, reason);
|
||||
|
||||
await this.taskModel.updateHeartbeat(taskId);
|
||||
|
||||
const currentTask = await this.taskModel.findById(taskId);
|
||||
|
||||
if (reason === 'done') {
|
||||
// 1. Update topic status
|
||||
if (topicId) await this.taskTopicModel.updateStatus(taskId, topicId, 'completed');
|
||||
|
||||
// 2. Generate handoff summary + topic title
|
||||
if (topicId && lastAssistantContent) {
|
||||
await this.generateHandoff(
|
||||
taskId,
|
||||
taskIdentifier,
|
||||
topicId,
|
||||
lastAssistantContent,
|
||||
currentTask,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Auto-review (if configured)
|
||||
if (currentTask && topicId && lastAssistantContent) {
|
||||
await this.runAutoReview(
|
||||
taskId,
|
||||
taskIdentifier,
|
||||
topicId,
|
||||
lastAssistantContent,
|
||||
currentTask,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Check if agent delivered a result brief → auto-complete
|
||||
// If the latest brief is type 'result' and no review is configured, complete the task
|
||||
const reviewConfig = currentTask ? this.taskModel.getReviewConfig(currentTask) : null;
|
||||
if (!reviewConfig?.enabled) {
|
||||
const briefs = await this.briefModel.findByTaskId(taskId);
|
||||
const latestBrief = briefs[0]; // sorted by createdAt desc
|
||||
if (latestBrief?.type === 'result') {
|
||||
await this.taskModel.updateStatus(taskId, 'completed', { error: null });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Checkpoint — pause for user review
|
||||
if (currentTask && this.taskModel.shouldPauseOnTopicComplete(currentTask)) {
|
||||
await this.taskModel.updateStatus(taskId, 'paused', { error: null });
|
||||
}
|
||||
} else if (reason === 'error') {
|
||||
if (topicId) await this.taskTopicModel.updateStatus(taskId, topicId, 'failed');
|
||||
|
||||
const topicSeq = currentTask?.totalTopics || '?';
|
||||
const topicRef = topicId ? ` #${topicSeq} (${topicId})` : '';
|
||||
|
||||
await this.briefModel.create({
|
||||
actions: DEFAULT_BRIEF_ACTIONS['error'],
|
||||
priority: 'urgent',
|
||||
summary: `Execution failed: ${errorMessage || 'Unknown error'}`,
|
||||
taskId,
|
||||
title: `${taskIdentifier} topic${topicRef} error`,
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
await this.taskModel.updateStatus(taskId, 'paused');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate handoff summary and update topic title via LLM.
|
||||
* Writes to task_topics handoff fields + updates topic title.
|
||||
*/
|
||||
private async generateHandoff(
|
||||
taskId: string,
|
||||
taskIdentifier: string,
|
||||
topicId: string,
|
||||
lastAssistantContent: string,
|
||||
currentTask: any,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { model, provider } = await (this.systemAgentService as any).getTaskModelConfig(
|
||||
'topic',
|
||||
);
|
||||
|
||||
const payload = chainTaskTopicHandoff({
|
||||
lastAssistantContent,
|
||||
taskInstruction: currentTask?.instruction || '',
|
||||
taskName: currentTask?.name || taskIdentifier,
|
||||
});
|
||||
|
||||
const modelRuntime = await initModelRuntimeFromDB(this.db, this.userId, provider);
|
||||
const result = await modelRuntime.generateObject(
|
||||
{
|
||||
messages: payload.messages as any[],
|
||||
model,
|
||||
schema: { name: 'task_topic_handoff', schema: TASK_TOPIC_HANDOFF_SCHEMA },
|
||||
},
|
||||
{ metadata: { trigger: 'task-handoff' } },
|
||||
);
|
||||
|
||||
const handoff = result as {
|
||||
keyFindings?: string[];
|
||||
nextAction?: string;
|
||||
summary?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
// Update topic title
|
||||
if (handoff.title) {
|
||||
await this.topicModel.update(topicId, { title: handoff.title });
|
||||
}
|
||||
|
||||
// Store handoff in task_topics dedicated fields
|
||||
await this.taskTopicModel.updateHandoff(taskId, topicId, handoff);
|
||||
|
||||
log('handoff generated for topic %s: title=%s', topicId, handoff.title);
|
||||
} catch (e) {
|
||||
console.warn('[TaskLifecycle] handoff generation failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run auto-review if configured.
|
||||
* @returns true if auto-retry is in progress (caller should skip pause)
|
||||
*/
|
||||
private async runAutoReview(
|
||||
taskId: string,
|
||||
taskIdentifier: string,
|
||||
topicId: string,
|
||||
content: string,
|
||||
currentTask: any,
|
||||
): Promise<void> {
|
||||
const reviewConfig = this.taskModel.getReviewConfig(currentTask);
|
||||
if (!reviewConfig?.enabled || !reviewConfig.rubrics?.length) return;
|
||||
|
||||
try {
|
||||
const topicLinks = await this.taskTopicModel.findByTaskId(taskId);
|
||||
const targetTopic = topicLinks.find((t) => t.topicId === topicId);
|
||||
const iteration = (targetTopic?.reviewIteration || 0) + 1;
|
||||
|
||||
const reviewService = new TaskReviewService(this.db, this.userId);
|
||||
const reviewResult = await reviewService.review({
|
||||
content,
|
||||
iteration,
|
||||
judge: reviewConfig.judge || {},
|
||||
rubrics: reviewConfig.rubrics,
|
||||
taskName: currentTask.name || taskIdentifier,
|
||||
});
|
||||
|
||||
log(
|
||||
'review result: task=%s passed=%s score=%d iteration=%d/%d',
|
||||
taskIdentifier,
|
||||
reviewResult.passed,
|
||||
reviewResult.overallScore,
|
||||
iteration,
|
||||
reviewConfig.maxIterations,
|
||||
);
|
||||
|
||||
// Save review result to task_topics
|
||||
await this.taskTopicModel.updateReview(taskId, topicId, {
|
||||
iteration,
|
||||
passed: reviewResult.passed,
|
||||
score: reviewResult.overallScore,
|
||||
scores: reviewResult.rubricResults,
|
||||
});
|
||||
|
||||
if (reviewResult.passed) {
|
||||
await this.briefModel.create({
|
||||
priority: 'info',
|
||||
summary: `Review passed (score: ${reviewResult.overallScore}%, iteration: ${iteration}). ${content.slice(0, 150)}`,
|
||||
taskId,
|
||||
title: `${taskIdentifier} review passed`,
|
||||
type: 'result',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (reviewConfig.autoRetry && iteration < reviewConfig.maxIterations) {
|
||||
await this.briefModel.create({
|
||||
priority: 'normal',
|
||||
summary: `Review failed (score: ${reviewResult.overallScore}%, iteration ${iteration}/${reviewConfig.maxIterations}). Auto-retrying...`,
|
||||
taskId,
|
||||
title: `${taskIdentifier} review failed, retrying`,
|
||||
type: 'insight',
|
||||
});
|
||||
|
||||
// Pause so the webhook / polling loop can pick up and re-run
|
||||
await this.taskModel.updateStatus(taskId, 'paused', { error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Max iterations reached
|
||||
await this.briefModel.create({
|
||||
actions: [
|
||||
{ key: 'retry', label: '🔄 重试', type: 'resolve' as const },
|
||||
{ key: 'approve', label: '✅ 强制通过', type: 'resolve' as const },
|
||||
{ key: 'feedback', label: '💬 修改意见', type: 'comment' as const },
|
||||
],
|
||||
priority: 'urgent',
|
||||
summary: `Review failed after ${iteration} iteration(s) (score: ${reviewResult.overallScore}%). Suggestions: ${reviewResult.suggestions?.join('; ') || 'none'}`,
|
||||
taskId,
|
||||
title: `${taskIdentifier} review failed — needs attention`,
|
||||
type: 'decision',
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[TaskLifecycle] auto-review failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/server/services/taskReview/index.ts
Normal file
121
src/server/services/taskReview/index.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { DEFAULT_SYSTEM_AGENT_CONFIG } from '@lobechat/const';
|
||||
import { evaluate, type EvaluateResult, type RubricResult } from '@lobechat/eval-rubric';
|
||||
import type { EvalBenchmarkRubric } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { UserModel } from '@/database/models/user';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
|
||||
const log = debug('task-review');
|
||||
|
||||
export interface ReviewConfig {
|
||||
autoRetry: boolean;
|
||||
enabled: boolean;
|
||||
judge: ReviewJudge;
|
||||
maxIterations: number;
|
||||
rubrics: EvalBenchmarkRubric[];
|
||||
}
|
||||
|
||||
export interface ReviewJudge {
|
||||
model?: string;
|
||||
prompt?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface ReviewResult {
|
||||
iteration: number;
|
||||
overallScore: number;
|
||||
passed: boolean;
|
||||
rubricResults: RubricResult[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export class TaskReviewService {
|
||||
private db: LobeChatDatabase;
|
||||
private userId: string;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
async review(params: {
|
||||
content: string;
|
||||
iteration?: number;
|
||||
judge: ReviewJudge;
|
||||
rubrics: EvalBenchmarkRubric[];
|
||||
taskName: string;
|
||||
}): Promise<ReviewResult> {
|
||||
const { content, rubrics, judge, taskName, iteration = 1 } = params;
|
||||
|
||||
// 1. Resolve model/provider
|
||||
const { model, provider } = await this.resolveModelConfig(judge);
|
||||
|
||||
log(
|
||||
'Starting review for task %s (iteration %d, model=%s, provider=%s, rubrics=%d)',
|
||||
taskName,
|
||||
iteration,
|
||||
model,
|
||||
provider,
|
||||
rubrics.length,
|
||||
);
|
||||
|
||||
// 2. Initialize ModelRuntime for LLM-based rubrics
|
||||
const modelRuntime = await initModelRuntimeFromDB(this.db, this.userId, provider);
|
||||
|
||||
// 3. Run evaluate() from @lobechat/eval-rubric
|
||||
const result: EvaluateResult = await evaluate(
|
||||
{
|
||||
actual: content,
|
||||
rubrics,
|
||||
testCase: { input: taskName },
|
||||
},
|
||||
{
|
||||
matchContext: {
|
||||
generateObject: async (payload) => {
|
||||
return (modelRuntime as any).generateObject(
|
||||
{
|
||||
messages: payload.messages as any[],
|
||||
model: payload.model || model,
|
||||
schema: { name: 'judge_score', schema: payload.schema },
|
||||
},
|
||||
{ metadata: { trigger: 'task-review' } },
|
||||
);
|
||||
},
|
||||
judgeModel: model,
|
||||
},
|
||||
passThreshold: 0.6,
|
||||
},
|
||||
);
|
||||
|
||||
log('Review complete: %s (score: %.2f, passed: %s)', taskName, result.score, result.passed);
|
||||
|
||||
return {
|
||||
iteration,
|
||||
overallScore: Math.round(result.score * 100),
|
||||
passed: result.passed,
|
||||
rubricResults: result.rubricResults,
|
||||
suggestions: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveModelConfig(
|
||||
judge: ReviewJudge,
|
||||
): Promise<{ model: string; provider: string }> {
|
||||
if (judge.model && judge.provider) {
|
||||
return { model: judge.model, provider: judge.provider };
|
||||
}
|
||||
|
||||
const userModel = new UserModel(this.db, this.userId);
|
||||
const settings = await userModel.getUserSettings();
|
||||
const systemAgent = settings?.systemAgent as Record<string, any> | undefined;
|
||||
const topicConfig = systemAgent?.topic;
|
||||
const defaults = DEFAULT_SYSTEM_AGENT_CONFIG.topic;
|
||||
|
||||
return {
|
||||
model: judge.model || topicConfig?.model || defaults.model,
|
||||
provider: judge.provider || topicConfig?.provider || defaults.provider,
|
||||
};
|
||||
}
|
||||
}
|
||||
25
src/server/services/taskScheduler/impls/index.ts
Normal file
25
src/server/services/taskScheduler/impls/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { appEnv } from '@/envs/app';
|
||||
|
||||
import { LocalTaskScheduler } from './local';
|
||||
import type { TaskSchedulerImpl } from './type';
|
||||
|
||||
// QStash implementation will be added later
|
||||
// import { QStashTaskScheduler } from './qstash';
|
||||
|
||||
/**
|
||||
* Create task scheduler module
|
||||
*
|
||||
* When AGENT_RUNTIME_MODE=queue: QStash (production)
|
||||
* When default: Local (setTimeout-based)
|
||||
*/
|
||||
export const createTaskSchedulerModule = (): TaskSchedulerImpl => {
|
||||
if (appEnv.enableQueueAgentRuntime) {
|
||||
// TODO: QStash implementation
|
||||
// return new QStashTaskScheduler({ qstashToken });
|
||||
}
|
||||
|
||||
return new LocalTaskScheduler();
|
||||
};
|
||||
|
||||
export { LocalTaskScheduler } from './local';
|
||||
export type { TaskSchedulerImpl } from './type';
|
||||
55
src/server/services/taskScheduler/impls/local.ts
Normal file
55
src/server/services/taskScheduler/impls/local.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import debug from 'debug';
|
||||
|
||||
import type { ScheduleNextTopicParams, TaskSchedulerImpl } from './type';
|
||||
|
||||
const log = debug('task-scheduler:local');
|
||||
|
||||
export type TaskExecutionCallback = (taskId: string, userId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Local task scheduler using setTimeout
|
||||
* For local development without QStash
|
||||
*/
|
||||
export class LocalTaskScheduler implements TaskSchedulerImpl {
|
||||
private executionCallback: TaskExecutionCallback | null = null;
|
||||
private pendingSchedules: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
setExecutionCallback(callback: TaskExecutionCallback): void {
|
||||
this.executionCallback = callback;
|
||||
}
|
||||
|
||||
async scheduleNextTopic(params: ScheduleNextTopicParams): Promise<string> {
|
||||
const { taskId, userId, delay = 0 } = params;
|
||||
const scheduleId = `local-task-${taskId}-${Date.now()}`;
|
||||
|
||||
log('Scheduling next topic for task %s (delay: %ds)', taskId, delay);
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
this.pendingSchedules.delete(scheduleId);
|
||||
|
||||
if (!this.executionCallback) {
|
||||
log('Warning: No execution callback set');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log('Executing next topic for task %s', taskId);
|
||||
await this.executionCallback(taskId, userId);
|
||||
} catch (error) {
|
||||
log('Failed to execute next topic for task %s: %O', taskId, error);
|
||||
}
|
||||
}, delay * 1000);
|
||||
|
||||
this.pendingSchedules.set(scheduleId, timer);
|
||||
return scheduleId;
|
||||
}
|
||||
|
||||
async cancelScheduled(scheduleId: string): Promise<void> {
|
||||
const timer = this.pendingSchedules.get(scheduleId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.pendingSchedules.delete(scheduleId);
|
||||
log('Canceled schedule %s', scheduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/server/services/taskScheduler/impls/type.ts
Normal file
11
src/server/services/taskScheduler/impls/type.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export interface ScheduleNextTopicParams {
|
||||
delay?: number; // delay in seconds, default 0
|
||||
taskId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface TaskSchedulerImpl {
|
||||
cancelScheduled: (scheduleId: string) => Promise<void>;
|
||||
|
||||
scheduleNextTopic: (params: ScheduleNextTopicParams) => Promise<string>;
|
||||
}
|
||||
2
src/server/services/taskScheduler/index.ts
Normal file
2
src/server/services/taskScheduler/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { createTaskSchedulerModule, LocalTaskScheduler } from './impls';
|
||||
export type { ScheduleNextTopicParams, TaskSchedulerImpl } from './impls/type';
|
||||
78
src/server/services/toolExecution/serverRuntimes/brief.ts
Normal file
78
src/server/services/toolExecution/serverRuntimes/brief.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { BriefIdentifier } from '@lobechat/builtin-tool-brief';
|
||||
import { formatBriefCreated, formatCheckpointCreated } from '@lobechat/prompts';
|
||||
import { DEFAULT_BRIEF_ACTIONS } from '@lobechat/types';
|
||||
|
||||
import { BriefModel } from '@/database/models/brief';
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
const createBriefRuntime = ({
|
||||
briefModel,
|
||||
taskId,
|
||||
taskModel,
|
||||
}: {
|
||||
briefModel: BriefModel;
|
||||
taskId?: string;
|
||||
taskModel: TaskModel;
|
||||
}) => ({
|
||||
createBrief: async (args: {
|
||||
actions?: Array<{ key: string; label: string; type: string }>;
|
||||
priority?: string;
|
||||
summary: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}) => {
|
||||
const actions = args.actions || DEFAULT_BRIEF_ACTIONS[args.type] || [];
|
||||
|
||||
const brief = await briefModel.create({
|
||||
actions,
|
||||
priority: args.priority || 'info',
|
||||
summary: args.summary,
|
||||
taskId,
|
||||
title: args.title,
|
||||
type: args.type,
|
||||
});
|
||||
|
||||
return {
|
||||
content: formatBriefCreated({
|
||||
id: brief.id,
|
||||
priority: args.priority || 'info',
|
||||
summary: args.summary,
|
||||
title: args.title,
|
||||
type: args.type,
|
||||
}),
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
|
||||
requestCheckpoint: async (args: { reason: string }) => {
|
||||
if (taskId) {
|
||||
await taskModel.updateStatus(taskId, 'paused');
|
||||
}
|
||||
|
||||
await briefModel.create({
|
||||
priority: 'normal',
|
||||
summary: args.reason,
|
||||
taskId,
|
||||
title: 'Checkpoint requested',
|
||||
type: 'decision',
|
||||
});
|
||||
|
||||
return { content: formatCheckpointCreated(args.reason), success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const briefRuntime: ServerRuntimeRegistration = {
|
||||
factory: (context) => {
|
||||
if (!context.userId || !context.serverDB) {
|
||||
throw new Error('userId and serverDB are required for Brief tool execution');
|
||||
}
|
||||
|
||||
const briefModel = new BriefModel(context.serverDB, context.userId);
|
||||
const taskModel = new TaskModel(context.serverDB, context.userId);
|
||||
|
||||
return createBriefRuntime({ briefModel, taskId: context.taskId, taskModel });
|
||||
},
|
||||
identifier: BriefIdentifier,
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
import { type ToolExecutionContext } from '../types';
|
||||
import { activatorRuntime } from './activator';
|
||||
import { agentDocumentsRuntime } from './agentDocuments';
|
||||
import { briefRuntime } from './brief';
|
||||
import { calculatorRuntime } from './calculator';
|
||||
import { cloudSandboxRuntime } from './cloudSandbox';
|
||||
import { localSystemRuntime } from './localSystem';
|
||||
|
|
@ -17,6 +18,7 @@ import { notebookRuntime } from './notebook';
|
|||
import { remoteDeviceRuntime } from './remoteDevice';
|
||||
import { skillsRuntime } from './skills';
|
||||
import { skillStoreRuntime } from './skillStore';
|
||||
import { taskRuntime } from './task';
|
||||
import { topicReferenceRuntime } from './topicReference';
|
||||
import { type ServerRuntimeFactory, type ServerRuntimeRegistration } from './types';
|
||||
import { webBrowsingRuntime } from './webBrowsing';
|
||||
|
|
@ -48,6 +50,8 @@ registerRuntimes([
|
|||
activatorRuntime,
|
||||
localSystemRuntime,
|
||||
remoteDeviceRuntime,
|
||||
briefRuntime,
|
||||
taskRuntime,
|
||||
topicReferenceRuntime,
|
||||
]);
|
||||
|
||||
|
|
|
|||
241
src/server/services/toolExecution/serverRuntimes/task.ts
Normal file
241
src/server/services/toolExecution/serverRuntimes/task.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { TaskIdentifier } from '@lobechat/builtin-tool-task';
|
||||
import {
|
||||
formatDependencyAdded,
|
||||
formatDependencyRemoved,
|
||||
formatTaskCreated,
|
||||
formatTaskDetail,
|
||||
formatTaskEdited,
|
||||
formatTaskList,
|
||||
priorityLabel,
|
||||
} from '@lobechat/prompts';
|
||||
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { TaskService } from '@/server/services/task';
|
||||
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
const createTaskRuntime = ({
|
||||
taskId,
|
||||
taskModel,
|
||||
taskService,
|
||||
}: {
|
||||
taskId?: string;
|
||||
taskModel: TaskModel;
|
||||
taskService: TaskService;
|
||||
}) => ({
|
||||
createTask: async (args: {
|
||||
instruction: string;
|
||||
name: string;
|
||||
parentIdentifier?: string;
|
||||
priority?: number;
|
||||
sortOrder?: number;
|
||||
review?: {
|
||||
autoRetry?: boolean;
|
||||
criteria?: Array<{ name: string; threshold: number }>;
|
||||
enabled?: boolean;
|
||||
maxIterations?: number;
|
||||
};
|
||||
}) => {
|
||||
let parentTaskId: string | undefined;
|
||||
let parentLabel: string | undefined;
|
||||
let parentConfig: Record<string, any> | undefined;
|
||||
|
||||
if (args.parentIdentifier) {
|
||||
const parent = await taskModel.resolve(args.parentIdentifier);
|
||||
if (!parent)
|
||||
return { content: `Parent task not found: ${args.parentIdentifier}`, success: false };
|
||||
parentTaskId = parent.id;
|
||||
parentLabel = parent.identifier;
|
||||
parentConfig = parent.config as Record<string, any>;
|
||||
} else if (taskId) {
|
||||
parentTaskId = taskId;
|
||||
const current = await taskModel.findById(taskId);
|
||||
parentLabel = current?.identifier || taskId;
|
||||
parentConfig = current?.config as Record<string, any>;
|
||||
}
|
||||
|
||||
// Build config: explicit review > inherited from parent
|
||||
let config: Record<string, any> | undefined;
|
||||
if (args.review) {
|
||||
config = { review: { enabled: true, ...args.review } };
|
||||
} else if (parentConfig?.review) {
|
||||
config = { review: parentConfig.review };
|
||||
}
|
||||
|
||||
const task = await taskModel.create({
|
||||
...(config && { config }),
|
||||
instruction: args.instruction,
|
||||
name: args.name,
|
||||
parentTaskId,
|
||||
priority: args.priority,
|
||||
sortOrder: args.sortOrder,
|
||||
});
|
||||
|
||||
return {
|
||||
content: formatTaskCreated({
|
||||
identifier: task.identifier,
|
||||
instruction: args.instruction,
|
||||
name: task.name,
|
||||
parentLabel,
|
||||
priority: task.priority,
|
||||
status: task.status,
|
||||
}),
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
|
||||
deleteTask: async (args: { identifier: string }) => {
|
||||
const task = await taskModel.resolve(args.identifier);
|
||||
if (!task) return { content: `Task not found: ${args.identifier}`, success: false };
|
||||
|
||||
await taskModel.delete(task.id);
|
||||
|
||||
return {
|
||||
content: `Task ${task.identifier} "${task.name || ''}" has been deleted.`,
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
|
||||
editTask: async (args: {
|
||||
addDependency?: string;
|
||||
identifier: string;
|
||||
instruction?: string;
|
||||
name?: string;
|
||||
priority?: number;
|
||||
removeDependency?: string;
|
||||
review?: {
|
||||
autoRetry?: boolean;
|
||||
criteria?: Array<{ name: string; threshold: number }>;
|
||||
enabled?: boolean;
|
||||
maxIterations?: number;
|
||||
};
|
||||
}) => {
|
||||
const task = await taskModel.resolve(args.identifier);
|
||||
if (!task) return { content: `Task not found: ${args.identifier}`, success: false };
|
||||
|
||||
const updateData: Record<string, any> = {};
|
||||
const changes: string[] = [];
|
||||
|
||||
if (args.name !== undefined) {
|
||||
updateData.name = args.name;
|
||||
changes.push(`name → "${args.name}"`);
|
||||
}
|
||||
if (args.instruction !== undefined) {
|
||||
updateData.instruction = args.instruction;
|
||||
changes.push(`instruction updated`);
|
||||
}
|
||||
if (args.priority !== undefined) {
|
||||
updateData.priority = args.priority;
|
||||
changes.push(`priority → ${priorityLabel(args.priority)}`);
|
||||
}
|
||||
if (args.review) {
|
||||
const currentConfig = (task.config as Record<string, any>) || {};
|
||||
updateData.config = { ...currentConfig, review: { enabled: true, ...args.review } };
|
||||
changes.push('review config updated');
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await taskModel.update(task.id, updateData);
|
||||
}
|
||||
|
||||
// Handle dependencies
|
||||
if (args.addDependency) {
|
||||
const dep = await taskModel.resolve(args.addDependency);
|
||||
if (!dep)
|
||||
return { content: `Dependency task not found: ${args.addDependency}`, success: false };
|
||||
await taskModel.addDependency(task.id, dep.id);
|
||||
changes.push(formatDependencyAdded(task.identifier, dep.identifier));
|
||||
}
|
||||
|
||||
if (args.removeDependency) {
|
||||
const dep = await taskModel.resolve(args.removeDependency);
|
||||
if (!dep)
|
||||
return { content: `Dependency task not found: ${args.removeDependency}`, success: false };
|
||||
await taskModel.removeDependency(task.id, dep.id);
|
||||
changes.push(formatDependencyRemoved(task.identifier, dep.identifier));
|
||||
}
|
||||
|
||||
return { content: formatTaskEdited(task.identifier, changes), success: true };
|
||||
},
|
||||
|
||||
listTasks: async (args: { parentIdentifier?: string; status?: string }) => {
|
||||
let parentId: string | undefined;
|
||||
let parentLabel = 'current task';
|
||||
|
||||
if (args.parentIdentifier) {
|
||||
const parent = await taskModel.resolve(args.parentIdentifier);
|
||||
if (!parent)
|
||||
return { content: `Parent task not found: ${args.parentIdentifier}`, success: false };
|
||||
parentId = parent.id;
|
||||
parentLabel = parent.identifier;
|
||||
} else {
|
||||
parentId = taskId;
|
||||
}
|
||||
|
||||
if (!parentId) return { content: 'No task context available.', success: false };
|
||||
|
||||
const subtasks = await taskModel.findSubtasks(parentId);
|
||||
let filtered = subtasks;
|
||||
if (args.status) {
|
||||
filtered = subtasks.filter((t) => t.status === args.status);
|
||||
}
|
||||
|
||||
return {
|
||||
content: formatTaskList(filtered, parentLabel, args.status),
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
|
||||
updateTaskStatus: async (args: { identifier?: string; status: string }) => {
|
||||
const id = args.identifier || taskId;
|
||||
if (!id) {
|
||||
return {
|
||||
content: 'No task identifier provided and no current task context.',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const task = await taskModel.resolve(id);
|
||||
if (!task) return { content: `Task not found: ${id}`, success: false };
|
||||
|
||||
const updated = await taskModel.updateStatus(task.id, args.status);
|
||||
if (!updated) return { content: `Failed to update task ${task.identifier}`, success: false };
|
||||
|
||||
return {
|
||||
content: `Task ${task.identifier} status updated to ${args.status}.`,
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
|
||||
viewTask: async (args: { identifier?: string }) => {
|
||||
const id = args.identifier || taskId;
|
||||
if (!id) {
|
||||
return {
|
||||
content: 'No task identifier provided and no current task context.',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const detail = await taskService.getTaskDetail(id);
|
||||
if (!detail) return { content: `Task not found: ${id}`, success: false };
|
||||
|
||||
return {
|
||||
content: formatTaskDetail(detail),
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const taskRuntime: ServerRuntimeRegistration = {
|
||||
factory: (context) => {
|
||||
if (!context.userId || !context.serverDB) {
|
||||
throw new Error('userId and serverDB are required for Task tool execution');
|
||||
}
|
||||
|
||||
const taskModel = new TaskModel(context.serverDB, context.userId);
|
||||
const taskService = new TaskService(context.serverDB, context.userId);
|
||||
|
||||
return createTaskRuntime({ taskId: context.taskId, taskModel, taskService });
|
||||
},
|
||||
identifier: TaskIdentifier,
|
||||
};
|
||||
|
|
@ -11,6 +11,8 @@ export interface ToolExecutionContext {
|
|||
memoryToolPermission?: 'read-only' | 'read-write';
|
||||
/** Server database for LobeHub Skills execution */
|
||||
serverDB?: LobeChatDatabase;
|
||||
/** Task ID when executing within the Task system */
|
||||
taskId?: string;
|
||||
toolManifestMap: Record<string, LobeToolManifest>;
|
||||
/**
|
||||
* Maximum length for tool execution result content (in characters)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { coverageConfigDefaults, defineConfig } from 'vitest/config';
|
||||
|
||||
|
|
@ -27,6 +28,15 @@ export default defineConfig({
|
|||
},
|
||||
plugins: [
|
||||
tsconfigPaths({ projects: ['.'] }),
|
||||
// Let `.md` imports resolve to their raw text content so Rollup/Vitest
|
||||
// doesn't try to parse Markdown as JavaScript.
|
||||
{
|
||||
name: 'raw-md',
|
||||
transform(_, id) {
|
||||
if (id.endsWith('.md'))
|
||||
return { code: 'export default ""', map: null };
|
||||
},
|
||||
},
|
||||
/**
|
||||
* @lobehub/fluent-emoji@4.0.0 ships `es/FluentEmoji/style.js` but its `es/FluentEmoji/index.js`
|
||||
* imports `./style/index.js` which doesn't exist.
|
||||
|
|
|
|||
Loading…
Reference in a new issue