fix(team): stabilize launch previews and codex reconnect
This commit is contained in:
parent
472a1501ad
commit
8d06ee81c2
12 changed files with 382 additions and 49 deletions
|
|
@ -184,6 +184,7 @@ export function useGraphMemberLogPreviews(input: {
|
|||
const cacheRef = useRef(new Map<string, { expiresAt: number; member: MemberLogPreviewMember }>());
|
||||
const previewsByMemberRef = useRef(previewsByMember);
|
||||
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 teamNameRef = useRef(input.teamName);
|
||||
|
||||
|
|
@ -196,6 +197,7 @@ export function useGraphMemberLogPreviews(input: {
|
|||
teamNameRef.current = input.teamName;
|
||||
cacheRef.current.clear();
|
||||
inFlightRef.current.clear();
|
||||
activeRequestKeyByMemberRef.current.clear();
|
||||
setPreviewsByMember(new Map());
|
||||
}
|
||||
if (!enabled || memberNames.length === 0) {
|
||||
|
|
@ -261,6 +263,9 @@ export function useGraphMemberLogPreviews(input: {
|
|||
forceRefresh: options?.forceRefresh,
|
||||
});
|
||||
const requestTeamName = input.teamName;
|
||||
for (const memberName of membersToRequest) {
|
||||
activeRequestKeyByMemberRef.current.set(normalizeMemberName(memberName), requestKey);
|
||||
}
|
||||
|
||||
if (!options?.background && hasMissingPreview) {
|
||||
setLoading(true);
|
||||
|
|
@ -310,7 +315,15 @@ export function useGraphMemberLogPreviews(input: {
|
|||
if (teamNameRef.current !== requestTeamName) {
|
||||
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);
|
||||
} catch (loadError) {
|
||||
if (teamNameRef.current !== requestTeamName) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
type CodexAppServerLoginAccountResponse,
|
||||
type CodexAppServerSession,
|
||||
} from '@main/services/infrastructure/codexAppServer';
|
||||
import { shell } from 'electron';
|
||||
|
||||
import type { CodexLoginStateDto } from '@features/codex-account/contracts';
|
||||
import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer';
|
||||
|
|
@ -139,8 +138,6 @@ export class CodexLoginSessionManager {
|
|||
startedAt: this.state.startedAt,
|
||||
authUrl: authUrl.toString(),
|
||||
});
|
||||
|
||||
await shell.openExternal(authUrl.toString());
|
||||
} catch (error) {
|
||||
const wasAbandonedDuringStart =
|
||||
this.pendingStartToken !== startToken &&
|
||||
|
|
|
|||
|
|
@ -537,8 +537,7 @@ Reply to this comment using MCP tool task_add_comment.
|
|||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Read task error',
|
||||
preview:
|
||||
"Tool 'task_get' execution failed: Task not found: 211e430b-0901-4c9e-9296-2b6e2059a08f",
|
||||
preview: "Tool 'task_get' execution failed: Task not found: 211e430b",
|
||||
tone: 'error',
|
||||
});
|
||||
});
|
||||
|
|
@ -638,6 +637,39 @@ Reply to this comment using MCP tool task_add_comment.
|
|||
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', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
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', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'opencode_runtime',
|
||||
|
|
|
|||
|
|
@ -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 } {
|
||||
const compact = compactWhitespace(removeHiddenInstructionBlocks(value));
|
||||
const compact = shortenLongIdsForPreview(compactWhitespace(removeHiddenInstructionBlocks(value)));
|
||||
if (compact.length <= limit) {
|
||||
return { preview: compact, truncated: false };
|
||||
}
|
||||
|
|
@ -533,6 +540,30 @@ function formatShellResultContext(toolContext: ToolUseContext | undefined): stri
|
|||
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(
|
||||
preview: ValuePreview,
|
||||
context: string | null,
|
||||
|
|
@ -580,15 +611,16 @@ function formatFileToolResultContext(toolContext: ToolUseContext | undefined): s
|
|||
stringField(input, 'filePath') ??
|
||||
stringField(input, 'path') ??
|
||||
stringField(input, 'cwd');
|
||||
const compactPath = path ? shortenPathForPreview(path) : null;
|
||||
if (toolContext.canonicalName === 'grep') {
|
||||
const query = stringField(input, 'query') ?? stringField(input, 'pattern');
|
||||
if (query && path) return `${query} in ${path}`;
|
||||
return query ?? path;
|
||||
if (query && compactPath) return `${query} in ${compactPath}`;
|
||||
return query ?? compactPath;
|
||||
}
|
||||
if (toolContext.canonicalName === 'glob') {
|
||||
const pattern = stringField(input, 'pattern') ?? stringField(input, 'glob');
|
||||
if (pattern && path) return `${pattern} in ${path}`;
|
||||
return pattern ?? path;
|
||||
if (pattern && compactPath) return `${pattern} in ${compactPath}`;
|
||||
return pattern ?? compactPath;
|
||||
}
|
||||
if (
|
||||
toolContext.canonicalName === 'read' ||
|
||||
|
|
@ -596,7 +628,7 @@ function formatFileToolResultContext(toolContext: ToolUseContext | undefined): s
|
|||
toolContext.canonicalName === 'edit' ||
|
||||
toolContext.canonicalName === 'ls'
|
||||
) {
|
||||
return path;
|
||||
return compactPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -733,7 +765,7 @@ function formatTaskCommentPayload(
|
|||
if (author && taskRef) return `Comment by ${author} on ${taskRef}: ${commentText}`;
|
||||
if (author) return `Comment by ${author}: ${commentText}`;
|
||||
if (taskRef) return `Comment on ${taskRef}: ${commentText}`;
|
||||
return `Comment: ${commentText}`;
|
||||
return commentText;
|
||||
}
|
||||
|
||||
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 {
|
||||
const compact = compactWhitespace(removeHiddenInstructionBlocks(value));
|
||||
if (!compact) {
|
||||
|
|
@ -1649,6 +1709,10 @@ function previewUnknownValue(
|
|||
if (plainStatus) {
|
||||
return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title };
|
||||
}
|
||||
const taggedFileResult = formatTaggedFileToolResult(value);
|
||||
if (taggedFileResult) {
|
||||
return truncatePreview(taggedFileResult, limit);
|
||||
}
|
||||
const parsed = parseJsonLikeString(value);
|
||||
if (parsed != null) {
|
||||
return previewUnknownValue(parsed, limit, priorityKeys, toolContext);
|
||||
|
|
@ -1749,6 +1813,15 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number):
|
|||
: 'Read cross-team outbox';
|
||||
return truncatePreview(text, limit);
|
||||
}
|
||||
const fileToolContext = formatFileToolResultContext({
|
||||
id: '',
|
||||
name: toolName,
|
||||
canonicalName: canonical,
|
||||
input: value,
|
||||
});
|
||||
if (fileToolContext) {
|
||||
return truncatePreview(fileToolContext, limit);
|
||||
}
|
||||
const payload = recordFromUnknown(value);
|
||||
if (payload) {
|
||||
const runtimeFormatted = formatRuntimePayload(payload, canonical, payload);
|
||||
|
|
|
|||
|
|
@ -16230,6 +16230,7 @@ export class TeamProvisioningService {
|
|||
run.child.stderr?.removeAllListeners('data');
|
||||
run.child.removeAllListeners('error');
|
||||
run.child.removeAllListeners('exit');
|
||||
run.child.removeAllListeners('close');
|
||||
killTeamProcess(run.child);
|
||||
run.child = null;
|
||||
}
|
||||
|
|
@ -16469,7 +16470,7 @@ export class TeamProvisioningService {
|
|||
this.cleanupRun(run);
|
||||
});
|
||||
|
||||
child.once('exit', (code) => {
|
||||
child.once('close', (code) => {
|
||||
void this.handleProcessExit(run, code);
|
||||
});
|
||||
}
|
||||
|
|
@ -17083,7 +17084,7 @@ export class TeamProvisioningService {
|
|||
this.cleanupRun(run);
|
||||
});
|
||||
|
||||
child.once('exit', (code) => {
|
||||
child.once('close', (code) => {
|
||||
void this.handleProcessExit(run, code);
|
||||
});
|
||||
|
||||
|
|
@ -18381,7 +18382,7 @@ export class TeamProvisioningService {
|
|||
this.cleanupRun(run);
|
||||
});
|
||||
|
||||
child.once('exit', (code) => {
|
||||
child.once('close', (code) => {
|
||||
void this.handleProcessExit(run, code);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -908,7 +908,7 @@ const InstalledBanner = ({
|
|||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
{codexLoginAuthUrl ? 'Open login' : 'Reconnect ChatGPT'}
|
||||
{codexLoginAuthUrl ? 'Open login' : 'Generate link'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
|
@ -1153,16 +1153,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
|
||||
const handleCodexDashboardLogin = useCallback(() => {
|
||||
void (async () => {
|
||||
const success = await codexAccount.startChatgptLogin();
|
||||
if (success) {
|
||||
await refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
}
|
||||
await codexAccount.startChatgptLogin();
|
||||
})();
|
||||
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
||||
}, [codexAccount]);
|
||||
|
||||
const recheckAuthState = useCallback(() => {
|
||||
setIsVerifyingAuth(true);
|
||||
|
|
|
|||
|
|
@ -1439,7 +1439,7 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
onClick={() => void handleCodexStartLogin()}
|
||||
>
|
||||
<Link2 className="mr-1 size-3.5" />
|
||||
{codexNeedsReconnect ? 'Reconnect ChatGPT' : 'Connect ChatGPT'}
|
||||
{codexNeedsReconnect ? 'Generate link' : 'Connect ChatGPT'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||
import { api } from '@renderer/api';
|
||||
import { LogIn } from 'lucide-react';
|
||||
|
||||
import type { ProvisioningProviderCheck } from './ProvisioningProviderStatusList';
|
||||
|
|
@ -78,13 +79,19 @@ export const CodexReconnectPrompt = ({
|
|||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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
|
||||
finish login in the browser and retry this dialog.
|
||||
Codex found the local ChatGPT account, but this session is stale. Sign in with ChatGPT,
|
||||
then finish login in the browser and retry this dialog.
|
||||
</p>
|
||||
<CodexLoginLinkCopyButton authUrl={authUrl} disabled={reconnectBusy} size="xs" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReconnect}
|
||||
onClick={() => {
|
||||
if (authUrl) {
|
||||
void api.openExternal(authUrl);
|
||||
return;
|
||||
}
|
||||
onReconnect();
|
||||
}}
|
||||
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"
|
||||
style={{
|
||||
|
|
@ -93,7 +100,7 @@ export const CodexReconnectPrompt = ({
|
|||
}}
|
||||
>
|
||||
<LogIn className="size-3" />
|
||||
{reconnectBusy ? 'Opening...' : 'Reconnect ChatGPT'}
|
||||
{reconnectBusy ? 'Generating...' : authUrl ? 'Open login' : 'Generate link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -713,16 +713,9 @@ export const CreateTeamDialog = ({
|
|||
|
||||
const handleCodexReconnect = useCallback(() => {
|
||||
void (async () => {
|
||||
const success = await codexAccount.startChatgptLogin();
|
||||
if (success) {
|
||||
await refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
}
|
||||
await codexAccount.startChatgptLogin();
|
||||
})();
|
||||
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
||||
}, [codexAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canCreate || !launchTeam) {
|
||||
|
|
|
|||
|
|
@ -590,16 +590,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
|
||||
const handleCodexReconnect = React.useCallback(() => {
|
||||
void (async () => {
|
||||
const success = await codexAccount.startChatgptLogin();
|
||||
if (success) {
|
||||
await refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
}
|
||||
await codexAccount.startChatgptLogin();
|
||||
})();
|
||||
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
||||
}, [codexAccount]);
|
||||
|
||||
// Schedule store actions
|
||||
const createSchedule = useStore((s) => s.createSchedule);
|
||||
|
|
|
|||
|
|
@ -12356,6 +12356,56 @@ describe('TeamProvisioningService', () => {
|
|||
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 () => {
|
||||
allowConsoleLogs();
|
||||
vi.useFakeTimers();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
let teamChangeListener:
|
||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||
|
|
|
|||
Loading…
Reference in a new issue