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 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) {

View file

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

View file

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

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 } {
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);

View file

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

View file

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

View file

@ -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>
</>
)}

View file

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

View file

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

View file

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

View file

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

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 () => {
let teamChangeListener:
| ((event: unknown, data: { teamName: string; type: string }) => void)