feat: add member log stream v2
This commit is contained in:
parent
f57b1bf18b
commit
fcca3649bf
54 changed files with 7827 additions and 528 deletions
1231
docs/team-management/member-log-stream-v2-implementation-plan.md
Normal file
1231
docs/team-management/member-log-stream-v2-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load diff
2046
docs/team-management/member-log-stream-v2-research-addendum.md
Normal file
2046
docs/team-management/member-log-stream-v2-research-addendum.md
Normal file
File diff suppressed because it is too large
Load diff
10
src/features/member-log-stream/contracts/api.ts
Normal file
10
src/features/member-log-stream/contracts/api.ts
Normal 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>;
|
||||||
|
}
|
||||||
2
src/features/member-log-stream/contracts/channels.ts
Normal file
2
src/features/member-log-stream/contracts/channels.ts
Normal 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';
|
||||||
72
src/features/member-log-stream/contracts/dto.ts
Normal file
72
src/features/member-log-stream/contracts/dto.ts
Normal 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;
|
||||||
|
}
|
||||||
4
src/features/member-log-stream/contracts/index.ts
Normal file
4
src/features/member-log-stream/contracts/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export type * from './api';
|
||||||
|
export * from './channels';
|
||||||
|
export type * from './dto';
|
||||||
|
export * from './normalize';
|
||||||
44
src/features/member-log-stream/contracts/normalize.ts
Normal file
44
src/features/member-log-stream/contracts/normalize.ts
Normal 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 ?? {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface ClockPort {
|
||||||
|
now(): number;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface MemberLogStreamTrackingPort {
|
||||||
|
setTracking(teamName: string, enabled: boolean): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
18
src/features/member-log-stream/main/featureGates.ts
Normal file
18
src/features/member-log-stream/main/featureGates.ts
Normal 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);
|
||||||
|
}
|
||||||
8
src/features/member-log-stream/main/index.ts
Normal file
8
src/features/member-log-stream/main/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export {
|
||||||
|
registerMemberLogStreamIpc,
|
||||||
|
removeMemberLogStreamIpc,
|
||||||
|
} from './adapters/input/ipc/registerMemberLogStreamIpc';
|
||||||
|
export {
|
||||||
|
createMemberLogStreamFeature,
|
||||||
|
type MemberLogStreamFeatureFacade,
|
||||||
|
} from './composition/createMemberLogStreamFeature';
|
||||||
|
|
@ -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}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/features/member-log-stream/preload/index.ts
Normal file
1
src/features/member-log-stream/preload/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { createMemberLogStreamBridge } from './createMemberLogStreamBridge';
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
7
src/features/member-log-stream/renderer/index.ts
Normal file
7
src/features/member-log-stream/renderer/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export { MemberLogStreamSection } from './adapters/MemberLogStreamSection';
|
||||||
|
export {
|
||||||
|
buildDefaultExecutionSegmentRenderKey,
|
||||||
|
ExecutionLogStreamView,
|
||||||
|
normalizeExecutionLogStream,
|
||||||
|
} from './ui/ExecutionLogStreamView';
|
||||||
|
export { isMemberLogStreamUiEnabled } from './utils/featureGates';
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,11 @@ import {
|
||||||
type CodexModelCatalogFeatureFacade,
|
type CodexModelCatalogFeatureFacade,
|
||||||
createCodexModelCatalogFeature,
|
createCodexModelCatalogFeature,
|
||||||
} from '@features/codex-model-catalog/main';
|
} from '@features/codex-model-catalog/main';
|
||||||
|
import {
|
||||||
|
createMemberLogStreamFeature,
|
||||||
|
registerMemberLogStreamIpc,
|
||||||
|
removeMemberLogStreamIpc,
|
||||||
|
} from '@features/member-log-stream/main';
|
||||||
import {
|
import {
|
||||||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||||
createMemberWorkSyncFeature,
|
createMemberWorkSyncFeature,
|
||||||
|
|
@ -49,6 +54,7 @@ import {
|
||||||
removeRuntimeProviderManagementIpc,
|
removeRuntimeProviderManagementIpc,
|
||||||
type RuntimeProviderManagementFeatureFacade,
|
type RuntimeProviderManagementFeatureFacade,
|
||||||
} from '@features/runtime-provider-management/main';
|
} from '@features/runtime-provider-management/main';
|
||||||
|
import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
|
||||||
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
|
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
|
||||||
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
|
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
|
||||||
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
||||||
|
|
@ -1138,6 +1144,13 @@ async function initializeServices(): Promise<void> {
|
||||||
undefined,
|
undefined,
|
||||||
teamTranscriptSourceLocator
|
teamTranscriptSourceLocator
|
||||||
);
|
);
|
||||||
|
const memberLogStreamFeature = createMemberLogStreamFeature({
|
||||||
|
logsFinder: teamMemberLogsFinder,
|
||||||
|
logSourceTracker: teamLogSourceTracker,
|
||||||
|
runtimeBridge: new ClaudeMultimodelBridgeService(),
|
||||||
|
configReader: taskLogConfigReader,
|
||||||
|
logger: createLogger('Feature:MemberLogStream'),
|
||||||
|
});
|
||||||
const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(
|
const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(
|
||||||
teamMemberLogsFinder
|
teamMemberLogsFinder
|
||||||
);
|
);
|
||||||
|
|
@ -1483,6 +1496,7 @@ async function initializeServices(): Promise<void> {
|
||||||
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
||||||
registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature);
|
registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature);
|
||||||
registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature);
|
registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature);
|
||||||
|
registerMemberLogStreamIpc(ipcMain, memberLogStreamFeature);
|
||||||
|
|
||||||
// Forward SSH state changes to renderer and HTTP SSE clients
|
// Forward SSH state changes to renderer and HTTP SSE clients
|
||||||
sshConnectionManager.on('state-change', (status: unknown) => {
|
sshConnectionManager.on('state-change', (status: unknown) => {
|
||||||
|
|
@ -1672,6 +1686,7 @@ async function shutdownServices(): Promise<void> {
|
||||||
removeRecentProjectsIpc(ipcMain);
|
removeRecentProjectsIpc(ipcMain);
|
||||||
removeRuntimeProviderManagementIpc(ipcMain);
|
removeRuntimeProviderManagementIpc(ipcMain);
|
||||||
removeMemberWorkSyncIpc(ipcMain);
|
removeMemberWorkSyncIpc(ipcMain);
|
||||||
|
removeMemberLogStreamIpc(ipcMain);
|
||||||
});
|
});
|
||||||
|
|
||||||
await runShutdownStep('team backup dispose', () => teamBackupService?.dispose());
|
await runShutdownStep('team backup dispose', () => teamBackupService?.dispose());
|
||||||
|
|
|
||||||
|
|
@ -925,6 +925,8 @@ export class ClaudeMultimodelBridgeService {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
laneId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
): Promise<OpenCodeRuntimeTranscriptResponse['transcript'] | null> {
|
): Promise<OpenCodeRuntimeTranscriptResponse['transcript'] | null> {
|
||||||
const { env } = await this.buildCliEnv(binaryPath);
|
const { env } = await this.buildCliEnv(binaryPath);
|
||||||
|
|
@ -943,12 +945,15 @@ export class ClaudeMultimodelBridgeService {
|
||||||
if (typeof params.limit === 'number') {
|
if (typeof params.limit === 'number') {
|
||||||
args.push('--limit', String(params.limit));
|
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 outputDir = await mkdtemp(path.join(tmpdir(), 'opencode-transcript-'));
|
||||||
const outputPath = path.join(outputDir, 'transcript.json');
|
const outputPath = path.join(outputDir, 'transcript.json');
|
||||||
try {
|
try {
|
||||||
await execCli(binaryPath, [...args, '--output', outputPath], {
|
await execCli(binaryPath, [...args, '--output', outputPath], {
|
||||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
timeout: params.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS,
|
||||||
env,
|
env,
|
||||||
});
|
});
|
||||||
const parsed = extractJsonObject<OpenCodeRuntimeTranscriptResponse>(
|
const parsed = extractJsonObject<OpenCodeRuntimeTranscriptResponse>(
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export type TeamLogSourceTrackingConsumer =
|
||||||
| 'change_presence'
|
| 'change_presence'
|
||||||
| 'tool_activity'
|
| 'tool_activity'
|
||||||
| 'task_log_stream'
|
| 'task_log_stream'
|
||||||
|
| 'member_log_stream'
|
||||||
| 'stall_monitor';
|
| 'stall_monitor';
|
||||||
|
|
||||||
interface TrackingState {
|
interface TrackingState {
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,19 @@ export interface MemberLogFileRef {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
mtimeMs: number;
|
mtimeMs: number;
|
||||||
|
sizeBytes?: number;
|
||||||
|
messageCount?: number;
|
||||||
|
kind?: 'lead_session' | 'member_session' | 'subagent';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FindRecentMemberLogFileRefsOptions =
|
||||||
|
| number
|
||||||
|
| null
|
||||||
|
| {
|
||||||
|
mtimeSinceMs?: number | null;
|
||||||
|
forceRefresh?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export interface TeamLogSourceLiveContext {
|
export interface TeamLogSourceLiveContext {
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
|
|
@ -935,8 +946,15 @@ export class TeamMemberLogsFinder {
|
||||||
async findRecentMemberLogFileRefsByMember(
|
async findRecentMemberLogFileRefsByMember(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberNames: readonly string[],
|
memberNames: readonly string[],
|
||||||
mtimeSinceMs?: number | null
|
options?: FindRecentMemberLogFileRefsOptions
|
||||||
): Promise<MemberLogFileRef[]> {
|
): 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>();
|
const requestedMembersByKey = new Map<string, string>();
|
||||||
for (const memberName of memberNames) {
|
for (const memberName of memberNames) {
|
||||||
const trimmed = memberName.trim();
|
const trimmed = memberName.trim();
|
||||||
|
|
@ -952,12 +970,18 @@ export class TeamMemberLogsFinder {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const discovery = await this.discoverProjectSessions(teamName);
|
const discovery = await this.discoverProjectSessions(teamName, {
|
||||||
|
forceRefresh: parsedOptions.forceRefresh,
|
||||||
|
});
|
||||||
if (!discovery) {
|
if (!discovery) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { projectDir, sessionIds, knownMembers, config } = discovery;
|
const { projectDir, sessionIds, knownMembers, config } = discovery;
|
||||||
|
const scopedKnownMembers = new Set(knownMembers);
|
||||||
|
for (const memberKey of requestedMembersByKey.keys()) {
|
||||||
|
scopedKnownMembers.add(memberKey);
|
||||||
|
}
|
||||||
const refs: MemberLogFileRef[] = [];
|
const refs: MemberLogFileRef[] = [];
|
||||||
const seenFilePaths = new Set<string>();
|
const seenFilePaths = new Set<string>();
|
||||||
const pushRef = (ref: MemberLogFileRef): void => {
|
const pushRef = (ref: MemberLogFileRef): void => {
|
||||||
|
|
@ -975,12 +999,17 @@ export class TeamMemberLogsFinder {
|
||||||
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(leadJsonl);
|
const stat = await fs.stat(leadJsonl);
|
||||||
if (stat.isFile()) {
|
if (
|
||||||
|
stat.isFile() &&
|
||||||
|
(parsedOptions.mtimeSinceMs == null || stat.mtimeMs >= parsedOptions.mtimeSinceMs)
|
||||||
|
) {
|
||||||
pushRef({
|
pushRef({
|
||||||
memberName: requestedMembersByKey.get(leadKey) ?? leadMemberName,
|
memberName: requestedMembersByKey.get(leadKey) ?? leadMemberName,
|
||||||
sessionId: config.leadSessionId,
|
sessionId: config.leadSessionId,
|
||||||
filePath: leadJsonl,
|
filePath: leadJsonl,
|
||||||
mtimeMs: stat.mtimeMs,
|
mtimeMs: stat.mtimeMs,
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
kind: 'lead_session',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -995,20 +1024,20 @@ export class TeamMemberLogsFinder {
|
||||||
if (!stat.isFile()) {
|
if (!stat.isFile()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (mtimeSinceMs != null && stat.mtimeMs < mtimeSinceMs) {
|
if (parsedOptions.mtimeSinceMs != null && stat.mtimeMs < parsedOptions.mtimeSinceMs) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const attribution =
|
const attribution =
|
||||||
candidate.kind === 'subagent'
|
candidate.kind === 'subagent'
|
||||||
? await this.getCachedSubagentAttribution(
|
? await this.getCachedSubagentAttribution(
|
||||||
candidate.filePath,
|
candidate.filePath,
|
||||||
knownMembers,
|
scopedKnownMembers,
|
||||||
stat.mtimeMs
|
stat.mtimeMs
|
||||||
)
|
)
|
||||||
: await this.getCachedMemberSessionAttribution(
|
: await this.getCachedMemberSessionAttribution(
|
||||||
candidate.filePath,
|
candidate.filePath,
|
||||||
teamName,
|
teamName,
|
||||||
knownMembers,
|
scopedKnownMembers,
|
||||||
stat.mtimeMs
|
stat.mtimeMs
|
||||||
);
|
);
|
||||||
if (!attribution) {
|
if (!attribution) {
|
||||||
|
|
@ -1024,6 +1053,8 @@ export class TeamMemberLogsFinder {
|
||||||
sessionId: candidate.sessionId,
|
sessionId: candidate.sessionId,
|
||||||
filePath: candidate.filePath,
|
filePath: candidate.filePath,
|
||||||
mtimeMs: stat.mtimeMs,
|
mtimeMs: stat.mtimeMs,
|
||||||
|
sizeBytes: stat.size,
|
||||||
|
kind: candidate.kind,
|
||||||
} satisfies MemberLogFileRef;
|
} satisfies MemberLogFileRef;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService';
|
import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService';
|
||||||
|
|
@ -7,17 +6,15 @@ import { ClaudeBinaryResolver } from '../../ClaudeBinaryResolver';
|
||||||
import { TeamTaskReader } from '../../TeamTaskReader';
|
import { TeamTaskReader } from '../../TeamTaskReader';
|
||||||
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
|
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
|
||||||
|
|
||||||
|
import { mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage } from './OpenCodeRuntimeProjectionMapper';
|
||||||
import { OpenCodeTaskLogAttributionStore } from './OpenCodeTaskLogAttributionStore';
|
import { OpenCodeTaskLogAttributionStore } from './OpenCodeTaskLogAttributionStore';
|
||||||
|
|
||||||
import type {
|
import type { OpenCodeRuntimeTranscriptLogMessage } from '../../../runtime/ClaudeMultimodelBridgeService';
|
||||||
OpenCodeRuntimeTranscriptLogContentBlock,
|
|
||||||
OpenCodeRuntimeTranscriptLogMessage,
|
|
||||||
} from '../../../runtime/ClaudeMultimodelBridgeService';
|
|
||||||
import type {
|
import type {
|
||||||
OpenCodeTaskLogAttributionReader,
|
OpenCodeTaskLogAttributionReader,
|
||||||
OpenCodeTaskLogAttributionRecord,
|
OpenCodeTaskLogAttributionRecord,
|
||||||
} from './OpenCodeTaskLogAttributionStore';
|
} from './OpenCodeTaskLogAttributionStore';
|
||||||
import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types';
|
import type { ParsedMessage } from '@main/types';
|
||||||
import type {
|
import type {
|
||||||
BoardTaskLogActor,
|
BoardTaskLogActor,
|
||||||
BoardTaskLogParticipant,
|
BoardTaskLogParticipant,
|
||||||
|
|
@ -431,7 +428,7 @@ function hasForeignTeamTaskMarker(
|
||||||
}
|
}
|
||||||
|
|
||||||
return projectedMessages
|
return projectedMessages
|
||||||
.map(toParsedMessage)
|
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
|
||||||
.filter((message): message is ParsedMessage => message !== null)
|
.filter((message): message is ParsedMessage => message !== null)
|
||||||
.some((message) =>
|
.some((message) =>
|
||||||
message.toolCalls.some((toolCall) => {
|
message.toolCalls.some((toolCall) => {
|
||||||
|
|
@ -758,7 +755,7 @@ function buildTaskMarkerProjection(
|
||||||
): TaskMarkerProjection | null {
|
): TaskMarkerProjection | null {
|
||||||
const parsedMessages = sortParsedMessagesByTime(
|
const parsedMessages = sortParsedMessagesByTime(
|
||||||
projectedMessages
|
projectedMessages
|
||||||
.map(toParsedMessage)
|
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
|
||||||
.filter((message): message is ParsedMessage => message !== null)
|
.filter((message): message is ParsedMessage => message !== null)
|
||||||
);
|
);
|
||||||
const taskRefs = buildTaskRefSet(task);
|
const taskRefs = buildTaskRefSet(task);
|
||||||
|
|
@ -919,7 +916,7 @@ function filterMessagesForAttribution(
|
||||||
record: OpenCodeTaskLogAttributionRecord
|
record: OpenCodeTaskLogAttributionRecord
|
||||||
): ParsedMessage[] {
|
): ParsedMessage[] {
|
||||||
const parsedMessages = messages
|
const parsedMessages = messages
|
||||||
.map(toParsedMessage)
|
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
|
||||||
.filter((message): message is ParsedMessage => message !== null);
|
.filter((message): message is ParsedMessage => message !== null);
|
||||||
|
|
||||||
const hasMessageBounds = Boolean(record.startMessageUuid || record.endMessageUuid);
|
const hasMessageBounds = Boolean(record.startMessageUuid || record.endMessageUuid);
|
||||||
|
|
@ -936,115 +933,6 @@ function filterMessagesForAttribution(
|
||||||
.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());
|
.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 {
|
export class OpenCodeTaskLogStreamSource {
|
||||||
private readonly cache = new Map<
|
private readonly cache = new Map<
|
||||||
string,
|
string,
|
||||||
|
|
@ -1187,7 +1075,7 @@ export class OpenCodeTaskLogStreamSource {
|
||||||
const filteredMessages =
|
const filteredMessages =
|
||||||
markerProjection?.messages ??
|
markerProjection?.messages ??
|
||||||
projectedMessages
|
projectedMessages
|
||||||
.map(toParsedMessage)
|
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
|
||||||
.filter((message): message is ParsedMessage => message !== null)
|
.filter((message): message is ParsedMessage => message !== null)
|
||||||
.filter((message) => isWithinTimeWindows(message.timestamp, timeWindows))
|
.filter((message) => isWithinTimeWindows(message.timestamp, timeWindows))
|
||||||
.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());
|
.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createCodexAccountBridge } from '@features/codex-account/preload';
|
import { createCodexAccountBridge } from '@features/codex-account/preload';
|
||||||
|
import { createMemberLogStreamBridge } from '@features/member-log-stream/preload';
|
||||||
import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload';
|
import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload';
|
||||||
import { createRecentProjectsBridge } from '@features/recent-projects/preload';
|
import { createRecentProjectsBridge } from '@features/recent-projects/preload';
|
||||||
import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload';
|
import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload';
|
||||||
|
|
@ -478,6 +479,7 @@ const electronAPI: ElectronAPI = {
|
||||||
...createRecentProjectsBridge(),
|
...createRecentProjectsBridge(),
|
||||||
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
|
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
|
||||||
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
|
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
|
||||||
|
memberLogStream: createMemberLogStreamBridge(),
|
||||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
getProjects: () => ipcRenderer.invoke('get-projects'),
|
getProjects: () => ipcRenderer.invoke('get-projects'),
|
||||||
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
|
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@
|
||||||
* to run in a regular browser connected to an HTTP server.
|
* 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 { 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 { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
|
||||||
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
|
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -251,6 +254,16 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
getDashboardRecentProjects = (): Promise<DashboardRecentProjectsPayload> =>
|
getDashboardRecentProjects = (): Promise<DashboardRecentProjectsPayload> =>
|
||||||
this.get<DashboardRecentProjectsPayload>('/api/dashboard/recent-projects');
|
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');
|
getProjects = (): Promise<Project[]> => this.get<Project[]>('/api/projects');
|
||||||
|
|
||||||
getSessions = (projectId: string): Promise<Session[]> =>
|
getSessions = (projectId: string): Promise<Session[]> =>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isMemberLogStreamUiEnabled,
|
||||||
|
MemberLogStreamSection,
|
||||||
|
} from '@features/member-log-stream/renderer';
|
||||||
// import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer';
|
// import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||||
|
|
@ -167,6 +171,7 @@ export const MemberDetailDialog = ({
|
||||||
const [activeTab, setActiveTab] = useState<MemberDetailTab>(initialTab);
|
const [activeTab, setActiveTab] = useState<MemberDetailTab>(initialTab);
|
||||||
const [restarting, setRestarting] = useState(false);
|
const [restarting, setRestarting] = useState(false);
|
||||||
const [restartError, setRestartError] = useState<string | null>(null);
|
const [restartError, setRestartError] = useState<string | null>(null);
|
||||||
|
const [showLegacyLogsFallback, setShowLegacyLogsFallback] = useState(false);
|
||||||
|
|
||||||
const runtimeSummary = useMemo(
|
const runtimeSummary = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -237,6 +242,7 @@ export const MemberDetailDialog = ({
|
||||||
setActiveTab(initialTab);
|
setActiveTab(initialTab);
|
||||||
setRestartError(null);
|
setRestartError(null);
|
||||||
setRestarting(false);
|
setRestarting(false);
|
||||||
|
setShowLegacyLogsFallback(false);
|
||||||
}, [initialTab, member, open]);
|
}, [initialTab, member, open]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -246,6 +252,7 @@ export const MemberDetailDialog = ({
|
||||||
} = useMemberStats(teamName, member?.name ?? null);
|
} = useMemberStats(teamName, member?.name ?? null);
|
||||||
|
|
||||||
const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null;
|
const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null;
|
||||||
|
const memberLogStreamEnabled = isMemberLogStreamUiEnabled();
|
||||||
|
|
||||||
if (!member) return null;
|
if (!member) return null;
|
||||||
|
|
||||||
|
|
@ -352,7 +359,26 @@ export const MemberDetailDialog = ({
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="logs" className="min-w-0 overflow-hidden">
|
<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>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,14 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 {
|
import {
|
||||||
getTeamColorSet,
|
ExecutionLogStreamView,
|
||||||
getThemedBadge,
|
normalizeExecutionLogStream,
|
||||||
getThemedBorder,
|
} from '@features/member-log-stream/renderer';
|
||||||
getThemedText,
|
import { api } from '@renderer/api';
|
||||||
} from '@renderer/constants/teamColors';
|
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
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 {
|
import type { BoardTaskLogStreamResponse } from '@shared/types';
|
||||||
BoardTaskLogActor,
|
|
||||||
BoardTaskLogSegment,
|
|
||||||
BoardTaskLogStreamResponse,
|
|
||||||
ResolvedTeamMember,
|
|
||||||
} from '@shared/types';
|
|
||||||
|
|
||||||
interface TaskLogStreamSectionProps {
|
interface TaskLogStreamSectionProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -33,54 +19,6 @@ interface TaskLogStreamSectionProps {
|
||||||
|
|
||||||
const LIVE_RELOAD_DEBOUNCE_MS = 350;
|
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 {
|
function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string {
|
||||||
if (stream?.source === 'opencode_runtime_attribution') {
|
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.';
|
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.';
|
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 = ({
|
export const TaskLogStreamSection = ({
|
||||||
teamName,
|
teamName,
|
||||||
taskId,
|
taskId,
|
||||||
|
|
@ -254,7 +56,6 @@ export const TaskLogStreamSection = ({
|
||||||
const [stream, setStream] = useState<BoardTaskLogStreamResponse | null>(null);
|
const [stream, setStream] = useState<BoardTaskLogStreamResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all');
|
|
||||||
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
|
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
|
||||||
const requestSeqRef = useRef(0);
|
const requestSeqRef = useRef(0);
|
||||||
const streamRef = useRef<BoardTaskLogStreamResponse | null>(null);
|
const streamRef = useRef<BoardTaskLogStreamResponse | null>(null);
|
||||||
|
|
@ -265,41 +66,25 @@ export const TaskLogStreamSection = ({
|
||||||
}, [stream]);
|
}, [stream]);
|
||||||
|
|
||||||
const loadStream = useCallback(
|
const loadStream = useCallback(
|
||||||
async (options?: { resetSelection?: boolean; background?: boolean }): Promise<void> => {
|
async (options?: { background?: boolean }): Promise<void> => {
|
||||||
const resetSelection = options?.resetSelection ?? false;
|
|
||||||
const background = options?.background ?? false;
|
const background = options?.background ?? false;
|
||||||
const hadExistingStream = streamRef.current != null;
|
const hadExistingStream = streamRef.current != null;
|
||||||
const requestSeq = requestSeqRef.current + 1;
|
const requestSeq = requestSeqRef.current + 1;
|
||||||
requestSeqRef.current = requestSeq;
|
requestSeqRef.current = requestSeq;
|
||||||
|
|
||||||
if (!background) {
|
if (!background) setLoading(true);
|
||||||
setLoading(true);
|
|
||||||
}
|
|
||||||
setError((prev) => (background ? prev : null));
|
setError((prev) => (background ? prev : null));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId));
|
const response = normalizeExecutionLogStream(
|
||||||
if (requestSeqRef.current !== requestSeq) {
|
await api.teams.getTaskLogStream(teamName, taskId)
|
||||||
return;
|
);
|
||||||
}
|
if (requestSeqRef.current !== requestSeq) return;
|
||||||
|
|
||||||
setStream(response);
|
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);
|
setError(null);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (requestSeqRef.current !== requestSeq) {
|
if (requestSeqRef.current !== requestSeq) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!background || streamRef.current == null) {
|
if (!background || streamRef.current == null) {
|
||||||
setError(
|
setError(
|
||||||
loadError instanceof Error ? loadError.message : 'Failed to load task log stream'
|
loadError instanceof Error ? loadError.message : 'Failed to load task log stream'
|
||||||
|
|
@ -319,13 +104,12 @@ export const TaskLogStreamSection = ({
|
||||||
setStream(null);
|
setStream(null);
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
setError(null);
|
setError(null);
|
||||||
setSelectedParticipantKey('all');
|
|
||||||
requestSeqRef.current += 1;
|
requestSeqRef.current += 1;
|
||||||
if (reloadTimerRef.current) {
|
if (reloadTimerRef.current) {
|
||||||
clearTimeout(reloadTimerRef.current);
|
clearTimeout(reloadTimerRef.current);
|
||||||
reloadTimerRef.current = null;
|
reloadTimerRef.current = null;
|
||||||
}
|
}
|
||||||
void loadStream({ resetSelection: true });
|
void loadStream();
|
||||||
}, [loadStream]);
|
}, [loadStream]);
|
||||||
|
|
||||||
const previousTaskMetaRef = useRef({ taskId, taskStatus });
|
const previousTaskMetaRef = useRef({ taskId, taskStatus });
|
||||||
|
|
@ -334,10 +118,7 @@ export const TaskLogStreamSection = ({
|
||||||
const previousTaskMeta = previousTaskMetaRef.current;
|
const previousTaskMeta = previousTaskMetaRef.current;
|
||||||
previousTaskMetaRef.current = { taskId, taskStatus };
|
previousTaskMetaRef.current = { taskId, taskStatus };
|
||||||
|
|
||||||
if (previousTaskMeta.taskId !== taskId) {
|
if (previousTaskMeta.taskId !== taskId) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
previousTaskMeta.taskStatus === 'in_progress' &&
|
previousTaskMeta.taskStatus === 'in_progress' &&
|
||||||
taskStatus &&
|
taskStatus &&
|
||||||
|
|
@ -357,12 +138,8 @@ export const TaskLogStreamSection = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleReload = (): void => {
|
const scheduleReload = (): void => {
|
||||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||||
return;
|
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
||||||
}
|
|
||||||
if (reloadTimerRef.current) {
|
|
||||||
clearTimeout(reloadTimerRef.current);
|
|
||||||
}
|
|
||||||
reloadTimerRef.current = setTimeout(() => {
|
reloadTimerRef.current = setTimeout(() => {
|
||||||
reloadTimerRef.current = null;
|
reloadTimerRef.current = null;
|
||||||
void loadStream({ background: true });
|
void loadStream({ background: true });
|
||||||
|
|
@ -370,22 +147,15 @@ export const TaskLogStreamSection = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
|
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
|
||||||
if (event.teamName !== teamName) {
|
if (event.teamName !== teamName) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const shouldReload =
|
const shouldReload =
|
||||||
event.type === 'log-source-change' ||
|
event.type === 'log-source-change' ||
|
||||||
(event.type === 'task-log-change' && event.taskId === taskId);
|
(event.type === 'task-log-change' && event.taskId === taskId);
|
||||||
if (!shouldReload) {
|
if (shouldReload) scheduleReload();
|
||||||
return;
|
|
||||||
}
|
|
||||||
scheduleReload();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleVisibilityChange = (): void => {
|
const handleVisibilityChange = (): void => {
|
||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible') scheduleReload();
|
||||||
scheduleReload();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
|
|
@ -400,115 +170,25 @@ export const TaskLogStreamSection = ({
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
}
|
}
|
||||||
if (typeof unsubscribe === 'function') {
|
if (typeof unsubscribe === 'function') unsubscribe();
|
||||||
unsubscribe();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [liveEnabled, loadStream, taskId, teamName]);
|
}, [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 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 (
|
return (
|
||||||
<div className="space-y-3">
|
<ExecutionLogStreamView
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
title="Task Log Stream"
|
||||||
Task Log Stream
|
description={streamDescription}
|
||||||
</h4>
|
stream={stream}
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">{streamDescription}</p>
|
loading={loading}
|
||||||
|
error={error}
|
||||||
{showChips ? (
|
teamName={teamName}
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
teamMembers={teamMembers}
|
||||||
<button
|
loadingText="Loading task log stream..."
|
||||||
type="button"
|
emptyTitle="No task log stream yet"
|
||||||
className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${
|
emptyDescription="Task-linked logs will appear here when transcript metadata or runtime projection is available."
|
||||||
selectedParticipantKey === 'all'
|
selectionResetKey={`${teamName}:${taskId}`}
|
||||||
? '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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ import type { TerminalAPI } from './terminal';
|
||||||
import type { TmuxAPI } from './tmux';
|
import type { TmuxAPI } from './tmux';
|
||||||
import type { WaterfallData } from './visualization';
|
import type { WaterfallData } from './visualization';
|
||||||
import type { CodexAccountElectronApi } from '@features/codex-account/contracts';
|
import type { CodexAccountElectronApi } from '@features/codex-account/contracts';
|
||||||
|
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
|
||||||
import type {
|
import type {
|
||||||
MemberWorkSyncMetricsRequest,
|
MemberWorkSyncMetricsRequest,
|
||||||
MemberWorkSyncReportRequest,
|
MemberWorkSyncReportRequest,
|
||||||
|
|
@ -904,6 +905,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
|
||||||
// Member actionable-work sync diagnostics API
|
// Member actionable-work sync diagnostics API
|
||||||
memberWorkSync: MemberWorkSyncElectronApi;
|
memberWorkSync: MemberWorkSyncElectronApi;
|
||||||
|
|
||||||
|
// Member log stream API
|
||||||
|
memberLogStream: MemberLogStreamApi;
|
||||||
|
|
||||||
// tmux runtime diagnostics API
|
// tmux runtime diagnostics API
|
||||||
tmux: TmuxAPI;
|
tmux: TmuxAPI;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -807,8 +807,14 @@ export interface ResolvedTeamMember {
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
isolation?: 'worktree';
|
isolation?: 'worktree';
|
||||||
providerId?: TeamProviderId;
|
providerId?: TeamProviderId;
|
||||||
|
providerBackendId?: TeamProviderBackendId;
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
selectedFastMode?: TeamFastMode;
|
||||||
|
resolvedFastMode?: boolean;
|
||||||
|
laneId?: string;
|
||||||
|
laneKind?: 'primary' | 'secondary';
|
||||||
|
laneOwnerProviderId?: TeamProviderId;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
/** Set only when member's git branch differs from the lead's branch. */
|
/** Set only when member's git branch differs from the lead's branch. */
|
||||||
gitBranch?: string;
|
gitBranch?: string;
|
||||||
|
|
@ -908,7 +914,13 @@ export interface TeamViewSnapshot {
|
||||||
|
|
||||||
export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
||||||
export type TeamProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
|
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 type TeamFastMode = 'inherit' | 'on' | 'off';
|
||||||
|
|
||||||
export interface ProviderModelLaunchIdentity {
|
export interface ProviderModelLaunchIdentity {
|
||||||
|
|
|
||||||
|
|
@ -954,8 +954,8 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
messageCount: 2,
|
messageCount: 2,
|
||||||
toolCallCount: 1,
|
toolCallCount: 1,
|
||||||
errorCount: 0,
|
errorCount: 0,
|
||||||
latestAssistantText: '/tmp/project',
|
latestAssistantText: '/Users/tester/project',
|
||||||
latestAssistantPreview: '/tmp/project',
|
latestAssistantPreview: '/Users/tester/project',
|
||||||
messages: [],
|
messages: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
logProjection: {
|
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 () => {
|
it('loads a large real OpenCode projection fixture through output-file transcript delivery', async () => {
|
||||||
const fixturePath = path.resolve(
|
const fixturePath = path.resolve(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
|
|
|
||||||
|
|
@ -35,38 +35,42 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
await fs.mkdir(projectRoot, { recursive: true });
|
await fs.mkdir(projectRoot, { recursive: true });
|
||||||
|
|
||||||
const projectResolver = {
|
const projectResolver = {
|
||||||
getLiveBaseContext: vi.fn(async () => ({
|
getLiveBaseContext: vi.fn(() =>
|
||||||
projectDir: projectRoot,
|
Promise.resolve({
|
||||||
projectId: '-Users-test-live-context',
|
projectDir: projectRoot,
|
||||||
config,
|
projectId: '-Users-test-live-context',
|
||||||
})),
|
config,
|
||||||
getContext: vi.fn(async () => {
|
})
|
||||||
throw new Error('broad context must not be used for live tracking');
|
),
|
||||||
}),
|
getContext: vi.fn(() =>
|
||||||
|
Promise.reject(new Error('broad context must not be used for live tracking'))
|
||||||
|
),
|
||||||
};
|
};
|
||||||
const launchStateStore = {
|
const launchStateStore = {
|
||||||
read: vi.fn(async () => ({
|
read: vi.fn(() =>
|
||||||
version: 2,
|
Promise.resolve({
|
||||||
teamName,
|
version: 2,
|
||||||
updatedAt: '2026-05-03T12:00:00.000Z',
|
teamName,
|
||||||
leadSessionId: 'lead-session',
|
updatedAt: '2026-05-03T12:00:00.000Z',
|
||||||
launchPhase: 'active',
|
leadSessionId: 'lead-session',
|
||||||
expectedMembers: ['bob'],
|
launchPhase: 'active',
|
||||||
members: {
|
expectedMembers: ['bob'],
|
||||||
bob: {
|
members: {
|
||||||
name: 'bob',
|
bob: {
|
||||||
launchState: 'runtime_pending_bootstrap',
|
name: 'bob',
|
||||||
agentToolAccepted: true,
|
launchState: 'runtime_pending_bootstrap',
|
||||||
runtimeAlive: false,
|
agentToolAccepted: true,
|
||||||
bootstrapConfirmed: false,
|
runtimeAlive: false,
|
||||||
hardFailure: false,
|
bootstrapConfirmed: false,
|
||||||
runtimeSessionId: 'runtime-bob',
|
hardFailure: false,
|
||||||
updatedAt: '2026-05-03T12:00:00.000Z',
|
runtimeSessionId: 'runtime-bob',
|
||||||
|
updatedAt: '2026-05-03T12:00:00.000Z',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
summary: {},
|
||||||
summary: {},
|
teamLaunchState: 'partial_pending',
|
||||||
teamLaunchState: 'partial_pending',
|
})
|
||||||
})),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const finder = new TeamMemberLogsFinder(
|
const finder = new TeamMemberLogsFinder(
|
||||||
|
|
@ -418,6 +422,97 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
expect(refs.some((ref) => ref.memberName === 'Tom')).toBe(false);
|
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 () => {
|
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-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
@ -452,18 +547,22 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
await fs.mkdir(path.join(projectRoot, currentSessionId, 'subagents'), { recursive: true });
|
await fs.mkdir(path.join(projectRoot, currentSessionId, 'subagents'), { recursive: true });
|
||||||
await fs.mkdir(path.join(projectRoot, oldSessionId, 'subagents'), { recursive: true });
|
await fs.mkdir(path.join(projectRoot, oldSessionId, 'subagents'), { recursive: true });
|
||||||
|
|
||||||
const attributedLog = [
|
const attributedLog =
|
||||||
JSON.stringify({
|
[
|
||||||
timestamp: '2026-01-01T00:00:01.000Z',
|
JSON.stringify({
|
||||||
type: 'user',
|
timestamp: '2026-01-01T00:00:01.000Z',
|
||||||
message: { role: 'user', content: `You are alice, a developer on team "${teamName}" (${teamName}).` },
|
type: 'user',
|
||||||
}),
|
message: {
|
||||||
JSON.stringify({
|
role: 'user',
|
||||||
timestamp: '2026-01-01T00:00:02.000Z',
|
content: `You are alice, a developer on team "${teamName}" (${teamName}).`,
|
||||||
type: 'assistant',
|
},
|
||||||
message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] },
|
}),
|
||||||
}),
|
JSON.stringify({
|
||||||
].join('\n') + '\n';
|
timestamp: '2026-01-01T00:00:02.000Z',
|
||||||
|
type: 'assistant',
|
||||||
|
message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] },
|
||||||
|
}),
|
||||||
|
].join('\n') + '\n';
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(projectRoot, currentSessionId, 'subagents', 'agent-current.jsonl'),
|
path.join(projectRoot, currentSessionId, 'subagents', 'agent-current.jsonl'),
|
||||||
|
|
@ -1259,7 +1358,11 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
message: {
|
message: {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [
|
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: {
|
message: {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [
|
content: [
|
||||||
{ type: 'tool_use', name: 'TaskUpdate', input: { taskId: '3', status: 'in_progress' } },
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
name: 'TaskUpdate',
|
||||||
|
input: { taskId: '3', status: 'in_progress' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ import { useStore } from '@renderer/store';
|
||||||
|
|
||||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
|
const memberLogStreamMockState = vi.hoisted(() => ({
|
||||||
|
uiEnabled: true,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/hooks/useMemberStats', () => ({
|
vi.mock('@renderer/hooks/useMemberStats', () => ({
|
||||||
useMemberStats: () => ({
|
useMemberStats: () => ({
|
||||||
stats: null,
|
stats: null,
|
||||||
|
|
@ -110,11 +114,32 @@ vi.mock('@renderer/components/team/members/MemberLogsTab', () => ({
|
||||||
MemberLogsTab: () => React.createElement('div', null, 'logs-tab'),
|
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';
|
import { MemberDetailDialog } from '@renderer/components/team/members/MemberDetailDialog';
|
||||||
|
|
||||||
describe('MemberDetailDialog activity count', () => {
|
describe('MemberDetailDialog activity count', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
memberLogStreamMockState.uiEnabled = true;
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
teamMessagesByName: {
|
teamMessagesByName: {
|
||||||
'demo-team': {
|
'demo-team': {
|
||||||
|
|
@ -139,6 +164,98 @@ describe('MemberDetailDialog activity count', () => {
|
||||||
vi.unstubAllGlobals();
|
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 () => {
|
it('counts task comments in the Activity badge even when messageCount is zero', async () => {
|
||||||
const member: ResolvedTeamMember = {
|
const member: ResolvedTeamMember = {
|
||||||
name: 'jack',
|
name: 'jack',
|
||||||
|
|
@ -348,7 +465,7 @@ describe('MemberDetailDialog activity count', () => {
|
||||||
messageCount: 0,
|
messageCount: 0,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
};
|
};
|
||||||
const onRestartMember = vi.fn(async () => undefined);
|
const onRestartMember = vi.fn(() => Promise.resolve(undefined));
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
|
|
@ -424,7 +541,7 @@ describe('MemberDetailDialog activity count', () => {
|
||||||
messageCount: 0,
|
messageCount: 0,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
};
|
};
|
||||||
const onRestartMember = vi.fn(async () => undefined);
|
const onRestartMember = vi.fn(() => Promise.resolve(undefined));
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue