feat: add member log stream v2

This commit is contained in:
777genius 2026-05-07 13:19:56 +03:00
parent f57b1bf18b
commit fcca3649bf
54 changed files with 7827 additions and 528 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
import type { MemberLogStreamRequestOptions, MemberLogStreamResponse } from './dto';
export interface MemberLogStreamApi {
getMemberLogStream(
teamName: string,
memberName: string,
options?: MemberLogStreamRequestOptions
): Promise<MemberLogStreamResponse>;
setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise<void>;
}

View file

@ -0,0 +1,2 @@
export const MEMBER_LOG_STREAM_GET = 'member-log-stream:getMemberLogStream';
export const MEMBER_LOG_STREAM_SET_TRACKING = 'member-log-stream:setTracking';

View file

@ -0,0 +1,72 @@
import type { BoardTaskLogParticipant, BoardTaskLogSegment } from '@shared/types';
export type MemberLogStreamProvider =
| 'claude_transcript'
| 'opencode_runtime'
| 'codex_native_trace';
export type MemberLogStreamSource =
| 'member_transcript'
| 'member_mixed_runtime'
| 'member_runtime_only'
| 'member_empty';
export interface MemberLogStreamRequestOptions {
limitSegments?: number;
since?: string;
laneId?: string;
forceRefresh?: boolean;
}
export interface MemberLogStreamCoverage {
provider: MemberLogStreamProvider;
status: 'included' | 'partial' | 'skipped';
reason?: string;
}
export interface MemberLogStreamWarning {
code:
| 'opencode_ambiguous_lane'
| 'opencode_missing_runtime_session'
| 'opencode_runtime_unavailable'
| 'opencode_runtime_timeout'
| 'codex_member_wide_not_supported'
| 'large_log_window_limited'
| 'segment_message_window_limited'
| 'message_content_limited'
| 'unreadable_transcript_file';
message: string;
}
export interface MemberLogStreamMetadata {
scannedTranscriptFileCount: number;
includedTranscriptFileCount: number;
droppedSegmentCount: number;
droppedChunkCount: number;
droppedMessageCount: number;
}
export interface MemberLogStreamSegmentSource {
provider: MemberLogStreamProvider;
label: string;
sessionId?: string;
laneId?: string;
messageCount?: number;
truncated?: boolean;
}
export interface MemberLogStreamSegment extends BoardTaskLogSegment {
source: MemberLogStreamSegmentSource;
}
export interface MemberLogStreamResponse {
participants: BoardTaskLogParticipant[];
defaultFilter: string;
segments: MemberLogStreamSegment[];
source: MemberLogStreamSource;
coverage: MemberLogStreamCoverage[];
warnings: MemberLogStreamWarning[];
truncated: boolean;
generatedAt: string;
metadata: MemberLogStreamMetadata;
}

View file

@ -0,0 +1,4 @@
export type * from './api';
export * from './channels';
export type * from './dto';
export * from './normalize';

View file

@ -0,0 +1,44 @@
import type { MemberLogStreamResponse } from './dto';
export function createEmptyMemberLogStreamResponse(
generatedAt = new Date().toISOString()
): MemberLogStreamResponse {
return {
participants: [],
defaultFilter: 'all',
segments: [],
source: 'member_empty',
coverage: [],
warnings: [],
truncated: false,
generatedAt,
metadata: {
scannedTranscriptFileCount: 0,
includedTranscriptFileCount: 0,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
},
};
}
export function normalizeMemberLogStreamResponse(
response: MemberLogStreamResponse | null | undefined
): MemberLogStreamResponse {
if (!response) {
return createEmptyMemberLogStreamResponse();
}
return {
...createEmptyMemberLogStreamResponse(response.generatedAt),
...response,
participants: Array.isArray(response.participants) ? response.participants : [],
segments: Array.isArray(response.segments) ? response.segments : [],
coverage: Array.isArray(response.coverage) ? response.coverage : [],
warnings: Array.isArray(response.warnings) ? response.warnings : [],
metadata: {
...createEmptyMemberLogStreamResponse(response.generatedAt).metadata,
...(response.metadata ?? {}),
},
};
}

View file

@ -0,0 +1,3 @@
export interface ClockPort {
now(): number;
}

View file

@ -0,0 +1,5 @@
export interface LoggerPort {
debug?(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
}

View file

@ -0,0 +1,40 @@
import type {
MemberLogStreamCoverage,
MemberLogStreamProvider,
MemberLogStreamSegment,
MemberLogStreamWarning,
} from '../../../contracts';
import type { MemberLogStreamBudget } from '../../domain/models/MemberLogStreamBudget';
import type { BoardTaskLogParticipant } from '@shared/types';
export interface MemberLogStreamSourceInput {
teamName: string;
memberName: string;
laneId?: string;
budget: MemberLogStreamBudget;
sinceMs?: number | null;
forceRefresh?: boolean;
}
export interface MemberLogStreamSourceMetadata {
scannedTranscriptFileCount?: number;
includedTranscriptFileCount?: number;
droppedSegmentCount?: number;
droppedChunkCount?: number;
droppedMessageCount?: number;
}
export interface MemberLogStreamSourceResult {
provider: MemberLogStreamProvider;
status: MemberLogStreamCoverage['status'];
reason?: string;
participants: BoardTaskLogParticipant[];
segments: MemberLogStreamSegment[];
warnings: MemberLogStreamWarning[];
metadata?: MemberLogStreamSourceMetadata;
}
export interface MemberLogStreamSource {
readonly provider: MemberLogStreamProvider;
load(input: MemberLogStreamSourceInput): Promise<MemberLogStreamSourceResult>;
}

View file

@ -0,0 +1,3 @@
export interface MemberLogStreamTrackingPort {
setTracking(teamName: string, enabled: boolean): Promise<void>;
}

View file

@ -0,0 +1,144 @@
import { createEmptyMemberLogStreamResponse } from '../../../contracts';
import {
clampMemberLogStreamSegmentLimit,
DEFAULT_MEMBER_LOG_STREAM_BUDGET,
} from '../../domain/models/MemberLogStreamBudget';
import { buildMemberLogStreamResponse } from '../../domain/policies/memberLogStreamMergePolicy';
import type { MemberLogStreamResponse } from '../../../contracts';
import type { MemberLogStreamBudget } from '../../domain/models/MemberLogStreamBudget';
import type { ClockPort } from '../ports/ClockPort';
import type { LoggerPort } from '../ports/LoggerPort';
import type {
MemberLogStreamSource,
MemberLogStreamSourceResult,
} from '../ports/MemberLogStreamSource';
export interface GetMemberLogStreamInput {
teamName: string;
memberName: string;
limitSegments?: number;
sinceMs?: number | null;
laneId?: string;
forceRefresh?: boolean;
}
interface GetMemberLogStreamUseCaseDeps {
sources: readonly MemberLogStreamSource[];
clock: ClockPort;
logger: LoggerPort;
budget?: Partial<MemberLogStreamBudget>;
}
function stableInputKey(input: GetMemberLogStreamInput, limitSegments: number): string {
return JSON.stringify([
input.teamName,
input.memberName,
limitSegments,
input.sinceMs ?? null,
input.laneId ?? '',
input.forceRefresh === true,
]);
}
export class GetMemberLogStreamUseCase {
private readonly budget: MemberLogStreamBudget;
private readonly inFlight = new Map<string, Promise<MemberLogStreamResponse>>();
constructor(private readonly deps: GetMemberLogStreamUseCaseDeps) {
this.budget = { ...DEFAULT_MEMBER_LOG_STREAM_BUDGET, ...(deps.budget ?? {}) };
}
async execute(input: GetMemberLogStreamInput): Promise<MemberLogStreamResponse> {
const limitSegments = clampMemberLogStreamSegmentLimit(input.limitSegments, this.budget);
const key = stableInputKey(input, limitSegments);
const existing = this.inFlight.get(key);
if (existing) {
return existing;
}
const promise = this.buildResponse(input, limitSegments).finally(() => {
this.inFlight.delete(key);
});
this.inFlight.set(key, promise);
return promise;
}
private async buildResponse(
input: GetMemberLogStreamInput,
limitSegments: number
): Promise<MemberLogStreamResponse> {
if (this.deps.sources.length === 0) {
return createEmptyMemberLogStreamResponse(new Date(this.deps.clock.now()).toISOString());
}
const sourceInput = {
teamName: input.teamName,
memberName: input.memberName,
laneId: input.laneId,
budget: this.budget,
sinceMs: input.sinceMs,
forceRefresh: input.forceRefresh,
};
const settled = await Promise.all(
this.deps.sources.map(async (source): Promise<MemberLogStreamSourceResult> => {
try {
return await source.load(sourceInput);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.deps.logger.warn(
`Member log stream source ${source.provider} failed for ${input.teamName}/${input.memberName}: ${message}`
);
return {
provider: source.provider,
status: 'skipped',
reason: message,
participants: [],
segments: [],
warnings: [
{
code:
source.provider === 'opencode_runtime'
? 'opencode_runtime_unavailable'
: 'unreadable_transcript_file',
message,
},
],
};
}
})
);
const metadata = {
scannedTranscriptFileCount: 0,
includedTranscriptFileCount: 0,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
};
for (const result of settled) {
metadata.scannedTranscriptFileCount += result.metadata?.scannedTranscriptFileCount ?? 0;
metadata.includedTranscriptFileCount += result.metadata?.includedTranscriptFileCount ?? 0;
metadata.droppedSegmentCount += result.metadata?.droppedSegmentCount ?? 0;
metadata.droppedChunkCount += result.metadata?.droppedChunkCount ?? 0;
metadata.droppedMessageCount += result.metadata?.droppedMessageCount ?? 0;
}
return buildMemberLogStreamResponse({
participants: settled.flatMap((result) => result.participants),
segments: settled.flatMap((result) => result.segments),
coverage: settled.map((result) => ({
provider: result.provider,
status: result.status,
...(result.reason ? { reason: result.reason } : {}),
})),
warnings: settled.flatMap((result) => result.warnings),
generatedAt: new Date(this.deps.clock.now()).toISOString(),
budget: this.budget,
limitSegments,
metadata,
});
}
}

View file

@ -0,0 +1,9 @@
import type { MemberLogStreamTrackingPort } from '../ports/MemberLogStreamTrackingPort';
export class SetMemberLogStreamTrackingUseCase {
constructor(private readonly tracking: MemberLogStreamTrackingPort) {}
async execute(teamName: string, enabled: boolean): Promise<void> {
await this.tracking.setTracking(teamName, enabled);
}
}

View file

@ -0,0 +1,145 @@
import { describe, expect, it, vi } from 'vitest';
import { GetMemberLogStreamUseCase } from '../GetMemberLogStreamUseCase';
import type { MemberLogStreamSegment } from '../../../../contracts';
import type {
MemberLogStreamSource,
MemberLogStreamSourceResult,
} from '../../ports/MemberLogStreamSource';
import type { BoardTaskLogParticipant } from '@shared/types';
const generatedAt = Date.parse('2026-02-01T00:00:00.000Z');
const participant: BoardTaskLogParticipant = {
key: 'member:alice',
label: 'alice',
role: 'member',
isLead: false,
isSidechain: false,
};
function segment(id: string): MemberLogStreamSegment {
return {
id,
participantKey: participant.key,
actor: {
memberName: 'alice',
role: 'member',
sessionId: `session-${id}`,
isSidechain: false,
},
startTimestamp: '2026-02-01T00:00:00.000Z',
endTimestamp: '2026-02-01T00:00:00.000Z',
chunks: [],
source: {
provider: 'claude_transcript',
label: 'Claude transcript',
sessionId: `session-${id}`,
},
};
}
function includedResult(id: string): MemberLogStreamSourceResult {
return {
provider: 'claude_transcript',
status: 'included',
participants: [participant],
segments: [segment(id)],
warnings: [],
metadata: {
scannedTranscriptFileCount: 1,
includedTranscriptFileCount: 1,
},
};
}
describe('GetMemberLogStreamUseCase', () => {
it('keeps the stream fail-soft when one source throws', async () => {
const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() };
const useCase = new GetMemberLogStreamUseCase({
sources: [
{
provider: 'claude_transcript',
load: vi.fn().mockResolvedValue(includedResult('ok')),
},
{
provider: 'opencode_runtime',
load: vi.fn().mockRejectedValue(new Error('runtime down')),
},
],
clock: { now: () => generatedAt },
logger,
});
const response = await useCase.execute({
teamName: 'alpha-team',
memberName: 'alice',
});
expect(response.segments.map((item) => item.id)).toEqual(['ok']);
expect(response.coverage).toEqual([
{ provider: 'claude_transcript', status: 'included' },
{ provider: 'opencode_runtime', status: 'skipped', reason: 'runtime down' },
]);
expect(response.warnings).toEqual([
{ code: 'opencode_runtime_unavailable', message: 'runtime down' },
]);
expect(response.generatedAt).toBe('2026-02-01T00:00:00.000Z');
expect(logger.warn).toHaveBeenCalledWith(
'Member log stream source opencode_runtime failed for alpha-team/alice: runtime down'
);
});
it('joins identical in-flight requests and releases the key after completion', async () => {
const resolveLoad: ((value: MemberLogStreamSourceResult) => void)[] = [];
const load = vi.fn(
() =>
new Promise<MemberLogStreamSourceResult>((resolve) => {
resolveLoad.push(resolve);
})
);
const source: MemberLogStreamSource = {
provider: 'claude_transcript',
load,
};
const useCase = new GetMemberLogStreamUseCase({
sources: [source],
clock: { now: () => generatedAt },
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
});
const first = useCase.execute({
teamName: 'alpha-team',
memberName: 'alice',
limitSegments: 5,
forceRefresh: true,
});
const second = useCase.execute({
teamName: 'alpha-team',
memberName: 'alice',
limitSegments: 5,
forceRefresh: true,
});
expect(load).toHaveBeenCalledTimes(1);
resolveLoad[0]?.(includedResult('joined'));
const [firstResponse, secondResponse] = await Promise.all([first, second]);
expect(firstResponse.segments.map((item) => item.id)).toEqual(['joined']);
expect(secondResponse.segments.map((item) => item.id)).toEqual(['joined']);
const third = useCase.execute({
teamName: 'alpha-team',
memberName: 'alice',
limitSegments: 5,
forceRefresh: true,
});
expect(load).toHaveBeenCalledTimes(2);
resolveLoad[1]?.(includedResult('after-release'));
await expect(third).resolves.toMatchObject({
segments: [{ id: 'after-release' } as MemberLogStreamSegment],
});
});
});

View file

@ -0,0 +1,35 @@
export interface MemberLogStreamBudget {
maxTranscriptFiles: number;
maxSegments: number;
maxChunks: number;
maxSourceMessages: number;
maxMessagesPerSegment: number;
maxTotalContentChars: number;
maxMessageContentChars: number;
maxToolResultContentChars: number;
openCodeMessageLimit: number;
openCodeTimeoutMs: number;
}
export const DEFAULT_MEMBER_LOG_STREAM_BUDGET: MemberLogStreamBudget = {
maxTranscriptFiles: 40,
maxSegments: 30,
maxChunks: 250,
maxSourceMessages: 1200,
maxMessagesPerSegment: 300,
maxTotalContentChars: 800_000,
maxMessageContentChars: 80_000,
maxToolResultContentChars: 120_000,
openCodeMessageLimit: 400,
openCodeTimeoutMs: 5_000,
};
export function clampMemberLogStreamSegmentLimit(
requested: number | undefined,
budget: MemberLogStreamBudget = DEFAULT_MEMBER_LOG_STREAM_BUDGET
): number {
if (typeof requested !== 'number' || !Number.isFinite(requested)) {
return budget.maxSegments;
}
return Math.max(1, Math.min(80, Math.floor(requested), budget.maxSegments));
}

View file

@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../models/MemberLogStreamBudget';
import { buildMemberLogStreamResponse } from '../memberLogStreamMergePolicy';
import type { MemberLogStreamSegment } from '../../../../contracts';
import type { BoardTaskLogParticipant } from '@shared/types';
const participant: BoardTaskLogParticipant = {
key: 'member:alice',
label: 'alice',
role: 'member',
isLead: false,
isSidechain: false,
};
function segment(
id: string,
timestamp: string,
provider: MemberLogStreamSegment['source']['provider'] = 'claude_transcript'
): MemberLogStreamSegment {
return {
id,
participantKey: participant.key,
actor: {
memberName: 'alice',
role: 'member',
sessionId: `session-${id}`,
isSidechain: false,
},
startTimestamp: timestamp,
endTimestamp: timestamp,
chunks: [],
source: {
provider,
label: provider,
sessionId: `session-${id}`,
},
};
}
describe('buildMemberLogStreamResponse', () => {
it('sorts segments chronologically, keeps the recent limit, and marks bounded windows as truncated', () => {
const response = buildMemberLogStreamResponse({
participants: [participant, participant],
segments: [
segment('newest', '2026-01-01T00:03:00.000Z'),
segment('oldest', '2026-01-01T00:01:00.000Z'),
segment('middle', '2026-01-01T00:02:00.000Z'),
],
coverage: [
{ provider: 'codex_native_trace', status: 'skipped' },
{ provider: 'claude_transcript', status: 'included' },
],
warnings: [],
generatedAt: '2026-01-01T00:04:00.000Z',
budget: DEFAULT_MEMBER_LOG_STREAM_BUDGET,
limitSegments: 2,
metadata: {
scannedTranscriptFileCount: 3,
includedTranscriptFileCount: 3,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
},
});
expect(response.segments.map((item) => item.id)).toEqual(['middle', 'newest']);
expect(response.participants).toEqual([participant]);
expect(response.coverage.map((item) => item.provider)).toEqual([
'claude_transcript',
'codex_native_trace',
]);
expect(response.truncated).toBe(true);
expect(response.metadata.droppedSegmentCount).toBe(1);
expect(response.warnings).toEqual([
{
code: 'large_log_window_limited',
message: 'Showing a bounded recent member log stream to keep the popup responsive.',
},
]);
});
it('classifies mixed transcript and runtime streams without relying on coverage-only data', () => {
const mixed = buildMemberLogStreamResponse({
participants: [participant],
segments: [
segment('claude', '2026-01-01T00:01:00.000Z', 'claude_transcript'),
segment('opencode', '2026-01-01T00:02:00.000Z', 'opencode_runtime'),
],
coverage: [
{ provider: 'claude_transcript', status: 'included' },
{ provider: 'opencode_runtime', status: 'included' },
{ provider: 'codex_native_trace', status: 'skipped' },
],
warnings: [],
generatedAt: '2026-01-01T00:03:00.000Z',
budget: DEFAULT_MEMBER_LOG_STREAM_BUDGET,
limitSegments: 10,
metadata: {
scannedTranscriptFileCount: 1,
includedTranscriptFileCount: 1,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
},
});
expect(mixed.source).toBe('member_mixed_runtime');
});
});

View file

@ -0,0 +1,147 @@
import type {
MemberLogStreamCoverage,
MemberLogStreamProvider,
MemberLogStreamResponse,
MemberLogStreamSegment,
MemberLogStreamSource,
MemberLogStreamWarning,
} from '../../../contracts';
import type { MemberLogStreamBudget } from '../models/MemberLogStreamBudget';
import type { BoardTaskLogParticipant } from '@shared/types';
export const MEMBER_LOG_STREAM_PROVIDER_ORDER: readonly MemberLogStreamProvider[] = [
'claude_transcript',
'opencode_runtime',
'codex_native_trace',
];
function getSegmentStartMs(segment: MemberLogStreamSegment): number {
const parsed = Date.parse(segment.startTimestamp);
return Number.isFinite(parsed) ? parsed : 0;
}
function dedupeParticipants(
participants: readonly BoardTaskLogParticipant[]
): BoardTaskLogParticipant[] {
const deduped = new Map<string, BoardTaskLogParticipant>();
for (const participant of participants) {
if (!deduped.has(participant.key)) {
deduped.set(participant.key, participant);
}
}
return [...deduped.values()];
}
export function inferMemberLogStreamSource(
segments: readonly MemberLogStreamSegment[]
): MemberLogStreamSource {
if (segments.length === 0) {
return 'member_empty';
}
const hasTranscript = segments.some((segment) => segment.source.provider === 'claude_transcript');
const hasRuntime = segments.some((segment) => segment.source.provider === 'opencode_runtime');
if (hasTranscript && hasRuntime) {
return 'member_mixed_runtime';
}
if (hasRuntime) {
return 'member_runtime_only';
}
return 'member_transcript';
}
export function buildMemberLogStreamResponse(input: {
participants: readonly BoardTaskLogParticipant[];
segments: readonly MemberLogStreamSegment[];
coverage: readonly MemberLogStreamCoverage[];
warnings: readonly MemberLogStreamWarning[];
generatedAt: string;
budget: MemberLogStreamBudget;
limitSegments: number;
metadata: {
scannedTranscriptFileCount: number;
includedTranscriptFileCount: number;
droppedSegmentCount: number;
droppedChunkCount: number;
droppedMessageCount: number;
};
}): MemberLogStreamResponse {
const warnings = [...input.warnings];
const sorted = [...input.segments].sort((left, right) => {
const byTime = getSegmentStartMs(left) - getSegmentStartMs(right);
return byTime !== 0 ? byTime : left.id.localeCompare(right.id);
});
let droppedSegmentCount = input.metadata.droppedSegmentCount;
let droppedChunkCount = input.metadata.droppedChunkCount;
let limitedSegments = sorted;
const maxSegments = Math.min(input.limitSegments, input.budget.maxSegments);
if (limitedSegments.length > maxSegments) {
droppedSegmentCount += limitedSegments.length - maxSegments;
limitedSegments = limitedSegments.slice(-maxSegments);
}
const totalChunks = limitedSegments.reduce((sum, segment) => sum + segment.chunks.length, 0);
if (totalChunks > input.budget.maxChunks) {
const retained: MemberLogStreamSegment[] = [];
let remaining = input.budget.maxChunks;
for (const segment of [...limitedSegments].reverse()) {
if (remaining <= 0) {
droppedSegmentCount += 1;
continue;
}
if (segment.chunks.length <= remaining) {
retained.push(segment);
remaining -= segment.chunks.length;
continue;
}
const keptChunks = segment.chunks.slice(-remaining);
droppedChunkCount += segment.chunks.length - keptChunks.length;
retained.push({
...segment,
chunks: keptChunks,
source: { ...segment.source, truncated: true },
});
remaining = 0;
}
const retainedInDisplayOrder = [...retained].reverse();
limitedSegments = retainedInDisplayOrder;
}
const truncated =
droppedSegmentCount > input.metadata.droppedSegmentCount ||
droppedChunkCount > input.metadata.droppedChunkCount ||
input.metadata.droppedMessageCount > 0 ||
limitedSegments.some((segment) => segment.source.truncated);
if (truncated && !warnings.some((warning) => warning.code === 'large_log_window_limited')) {
warnings.push({
code: 'large_log_window_limited',
message: 'Showing a bounded recent member log stream to keep the popup responsive.',
});
}
const participants = dedupeParticipants(input.participants);
return {
participants,
defaultFilter: participants.length === 1 ? (participants[0]?.key ?? 'all') : 'all',
segments: limitedSegments,
source: inferMemberLogStreamSource(limitedSegments),
coverage: [...input.coverage].sort(
(left, right) =>
MEMBER_LOG_STREAM_PROVIDER_ORDER.indexOf(left.provider) -
MEMBER_LOG_STREAM_PROVIDER_ORDER.indexOf(right.provider)
),
warnings,
truncated,
generatedAt: input.generatedAt,
metadata: {
scannedTranscriptFileCount: input.metadata.scannedTranscriptFileCount,
includedTranscriptFileCount: input.metadata.includedTranscriptFileCount,
droppedSegmentCount,
droppedChunkCount,
droppedMessageCount: input.metadata.droppedMessageCount,
},
};
}

View file

@ -0,0 +1,195 @@
import { describe, expect, it, vi } from 'vitest';
import { MEMBER_LOG_STREAM_GET, MEMBER_LOG_STREAM_SET_TRACKING } from '../../../../../contracts';
import {
registerMemberLogStreamIpc,
removeMemberLogStreamIpc,
} from '../registerMemberLogStreamIpc';
import type { MemberLogStreamResponse } from '../../../../../contracts';
import type { MemberLogStreamFeatureFacade } from '../../../../composition/createMemberLogStreamFeature';
import type { IpcMainInvokeEvent } from 'electron';
vi.mock('@shared/utils/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
function emptyResponse(): MemberLogStreamResponse {
return {
participants: [],
defaultFilter: 'all',
segments: [],
source: 'member_empty',
coverage: [],
warnings: [],
truncated: false,
generatedAt: '2026-03-01T00:00:00.000Z',
metadata: {
scannedTranscriptFileCount: 0,
includedTranscriptFileCount: 0,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
},
};
}
function createFakeIpcMain(): {
handlers: Map<string, (...args: unknown[]) => unknown>;
ipcMain: {
handle: ReturnType<typeof vi.fn>;
removeHandler: ReturnType<typeof vi.fn>;
};
} {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => {
handlers.set(channel, handler);
}),
removeHandler: vi.fn((channel: string) => {
handlers.delete(channel);
}),
},
};
}
describe('registerMemberLogStreamIpc', () => {
it('validates and normalizes getMemberLogStream options before calling the feature facade', async () => {
const { handlers, ipcMain } = createFakeIpcMain();
const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse());
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream,
setMemberLogStreamTracking: vi.fn(),
};
registerMemberLogStreamIpc(ipcMain as never, feature);
const result = await handlers.get(MEMBER_LOG_STREAM_GET)?.(
{} as IpcMainInvokeEvent,
'alpha-team',
'alice',
{
limitSegments: 200,
since: '2026-03-01T12:34:56.000Z',
laneId: ' secondary:opencode:alice ',
forceRefresh: true,
}
);
expect(result).toEqual({ success: true, data: emptyResponse() });
expect(getMemberLogStream).toHaveBeenCalledWith({
teamName: 'alpha-team',
memberName: 'alice',
limitSegments: 80,
sinceMs: Date.parse('2026-03-01T12:34:56.000Z'),
laneId: 'secondary:opencode:alice',
forceRefresh: true,
});
});
it('rejects unknown options and unsafe runtime lane ids', async () => {
const { handlers, ipcMain } = createFakeIpcMain();
const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse());
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream,
setMemberLogStreamTracking: vi.fn(),
};
registerMemberLogStreamIpc(ipcMain as never, feature);
const get = handlers.get(MEMBER_LOG_STREAM_GET)!;
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { unknown: true })
).resolves.toEqual({
success: false,
error: 'Unknown getMemberLogStream option: unknown',
});
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: '../bad' })
).resolves.toEqual({
success: false,
error: 'laneId contains invalid characters',
});
expect(getMemberLogStream).not.toHaveBeenCalled();
});
it('accepts primary lane ids and rejects malformed optional values', async () => {
const { handlers, ipcMain } = createFakeIpcMain();
const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse());
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream,
setMemberLogStreamTracking: vi.fn(),
};
registerMemberLogStreamIpc(ipcMain as never, feature);
const get = handlers.get(MEMBER_LOG_STREAM_GET)!;
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: 'primary' })
).resolves.toEqual({ success: true, data: emptyResponse() });
expect(getMemberLogStream).toHaveBeenCalledWith({
teamName: 'alpha-team',
memberName: 'alice',
laneId: 'primary',
});
getMemberLogStream.mockClear();
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { since: 'not-a-date' })
).resolves.toEqual({
success: false,
error: 'since must be a valid timestamp',
});
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { forceRefresh: 'true' })
).resolves.toEqual({
success: false,
error: 'forceRefresh must be a boolean',
});
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: 'bad\nlane' })
).resolves.toEqual({
success: false,
error: 'laneId contains invalid characters',
});
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: 'x'.repeat(257) })
).resolves.toEqual({
success: false,
error: 'laneId exceeds max length (256)',
});
expect(getMemberLogStream).not.toHaveBeenCalled();
});
it('validates tracking calls and unregisters both handlers', async () => {
const { handlers, ipcMain } = createFakeIpcMain();
const setMemberLogStreamTracking = vi.fn().mockResolvedValue(undefined);
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
setMemberLogStreamTracking,
};
registerMemberLogStreamIpc(ipcMain as never, feature);
const setTracking = handlers.get(MEMBER_LOG_STREAM_SET_TRACKING)!;
await expect(setTracking({} as IpcMainInvokeEvent, 'alpha-team', true)).resolves.toEqual({
success: true,
});
await expect(setTracking({} as IpcMainInvokeEvent, 'alpha-team', 'yes')).resolves.toEqual({
success: false,
error: 'enabled must be a boolean',
});
expect(setMemberLogStreamTracking).toHaveBeenCalledWith('alpha-team', true);
removeMemberLogStreamIpc(ipcMain as never);
expect(handlers.has(MEMBER_LOG_STREAM_GET)).toBe(false);
expect(handlers.has(MEMBER_LOG_STREAM_SET_TRACKING)).toBe(false);
});
});

View file

@ -0,0 +1,183 @@
import { validateMemberName, validateTeamName } from '@main/ipc/guards';
import { createLogger } from '@shared/utils/logger';
import {
MEMBER_LOG_STREAM_GET,
MEMBER_LOG_STREAM_SET_TRACKING,
normalizeMemberLogStreamResponse,
} from '../../../../contracts';
import type { MemberLogStreamRequestOptions, MemberLogStreamResponse } from '../../../../contracts';
import type { MemberLogStreamFeatureFacade } from '../../../composition/createMemberLogStreamFeature';
import type { IpcResult } from '@shared/types';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const logger = createLogger('Feature:MemberLogStream:IPC');
const ALLOWED_OPTION_KEYS = new Set(['limitSegments', 'since', 'laneId', 'forceRefresh']);
interface ValidationResult<T> {
valid: boolean;
value?: T;
error?: string;
}
function validateOptionalRuntimeLaneId(value: unknown): ValidationResult<string | undefined> {
if (value == null) return { valid: true, value: undefined };
if (typeof value !== 'string') return { valid: false, error: 'laneId must be a string' };
const trimmed = value.trim();
if (!trimmed) return { valid: true, value: undefined };
if (trimmed.length > 256) return { valid: false, error: 'laneId exceeds max length (256)' };
if (
trimmed.includes('/') ||
trimmed.includes('\\') ||
[...trimmed].some((char) => {
const code = char.charCodeAt(0);
return code <= 31 || code === 127;
})
) {
return { valid: false, error: 'laneId contains invalid characters' };
}
return { valid: true, value: trimmed };
}
function normalizeOptions(options: unknown): ValidationResult<{
limitSegments?: number;
sinceMs?: number | null;
laneId?: string;
forceRefresh?: boolean;
}> {
if (options == null) {
return { valid: true, value: {} };
}
if (typeof options !== 'object' || Array.isArray(options)) {
return { valid: false, error: 'options must be an object' };
}
const record = options as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!ALLOWED_OPTION_KEYS.has(key)) {
return { valid: false, error: `Unknown getMemberLogStream option: ${key}` };
}
}
let limitSegments: number | undefined;
if (record.limitSegments != null) {
if (typeof record.limitSegments !== 'number' || !Number.isFinite(record.limitSegments)) {
return { valid: false, error: 'limitSegments must be a finite number' };
}
limitSegments = Math.max(1, Math.min(80, Math.floor(record.limitSegments)));
}
let sinceMs: number | null | undefined;
if (record.since != null) {
if (typeof record.since !== 'string') {
return { valid: false, error: 'since must be an ISO timestamp string' };
}
const parsed = Date.parse(record.since);
if (!Number.isFinite(parsed)) {
return { valid: false, error: 'since must be a valid timestamp' };
}
sinceMs = parsed;
}
const lane = validateOptionalRuntimeLaneId(record.laneId);
if (!lane.valid) {
return { valid: false, error: lane.error };
}
let forceRefresh: boolean | undefined;
if (record.forceRefresh != null) {
if (typeof record.forceRefresh !== 'boolean') {
return { valid: false, error: 'forceRefresh must be a boolean' };
}
forceRefresh = record.forceRefresh;
}
return {
valid: true,
value: {
...(limitSegments !== undefined ? { limitSegments } : {}),
...(sinceMs !== undefined ? { sinceMs } : {}),
...(lane.value !== undefined ? { laneId: lane.value } : {}),
...(forceRefresh !== undefined ? { forceRefresh } : {}),
},
};
}
export function registerMemberLogStreamIpc(
ipcMain: IpcMain,
feature: MemberLogStreamFeatureFacade
): void {
ipcMain.handle(
MEMBER_LOG_STREAM_GET,
async (
_event: IpcMainInvokeEvent,
teamName: unknown,
memberName: unknown,
options?: MemberLogStreamRequestOptions
): Promise<IpcResult<MemberLogStreamResponse>> => {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
const vMember = validateMemberName(memberName);
if (!vMember.valid) {
return { success: false, error: vMember.error ?? 'Invalid memberName' };
}
const vOptions = normalizeOptions(options);
if (!vOptions.valid) {
return { success: false, error: vOptions.error ?? 'Invalid options' };
}
try {
const response = await feature.getMemberLogStream({
teamName: vTeam.value!,
memberName: vMember.value!,
...vOptions.value!,
});
return { success: true, data: normalizeMemberLogStreamResponse(response) };
} catch (error) {
logger.error('Failed to load member log stream', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to load member log stream',
};
}
}
);
ipcMain.handle(
MEMBER_LOG_STREAM_SET_TRACKING,
async (
_event: IpcMainInvokeEvent,
teamName: unknown,
enabled: unknown
): Promise<IpcResult<void>> => {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
if (typeof enabled !== 'boolean') {
return { success: false, error: 'enabled must be a boolean' };
}
try {
await feature.setMemberLogStreamTracking(vTeam.value!, enabled);
return { success: true };
} catch (error) {
logger.error('Failed to update member log stream tracking', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Failed to update member log stream tracking',
};
}
}
);
}
export function removeMemberLogStreamIpc(ipcMain: IpcMain): void {
ipcMain.removeHandler(MEMBER_LOG_STREAM_GET);
ipcMain.removeHandler(MEMBER_LOG_STREAM_SET_TRACKING);
}

View file

