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 <iliyazelenkog@gmail.com>
Co-authored-by: 777genius <quantjumppro@gmail.com>
This commit is contained in:
Zelen 2026-05-01 21:25:03 +03:00 committed by GitHub
parent 8f1ee5603c
commit ca21ab206e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 105 additions and 23 deletions

View file

@ -179,6 +179,11 @@ export class TeamConfigReader {
private static readonly configCacheByPath = new Map<string, CachedTeamConfig>();
private static readonly configReadInFlightByPath = new Map<string, Promise<TeamConfig | null>>();
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);

View file

@ -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'

View file

@ -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,

View file

@ -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,

View file

@ -85,11 +85,11 @@ const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
const NOOP_TEAM_CLICK = (): void => undefined;
type ViewerMarkdownMode = 'default' | 'compact-preview';
type HastElementLike = {
interface HastElementLike {
tagName?: string;
value?: string;
children?: unknown[];
};
}
// =============================================================================
// Helpers

View file

@ -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 ? (
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-400">
<AlertTriangle size={10} />
API Error
{message.messageKind === 'agent_error' ? 'Agent Error' : 'API Error'}
</span>
) : null;

View file

@ -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,

View file

@ -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;

View file

@ -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<boolean>): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < 5_000) {
while (Date.now() - startedAt < 20_000) {
if (await assertion()) {
return;
}

View file

@ -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',
});
});
});

View file

@ -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<any> {
describe('TeamProvisioningService relayLeadInboxMessages', () => {
beforeEach(() => {
TeamConfigReader.clearCacheForTests();
hoisted.files.clear();
hoisted.readFile.mockClear();
hoisted.mkdir.mockClear();

View file

@ -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', () => {

View file

@ -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 () => {

View file

@ -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');

View file

@ -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');