agent-ecosystem/test/renderer/components/team/members/MemberCardOpenCodeDeliveryAdvisory.fixture-e2e.test.tsx
2026-05-09 14:34:33 +03:00

425 lines
14 KiB
TypeScript

import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
import { TeamMemberRuntimeAdvisoryService } from '@main/services/team/TeamMemberRuntimeAdvisoryService';
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import { OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS } from '@main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy';
import type { OpenCodePromptDeliveryLedgerRecord } from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger';
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import type {
MemberRuntimeAdvisory,
ResolvedTeamMember,
TeamChangeEvent,
} from '@shared/types';
const hoisted = vi.hoisted(() => ({
openExternal: vi.fn(),
}));
vi.mock('@renderer/api', () => ({
api: {
openExternal: hoisted.openExternal,
},
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({
children,
className,
title,
}: {
children: React.ReactNode;
className?: string;
title?: string;
}) => React.createElement('span', { className, title }, children),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({
CurrentTaskIndicator: () => null,
}));
import { MemberCard } from '@renderer/components/team/members/MemberCard';
const TEAM_NAME = 'opencode-advisory-e2e';
const MEMBER_NAME = 'jack';
const LANE_ID = 'secondary:opencode:jack';
const NOW_ISO = '2026-05-09T12:05:00.000Z';
const OLD_FAILURE_ISO = new Date(
Date.parse(NOW_ISO) - OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS - 5_000
).toISOString();
const FRESH_FAILURE_ISO = new Date(Date.parse(NOW_ISO) - 10_000).toISOString();
let tempDir = '';
let tempClaudeRoot = '';
interface SideEffectHarness {
addTeamNotification: ReturnType<typeof vi.fn>;
sendMessageToRun: ReturnType<typeof vi.fn>;
teamChangeEvents: TeamChangeEvent[];
invalidations: { teamName: string; memberName: string }[];
}
interface TeamProvisioningSideEffectAccess {
aliveRunByTeam: Map<string, string>;
runs: Map<string, unknown>;
sendMessageToRun: (run: unknown, text: string) => Promise<void>;
handleOpenCodeRuntimeDeliveryUserFacingSideEffects: (
record: OpenCodePromptDeliveryLedgerRecord
) => Promise<void>;
openCodeRuntimeDeliveryAdvisoryReviewTimers: Map<string, ReturnType<typeof setTimeout>>;
}
const baseMember: ResolvedTeamMember = {
name: MEMBER_NAME,
status: 'unknown',
taskCount: 0,
currentTaskId: null,
lastActiveAt: null,
messageCount: 0,
color: 'purple',
agentType: 'developer',
role: 'Developer',
providerId: 'opencode',
removedAt: undefined,
};
describe('MemberCard OpenCode delivery advisory fixture e2e', () => {
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(NOW_ISO));
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-card-opencode-advisory-e2e-'));
tempClaudeRoot = path.join(tempDir, '.claude');
await fs.mkdir(tempClaudeRoot, { recursive: true });
setClaudeBasePathOverride(tempClaudeRoot);
});
afterEach(async () => {
document.body.innerHTML = '';
hoisted.openExternal.mockReset();
NotificationManager.resetInstance();
setClaudeBasePathOverride(null);
vi.unstubAllGlobals();
vi.useRealTimers();
await fs.rm(tempDir, { recursive: true, force: true });
});
it('keeps a fresh generic terminal failure out of the member card and user-facing side effects', async () => {
const record = makeDeliveryRecord({
failedAt: FRESH_FAILURE_ISO,
updatedAt: FRESH_FAILURE_ISO,
lastObservedAt: FRESH_FAILURE_ISO,
respondedAt: FRESH_FAILURE_ISO,
});
await writeDeliveryFixture(record);
const advisory = await readMemberAdvisory();
expect(advisory).toBeNull();
const cardText = await renderMemberCardText(advisory);
expect(cardText).not.toContain('OpenCode delivery error');
expect(cardText).not.toContain('OpenCode returned an empty assistant turn');
const sideEffects = await runUserFacingSideEffects(record);
expect(sideEffects.addTeamNotification).not.toHaveBeenCalled();
expect(sideEffects.sendMessageToRun).not.toHaveBeenCalled();
expect(sideEffects.invalidations).toEqual([{ teamName: TEAM_NAME, memberName: MEMBER_NAME }]);
expect(sideEffects.teamChangeEvents).toContainEqual(
expect.objectContaining({
type: 'member-advisory',
teamName: TEAM_NAME,
})
);
});
it('suppresses a stale terminal failure across card, notification, and lead notice after visible reply proof appears', async () => {
const record = makeDeliveryRecord({
failedAt: OLD_FAILURE_ISO,
updatedAt: OLD_FAILURE_ISO,
lastObservedAt: OLD_FAILURE_ISO,
respondedAt: OLD_FAILURE_ISO,
});
await writeDeliveryFixture(record);
await writeVisibleRuntimeReplyProof(record);
const advisory = await readMemberAdvisory();
expect(advisory).toBeNull();
const cardText = await renderMemberCardText(advisory);
expect(cardText).not.toContain('OpenCode delivery error');
expect(cardText).not.toContain('OpenCode returned an empty assistant turn');
const sideEffects = await runUserFacingSideEffects(record);
expect(sideEffects.addTeamNotification).not.toHaveBeenCalled();
expect(sideEffects.sendMessageToRun).not.toHaveBeenCalled();
expect(sideEffects.invalidations).toEqual([{ teamName: TEAM_NAME, memberName: MEMBER_NAME }]);
});
it('still surfaces a stale terminal failure with no proof in the card, notification, and lead notice', async () => {
const record = makeDeliveryRecord({
failedAt: OLD_FAILURE_ISO,
updatedAt: OLD_FAILURE_ISO,
lastObservedAt: OLD_FAILURE_ISO,
respondedAt: OLD_FAILURE_ISO,
});
await writeDeliveryFixture(record);
const advisory = await readMemberAdvisory();
expect(advisory).toMatchObject({
kind: 'api_error',
reasonCode: 'backend_error',
message: 'OpenCode returned an empty assistant turn.',
});
const cardText = await renderMemberCardText(advisory);
expect(cardText).toContain('OpenCode delivery error');
expect(cardText).toContain('OpenCode returned an empty assistant turn.');
const sideEffects = await runUserFacingSideEffects(record);
expect(sideEffects.addTeamNotification).toHaveBeenCalledTimes(1);
expect(sideEffects.addTeamNotification.mock.calls[0]?.[0]).toMatchObject({
teamEventType: 'api_error',
teamName: TEAM_NAME,
from: MEMBER_NAME,
summary: 'OpenCode runtime error #task-1',
});
expect(sideEffects.sendMessageToRun).toHaveBeenCalledTimes(1);
expect(String(sideEffects.sendMessageToRun.mock.calls[0]?.[1])).toContain(
'System notice: OpenCode teammate @jack hit a runtime delivery error while handling #task-1.'
);
});
});
async function readMemberAdvisory(): Promise<MemberRuntimeAdvisory | null> {
const service = new TeamMemberRuntimeAdvisoryService({
findMemberLogs: vi.fn(() => Promise.resolve([])),
});
return await service.getMemberAdvisory(TEAM_NAME, MEMBER_NAME);
}
async function renderMemberCardText(
runtimeAdvisory: MemberRuntimeAdvisory | null
): Promise<string> {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: {
...baseMember,
runtimeAdvisory: runtimeAdvisory ?? undefined,
},
memberColor: 'purple',
runtimeSummary: 'OpenCode - kimi-k2.6',
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'online',
spawnLaunchState: 'confirmed_alive',
spawnRuntimeAlive: true,
})
);
await Promise.resolve();
});
const text = host.textContent ?? '';
await act(async () => {
root.unmount();
await Promise.resolve();
});
host.remove();
return text;
}
async function runUserFacingSideEffects(
record: OpenCodePromptDeliveryLedgerRecord
): Promise<SideEffectHarness> {
const addTeamNotification = vi.fn(() => Promise.resolve(undefined));
NotificationManager.setInstance({ addTeamNotification } as never);
const service = new TeamProvisioningService();
const access = service as unknown as TeamProvisioningSideEffectAccess;
const sendMessageToRun = vi.fn(() => Promise.resolve(undefined));
const teamChangeEvents: TeamChangeEvent[] = [];
const invalidations: { teamName: string; memberName: string }[] = [];
service.setTeamChangeEmitter((event) => {
teamChangeEvents.push(event);
});
service.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => {
invalidations.push({ teamName, memberName });
});
access.sendMessageToRun = sendMessageToRun;
access.aliveRunByTeam.set(TEAM_NAME, 'lead-run-1');
access.runs.set('lead-run-1', {
runId: 'lead-run-1',
teamName: TEAM_NAME,
processKilled: false,
cancelRequested: false,
});
await access.handleOpenCodeRuntimeDeliveryUserFacingSideEffects(record);
for (const timer of access.openCodeRuntimeDeliveryAdvisoryReviewTimers.values()) {
clearTimeout(timer);
}
access.openCodeRuntimeDeliveryAdvisoryReviewTimers.clear();
return {
addTeamNotification,
sendMessageToRun,
teamChangeEvents,
invalidations,
};
}
function makeDeliveryRecord(
overrides: Partial<OpenCodePromptDeliveryLedgerRecord> = {}
): OpenCodePromptDeliveryLedgerRecord {
return {
id: 'opencode-prompt:msg-empty-turn',
teamName: TEAM_NAME,
memberName: MEMBER_NAME,
laneId: LANE_ID,
runId: 'opencode-run-1',
runtimeSessionId: 'session-jack',
inboxMessageId: 'msg-empty-turn',
inboxTimestamp: overrides.inboxTimestamp ?? OLD_FAILURE_ISO,
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: 'ask',
taskRefs: [{ taskId: 'task-1', displayId: 'task-1', teamName: TEAM_NAME }],
payloadHash: 'sha256:test',
status: 'failed_terminal',
responseState: 'empty_assistant_turn',
attempts: 3,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: overrides.lastAttemptAt ?? OLD_FAILURE_ISO,
lastObservedAt: overrides.lastObservedAt ?? OLD_FAILURE_ISO,
acceptedAt: overrides.acceptedAt ?? OLD_FAILURE_ISO,
respondedAt: overrides.respondedAt ?? OLD_FAILURE_ISO,
failedAt: overrides.failedAt ?? OLD_FAILURE_ISO,
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'opencode-user-msg-1',
observedAssistantMessageId: 'opencode-assistant-empty',
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: 'empty_assistant_turn',
diagnostics: ['empty_assistant_turn'],
createdAt: overrides.createdAt ?? OLD_FAILURE_ISO,
updatedAt: overrides.updatedAt ?? OLD_FAILURE_ISO,
...overrides,
};
}
async function writeDeliveryFixture(record: OpenCodePromptDeliveryLedgerRecord): Promise<void> {
const teamDir = path.join(tempClaudeRoot, 'teams', TEAM_NAME);
const runtimeDir = path.join(teamDir, '.opencode-runtime');
const laneDir = path.join(runtimeDir, 'lanes', encodeURIComponent(LANE_ID));
await fs.mkdir(laneDir, { recursive: true });
await fs.mkdir(path.join(teamDir, 'inboxes'), { recursive: true });
await fs.writeFile(
path.join(teamDir, 'config.json'),
`${JSON.stringify(
{
name: TEAM_NAME,
projectPath: path.join(tempDir, 'project'),
leadSessionId: 'lead-session',
members: [
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
{ name: MEMBER_NAME, role: 'Developer', providerId: 'opencode' },
],
},
null,
2
)}\n`,
'utf8'
);
await fs.writeFile(
path.join(runtimeDir, 'lanes.json'),
`${JSON.stringify(
{
version: 1,
updatedAt: record.updatedAt,
lanes: {
[LANE_ID]: {
laneId: LANE_ID,
state: 'active',
updatedAt: record.updatedAt,
},
},
},
null,
2
)}\n`,
'utf8'
);
await fs.writeFile(
path.join(laneDir, 'opencode-prompt-delivery-ledger.json'),
`${JSON.stringify(
{
schemaVersion: 1,
updatedAt: record.updatedAt,
data: [record],
},
null,
2
)}\n`,
'utf8'
);
}
async function writeVisibleRuntimeReplyProof(
record: OpenCodePromptDeliveryLedgerRecord
): Promise<void> {
await fs.writeFile(
path.join(tempClaudeRoot, 'teams', TEAM_NAME, 'inboxes', 'team-lead.json'),
`${JSON.stringify(
[
{
from: MEMBER_NAME,
to: 'team-lead',
text: 'Done, visible reply already delivered.',
timestamp: NOW_ISO,
read: false,
source: 'runtime_delivery',
messageId: 'visible-runtime-reply-1',
relayOfMessageId: record.inboxMessageId,
taskRefs: record.taskRefs,
},
],
null,
2
)}\n`,
'utf8'
);
}