@ -0,0 +1,214 @@
import { applyMemberLogMessageBudget } from '../../../infrastructure/memberLogMessageBudget';
import {
buildMemberActor,
buildMemberParticipant,
buildSegmentId,
normalizeMemberName,
shortHash,
withSegmentSource,
} from './memberLogStreamSourceUtils';
import type { MemberLogStreamWarning } from '../../../../contracts';
import type { LoggerPort } from '../../../../core/application/ports/LoggerPort';
import type {
MemberLogStreamSource,
MemberLogStreamSourceInput,
MemberLogStreamSourceResult,
} from '../../../../core/application/ports/MemberLogStreamSource';
import type { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
import type { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser';
import type {
MemberLogFileRef,
TeamMemberLogsFinder,
} from '@main/services/team/TeamMemberLogsFinder';
import type { ParsedMessage } from '@main/types';
function isPreferredRef(candidate: MemberLogFileRef, existing: MemberLogFileRef): boolean {
const candidateMessageCount = candidate.messageCount ?? -1;
const existingMessageCount = existing.messageCount ?? -1;
if (candidateMessageCount !== existingMessageCount) {
return candidateMessageCount > existingMessageCount;
}
const candidateSize = candidate.sizeBytes ?? -1;
const existingSize = existing.sizeBytes ?? -1;
if (candidateSize !== existingSize) {
return candidateSize > existingSize;
}
return candidate.mtimeMs > existing.mtimeMs;
}
function dedupeMemberLogRefs(refs: readonly MemberLogFileRef[]): MemberLogFileRef[] {
const byFilePath = new Map<string, MemberLogFileRef>();
const bySession = new Map<string, MemberLogFileRef>();
const passthrough: MemberLogFileRef[] = [];
for (const ref of refs) {
if (byFilePath.has(ref.filePath)) continue;
byFilePath.set(ref.filePath, ref);
if (ref.kind === 'lead_session') {
passthrough.push(ref);
continue;
}
const key = `${ref.kind ?? 'unknown'}:${normalizeMemberName(ref.memberName)}:${ref.sessionId}`;
const existing = bySession.get(key);
if (!existing || isPreferredRef(ref, existing)) {
bySession.set(key, ref);
}
}
return [...passthrough, ...bySession.values()].sort((left, right) => {
const byTime = right.mtimeMs - left.mtimeMs;
return byTime !== 0 ? byTime : left.filePath.localeCompare(right.filePath);
});
}
function filterSourceMessageBudget(
messages: readonly ParsedMessage[],
remaining: number
): { messages: ParsedMessage[]; dropped: number; limited: boolean } {
if (remaining <= 0) {
return { messages: [], dropped: messages.length, limited: messages.length > 0 };
}
if (messages.length <= remaining) {
return { messages: [...messages], dropped: 0, limited: false };
}
return {
messages: messages.slice(-remaining),
dropped: messages.length - remaining,
limited: true,
};
}
export class ClaudeMemberTranscriptStreamSource implements MemberLogStreamSource {
readonly provider = 'claude_transcript' as const;
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
private readonly parser: BoardTaskExactLogStrictParser,
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder,
private readonly logger: LoggerPort
) {}
async load(input: MemberLogStreamSourceInput): Promise<MemberLogStreamSourceResult> {
const warnings: MemberLogStreamWarning[] = [];
const refs = await this.logsFinder.findRecentMemberLogFileRefsByMember(
input.teamName,
[input.memberName],
{
mtimeSinceMs: input.sinceMs ?? null,
forceRefresh: input.forceRefresh === true,
}
);
const dedupedRefs = dedupeMemberLogRefs(refs);
const cappedRefs = dedupedRefs.slice(0, input.budget.maxTranscriptFiles);
const droppedRefCount = Math.max(0, dedupedRefs.length - cappedRefs.length);
if (droppedRefCount > 0) {
warnings.push({
code: 'large_log_window_limited',
message: `Showing ${cappedRefs.length} recent transcript files for this member.`,
});
}
const parsedByPath = await this.parser.parseFiles(cappedRefs.map((ref) => ref.filePath));
const participant = buildMemberParticipant(input.memberName);
const segments = [];
let remainingSourceMessages = input.budget.maxSourceMessages;
let includedTranscriptFileCount = 0;
let droppedMessageCount = 0;
let contentLimited = false;
let windowLimited = false;
for (const ref of cappedRefs) {
const parsedMessages = parsedByPath.get(ref.filePath) ?? [];
if (parsedMessages.length === 0) continue;
const sourceBudgeted = filterSourceMessageBudget(parsedMessages, remainingSourceMessages);
remainingSourceMessages -= sourceBudgeted.messages.length;
droppedMessageCount += sourceBudgeted.dropped;
windowLimited = windowLimited || sourceBudgeted.limited;
const budgeted = applyMemberLogMessageBudget(sourceBudgeted.messages, input.budget);
droppedMessageCount += budgeted.droppedMessageCount;
contentLimited = contentLimited || budgeted.contentLimited;
windowLimited = windowLimited || budgeted.segmentWindowLimited;
if (budgeted.messages.length === 0) continue;
const chunks = this.chunkBuilder.buildBundleChunks(budgeted.messages);
if (chunks.length === 0) continue;
const first = budgeted.messages[0];
const last = budgeted.messages[budgeted.messages.length - 1];
if (!first || !last) continue;
includedTranscriptFileCount += 1;
const role = ref.kind === 'lead_session' ? 'lead' : 'member';
segments.push(
withSegmentSource(
{
id: buildSegmentId({
provider: this.provider,
teamName: input.teamName,
memberName: input.memberName,
sessionId: ref.sessionId,
fingerprint: shortHash(`${ref.filePath}:${ref.mtimeMs}:${ref.sizeBytes ?? ''}`),
startTimestamp: first.timestamp.toISOString(),
}),
participantKey: participant.key,
actor: buildMemberActor({
memberName: input.memberName,
sessionId: ref.sessionId,
role,
}),
startTimestamp: first.timestamp.toISOString(),
endTimestamp: last.timestamp.toISOString(),
chunks,
},
{
provider: this.provider,
label: role === 'lead' ? 'Claude lead transcript' : 'Claude transcript',
sessionId: ref.sessionId,
messageCount: budgeted.messages.length,
truncated: budgeted.droppedMessageCount > 0 || budgeted.contentLimited,
}
)
);
}
if (windowLimited) {
warnings.push({
code: 'segment_message_window_limited',
message: 'Some transcript sessions were trimmed to recent messages.',
});
}
if (contentLimited) {
warnings.push({
code: 'message_content_limited',
message: 'Some large message content was truncated before rendering.',
});
}
this.logger.debug?.(
`Claude member log stream ${input.teamName}/${input.memberName}: refs=${refs.length}, segments=${segments.length}`
);
return {
provider: this.provider,
status: segments.length > 0 ? 'included' : 'skipped',
reason: segments.length > 0 ? undefined : 'no_member_transcripts',
participants: segments.length > 0 ? [participant] : [],
segments,
warnings,
metadata: {
scannedTranscriptFileCount: refs.length,
includedTranscriptFileCount,
droppedSegmentCount: droppedRefCount,
droppedMessageCount,
},
};
}
}

View file

@ -0,0 +1,41 @@
import { isLeadMember } from '@shared/utils/leadDetection';
import type {
MemberLogStreamSource,
MemberLogStreamSourceInput,
MemberLogStreamSourceResult,
} from '../../../../core/application/ports/MemberLogStreamSource';
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
export class CodexNativeMemberTraceStreamSource implements MemberLogStreamSource {
readonly provider = 'codex_native_trace' as const;
constructor(private readonly configReader: TeamConfigReader) {}
async load(input: MemberLogStreamSourceInput): Promise<MemberLogStreamSourceResult> {
const config = await this.configReader.getConfig(input.teamName).catch(() => null);
const member = config?.members?.find(
(item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase()
);
const isCodexMember =
member?.providerId === 'codex' ||
member?.providerBackendId === 'codex-native' ||
(member ? false : isLeadMember({ name: input.memberName }));
return {
provider: this.provider,
status: 'skipped',
reason: 'codex_member_wide_not_supported',
participants: [],
segments: [],
warnings: isCodexMember
? [
{
code: 'codex_member_wide_not_supported',
message: 'Codex member-wide native trace is not available in this variant yet.',
},
]
: [],
};
}
}

View file

@ -0,0 +1,230 @@
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { mapOpenCodeRuntimeTranscriptMessagesToParsedMessages } from '@main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper';
import { applyMemberLogMessageBudget } from '../../../infrastructure/memberLogMessageBudget';
import {
buildMemberActor,
buildMemberParticipant,
buildSegmentId,
normalizeMemberName,
withSegmentSource,
} from './memberLogStreamSourceUtils';
import type { MemberLogStreamWarning } from '../../../../contracts';
import type {
MemberLogStreamSource,
MemberLogStreamSourceInput,
MemberLogStreamSourceResult,
} from '../../../../core/application/ports/MemberLogStreamSource';
import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
import type { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
interface BinaryResolverLike {
resolve(): Promise<string | null>;
}
const CACHE_TTL_MS = 1_500;
function classifyOpenCodeError(error: unknown): MemberLogStreamWarning {
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
if (normalized.includes('timed out') || normalized.includes('timeout')) {
return {
code: 'opencode_runtime_timeout',
message: 'OpenCode runtime transcript timed out; showing other member logs only.',
};
}
if (normalized.includes('--lane') || normalized.includes('multiple') || normalized.includes('ambiguous')) {
return {
code: 'opencode_ambiguous_lane',
message: 'OpenCode runtime session is ambiguous without a safe lane id.',
};
}
return {
code: 'opencode_runtime_unavailable',
message: `OpenCode runtime transcript is unavailable: ${message}`,
};
}
export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource {
readonly provider = 'opencode_runtime' as const;
private readonly cache = new Map<string, { expiresAt: number; result: MemberLogStreamSourceResult }>();
private readonly inFlight = new Map<string, Promise<MemberLogStreamSourceResult>>();
constructor(
private readonly runtimeBridge: ClaudeMultimodelBridgeService,
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder,
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver
) {}
async load(input: MemberLogStreamSourceInput): Promise<MemberLogStreamSourceResult> {
const cacheKey = [
input.teamName,
normalizeMemberName(input.memberName),
input.laneId ?? '',
input.budget.openCodeMessageLimit,
].join('::');
if (!input.forceRefresh) {
const cached = this.cache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.result;
}
}
const existing = this.inFlight.get(cacheKey);
if (existing) {
return existing;
}
const promise = this.buildResult(input)
.then((result) => {
this.cache.set(cacheKey, { expiresAt: Date.now() + CACHE_TTL_MS, result });
return result;
})
.finally(() => {
this.inFlight.delete(cacheKey);
});
this.inFlight.set(cacheKey, promise);
return promise;
}
private async buildResult(input: MemberLogStreamSourceInput): Promise<MemberLogStreamSourceResult> {
const binaryPath = await this.binaryResolver.resolve();
if (!binaryPath) {
return this.skipped('opencode_runtime_unavailable', 'OpenCode runtime bridge is unavailable.');
}
try {
const transcript = await this.runtimeBridge.getOpenCodeTranscript(binaryPath, {
teamId: input.teamName,
memberName: input.memberName,
limit: input.budget.openCodeMessageLimit,
laneId: input.laneId,
timeoutMs: input.budget.openCodeTimeoutMs,
});
const projectedMessages = transcript?.logProjection?.messages ?? [];
const parsedMessages = mapOpenCodeRuntimeTranscriptMessagesToParsedMessages(projectedMessages)
.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());
if (parsedMessages.length === 0) {
return {
provider: this.provider,
status: 'skipped',
reason: 'opencode_missing_runtime_session',
participants: [],
segments: [],
warnings: [],
};
}
const budgeted = applyMemberLogMessageBudget(parsedMessages, input.budget);
if (budgeted.messages.length === 0) {
return {
provider: this.provider,
status: 'skipped',
reason: 'opencode_no_renderable_chunks',
participants: [],
segments: [],
warnings: [],
};
}
const chunks = this.chunkBuilder.buildBundleChunks(budgeted.messages);
if (chunks.length === 0) {
return {
provider: this.provider,
status: 'skipped',
reason: 'opencode_no_renderable_chunks',
participants: [],
segments: [],
warnings: [],
};
}
const first = budgeted.messages[0];
const last = budgeted.messages[budgeted.messages.length - 1];
if (!first || !last) {
return this.skipped('opencode_missing_runtime_session', 'OpenCode runtime projection was empty.');
}
const participant = buildMemberParticipant(input.memberName);
const sessionId = transcript?.sessionId ?? first.sessionId ?? `opencode:${normalizeMemberName(input.memberName)}`;
const segment = withSegmentSource(
{
id: buildSegmentId({
provider: this.provider,
teamName: input.teamName,
memberName: input.memberName,
sessionId,
fingerprint: `${sessionId}:${input.laneId ?? ''}:${budgeted.messages.length}`,
startTimestamp: first.timestamp.toISOString(),
}),
participantKey: participant.key,
actor: buildMemberActor({
memberName: input.memberName,
sessionId,
role: 'member',
}),
startTimestamp: first.timestamp.toISOString(),
endTimestamp: last.timestamp.toISOString(),
chunks,
},
{
provider: this.provider,
label: 'OpenCode runtime',
sessionId,
...(input.laneId ? { laneId: input.laneId } : {}),
messageCount: budgeted.messages.length,
truncated:
budgeted.droppedMessageCount > 0 ||
budgeted.segmentWindowLimited ||
budgeted.contentLimited,
}
);
const warnings: MemberLogStreamWarning[] = [];
if (budgeted.segmentWindowLimited) {
warnings.push({
code: 'segment_message_window_limited',
message: 'OpenCode runtime stream was trimmed to recent messages.',
});
}
if (budgeted.contentLimited) {
warnings.push({
code: 'message_content_limited',
message: 'Some large OpenCode runtime content was truncated before rendering.',
});
}
return {
provider: this.provider,
status: 'included',
participants: [participant],
segments: [segment],
warnings,
metadata: {
droppedMessageCount: budgeted.droppedMessageCount,
},
};
} catch (error) {
const warning = classifyOpenCodeError(error);
return this.skipped(warning.code, warning.message, warning);
}
}
private skipped(
code: MemberLogStreamWarning['code'],
reason: string,
warning: MemberLogStreamWarning = { code, message: reason }
): MemberLogStreamSourceResult {
return {
provider: this.provider,
status: 'skipped',
reason,
participants: [],
segments: [],
warnings: [warning],
};
}
}

View file

@ -0,0 +1,272 @@
import { describe, expect, it, vi } from 'vitest';
import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../../../../core/domain/models/MemberLogStreamBudget';
import { ClaudeMemberTranscriptStreamSource } from '../ClaudeMemberTranscriptStreamSource';
import { CodexNativeMemberTraceStreamSource } from '../CodexNativeMemberTraceStreamSource';
import { OpenCodeMemberRuntimeStreamSource } from '../OpenCodeMemberRuntimeStreamSource';
import type { MemberLogStreamSourceInput } from '../../../../../core/application/ports/MemberLogStreamSource';
import type { EnhancedChunk, ParsedMessage } from '@main/types';
function parsedMessage(uuid: string, timestamp: string): ParsedMessage {
return {
uuid,
parentUuid: null,
type: 'assistant',
timestamp: new Date(timestamp),
role: 'assistant',
content: `message ${uuid}`,
isSidechain: true,
isMeta: false,
sessionId: 'session-1',
toolCalls: [],
toolResults: [],
};
}
function fakeChunk(id: string): EnhancedChunk {
return {
id,
chunkType: 'ai',
startTime: new Date('2026-04-04T00:00:00.000Z'),
endTime: new Date('2026-04-04T00:00:01.000Z'),
durationMs: 1_000,
metrics: {
durationMs: 1_000,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
messageCount: 1,
},
responses: [],
processes: [],
sidechainMessages: [],
toolExecutions: [],
semanticSteps: [],
rawMessages: [],
};
}
function sourceInput(overrides: Partial<MemberLogStreamSourceInput> = {}): MemberLogStreamSourceInput {
return {
teamName: 'alpha-team',
memberName: 'alice',
budget: DEFAULT_MEMBER_LOG_STREAM_BUDGET,
...overrides,
};
}
describe('ClaudeMemberTranscriptStreamSource', () => {
it('dedupes cumulative subagent refs by member/session before parsing and keeps path-safe segment ids', async () => {
const parseFiles = vi.fn().mockImplementation(async (paths: string[]) => {
const parsed = new Map<string, ParsedMessage[]>();
parsed.set('/transcripts/larger.jsonl', [
parsedMessage('msg-1', '2026-04-04T00:00:00.000Z'),
parsedMessage('msg-2', '2026-04-04T00:01:00.000Z'),
]);
expect(paths).toEqual(['/transcripts/larger.jsonl']);
return parsed;
});
const chunkBuilder = {
buildBundleChunks: vi.fn(() => [fakeChunk('chunk-1')]),
};
const source = new ClaudeMemberTranscriptStreamSource(
{
findRecentMemberLogFileRefsByMember: vi.fn().mockResolvedValue([
{
memberName: 'alice',
sessionId: 'session-1',
filePath: '/transcripts/smaller.jsonl',
mtimeMs: 10,
sizeBytes: 1_000,
messageCount: 1,
kind: 'subagent',
},
{
memberName: 'alice',
sessionId: 'session-1',
filePath: '/transcripts/larger.jsonl',
mtimeMs: 20,
sizeBytes: 5_000,
messageCount: 10,
kind: 'subagent',
},
]),
} as never,
{ parseFiles } as never,
chunkBuilder as never,
{ warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
);
const result = await source.load(sourceInput());
expect(result.status).toBe('included');
expect(parseFiles).toHaveBeenCalledWith(['/transcripts/larger.jsonl']);
expect(result.segments).toHaveLength(1);
expect(result.segments[0]?.id).not.toContain('/transcripts');
expect(result.segments[0]?.source).toMatchObject({
provider: 'claude_transcript',
sessionId: 'session-1',
messageCount: 2,
});
});
});
describe('OpenCodeMemberRuntimeStreamSource', () => {
it('enforces member message and content budgets before building OpenCode chunks', async () => {
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
sessionId: 'opencode-session',
logProjection: {
messages: [0, 1, 2].map((index) => ({
uuid: `opencode-${index}`,
parentUuid: index === 0 ? null : `opencode-${index - 1}`,
type: 'assistant',
timestamp: `2026-04-04T00:00:0${index}.000Z`,
role: 'assistant',
content: `long OpenCode runtime message ${index} ${'x'.repeat(80)}`,
toolCalls: [],
toolResults: [],
isMeta: false,
sessionId: 'opencode-session',
})),
},
});
const buildBundleChunks = vi.fn((_: ParsedMessage[]) => [
fakeChunk('opencode-budgeted-chunk'),
]);
const source = new OpenCodeMemberRuntimeStreamSource(
{ getOpenCodeTranscript } as never,
{ buildBundleChunks } as never,
{ resolve: vi.fn().mockResolvedValue('/mock/orchestrator') }
);
const result = await source.load(
sourceInput({
budget: {
...DEFAULT_MEMBER_LOG_STREAM_BUDGET,
maxMessagesPerSegment: 2,
maxTotalContentChars: 60,
maxMessageContentChars: 40,
},
})
);
expect(result.status).toBe('included');
expect(result.metadata?.droppedMessageCount).toBe(1);
expect(result.warnings.map((warning) => warning.code)).toEqual(
expect.arrayContaining(['segment_message_window_limited', 'message_content_limited'])
);
expect(result.segments[0]?.source).toMatchObject({
provider: 'opencode_runtime',
messageCount: 2,
truncated: true,
});
expect(buildBundleChunks).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ uuid: 'opencode-1' }),
expect.objectContaining({ uuid: 'opencode-2' }),
])
);
expect(JSON.stringify(buildBundleChunks.mock.calls[0]?.[0])).toContain(
'[content truncated by member log stream budget]'
);
});
it('joins active bridge calls, uses TTL cache, and lets forceRefresh bypass completed cache only', async () => {
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
sessionId: 'opencode-session',
logProjection: {
messages: [
{
uuid: 'opencode-1',
parentUuid: null,
type: 'assistant',
timestamp: '2026-04-04T00:00:00.000Z',
role: 'assistant',
content: 'hello',
toolCalls: [],
toolResults: [],
isMeta: false,
sessionId: 'opencode-session',
},
],
},
});
const source = new OpenCodeMemberRuntimeStreamSource(
{ getOpenCodeTranscript } as never,
{ buildBundleChunks: vi.fn(() => [fakeChunk('opencode-chunk')]) } as never,
{ resolve: vi.fn().mockResolvedValue('/mock/orchestrator') }
);
const input = sourceInput({ laneId: 'secondary:opencode:alice' });
const [first, second] = await Promise.all([source.load(input), source.load(input)]);
expect(first.status).toBe('included');
expect(second.status).toBe('included');
expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1);
await source.load(input);
expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1);
await source.load({ ...input, forceRefresh: true });
expect(getOpenCodeTranscript).toHaveBeenCalledTimes(2);
expect(getOpenCodeTranscript).toHaveBeenLastCalledWith(
'/mock/orchestrator',
expect.objectContaining({
teamId: 'alpha-team',
memberName: 'alice',
laneId: 'secondary:opencode:alice',
timeoutMs: DEFAULT_MEMBER_LOG_STREAM_BUDGET.openCodeTimeoutMs,
})
);
});
it('reports ambiguous OpenCode lane errors as skipped provider warnings', async () => {
const source = new OpenCodeMemberRuntimeStreamSource(
{
getOpenCodeTranscript: vi.fn().mockRejectedValue(new Error('multiple records, pass --lane')),
} as never,
{ buildBundleChunks: vi.fn(() => [fakeChunk('opencode-chunk')]) } as never,
{ resolve: vi.fn().mockResolvedValue('/mock/orchestrator') }
);
const result = await source.load(sourceInput());
expect(result).toMatchObject({
provider: 'opencode_runtime',
status: 'skipped',
warnings: [
{
code: 'opencode_ambiguous_lane',
message: 'OpenCode runtime session is ambiguous without a safe lane id.',
},
],
});
});
});
describe('CodexNativeMemberTraceStreamSource', () => {
it('returns an honest skipped warning for Codex members only', async () => {
const codexSource = new CodexNativeMemberTraceStreamSource({
getConfig: vi.fn().mockResolvedValue({
members: [{ name: 'alice', providerId: 'codex' }],
}),
} as never);
const nonCodexSource = new CodexNativeMemberTraceStreamSource({
getConfig: vi.fn().mockResolvedValue({
members: [{ name: 'alice', providerId: 'opencode' }],
}),
} as never);
await expect(codexSource.load(sourceInput())).resolves.toMatchObject({
status: 'skipped',
warnings: [{ code: 'codex_member_wide_not_supported' }],
});
await expect(nonCodexSource.load(sourceInput())).resolves.toMatchObject({
status: 'skipped',
warnings: [],
});
});
});

View file

@ -0,0 +1,75 @@
import { createHash } from 'crypto';
import type {
MemberLogStreamProvider,
MemberLogStreamSegmentSource,
} from '../../../../contracts';
import type {
BoardTaskLogActor,
BoardTaskLogParticipant,
BoardTaskLogSegment,
} from '@shared/types';
export function normalizeMemberName(value: string): string {
return value.trim().toLowerCase();
}
export function normalizeTeamName(value: string): string {
return value.trim().toLowerCase();
}
export function buildMemberParticipant(
memberName: string,
role: 'member' | 'lead' = 'member'
): BoardTaskLogParticipant {
const isLead = role === 'lead';
return {
key: `member:${normalizeMemberName(memberName)}`,
label: memberName,
role,
isLead,
isSidechain: !isLead,
};
}
export function buildMemberActor(input: {
memberName: string;
sessionId: string;
role?: 'member' | 'lead';
}): BoardTaskLogActor {
const role = input.role ?? 'member';
return {
memberName: input.memberName,
role,
sessionId: input.sessionId,
isSidechain: role !== 'lead',
};
}
export function shortHash(value: string): string {
return createHash('sha256').update(value).digest('hex').slice(0, 12);
}
export function buildSegmentId(input: {
provider: MemberLogStreamProvider;
teamName: string;
memberName: string;
sessionId: string;
fingerprint: string;
startTimestamp: string;
}): string {
return [
input.provider,
normalizeTeamName(input.teamName),
normalizeMemberName(input.memberName),
input.sessionId,
shortHash(`${input.fingerprint}:${input.startTimestamp}`),
].join(':');
}
export function withSegmentSource<T extends BoardTaskLogSegment>(
segment: T,
source: MemberLogStreamSegmentSource
): T & { source: MemberLogStreamSegmentSource } {
return { ...segment, source };
}

View file

@ -0,0 +1,75 @@
import { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
import { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser';
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import { createEmptyMemberLogStreamResponse } from '../../contracts';
import { GetMemberLogStreamUseCase } from '../../core/application/use-cases/GetMemberLogStreamUseCase';
import { SetMemberLogStreamTrackingUseCase } from '../../core/application/use-cases/SetMemberLogStreamTrackingUseCase';
import { ClaudeMemberTranscriptStreamSource } from '../adapters/output/sources/ClaudeMemberTranscriptStreamSource';
import { CodexNativeMemberTraceStreamSource } from '../adapters/output/sources/CodexNativeMemberTraceStreamSource';
import { OpenCodeMemberRuntimeStreamSource } from '../adapters/output/sources/OpenCodeMemberRuntimeStreamSource';
import { isMemberLogStreamReadEnabled } from '../featureGates';
import type { MemberLogStreamResponse } from '../../contracts';
import type { LoggerPort } from '../../core/application/ports/LoggerPort';
import type { MemberLogStreamTrackingPort } from '../../core/application/ports/MemberLogStreamTrackingPort';
import type { GetMemberLogStreamInput } from '../../core/application/use-cases/GetMemberLogStreamUseCase';
import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
import type { TeamLogSourceTracker } from '@main/services/team/TeamLogSourceTracker';
import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
export interface MemberLogStreamFeatureFacade {
getMemberLogStream(input: GetMemberLogStreamInput): Promise<MemberLogStreamResponse>;
setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise<void>;
}
class TeamLogSourceTrackerMemberStreamPort implements MemberLogStreamTrackingPort {
constructor(private readonly tracker: TeamLogSourceTracker) {}
async setTracking(teamName: string, enabled: boolean): Promise<void> {
if (enabled) {
await this.tracker.enableTracking(teamName, 'member_log_stream');
return;
}
await this.tracker.disableTracking(teamName, 'member_log_stream');
}
}
export function createMemberLogStreamFeature(deps: {
logsFinder: TeamMemberLogsFinder;
logSourceTracker: TeamLogSourceTracker;
runtimeBridge: ClaudeMultimodelBridgeService;
configReader?: TeamConfigReader;
logger: LoggerPort;
}): MemberLogStreamFeatureFacade {
const chunkBuilder = new BoardTaskExactLogChunkBuilder();
const sources = [
new ClaudeMemberTranscriptStreamSource(
deps.logsFinder,
new BoardTaskExactLogStrictParser(),
chunkBuilder,
deps.logger
),
new OpenCodeMemberRuntimeStreamSource(deps.runtimeBridge, chunkBuilder),
new CodexNativeMemberTraceStreamSource(deps.configReader ?? new TeamConfigReader()),
];
const getUseCase = new GetMemberLogStreamUseCase({
sources,
clock: { now: () => Date.now() },
logger: deps.logger,
});
const trackingUseCase = new SetMemberLogStreamTrackingUseCase(
new TeamLogSourceTrackerMemberStreamPort(deps.logSourceTracker)
);
return {
getMemberLogStream: async (input) => {
if (!isMemberLogStreamReadEnabled()) {
return createEmptyMemberLogStreamResponse();
}
return getUseCase.execute(input);
},
setMemberLogStreamTracking: (teamName, enabled) =>
trackingUseCase.execute(teamName, enabled),
};
}

View file

@ -0,0 +1,18 @@
function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean {
if (value == null) {
return defaultValue;
}
const normalized = value.trim().toLowerCase();
if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') {
return false;
}
if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') {
return true;
}
return defaultValue;
}
export function isMemberLogStreamReadEnabled(): boolean {
return readEnabledFlag(process.env.CLAUDE_TEAM_MEMBER_LOG_STREAM_READ_ENABLED, true);
}

View file

@ -0,0 +1,8 @@
export {
registerMemberLogStreamIpc,
removeMemberLogStreamIpc,
} from './adapters/input/ipc/registerMemberLogStreamIpc';
export {
createMemberLogStreamFeature,
type MemberLogStreamFeatureFacade,
} from './composition/createMemberLogStreamFeature';

View file

@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../../core/domain/models/MemberLogStreamBudget';
import { applyMemberLogMessageBudget } from '../memberLogMessageBudget';
import type { MemberLogStreamBudget } from '../../../core/domain/models/MemberLogStreamBudget';
import type { ParsedMessage } from '@main/types';
function budget(overrides: Partial<MemberLogStreamBudget>): MemberLogStreamBudget {
return { ...DEFAULT_MEMBER_LOG_STREAM_BUDGET, ...overrides };
}
function message(overrides: Partial<ParsedMessage>): ParsedMessage {
return {
uuid: overrides.uuid ?? 'msg-1',
parentUuid: null,
type: 'assistant',
timestamp: new Date('2026-04-01T00:00:00.000Z'),
content: '',
isSidechain: true,
isMeta: false,
toolCalls: [],
toolResults: [],
...overrides,
};
}
describe('applyMemberLogMessageBudget', () => {
it('truncates oversized toolUseResult content, preserves ids, and reports content limiting', () => {
const result = applyMemberLogMessageBudget(
[
message({
type: 'user',
role: 'user',
isMeta: true,
sourceToolUseID: 'tool-1',
toolUseResult: {
toolUseId: 'tool-1',
content: 'x'.repeat(200),
stdout: 'y'.repeat(200),
},
}),
],
budget({
maxToolResultContentChars: 80,
maxTotalContentChars: 120,
})
);
const toolUseResult = result.messages[0]?.toolUseResult;
expect(result.contentLimited).toBe(true);
expect(toolUseResult?.toolUseId).toBe('tool-1');
expect(String(toolUseResult?.content)).toContain(
'[content truncated by member log stream budget]'
);
expect(String(toolUseResult?.stdout)).toContain(
'[content truncated by member log stream budget]'
);
});
it('drops orphan tool results after window trimming instead of rendering unpaired results', () => {
const result = applyMemberLogMessageBudget(
[
message({
uuid: 'assistant-1',
toolCalls: [{ id: 'tool-1', name: 'Bash', input: {}, isTask: false }],
}),
message({
uuid: 'result-1',
type: 'user',
role: 'user',
isMeta: true,
sourceToolUseID: 'tool-1',
toolResults: [{ toolUseId: 'tool-1', content: 'done', isError: false }],
}),
],
budget({ maxMessagesPerSegment: 1 })
);
expect(result.segmentWindowLimited).toBe(true);
expect(result.messages).toEqual([]);
expect(result.droppedMessageCount).toBe(2);
});
it('keeps JSON-looking output visible when it does not exceed the content budget', () => {
const result = applyMemberLogMessageBudget(
[message({ content: '{"status":"ok","value":42}' })],
budget({
maxMessageContentChars: 1_000,
maxTotalContentChars: 1_000,
})
);
expect(result.contentLimited).toBe(false);
expect(result.messages[0]?.content).toBe('{"status":"ok","value":42}');
});
});

View file

@ -0,0 +1,255 @@
import type { MemberLogStreamBudget } from '../../core/domain/models/MemberLogStreamBudget';
import type { ContentBlock, ParsedMessage, ToolResult, ToolUseResultData } from '@main/types';
export interface MessageBudgetResult {
messages: ParsedMessage[];
droppedMessageCount: number;
segmentWindowLimited: boolean;
contentLimited: boolean;
}
const CONTENT_LIMIT_SUFFIX = '\n\n[content truncated by member log stream budget]';
const TOOL_RESULT_ID_KEYS = new Set([
'id',
'toolUseId',
'tool_use_id',
'sourceToolUseID',
'sourceToolAssistantUUID',
'uuid',
'parentUuid',
]);
function truncateString(value: string, limit: number): { value: string; truncated: boolean } {
if (value.length <= limit) {
return { value, truncated: false };
}
const allowed = Math.max(0, limit - CONTENT_LIMIT_SUFFIX.length);
return { value: `${value.slice(0, allowed)}${CONTENT_LIMIT_SUFFIX}`, truncated: true };
}
function buildAssistantToolUseIds(messages: readonly ParsedMessage[]): Set<string> {
const ids = new Set<string>();
for (const message of messages) {
if (message.type !== 'assistant') {
continue;
}
for (const toolCall of message.toolCalls) {
ids.add(toolCall.id);
}
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === 'tool_use') {
ids.add(block.id);
}
}
}
}
return ids;
}
function dropOrphanToolResults(messages: readonly ParsedMessage[]): ParsedMessage[] {
const assistantToolUseIds = buildAssistantToolUseIds(messages);
return messages.filter((message) => {
if (!message.isMeta && message.toolResults.length === 0 && !message.sourceToolUseID) {
return true;
}
const toolUseIds = [
message.sourceToolUseID,
...message.toolResults.map((toolResult) => toolResult.toolUseId),
].filter((value): value is string => typeof value === 'string' && value.length > 0);
if (toolUseIds.length === 0) {
return true;
}
return toolUseIds.some((toolUseId) => assistantToolUseIds.has(toolUseId));
});
}
function trimMessageWindow(
messages: readonly ParsedMessage[],
maxMessages: number
): { messages: ParsedMessage[]; droppedMessageCount: number; limited: boolean } {
if (messages.length <= maxMessages) {
return { messages: [...messages], droppedMessageCount: 0, limited: false };
}
const sliced = messages.slice(-maxMessages);
const paired = dropOrphanToolResults(sliced);
return {
messages: paired,
droppedMessageCount: messages.length - paired.length,
limited: true,
};
}
function truncateContentBlock(
block: ContentBlock,
budget: MemberLogStreamBudget,
total: { remaining: number }
): { block: ContentBlock; truncated: boolean } {
if (total.remaining <= 0) {
if (block.type === 'text') {
return { block: { ...block, text: CONTENT_LIMIT_SUFFIX.trim() }, truncated: true };
}
if (block.type === 'thinking') {
return { block: { ...block, thinking: CONTENT_LIMIT_SUFFIX.trim() }, truncated: true };
}
if (block.type === 'tool_result') {
return { block: { ...block, content: CONTENT_LIMIT_SUFFIX.trim() }, truncated: true };
}
return { block, truncated: false };
}
if (block.type === 'text') {
const limit = Math.min(budget.maxMessageContentChars, total.remaining);
const truncated = truncateString(block.text, limit);
total.remaining -= truncated.value.length;
return { block: { ...block, text: truncated.value }, truncated: truncated.truncated };
}
if (block.type === 'thinking') {
const limit = Math.min(budget.maxMessageContentChars, total.remaining);
const truncated = truncateString(block.thinking, limit);
total.remaining -= truncated.value.length;
return { block: { ...block, thinking: truncated.value }, truncated: truncated.truncated };
}
if (block.type === 'tool_result') {
if (typeof block.content === 'string') {
const limit = Math.min(budget.maxToolResultContentChars, total.remaining);
const truncated = truncateString(block.content, limit);
total.remaining -= truncated.value.length;
return { block: { ...block, content: truncated.value }, truncated: truncated.truncated };
}
const nested = block.content.map((item) => truncateContentBlock(item, budget, total));
return {
block: { ...block, content: nested.map((item) => item.block) },
truncated: nested.some((item) => item.truncated),
};
}
return { block, truncated: false };
}
function truncateToolResult(
toolResult: ToolResult,
budget: MemberLogStreamBudget,
total: { remaining: number }
): { toolResult: ToolResult; truncated: boolean } {
if (typeof toolResult.content !== 'string') {
return { toolResult, truncated: false };
}
const limit = Math.min(budget.maxToolResultContentChars, Math.max(0, total.remaining));
const truncated = truncateString(toolResult.content, limit);
total.remaining -= truncated.value.length;
return {
toolResult: { ...toolResult, content: truncated.value },
truncated: truncated.truncated,
};
}
function truncateUnknownToolResultValue(
value: unknown,
budget: MemberLogStreamBudget,
total: { remaining: number },
key?: string
): { value: unknown; truncated: boolean } {
if (typeof value === 'string') {
if (key && TOOL_RESULT_ID_KEYS.has(key)) {
return { value, truncated: false };
}
const limit = Math.min(budget.maxToolResultContentChars, Math.max(0, total.remaining));
const truncated = truncateString(value, limit);
total.remaining = Math.max(0, total.remaining - truncated.value.length);
return { value: truncated.value, truncated: truncated.truncated };
}
if (Array.isArray(value)) {
let truncated = false;
const mapped = value.map((item) => {
const result = truncateUnknownToolResultValue(item, budget, total);
truncated = truncated || result.truncated;
return result.value;
});
return { value: mapped, truncated };
}
if (value && typeof value === 'object') {
let truncated = false;
const mapped: Record<string, unknown> = {};
for (const [childKey, childValue] of Object.entries(value)) {
const result = truncateUnknownToolResultValue(childValue, budget, total, childKey);
truncated = truncated || result.truncated;
mapped[childKey] = result.value;
}
return { value: mapped, truncated };
}
return { value, truncated: false };
}
function truncateToolUseResult(
toolUseResult: ToolUseResultData | undefined,
budget: MemberLogStreamBudget,
total: { remaining: number }
): { toolUseResult: ToolUseResultData | undefined; truncated: boolean } {
if (!toolUseResult) {
return { toolUseResult, truncated: false };
}
const result = truncateUnknownToolResultValue(toolUseResult, budget, total);
return {
toolUseResult: result.value as ToolUseResultData,
truncated: result.truncated,
};
}
function truncateMessageContent(
message: ParsedMessage,
budget: MemberLogStreamBudget,
total: { remaining: number }
): { message: ParsedMessage; truncated: boolean } {
let truncated = false;
let content: ParsedMessage['content'];
if (typeof message.content === 'string') {
const limit = Math.min(budget.maxMessageContentChars, Math.max(0, total.remaining));
const result = truncateString(message.content, limit);
total.remaining -= result.value.length;
truncated = result.truncated;
content = result.value;
} else {
const mapped = message.content.map((block) => truncateContentBlock(block, budget, total));
truncated = mapped.some((item) => item.truncated);
content = mapped.map((item) => item.block);
}
const toolResults = message.toolResults.map((toolResult) =>
truncateToolResult(toolResult, budget, total)
);
const toolUseResult = truncateToolUseResult(message.toolUseResult, budget, total);
return {
message: {
...message,
content,
toolResults: toolResults.map((item) => item.toolResult),
...(toolUseResult.toolUseResult ? { toolUseResult: toolUseResult.toolUseResult } : {}),
},
truncated:
truncated ||
toolResults.some((item) => item.truncated) ||
toolUseResult.truncated,
};
}
export function applyMemberLogMessageBudget(
messages: readonly ParsedMessage[],
budget: MemberLogStreamBudget
): MessageBudgetResult {
const windowed = trimMessageWindow(messages, budget.maxMessagesPerSegment);
const total = { remaining: budget.maxTotalContentChars };
const truncated = windowed.messages.map((message) => truncateMessageContent(message, budget, total));
return {
messages: truncated.map((item) => item.message),
droppedMessageCount: windowed.droppedMessageCount,
segmentWindowLimited: windowed.limited,
contentLimited: truncated.some((item) => item.truncated),
};
}

View file

@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
MEMBER_LOG_STREAM_GET,
MEMBER_LOG_STREAM_SET_TRACKING,
} from '../../contracts';
import { createMemberLogStreamBridge } from '../createMemberLogStreamBridge';
const mocks = vi.hoisted(() => ({
ipcRenderer: {
invoke: vi.fn(),
},
}));
vi.mock('electron', () => ({
ipcRenderer: mocks.ipcRenderer,
}));
describe('createMemberLogStreamBridge', () => {
beforeEach(() => {
mocks.ipcRenderer.invoke.mockReset();
});
it('forwards member log stream IPC requests and normalizes response payloads', async () => {
mocks.ipcRenderer.invoke.mockResolvedValueOnce({
success: true,
data: {
participants: [],
segments: [],
generatedAt: '2026-04-02T00:00:00.000Z',
},
});
const bridge = createMemberLogStreamBridge();
const response = await bridge.getMemberLogStream('alpha-team', 'alice', {
limitSegments: 30,
laneId: 'secondary:opencode:alice',
forceRefresh: true,
});
expect(response).toMatchObject({
participants: [],
segments: [],
source: 'member_empty',
generatedAt: '2026-04-02T00:00:00.000Z',
metadata: {
scannedTranscriptFileCount: 0,
includedTranscriptFileCount: 0,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
},
});
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith(
MEMBER_LOG_STREAM_GET,
'alpha-team',
'alice',
{
limitSegments: 30,
laneId: 'secondary:opencode:alice',
forceRefresh: true,
}
);
});
it('forwards tracking calls and throws IPC errors', async () => {
mocks.ipcRenderer.invoke
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ success: false, error: 'bad lane' });
const bridge = createMemberLogStreamBridge();
await expect(bridge.setMemberLogStreamTracking('alpha-team', true)).resolves.toBeUndefined();
await expect(bridge.getMemberLogStream('alpha-team', 'alice')).rejects.toThrow('bad lane');
expect(mocks.ipcRenderer.invoke).toHaveBeenNthCalledWith(
1,
MEMBER_LOG_STREAM_SET_TRACKING,
'alpha-team',
true
);
});
});

View file

@ -0,0 +1,42 @@
import { ipcRenderer } from 'electron';
import {
MEMBER_LOG_STREAM_GET,
MEMBER_LOG_STREAM_SET_TRACKING,
normalizeMemberLogStreamResponse,
} from '../contracts';
import type {
MemberLogStreamApi,
MemberLogStreamRequestOptions,
MemberLogStreamResponse,
} from '../contracts';
import type { IpcResult } from '@shared/types';
async function invokeIpcWithResult<T>(channel: string, ...args: unknown[]): Promise<T> {
const result = (await ipcRenderer.invoke(channel, ...args)) as IpcResult<T>;
if (!result.success) {
throw new Error(result.error ?? 'Unknown error');
}
return result.data as T;
}
export function createMemberLogStreamBridge(): MemberLogStreamApi {
return {
getMemberLogStream: async (
teamName: string,
memberName: string,
options?: MemberLogStreamRequestOptions
): Promise<MemberLogStreamResponse> =>
normalizeMemberLogStreamResponse(
await invokeIpcWithResult<MemberLogStreamResponse>(
MEMBER_LOG_STREAM_GET,
teamName,
memberName,
options
)
),
setMemberLogStreamTracking: (teamName: string, enabled: boolean): Promise<void> =>
invokeIpcWithResult<void>(MEMBER_LOG_STREAM_SET_TRACKING, teamName, enabled),
};
}

View file

@ -0,0 +1 @@
export { createMemberLogStreamBridge } from './createMemberLogStreamBridge';

View file

@ -0,0 +1,78 @@
import { useEffect, useMemo } from 'react';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { useMemberLogStream } from '../hooks/useMemberLogStream';
import { ExecutionLogStreamView } from '../ui/ExecutionLogStreamView';
import type { MemberLogStreamSegment } from '../../contracts';
import type { ResolvedTeamMember } from '@shared/types';
interface MemberLogStreamSectionProps {
teamName: string;
member: ResolvedTeamMember;
enabled?: boolean;
onInitialLoadErrorChange?: (hasError: boolean) => void;
}
function describeMemberStream(): string {
return 'Member-scoped transcript and runtime logs rendered with the same execution-log components used in Task Log Stream.';
}
function getSegmentMetaLabel(segment: MemberLogStreamSegment): string {
const details = [segment.source.label];
if (segment.source.laneId) {
details.push(`lane ${segment.source.laneId}`);
} else if (segment.source.sessionId) {
details.push(`session ${segment.source.sessionId.slice(0, 8)}`);
}
return details.join(' · ');
}
function buildMemberSegmentRenderKey(segment: MemberLogStreamSegment): string {
const firstChunkId = segment.chunks[0]?.id;
return `${segment.id}:${firstChunkId ?? segment.startTimestamp}`;
}
export function MemberLogStreamSection({
teamName,
member,
enabled = true,
onInitialLoadErrorChange,
}: Readonly<MemberLogStreamSectionProps>): React.JSX.Element {
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
const { stream, loading, error } = useMemberLogStream({ teamName, member, enabled });
const hasInitialLoadError = Boolean(error && !stream && !loading);
const boundedHistoryNote = useMemo(() => {
if (!stream) return null;
const isBounded =
stream.truncated ||
stream.warnings.some((warning) => warning.code === 'large_log_window_limited');
return isBounded ? 'Showing a bounded recent member log stream.' : null;
}, [stream]);
useEffect(() => {
onInitialLoadErrorChange?.(hasInitialLoadError);
}, [hasInitialLoadError, onInitialLoadErrorChange]);
return (
<ExecutionLogStreamView
title="Logs"
description={describeMemberStream()}
stream={stream}
loading={loading}
error={error}
teamName={teamName}
teamMembers={teamMembers}
loadingText="Loading member log stream..."
emptyTitle="No log stream entries were found for this member yet."
emptyDescription="Member-scoped transcript or runtime logs will appear here when available."
selectionResetKey={`${teamName}:${member.name}`}
boundedHistoryNote={boundedHistoryNote}
forceSegmentHeaders
buildSegmentRenderKey={buildMemberSegmentRenderKey}
getSegmentMetaLabel={getSegmentMetaLabel}
/>
);
}

View file

@ -0,0 +1,326 @@
import React, { act, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useMemberLogStream } from '../useMemberLogStream';
import type { MemberLogStreamResponse } from '../../../contracts';
import type { ResolvedTeamMember } from '@shared/types';
const apiMock = vi.hoisted(() => ({
memberLogStream: {
getMemberLogStream: vi.fn(),
setMemberLogStreamTracking: vi.fn(),
},
teams: {
onTeamChange: vi.fn(),
},
}));
vi.mock('@renderer/api', () => ({
api: apiMock,
}));
function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
function member(name: string): ResolvedTeamMember {
return {
name,
status: 'idle',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
};
}
function response(generatedAt: string): MemberLogStreamResponse {
return {
participants: [],
defaultFilter: 'all',
segments: [],
source: 'member_empty',
coverage: [],
warnings: [],
truncated: false,
generatedAt,
metadata: {
scannedTranscriptFileCount: 0,
includedTranscriptFileCount: 0,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
},
};
}
const HookProbe = ({
teamName,
selectedMember,
enabled = true,
onState,
}: {
teamName: string;
selectedMember: ResolvedTeamMember;
enabled?: boolean;
onState: (state: ReturnType<typeof useMemberLogStream>) => void;
}): React.JSX.Element | null => {
const state = useMemberLogStream({ teamName, member: selectedMember, enabled });
useEffect(() => {
onState(state);
}, [onState, state]);
return null;
};
describe('useMemberLogStream', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiMock.memberLogStream.getMemberLogStream.mockReset();
apiMock.memberLogStream.setMemberLogStreamTracking.mockReset();
apiMock.memberLogStream.setMemberLogStreamTracking.mockResolvedValue(undefined);
apiMock.teams.onTeamChange.mockReset();
apiMock.teams.onTeamChange.mockReturnValue(() => undefined);
});
afterEach(() => {
document.body.innerHTML = '';
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('does not let an older in-flight member request drive a pending reload after member key changes', async () => {
const aliceLoad = createDeferred<MemberLogStreamResponse>();
const bobLoad = createDeferred<MemberLogStreamResponse>();
apiMock.memberLogStream.getMemberLogStream
.mockReturnValueOnce(aliceLoad.promise)
.mockReturnValueOnce(bobLoad.promise);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onState = vi.fn((_: ReturnType<typeof useMemberLogStream>) => undefined);
const latestState = (): ReturnType<typeof useMemberLogStream> | undefined =>
onState.mock.calls.at(-1)?.[0];
await act(async () => {
root.render(
<HookProbe teamName="alpha-team" selectedMember={member('alice')} onState={onState} />
);
await Promise.resolve();
});
await act(async () => {
root.render(
<HookProbe teamName="alpha-team" selectedMember={member('bob')} onState={onState} />
);
await Promise.resolve();
});
const requestedMembers = apiMock.memberLogStream.getMemberLogStream.mock.calls.map(
(call: unknown[]) => String(call[1])
);
expect(requestedMembers).toEqual(['alice', 'bob']);
await act(async () => {
aliceLoad.resolve(response('2026-04-03T00:00:00.000Z'));
await Promise.resolve();
});
expect(latestState()?.stream).toBeNull();
await act(async () => {
bobLoad.resolve(response('2026-04-03T00:01:00.000Z'));
await Promise.resolve();
});
expect(latestState()?.stream?.generatedAt).toBe('2026-04-03T00:01:00.000Z');
act(() => {
root.unmount();
});
});
it('reloads on same-team log events with forceRefresh only for source changes', async () => {
vi.useFakeTimers();
let teamChangeListener:
| ((event: unknown, data: { teamName: string; type: string }) => void)
| null = null;
apiMock.teams.onTeamChange.mockImplementation((callback) => {
teamChangeListener = callback as typeof teamChangeListener;
return () => undefined;
});
apiMock.memberLogStream.getMemberLogStream
.mockResolvedValueOnce(response('2026-04-03T00:00:00.000Z'))
.mockResolvedValueOnce(response('2026-04-03T00:01:00.000Z'))
.mockResolvedValueOnce(response('2026-04-03T00:02:00.000Z'));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
selectedMember={member('alice')}
onState={() => undefined}
/>
);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1);
await act(async () => {
teamChangeListener?.(null, { teamName: 'other-team', type: 'log-source-change' });
vi.advanceTimersByTime(700);
await Promise.resolve();
});
await act(async () => {
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' });
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1);
await act(async () => {
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'log-source-change' });
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(2);
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenLastCalledWith(
'alpha-team',
'alice',
expect.objectContaining({ forceRefresh: true })
);
await act(async () => {
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'task-log-change' });
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(3);
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenLastCalledWith(
'alpha-team',
'alice',
expect.not.objectContaining({ forceRefresh: true })
);
act(() => {
root.unmount();
});
});
it('releases stale in-flight state when the section is disabled before a request finishes', async () => {
const firstLoad = createDeferred<MemberLogStreamResponse>();
apiMock.memberLogStream.getMemberLogStream
.mockReturnValueOnce(firstLoad.promise)
.mockResolvedValueOnce(response('2026-04-03T00:02:00.000Z'));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onState = vi.fn((_: ReturnType<typeof useMemberLogStream>) => undefined);
const latestState = (): ReturnType<typeof useMemberLogStream> | undefined =>
onState.mock.calls.at(-1)?.[0];
const selectedMember = member('alice');
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
selectedMember={selectedMember}
enabled
onState={onState}
/>
);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1);
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
selectedMember={selectedMember}
enabled={false}
onState={onState}
/>
);
await Promise.resolve();
});
await act(async () => {
firstLoad.resolve(response('2026-04-03T00:01:00.000Z'));
await Promise.resolve();
});
expect(latestState()?.stream).toBeNull();
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
selectedMember={selectedMember}
enabled
onState={onState}
/>
);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(2);
expect(latestState()?.stream?.generatedAt).toBe('2026-04-03T00:02:00.000Z');
act(() => {
root.unmount();
});
});
it('passes an OpenCode lane only for OpenCode-owned members', async () => {
apiMock.memberLogStream.getMemberLogStream.mockResolvedValue(
response('2026-04-03T00:00:00.000Z')
);
const staleLaneMember: ResolvedTeamMember = {
...member('alice'),
providerId: 'anthropic',
laneId: 'secondary:opencode:alice',
laneOwnerProviderId: 'opencode',
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
selectedMember={staleLaneMember}
onState={() => undefined}
/>
);
await Promise.resolve();
});
const request = apiMock.memberLogStream.getMemberLogStream.mock.calls[0] as
| [string, string, { laneId?: unknown }]
| undefined;
expect(request?.[0]).toBe('alpha-team');
expect(request?.[1]).toBe('alice');
expect(request?.[2].laneId).toBeUndefined();
act(() => {
root.unmount();
});
});
});

View file

@ -0,0 +1,197 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api';
import {
type MemberLogStreamRequestOptions,
type MemberLogStreamResponse,
normalizeMemberLogStreamResponse,
} from '../../contracts';
import { normalizeExecutionLogStream } from '../ui/ExecutionLogStreamView';
import type { ResolvedTeamMember } from '@shared/types';
const LIVE_RELOAD_DEBOUNCE_MS = 650;
function getSafeOpenCodeLaneId(member: ResolvedTeamMember): string | undefined {
if (member.providerId !== 'opencode') return undefined;
if (member.laneOwnerProviderId !== 'opencode') return undefined;
const laneId = member.laneId?.trim();
return laneId ? laneId : undefined;
}
export function useMemberLogStream(input: {
teamName: string;
member: ResolvedTeamMember;
enabled?: boolean;
}): {
stream: MemberLogStreamResponse | null;
loading: boolean;
error: string | null;
reload: (options?: { forceRefresh?: boolean; background?: boolean }) => Promise<void>;
} {
const enabled = input.enabled ?? true;
const [stream, setStream] = useState<MemberLogStreamResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const streamRef = useRef<MemberLogStreamResponse | null>(null);
const activeLoadKeyRef = useRef<string | null>(null);
const pendingReloadRef = useRef<{ key: string; forceRefresh?: boolean } | null>(null);
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const requestSeqRef = useRef(0);
const memberName = input.member.name;
const openCodeLaneId = getSafeOpenCodeLaneId(input.member);
const streamKey = `${input.teamName}:${memberName}:${openCodeLaneId ?? ''}`;
useEffect(() => {
streamRef.current = stream;
}, [stream]);
const loadStream = useCallback(
async (options?: { forceRefresh?: boolean; background?: boolean }): Promise<void> => {
if (!enabled) return;
if (activeLoadKeyRef.current === streamKey) {
const existingPending = pendingReloadRef.current;
pendingReloadRef.current = {
key: streamKey,
forceRefresh:
(existingPending?.key === streamKey && existingPending.forceRefresh) ||
options?.forceRefresh,
};
return;
}
activeLoadKeyRef.current = streamKey;
const background = options?.background ?? false;
const hadExistingStream = streamRef.current != null;
const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq;
if (!background) setLoading(true);
setError((prev) => (background ? prev : null));
try {
const requestOptions: MemberLogStreamRequestOptions = {
limitSegments: 30,
...(options?.forceRefresh ? { forceRefresh: true } : {}),
};
if (openCodeLaneId) {
requestOptions.laneId = openCodeLaneId;
}
const response = normalizeExecutionLogStream(
normalizeMemberLogStreamResponse(
await api.memberLogStream.getMemberLogStream(input.teamName, memberName, requestOptions)
)
);
if (requestSeqRef.current !== requestSeq) return;
setStream(response);
setError(null);
} catch (loadError) {
if (requestSeqRef.current !== requestSeq) return;
if (!background || streamRef.current == null) {
setError(
loadError instanceof Error ? loadError.message : 'Failed to load member log stream'
);
setStream(null);
}
} finally {
const isCurrentRequest =
requestSeqRef.current === requestSeq && activeLoadKeyRef.current === streamKey;
if (isCurrentRequest && (!background || !hadExistingStream)) {
setLoading(false);
}
if (isCurrentRequest) {
activeLoadKeyRef.current = null;
}
const pending = pendingReloadRef.current;
if (pending?.key === streamKey) {
pendingReloadRef.current = null;
}
if (isCurrentRequest && pending?.key === streamKey && enabled) {
void loadStream({ background: true, forceRefresh: pending.forceRefresh });
}
}
},
[enabled, input.teamName, memberName, openCodeLaneId, streamKey]
);
useEffect(() => {
requestSeqRef.current += 1;
setStream(null);
streamRef.current = null;
setError(null);
setLoading(enabled);
pendingReloadRef.current = null;
activeLoadKeyRef.current = null;
if (reloadTimerRef.current) {
clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = null;
}
if (enabled) {
void loadStream();
}
}, [enabled, streamKey, loadStream]);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
void api.memberLogStream
.setMemberLogStreamTracking(input.teamName, true)
.catch(() => undefined);
return () => {
if (cancelled) return;
cancelled = true;
void api.memberLogStream
.setMemberLogStreamTracking(input.teamName, false)
.catch(() => undefined);
};
}, [enabled, input.teamName]);
useEffect(() => {
if (!enabled) return;
const scheduleReload = (forceRefresh: boolean): void => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = setTimeout(() => {
reloadTimerRef.current = null;
void loadStream({ background: true, forceRefresh });
}, LIVE_RELOAD_DEBOUNCE_MS);
};
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
if (event.teamName !== input.teamName) return;
if (event.type === 'log-source-change') {
scheduleReload(true);
return;
}
if (event.type === 'task-log-change') {
scheduleReload(false);
}
});
const handleVisibilityChange = (): void => {
if (document.visibilityState === 'visible') scheduleReload(false);
};
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange);
}
return () => {
if (reloadTimerRef.current) {
clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = null;
}
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
if (typeof unsubscribe === 'function') unsubscribe();
};
}, [enabled, input.teamName, loadStream]);
return { stream, loading, error, reload: loadStream };
}

View file

@ -0,0 +1,7 @@
export { MemberLogStreamSection } from './adapters/MemberLogStreamSection';
export {
buildDefaultExecutionSegmentRenderKey,
ExecutionLogStreamView,
normalizeExecutionLogStream,
} from './ui/ExecutionLogStreamView';
export { isMemberLogStreamUiEnabled } from './utils/featureGates';

View file

