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:
parent
8f1ee5603c
commit
ca21ab206e
15 changed files with 105 additions and 23 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue