🔨 chore(task): add participants to task.list response (#13778)

*  feat(task): add participants array to task.list response

Return a participants array per task (id / type / avatar / name) so
clients can show avatar groups on task cards. For now participants
only contains the assignee agent; future iterations can aggregate
comment authors and topic executors.

Also extract TaskItem into @lobechat/types as an explicit type
definition so it no longer relies on drizzle schema inference.

* ♻️ refactor(task): extract NewTask to @lobechat/types

Remove the drizzle $inferInsert NewTask from schemas and define it
explicitly in @lobechat/types alongside TaskItem.

*  test(task): cover participants in task.list response
This commit is contained in:
Arvin Xu 2026-04-13 16:09:53 +08:00 committed by GitHub
parent f439fb913a
commit f0f2feb015
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 141 additions and 7 deletions

View file

@ -1,5 +1,7 @@
import type {
CheckpointConfig,
NewTask,
TaskItem,
WorkspaceData,
WorkspaceDocNode,
WorkspaceTreeNode,
@ -8,7 +10,7 @@ import { and, desc, eq, inArray, isNotNull, isNull, ne, sql } from 'drizzle-orm'
import { merge } from '@/utils/merge';
import type { NewTask, NewTaskComment, TaskCommentItem, TaskItem } from '../schemas/task';
import type { NewTaskComment, TaskCommentItem } from '../schemas/task';
import { taskComments, taskDependencies, taskDocuments, tasks } from '../schemas/task';
import type { LobeChatDatabase } from '../type';
@ -307,7 +309,7 @@ export class TaskModel {
SELECT * FROM task_tree
`);
return result.rows as TaskItem[];
return result.rows as unknown as TaskItem[];
}
/**

View file

@ -101,9 +101,6 @@ export const tasks = pgTable(
],
);
export type NewTask = typeof tasks.$inferInsert;
export type TaskItem = typeof tasks.$inferSelect;
// ── Task Dependencies ────────────────────────────────────
export const taskDependencies = pgTable(

View file

@ -38,6 +38,85 @@ export interface TaskTopicHandoff {
title?: string;
}
// ── Task list item (shared between router response and client) ──
export interface TaskParticipant {
avatar: string | null;
id: string;
name: string;
type: 'user' | 'agent';
}
export interface TaskItem {
accessedAt: Date;
assigneeAgentId: string | null;
assigneeUserId: string | null;
completedAt: Date | null;
config: unknown;
context: unknown;
createdAt: Date;
createdByAgentId: string | null;
createdByUserId: string;
currentTopicId: string | null;
description: string | null;
error: string | null;
heartbeatInterval: number | null;
heartbeatTimeout: number | null;
id: string;
identifier: string;
instruction: string;
lastHeartbeatAt: Date | null;
maxTopics: number | null;
name: string | null;
parentTaskId: string | null;
priority: number | null;
schedulePattern: string | null;
scheduleTimezone: string | null;
seq: number;
sortOrder: number | null;
startedAt: Date | null;
status: string;
totalTopics: number | null;
updatedAt: Date;
}
export type TaskListItem = TaskItem & {
participants: TaskParticipant[];
};
export interface NewTask {
accessedAt?: Date;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
completedAt?: Date | null;
config?: unknown;
context?: unknown;
createdAt?: Date;
createdByAgentId?: string | null;
createdByUserId: string;
currentTopicId?: string | null;
description?: string | null;
error?: string | null;
heartbeatInterval?: number | null;
heartbeatTimeout?: number | null;
id?: string;
identifier: string;
instruction: string;
lastHeartbeatAt?: Date | null;
maxTopics?: number | null;
name?: string | null;
parentTaskId?: string | null;
priority?: number | null;
schedulePattern?: string | null;
scheduleTimezone?: string | null;
seq: number;
sortOrder?: number | null;
startedAt?: Date | null;
status?: string;
totalTopics?: number | null;
updatedAt?: Date;
}
// ── Task Detail (shared across CLI, viewTask tool, task.detail router) ──
export interface TaskDetailSubtask {

View file

@ -419,6 +419,31 @@ describe('Task Router Integration', () => {
});
});
describe('list participants', () => {
it('should populate participants from assignee agent', async () => {
const { agents } = await import('@/database/schemas');
const { eq } = await import('drizzle-orm');
await serverDB
.update(agents)
.set({ avatar: 'avatar.png', title: 'Agent One' })
.where(eq(agents.id, testAgentId));
await caller.create({ assigneeAgentId: testAgentId, instruction: 'Task A' });
await caller.create({ instruction: 'Task without assignee' });
const list = await caller.list({});
expect(list.data).toHaveLength(2);
const assigned = list.data.find((t) => t.assigneeAgentId === testAgentId)!;
expect(assigned.participants).toEqual([
{ avatar: 'avatar.png', id: testAgentId, name: 'Agent One', type: 'agent' },
]);
const unassigned = list.data.find((t) => !t.assigneeAgentId)!;
expect(unassigned.participants).toEqual([]);
});
});
describe('heartbeat timeout detection', () => {
it('should auto-detect timeout on detail and pause task', async () => {
const task = await caller.create({

View file

@ -2,10 +2,16 @@ import { TaskIdentifier as TaskSkillIdentifier } from '@lobechat/builtin-skills'
import { BriefIdentifier } from '@lobechat/builtin-tool-brief';
import { NotebookIdentifier } from '@lobechat/builtin-tool-notebook';
import { buildTaskRunPrompt } from '@lobechat/prompts';
import type { TaskTopicHandoff, WorkspaceData } from '@lobechat/types';
import type {
TaskListItem,
TaskParticipant,
TaskTopicHandoff,
WorkspaceData,
} from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { AgentModel } from '@/database/models/agent';
import { BriefModel } from '@/database/models/brief';
import { TaskModel } from '@/database/models/task';
import { TaskTopicModel } from '@/database/models/taskTopic';
@ -21,6 +27,7 @@ const taskProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
return opts.next({
ctx: {
agentModel: new AgentModel(ctx.serverDB, ctx.userId),
briefModel: new BriefModel(ctx.serverDB, ctx.userId),
taskLifecycle: new TaskLifecycleService(ctx.serverDB, ctx.userId),
taskModel: new TaskModel(ctx.serverDB, ctx.userId),
@ -721,7 +728,31 @@ export const taskRouter = router({
try {
const model = ctx.taskModel;
const result = await model.list(input);
return { data: result.tasks, success: true, total: result.total };
const assigneeIds = [
...new Set(result.tasks.map((t) => t.assigneeAgentId).filter((id): id is string => !!id)),
];
const agents =
assigneeIds.length > 0 ? await ctx.agentModel.getAgentAvatarsByIds(assigneeIds) : [];
const agentMap = new Map(agents.map((a) => [a.id, a]));
const data: TaskListItem[] = result.tasks.map((task) => {
const participants: TaskParticipant[] = [];
if (task.assigneeAgentId) {
const agent = agentMap.get(task.assigneeAgentId);
if (agent) {
participants.push({
avatar: agent.avatar,
id: agent.id,
name: agent.title ?? '',
type: 'agent',
});
}
}
return { ...task, participants };
});
return { data, success: true, total: result.total };
} catch (error) {
console.error('[task:list]', error);
throw new TRPCError({