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" } 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/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, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index dff78461..5bf9f284 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). @@ -2288,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 ''; @@ -2370,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; @@ -2392,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; } @@ -2402,6 +2411,88 @@ 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 { + const claudeLogLines = Array.isArray(run.claudeLogLines) ? run.claudeLogLines : []; + if (claudeLogLines.length > 0) { + return { + lines: [...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 +2623,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 +2650,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 +2686,7 @@ export class TeamProvisioningService { this.inboxReader, this.membersMetaStore ); + this.transcriptProjectResolver = new TeamTranscriptProjectResolver(this.configReader); } setCrossTeamSender( @@ -2613,52 +2711,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 +2747,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 +2839,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 +11636,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 +11729,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/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 = (
- 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(); + }); + }); +}); diff --git a/test/renderer/components/team/activity/ActivityTimeline.test.ts b/test/renderer/components/team/activity/ActivityTimeline.test.ts index fac0ca87..d02b5bb1 100644 --- a/test/renderer/components/team/activity/ActivityTimeline.test.ts +++ b/test/renderer/components/team/activity/ActivityTimeline.test.ts @@ -153,4 +153,135 @@ describe('ActivityTimeline session separators', () => { 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(); + }); + }); }); 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', + }), + ]); + }); }); 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');