@ -0,0 +1,369 @@
import { useEffect, useMemo, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import {
getTeamColorSet,
getThemedBadge,
getThemedBorder,
getThemedText,
} from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isLeadMember } from '@shared/utils/leadDetection';
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
import type {
BoardTaskLogActor,
BoardTaskLogParticipant,
BoardTaskLogSegment,
ResolvedTeamMember,
} from '@shared/types';
interface ExecutionLogStreamLike {
participants: BoardTaskLogParticipant[];
defaultFilter: string;
segments: BoardTaskLogSegment[];
}
interface ParticipantVisual {
name: string;
color?: string;
}
export interface ExecutionLogStreamViewProps<TStream extends ExecutionLogStreamLike> {
title: string;
description: string;
stream: TStream | null;
loading: boolean;
error: string | null;
teamName: string;
teamMembers: readonly ResolvedTeamMember[];
loadingText: string;
emptyTitle: string;
emptyDescription: string;
selectionResetKey: string;
boundedHistoryNote?: string | null;
forceSegmentHeaders?: boolean;
buildSegmentRenderKey?: (segment: TStream['segments'][number]) => string;
getSegmentMetaLabel?: (segment: TStream['segments'][number]) => string | null;
}
function formatRelativeTime(isoString: string): string {
const date = new Date(isoString);
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMin / 60);
const diffDays = Math.floor(diffHours / 24);
if (!Number.isFinite(diffMs)) return '--';
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
function actorLabel(actor: BoardTaskLogActor): string {
if (actor.memberName) return actor.memberName;
if (actor.role === 'lead' || actor.isSidechain === false) return 'lead session';
if (actor.agentId) return `member ${actor.agentId.slice(0, 8)}`;
return `member session ${actor.sessionId.slice(0, 8)}`;
}
export function normalizeExecutionLogStream<TStream extends ExecutionLogStreamLike>(
response: TStream
): TStream {
return {
...response,
segments: response.segments.map((segment) => ({
...segment,
chunks: asEnhancedChunkArray(segment.chunks) ?? [],
})),
};
}
export function buildDefaultExecutionSegmentRenderKey(segment: BoardTaskLogSegment): string {
const firstChunkId = segment.chunks[0]?.id;
if (firstChunkId) {
return `${segment.participantKey}:${firstChunkId}`;
}
return `${segment.participantKey}:${segment.startTimestamp}`;
}
function buildParticipantVisualMap(
stream: ExecutionLogStreamLike | null,
members: readonly ResolvedTeamMember[],
memberColorMap: ReadonlyMap<string, string>
): Map<string, ParticipantVisual> {
const visuals = new Map<string, ParticipantVisual>();
const leadMember = members.find((member) => isLeadMember(member));
for (const participant of stream?.participants ?? []) {
const matchingSegment = stream?.segments.find(
(segment) => segment.participantKey === participant.key
);
const name =
matchingSegment?.actor.memberName ??
(participant.isLead ? leadMember?.name : undefined) ??
participant.label;
visuals.set(participant.key, {
name,
color: memberColorMap.get(name) ?? memberColorMap.get(participant.label),
});
}
for (const segment of stream?.segments ?? []) {
if (visuals.has(segment.participantKey)) continue;
const name = segment.actor.memberName ?? actorLabel(segment.actor);
visuals.set(segment.participantKey, { name, color: memberColorMap.get(name) });
}
return visuals;
}
const SegmentMarker = <TSegment extends BoardTaskLogSegment>({
segment,
visual,
teamName,
metaLabel,
}: {
segment: TSegment;
visual?: ParticipantVisual;
teamName: string;
metaLabel?: string | null;
}): React.JSX.Element => (
<div className="mb-2 flex flex-wrap items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{visual ? (
<MemberBadge
name={visual.name}
color={visual.color}
teamName={teamName}
size="xs"
disableHoverCard
/>
) : null}
{metaLabel ? <span>{metaLabel}</span> : null}
<span className="flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(segment.endTimestamp)}
</span>
</div>
);
const SegmentBlock = <TSegment extends BoardTaskLogSegment>({
segment,
showHeader,
teamName,
visual,
metaLabel,
}: {
segment: TSegment;
showHeader: boolean;
teamName: string;
visual?: ParticipantVisual;
metaLabel?: string | null;
}): React.JSX.Element => (
<div className="min-w-0 overflow-hidden">
{showHeader ? (
<SegmentMarker segment={segment} visual={visual} teamName={teamName} metaLabel={metaLabel} />
) : null}
<MemberExecutionLog
chunks={segment.chunks}
memberName={segment.actor.memberName}
memberColor={visual?.color}
teamName={teamName}
hideMemberHeading={showHeader && Boolean(segment.actor.memberName)}
/>
</div>
);
const ParticipantFilterChip = ({
label,
selected,
visual,
teamName,
onClick,
}: {
label: string;
selected: boolean;
visual?: ParticipantVisual;
teamName: string;
onClick: () => void;
}): React.JSX.Element => {
const { isLight } = useTheme();
const colors = getTeamColorSet(visual?.color ?? '');
const borderColor = selected ? getThemedBorder(colors, isLight) : 'var(--color-border)';
const backgroundColor = selected ? getThemedBadge(colors, isLight) : 'transparent';
const textColor = selected ? getThemedText(colors, isLight) : 'var(--color-text-muted)';
return (
<button
type="button"
className="rounded-full border px-2 py-1 text-[11px] transition-colors hover:text-[var(--color-text)]"
style={{ borderColor, backgroundColor, color: textColor }}
onClick={onClick}
>
{visual ? (
<MemberBadge
name={visual.name}
color={visual.color}
teamName={teamName}
size="xs"
disableHoverCard
/>
) : (
label
)}
</button>
);
};
export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
title,
description,
stream,
loading,
error,
teamName,
teamMembers,
loadingText,
emptyTitle,
emptyDescription,
selectionResetKey,
boundedHistoryNote,
forceSegmentHeaders = false,
buildSegmentRenderKey,
getSegmentMetaLabel,
}: Readonly<ExecutionLogStreamViewProps<TStream>>): React.JSX.Element {
const [selectedParticipantKey, setSelectedParticipantKey] = useState<string>('all');
const participants = stream?.participants ?? [];
const memberColorMap = useMemo(() => buildMemberColorMap([...teamMembers]), [teamMembers]);
const participantVisuals = useMemo(
() => buildParticipantVisualMap(stream, teamMembers, memberColorMap),
[memberColorMap, stream, teamMembers]
);
useEffect(() => {
if (!stream) {
setSelectedParticipantKey('all');
return;
}
setSelectedParticipantKey(stream.defaultFilter);
}, [selectionResetKey, stream]);
useEffect(() => {
if (!stream) return;
const availableParticipantKeys = new Set([
'all',
...stream.participants.map((participant) => participant.key),
]);
setSelectedParticipantKey((prev) =>
availableParticipantKeys.has(prev) ? prev : stream.defaultFilter
);
}, [stream]);
const showChips = participants.length > 1;
const visibleSegments = useMemo(() => {
const source = stream?.segments ?? [];
const filtered =
selectedParticipantKey === 'all'
? source
: source.filter((segment) => segment.participantKey === selectedParticipantKey);
return [...filtered].reverse();
}, [selectedParticipantKey, stream?.segments]);
const showSegmentHeaders =
forceSegmentHeaders ||
participants.length > 1 ||
(selectedParticipantKey !== 'all' && visibleSegments.length > 1);
const renderKey = buildSegmentRenderKey ?? buildDefaultExecutionSegmentRenderKey;
if (loading) {
return (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">
{title}
</h4>
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" />
{loadingText}
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">
{title}
</h4>
<div className="flex items-center gap-2 py-4 text-xs text-red-400">
<AlertCircle size={14} />
{error}
</div>
</div>
);
}
return (
<div className="space-y-3">
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">
{title}
</h4>
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
{boundedHistoryNote ? (
<p className="text-[11px] text-amber-300">{boundedHistoryNote}</p>
) : null}
{showChips ? (
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${
selectedParticipantKey === 'all'
? 'bg-[var(--color-accent)]/10 border-[var(--color-accent)] text-[var(--color-text)]'
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
}`}
onClick={() => setSelectedParticipantKey('all')}
>
All
</button>
{participants.map((participant) => (
<ParticipantFilterChip
key={participant.key}
label={participant.label}
selected={selectedParticipantKey === participant.key}
visual={participantVisuals.get(participant.key)}
teamName={teamName}
onClick={() => setSelectedParticipantKey(participant.key)}
/>
))}
</div>
) : null}
{visibleSegments.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--color-text-muted)]">
<FileText size={20} className="mx-auto mb-2 opacity-40" />
{emptyTitle}
<p className="mt-1 text-[10px] opacity-60">{emptyDescription}</p>
</div>
) : (
<div className="space-y-6">
{visibleSegments.map((segment) => (
<SegmentBlock
key={renderKey(segment)}
segment={segment}
showHeader={showSegmentHeaders}
teamName={teamName}
visual={participantVisuals.get(segment.participantKey)}
metaLabel={getSegmentMetaLabel?.(segment)}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,18 @@
function readEnabledFlag(value: unknown, defaultValue: boolean): boolean {
if (typeof value !== 'string') {
return defaultValue;
}
const normalized = value.trim().toLowerCase();
if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') {
return false;
}
if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') {
return true;
}
return defaultValue;
}
export function isMemberLogStreamUiEnabled(): boolean {
return readEnabledFlag(import.meta.env.VITE_MEMBER_LOG_STREAM_UI_ENABLED, true);
}

View file

@ -30,6 +30,11 @@ import {
type CodexModelCatalogFeatureFacade,
createCodexModelCatalogFeature,
} from '@features/codex-model-catalog/main';
import {
createMemberLogStreamFeature,
registerMemberLogStreamIpc,
removeMemberLogStreamIpc,
} from '@features/member-log-stream/main';
import {
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
createMemberWorkSyncFeature,
@ -49,6 +54,7 @@ import {
removeRuntimeProviderManagementIpc,
type RuntimeProviderManagementFeatureFacade,
} from '@features/runtime-provider-management/main';
import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
@ -1138,6 +1144,13 @@ async function initializeServices(): Promise<void> {
undefined,
teamTranscriptSourceLocator
);
const memberLogStreamFeature = createMemberLogStreamFeature({
logsFinder: teamMemberLogsFinder,
logSourceTracker: teamLogSourceTracker,
runtimeBridge: new ClaudeMultimodelBridgeService(),
configReader: taskLogConfigReader,
logger: createLogger('Feature:MemberLogStream'),
});
const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(
teamMemberLogsFinder
);
@ -1483,6 +1496,7 @@ async function initializeServices(): Promise<void> {
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature);
registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature);
registerMemberLogStreamIpc(ipcMain, memberLogStreamFeature);
// Forward SSH state changes to renderer and HTTP SSE clients
sshConnectionManager.on('state-change', (status: unknown) => {
@ -1672,6 +1686,7 @@ async function shutdownServices(): Promise<void> {
removeRecentProjectsIpc(ipcMain);
removeRuntimeProviderManagementIpc(ipcMain);
removeMemberWorkSyncIpc(ipcMain);
removeMemberLogStreamIpc(ipcMain);
});
await runShutdownStep('team backup dispose', () => teamBackupService?.dispose());

View file

@ -925,6 +925,8 @@ export class ClaudeMultimodelBridgeService {
teamId: string;
memberName: string;
limit?: number;
laneId?: string;
timeoutMs?: number;
}
): Promise<OpenCodeRuntimeTranscriptResponse['transcript'] | null> {
const { env } = await this.buildCliEnv(binaryPath);
@ -943,12 +945,15 @@ export class ClaudeMultimodelBridgeService {
if (typeof params.limit === 'number') {
args.push('--limit', String(params.limit));
}
if (typeof params.laneId === 'string' && params.laneId.trim().length > 0) {
args.push('--lane', params.laneId.trim());
}
const outputDir = await mkdtemp(path.join(tmpdir(), 'opencode-transcript-'));
const outputPath = path.join(outputDir, 'transcript.json');
try {
await execCli(binaryPath, [...args, '--output', outputPath], {
timeout: PROVIDER_STATUS_TIMEOUT_MS,
timeout: params.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS,
env,
});
const parsed = extractJsonObject<OpenCodeRuntimeTranscriptResponse>(

View file

@ -39,6 +39,7 @@ export type TeamLogSourceTrackingConsumer =
| 'change_presence'
| 'tool_activity'
| 'task_log_stream'
| 'member_log_stream'
| 'stall_monitor';
interface TrackingState {

View file

@ -111,8 +111,19 @@ export interface MemberLogFileRef {
sessionId: string;
filePath: string;
mtimeMs: number;
sizeBytes?: number;
messageCount?: number;
kind?: 'lead_session' | 'member_session' | 'subagent';
}
type FindRecentMemberLogFileRefsOptions =
| number
| null
| {
mtimeSinceMs?: number | null;
forceRefresh?: boolean;
};
export interface TeamLogSourceLiveContext {
projectDir: string;
projectPath?: string;
@ -935,8 +946,15 @@ export class TeamMemberLogsFinder {
async findRecentMemberLogFileRefsByMember(
teamName: string,
memberNames: readonly string[],
mtimeSinceMs?: number | null
options?: FindRecentMemberLogFileRefsOptions
): Promise<MemberLogFileRef[]> {
const parsedOptions =
typeof options === 'number' || options === null
? { mtimeSinceMs: options ?? null, forceRefresh: false }
: {
mtimeSinceMs: options?.mtimeSinceMs ?? null,
forceRefresh: options?.forceRefresh === true,
};
const requestedMembersByKey = new Map<string, string>();
for (const memberName of memberNames) {
const trimmed = memberName.trim();
@ -952,12 +970,18 @@ export class TeamMemberLogsFinder {
return [];
}
const discovery = await this.discoverProjectSessions(teamName);
const discovery = await this.discoverProjectSessions(teamName, {
forceRefresh: parsedOptions.forceRefresh,
});
if (!discovery) {
return [];
}
const { projectDir, sessionIds, knownMembers, config } = discovery;
const scopedKnownMembers = new Set(knownMembers);
for (const memberKey of requestedMembersByKey.keys()) {
scopedKnownMembers.add(memberKey);
}
const refs: MemberLogFileRef[] = [];
const seenFilePaths = new Set<string>();
const pushRef = (ref: MemberLogFileRef): void => {
@ -975,12 +999,17 @@ export class TeamMemberLogsFinder {
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
try {
const stat = await fs.stat(leadJsonl);
if (stat.isFile()) {
if (
stat.isFile() &&
(parsedOptions.mtimeSinceMs == null || stat.mtimeMs >= parsedOptions.mtimeSinceMs)
) {
pushRef({
memberName: requestedMembersByKey.get(leadKey) ?? leadMemberName,
sessionId: config.leadSessionId,
filePath: leadJsonl,
mtimeMs: stat.mtimeMs,
sizeBytes: stat.size,
kind: 'lead_session',
});
}
} catch {
@ -995,20 +1024,20 @@ export class TeamMemberLogsFinder {
if (!stat.isFile()) {
return null;
}
if (mtimeSinceMs != null && stat.mtimeMs < mtimeSinceMs) {
if (parsedOptions.mtimeSinceMs != null && stat.mtimeMs < parsedOptions.mtimeSinceMs) {
return null;
}
const attribution =
candidate.kind === 'subagent'
? await this.getCachedSubagentAttribution(
candidate.filePath,
knownMembers,
scopedKnownMembers,
stat.mtimeMs
)
: await this.getCachedMemberSessionAttribution(
candidate.filePath,
teamName,
knownMembers,
scopedKnownMembers,
stat.mtimeMs
);
if (!attribution) {
@ -1024,6 +1053,8 @@ export class TeamMemberLogsFinder {
sessionId: candidate.sessionId,
filePath: candidate.filePath,
mtimeMs: stat.mtimeMs,
sizeBytes: stat.size,
kind: candidate.kind,
} satisfies MemberLogFileRef;
} catch {
return null;

View file

@ -0,0 +1,126 @@
import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
import type {
OpenCodeRuntimeTranscriptLogContentBlock,
OpenCodeRuntimeTranscriptLogMessage,
} from '../../../runtime/ClaudeMultimodelBridgeService';
import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types';
function mapOpenCodeContentBlock(
block: OpenCodeRuntimeTranscriptLogContentBlock
): ContentBlock | null {
switch (block.type) {
case 'text': {
const text = sanitizeDisplayContent(block.text);
return text.length > 0 ? { type: 'text', text } : null;
}
case 'thinking':
return {
type: 'thinking',
thinking: block.thinking,
signature: block.signature,
};
case 'tool_use':
return {
type: 'tool_use',
id: block.id,
name: block.name,
input: block.input,
};
case 'tool_result':
return {
type: 'tool_result',
tool_use_id: block.tool_use_id,
content: Array.isArray(block.content)
? block.content
.map(mapOpenCodeContentBlock)
.filter((item): item is ContentBlock => item !== null)
: block.content,
...(block.is_error ? { is_error: true } : {}),
};
default:
return null;
}
}
function buildToolUseResultData(
message: OpenCodeRuntimeTranscriptLogMessage
): ToolUseResultData | undefined {
if (!message.sourceToolUseID || message.toolResults.length !== 1) {
return undefined;
}
const toolResult = message.toolResults[0];
if (!toolResult) {
return undefined;
}
return {
toolUseId: toolResult.toolUseId,
content: toolResult.content,
isError: toolResult.isError,
};
}
export function mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage(
message: OpenCodeRuntimeTranscriptLogMessage
): ParsedMessage | null {
const timestamp = new Date(message.timestamp);
if (Number.isNaN(timestamp.getTime())) {
return null;
}
const normalizedContent: ContentBlock[] | string =
typeof message.content === 'string'
? sanitizeDisplayContent(message.content)
: message.content
.map(mapOpenCodeContentBlock)
.filter((item): item is ContentBlock => item !== null);
const toolCalls = message.toolCalls.map((toolCall) => ({
id: toolCall.id,
name: toolCall.name,
input: toolCall.input,
isTask: toolCall.isTask,
...(toolCall.taskDescription ? { taskDescription: toolCall.taskDescription } : {}),
...(toolCall.taskSubagentType ? { taskSubagentType: toolCall.taskSubagentType } : {}),
}));
const toolResults = message.toolResults.map((toolResult) => ({
toolUseId: toolResult.toolUseId,
content: toolResult.content,
isError: toolResult.isError,
}));
const toolUseResult = buildToolUseResultData(message);
return {
uuid: message.uuid,
parentUuid: message.parentUuid,
type: message.type,
timestamp,
role: message.role,
content: normalizedContent,
model: message.model,
agentName: message.agentName,
isSidechain: true,
isMeta: message.isMeta,
sessionId: message.sessionId,
toolCalls,
toolResults,
...(message.sourceToolUseID ? { sourceToolUseID: message.sourceToolUseID } : {}),
...(message.sourceToolAssistantUUID
? { sourceToolAssistantUUID: message.sourceToolAssistantUUID }
: {}),
...(toolUseResult ? { toolUseResult } : {}),
...(message.subtype ? { subtype: message.subtype } : {}),
...(message.level ? { level: message.level } : {}),
};
}
export function mapOpenCodeRuntimeTranscriptMessagesToParsedMessages(
messages: readonly OpenCodeRuntimeTranscriptLogMessage[]
): ParsedMessage[] {
return messages
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
.filter((message): message is ParsedMessage => message !== null);
}

View file

@ -1,4 +1,3 @@
import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
import { createLogger } from '@shared/utils/logger';
import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService';
@ -7,17 +6,15 @@ import { ClaudeBinaryResolver } from '../../ClaudeBinaryResolver';
import { TeamTaskReader } from '../../TeamTaskReader';
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
import { mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage } from './OpenCodeRuntimeProjectionMapper';
import { OpenCodeTaskLogAttributionStore } from './OpenCodeTaskLogAttributionStore';
import type {
OpenCodeRuntimeTranscriptLogContentBlock,
OpenCodeRuntimeTranscriptLogMessage,
} from '../../../runtime/ClaudeMultimodelBridgeService';
import type { OpenCodeRuntimeTranscriptLogMessage } from '../../../runtime/ClaudeMultimodelBridgeService';
import type {
OpenCodeTaskLogAttributionReader,
OpenCodeTaskLogAttributionRecord,
} from './OpenCodeTaskLogAttributionStore';
import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types';
import type { ParsedMessage } from '@main/types';
import type {
BoardTaskLogActor,
BoardTaskLogParticipant,
@ -431,7 +428,7 @@ function hasForeignTeamTaskMarker(
}
return projectedMessages
.map(toParsedMessage)
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
.filter((message): message is ParsedMessage => message !== null)
.some((message) =>
message.toolCalls.some((toolCall) => {
@ -758,7 +755,7 @@ function buildTaskMarkerProjection(
): TaskMarkerProjection | null {
const parsedMessages = sortParsedMessagesByTime(
projectedMessages
.map(toParsedMessage)
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
.filter((message): message is ParsedMessage => message !== null)
);
const taskRefs = buildTaskRefSet(task);
@ -919,7 +916,7 @@ function filterMessagesForAttribution(
record: OpenCodeTaskLogAttributionRecord
): ParsedMessage[] {
const parsedMessages = messages
.map(toParsedMessage)
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
.filter((message): message is ParsedMessage => message !== null);
const hasMessageBounds = Boolean(record.startMessageUuid || record.endMessageUuid);
@ -936,115 +933,6 @@ function filterMessagesForAttribution(
.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());
}
function mapOpenCodeContentBlock(
block: OpenCodeRuntimeTranscriptLogContentBlock
): ContentBlock | null {
switch (block.type) {
case 'text': {
const text = sanitizeDisplayContent(block.text);
return text.length > 0 ? { type: 'text', text } : null;
}
case 'thinking':
return {
type: 'thinking',
thinking: block.thinking,
signature: block.signature,
};
case 'tool_use':
return {
type: 'tool_use',
id: block.id,
name: block.name,
input: block.input,
};
case 'tool_result':
return {
type: 'tool_result',
tool_use_id: block.tool_use_id,
content: Array.isArray(block.content)
? block.content
.map(mapOpenCodeContentBlock)
.filter((item): item is ContentBlock => item !== null)
: block.content,
...(block.is_error ? { is_error: true } : {}),
};
default:
return null;
}
}
function buildToolUseResultData(
message: OpenCodeRuntimeTranscriptLogMessage
): ToolUseResultData | undefined {
if (!message.sourceToolUseID || message.toolResults.length !== 1) {
return undefined;
}
const toolResult = message.toolResults[0];
if (!toolResult) {
return undefined;
}
return {
toolUseId: toolResult.toolUseId,
content: toolResult.content,
isError: toolResult.isError,
};
}
function toParsedMessage(message: OpenCodeRuntimeTranscriptLogMessage): ParsedMessage | null {
const timestamp = new Date(message.timestamp);
if (Number.isNaN(timestamp.getTime())) {
return null;
}
const normalizedContent: ContentBlock[] | string =
typeof message.content === 'string'
? sanitizeDisplayContent(message.content)
: message.content
.map(mapOpenCodeContentBlock)
.filter((item): item is ContentBlock => item !== null);
const toolCalls = message.toolCalls.map((toolCall) => ({
id: toolCall.id,
name: toolCall.name,
input: toolCall.input,
isTask: toolCall.isTask,
...(toolCall.taskDescription ? { taskDescription: toolCall.taskDescription } : {}),
...(toolCall.taskSubagentType ? { taskSubagentType: toolCall.taskSubagentType } : {}),
}));
const toolResults = message.toolResults.map((toolResult) => ({
toolUseId: toolResult.toolUseId,
content: toolResult.content,
isError: toolResult.isError,
}));
const toolUseResult = buildToolUseResultData(message);
return {
uuid: message.uuid,
parentUuid: message.parentUuid,
type: message.type,
timestamp,
role: message.role,
content: normalizedContent,
model: message.model,
agentName: message.agentName,
isSidechain: true,
isMeta: message.isMeta,
sessionId: message.sessionId,
toolCalls,
toolResults,
...(message.sourceToolUseID ? { sourceToolUseID: message.sourceToolUseID } : {}),
...(message.sourceToolAssistantUUID
? { sourceToolAssistantUUID: message.sourceToolAssistantUUID }
: {}),
...(toolUseResult ? { toolUseResult } : {}),
...(message.subtype ? { subtype: message.subtype } : {}),
...(message.level ? { level: message.level } : {}),
};
}
export class OpenCodeTaskLogStreamSource {
private readonly cache = new Map<
string,
@ -1187,7 +1075,7 @@ export class OpenCodeTaskLogStreamSource {
const filteredMessages =
markerProjection?.messages ??
projectedMessages
.map(toParsedMessage)
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
.filter((message): message is ParsedMessage => message !== null)
.filter((message) => isWithinTimeWindows(message.timestamp, timeWindows))
.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());

View file

@ -1,4 +1,5 @@
import { createCodexAccountBridge } from '@features/codex-account/preload';
import { createMemberLogStreamBridge } from '@features/member-log-stream/preload';
import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload';
import { createRecentProjectsBridge } from '@features/recent-projects/preload';
import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload';
@ -478,6 +479,7 @@ const electronAPI: ElectronAPI = {
...createRecentProjectsBridge(),
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
memberLogStream: createMemberLogStreamBridge(),
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getProjects: () => ipcRenderer.invoke('get-projects'),
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),

View file

@ -6,7 +6,10 @@
* to run in a regular browser connected to an HTTP server.
*/
import { createEmptyMemberLogStreamResponse } from '@features/member-log-stream/contracts';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
import type {
@ -251,6 +254,16 @@ export class HttpAPIClient implements ElectronAPI {
getDashboardRecentProjects = (): Promise<DashboardRecentProjectsPayload> =>
this.get<DashboardRecentProjectsPayload>('/api/dashboard/recent-projects');
memberLogStream: MemberLogStreamApi = {
getMemberLogStream: async () => {
console.warn('[HttpAPIClient] getMemberLogStream is not available in browser mode');
return createEmptyMemberLogStreamResponse();
},
setMemberLogStreamTracking: async () => {
// Not available in browser mode - no-op.
},
};
getProjects = (): Promise<Project[]> => this.get<Project[]>('/api/projects');
getSessions = (projectId: string): Promise<Session[]> =>

View file

@ -1,5 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import {
isMemberLogStreamUiEnabled,
MemberLogStreamSection,
} from '@features/member-log-stream/renderer';
// import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer';
import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
@ -167,6 +171,7 @@ export const MemberDetailDialog = ({
const [activeTab, setActiveTab] = useState<MemberDetailTab>(initialTab);
const [restarting, setRestarting] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
const [showLegacyLogsFallback, setShowLegacyLogsFallback] = useState(false);
const runtimeSummary = useMemo(
() =>
@ -237,6 +242,7 @@ export const MemberDetailDialog = ({
setActiveTab(initialTab);
setRestartError(null);
setRestarting(false);
setShowLegacyLogsFallback(false);
}, [initialTab, member, open]);
const {
@ -246,6 +252,7 @@ export const MemberDetailDialog = ({
} = useMemberStats(teamName, member?.name ?? null);
const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null;
const memberLogStreamEnabled = isMemberLogStreamUiEnabled();
if (!member) return null;
@ -352,7 +359,26 @@ export const MemberDetailDialog = ({
/>
</TabsContent>
<TabsContent value="logs" className="min-w-0 overflow-hidden">
<MemberLogsTab teamName={teamName} memberName={member.name} />
{memberLogStreamEnabled ? (
<div className="space-y-4">
<MemberLogStreamSection
teamName={teamName}
member={member}
enabled={open && activeTab === 'logs'}
onInitialLoadErrorChange={setShowLegacyLogsFallback}
/>
{showLegacyLogsFallback ? (
<div className="rounded-md border border-[var(--color-border)] p-3">
<div className="mb-3 text-xs font-semibold uppercase text-[var(--color-text-muted)]">
Legacy Logs Fallback
</div>
<MemberLogsTab teamName={teamName} memberName={member.name} />
</div>
) : null}
</div>
) : (
<MemberLogsTab teamName={teamName} memberName={member.name} />
)}
</TabsContent>
</Tabs>

View file

@ -1,28 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import {
getTeamColorSet,
getThemedBadge,
getThemedBorder,
getThemedText,
} from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
ExecutionLogStreamView,
normalizeExecutionLogStream,
} from '@features/member-log-stream/renderer';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isLeadMember } from '@shared/utils/leadDetection';
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
import type {
BoardTaskLogActor,
BoardTaskLogSegment,
BoardTaskLogStreamResponse,
ResolvedTeamMember,
} from '@shared/types';
import type { BoardTaskLogStreamResponse } from '@shared/types';
interface TaskLogStreamSectionProps {
teamName: string;
@ -33,54 +19,6 @@ interface TaskLogStreamSectionProps {
const LIVE_RELOAD_DEBOUNCE_MS = 350;
function formatRelativeTime(isoString: string): string {
const date = new Date(isoString);
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMin / 60);
const diffDays = Math.floor(diffHours / 24);
if (!Number.isFinite(diffMs)) return '--';
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
function actorLabel(actor: BoardTaskLogActor): string {
if (actor.memberName) {
return actor.memberName;
}
if (actor.role === 'lead' || actor.isSidechain === false) {
return 'lead session';
}
if (actor.agentId) {
return `member ${actor.agentId.slice(0, 8)}`;
}
return `member session ${actor.sessionId.slice(0, 8)}`;
}
function normalizeResponse(response: BoardTaskLogStreamResponse): BoardTaskLogStreamResponse {
return {
participants: response.participants,
defaultFilter: response.defaultFilter,
source: response.source,
runtimeProjection: response.runtimeProjection,
segments: response.segments.map((segment) => ({
...segment,
chunks: asEnhancedChunkArray(segment.chunks) ?? [],
})),
};
}
function buildStableSegmentRenderKey(segment: BoardTaskLogSegment): string {
const firstChunkId = segment.chunks[0]?.id;
if (firstChunkId) {
return `${segment.participantKey}:${firstChunkId}`;
}
return `${segment.participantKey}:${segment.startTimestamp}`;
}
function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string {
if (stream?.source === 'opencode_runtime_attribution') {
return 'Task-scoped OpenCode runtime logs projected from explicit task attribution into the same execution-log components used in Logs.';
@ -109,142 +47,6 @@ function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string
return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.';
}
interface ParticipantVisual {
name: string;
color?: string;
}
function buildParticipantVisualMap(
stream: BoardTaskLogStreamResponse | null,
members: readonly ResolvedTeamMember[],
memberColorMap: ReadonlyMap<string, string>
): Map<string, ParticipantVisual> {
const visuals = new Map<string, ParticipantVisual>();
const leadMember = members.find((member) => isLeadMember(member));
for (const participant of stream?.participants ?? []) {
const matchingSegment = stream?.segments.find(
(segment) => segment.participantKey === participant.key
);
const name =
matchingSegment?.actor.memberName ??
(participant.isLead ? leadMember?.name : undefined) ??
participant.label;
visuals.set(participant.key, {
name,
color: memberColorMap.get(name) ?? memberColorMap.get(participant.label),
});
}
for (const segment of stream?.segments ?? []) {
if (visuals.has(segment.participantKey)) {
continue;
}
const name = segment.actor.memberName ?? actorLabel(segment.actor);
visuals.set(segment.participantKey, {
name,
color: memberColorMap.get(name),
});
}
return visuals;
}
const SegmentMarker = ({
segment,
visual,
teamName,
}: {
segment: BoardTaskLogSegment;
visual?: ParticipantVisual;
teamName: string;
}): React.JSX.Element => {
return (
<div className="mb-2 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{visual ? (
<MemberBadge
name={visual.name}
color={visual.color}
teamName={teamName}
size="xs"
disableHoverCard
/>
) : null}
<span className="flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(segment.endTimestamp)}
</span>
</div>
);
};
const SegmentBlock = ({
segment,
showHeader,
teamName,
visual,
}: {
segment: BoardTaskLogSegment;
showHeader: boolean;
teamName: string;
visual?: ParticipantVisual;
}): React.JSX.Element => {
return (
<div className="min-w-0 overflow-hidden">
{showHeader ? <SegmentMarker segment={segment} visual={visual} teamName={teamName} /> : null}
<MemberExecutionLog
chunks={segment.chunks}
memberName={segment.actor.memberName}
memberColor={visual?.color}
teamName={teamName}
hideMemberHeading={showHeader && Boolean(segment.actor.memberName)}
/>
</div>
);
};
const ParticipantFilterChip = ({
label,
selected,
visual,
teamName,
onClick,
}: {
label: string;
selected: boolean;
visual?: ParticipantVisual;
teamName: string;
onClick: () => void;
}): React.JSX.Element => {
const { isLight } = useTheme();
const colors = getTeamColorSet(visual?.color ?? '');
const borderColor = selected ? getThemedBorder(colors, isLight) : 'var(--color-border)';
const backgroundColor = selected ? getThemedBadge(colors, isLight) : 'transparent';
const textColor = selected ? getThemedText(colors, isLight) : 'var(--color-text-muted)';
return (
<button
type="button"
className="rounded-full border px-2 py-1 text-[11px] transition-colors hover:text-[var(--color-text)]"
style={{ borderColor, backgroundColor, color: textColor }}
onClick={onClick}
>
{visual ? (
<MemberBadge
name={visual.name}
color={visual.color}
teamName={teamName}
size="xs"
disableHoverCard
/>
) : (
label
)}
</button>
);
};
export const TaskLogStreamSection = ({
teamName,
taskId,
@ -254,7 +56,6 @@ export const TaskLogStreamSection = ({
const [stream, setStream] = useState<BoardTaskLogStreamResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all');
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
const requestSeqRef = useRef(0);
const streamRef = useRef<BoardTaskLogStreamResponse | null>(null);
@ -265,41 +66,25 @@ export const TaskLogStreamSection = ({
}, [stream]);
const loadStream = useCallback(
async (options?: { resetSelection?: boolean; background?: boolean }): Promise<void> => {
const resetSelection = options?.resetSelection ?? false;
async (options?: { background?: boolean }): Promise<void> => {
const background = options?.background ?? false;
const hadExistingStream = streamRef.current != null;
const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq;
if (!background) {
setLoading(true);
}
if (!background) setLoading(true);
setError((prev) => (background ? prev : null));
try {
const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId));
if (requestSeqRef.current !== requestSeq) {
return;
}
const response = normalizeExecutionLogStream(
await api.teams.getTaskLogStream(teamName, taskId)
);
if (requestSeqRef.current !== requestSeq) return;
setStream(response);
setSelectedParticipantKey((prev) => {
if (resetSelection) {
return response.defaultFilter;
}
const availableParticipantKeys = new Set([
'all',
...response.participants.map((participant) => participant.key),
]);
return availableParticipantKeys.has(prev) ? prev : response.defaultFilter;
});
setError(null);
} catch (loadError) {
if (requestSeqRef.current !== requestSeq) {
return;
}
if (requestSeqRef.current !== requestSeq) return;
if (!background || streamRef.current == null) {
setError(
loadError instanceof Error ? loadError.message : 'Failed to load task log stream'
@ -319,13 +104,12 @@ export const TaskLogStreamSection = ({
setStream(null);
streamRef.current = null;
setError(null);
setSelectedParticipantKey('all');
requestSeqRef.current += 1;
if (reloadTimerRef.current) {
clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = null;
}
void loadStream({ resetSelection: true });
void loadStream();
}, [loadStream]);
const previousTaskMetaRef = useRef({ taskId, taskStatus });
@ -334,10 +118,7 @@ export const TaskLogStreamSection = ({
const previousTaskMeta = previousTaskMetaRef.current;
previousTaskMetaRef.current = { taskId, taskStatus };
if (previousTaskMeta.taskId !== taskId) {
return;
}
if (previousTaskMeta.taskId !== taskId) return;
if (
previousTaskMeta.taskStatus === 'in_progress' &&
taskStatus &&
@ -357,12 +138,8 @@ export const TaskLogStreamSection = ({
}
const scheduleReload = (): void => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return;
}
if (reloadTimerRef.current) {
clearTimeout(reloadTimerRef.current);
}
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = setTimeout(() => {
reloadTimerRef.current = null;
void loadStream({ background: true });
@ -370,22 +147,15 @@ export const TaskLogStreamSection = ({
};
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
if (event.teamName !== teamName) {
return;
}
if (event.teamName !== teamName) return;
const shouldReload =
event.type === 'log-source-change' ||
(event.type === 'task-log-change' && event.taskId === taskId);
if (!shouldReload) {
return;
}
scheduleReload();
if (shouldReload) scheduleReload();
});
const handleVisibilityChange = (): void => {
if (document.visibilityState === 'visible') {
scheduleReload();
}
if (document.visibilityState === 'visible') scheduleReload();
};
if (typeof document !== 'undefined') {
@ -400,115 +170,25 @@ export const TaskLogStreamSection = ({
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
if (typeof unsubscribe === 'function') {
unsubscribe();
}
if (typeof unsubscribe === 'function') unsubscribe();
};
}, [liveEnabled, loadStream, taskId, teamName]);
const participants = stream?.participants ?? [];
const memberColorMap = useMemo(() => buildMemberColorMap(teamMembers), [teamMembers]);
const participantVisuals = useMemo(
() => buildParticipantVisualMap(stream, teamMembers, memberColorMap),
[memberColorMap, stream, teamMembers]
);
const showChips = participants.length > 1;
const streamDescription = useMemo(() => describeStreamSource(stream), [stream]);
const visibleSegments = useMemo(() => {
const source = stream?.segments ?? [];
const filtered =
selectedParticipantKey === 'all'
? source
: source.filter((segment) => segment.participantKey === selectedParticipantKey);
return [...filtered].reverse();
}, [selectedParticipantKey, stream?.segments]);
const showSegmentHeaders =
participants.length > 1 || (selectedParticipantKey !== 'all' && visibleSegments.length > 1);
if (loading) {
return (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Task Log Stream
</h4>
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" />
Loading task log stream...
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Task Log Stream
</h4>
<div className="flex items-center gap-2 py-4 text-xs text-red-400">
<AlertCircle size={14} />
{error}
</div>
</div>
);
}
return (
<div className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Task Log Stream
</h4>
<p className="text-xs text-[var(--color-text-muted)]">{streamDescription}</p>
{showChips ? (
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${
selectedParticipantKey === 'all'
? 'bg-[var(--color-accent)]/10 border-[var(--color-accent)] text-[var(--color-text)]'
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
}`}
onClick={() => setSelectedParticipantKey('all')}
>
All
</button>
{participants.map((participant) => (
<ParticipantFilterChip
key={participant.key}
label={participant.label}
selected={selectedParticipantKey === participant.key}
visual={participantVisuals.get(participant.key)}
teamName={teamName}
onClick={() => setSelectedParticipantKey(participant.key)}
/>
))}
</div>
) : null}
{visibleSegments.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--color-text-muted)]">
<FileText size={20} className="mx-auto mb-2 opacity-40" />
No task log stream yet
<p className="mt-1 text-[10px] opacity-60">
Task-linked logs will appear here when transcript metadata or runtime projection is
available.
</p>
</div>
) : (
<div className="space-y-6">
{visibleSegments.map((segment) => (
<SegmentBlock
key={buildStableSegmentRenderKey(segment)}
segment={segment}
showHeader={showSegmentHeaders}
teamName={teamName}
visual={participantVisuals.get(segment.participantKey)}
/>
))}
</div>
)}
</div>
<ExecutionLogStreamView
title="Task Log Stream"
description={streamDescription}
stream={stream}
loading={loading}
error={error}
teamName={teamName}
teamMembers={teamMembers}
loadingText="Loading task log stream..."
emptyTitle="No task log stream yet"
emptyDescription="Task-linked logs will appear here when transcript metadata or runtime projection is available."
selectionResetKey={`${teamName}:${taskId}`}
/>
);
};

View file

@ -98,6 +98,7 @@ import type { TerminalAPI } from './terminal';
import type { TmuxAPI } from './tmux';
import type { WaterfallData } from './visualization';
import type { CodexAccountElectronApi } from '@features/codex-account/contracts';
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
import type {
MemberWorkSyncMetricsRequest,
MemberWorkSyncReportRequest,
@ -904,6 +905,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
// Member actionable-work sync diagnostics API
memberWorkSync: MemberWorkSyncElectronApi;
// Member log stream API
memberLogStream: MemberLogStreamApi;
// tmux runtime diagnostics API
tmux: TmuxAPI;

View file

@ -807,8 +807,14 @@ export interface ResolvedTeamMember {
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string;
effort?: EffortLevel;
selectedFastMode?: TeamFastMode;
resolvedFastMode?: boolean;
laneId?: string;
laneKind?: 'primary' | 'secondary';
laneOwnerProviderId?: TeamProviderId;
cwd?: string;
/** Set only when member's git branch differs from the lead's branch. */
gitBranch?: string;
@ -908,7 +914,13 @@ export interface TeamViewSnapshot {
export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
export type TeamProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
export type TeamProviderBackendId = 'auto' | 'adapter' | 'api' | 'cli-sdk' | 'codex-native';
export type TeamProviderBackendId =
| 'auto'
| 'adapter'
| 'api'
| 'cli-sdk'
| 'codex-native'
| 'opencode-cli';
export type TeamFastMode = 'inherit' | 'on' | 'off';
export interface ProviderModelLaunchIdentity {

View file

@ -954,8 +954,8 @@ describe('ClaudeMultimodelBridgeService', () => {
messageCount: 2,
toolCallCount: 1,
errorCount: 0,
latestAssistantText: '/tmp/project',
latestAssistantPreview: '/tmp/project',
latestAssistantText: '/Users/tester/project',
latestAssistantPreview: '/Users/tester/project',
messages: [],
diagnostics: [],
logProjection: {
@ -1027,6 +1027,65 @@ describe('ClaudeMultimodelBridgeService', () => {
});
});
it('passes OpenCode lane and popup timeout to the runtime transcript command', async () => {
execCliMock.mockImplementation(async (_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
if (
normalizedArgs.startsWith(
'runtime transcript --json --provider opencode --team team-a --member alice --projection-only --limit 20 --lane secondary:opencode:alice --output '
)
) {
const outputIndex = Array.isArray(args) ? args.indexOf('--output') : -1;
const outputPath =
outputIndex >= 0 && Array.isArray(args) ? String(args[outputIndex + 1] ?? '') : '';
await writeFile(
outputPath,
JSON.stringify({
schemaVersion: 1,
providerId: 'opencode',
transcript: {
sessionId: 'session-lane',
durableState: 'idle',
messages: [],
diagnostics: [],
logProjection: {
messages: [],
},
},
}),
'utf8'
);
return Promise.resolve({
stdout: '',
stderr: '',
exitCode: 0,
});
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const transcript = await service.getOpenCodeTranscript('/mock/agent_teams_orchestrator', {
teamId: 'team-a',
memberName: 'alice',
limit: 20,
laneId: ' secondary:opencode:alice ',
timeoutMs: 1_234,
});
expect(transcript?.sessionId).toBe('session-lane');
expect(execCliMock).toHaveBeenCalledWith(
'/mock/agent_teams_orchestrator',
expect.arrayContaining(['--lane', 'secondary:opencode:alice']),
expect.objectContaining({ timeout: 1_234 })
);
});
it('loads a large real OpenCode projection fixture through output-file transcript delivery', async () => {
const fixturePath = path.resolve(
process.cwd(),

View file

@ -35,38 +35,42 @@ describe('TeamMemberLogsFinder', () => {
await fs.mkdir(projectRoot, { recursive: true });
const projectResolver = {
getLiveBaseContext: vi.fn(async () => ({
projectDir: projectRoot,
projectId: '-Users-test-live-context',
config,
})),
getContext: vi.fn(async () => {
throw new Error('broad context must not be used for live tracking');
}),
getLiveBaseContext: vi.fn(() =>
Promise.resolve({
projectDir: projectRoot,
projectId: '-Users-test-live-context',
config,
})
),
getContext: vi.fn(() =>
Promise.reject(new Error('broad context must not be used for live tracking'))
),
};
const launchStateStore = {
read: vi.fn(async () => ({
version: 2,
teamName,
updatedAt: '2026-05-03T12:00:00.000Z',
leadSessionId: 'lead-session',
launchPhase: 'active',
expectedMembers: ['bob'],
members: {
bob: {
name: 'bob',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
runtimeSessionId: 'runtime-bob',
updatedAt: '2026-05-03T12:00:00.000Z',
read: vi.fn(() =>
Promise.resolve({
version: 2,
teamName,
updatedAt: '2026-05-03T12:00:00.000Z',
leadSessionId: 'lead-session',
launchPhase: 'active',
expectedMembers: ['bob'],
members: {
bob: {
name: 'bob',
launchState: 'runtime_pending_bootstrap',
agentToolAccepted: true,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
runtimeSessionId: 'runtime-bob',
updatedAt: '2026-05-03T12:00:00.000Z',
},
},
},
summary: {},
teamLaunchState: 'partial_pending',
})),
summary: {},
teamLaunchState: 'partial_pending',
})
),
};
const finder = new TeamMemberLogsFinder(
@ -418,6 +422,97 @@ describe('TeamMemberLogsFinder', () => {
expect(refs.some((ref) => ref.memberName === 'Tom')).toBe(false);
});
it('applies recent-ref object options to discovery, lead refs, metadata, and requested-member attribution', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'member-stream-ref-options';
const projectPath = '/Users/test/member-stream-ref-options';
const projectId = '-Users-test-member-stream-ref-options';
const leadSessionId = 'lead-session';
const recentSince = Date.now() - 10 * 60_000;
const old = new Date(Date.now() - 30 * 60_000);
const now = new Date();
const projectRoot = path.join(tmpDir, 'projects', projectId);
const subagentsDir = path.join(projectRoot, leadSessionId, 'subagents');
await fs.mkdir(subagentsDir, { recursive: true });
const leadPath = path.join(projectRoot, `${leadSessionId}.jsonl`);
await fs.writeFile(
leadPath,
JSON.stringify({
timestamp: old.toISOString(),
type: 'user',
message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` },
}) + '\n',
'utf8'
);
await fs.utimes(leadPath, old, old);
const zoePath = path.join(subagentsDir, 'agent-zoe.jsonl');
await fs.writeFile(
zoePath,
[
JSON.stringify({
timestamp: now.toISOString(),
type: 'user',
message: {
role: 'user',
content: `You are Zoe, a developer on team "${teamName}" (${teamName}).`,
},
}),
JSON.stringify({
timestamp: now.toISOString(),
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'Ready' }] },
}),
].join('\n') + '\n',
'utf8'
);
await fs.utimes(zoePath, now, now);
const projectResolver = {
getContext: vi.fn(() =>
Promise.resolve({
projectDir: projectRoot,
projectId,
sessionIds: [leadSessionId],
config: {
name: teamName,
projectPath,
leadSessionId,
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
})
),
};
const finder = new TeamMemberLogsFinder(
undefined,
undefined,
undefined,
projectResolver as never
);
const refs = await finder.findRecentMemberLogFileRefsByMember(teamName, ['team-lead', 'Zoe'], {
mtimeSinceMs: recentSince,
forceRefresh: true,
});
expect(projectResolver.getContext).toHaveBeenCalledWith(
teamName,
expect.objectContaining({ forceRefresh: true })
);
expect(refs).toEqual([
expect.objectContaining({
memberName: 'Zoe',
filePath: zoePath,
kind: 'subagent',
sizeBytes: expect.any(Number),
}),
]);
expect(refs.some((ref) => ref.filePath === leadPath)).toBe(false);
});
it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir);
@ -452,18 +547,22 @@ describe('TeamMemberLogsFinder', () => {
await fs.mkdir(path.join(projectRoot, currentSessionId, 'subagents'), { recursive: true });
await fs.mkdir(path.join(projectRoot, oldSessionId, 'subagents'), { recursive: true });
const attributedLog = [
JSON.stringify({
timestamp: '2026-01-01T00:00:01.000Z',
type: 'user',
message: { role: 'user', content: `You are alice, a developer on team "${teamName}" (${teamName}).` },
}),
JSON.stringify({
timestamp: '2026-01-01T00:00:02.000Z',
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] },
}),
].join('\n') + '\n';
const attributedLog =
[
JSON.stringify({
timestamp: '2026-01-01T00:00:01.000Z',
type: 'user',
message: {
role: 'user',
content: `You are alice, a developer on team "${teamName}" (${teamName}).`,
},
}),
JSON.stringify({
timestamp: '2026-01-01T00:00:02.000Z',
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] },
}),
].join('\n') + '\n';
await fs.writeFile(
path.join(projectRoot, currentSessionId, 'subagents', 'agent-current.jsonl'),
@ -1259,7 +1358,11 @@ describe('TeamMemberLogsFinder', () => {
message: {
role: 'assistant',
content: [
{ type: 'tool_use', name: 'TaskUpdate', input: { taskId: '5', status: 'in_progress' } },
{
type: 'tool_use',
name: 'TaskUpdate',
input: { taskId: '5', status: 'in_progress' },
},
],
},
}),
@ -1413,7 +1516,11 @@ describe('TeamMemberLogsFinder', () => {
message: {
role: 'assistant',
content: [
{ type: 'tool_use', name: 'TaskUpdate', input: { taskId: '3', status: 'in_progress' } },
{
type: 'tool_use',
name: 'TaskUpdate',
input: { taskId: '3', status: 'in_progress' },
},
],
},
}),

View file

@ -6,6 +6,10 @@ import { useStore } from '@renderer/store';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
const memberLogStreamMockState = vi.hoisted(() => ({
uiEnabled: true,
}));
vi.mock('@renderer/hooks/useMemberStats', () => ({
useMemberStats: () => ({
stats: null,
@ -110,11 +114,32 @@ vi.mock('@renderer/components/team/members/MemberLogsTab', () => ({
MemberLogsTab: () => React.createElement('div', null, 'logs-tab'),
}));
vi.mock('@features/member-log-stream/renderer', async () => {
const ReactModule = await import('react');
return {
isMemberLogStreamUiEnabled: () => memberLogStreamMockState.uiEnabled,
MemberLogStreamSection: ({
onInitialLoadErrorChange,
}: {
onInitialLoadErrorChange?: (hasError: boolean) => void;
}) =>
ReactModule.createElement(
'button',
{
type: 'button',
onClick: () => onInitialLoadErrorChange?.(true),
},
'member-log-stream'
),
};
});
import { MemberDetailDialog } from '@renderer/components/team/members/MemberDetailDialog';
describe('MemberDetailDialog activity count', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
memberLogStreamMockState.uiEnabled = true;
useStore.setState({
teamMessagesByName: {
'demo-team': {
@ -139,6 +164,98 @@ describe('MemberDetailDialog activity count', () => {
vi.unstubAllGlobals();
});
it('renders legacy member logs directly when the member log stream UI gate is off', async () => {
memberLogStreamMockState.uiEnabled = false;
const member: ResolvedTeamMember = {
name: 'jack',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberDetailDialog, {
open: true,
member,
teamName: 'demo-team',
members: [member],
tasks: [],
initialTab: 'logs',
onClose: () => undefined,
onSendMessage: () => undefined,
onAssignTask: () => undefined,
onTaskClick: () => undefined,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('logs-tab');
expect(host.textContent).not.toContain('member-log-stream');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps the stream visible and renders legacy fallback after an initial stream error', async () => {
const member: ResolvedTeamMember = {
name: 'jack',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberDetailDialog, {
open: true,
member,
teamName: 'demo-team',
members: [member],
tasks: [],
initialTab: 'logs',
onClose: () => undefined,
onSendMessage: () => undefined,
onAssignTask: () => undefined,
onTaskClick: () => undefined,
})
);
await Promise.resolve();
});
const streamButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('member-log-stream')
);
expect(streamButton).not.toBeUndefined();
await act(async () => {
streamButton?.click();
await Promise.resolve();
});
expect(host.textContent).toContain('member-log-stream');
expect(host.textContent).toContain('Legacy Logs Fallback');
expect(host.textContent).toContain('logs-tab');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('counts task comments in the Activity badge even when messageCount is zero', async () => {
const member: ResolvedTeamMember = {
name: 'jack',
@ -348,7 +465,7 @@ describe('MemberDetailDialog activity count', () => {
messageCount: 0,
providerId: 'opencode',
};
const onRestartMember = vi.fn(async () => undefined);
const onRestartMember = vi.fn(() => Promise.resolve(undefined));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
@ -424,7 +541,7 @@ describe('MemberDetailDialog activity count', () => {
messageCount: 0,
providerId: 'opencode',
};
const onRestartMember = vi.fn(async () => undefined);
const onRestartMember = vi.fn(() => Promise.resolve(undefined));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);

View file

@ -0,0 +1,378 @@
/* eslint-disable security/detect-non-literal-fs-filename -- Fixture E2E reads a repo fixture and writes temp JSONL. */
import { readFile, rm, stat, writeFile, mkdtemp } from 'fs/promises';
import os from 'os';
import path from 'path';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { GetMemberLogStreamUseCase } from '../../../../../src/features/member-log-stream/core/application/use-cases/GetMemberLogStreamUseCase';
import {
type MemberLogStreamRequestOptions,
type MemberLogStreamResponse,
} from '../../../../../src/features/member-log-stream/contracts';
import { ClaudeMemberTranscriptStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource';
import { OpenCodeMemberRuntimeStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource';
import { BoardTaskExactLogChunkBuilder } from '../../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
import { BoardTaskExactLogStrictParser } from '../../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser';
import { TooltipProvider } from '../../../../../src/renderer/components/ui/tooltip';
import type { OpenCodeRuntimeTranscriptResponse } from '../../../../../src/main/services/runtime/ClaudeMultimodelBridgeService';
import type { MemberLogFileRef } from '../../../../../src/main/services/team/TeamMemberLogsFinder';
import type { ResolvedTeamMember } from '../../../../../src/shared/types';
const TEAM_NAME = 'relay-works-10';
const MEMBER_NAME = 'jack';
const LANE_ID = 'secondary:opencode:jack';
const GENERATED_AT = '2026-04-24T20:40:00.000Z';
const FIXTURE_PATH = path.resolve(
process.cwd(),
'test/fixtures/team/opencode/relay-works-10-jack-projection-transcript.json'
);
const tempDirs: string[] = [];
const apiState = {
getMemberLogStream:
vi.fn<
(
teamName: string,
memberName: string,
options?: MemberLogStreamRequestOptions
) => Promise<MemberLogStreamResponse>
>(),
setMemberLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
onTeamChange: vi.fn<(callback: (event: unknown, data: unknown) => void) => () => void>(),
};
vi.mock('@renderer/api', () => ({
api: {
memberLogStream: {
getMemberLogStream: (...args: Parameters<typeof apiState.getMemberLogStream>) =>
apiState.getMemberLogStream(...args),
setMemberLogStreamTracking: (
...args: Parameters<typeof apiState.setMemberLogStreamTracking>
) => apiState.setMemberLogStreamTracking(...args),
},
teams: {
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
apiState.onTeamChange(...args),
},
},
}));
import { MemberLogStreamSection } from '../../../../../src/features/member-log-stream/renderer';
function flushMicrotasks(): Promise<void> {
return Promise.resolve();
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
async function waitForText(
host: HTMLElement,
predicate: (text: string) => boolean
): Promise<string> {
let text = '';
for (let attempt = 0; attempt < 25; attempt += 1) {
await act(async () => {
await flushAsyncWork();
});
text = host.textContent ?? '';
if (predicate(text)) {
return text;
}
}
return text;
}
async function loadOpenCodeFixtureTranscript(): Promise<
NonNullable<OpenCodeRuntimeTranscriptResponse['transcript']>
> {
const parsed = JSON.parse(
await readFile(FIXTURE_PATH, 'utf8')
) as OpenCodeRuntimeTranscriptResponse;
if (parsed.providerId !== 'opencode' || !parsed.transcript) {
throw new Error('Invalid OpenCode transcript fixture');
}
return parsed.transcript;
}
async function createClaudeTranscriptRef(): Promise<MemberLogFileRef> {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'member-log-stream-e2e-'));
tempDirs.push(tempDir);
const filePath = path.join(tempDir, 'jack-claude-session.jsonl');
const rows = [
{
parentUuid: null,
isSidechain: true,
userType: 'external',
cwd: '/Users/tester/project',
sessionId: 'claude-session-jack',
version: '1.0.0',
gitBranch: 'main',
agentName: MEMBER_NAME,
type: 'system',
uuid: 'claude-init',
timestamp: '2026-04-24T20:25:00.000Z',
subtype: 'init',
level: 'info',
isMeta: false,
content: 'member session started',
},
{
parentUuid: 'claude-init',
isSidechain: true,
userType: 'external',
cwd: '/Users/tester/project',
sessionId: 'claude-session-jack',
version: '1.0.0',
gitBranch: 'main',
agentName: MEMBER_NAME,
type: 'user',
uuid: 'claude-user-1',
timestamp: '2026-04-24T20:25:01.000Z',
isMeta: false,
message: {
role: 'user',
content: 'Collect member-wide evidence for calculator behavior.',
},
},
{
parentUuid: 'claude-user-1',
isSidechain: true,
userType: 'external',
cwd: '/Users/tester/project',
sessionId: 'claude-session-jack',
version: '1.0.0',
gitBranch: 'main',
agentName: MEMBER_NAME,
type: 'assistant',
uuid: 'claude-assistant-1',
requestId: 'req-claude-1',
timestamp: '2026-04-24T20:25:03.000Z',
message: {
role: 'assistant',
id: 'msg-claude-1',
type: 'message',
model: 'claude-sonnet-4-5-20250929',
content: [
{
type: 'text',
text: 'Member-wide Claude transcript final note for Jack.',
},
],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 12, output_tokens: 16 },
},
},
];
await writeFile(filePath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
const fileStat = await stat(filePath);
return {
memberName: MEMBER_NAME,
sessionId: 'claude-session-jack',
filePath,
mtimeMs: fileStat.mtimeMs,
sizeBytes: fileStat.size,
messageCount: rows.length,
kind: 'subagent',
};
}
async function createFixtureUseCase(): Promise<{
useCase: GetMemberLogStreamUseCase;
getOpenCodeTranscript: ReturnType<typeof vi.fn>;
findRecentMemberLogFileRefsByMember: ReturnType<typeof vi.fn>;
}> {
const claudeRef = await createClaudeTranscriptRef();
const openCodeTranscript = await loadOpenCodeFixtureTranscript();
const findRecentMemberLogFileRefsByMember = vi.fn(() => Promise.resolve([claudeRef]));
const getOpenCodeTranscript = vi.fn(() => Promise.resolve(openCodeTranscript));
const chunkBuilder = new BoardTaskExactLogChunkBuilder();
const sources = [
new ClaudeMemberTranscriptStreamSource(
{ findRecentMemberLogFileRefsByMember } as never,
new BoardTaskExactLogStrictParser(),
chunkBuilder,
{ warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
),
new OpenCodeMemberRuntimeStreamSource(
{ getOpenCodeTranscript } as never,
chunkBuilder,
{ resolve: vi.fn(() => Promise.resolve('/Users/tester/agent_teams_orchestrator')) }
),
];
return {
useCase: new GetMemberLogStreamUseCase({
sources,
clock: { now: () => Date.parse(GENERATED_AT) },
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}),
getOpenCodeTranscript,
findRecentMemberLogFileRefsByMember,
};
}
function createMember(): ResolvedTeamMember {
return {
name: MEMBER_NAME,
status: 'idle',
currentTaskId: null,
taskCount: 2,
lastActiveAt: '2026-04-24T20:34:00.000Z',
messageCount: 12,
color: 'blue',
providerId: 'opencode',
laneId: LANE_ID,
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
};
}
function stubMatchMedia(): void {
const matchMedia = vi.fn((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal('matchMedia', matchMedia);
}
function expectCapturedResponse(
value: MemberLogStreamResponse | null
): MemberLogStreamResponse {
expect(value).not.toBeNull();
return value!;
}
describe('MemberLogStreamSection real fixture e2e', () => {
afterEach(async () => {
document.body.innerHTML = '';
apiState.getMemberLogStream.mockReset();
apiState.setMemberLogStreamTracking.mockReset();
apiState.onTeamChange.mockReset();
vi.unstubAllGlobals();
await Promise.all(
tempDirs.splice(0, tempDirs.length).map((dirPath) =>
rm(dirPath, { recursive: true, force: true })
)
);
});
it('renders member-wide Claude transcript and OpenCode runtime logs through the member Logs UI', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
stubMatchMedia();
apiState.onTeamChange.mockImplementation(() => () => undefined);
apiState.setMemberLogStreamTracking.mockResolvedValue(undefined);
const { useCase, getOpenCodeTranscript, findRecentMemberLogFileRefsByMember } =
await createFixtureUseCase();
const capturedResponseRef: { current: MemberLogStreamResponse | null } = { current: null };
apiState.getMemberLogStream.mockImplementation(async (teamName, memberName, options) => {
const response = await useCase.execute({
teamName,
memberName,
limitSegments: options?.limitSegments,
laneId: options?.laneId,
forceRefresh: options?.forceRefresh,
});
capturedResponseRef.current = response;
return response;
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(
TooltipProvider,
null,
React.createElement(MemberLogStreamSection, {
teamName: TEAM_NAME,
member: createMember(),
})
)
);
await flushMicrotasks();
});
const text = await waitForText(host, (content) =>
content.includes('Member-wide Claude transcript final note for Jack.')
);
expect(text).toContain('Logs');
expect(text).toContain('Member-scoped transcript and runtime logs');
expect(text).toContain('Claude transcript');
expect(text).toContain('OpenCode runtime');
expect(text).toContain('Calculator behavior');
expect(text).toContain('Logic smoke check');
expect(text).toContain('Collect member-wide evidence for calculator behavior.');
const capturedResponse = expectCapturedResponse(capturedResponseRef.current);
expect(capturedResponse).toMatchObject({
source: 'member_mixed_runtime',
defaultFilter: 'member:jack',
generatedAt: GENERATED_AT,
metadata: {
scannedTranscriptFileCount: 1,
includedTranscriptFileCount: 1,
},
});
expect(capturedResponse.coverage).toEqual(
expect.arrayContaining([
{ provider: 'claude_transcript', status: 'included' },
{ provider: 'opencode_runtime', status: 'included' },
])
);
expect(JSON.stringify(capturedResponse.segments)).toContain('Keyboard handlers added');
expect(apiState.getMemberLogStream).toHaveBeenCalledWith(
TEAM_NAME,
MEMBER_NAME,
expect.objectContaining({
limitSegments: 30,
laneId: LANE_ID,
})
);
expect(findRecentMemberLogFileRefsByMember).toHaveBeenCalledWith(
TEAM_NAME,
[MEMBER_NAME],
expect.objectContaining({ forceRefresh: false })
);
expect(getOpenCodeTranscript).toHaveBeenCalledWith(
'/Users/tester/agent_teams_orchestrator',
expect.objectContaining({
teamId: TEAM_NAME,
memberName: MEMBER_NAME,
laneId: LANE_ID,
limit: 400,
timeoutMs: 5_000,
})
);
await act(async () => {
root.unmount();
await flushMicrotasks();
});
expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, true);
expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, false);
});
});