fix(team): stabilize launch previews and codex reconnect

This commit is contained in:
777genius 2026-05-07 20:58:40 +03:00
parent 472a1501ad
commit 8d06ee81c2
12 changed files with 382 additions and 49 deletions

View file

@ -184,6 +184,7 @@ export function useGraphMemberLogPreviews(input: {
const cacheRef = useRef(new Map<string, { expiresAt: number; member: MemberLogPreviewMember }>()); const cacheRef = useRef(new Map<string, { expiresAt: number; member: MemberLogPreviewMember }>());
const previewsByMemberRef = useRef(previewsByMember); const previewsByMemberRef = useRef(previewsByMember);
const inFlightRef = useRef(new Map<string, Promise<Map<string, MemberLogPreviewMember>>>()); const inFlightRef = useRef(new Map<string, Promise<Map<string, MemberLogPreviewMember>>>());
const activeRequestKeyByMemberRef = useRef(new Map<string, string>());
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const teamNameRef = useRef(input.teamName); const teamNameRef = useRef(input.teamName);
@ -196,6 +197,7 @@ export function useGraphMemberLogPreviews(input: {
teamNameRef.current = input.teamName; teamNameRef.current = input.teamName;
cacheRef.current.clear(); cacheRef.current.clear();
inFlightRef.current.clear(); inFlightRef.current.clear();
activeRequestKeyByMemberRef.current.clear();
setPreviewsByMember(new Map()); setPreviewsByMember(new Map());
} }
if (!enabled || memberNames.length === 0) { if (!enabled || memberNames.length === 0) {
@ -261,6 +263,9 @@ export function useGraphMemberLogPreviews(input: {
forceRefresh: options?.forceRefresh, forceRefresh: options?.forceRefresh,
}); });
const requestTeamName = input.teamName; const requestTeamName = input.teamName;
for (const memberName of membersToRequest) {
activeRequestKeyByMemberRef.current.set(normalizeMemberName(memberName), requestKey);
}
if (!options?.background && hasMissingPreview) { if (!options?.background && hasMissingPreview) {
setLoading(true); setLoading(true);
@ -310,7 +315,15 @@ export function useGraphMemberLogPreviews(input: {
if (teamNameRef.current !== requestTeamName) { if (teamNameRef.current !== requestTeamName) {
return; return;
} }
setPreviewsByMember((current) => mergeMemberPreviews(current, members.values())); const currentMembers = Array.from(members.values()).filter((member) => {
return (
activeRequestKeyByMemberRef.current.get(normalizeMemberName(member.memberName)) ===
requestKey
);
});
if (currentMembers.length > 0) {
setPreviewsByMember((current) => mergeMemberPreviews(current, currentMembers));
}
setError(null); setError(null);
} catch (loadError) { } catch (loadError) {
if (teamNameRef.current !== requestTeamName) { if (teamNameRef.current !== requestTeamName) {

View file

@ -4,7 +4,6 @@ import {
type CodexAppServerLoginAccountResponse, type CodexAppServerLoginAccountResponse,
type CodexAppServerSession, type CodexAppServerSession,
} from '@main/services/infrastructure/codexAppServer'; } from '@main/services/infrastructure/codexAppServer';
import { shell } from 'electron';
import type { CodexLoginStateDto } from '@features/codex-account/contracts'; import type { CodexLoginStateDto } from '@features/codex-account/contracts';
import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer'; import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer';
@ -139,8 +138,6 @@ export class CodexLoginSessionManager {
startedAt: this.state.startedAt, startedAt: this.state.startedAt,
authUrl: authUrl.toString(), authUrl: authUrl.toString(),
}); });
await shell.openExternal(authUrl.toString());
} catch (error) { } catch (error) {
const wasAbandonedDuringStart = const wasAbandonedDuringStart =
this.pendingStartToken !== startToken && this.pendingStartToken !== startToken &&

View file

@ -537,8 +537,7 @@ Reply to this comment using MCP tool task_add_comment.
expect(result.items[0]).toMatchObject({ expect(result.items[0]).toMatchObject({
kind: 'tool_result', kind: 'tool_result',
title: 'Read task error', title: 'Read task error',
preview: preview: "Tool 'task_get' execution failed: Task not found: 211e430b",
"Tool 'task_get' execution failed: Task not found: 211e430b-0901-4c9e-9296-2b6e2059a08f",
tone: 'error', tone: 'error',
}); });
}); });
@ -638,6 +637,39 @@ Reply to this comment using MCP tool task_add_comment.
expect(result.items).toHaveLength(1); expect(result.items).toHaveLength(1);
}); });
it('does not repeat generic comment titles in compact previews', () => {
const result = extractMemberLogPreviewItems({
provider: 'claude_transcript',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'comment-result',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-comment',
content: JSON.stringify({
comment: {
text: 'Focused checks passed.',
},
}),
},
],
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Comment',
preview: 'Focused checks passed.',
});
});
it('distinguishes read-comment results from add-comment results', () => { it('distinguishes read-comment results from add-comment results', () => {
const result = extractMemberLogPreviewItems({ const result = extractMemberLogPreviewItems({
provider: 'claude_transcript', provider: 'claude_transcript',
@ -1694,6 +1726,115 @@ Reply to this comment using MCP tool task_add_comment.
}); });
}); });
it('cleans tagged file tool output and shortens absolute paths for compact rows', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'read-call',
timestamp: '2026-04-01T10:00:00.000Z',
content: [
{
type: 'tool_use',
id: 'tool-read',
name: 'Read',
input: {
file_path: '/Users/belief/dev/projects/demo/app/page.tsx',
},
},
],
}),
message({
uuid: 'read-result',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-read',
content: `<path>/Users/belief/dev/projects/demo/app/page.tsx</path>
<type>file</type>
<content>
1: export default function Page() {
2: return null;
3: }
(End of file - total 3 lines)
</content>`,
},
],
}),
],
});
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Read result',
preview: 'demo/app/page.tsx - export default function Page() { return null; }',
});
expect(result.items[0]?.preview).not.toContain('/Users/belief');
expect(result.items[0]?.preview).not.toContain('<path>');
expect(result.items[0]?.preview).not.toContain('1:');
});
it('cleans tagged directory tool output without repeating absolute paths', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'read-call',
timestamp: '2026-04-01T10:00:00.000Z',
content: [
{
type: 'tool_use',
id: 'tool-read',
name: 'Read',
input: {
file_path: '/Users/belief/dev/projects/demo',
},
},
],
}),
message({
uuid: 'read-result',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-read',
content: `<path>/Users/belief/dev/projects/demo</path>
<type>directory</type>
<entries>
app/
package.json
README.md
(3 entries)
</entries>`,
},
],
}),
],
});
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Read result',
preview: 'demo - directory app/ package.json README.md',
});
expect(result.items[0]?.preview).not.toContain('/Users/belief');
expect(result.items[0]?.preview).not.toContain('(3 entries)');
});
it('does not label arbitrary message fields as sent messages', () => { it('does not label arbitrary message fields as sent messages', () => {
const result = extractMemberLogPreviewItems({ const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime', provider: 'opencode_runtime',

View file

@ -191,8 +191,15 @@ function parseJsonLikeString(value: string): unknown {
} }
} }
function shortenLongIdsForPreview(value: string): string {
return value.replace(
/\b([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi,
'$1'
);
}
function truncatePreview(value: string, limit: number): { preview: string; truncated: boolean } { function truncatePreview(value: string, limit: number): { preview: string; truncated: boolean } {
const compact = compactWhitespace(removeHiddenInstructionBlocks(value)); const compact = shortenLongIdsForPreview(compactWhitespace(removeHiddenInstructionBlocks(value)));
if (compact.length <= limit) { if (compact.length <= limit) {
return { preview: compact, truncated: false }; return { preview: compact, truncated: false };
} }
@ -533,6 +540,30 @@ function formatShellResultContext(toolContext: ToolUseContext | undefined): stri
return stringField(input, 'description') ?? stringField(input, 'command'); return stringField(input, 'description') ?? stringField(input, 'command');
} }
function shortenPathForPreview(value: string): string {
const compact = compactWhitespace(value);
if (!compact) {
return '';
}
const normalized = compact.replace(/\\/g, '/');
if (!normalized.startsWith('/') && normalized.length <= 56) {
return normalized;
}
const parts = normalized.split('/').filter(Boolean);
if (parts.length <= 3) {
return normalized.startsWith('/') ? parts.join('/') : normalized;
}
const projectsIndex = parts.lastIndexOf('projects');
if (projectsIndex >= 0 && projectsIndex < parts.length - 1) {
const projectRelative = parts.slice(projectsIndex + 1);
if (projectRelative.length <= 4) {
return projectRelative.join('/');
}
}
const tail = parts.slice(-3);
return tail[0] === 'projects' ? parts.slice(-2).join('/') : tail.join('/');
}
function addContextToSuccessResultPreview( function addContextToSuccessResultPreview(
preview: ValuePreview, preview: ValuePreview,
context: string | null, context: string | null,
@ -580,15 +611,16 @@ function formatFileToolResultContext(toolContext: ToolUseContext | undefined): s
stringField(input, 'filePath') ?? stringField(input, 'filePath') ??
stringField(input, 'path') ?? stringField(input, 'path') ??
stringField(input, 'cwd'); stringField(input, 'cwd');
const compactPath = path ? shortenPathForPreview(path) : null;
if (toolContext.canonicalName === 'grep') { if (toolContext.canonicalName === 'grep') {
const query = stringField(input, 'query') ?? stringField(input, 'pattern'); const query = stringField(input, 'query') ?? stringField(input, 'pattern');
if (query && path) return `${query} in ${path}`; if (query && compactPath) return `${query} in ${compactPath}`;
return query ?? path; return query ?? compactPath;
} }
if (toolContext.canonicalName === 'glob') { if (toolContext.canonicalName === 'glob') {
const pattern = stringField(input, 'pattern') ?? stringField(input, 'glob'); const pattern = stringField(input, 'pattern') ?? stringField(input, 'glob');
if (pattern && path) return `${pattern} in ${path}`; if (pattern && compactPath) return `${pattern} in ${compactPath}`;
return pattern ?? path; return pattern ?? compactPath;
} }
if ( if (
toolContext.canonicalName === 'read' || toolContext.canonicalName === 'read' ||
@ -596,7 +628,7 @@ function formatFileToolResultContext(toolContext: ToolUseContext | undefined): s
toolContext.canonicalName === 'edit' || toolContext.canonicalName === 'edit' ||
toolContext.canonicalName === 'ls' toolContext.canonicalName === 'ls'
) { ) {
return path; return compactPath;
} }
return null; return null;
} }
@ -733,7 +765,7 @@ function formatTaskCommentPayload(
if (author && taskRef) return `Comment by ${author} on ${taskRef}: ${commentText}`; if (author && taskRef) return `Comment by ${author} on ${taskRef}: ${commentText}`;
if (author) return `Comment by ${author}: ${commentText}`; if (author) return `Comment by ${author}: ${commentText}`;
if (taskRef) return `Comment on ${taskRef}: ${commentText}`; if (taskRef) return `Comment on ${taskRef}: ${commentText}`;
return `Comment: ${commentText}`; return commentText;
} }
function countArrayField(payload: Record<string, unknown>, keys: readonly string[]): number | null { function countArrayField(payload: Record<string, unknown>, keys: readonly string[]): number | null {
@ -1485,6 +1517,34 @@ function formatPlainToolResultStatus(
); );
} }
function taggedSection(value: string, tag: string): string | null {
const match = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(value);
return match?.[1]?.trim() || null;
}
function stripTaggedFileLineNumbers(value: string): string {
return value
.replace(/\(End of file - total \d+ lines?\)/gi, ' ')
.replace(/\(\d+ entries\)/gi, ' ')
.split(/\r?\n/)
.map((line) => line.replace(/^\s*\d+:\s*/, '').trim())
.filter(Boolean)
.join(' ');
}
function formatTaggedFileToolResult(value: string): string | null {
const content = taggedSection(value, 'content') ?? taggedSection(value, 'entries');
if (!content) {
return null;
}
const type = taggedSection(value, 'type')?.toLowerCase();
const body = compactWhitespace(stripTaggedFileLineNumbers(content));
if (!body) {
return null;
}
return type === 'directory' ? `directory ${body}` : body;
}
function formatPlainToolErrorText(value: string, limit: number): ValuePreview | null { function formatPlainToolErrorText(value: string, limit: number): ValuePreview | null {
const compact = compactWhitespace(removeHiddenInstructionBlocks(value)); const compact = compactWhitespace(removeHiddenInstructionBlocks(value));
if (!compact) { if (!compact) {
@ -1649,6 +1709,10 @@ function previewUnknownValue(
if (plainStatus) { if (plainStatus) {
return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title }; return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title };
} }
const taggedFileResult = formatTaggedFileToolResult(value);
if (taggedFileResult) {
return truncatePreview(taggedFileResult, limit);
}
const parsed = parseJsonLikeString(value); const parsed = parseJsonLikeString(value);
if (parsed != null) { if (parsed != null) {
return previewUnknownValue(parsed, limit, priorityKeys, toolContext); return previewUnknownValue(parsed, limit, priorityKeys, toolContext);
@ -1749,6 +1813,15 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number):
: 'Read cross-team outbox'; : 'Read cross-team outbox';
return truncatePreview(text, limit); return truncatePreview(text, limit);
} }
const fileToolContext = formatFileToolResultContext({
id: '',
name: toolName,
canonicalName: canonical,
input: value,
});
if (fileToolContext) {
return truncatePreview(fileToolContext, limit);
}
const payload = recordFromUnknown(value); const payload = recordFromUnknown(value);
if (payload) { if (payload) {
const runtimeFormatted = formatRuntimePayload(payload, canonical, payload); const runtimeFormatted = formatRuntimePayload(payload, canonical, payload);

View file

@ -16230,6 +16230,7 @@ export class TeamProvisioningService {
run.child.stderr?.removeAllListeners('data'); run.child.stderr?.removeAllListeners('data');
run.child.removeAllListeners('error'); run.child.removeAllListeners('error');
run.child.removeAllListeners('exit'); run.child.removeAllListeners('exit');
run.child.removeAllListeners('close');
killTeamProcess(run.child); killTeamProcess(run.child);
run.child = null; run.child = null;
} }
@ -16469,7 +16470,7 @@ export class TeamProvisioningService {
this.cleanupRun(run); this.cleanupRun(run);
}); });
child.once('exit', (code) => { child.once('close', (code) => {
void this.handleProcessExit(run, code); void this.handleProcessExit(run, code);
}); });
} }
@ -17083,7 +17084,7 @@ export class TeamProvisioningService {
this.cleanupRun(run); this.cleanupRun(run);
}); });
child.once('exit', (code) => { child.once('close', (code) => {
void this.handleProcessExit(run, code); void this.handleProcessExit(run, code);
}); });
@ -18381,7 +18382,7 @@ export class TeamProvisioningService {
this.cleanupRun(run); this.cleanupRun(run);
}); });
child.once('exit', (code) => { child.once('close', (code) => {
void this.handleProcessExit(run, code); void this.handleProcessExit(run, code);
}); });

