commit
56822467d2
11 changed files with 688 additions and 80 deletions
|
|
@ -1,27 +1,27 @@
|
||||||
{
|
{
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"sourceRef": "v0.0.2",
|
"sourceRef": "v0.0.3",
|
||||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||||
"releaseTag": "v1.2.0",
|
"releaseTag": "v1.2.0",
|
||||||
"assets": {
|
"assets": {
|
||||||
"darwin-arm64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"darwin-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"linux-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"win32-x64": {
|
"win32-x64": {
|
||||||
"file": "agent-teams-runtime-win32-x64-v0.0.2.zip",
|
"file": "agent-teams-runtime-win32-x64-v0.0.3.zip",
|
||||||
"archiveKind": "zip",
|
"archiveKind": "zip",
|
||||||
"binaryName": "claude-multimodel.exe"
|
"binaryName": "claude-multimodel.exe"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1315,7 +1315,7 @@ async function handleGetClaudeLogs(
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapTeamHandler('getClaudeLogs', async () => {
|
return wrapTeamHandler('getClaudeLogs', async () => {
|
||||||
const data = getTeamProvisioningService().getClaudeLogs(validated.value!, parsed);
|
const data = await getTeamProvisioningService().getClaudeLogs(validated.value!, parsed);
|
||||||
return {
|
return {
|
||||||
lines: data.lines,
|
lines: data.lines,
|
||||||
total: data.total,
|
total: data.total,
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ export class ClaudeExtensionsAdapter implements ExtensionsRuntimeAdapter {
|
||||||
export class MultimodelExtensionsAdapter implements ExtensionsRuntimeAdapter {
|
export class MultimodelExtensionsAdapter implements ExtensionsRuntimeAdapter {
|
||||||
readonly flavor = 'agent_teams_orchestrator' as const;
|
readonly flavor = 'agent_teams_orchestrator' as const;
|
||||||
|
|
||||||
|
constructor(private readonly stateReader = new McpConfigStateReader()) {}
|
||||||
|
|
||||||
async buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv> {
|
async buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv> {
|
||||||
return buildManagementCliEnvForBinary(binaryPath);
|
return buildManagementCliEnvForBinary(binaryPath);
|
||||||
}
|
}
|
||||||
|
|
@ -72,13 +74,21 @@ export class MultimodelExtensionsAdapter implements ExtensionsRuntimeAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = await this.buildManagementCliEnv(binaryPath);
|
const env = await this.buildManagementCliEnv(binaryPath);
|
||||||
const { stdout } = await execCli(binaryPath, ['mcp', 'list', '--json'], {
|
try {
|
||||||
timeout: MCP_LIST_TIMEOUT_MS,
|
const { stdout } = await execCli(binaryPath, ['mcp', 'list', '--json'], {
|
||||||
cwd: projectPath,
|
timeout: MCP_LIST_TIMEOUT_MS,
|
||||||
env,
|
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<McpServerDiagnostic[]> {
|
async diagnoseMcp(projectPath?: string): Promise<McpServerDiagnostic[]> {
|
||||||
|
|
@ -88,16 +98,44 @@ export class MultimodelExtensionsAdapter implements ExtensionsRuntimeAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = await this.buildManagementCliEnv(binaryPath);
|
const env = await this.buildManagementCliEnv(binaryPath);
|
||||||
const { stdout } = await execCli(binaryPath, ['mcp', 'diagnose', '--json'], {
|
try {
|
||||||
timeout: MCP_DIAGNOSE_TIMEOUT_MS,
|
const { stdout } = await execCli(binaryPath, ['mcp', 'diagnose', '--json'], {
|
||||||
cwd: projectPath,
|
timeout: MCP_DIAGNOSE_TIMEOUT_MS,
|
||||||
env,
|
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 {
|
class RuntimeSwitchingExtensionsAdapter implements ExtensionsRuntimeAdapter {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly claudeAdapter: ClaudeExtensionsAdapter,
|
private readonly claudeAdapter: ClaudeExtensionsAdapter,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import pidusage from 'pidusage';
|
import pidusage from 'pidusage';
|
||||||
|
import * as readline from 'readline';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type GeminiRuntimeAuthState,
|
type GeminiRuntimeAuthState,
|
||||||
|
|
@ -121,6 +122,7 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from './TeamMetaStore';
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
|
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill a team CLI process using SIGKILL (uncatchable).
|
* Kill a team CLI process using SIGKILL (uncatchable).
|
||||||
|
|
@ -2288,9 +2290,12 @@ function updateProgress(
|
||||||
return run.progress;
|
return run.progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCombinedLogs(stdoutBuffer: string, stderrBuffer: string): string {
|
function buildCombinedLogs(
|
||||||
const stdoutTrimmed = stdoutBuffer.trim();
|
stdoutBuffer: string | undefined,
|
||||||
const stderrTrimmed = stderrBuffer.trim();
|
stderrBuffer: string | undefined
|
||||||
|
): string {
|
||||||
|
const stdoutTrimmed = (stdoutBuffer ?? '').trim();
|
||||||
|
const stderrTrimmed = (stderrBuffer ?? '').trim();
|
||||||
|
|
||||||
if (stdoutTrimmed.length === 0 && stderrTrimmed.length === 0) {
|
if (stdoutTrimmed.length === 0 && stderrTrimmed.length === 0) {
|
||||||
return '';
|
return '';
|
||||||
|
|
@ -2370,7 +2375,10 @@ function normalizeRecordStringValues(value: unknown): Record<string, string> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | undefined {
|
function extractLogsTail(
|
||||||
|
stdoutBuffer: string | undefined,
|
||||||
|
stderrBuffer: string | undefined
|
||||||
|
): string | undefined {
|
||||||
const trimmed = buildCombinedLogs(stdoutBuffer, stderrBuffer).trim();
|
const trimmed = buildCombinedLogs(stdoutBuffer, stderrBuffer).trim();
|
||||||
if (trimmed.length === 0) {
|
if (trimmed.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -2392,8 +2400,9 @@ function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | u
|
||||||
* in provisioning before any output has been line-split).
|
* in provisioning before any output has been line-split).
|
||||||
*/
|
*/
|
||||||
function extractCliLogsFromRun(run: ProvisioningRun): string | undefined {
|
function extractCliLogsFromRun(run: ProvisioningRun): string | undefined {
|
||||||
if (run.claudeLogLines.length > 0) {
|
const claudeLogLines = Array.isArray(run.claudeLogLines) ? run.claudeLogLines : [];
|
||||||
const joined = run.claudeLogLines.join('\n').trim();
|
if (claudeLogLines.length > 0) {
|
||||||
|
const joined = claudeLogLines.join('\n').trim();
|
||||||
if (joined.length === 0) {
|
if (joined.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -2402,6 +2411,88 @@ function extractCliLogsFromRun(run: ProvisioningRun): string | undefined {
|
||||||
return extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
|
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
|
* 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
|
* 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<string, ProvisioningRun>();
|
private readonly runs = new Map<string, ProvisioningRun>();
|
||||||
private readonly provisioningRunByTeam = new Map<string, string>();
|
private readonly provisioningRunByTeam = new Map<string, string>();
|
||||||
private readonly aliveRunByTeam = new Map<string, string>();
|
private readonly aliveRunByTeam = new Map<string, string>();
|
||||||
|
private readonly retainedClaudeLogsByTeam = new Map<string, RetainedClaudeLogsSnapshot>();
|
||||||
|
private readonly persistedTranscriptClaudeLogsCache = new Map<
|
||||||
|
string,
|
||||||
|
PersistedTranscriptClaudeLogsCacheEntry
|
||||||
|
>();
|
||||||
private readonly teamOpLocks = new Map<string, Promise<void>>();
|
private readonly teamOpLocks = new Map<string, Promise<void>>();
|
||||||
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
|
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
|
||||||
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
|
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
|
||||||
|
|
@ -2554,6 +2650,7 @@ export class TeamProvisioningService {
|
||||||
>();
|
>();
|
||||||
private readonly launchStateStore = new TeamLaunchStateStore();
|
private readonly launchStateStore = new TeamLaunchStateStore();
|
||||||
private readonly memberLogsFinder: TeamMemberLogsFinder;
|
private readonly memberLogsFinder: TeamMemberLogsFinder;
|
||||||
|
private readonly transcriptProjectResolver: TeamTranscriptProjectResolver;
|
||||||
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
||||||
private helpOutputCache: string | null = null;
|
private helpOutputCache: string | null = null;
|
||||||
private helpOutputCacheTime = 0;
|
private helpOutputCacheTime = 0;
|
||||||
|
|
@ -2589,6 +2686,7 @@ export class TeamProvisioningService {
|
||||||
this.inboxReader,
|
this.inboxReader,
|
||||||
this.membersMetaStore
|
this.membersMetaStore
|
||||||
);
|
);
|
||||||
|
this.transcriptProjectResolver = new TeamTranscriptProjectResolver(this.configReader);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCrossTeamSender(
|
setCrossTeamSender(
|
||||||
|
|
@ -2613,52 +2711,28 @@ export class TeamProvisioningService {
|
||||||
this.controlApiBaseUrlResolver = resolver;
|
this.controlApiBaseUrlResolver = resolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
getClaudeLogs(
|
async getClaudeLogs(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
query?: { offset?: number; limit?: number }
|
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);
|
const runId = this.getTrackedRunId(teamName);
|
||||||
if (!runId) {
|
if (runId) {
|
||||||
return { lines: [], total: 0, hasMore: false };
|
const run = this.runs.get(runId);
|
||||||
}
|
if (run) {
|
||||||
const run = this.runs.get(runId);
|
return sliceClaudeLogs(run.claudeLogLines, run.claudeLogsUpdatedAt, query);
|
||||||
if (!run) {
|
}
|
||||||
return { lines: [], total: 0, hasMore: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const offsetRaw = query?.offset ?? 0;
|
const retained = this.retainedClaudeLogsByTeam.get(teamName);
|
||||||
const limitRaw = query?.limit ?? 100;
|
if (!retained) {
|
||||||
const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0;
|
const transcriptSnapshot = await this.getPersistedTranscriptClaudeLogs(teamName);
|
||||||
const limit = Number.isFinite(limitRaw)
|
if (!transcriptSnapshot) {
|
||||||
? Math.max(1, Math.min(1000, Math.floor(limitRaw)))
|
return { lines: [], total: 0, hasMore: false };
|
||||||
: 100;
|
}
|
||||||
|
return sliceClaudeLogs(transcriptSnapshot.lines, transcriptSnapshot.updatedAt, query);
|
||||||
const total = run.claudeLogLines.length;
|
|
||||||
if (total === 0) {
|
|
||||||
return { lines: [], total: 0, hasMore: false, updatedAt: run.claudeLogsUpdatedAt };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newestExclusive = Math.max(0, total - offset);
|
return sliceClaudeLogs(retained.lines, retained.updatedAt, query);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getProvisioningRunId(teamName: string): string | null {
|
private getProvisioningRunId(teamName: string): string | null {
|
||||||
|
|
@ -2673,6 +2747,85 @@ export class TeamProvisioningService {
|
||||||
return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName);
|
return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getPersistedTranscriptClaudeLogs(
|
||||||
|
teamName: string
|
||||||
|
): Promise<RetainedClaudeLogsSnapshot | null> {
|
||||||
|
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<string[]> {
|
||||||
|
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 {
|
private clearSameTeamRetryTimers(teamName: string): void {
|
||||||
for (const suffix of ['deferred', 'persist']) {
|
for (const suffix of ['deferred', 'persist']) {
|
||||||
const key = `same-team-${suffix}:${teamName}`;
|
const key = `same-team-${suffix}:${teamName}`;
|
||||||
|
|
@ -2686,6 +2839,8 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
private resetTeamScopedTransientStateForNewRun(teamName: string): void {
|
private resetTeamScopedTransientStateForNewRun(teamName: string): void {
|
||||||
peekAutoResumeService()?.cancelPendingAutoResume(teamName);
|
peekAutoResumeService()?.cancelPendingAutoResume(teamName);
|
||||||
|
this.retainedClaudeLogsByTeam.delete(teamName);
|
||||||
|
this.persistedTranscriptClaudeLogsCache.delete(teamName);
|
||||||
this.leadInboxRelayInFlight.delete(teamName);
|
this.leadInboxRelayInFlight.delete(teamName);
|
||||||
this.relayedLeadInboxMessageIds.delete(teamName);
|
this.relayedLeadInboxMessageIds.delete(teamName);
|
||||||
this.pendingCrossTeamFirstReplies.delete(teamName);
|
this.pendingCrossTeamFirstReplies.delete(teamName);
|
||||||
|
|
@ -11481,6 +11636,7 @@ export class TeamProvisioningService {
|
||||||
private cleanupRun(run: ProvisioningRun): void {
|
private cleanupRun(run: ProvisioningRun): void {
|
||||||
const currentTrackedRunId = this.getTrackedRunId(run.teamName);
|
const currentTrackedRunId = this.getTrackedRunId(run.teamName);
|
||||||
const hasNewerTrackedRun = currentTrackedRunId !== null && currentTrackedRunId !== run.runId;
|
const hasNewerTrackedRun = currentTrackedRunId !== null && currentTrackedRunId !== run.runId;
|
||||||
|
const retainedClaudeLogs = hasNewerTrackedRun ? null : buildRetainedClaudeLogsSnapshot(run);
|
||||||
|
|
||||||
if (!hasNewerTrackedRun) {
|
if (!hasNewerTrackedRun) {
|
||||||
peekAutoResumeService()?.cancelPendingAutoResume(run.teamName);
|
peekAutoResumeService()?.cancelPendingAutoResume(run.teamName);
|
||||||
|
|
@ -11573,6 +11729,13 @@ export class TeamProvisioningService {
|
||||||
void removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath);
|
void removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath);
|
||||||
run.bootstrapUserPromptPath = null;
|
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)
|
// Remove from runs Map to free memory (stdoutBuffer, stderrBuffer, claudeLogLines)
|
||||||
this.runs.delete(run.runId);
|
this.runs.delete(run.runId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,13 @@ const EMPTY_TEAM_NAMES: string[] = [];
|
||||||
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
||||||
const DEFAULT_COLLAPSE_MODE = 'default' as const;
|
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 {
|
interface ItemCollapseProps {
|
||||||
collapseMode: 'default' | 'managed';
|
collapseMode: 'default' | 'managed';
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
|
|
@ -418,13 +425,20 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
setVisibleCount(Infinity);
|
setVisibleCount(Infinity);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getItemSessionAnchorId = (item: TimelineItem): string | undefined => {
|
// Precompute, per timeline index, the most recent session anchor that appears
|
||||||
if (item.type === 'lead-thoughts') {
|
// strictly earlier in the list. Replaces an O(n) backward scan during render
|
||||||
return item.group.thoughts[0]?.leadSessionId;
|
// with an O(1) lookup; total work drops from O(n^2) to O(n) per timelineItems
|
||||||
|
// change.
|
||||||
|
const previousSessionAnchorByIndex = useMemo<readonly (string | undefined)[]>(() => {
|
||||||
|
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 anchors;
|
||||||
return undefined;
|
}, [timelineItems]);
|
||||||
};
|
|
||||||
|
|
||||||
// Pin the newest thought group (if first) so it stays at the top and doesn't jump.
|
// 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;
|
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;
|
let sessionSeparator: React.JSX.Element | null = null;
|
||||||
if (realIndex > 0) {
|
if (realIndex > 0) {
|
||||||
const currSessionId = getItemSessionAnchorId(item);
|
const currSessionId = getItemSessionAnchorId(item);
|
||||||
let prevSessionId: string | undefined;
|
const prevSessionId = previousSessionAnchorByIndex[realIndex];
|
||||||
for (let searchIndex = realIndex - 1; searchIndex >= 0; searchIndex -= 1) {
|
|
||||||
const candidateSessionId = getItemSessionAnchorId(timelineItems[searchIndex]);
|
|
||||||
if (candidateSessionId) {
|
|
||||||
prevSessionId = candidateSessionId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
|
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
|
||||||
sessionSeparator = (
|
sessionSeparator = (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,7 @@ const AIExecutionGroup = ({
|
||||||
>
|
>
|
||||||
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
|
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
|
||||||
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
|
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
|
||||||
Claude
|
Agent
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]">
|
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]">
|
||||||
{enhanced.itemsSummary}
|
{enhanced.itemsSummary}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchSta
|
||||||
import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore';
|
import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore';
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { spawnCli } from '@main/utils/childProcess';
|
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 { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES } from 'agent-teams-controller';
|
||||||
import { listTmuxPanePidsForCurrentPlatform } from '@features/tmux-installer/main';
|
import { listTmuxPanePidsForCurrentPlatform } from '@features/tmux-installer/main';
|
||||||
import pidusage from 'pidusage';
|
import pidusage from 'pidusage';
|
||||||
|
|
@ -234,6 +235,40 @@ function createMemberSpawnRun(params?: {
|
||||||
} as any;
|
} as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createClaudeLogsRun(overrides: Record<string, unknown> = {}) {
|
||||||
|
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', () => {
|
describe('TeamProvisioningService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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', () => {
|
describe('getTeamAgentRuntimeSnapshot', () => {
|
||||||
it('uses batched pidusage rss values for lead and teammates', async () => {
|
it('uses batched pidusage rss values for lead and teammates', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
|
|
|
||||||
134
test/renderer/components/team/ClaudeLogsPanel.test.ts
Normal file
134
test/renderer/components/team/ClaudeLogsPanel.test.ts
Normal file
|
|
@ -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<Record<string, unknown>>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/team/CliLogsRichView', () => ({
|
||||||
|
CliLogsRichView: (props: Record<string, unknown>) => {
|
||||||
|
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> = {}): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -153,4 +153,135 @@ describe('ActivityTimeline session separators', () => {
|
||||||
root.unmount();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildMembersFromDrafts,
|
||||||
createMemberDraftsFromInputs,
|
createMemberDraftsFromInputs,
|
||||||
filterEditableMemberInputs,
|
filterEditableMemberInputs,
|
||||||
} from '@renderer/components/team/members/MembersEditorSection';
|
} from '@renderer/components/team/members/MembersEditorSection';
|
||||||
|
|
@ -60,4 +61,29 @@ describe('members editor editable input filtering', () => {
|
||||||
effort: 'medium',
|
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<ResolvedTeamMember, 'name' | 'agentType' | 'providerId' | 'model' | 'effort'>
|
||||||
|
>)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(buildMembersFromDrafts(drafts)).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'alice',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4-mini',
|
||||||
|
effort: 'medium',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -402,7 +402,7 @@ describe('TaskLogStreamSection integration', () => {
|
||||||
expect(text).toContain('Task Log Stream');
|
expect(text).toContain('Task Log Stream');
|
||||||
expect(text).toContain('Grep');
|
expect(text).toContain('Grep');
|
||||||
expect(text).toContain('Edit');
|
expect(text).toContain('Edit');
|
||||||
expect(text).toContain('Claude');
|
expect(text).toContain('Agent');
|
||||||
expect(text).toContain('3 tool calls');
|
expect(text).toContain('3 tool calls');
|
||||||
expect(text).not.toContain('[]');
|
expect(text).not.toContain('[]');
|
||||||
expect(text).not.toContain('Audit complete');
|
expect(text).not.toContain('Audit complete');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue