From c4dba278b02b3805aff492f0a4404fbdaf811b6b Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 19 Apr 2026 01:38:44 +0300 Subject: [PATCH 1/6] test(members): small improvements --- .../team/members/membersEditorUtils.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/renderer/components/team/members/membersEditorUtils.test.ts b/test/renderer/components/team/members/membersEditorUtils.test.ts index 44dbc2dd..defea87b 100644 --- a/test/renderer/components/team/members/membersEditorUtils.test.ts +++ b/test/renderer/components/team/members/membersEditorUtils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + buildMembersFromDrafts, createMemberDraftsFromInputs, filterEditableMemberInputs, } from '@renderer/components/team/members/MembersEditorSection'; @@ -60,4 +61,29 @@ describe('members editor editable input filtering', () => { effort: 'medium', }); }); + + it('preserves explicit codex models when exporting member inputs', () => { + const drafts = createMemberDraftsFromInputs( + filterEditableMemberInputs([ + { + name: 'alice', + agentType: 'reviewer', + providerId: 'codex', + model: 'gpt-5.4-mini', + effort: 'medium', + }, + ] satisfies Array< + Pick + >) + ); + + expect(buildMembersFromDrafts(drafts)).toEqual([ + expect.objectContaining({ + name: 'alice', + providerId: 'codex', + model: 'gpt-5.4-mini', + effort: 'medium', + }), + ]); + }); }); From 6ff9a28cccc7c6e6b254cc6896e303fb3d5f158a Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 19 Apr 2026 01:38:58 +0300 Subject: [PATCH 2/6] feat(team): enhance Claude logs handling and improve retrieval logic - Updated `getClaudeLogs` method to support asynchronous fetching of logs. - Introduced new interfaces for retained logs and transcript cache entries. - Added logic to retain and retrieve Claude logs even after cleanup of live runs. - Implemented fallback mechanism to use persisted transcripts when no live run exists. - Updated tests to cover new log retention and retrieval scenarios. --- src/main/ipc/teams.ts | 2 +- .../services/team/TeamProvisioningService.ts | 233 +++++++++++++++--- .../team/members/MemberExecutionLog.tsx | 2 +- .../team/TeamProvisioningService.test.ts | 109 ++++++++ .../components/team/ClaudeLogsPanel.test.ts | 134 ++++++++++ 5 files changed, 439 insertions(+), 41 deletions(-) create mode 100644 test/renderer/components/team/ClaudeLogsPanel.test.ts diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index ca2d6dbb..dcfdc648 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1315,7 +1315,7 @@ async function handleGetClaudeLogs( } return wrapTeamHandler('getClaudeLogs', async () => { - const data = getTeamProvisioningService().getClaudeLogs(validated.value!, parsed); + const data = await getTeamProvisioningService().getClaudeLogs(validated.value!, parsed); return { lines: data.lines, total: data.total, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index dff78461..4481fc52 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -69,6 +69,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import pidusage from 'pidusage'; +import * as readline from 'readline'; import { type GeminiRuntimeAuthState, @@ -121,6 +122,7 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; +import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; /** * Kill a team CLI process using SIGKILL (uncatchable). @@ -2402,6 +2404,87 @@ function extractCliLogsFromRun(run: ProvisioningRun): string | undefined { return extractLogsTail(run.stdoutBuffer, run.stderrBuffer); } +interface RetainedClaudeLogsSnapshot { + lines: string[]; + updatedAt?: string; +} + +interface PersistedTranscriptClaudeLogsCacheEntry { + transcriptPath: string; + mtimeMs: number; + size: number; + snapshot: RetainedClaudeLogsSnapshot; +} + +function buildRetainedClaudeLogsSnapshot(run: ProvisioningRun): RetainedClaudeLogsSnapshot | null { + if (run.claudeLogLines.length > 0) { + return { + lines: [...run.claudeLogLines], + updatedAt: run.claudeLogsUpdatedAt, + }; + } + + const fallback = extractCliLogsFromRun(run); + if (!fallback) { + return null; + } + + const lines = fallback + .split('\n') + .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) + .filter((line) => line.length > 0); + + if (lines.length === 0) { + return null; + } + + return { + lines, + updatedAt: run.claudeLogsUpdatedAt ?? run.progress.updatedAt, + }; +} + +function sliceClaudeLogs( + linesChronological: string[], + updatedAt: string | undefined, + query?: { offset?: number; limit?: number } +): { lines: string[]; total: number; hasMore: boolean; updatedAt?: string } { + const offsetRaw = query?.offset ?? 0; + const limitRaw = query?.limit ?? 100; + const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0; + const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100; + + const total = linesChronological.length; + if (total === 0) { + return { lines: [], total: 0, hasMore: false, updatedAt }; + } + + const newestExclusive = Math.max(0, total - offset); + const oldestInclusive = Math.max(0, newestExclusive - limit); + const normalizeLine = (line: string): string => { + // Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] " + if (line.startsWith('[stdout] ') && line !== '[stdout]') { + return line.slice('[stdout] '.length); + } + if (line.startsWith('[stderr] ') && line !== '[stderr]') { + return line.slice('[stderr] '.length); + } + return line; + }; + + const lines = linesChronological + .slice(oldestInclusive, newestExclusive) + .map(normalizeLine) + .toReversed(); + + return { + lines, + total, + hasMore: oldestInclusive > 0, + updatedAt, + }; +} + /** * Emit a throttled progress update for the renderer. Payloads are capped to a * tail window so that the hot emission path (called every LOG_PROGRESS_THROTTLE_MS @@ -2532,6 +2615,11 @@ export class TeamProvisioningService { private readonly runs = new Map(); private readonly provisioningRunByTeam = new Map(); private readonly aliveRunByTeam = new Map(); + private readonly retainedClaudeLogsByTeam = new Map(); + private readonly persistedTranscriptClaudeLogsCache = new Map< + string, + PersistedTranscriptClaudeLogsCacheEntry + >(); private readonly teamOpLocks = new Map>(); private readonly leadInboxRelayInFlight = new Map>(); private readonly relayedLeadInboxMessageIds = new Map>(); @@ -2554,6 +2642,7 @@ export class TeamProvisioningService { >(); private readonly launchStateStore = new TeamLaunchStateStore(); private readonly memberLogsFinder: TeamMemberLogsFinder; + private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; private helpOutputCache: string | null = null; private helpOutputCacheTime = 0; @@ -2589,6 +2678,7 @@ export class TeamProvisioningService { this.inboxReader, this.membersMetaStore ); + this.transcriptProjectResolver = new TeamTranscriptProjectResolver(this.configReader); } setCrossTeamSender( @@ -2613,52 +2703,28 @@ export class TeamProvisioningService { this.controlApiBaseUrlResolver = resolver; } - getClaudeLogs( + async getClaudeLogs( teamName: string, query?: { offset?: number; limit?: number } - ): { lines: string[]; total: number; hasMore: boolean; updatedAt?: string } { + ): Promise<{ lines: string[]; total: number; hasMore: boolean; updatedAt?: string }> { const runId = this.getTrackedRunId(teamName); - if (!runId) { - return { lines: [], total: 0, hasMore: false }; - } - const run = this.runs.get(runId); - if (!run) { - return { lines: [], total: 0, hasMore: false }; + if (runId) { + const run = this.runs.get(runId); + if (run) { + return sliceClaudeLogs(run.claudeLogLines, run.claudeLogsUpdatedAt, query); + } } - const offsetRaw = query?.offset ?? 0; - const limitRaw = query?.limit ?? 100; - const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0; - const limit = Number.isFinite(limitRaw) - ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) - : 100; - - const total = run.claudeLogLines.length; - if (total === 0) { - return { lines: [], total: 0, hasMore: false, updatedAt: run.claudeLogsUpdatedAt }; + const retained = this.retainedClaudeLogsByTeam.get(teamName); + if (!retained) { + const transcriptSnapshot = await this.getPersistedTranscriptClaudeLogs(teamName); + if (!transcriptSnapshot) { + return { lines: [], total: 0, hasMore: false }; + } + return sliceClaudeLogs(transcriptSnapshot.lines, transcriptSnapshot.updatedAt, query); } - const newestExclusive = Math.max(0, total - offset); - const oldestInclusive = Math.max(0, newestExclusive - limit); - const normalizeLine = (line: string): string => { - // Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] " - if (line.startsWith('[stdout] ') && line !== '[stdout]') - return line.slice('[stdout] '.length); - if (line.startsWith('[stderr] ') && line !== '[stderr]') - return line.slice('[stderr] '.length); - return line; - }; - - const lines = run.claudeLogLines - .slice(oldestInclusive, newestExclusive) - .map(normalizeLine) - .toReversed(); - return { - lines, - total, - hasMore: oldestInclusive > 0, - updatedAt: run.claudeLogsUpdatedAt, - }; + return sliceClaudeLogs(retained.lines, retained.updatedAt, query); } private getProvisioningRunId(teamName: string): string | null { @@ -2673,6 +2739,85 @@ export class TeamProvisioningService { return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName); } + private async getPersistedTranscriptClaudeLogs( + teamName: string + ): Promise { + const context = await this.transcriptProjectResolver.getContext(teamName); + const leadSessionId = + typeof context?.config.leadSessionId === 'string' ? context.config.leadSessionId.trim() : ''; + if (!context || leadSessionId.length === 0) { + this.persistedTranscriptClaudeLogsCache.delete(teamName); + return null; + } + + const transcriptPath = path.join(context.projectDir, `${leadSessionId}.jsonl`); + + let stat: fs.Stats; + try { + stat = await fs.promises.stat(transcriptPath); + } catch { + this.persistedTranscriptClaudeLogsCache.delete(teamName); + return null; + } + + if (!stat.isFile()) { + this.persistedTranscriptClaudeLogsCache.delete(teamName); + return null; + } + + const cached = this.persistedTranscriptClaudeLogsCache.get(teamName); + if ( + cached && + cached.transcriptPath === transcriptPath && + cached.mtimeMs === stat.mtimeMs && + cached.size === stat.size + ) { + return cached.snapshot; + } + + const lines = await this.readTranscriptClaudeLogLines(transcriptPath); + if (lines.length === 0) { + this.persistedTranscriptClaudeLogsCache.delete(teamName); + return null; + } + + const snapshot = { + lines, + updatedAt: stat.mtime.toISOString(), + }; + this.persistedTranscriptClaudeLogsCache.set(teamName, { + transcriptPath, + mtimeMs: stat.mtimeMs, + size: stat.size, + snapshot, + }); + return snapshot; + } + + private async readTranscriptClaudeLogLines(filePath: string): Promise { + const lines: string[] = []; + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + try { + for await (const rawLine of rl) { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + if (!line.trim()) { + continue; + } + lines.push(line); + if (lines.length > TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT) { + lines.splice(0, lines.length - TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT); + } + } + } finally { + rl.close(); + stream.close(); + } + + return lines; + } + private clearSameTeamRetryTimers(teamName: string): void { for (const suffix of ['deferred', 'persist']) { const key = `same-team-${suffix}:${teamName}`; @@ -2686,6 +2831,8 @@ export class TeamProvisioningService { private resetTeamScopedTransientStateForNewRun(teamName: string): void { peekAutoResumeService()?.cancelPendingAutoResume(teamName); + this.retainedClaudeLogsByTeam.delete(teamName); + this.persistedTranscriptClaudeLogsCache.delete(teamName); this.leadInboxRelayInFlight.delete(teamName); this.relayedLeadInboxMessageIds.delete(teamName); this.pendingCrossTeamFirstReplies.delete(teamName); @@ -11481,6 +11628,7 @@ export class TeamProvisioningService { private cleanupRun(run: ProvisioningRun): void { const currentTrackedRunId = this.getTrackedRunId(run.teamName); const hasNewerTrackedRun = currentTrackedRunId !== null && currentTrackedRunId !== run.runId; + const retainedClaudeLogs = hasNewerTrackedRun ? null : buildRetainedClaudeLogsSnapshot(run); if (!hasNewerTrackedRun) { peekAutoResumeService()?.cancelPendingAutoResume(run.teamName); @@ -11573,6 +11721,13 @@ export class TeamProvisioningService { void removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath); run.bootstrapUserPromptPath = null; } + if (!hasNewerTrackedRun) { + if (retainedClaudeLogs) { + this.retainedClaudeLogsByTeam.set(run.teamName, retainedClaudeLogs); + } else { + this.retainedClaudeLogsByTeam.delete(run.teamName); + } + } // Remove from runs Map to free memory (stdoutBuffer, stderrBuffer, claudeLogLines) this.runs.delete(run.runId); } diff --git a/src/renderer/components/team/members/MemberExecutionLog.tsx b/src/renderer/components/team/members/MemberExecutionLog.tsx index 96d2f727..56ef501f 100644 --- a/src/renderer/components/team/members/MemberExecutionLog.tsx +++ b/src/renderer/components/team/members/MemberExecutionLog.tsx @@ -192,7 +192,7 @@ const AIExecutionGroup = ({ > - Claude + Agent {enhanced.itemsSummary} diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index e110fea8..232f910e 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -73,6 +73,7 @@ import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchSta import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { spawnCli } from '@main/utils/childProcess'; +import { encodePath } from '@main/utils/pathDecoder'; import { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES } from 'agent-teams-controller'; import { listTmuxPanePidsForCurrentPlatform } from '@features/tmux-installer/main'; import pidusage from 'pidusage'; @@ -234,6 +235,40 @@ function createMemberSpawnRun(params?: { } as any; } +function createClaudeLogsRun(overrides: Record = {}) { + return { + runId: 'run-logs-1', + teamName: 'logs-team', + startedAt: '2026-04-19T10:00:00.000Z', + isLaunch: false, + provisioningComplete: true, + processKilled: false, + cancelRequested: false, + timeoutHandle: null, + fsMonitorHandle: null, + stallCheckHandle: null, + silentUserDmForwardClearHandle: null, + child: null, + leadActivityState: 'idle', + activeToolCalls: new Map(), + pendingDirectCrossTeamSendRefresh: false, + memberSpawnStatuses: new Map(), + activeCrossTeamReplyHints: [], + pendingInboxRelayCandidates: [], + pendingApprovals: new Map(), + mcpConfigPath: null, + bootstrapSpecPath: null, + bootstrapUserPromptPath: null, + claudeLogLines: ['[stdout]', 'first line', '[stderr]', 'boom'], + claudeLogsUpdatedAt: '2026-04-19T10:00:01.000Z', + progress: { + updatedAt: '2026-04-19T10:00:01.000Z', + state: 'ready', + }, + ...overrides, + } as any; +} + describe('TeamProvisioningService', () => { beforeEach(() => { vi.clearAllMocks(); @@ -283,6 +318,80 @@ describe('TeamProvisioningService', () => { }); }); + describe('getClaudeLogs', () => { + it('retains the last logs after cleanupRun removes the live run', async () => { + const svc = new TeamProvisioningService(); + const run = createClaudeLogsRun(); + + (svc as any).runs.set(run.runId, run); + (svc as any).aliveRunByTeam.set(run.teamName, run.runId); + + await expect(svc.getClaudeLogs(run.teamName)).resolves.toEqual({ + lines: ['boom', '[stderr]', 'first line', '[stdout]'], + total: 4, + hasMore: false, + updatedAt: '2026-04-19T10:00:01.000Z', + }); + + (svc as any).cleanupRun(run); + + await expect(svc.getClaudeLogs(run.teamName)).resolves.toEqual({ + lines: ['boom', '[stderr]', 'first line', '[stdout]'], + total: 4, + hasMore: false, + updatedAt: '2026-04-19T10:00:01.000Z', + }); + }); + + it('falls back to the persisted lead transcript when no live run exists', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'offline-logs-team'; + const projectPath = '/tmp/offline-logs-project'; + const leadSessionId = 'lead-session-1'; + const projectDir = path.join(tempProjectsBase, encodePath(projectPath)); + + writeLaunchConfig(teamName, projectPath, leadSessionId, []); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync( + path.join(projectDir, `${leadSessionId}.jsonl`), + [ + '{"type":"user","message":{"role":"user","content":"first"}}', + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"second"}]}}', + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"third"}]}}', + ].join('\n') + '\n', + 'utf8' + ); + + await expect(svc.getClaudeLogs(teamName)).resolves.toEqual({ + lines: [ + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"third"}]}}', + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"second"}]}}', + '{"type":"user","message":{"role":"user","content":"first"}}', + ], + total: 3, + hasMore: false, + updatedAt: expect.any(String), + }); + }); + + it('clears retained logs when a new run starts for the same team', async () => { + const svc = new TeamProvisioningService(); + + (svc as any).retainedClaudeLogsByTeam.set('logs-team', { + lines: ['[stdout]', 'stale line'], + updatedAt: '2026-04-19T10:00:01.000Z', + }); + + (svc as any).resetTeamScopedTransientStateForNewRun('logs-team'); + + await expect(svc.getClaudeLogs('logs-team')).resolves.toEqual({ + lines: [], + total: 0, + hasMore: false, + }); + }); + }); + describe('getTeamAgentRuntimeSnapshot', () => { it('uses batched pidusage rss values for lead and teammates', async () => { const svc = new TeamProvisioningService(); diff --git a/test/renderer/components/team/ClaudeLogsPanel.test.ts b/test/renderer/components/team/ClaudeLogsPanel.test.ts new file mode 100644 index 00000000..9a95f41a --- /dev/null +++ b/test/renderer/components/team/ClaudeLogsPanel.test.ts @@ -0,0 +1,134 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController'; + +const cliLogsRichViewState = vi.hoisted(() => ({ + calls: [] as Array>, +})); + +vi.mock('@renderer/components/team/CliLogsRichView', () => ({ + CliLogsRichView: (props: Record) => { + cliLogsRichViewState.calls.push(props); + return React.createElement( + 'div', + { 'data-testid': 'cli-logs-rich-view' }, + String(props.cliLogsTail ?? '') + ); + }, +})); + +vi.mock('@renderer/components/team/ClaudeLogsFilterPopover', () => ({ + ClaudeLogsFilterPopover: () => React.createElement('div', { 'data-testid': 'logs-filter' }), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + }) => React.createElement('button', { type: 'button', onClick, disabled }, children), +})); + +import { ClaudeLogsPanel } from '@renderer/components/team/ClaudeLogsPanel'; + +function createController(overrides: Partial = {}): ClaudeLogsController { + return { + data: { lines: [], total: 0, hasMore: false }, + loading: false, + loadingMore: false, + error: null, + pendingNewCount: 0, + isAlive: false, + filteredText: '', + online: false, + badge: undefined, + showMoreVisible: false, + lastLogPreview: null, + searchQuery: '', + setSearchQuery: vi.fn(), + filter: { streams: new Set(), kinds: new Set() } as ClaudeLogsController['filter'], + setFilter: vi.fn(), + filterOpen: false, + setFilterOpen: vi.fn(), + viewerState: {} as ClaudeLogsController['viewerState'], + onViewerStateChange: vi.fn(), + applyPending: vi.fn(async () => {}), + loadOlderLogs: vi.fn(async () => {}), + containerRefCallback: vi.fn(), + handleScroll: vi.fn(), + ...overrides, + }; +} + +describe('ClaudeLogsPanel', () => { + afterEach(() => { + document.body.innerHTML = ''; + cliLogsRichViewState.calls = []; + vi.unstubAllGlobals(); + }); + + it('renders logs even when the team is offline if log lines are available', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const ctrl = createController({ + isAlive: false, + data: { + lines: ['second line', 'first line'], + total: 2, + hasMore: false, + updatedAt: '2026-04-19T10:00:01.000Z', + }, + filteredText: '[stdout]\nfirst line\nsecond line', + badge: 2, + }); + + await act(async () => { + root.render(React.createElement(ClaudeLogsPanel, { ctrl })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('2 lines'); + expect(host.textContent).toContain('first line'); + expect(host.textContent).not.toContain('Team is not running.'); + expect(host.querySelector('[data-testid="cli-logs-rich-view"]')).not.toBeNull(); + expect(cliLogsRichViewState.calls.at(-1)?.cliLogsTail).toBe('[stdout]\nfirst line\nsecond line'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows the offline empty state only when no logs exist', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const ctrl = createController({ + isAlive: false, + data: { lines: [], total: 0, hasMore: false }, + filteredText: '', + }); + + await act(async () => { + root.render(React.createElement(ClaudeLogsPanel, { ctrl })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Team is not running.'); + expect(host.querySelector('[data-testid="cli-logs-rich-view"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); From 2c62909e89b8e573f0d1562b63c7d0c64a75b57f Mon Sep 17 00:00:00 2001 From: Artem Rootman <4586640+artemrootman@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:07:45 +0000 Subject: [PATCH 3/6] Handle legacy multimodel MCP diagnostics --- .../runtime/ExtensionsRuntimeAdapter.ts | 62 +++++++++++++++---- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts index 716236db..46521eeb 100644 --- a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts +++ b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts @@ -61,6 +61,8 @@ export class ClaudeExtensionsAdapter implements ExtensionsRuntimeAdapter { export class MultimodelExtensionsAdapter implements ExtensionsRuntimeAdapter { readonly flavor = 'agent_teams_orchestrator' as const; + constructor(private readonly stateReader = new McpConfigStateReader()) {} + async buildManagementCliEnv(binaryPath: string): Promise { return buildManagementCliEnvForBinary(binaryPath); } @@ -72,13 +74,21 @@ export class MultimodelExtensionsAdapter implements ExtensionsRuntimeAdapter { } const env = await this.buildManagementCliEnv(binaryPath); - const { stdout } = await execCli(binaryPath, ['mcp', 'list', '--json'], { - timeout: MCP_LIST_TIMEOUT_MS, - cwd: projectPath, - env, - }); + try { + const { stdout } = await execCli(binaryPath, ['mcp', 'list', '--json'], { + timeout: MCP_LIST_TIMEOUT_MS, + cwd: projectPath, + env, + }); - return parseInstalledMcpJsonOutput(stdout); + return parseInstalledMcpJsonOutput(stdout); + } catch (error) { + if (!isUnsupportedMcpJsonContractError(error)) { + throw error; + } + + return this.stateReader.readInstalled(projectPath); + } } async diagnoseMcp(projectPath?: string): Promise { @@ -88,16 +98,44 @@ export class MultimodelExtensionsAdapter implements ExtensionsRuntimeAdapter { } const env = await this.buildManagementCliEnv(binaryPath); - const { stdout } = await execCli(binaryPath, ['mcp', 'diagnose', '--json'], { - timeout: MCP_DIAGNOSE_TIMEOUT_MS, - cwd: projectPath, - env, - }); + try { + const { stdout } = await execCli(binaryPath, ['mcp', 'diagnose', '--json'], { + timeout: MCP_DIAGNOSE_TIMEOUT_MS, + cwd: projectPath, + env, + }); - return parseMcpDiagnosticsJsonOutput(stdout); + return parseMcpDiagnosticsJsonOutput(stdout); + } catch (error) { + if (!isUnsupportedMcpJsonContractError(error)) { + throw error; + } + + const { stdout, stderr } = await execCli(binaryPath, ['mcp', 'list'], { + timeout: MCP_DIAGNOSE_TIMEOUT_MS, + cwd: projectPath, + env, + }); + + return parseMcpDiagnosticsOutput([stdout, stderr].filter(Boolean).join('\n')); + } } } +function isUnsupportedMcpJsonContractError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + + return ( + normalized.includes("unknown command 'diagnose'") || + normalized.includes('unknown command "diagnose"') || + normalized.includes('unknown option') || + normalized.includes('unknown argument') || + normalized.includes('unexpected argument') || + normalized.includes('unrecognized option') + ); +} + class RuntimeSwitchingExtensionsAdapter implements ExtensionsRuntimeAdapter { constructor( private readonly claudeAdapter: ClaudeExtensionsAdapter, From 4c359d5185d1bd74b4fc2291d89feb8213717f09 Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 19 Apr 2026 09:00:59 +0500 Subject: [PATCH 4/6] perf(team): precompute ActivityTimeline session anchors once per render Replace the per-item backward scan that located the most recent session anchor with a single forward pass via useMemo. Before: for every timeline item the render loop walked backward until it found a lead-thought anchor, so N items produced up to N * N anchor lookups on every render pass. After: a single O(n) sweep builds previousSessionAnchorByIndex; render time lookup is O(1). getItemSessionAnchorId is hoisted to module scope so it is not recreated per render. Behavior is unchanged. The three existing separator tests still pass, and four new cases cover three-session transitions, long runs of non-anchor items between thought groups, consecutive same-session thoughts, and single-item lists. --- .../team/activity/ActivityTimeline.tsx | 35 +++-- .../team/activity/ActivityTimeline.test.ts | 131 ++++++++++++++++++ 2 files changed, 152 insertions(+), 14 deletions(-) diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index d803c2de..abc3313d 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -75,6 +75,13 @@ const EMPTY_TEAM_NAMES: string[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); const DEFAULT_COLLAPSE_MODE = 'default' as const; +function getItemSessionAnchorId(item: TimelineItem): string | undefined { + if (item.type === 'lead-thoughts') { + return item.group.thoughts[0]?.leadSessionId; + } + return undefined; +} + interface ItemCollapseProps { collapseMode: 'default' | 'managed'; isCollapsed: boolean; @@ -418,13 +425,20 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ setVisibleCount(Infinity); }; - const getItemSessionAnchorId = (item: TimelineItem): string | undefined => { - if (item.type === 'lead-thoughts') { - return item.group.thoughts[0]?.leadSessionId; + // Precompute, per timeline index, the most recent session anchor that appears + // strictly earlier in the list. Replaces an O(n) backward scan during render + // with an O(1) lookup; total work drops from O(n^2) to O(n) per timelineItems + // change. + const previousSessionAnchorByIndex = useMemo(() => { + const anchors: (string | undefined)[] = []; + let lastSeen: string | undefined; + for (const item of timelineItems) { + anchors.push(lastSeen); + const anchor = getItemSessionAnchorId(item); + if (anchor) lastSeen = anchor; } - - return undefined; - }; + return anchors; + }, [timelineItems]); // Pin the newest thought group (if first) so it stays at the top and doesn't jump. const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null; @@ -532,14 +546,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ let sessionSeparator: React.JSX.Element | null = null; if (realIndex > 0) { const currSessionId = getItemSessionAnchorId(item); - let prevSessionId: string | undefined; - for (let searchIndex = realIndex - 1; searchIndex >= 0; searchIndex -= 1) { - const candidateSessionId = getItemSessionAnchorId(timelineItems[searchIndex]); - if (candidateSessionId) { - prevSessionId = candidateSessionId; - break; - } - } + const prevSessionId = previousSessionAnchorByIndex[realIndex]; if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { sessionSeparator = (
{ root.unmount(); }); }); + + it('renders a separator for every session transition across three lead sessions', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'thought-s3', + text: 'thought session 3', + leadSessionId: 'lead-session-3', + from: 'team-lead', + source: 'lead_session', + }), + makeMessage({ + messageId: 'thought-s2', + text: 'thought session 2', + leadSessionId: 'lead-session-2', + from: 'team-lead', + source: 'lead_session', + }), + makeMessage({ + messageId: 'thought-s1', + text: 'thought session 1', + leadSessionId: 'lead-session-1', + from: 'team-lead', + source: 'lead_session', + }), + ]; + + await act(async () => { + root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' })); + }); + + const matches = container.textContent?.match(/New session/g) ?? []; + expect(matches.length).toBe(2); + + await act(async () => { + root.unmount(); + }); + }); + + it('finds the previous anchor even when many non-anchor items sit between lead thought groups', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'thought-newest', + text: 'newest thought', + leadSessionId: 'lead-session-newest', + from: 'team-lead', + source: 'lead_session', + }), + ...Array.from({ length: 8 }, (_, i) => + makeMessage({ + messageId: `filler-${i}`, + text: `filler message ${i}`, + leadSessionId: `member-session-${i}`, + from: 'alice', + source: 'inbox', + }) + ), + makeMessage({ + messageId: 'thought-oldest', + text: 'oldest thought', + leadSessionId: 'lead-session-oldest', + from: 'team-lead', + source: 'lead_session', + }), + ]; + + await act(async () => { + root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' })); + }); + + expect(container.textContent).toContain('New session'); + + await act(async () => { + root.unmount(); + }); + }); + + it('does not render a separator when two consecutive lead thoughts share the same session', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'thought-a', + text: 'thought a', + leadSessionId: 'lead-session-shared', + from: 'team-lead', + source: 'lead_session', + }), + makeMessage({ + messageId: 'thought-b', + text: 'thought b', + leadSessionId: 'lead-session-shared', + from: 'team-lead', + source: 'lead_session', + }), + ]; + + await act(async () => { + root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' })); + }); + + expect(container.textContent).not.toContain('New session'); + + await act(async () => { + root.unmount(); + }); + }); + + it('handles a single message list without errors or separators', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'only', + text: 'only message', + leadSessionId: 'lead-session-1', + from: 'team-lead', + source: 'lead_session', + }), + ]; + + await act(async () => { + root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' })); + }); + + expect(container.textContent).not.toContain('New session'); + expect(container.textContent).toContain('only message'); + + await act(async () => { + root.unmount(); + }); + }); }); From a713d4f058c145a17b76beb423bd41114f97e275 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 19 Apr 2026 08:46:36 +0300 Subject: [PATCH 5/6] chore(runtime): bump dev bootstrap to 0.0.3 --- runtime.lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/runtime.lock.json b/runtime.lock.json index e0e70603..54508861 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.2", - "sourceRef": "v0.0.2", + "version": "0.0.3", + "sourceRef": "v0.0.3", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/claude_agent_teams_ui", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.2.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.3.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.2.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.3.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.2.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.3.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.2.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.3.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } From 98657f8b5ff4650571477871cb3ad93d8b7d689e Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 19 Apr 2026 09:00:45 +0300 Subject: [PATCH 6/6] fix(team): harden retained log cleanup fallback --- .../services/team/TeamProvisioningService.ts | 24 ++++++++++++------- .../TaskLogStreamSection.integration.test.ts | 2 +- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4481fc52..5bf9f284 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2290,9 +2290,12 @@ function updateProgress( return run.progress; } -function buildCombinedLogs(stdoutBuffer: string, stderrBuffer: string): string { - const stdoutTrimmed = stdoutBuffer.trim(); - const stderrTrimmed = stderrBuffer.trim(); +function buildCombinedLogs( + stdoutBuffer: string | undefined, + stderrBuffer: string | undefined +): string { + const stdoutTrimmed = (stdoutBuffer ?? '').trim(); + const stderrTrimmed = (stderrBuffer ?? '').trim(); if (stdoutTrimmed.length === 0 && stderrTrimmed.length === 0) { return ''; @@ -2372,7 +2375,10 @@ function normalizeRecordStringValues(value: unknown): Record { ); } -function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | undefined { +function extractLogsTail( + stdoutBuffer: string | undefined, + stderrBuffer: string | undefined +): string | undefined { const trimmed = buildCombinedLogs(stdoutBuffer, stderrBuffer).trim(); if (trimmed.length === 0) { return undefined; @@ -2394,8 +2400,9 @@ function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | u * in provisioning before any output has been line-split). */ function extractCliLogsFromRun(run: ProvisioningRun): string | undefined { - if (run.claudeLogLines.length > 0) { - const joined = run.claudeLogLines.join('\n').trim(); + const claudeLogLines = Array.isArray(run.claudeLogLines) ? run.claudeLogLines : []; + if (claudeLogLines.length > 0) { + const joined = claudeLogLines.join('\n').trim(); if (joined.length === 0) { return undefined; } @@ -2417,9 +2424,10 @@ interface PersistedTranscriptClaudeLogsCacheEntry { } function buildRetainedClaudeLogsSnapshot(run: ProvisioningRun): RetainedClaudeLogsSnapshot | null { - if (run.claudeLogLines.length > 0) { + const claudeLogLines = Array.isArray(run.claudeLogLines) ? run.claudeLogLines : []; + if (claudeLogLines.length > 0) { return { - lines: [...run.claudeLogLines], + lines: [...claudeLogLines], updatedAt: run.claudeLogsUpdatedAt, }; } diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts index 6cfacbea..47a80be8 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts @@ -402,7 +402,7 @@ describe('TaskLogStreamSection integration', () => { expect(text).toContain('Task Log Stream'); expect(text).toContain('Grep'); expect(text).toContain('Edit'); - expect(text).toContain('Claude'); + expect(text).toContain('Agent'); expect(text).toContain('3 tool calls'); expect(text).not.toContain('[]'); expect(text).not.toContain('Audit complete');