View file

@ -908,7 +908,7 @@ const InstalledBanner = ({
color: '#fbbf24', color: '#fbbf24',
}} }}
> >
{codexLoginAuthUrl ? 'Open login' : 'Reconnect ChatGPT'} {codexLoginAuthUrl ? 'Open login' : 'Generate link'}
</button> </button>
</> </>
) : null} ) : null}
@ -1153,16 +1153,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const handleCodexDashboardLogin = useCallback(() => { const handleCodexDashboardLogin = useCallback(() => {
void (async () => { void (async () => {
const success = await codexAccount.startChatgptLogin(); await codexAccount.startChatgptLogin();
if (success) {
await refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
}
})(); })();
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); }, [codexAccount]);
const recheckAuthState = useCallback(() => { const recheckAuthState = useCallback(() => {
setIsVerifyingAuth(true); setIsVerifyingAuth(true);

View file

@ -1439,7 +1439,7 @@ export const ProviderRuntimeSettingsDialog = ({
onClick={() => void handleCodexStartLogin()} onClick={() => void handleCodexStartLogin()}
> >
<Link2 className="mr-1 size-3.5" /> <Link2 className="mr-1 size-3.5" />
{codexNeedsReconnect ? 'Reconnect ChatGPT' : 'Connect ChatGPT'} {codexNeedsReconnect ? 'Generate link' : 'Connect ChatGPT'}
</Button> </Button>
</> </>
)} )}

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton'; import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
import { api } from '@renderer/api';
import { LogIn } from 'lucide-react'; import { LogIn } from 'lucide-react';
import type { ProvisioningProviderCheck } from './ProvisioningProviderStatusList'; import type { ProvisioningProviderCheck } from './ProvisioningProviderStatusList';
@ -78,13 +79,19 @@ export const CodexReconnectPrompt = ({
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<p className="min-w-0 flex-1 text-[11px] text-amber-100/90"> <p className="min-w-0 flex-1 text-[11px] text-amber-100/90">
Codex found the local ChatGPT account, but this session is stale. Reconnect ChatGPT, then Codex found the local ChatGPT account, but this session is stale. Sign in with ChatGPT,
finish login in the browser and retry this dialog. then finish login in the browser and retry this dialog.
</p> </p>
<CodexLoginLinkCopyButton authUrl={authUrl} disabled={reconnectBusy} size="xs" /> <CodexLoginLinkCopyButton authUrl={authUrl} disabled={reconnectBusy} size="xs" />
<button <button
type="button" type="button"
onClick={onReconnect} onClick={() => {
if (authUrl) {
void api.openExternal(authUrl);
return;
}
onReconnect();
}}
disabled={reconnectBusy} disabled={reconnectBusy}
className="inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium text-amber-300 transition-colors hover:bg-white/5 disabled:opacity-50" className="inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium text-amber-300 transition-colors hover:bg-white/5 disabled:opacity-50"
style={{ style={{
@ -93,7 +100,7 @@ export const CodexReconnectPrompt = ({
}} }}
> >
<LogIn className="size-3" /> <LogIn className="size-3" />
{reconnectBusy ? 'Opening...' : 'Reconnect ChatGPT'} {reconnectBusy ? 'Generating...' : authUrl ? 'Open login' : 'Generate link'}
</button> </button>
</div> </div>
</div> </div>

View file

@ -713,16 +713,9 @@ export const CreateTeamDialog = ({
const handleCodexReconnect = useCallback(() => { const handleCodexReconnect = useCallback(() => {
void (async () => { void (async () => {
const success = await codexAccount.startChatgptLogin(); await codexAccount.startChatgptLogin();
if (success) {
await refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
}
})(); })();
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); }, [codexAccount]);
useEffect(() => { useEffect(() => {
if (!open || !canCreate || !launchTeam) { if (!open || !canCreate || !launchTeam) {

View file

@ -590,16 +590,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const handleCodexReconnect = React.useCallback(() => { const handleCodexReconnect = React.useCallback(() => {
void (async () => { void (async () => {
const success = await codexAccount.startChatgptLogin(); await codexAccount.startChatgptLogin();
if (success) {
await refreshCliStatusForCurrentMode({
multimodelEnabled,
bootstrapCliStatus,
fetchCliStatus,
});
}
})(); })();
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); }, [codexAccount]);
// Schedule store actions // Schedule store actions
const createSchedule = useStore((s) => s.createSchedule); const createSchedule = useStore((s) => s.createSchedule);

View file

@ -12356,6 +12356,56 @@ describe('TeamProvisioningService', () => {
await svc.cancelProvisioning(runId); await svc.cancelProvisioning(runId);
}); });
it('waits for child close before handling launch process exit so stream-json can drain', async () => {
allowConsoleLogs();
const teamName = 'launch-close-drains-stdout-team';
const leadSessionId = 'lead-session-close-drain';
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
const child = createRunningChild();
vi.mocked(spawnCli).mockReturnValue(child as any);
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
removeConfigFile: vi.fn(async () => {}),
} as any);
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: { ANTHROPIC_API_KEY: 'test' },
authSource: 'anthropic_api_key',
}));
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
members: [{ name: 'alice' }],
source: 'members-meta',
warning: undefined,
}));
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).pathExists = vi.fn(async (targetPath: string) =>
targetPath.endsWith(`${leadSessionId}.jsonl`)
);
const handleProcessExit = vi
.spyOn(svc as any, 'handleProcessExit')
.mockResolvedValue(undefined);
const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {});
child.emit('exit', 0);
await Promise.resolve();
expect(handleProcessExit).not.toHaveBeenCalled();
child.emit('close', 0);
await vi.waitFor(() => expect(handleProcessExit).toHaveBeenCalledTimes(1));
expect(handleProcessExit.mock.calls[0]?.[1]).toBe(0);
await svc.cancelProvisioning(runId);
});
it('clears stale team-scoped transient state before starting a new launch run', async () => { it('clears stale team-scoped transient state before starting a new launch run', async () => {
allowConsoleLogs(); allowConsoleLogs();
vi.useFakeTimers(); vi.useFakeTimers();

View file

@ -337,6 +337,78 @@ describe('useGraphMemberLogPreviews', () => {
}); });
}); });
it('ignores stale responses when the same member receives a newer lane request', async () => {
const oldLaneLoad = createDeferred<MemberLogPreviewResponse>();
const newLaneLoad = createDeferred<MemberLogPreviewResponse>();
apiMock.memberLogStream.getMemberLogPreviews
.mockReturnValueOnce(oldLaneLoad.promise)
.mockReturnValueOnce(newLaneLoad.promise);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
states.push(state);
});
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
states.at(-1);
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
memberNames={['alice']}
laneIdsByMember={{ alice: 'secondary:opencode:alice:old' }}
onState={onState}
/>
);
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
memberNames={['alice']}
laneIdsByMember={{ alice: 'secondary:opencode:alice:new' }}
onState={onState}
/>
);
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
await act(async () => {
newLaneLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z'));
await Promise.resolve();
});
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe(
'alice:2026-04-03T00:01:00.000Z'
);
await act(async () => {
oldLaneLoad.resolve(response('alice', '2026-04-03T00:00:00.000Z'));
await Promise.resolve();
});
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe(
'alice:2026-04-03T00:01:00.000Z'
);
act(() => {
root.unmount();
});
});
it('reloads visible members on log-source events with force refresh', async () => { it('reloads visible members on log-source events with force refresh', async () => {
let teamChangeListener: let teamChangeListener:
| ((event: unknown, data: { teamName: string; type: string }) => void) | ((event: unknown, data: { teamName: string; type: string }) => void)