From ca21ab206ed3a9ba25d2b5344485bb8b848bddb9 Mon Sep 17 00:00:00 2001 From: Zelen <52129260+AlexeyZelenko@users.noreply.github.com> Date: Fri, 1 May 2026 21:25:03 +0300 Subject: [PATCH] fix(team): render agent error messages * fix(team): render agent error messages * test(team): cover agent error activity rendering * fix(ci): clear ui lint gate * test(team): reset config cache in relay suites * test(team): harden mixed lane matrix waits * test(team): harden ci-sensitive team assertions --------- Co-authored-by: iliya Co-authored-by: 777genius --- src/main/services/team/TeamConfigReader.ts | 7 ++-- src/main/services/team/TeamInboxReader.ts | 4 ++- .../services/team/TeamProvisioningService.ts | 8 ++--- src/main/services/team/index.ts | 2 +- .../chat/viewers/MarkdownViewer.tsx | 4 +-- .../components/team/activity/ActivityItem.tsx | 4 +-- .../team/members/MemberDetailDialog.tsx | 2 +- src/shared/types/team.ts | 3 +- .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 26 +++++++++++---- .../services/team/TeamInboxReader.test.ts | 27 +++++++++++++++ .../team/TeamProvisioningServiceRelay.test.ts | 2 ++ .../team/activity/ActivityItem.test.ts | 33 +++++++++++++++++++ .../team/members/MemberExecutionLog.test.ts | 2 +- .../TaskLogStreamSection.integration.test.ts | 2 +- ...treamSection.opencode-fixture-e2e.test.tsx | 2 +- 15 files changed, 105 insertions(+), 23 deletions(-) diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 69f4b417..3b1fbc38 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -179,6 +179,11 @@ export class TeamConfigReader { private static readonly configCacheByPath = new Map(); private static readonly configReadInFlightByPath = new Map>(); + static clearCacheForTests(): void { + TeamConfigReader.configCacheByPath.clear(); + TeamConfigReader.configReadInFlightByPath.clear(); + } + constructor( private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore() @@ -553,8 +558,6 @@ export class TeamConfigReader { try { return await this.resolveConfigRead(teamName, configPath, readPromise); - } catch (error) { - return null; } finally { if (TeamConfigReader.configReadInFlightByPath.get(configPath) === readPromise) { TeamConfigReader.configReadInFlightByPath.delete(configPath); diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index 11793f4d..9e64b9e2 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -141,7 +141,9 @@ export class TeamInboxReader { messageKind: row.messageKind === 'slash_command' || row.messageKind === 'slash_command_result' || - row.messageKind === 'task_comment_notification' + row.messageKind === 'task_comment_notification' || + row.messageKind === 'member_work_sync_nudge' || + row.messageKind === 'agent_error' ? row.messageKind : row.messageKind === 'default' ? 'default' diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index a9eafad7..89429046 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -168,10 +168,6 @@ import { setOpenCodeRuntimeActiveRunManifest, upsertOpenCodeRuntimeLaneIndexEntry, } from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; -import type { - OpenCodeCommittedBootstrapSessionRecord, - OpenCodeRuntimeLaneIndexEntry, -} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { createRuntimeRunTombstoneStore, type RuntimeEvidenceKind, @@ -242,6 +238,10 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; +import type { + OpenCodeCommittedBootstrapSessionRecord, + OpenCodeRuntimeLaneIndexEntry, +} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import type { OpenCodeTeamRuntimeMessageInput, OpenCodeTeamRuntimeMessageResult, diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 908f879e..799dd164 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -59,9 +59,9 @@ export { TaskBoundaryParser } from './TaskBoundaryParser'; export { BoardTaskActivityDetailService } from './taskLogs/activity/BoardTaskActivityDetailService'; export { BoardTaskActivityRecordSource } from './taskLogs/activity/BoardTaskActivityRecordSource'; export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityService'; +export { TeamTranscriptSourceLocator } from './taskLogs/discovery/TeamTranscriptSourceLocator'; export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService'; export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService'; -export { TeamTranscriptSourceLocator } from './taskLogs/discovery/TeamTranscriptSourceLocator'; export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService'; export type { OpenCodeTaskLogAttributionBulkWriteOutcome, diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index a91caa9e..5bd3b977 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -85,11 +85,11 @@ const EMPTY_TEAM_COLOR_MAP = new Map(); const NOOP_TEAM_CLICK = (): void => undefined; type ViewerMarkdownMode = 'default' | 'compact-preview'; -type HastElementLike = { +interface HastElementLike { tagName?: string; value?: string; children?: unknown[]; -}; +} // ============================================================================= // Helpers diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 5a14ac1e..4cde29bc 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -681,7 +681,7 @@ export const ActivityItem = memo( // Only flag agent messages as rate-limited, not user's own quotes const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text); // Highlight messages containing API errors - const isApiError = message.text.includes('API Error'); + const isApiError = message.messageKind === 'agent_error' || message.text.includes('API Error'); // Detect auth errors that may be resolved by restarting the team const isAuthError = isApiError && AUTH_ERROR_PATTERNS.some((p) => p.test(message.text)); // Never collapse rate limit messages as noise — they must be visible @@ -1029,7 +1029,7 @@ export const ActivityItem = memo( ) : isApiError ? ( - API Error + {message.messageKind === 'agent_error' ? 'Agent Error' : 'API Error'} ) : null; diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 8a72d102..8ab479ef 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -7,13 +7,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u import { useMemberStats } from '@renderer/hooks/useMemberStats'; import { useStore } from '@renderer/store'; import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice'; +import { isOpenCodeRelaunchActionable } from '@renderer/utils/memberHelpers'; import { buildMemberLaunchDiagnosticsPayload, getMemberLaunchDiagnosticsErrorMessage, hasMemberLaunchDiagnosticsDetails, hasMemberLaunchDiagnosticsError, } from '@renderer/utils/memberLaunchDiagnostics'; -import { isOpenCodeRelaunchActionable } from '@renderer/utils/memberHelpers'; import { getRuntimeMemorySourceLabel, resolveMemberRuntimeSummary, diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 04d70a7a..ecbe2b69 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -414,7 +414,8 @@ export type InboxMessageKind = | 'slash_command' | 'slash_command_result' | 'task_comment_notification' - | 'member_work_sync_nudge'; + | 'member_work_sync_nudge' + | 'agent_error'; export interface SlashCommandMeta { name: string; diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index c3b16348..f32a1e75 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; import type { OpenCodeTeamRuntimeMessageInput, @@ -47,6 +48,7 @@ describe('Team agent launch matrix safe e2e', () => { let projectPath: string; beforeEach(async () => { + TeamConfigReader.clearCacheForTests(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-launch-matrix-e2e-')); tempClaudeRoot = path.join(tempDir, '.claude'); projectPath = path.join(tempDir, 'project'); @@ -56,6 +58,7 @@ describe('Team agent launch matrix safe e2e', () => { }); afterEach(async () => { + TeamConfigReader.clearCacheForTests(); setClaudeBasePathOverride(null); await removeTempDirWithRetries(tempDir); }); @@ -10066,7 +10069,9 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(cancelledRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); + await waitForCondition(() => + adapter.pendingLaunchInputs.some((input) => input.teamName === cancelledTeamName) + ); await svc.cancelProvisioning(cancelledRun.runId); @@ -10191,7 +10196,9 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(cancelledRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); + await waitForCondition(() => + adapter.pendingLaunchInputs.some((input) => input.teamName === cancelledTeamName) + ); await svc.cancelProvisioning(cancelledRun.runId); @@ -10996,7 +11003,10 @@ describe('Team agent launch matrix safe e2e', () => { svc.stopTeam(teamName); await waitForCondition(() => adapter.stopInputs.length === 2); await waitForCondition(() => !svc.isTeamAlive(teamName)); - expect((await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).lanes).toEqual({}); + await waitForCondition(async () => { + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); + return Object.keys(laneIndex.lanes).length === 0; + }); await expect( svc.deliverOpenCodeMemberMessage(teamName, { @@ -15141,7 +15151,9 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(stoppedRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); + await waitForCondition(() => + adapter.pendingLaunchInputs.some((input) => input.teamName === stoppedTeamName) + ); svc.stopTeam(stoppedTeamName); @@ -15864,7 +15876,9 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(cancelledRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); + await waitForCondition(() => + adapter.pendingLaunchInputs.some((input) => input.teamName === cancelledTeamName) + ); await svc.cancelProvisioning(cancelledRun.runId); @@ -17108,7 +17122,7 @@ async function upsertActiveOpenCodeRuntimeLaneForTest(input: { async function waitForCondition(assertion: () => boolean | Promise): Promise { const startedAt = Date.now(); - while (Date.now() - startedAt < 5_000) { + while (Date.now() - startedAt < 20_000) { if (await assertion()) { return; } diff --git a/test/main/services/team/TeamInboxReader.test.ts b/test/main/services/team/TeamInboxReader.test.ts index 5f59af5c..c4a5ef7f 100644 --- a/test/main/services/team/TeamInboxReader.test.ts +++ b/test/main/services/team/TeamInboxReader.test.ts @@ -176,4 +176,31 @@ describe('TeamInboxReader', () => { summary: 'Comment on #abcd1234', }); }); + + it('preserves agent error semantic kind from the team lead inbox', async () => { + hoisted.files.set( + '/mock/teams/my-team/inboxes/team-lead.json', + JSON.stringify([ + { + from: 'bob', + to: 'team-lead', + text: 'bob hit a mailbox turn execution error. API Error: Credit balance is too low', + timestamp: '2026-01-01T03:00:00.000Z', + read: false, + messageId: 'm-agent-error', + messageKind: 'agent_error', + summary: 'Mailbox turn execution failed', + }, + ]) + ); + + const messages = await reader.getMessagesFor('my-team', 'team-lead'); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + messageId: 'm-agent-error', + to: 'team-lead', + messageKind: 'agent_error', + summary: 'Mailbox turn execution failed', + }); + }); }); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index d7c84ac7..1b5e8b7e 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -148,6 +148,7 @@ vi.mock('agent-teams-controller', () => ({ })); import { buildLegacyInboxMessageId } from '../../../../src/main/services/team/inboxMessageIdentity'; +import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; function seedConfig(teamName: string): void { @@ -249,6 +250,7 @@ async function waitForCapture(service: TeamProvisioningService): Promise { describe('TeamProvisioningService relayLeadInboxMessages', () => { beforeEach(() => { + TeamConfigReader.clearCacheForTests(); hoisted.files.clear(); hoisted.readFile.mockClear(); hoisted.mkdir.mockClear(); diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts index 64205bcc..58580b34 100644 --- a/test/renderer/components/team/activity/ActivityItem.test.ts +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -347,6 +347,39 @@ describe('ActivityItem slash command rendering', () => { await Promise.resolve(); }); }); + + it('renders agent error messages with the dedicated Agent Error badge', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + const message: InboxMessage = { + from: 'bob', + to: 'team-lead', + text: 'bob hit a mailbox turn execution error for #abc12345. API Error: Credit balance is too low', + timestamp: new Date('2026-05-01T12:02:00.000Z').toISOString(), + read: false, + source: 'inbox', + messageKind: 'agent_error', + summary: 'Mailbox turn execution failed', + }; + + await act(async () => { + root.render(React.createElement(ActivityItem, { message, teamName: 'my-team' })); + await Promise.resolve(); + }); + + const badgeTexts = Array.from(host.querySelectorAll('span')).map((node) => + node.textContent?.trim() + ); + expect(badgeTexts).toContain('Agent Error'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); describe('ActivityItem legacy system message fallback', () => { diff --git a/test/renderer/components/team/members/MemberExecutionLog.test.ts b/test/renderer/components/team/members/MemberExecutionLog.test.ts index 6e347df4..fe4e48cd 100644 --- a/test/renderer/components/team/members/MemberExecutionLog.test.ts +++ b/test/renderer/components/team/members/MemberExecutionLog.test.ts @@ -209,7 +209,7 @@ describe('MemberExecutionLog', () => { await flushMicrotasks(); }); - expect(host.textContent).toContain('jack turn'); + expect(host.textContent ?? '').toMatch(/jack\s*turn/); expect(host.textContent).not.toContain('Agent turn'); await act(async () => { diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts index e3155d10..28e6af0e 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts @@ -414,7 +414,7 @@ describe('TaskLogStreamSection integration', () => { expect(text).toContain('Task Log Stream'); expect(text).toContain('Grep'); expect(text).toContain('Edit'); - expect(text).toContain('tom turn'); + expect(text).toMatch(/tom\s*turn/); expect(text).toContain('3 tool calls'); expect(text).not.toContain('[]'); expect(text).not.toContain('Audit complete'); diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx b/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx index ed594cf4..b1ae030a 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx @@ -144,7 +144,7 @@ describe('TaskLogStreamSection OpenCode real fixture e2e', () => { const text = host.textContent ?? ''; expect(text).toContain('Task Log Stream'); expect(text).toContain('matched task tool markers'); - expect(text).toContain('jack turn'); + expect(text).toMatch(/jack\s*turn/); expect(text).toContain('Calculator behavior'); expect(text).toContain('Задача #0b3a0624 завершена'); expect(text).not.toContain('Keyboard handlers added');