🔨 chore: return full brief data in task activities (#13914)

*  feat: return full brief data in task activities (LOBE-7266)

The activity feed for tasks previously emitted a stripped `brief` row that
concatenated `resolvedAction` and `resolvedComment` and omitted everything
BriefCard needs (taskId, topicId, agentId, cronJobId, agents, actions,
artifacts, readAt, resolvedAt, etc.). Map the full `BriefItem` into each
activity row and reuse `BriefService.enrichBriefsWithAgents` to populate
the participant avatars. The CLI and prompt formatter now compose the
action + comment display string themselves.

* 🐛 fix: degrade gracefully when brief agent enrichment fails

getTaskDetail was calling BriefService.enrichBriefsWithAgents inside
Promise.all without a fallback, so a failure in the agent-tree lookup
would reject the whole request — a regression vs. the existing
.catch(() => []) pattern used by other activity reads in this method.
Fall back to agentless briefs on error so the task detail keeps
rendering.
This commit is contained in:
Arvin Xu 2026-04-17 19:10:48 +08:00 committed by GitHub
parent 828175f8f0
commit 34b60e1842
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 143 additions and 12 deletions

View file

@ -466,7 +466,12 @@ export function registerTaskCommand(program: Command) {
: act.priority === 'normal'
? pc.yellow(' [normal]')
: '';
const resolved = act.resolvedAction ? pc.green(` ✏️ ${act.resolvedAction}`) : '';
const resolvedLabel = act.resolvedAction
? act.resolvedComment
? `${act.resolvedAction}: ${act.resolvedComment}`
: act.resolvedAction
: '';
const resolved = resolvedLabel ? pc.green(` ✏️ ${resolvedLabel}`) : '';
const typeLabel = pc.dim(`[${act.briefType}]`);
console.log(
` ${icon} ${pc.dim(ago.padStart(7))} Brief ${typeLabel} ${act.title}${pri}${resolved}${idSuffix}`,

View file

@ -210,7 +210,12 @@ export const formatTaskDetail = (t: TaskDetailData): string => {
` 💬 ${act.time || ''} Topic #${act.seq || '?'} ${act.title || 'Untitled'} ${statusIcon(status)} ${status}${idSuffix}`,
);
} else if (act.type === 'brief') {
const resolved = act.resolvedAction ? ` ✏️ ${act.resolvedAction}` : '';
const resolvedLabel = act.resolvedAction
? act.resolvedComment
? `${act.resolvedAction}: ${act.resolvedComment}`
: act.resolvedAction
: '';
const resolved = resolvedLabel ? ` ✏️ ${resolvedLabel}` : '';
const priStr = act.priority ? ` [${act.priority}]` : '';
lines.push(
` ${briefIcon(act.briefType || '')} ${act.time || ''} Brief [${act.briefType}] ${act.title}${priStr}${resolved}${idSuffix}`,

View file

@ -146,20 +146,38 @@ export interface TaskDetailActivityAuthor {
type: 'agent' | 'user';
}
export interface TaskDetailActivityAgent {
avatar: string | null;
backgroundColor: string | null;
id: string;
title: string | null;
}
export interface TaskDetailActivity {
actions?: unknown;
agentId?: string | null;
agents?: TaskDetailActivityAgent[];
artifacts?: unknown;
author?: TaskDetailActivityAuthor;
briefType?: string;
content?: string;
createdAt?: string;
cronJobId?: string | null;
id?: string;
priority?: string | null;
readAt?: string | null;
resolvedAction?: string | null;
resolvedAt?: string | null;
resolvedComment?: string | null;
seq?: number | null;
status?: string | null;
summary?: string;
taskId?: string | null;
time?: string;
title?: string;
topicId?: string | null;
type: 'brief' | 'comment' | 'topic';
userId?: string | null;
}
export interface TaskDetailData {

View file

@ -47,6 +47,7 @@ describe('TaskService', () => {
getDependencies: vi.fn(),
getDependenciesByTaskIds: vi.fn(),
getReviewConfig: vi.fn(),
getTreeAgentIdsForTaskIds: vi.fn().mockResolvedValue({}),
getTreePinnedDocuments: vi.fn(),
resolve: vi.fn(),
};
@ -874,7 +875,7 @@ describe('TaskService', () => {
expect(result?.workspace).toBeUndefined();
});
it('should build brief activities with resolvedAction concatenated with resolvedComment', async () => {
it('should build brief activities with full BriefItem fields', async () => {
const task = {
assigneeAgentId: null,
assigneeUserId: null,
@ -896,14 +897,23 @@ describe('TaskService', () => {
const briefs = [
{
actions: [{ key: 'approve', label: '✅', type: 'resolve' }],
agentId: 'agent-1',
artifacts: ['doc_1'],
createdAt: new Date('2024-01-01T00:00:00Z'),
cronJobId: null,
id: 'brief-1',
priority: 'urgent',
readAt: new Date('2024-01-01T01:00:00Z'),
resolvedAction: 'approved',
resolvedAt: new Date('2024-01-01T02:00:00Z'),
resolvedComment: 'looks good',
summary: 'Review brief',
taskId: 'task_001',
title: 'Approval',
topicId: null,
type: 'decision',
userId: 'user-1',
},
];
@ -917,22 +927,38 @@ describe('TaskService', () => {
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getTreeAgentIdsForTaskIds.mockResolvedValue({ task_001: ['agent-1'] });
mockAgentModel.getAgentAvatarsByIds.mockResolvedValue([
{ avatar: 'avatar.png', backgroundColor: '#fff', id: 'agent-1', title: 'Agent One' },
]);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
expect(result?.activities?.[0]).toMatchObject({
actions: [{ key: 'approve', label: '✅', type: 'resolve' }],
agentId: 'agent-1',
agents: [
{ avatar: 'avatar.png', backgroundColor: '#fff', id: 'agent-1', title: 'Agent One' },
],
artifacts: ['doc_1'],
briefType: 'decision',
createdAt: '2024-01-01T00:00:00.000Z',
id: 'brief-1',
priority: 'urgent',
resolvedAction: 'approved: looks good',
readAt: '2024-01-01T01:00:00.000Z',
resolvedAction: 'approved',
resolvedAt: '2024-01-01T02:00:00.000Z',
resolvedComment: 'looks good',
summary: 'Review brief',
taskId: 'task_001',
title: 'Approval',
type: 'brief',
userId: 'user-1',
});
});
it('should use only resolvedAction when resolvedComment is absent', async () => {
it('should keep resolvedAction and resolvedComment as separate fields', async () => {
const task = {
assigneeAgentId: null,
assigneeUserId: null,
@ -981,6 +1007,66 @@ describe('TaskService', () => {
expect(result?.activities?.[0]).toMatchObject({
resolvedAction: 'retry',
resolvedComment: null,
type: 'brief',
});
});
it('should still return task detail when brief agent enrichment fails', async () => {
const task = {
assigneeAgentId: null,
assigneeUserId: null,
createdAt: null,
description: null,
error: null,
heartbeatInterval: null,
heartbeatTimeout: null,
id: 'task_001',
identifier: 'TASK-1',
instruction: null,
lastHeartbeatAt: null,
name: 'Task 1',
parentTaskId: null,
priority: 'normal',
status: 'todo',
totalTopics: 0,
};
const briefs = [
{
agentId: 'agent-1',
createdAt: new Date('2024-01-01T00:00:00Z'),
id: 'brief-1',
priority: 'normal',
resolvedAction: null,
resolvedComment: null,
summary: 'Brief',
taskId: 'task_001',
title: 'Brief A',
type: 'insight',
},
];
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue(briefs);
mockTaskModel.getComments.mockResolvedValue([]);
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getTreeAgentIdsForTaskIds.mockRejectedValue(new Error('DB error'));
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
expect(result).not.toBeNull();
expect(result?.activities).toHaveLength(1);
expect(result?.activities?.[0]).toMatchObject({
agents: [],
id: 'brief-1',
type: 'brief',
});
});

View file

@ -15,11 +15,14 @@ import { TaskTopicModel } from '@/database/models/taskTopic';
import { UserModel } from '@/database/models/user';
import type { LobeChatDatabase } from '@/database/type';
import { BriefService } from '../brief';
const emptyWorkspace: WorkspaceData = { nodeMap: {}, tree: [] };
export class TaskService {
private agentModel: AgentModel;
private briefModel: BriefModel;
private briefService: BriefService;
private db: LobeChatDatabase;
private taskModel: TaskModel;
private taskTopicModel: TaskTopicModel;
@ -30,6 +33,7 @@ export class TaskService {
this.taskModel = new TaskModel(db, userId);
this.taskTopicModel = new TaskTopicModel(db, userId);
this.briefModel = new BriefModel(db, userId);
this.briefService = new BriefService(db, userId);
}
async getTaskDetail(taskIdOrIdentifier: string): Promise<TaskDetailData | null> {
@ -135,7 +139,12 @@ export class TaskService {
if (c.authorUserId) userIds.add(c.authorUserId);
}
const authorMap = await this.resolveAuthors(agentIds, userIds);
const [authorMap, enrichedBriefs] = await Promise.all([
this.resolveAuthors(agentIds, userIds),
this.briefService
.enrichBriefsWithAgents(briefs)
.catch(() => briefs.map((b) => ({ ...b, agents: [] }))),
]);
const activities: TaskDetailActivity[] = [
...topics.map((t) => ({
@ -147,20 +156,28 @@ export class TaskService {
title: (t.handoff as TaskTopicHandoff | null)?.title || 'Untitled',
type: 'topic' as const,
})),
...briefs.map((b) => ({
...enrichedBriefs.map((b) => ({
actions: b.actions ?? undefined,
agentId: b.agentId,
agents: b.agents,
artifacts: b.artifacts ?? undefined,
author: b.agentId ? authorMap.get(b.agentId) : undefined,
briefType: b.type,
createdAt: toISO(b.createdAt),
cronJobId: b.cronJobId,
id: b.id,
priority: b.priority,
resolvedAction: b.resolvedAction
? b.resolvedComment
? `${b.resolvedAction}: ${b.resolvedComment}`
: b.resolvedAction
: undefined,
readAt: toISO(b.readAt),
resolvedAction: b.resolvedAction,
resolvedAt: toISO(b.resolvedAt),
resolvedComment: b.resolvedComment,
summary: b.summary,
taskId: b.taskId,
time: toISO(b.createdAt),
title: b.title,
topicId: b.topicId,
type: 'brief' as const,
userId: b.userId,
})),
...comments.map((c) => ({
agentId: c.authorAgentId,