feat(team): improve runtime lane presence state
This commit is contained in:
parent
afe50439b1
commit
212cd37d3f
35 changed files with 1626 additions and 182 deletions
|
|
@ -146,6 +146,16 @@ function createPrimaryLaneMemberState(params: {
|
|||
const runtime = params.status;
|
||||
const strongRuntimeAlive = preservesStrongRuntimeAlive(runtime ?? {});
|
||||
const sources = runtime ? createSourcesFromStatus(runtime) : undefined;
|
||||
const launchState =
|
||||
runtime?.launchState ??
|
||||
deriveMemberLaunchState({
|
||||
hardFailure: runtime?.hardFailure,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
agentToolAccepted: runtime?.agentToolAccepted,
|
||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
|
||||
});
|
||||
const hardFailure = runtime?.hardFailure === true || launchState === 'failed_to_start';
|
||||
const base: PersistedTeamLaunchMemberState = {
|
||||
name: params.member.name.trim(),
|
||||
providerId,
|
||||
|
|
@ -173,20 +183,12 @@ function createPrimaryLaneMemberState(params: {
|
|||
providerId === params.leadDefaults.providerId
|
||||
? (params.leadDefaults.launchIdentity ?? undefined)
|
||||
: undefined,
|
||||
launchState:
|
||||
runtime?.launchState ??
|
||||
deriveMemberLaunchState({
|
||||
hardFailure: runtime?.hardFailure,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
agentToolAccepted: runtime?.agentToolAccepted,
|
||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
|
||||
}),
|
||||
launchState,
|
||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
||||
hardFailure,
|
||||
hardFailureReason: hardFailure ? (runtime?.hardFailureReason ?? runtime?.error) : undefined,
|
||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
||||
? [...new Set(runtime.pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
|
|
@ -212,7 +214,6 @@ function createSecondaryLaneMemberState(
|
|||
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
||||
const evidence = params.evidence;
|
||||
const strongRuntimeAlive = preservesStrongRuntimeAlive(evidence ?? {});
|
||||
const hardFailureReason = evidence?.hardFailureReason;
|
||||
const launchState =
|
||||
evidence?.launchState ??
|
||||
deriveMemberLaunchState({
|
||||
|
|
@ -222,6 +223,8 @@ function createSecondaryLaneMemberState(
|
|||
agentToolAccepted: evidence?.agentToolAccepted,
|
||||
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds,
|
||||
});
|
||||
const hardFailure = evidence?.hardFailure === true || launchState === 'failed_to_start';
|
||||
const hardFailureReason = hardFailure ? evidence?.hardFailureReason : undefined;
|
||||
const base: PersistedTeamLaunchMemberState = {
|
||||
name: params.member.name.trim(),
|
||||
providerId,
|
||||
|
|
@ -249,7 +252,7 @@ function createSecondaryLaneMemberState(
|
|||
agentToolAccepted: evidence?.agentToolAccepted === true,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
|
||||
hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start',
|
||||
hardFailure,
|
||||
hardFailureReason,
|
||||
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds?.length
|
||||
? [...new Set(evidence.pendingPermissionRequestIds)]
|
||||
|
|
|
|||
|
|
@ -47,4 +47,49 @@ describe('createTeamRuntimeLaneCoordinator', () => {
|
|||
})
|
||||
).toThrow('Mixed teams with OpenCode side lanes require the OpenCode runtime adapter');
|
||||
});
|
||||
|
||||
it('drops stale hard-failure reasons when secondary OpenCode evidence later confirms alive', () => {
|
||||
const coordinator = createTeamRuntimeLaneCoordinator();
|
||||
|
||||
const snapshot = coordinator.buildAggregateLaunchSnapshot({
|
||||
teamName: 'mixed-team',
|
||||
launchPhase: 'active',
|
||||
leadDefaults: {
|
||||
providerId: 'codex',
|
||||
},
|
||||
primaryMembers: [],
|
||||
primaryStatuses: {},
|
||||
secondaryMembers: [
|
||||
{
|
||||
laneId: 'secondary:opencode:jack',
|
||||
member: {
|
||||
name: 'jack',
|
||||
providerId: 'opencode',
|
||||
model: 'qwen/qwen3-coder',
|
||||
},
|
||||
leadDefaults: {
|
||||
providerId: 'codex',
|
||||
},
|
||||
evidence: {
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
hardFailureReason: 'OpenCode bridge reported member launch failure',
|
||||
diagnostics: ['OpenCode runtime bootstrap check-in accepted'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.members.jack).toMatchObject({
|
||||
launchState: 'confirmed_alive',
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
});
|
||||
expect(snapshot.members.jack.diagnostics).not.toContain(
|
||||
'hard failure reason: OpenCode bridge reported member launch failure'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4739,6 +4739,8 @@ async function handleGetSavedRequest(
|
|||
name: m.name,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
isolation: m.isolation,
|
||||
cwd: m.cwd,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
|
|
|
|||
|
|
@ -428,6 +428,9 @@ function normalizePersistedMemberState(
|
|||
bootstrapConfirmed,
|
||||
livenessKind,
|
||||
});
|
||||
const hardFailure = skippedForLaunch
|
||||
? false
|
||||
: toBoolean(parsed.hardFailure) || parsed.launchState === 'failed_to_start';
|
||||
const sources = normalizeSources(parsed.sources) ?? {};
|
||||
if (!runtimeAlive) {
|
||||
sources.processAlive = undefined;
|
||||
|
|
@ -467,8 +470,8 @@ function normalizePersistedMemberState(
|
|||
agentToolAccepted: skippedForLaunch ? false : toBoolean(parsed.agentToolAccepted),
|
||||
runtimeAlive,
|
||||
bootstrapConfirmed,
|
||||
hardFailure: skippedForLaunch ? false : toBoolean(parsed.hardFailure),
|
||||
hardFailureReason: skippedForLaunch
|
||||
hardFailure,
|
||||
hardFailureReason: !hardFailure
|
||||
? undefined
|
||||
: typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
||||
? parsed.hardFailureReason.trim()
|
||||
|
|
@ -629,23 +632,22 @@ export function snapshotFromRuntimeMemberStatuses(params: {
|
|||
if (runtime?.livenessSource === 'process' && runtimeAlive) {
|
||||
sources.processAlive = true;
|
||||
}
|
||||
const launchState = runtime?.launchState ?? 'starting';
|
||||
const hardFailure =
|
||||
runtime?.launchState === 'skipped_for_launch'
|
||||
? false
|
||||
: runtime?.hardFailure === true || launchState === 'failed_to_start';
|
||||
const entry: PersistedTeamLaunchMemberState = {
|
||||
name,
|
||||
launchState: runtime?.launchState ?? 'starting',
|
||||
launchState,
|
||||
skippedForLaunch,
|
||||
skipReason: runtime?.skipReason,
|
||||
skippedAt: runtime?.skippedAt,
|
||||
agentToolAccepted: skippedForLaunch ? false : runtime?.agentToolAccepted === true,
|
||||
runtimeAlive,
|
||||
bootstrapConfirmed: skippedForLaunch ? false : runtime?.bootstrapConfirmed === true,
|
||||
hardFailure:
|
||||
runtime?.launchState === 'skipped_for_launch'
|
||||
? false
|
||||
: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||
hardFailureReason:
|
||||
runtime?.launchState === 'skipped_for_launch'
|
||||
? undefined
|
||||
: (runtime?.hardFailureReason ?? runtime?.error),
|
||||
hardFailure,
|
||||
hardFailureReason: hardFailure ? (runtime?.hardFailureReason ?? runtime?.error) : undefined,
|
||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
||||
? [...new Set(runtime.pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { getAppDataPath, getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { createHash } from 'crypto';
|
||||
import { execFile } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
|
|
@ -101,10 +101,18 @@ export class TeamMemberWorktreeManager {
|
|||
): Promise<TeamMemberWorktreeResolution> {
|
||||
const baseRepoPath = await this.resolveBaseRepoPath(request.baseCwd);
|
||||
const repoHash = shortHash(baseRepoPath);
|
||||
const projectSlug = slugify(path.basename(baseRepoPath));
|
||||
const teamSlug = slugify(request.teamName);
|
||||
const memberSlug = slugify(request.memberName);
|
||||
const branchName = `agent-teams/${teamSlug}/${memberSlug}-${repoHash}`;
|
||||
const worktreePath = path.join(
|
||||
getAppDataPath(),
|
||||
'team-worktrees',
|
||||
`${projectSlug}-${repoHash}`,
|
||||
teamSlug,
|
||||
memberSlug
|
||||
);
|
||||
const legacyWorktreePath = path.join(
|
||||
getClaudeBasePath(),
|
||||
'team-worktrees',
|
||||
repoHash,
|
||||
|
|
@ -121,6 +129,15 @@ export class TeamMemberWorktreeManager {
|
|||
return { baseRepoPath, worktreePath, branchName };
|
||||
}
|
||||
|
||||
const legacyStat = await fs.promises.stat(legacyWorktreePath).catch(() => null);
|
||||
if (legacyStat) {
|
||||
if (!legacyStat.isDirectory()) {
|
||||
throw new Error(`Worktree path exists but is not a directory: ${legacyWorktreePath}`);
|
||||
}
|
||||
await this.assertExistingWorktreeMatchesRepo(legacyWorktreePath, baseRepoPath, branchName);
|
||||
return { baseRepoPath, worktreePath: legacyWorktreePath, branchName };
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(path.dirname(worktreePath), { recursive: true });
|
||||
await this.createWorktree({ baseRepoPath, worktreePath, branchName });
|
||||
return { baseRepoPath, worktreePath, branchName };
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ import {
|
|||
readOpenCodeRuntimeLaneIndex,
|
||||
recoverStaleOpenCodeRuntimeLaneIndexEntry,
|
||||
removeOpenCodeRuntimeLaneIndexEntry,
|
||||
setOpenCodeRuntimeActiveRunManifest,
|
||||
upsertOpenCodeRuntimeLaneIndexEntry,
|
||||
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||
import {
|
||||
|
|
@ -1596,6 +1597,10 @@ function isConfigRegistrationFailureReason(reason?: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function isOpenCodeBridgeLaunchFailureReason(reason?: string): boolean {
|
||||
return reason?.trim() === 'OpenCode bridge reported member launch failure';
|
||||
}
|
||||
|
||||
function isTmuxNoServerRunningError(error: unknown): boolean {
|
||||
const text = error instanceof Error ? error.message : String(error ?? '');
|
||||
return (
|
||||
|
|
@ -1608,7 +1613,8 @@ function isAutoClearableLaunchFailureReason(reason?: string): boolean {
|
|||
return (
|
||||
isNeverSpawnedDuringLaunchReason(reason) ||
|
||||
isLaunchGraceWindowFailureReason(reason) ||
|
||||
isConfigRegistrationFailureReason(reason)
|
||||
isConfigRegistrationFailureReason(reason) ||
|
||||
isOpenCodeBridgeLaunchFailureReason(reason)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -4621,7 +4627,41 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
const hasTaskRefs = (input.taskRefs ?? []).length > 0;
|
||||
return hasTaskRefs || input.actionMode === 'do' || input.actionMode === 'delegate';
|
||||
if (!hasTaskRefs && input.actionMode !== 'do' && input.actionMode !== 'delegate') {
|
||||
return false;
|
||||
}
|
||||
return this.hasOpenCodeNonVisibleProgressProof(input.ledgerRecord);
|
||||
}
|
||||
|
||||
private hasOpenCodeNonVisibleProgressProof(
|
||||
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null
|
||||
): boolean {
|
||||
const toolNames = ledgerRecord?.observedToolCallNames ?? [];
|
||||
return toolNames.some((toolName) => {
|
||||
const normalized = this.normalizeOpenCodeObservedToolName(toolName);
|
||||
return (
|
||||
normalized === 'task_start' ||
|
||||
normalized === 'task_add_comment' ||
|
||||
normalized === 'task_complete' ||
|
||||
normalized === 'task_set_status' ||
|
||||
normalized === 'task_set_clarification' ||
|
||||
normalized === 'task_create' ||
|
||||
normalized === 'task_link' ||
|
||||
normalized === 'runtime_task_event' ||
|
||||
normalized === 'write' ||
|
||||
normalized === 'edit' ||
|
||||
normalized === 'patch'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeOpenCodeObservedToolName(toolName: string): string {
|
||||
return toolName
|
||||
.trim()
|
||||
.replace(/^mcp__agent[-_]teams__/, '')
|
||||
.replace(/^agent[-_]teams_/, '')
|
||||
.replace(/^mcp__agent_teams__/, '')
|
||||
.replace(/^agent_teams_/, '');
|
||||
}
|
||||
|
||||
private isOpenCodePlainTextResponseReadCommitAllowed(input: {
|
||||
|
|
@ -4673,6 +4713,9 @@ export class TeamProvisioningService {
|
|||
if (!hasTaskRefs && input.actionMode !== 'do' && input.actionMode !== 'delegate') {
|
||||
return 'visible_reply_still_required';
|
||||
}
|
||||
if (!this.hasOpenCodeNonVisibleProgressProof(record)) {
|
||||
return 'non_visible_tool_without_task_progress';
|
||||
}
|
||||
}
|
||||
if (state === 'empty_assistant_turn') {
|
||||
return 'empty_assistant_turn';
|
||||
|
|
@ -12178,6 +12221,12 @@ export class TeamProvisioningService {
|
|||
);
|
||||
|
||||
try {
|
||||
await setOpenCodeRuntimeActiveRunManifest({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName: input.request.teamName,
|
||||
laneId: 'primary',
|
||||
runId,
|
||||
});
|
||||
const result = await adapter.launch(launchInput);
|
||||
if (
|
||||
this.cancelledRuntimeAdapterRunIds.delete(runId) ||
|
||||
|
|
@ -12337,6 +12386,7 @@ export class TeamProvisioningService {
|
|||
): PersistedTeamLaunchMemberState {
|
||||
const now = nowIso();
|
||||
const launchState = evidence?.launchState ?? 'failed_to_start';
|
||||
const hardFailure = evidence?.hardFailure === true || launchState === 'failed_to_start';
|
||||
return {
|
||||
name: member.name,
|
||||
providerId: 'opencode',
|
||||
|
|
@ -12351,8 +12401,8 @@ export class TeamProvisioningService {
|
|||
agentToolAccepted: evidence?.agentToolAccepted === true,
|
||||
runtimeAlive: evidence?.runtimeAlive === true,
|
||||
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
|
||||
hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start',
|
||||
hardFailureReason: evidence?.hardFailureReason,
|
||||
hardFailure,
|
||||
hardFailureReason: hardFailure ? evidence?.hardFailureReason : undefined,
|
||||
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds?.length
|
||||
? [...new Set(evidence.pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
|
|
@ -16282,6 +16332,12 @@ export class TeamProvisioningService {
|
|||
const previousLaunchState = await this.launchStateStore.read(run.teamName);
|
||||
|
||||
try {
|
||||
await setOpenCodeRuntimeActiveRunManifest({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName: run.teamName,
|
||||
laneId: lane.laneId,
|
||||
runId: lane.runId,
|
||||
});
|
||||
const result = await adapter.launch({
|
||||
runId: lane.runId,
|
||||
laneId: lane.laneId,
|
||||
|
|
@ -20026,7 +20082,7 @@ export class TeamProvisioningService {
|
|||
providerId: run.request.providerId,
|
||||
model: run.request.model,
|
||||
effort: run.request.effort,
|
||||
members: run.effectiveMembers,
|
||||
members: run.allEffectiveMembers,
|
||||
}
|
||||
);
|
||||
await this.cleanupPrelaunchBackup(run.teamName);
|
||||
|
|
@ -20228,7 +20284,7 @@ export class TeamProvisioningService {
|
|||
providerId: run.request.providerId,
|
||||
model: run.request.model,
|
||||
effort: run.request.effort,
|
||||
members: run.effectiveMembers,
|
||||
members: run.allEffectiveMembers,
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -21169,7 +21225,7 @@ export class TeamProvisioningService {
|
|||
providerId: run.request.providerId,
|
||||
model: run.request.model,
|
||||
effort: run.request.effort,
|
||||
members: run.effectiveMembers,
|
||||
members: run.allEffectiveMembers,
|
||||
}
|
||||
);
|
||||
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||
|
|
@ -21396,6 +21452,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
private applyEffectiveLaunchStateToConfig(
|
||||
teamName: string,
|
||||
config: Record<string, unknown>,
|
||||
launchState?: {
|
||||
providerId?: TeamProviderId;
|
||||
|
|
@ -21419,7 +21476,7 @@ export class TeamProvisioningService {
|
|||
(launchState.members ?? []).map((member) => [member.name.toLowerCase(), member] as const)
|
||||
);
|
||||
|
||||
config.members = (config.members as Record<string, unknown>[]).map((member) => {
|
||||
const nextMembers = (config.members as Record<string, unknown>[]).map((member) => {
|
||||
if (!member || typeof member !== 'object') {
|
||||
return member;
|
||||
}
|
||||
|
|
@ -21477,6 +21534,53 @@ export class TeamProvisioningService {
|
|||
});
|
||||
return nextMember;
|
||||
});
|
||||
|
||||
const existingNames = new Set(
|
||||
nextMembers
|
||||
.map((member) => (typeof member.name === 'string' ? member.name.trim().toLowerCase() : ''))
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
for (const member of launchState.members ?? []) {
|
||||
const name = member.name?.trim();
|
||||
if (!name || existingNames.has(name.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const providerId = normalizeTeamMemberProviderId(member.providerId);
|
||||
if (providerId !== 'opencode') {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextMembers.push(this.buildOpenCodeConfigMemberFromLaunchMember(teamName, member));
|
||||
existingNames.add(name.toLowerCase());
|
||||
}
|
||||
|
||||
config.members = nextMembers;
|
||||
}
|
||||
|
||||
private buildOpenCodeConfigMemberFromLaunchMember(
|
||||
teamName: string,
|
||||
member: TeamCreateRequest['members'][number]
|
||||
): Record<string, unknown> {
|
||||
const name = member.name.trim();
|
||||
const configMember: Record<string, unknown> = {
|
||||
name,
|
||||
agentId: `${name}@${teamName}`,
|
||||
agentType: 'general-purpose',
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
isolation: member.isolation === 'worktree' ? 'worktree' : undefined,
|
||||
providerId: 'opencode',
|
||||
model: member.model?.trim() || undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
cwd: member.cwd?.trim() || undefined,
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(configMember).filter(([, value]) => value !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -21571,7 +21675,7 @@ export class TeamProvisioningService {
|
|||
: pathHistory;
|
||||
}
|
||||
|
||||
this.applyEffectiveLaunchStateToConfig(config, launchState);
|
||||
this.applyEffectiveLaunchStateToConfig(teamName, config, launchState);
|
||||
|
||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { withFileLock } from '../../fileLock';
|
|||
|
||||
import {
|
||||
createDefaultRuntimeStoreManifest,
|
||||
createRuntimeStoreManifestStore,
|
||||
OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION,
|
||||
validateRuntimeStoreManifest,
|
||||
} from './RuntimeStoreManifest';
|
||||
|
||||
|
|
@ -386,6 +388,75 @@ export async function upsertOpenCodeRuntimeLaneIndexEntry(params: {
|
|||
});
|
||||
}
|
||||
|
||||
export async function setOpenCodeRuntimeActiveRunManifest(params: {
|
||||
teamsBasePath: string;
|
||||
teamName: string;
|
||||
laneId?: string | null;
|
||||
runId: string | null;
|
||||
clock?: () => Date;
|
||||
}): Promise<void> {
|
||||
const manifestPath = getOpenCodeRuntimeManifestPath(
|
||||
params.teamsBasePath,
|
||||
params.teamName,
|
||||
params.laneId
|
||||
);
|
||||
await ensureRuntimeManifestEnvelope(
|
||||
manifestPath,
|
||||
params.teamName,
|
||||
params.clock ?? (() => new Date())
|
||||
);
|
||||
const manifestStore = createRuntimeStoreManifestStore({
|
||||
filePath: manifestPath,
|
||||
teamName: params.teamName,
|
||||
clock: params.clock,
|
||||
});
|
||||
await manifestStore.setActiveRun({ runId: params.runId });
|
||||
}
|
||||
|
||||
async function ensureRuntimeManifestEnvelope(
|
||||
manifestPath: string,
|
||||
teamName: string,
|
||||
clock: () => Date
|
||||
): Promise<void> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(manifestPath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
!Array.isArray(parsed) &&
|
||||
Object.prototype.hasOwnProperty.call(parsed, 'data')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = validateRuntimeStoreManifest(parsed);
|
||||
await mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await atomicWriteAsync(
|
||||
manifestPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION,
|
||||
updatedAt: clock().toISOString(),
|
||||
data: {
|
||||
...manifest,
|
||||
teamName,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeOpenCodeRuntimeLaneIndexEntry(params: {
|
||||
teamsBasePath: string;
|
||||
teamName: string;
|
||||
|
|
|
|||
|
|
@ -274,6 +274,57 @@ export class RuntimeStoreManifestStore {
|
|||
return readStoreDataOrThrow(this.store);
|
||||
}
|
||||
|
||||
async setActiveRun(input: {
|
||||
runId: string | null;
|
||||
capabilitySnapshotId?: string | null;
|
||||
behaviorFingerprint?: string | null;
|
||||
}): Promise<RuntimeStoreManifest> {
|
||||
const normalizedRunId = input.runId?.trim() || null;
|
||||
const result = await this.store.updateLocked((manifest) => {
|
||||
const normalizedCapabilitySnapshotId =
|
||||
input.capabilitySnapshotId === undefined
|
||||
? manifest.activeCapabilitySnapshotId
|
||||
: input.capabilitySnapshotId?.trim() || null;
|
||||
const normalizedBehaviorFingerprint =
|
||||
input.behaviorFingerprint === undefined
|
||||
? manifest.activeBehaviorFingerprint
|
||||
: input.behaviorFingerprint?.trim() || null;
|
||||
const changed =
|
||||
manifest.activeRunId !== normalizedRunId ||
|
||||
manifest.activeCapabilitySnapshotId !== normalizedCapabilitySnapshotId ||
|
||||
manifest.activeBehaviorFingerprint !== normalizedBehaviorFingerprint ||
|
||||
this.isActiveRunOnlyWatermark(manifest);
|
||||
if (!changed) {
|
||||
return manifest;
|
||||
}
|
||||
|
||||
return {
|
||||
...manifest,
|
||||
activeRunId: normalizedRunId,
|
||||
activeCapabilitySnapshotId: normalizedCapabilitySnapshotId,
|
||||
activeBehaviorFingerprint: normalizedBehaviorFingerprint,
|
||||
highWatermark: this.resolveActiveRunWatermark(manifest),
|
||||
updatedAt: this.clock().toISOString(),
|
||||
};
|
||||
});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
private isActiveRunOnlyWatermark(manifest: RuntimeStoreManifest): boolean {
|
||||
return (
|
||||
manifest.highWatermark > 0 &&
|
||||
manifest.entries.length === 0 &&
|
||||
manifest.lastCommittedBatchId === null
|
||||
);
|
||||
}
|
||||
|
||||
private resolveActiveRunWatermark(manifest: RuntimeStoreManifest): number {
|
||||
if (this.isActiveRunOnlyWatermark(manifest)) {
|
||||
return 0;
|
||||
}
|
||||
return manifest.highWatermark;
|
||||
}
|
||||
|
||||
async markBatchPreparing(batch: RuntimeStoreWriteBatch): Promise<void> {
|
||||
await this.store.updateLocked((manifest) => ({
|
||||
...manifest,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
|
||||
|
||||
import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService';
|
||||
import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames';
|
||||
|
|
@ -737,8 +738,10 @@ function mapOpenCodeContentBlock(
|
|||
block: OpenCodeRuntimeTranscriptLogContentBlock
|
||||
): ContentBlock | null {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return { type: 'text', text: block.text };
|
||||
case 'text': {
|
||||
const text = sanitizeDisplayContent(block.text);
|
||||
return text.length > 0 ? { type: 'text', text } : null;
|
||||
}
|
||||
case 'thinking':
|
||||
return {
|
||||
type: 'thinking',
|
||||
|
|
@ -795,7 +798,7 @@ function toParsedMessage(message: OpenCodeRuntimeTranscriptLogMessage): ParsedMe
|
|||
|
||||
const normalizedContent: ContentBlock[] | string =
|
||||
typeof message.content === 'string'
|
||||
? message.content
|
||||
? sanitizeDisplayContent(message.content)
|
||||
: message.content
|
||||
.map(mapOpenCodeContentBlock)
|
||||
.filter((item): item is ContentBlock => item !== null);
|
||||
|
|
|
|||
|
|
@ -306,6 +306,26 @@ function deriveTeammateWorktreeDefault(
|
|||
);
|
||||
}
|
||||
|
||||
function buildWorktreePathByMemberName(
|
||||
members: readonly {
|
||||
name: string;
|
||||
isolation?: 'worktree';
|
||||
cwd?: string;
|
||||
removedAt?: number | string | null;
|
||||
}[]
|
||||
): Record<string, string> {
|
||||
const paths: Record<string, string> = {};
|
||||
for (const member of members) {
|
||||
const name = member.name.trim().toLowerCase();
|
||||
const cwd = member.cwd?.trim();
|
||||
if (!name || member.removedAt || member.isolation !== 'worktree' || !cwd) {
|
||||
continue;
|
||||
}
|
||||
paths[name] = cwd;
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
|
@ -458,6 +478,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const [maxTurns, setMaxTurns] = useState(50);
|
||||
const [maxBudgetUsd, setMaxBudgetUsd] = useState('');
|
||||
const [scheduleHydrationKey, setScheduleHydrationKey] = useState<string | null>(null);
|
||||
const [worktreePathByMemberName, setWorktreePathByMemberName] = useState<Record<string, string>>(
|
||||
{}
|
||||
);
|
||||
const effectiveMemberDrafts = useMemo(
|
||||
() => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts),
|
||||
[membersDrafts, syncModelsWithLead]
|
||||
|
|
@ -802,6 +825,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
normalizeMemberDraftForProviderMode(member, multimodelEnabled)
|
||||
)
|
||||
);
|
||||
setWorktreePathByMemberName(buildWorktreePathByMemberName(editableMembersSource));
|
||||
setTeammateWorktreeDefault(deriveTeammateWorktreeDefault(editableMembersSource));
|
||||
setSyncModelsWithLead(
|
||||
!editableMembersSource.some((member) => member.providerId || member.model || member.effort)
|
||||
|
|
@ -1280,6 +1304,31 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
return warnings;
|
||||
}, [memberRuntimeWarningById, teammateRuntimeCompatibility.memberWarningById]);
|
||||
|
||||
const memberWorktreeContinuationInfoById = useMemo(() => {
|
||||
if (!isLaunchMode) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const info: Record<string, string> = {};
|
||||
for (const member of effectiveMemberDrafts) {
|
||||
if (member.removedAt || member.isolation !== 'worktree') {
|
||||
continue;
|
||||
}
|
||||
const lookupName = (member.originalName?.trim() || member.name.trim()).toLowerCase();
|
||||
if (!lookupName) {
|
||||
continue;
|
||||
}
|
||||
const previousWorktreePath = worktreePathByMemberName[lookupName];
|
||||
if (!previousWorktreePath) {
|
||||
continue;
|
||||
}
|
||||
info[member.id] =
|
||||
`This teammate will continue from its existing worktree: ${previousWorktreePath}`;
|
||||
}
|
||||
|
||||
return info;
|
||||
}, [effectiveMemberDrafts, isLaunchMode, worktreePathByMemberName]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Launch-only effects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2451,6 +2500,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||
leadWarningText={leadRuntimeWarningText}
|
||||
memberWarningById={combinedMemberRuntimeWarningById}
|
||||
memberInfoById={memberWorktreeContinuationInfoById}
|
||||
leadModelIssueText={leadModelIssueText}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
softDeleteMembers
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ import {
|
|||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import { canDisplayTaskChanges } from '@shared/utils/taskChangeState';
|
||||
import {
|
||||
deriveTaskDisplayId,
|
||||
formatTaskDisplayLabel,
|
||||
|
|
@ -86,6 +85,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
|
||||
const TASK_CHANGES_AUTO_REFRESH_MS = 20_000;
|
||||
const TASK_CHANGES_INITIAL_LOAD_DELAY_MS = 1_500;
|
||||
|
||||
import { SourceMessageAttachments } from '../attachments/SourceMessageAttachments';
|
||||
|
||||
|
|
@ -325,8 +325,9 @@ export const TaskDetailDialog = ({
|
|||
? currentTask.sourceMessage.attachments.length
|
||||
: 0;
|
||||
|
||||
// Lazy-load task changes for any displayable state (in_progress, review, approved, completed).
|
||||
const canShowTaskChanges = currentTask ? canDisplayTaskChanges(currentTask) : false;
|
||||
// Changes is the explicit lazy-load entry point. Keep it visible for all team tasks,
|
||||
// including old/pending tasks that may resolve to an empty result.
|
||||
const canShowTaskChanges = Boolean(currentTask);
|
||||
const taskSince = useMemo(() => deriveTaskSince(currentTask), [currentTask]);
|
||||
const taskChangeRequestOptions = useMemo(
|
||||
() => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null),
|
||||
|
|
@ -361,13 +362,7 @@ export const TaskDetailDialog = ({
|
|||
|
||||
const loadTaskChangeSummary = useCallback(
|
||||
async (forceFresh = false): Promise<TaskChangeSetV2 | null> => {
|
||||
if (
|
||||
!currentTask ||
|
||||
!taskChangeSummaryOptions ||
|
||||
variant !== 'team' ||
|
||||
!canShowTaskChanges ||
|
||||
!onViewChanges
|
||||
) {
|
||||
if (!currentTask || !taskChangeSummaryOptions || variant !== 'team' || !canShowTaskChanges) {
|
||||
return null;
|
||||
}
|
||||
const data = await api.review.getTaskChanges(teamName, currentTask.id, {
|
||||
|
|
@ -376,7 +371,7 @@ export const TaskDetailDialog = ({
|
|||
});
|
||||
return data;
|
||||
},
|
||||
[canShowTaskChanges, currentTask, onViewChanges, taskChangeSummaryOptions, teamName, variant]
|
||||
[canShowTaskChanges, currentTask, taskChangeSummaryOptions, teamName, variant]
|
||||
);
|
||||
|
||||
const syncTaskChangeSummaryResult = useCallback(
|
||||
|
|
@ -410,14 +405,7 @@ export const TaskDetailDialog = ({
|
|||
preserveFilesOnError?: boolean;
|
||||
} = {}): Promise<void> => {
|
||||
const requestKey = currentTaskChangeSummaryKeyRef.current;
|
||||
if (
|
||||
!requestKey ||
|
||||
!currentTask ||
|
||||
variant !== 'team' ||
|
||||
!canShowTaskChanges ||
|
||||
!onViewChanges
|
||||
)
|
||||
return;
|
||||
if (!requestKey || !currentTask || variant !== 'team' || !canShowTaskChanges) return;
|
||||
if (taskChangesLoadInFlightKeysRef.current.has(requestKey)) return;
|
||||
|
||||
taskChangesLoadInFlightKeysRef.current.add(requestKey);
|
||||
|
|
@ -449,32 +437,27 @@ export const TaskDetailDialog = ({
|
|||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
canShowTaskChanges,
|
||||
currentTask,
|
||||
loadTaskChangeSummary,
|
||||
onViewChanges,
|
||||
syncTaskChangeSummaryResult,
|
||||
variant,
|
||||
]
|
||||
[canShowTaskChanges, currentTask, loadTaskChangeSummary, syncTaskChangeSummaryResult, variant]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== 'team') return;
|
||||
if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen)
|
||||
return;
|
||||
if (!open || !currentTask || !canShowTaskChanges || !changesSectionOpen) return;
|
||||
|
||||
const summaryKey = currentTaskChangeSummaryKey;
|
||||
if (loadedTaskChangeSummaryKeyRef.current === summaryKey) {
|
||||
return;
|
||||
}
|
||||
if (taskChangesFiles !== null) {
|
||||
loadedTaskChangeSummaryKeyRef.current = summaryKey;
|
||||
return;
|
||||
}
|
||||
loadedTaskChangeSummaryKeyRef.current = summaryKey;
|
||||
|
||||
// Show full loading state only when no files are cached yet;
|
||||
// otherwise let the refresh button spinner indicate background reload.
|
||||
// The manual open path only reaches this branch when no summary is cached yet.
|
||||
void requestTaskChangeSummary({
|
||||
forceFresh: false,
|
||||
showSpinner: !taskChangesFiles || taskChangesFiles.length === 0,
|
||||
showSpinner: true,
|
||||
preserveFilesOnError: false,
|
||||
});
|
||||
}, [
|
||||
|
|
@ -483,7 +466,6 @@ export const TaskDetailDialog = ({
|
|||
currentTask,
|
||||
canShowTaskChanges,
|
||||
teamName,
|
||||
onViewChanges,
|
||||
currentTaskChangeSummaryKey,
|
||||
taskChangeRequestSignature,
|
||||
variant,
|
||||
|
|
@ -491,6 +473,41 @@ export const TaskDetailDialog = ({
|
|||
taskChangesFiles,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== 'team') return;
|
||||
if (!open || !currentTask || !canShowTaskChanges || changesSectionOpen) return;
|
||||
if (!currentTaskChangeSummaryKey || taskChangesFiles !== null) return;
|
||||
|
||||
const summaryKey = currentTaskChangeSummaryKey;
|
||||
if (loadedTaskChangeSummaryKeyRef.current === summaryKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
if (currentTaskChangeSummaryKeyRef.current !== summaryKey) {
|
||||
return;
|
||||
}
|
||||
void requestTaskChangeSummary({
|
||||
forceFresh: false,
|
||||
showSpinner: true,
|
||||
preserveFilesOnError: true,
|
||||
});
|
||||
}, TASK_CHANGES_INITIAL_LOAD_DELAY_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [
|
||||
changesSectionOpen,
|
||||
open,
|
||||
currentTask,
|
||||
canShowTaskChanges,
|
||||
currentTaskChangeSummaryKey,
|
||||
requestTaskChangeSummary,
|
||||
taskChangesFiles,
|
||||
variant,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !changesSectionOpen) {
|
||||
loadedTaskChangeSummaryKeyRef.current = null;
|
||||
|
|
@ -499,7 +516,7 @@ export const TaskDetailDialog = ({
|
|||
|
||||
useEffect(() => {
|
||||
if (variant !== 'team') return;
|
||||
if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen) {
|
||||
if (!open || !currentTask || !canShowTaskChanges || !changesSectionOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -519,7 +536,6 @@ export const TaskDetailDialog = ({
|
|||
open,
|
||||
currentTask,
|
||||
canShowTaskChanges,
|
||||
onViewChanges,
|
||||
requestTaskChangeSummary,
|
||||
variant,
|
||||
]);
|
||||
|
|
@ -1138,14 +1154,21 @@ export const TaskDetailDialog = ({
|
|||
</CollapsibleTeamSection>
|
||||
|
||||
{/* Changes */}
|
||||
{variant === 'team' && canShowTaskChanges && onViewChanges ? (
|
||||
{variant === 'team' && canShowTaskChanges ? (
|
||||
<CollapsibleTeamSection
|
||||
key={`task-changes:${currentTask.id}`}
|
||||
title="Changes"
|
||||
icon={<FileDiff size={14} />}
|
||||
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
||||
badge={
|
||||
!taskChangesLoading && taskChangesFiles ? taskChangesFiles.length : undefined
|
||||
}
|
||||
headerExtra={
|
||||
changesSectionOpen ? (
|
||||
taskChangesLoading && !changesSectionOpen ? (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="pointer-events-none animate-spin text-[var(--color-text-muted)]"
|
||||
/>
|
||||
) : changesSectionOpen ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -1192,16 +1215,22 @@ export const TaskDetailDialog = ({
|
|||
fileName={file.relativePath.split('/').pop() ?? file.relativePath}
|
||||
className="size-3.5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onViewChanges(currentTask.id, file.filePath);
|
||||
}}
|
||||
>
|
||||
{file.relativePath}
|
||||
</button>
|
||||
{onViewChanges ? (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onViewChanges(currentTask.id, file.filePath);
|
||||
}}
|
||||
>
|
||||
{file.relativePath}
|
||||
</button>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)]">
|
||||
{file.relativePath}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{file.linesAdded > 0 ? (
|
||||
<span className="text-emerald-400">+{file.linesAdded}</span>
|
||||
|
|
@ -1211,21 +1240,23 @@ export const TaskDetailDialog = ({
|
|||
) : null}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onViewChanges(currentTask.id, file.filePath);
|
||||
}}
|
||||
>
|
||||
<GitCompareArrows size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Review diff</TooltipContent>
|
||||
</Tooltip>
|
||||
{onViewChanges ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onViewChanges(currentTask.id, file.filePath);
|
||||
}}
|
||||
>
|
||||
<GitCompareArrows size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Review diff</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onOpenInEditor ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
||||
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ export const CurrentTaskIndicator = ({
|
|||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<Loader2 className="size-3 shrink-0 animate-spin" style={{ color: borderColor }} />
|
||||
<SyncedLoader2 className="size-3 shrink-0" style={{ color: borderColor }} />
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
|
|
@ -21,18 +22,11 @@ import {
|
|||
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { AlertTriangle, Ban, GitBranch, MessageSquare, Plus, RotateCcw } from 'lucide-react';
|
||||
|
||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||
import { MemberPresenceDot } from './MemberPresenceDot';
|
||||
|
||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type {
|
||||
|
|
@ -183,9 +177,11 @@ export const MemberCard = ({
|
|||
const isLead = isLeadMember(member);
|
||||
const workspacePath = member.cwd?.trim();
|
||||
const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree';
|
||||
const workspaceBadgeTitle = workspacePath
|
||||
? `Worktree isolation configured. Worktree path: ${workspacePath}`
|
||||
: 'Worktree isolation is configured, but the runtime path is not available yet';
|
||||
const workspaceTooltipLines = [
|
||||
'Worktree isolation is enabled.',
|
||||
workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.',
|
||||
member.gitBranch ? `Branch: ${member.gitBranch}` : null,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
const activityTask = currentTask ?? reviewTask ?? null;
|
||||
const activityTitle = currentTask
|
||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||
|
|
@ -200,7 +196,6 @@ export const MemberCard = ({
|
|||
!runtimeSummary;
|
||||
const showLaunchBadge =
|
||||
!isRemoved &&
|
||||
!activityTask &&
|
||||
!runtimeAdvisoryLabel &&
|
||||
(presenceLabel === 'starting' ||
|
||||
presenceLabel === 'connecting' ||
|
||||
|
|
@ -335,29 +330,36 @@ export const MemberCard = ({
|
|||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
||||
aria-label={displayPresenceLabel}
|
||||
/>
|
||||
<MemberPresenceDot className={`size-2.5 ${dotClass}`} label={displayPresenceLabel} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-sm">
|
||||
<span className="shrink-0 font-medium text-[var(--color-text)]">
|
||||
{displayMemberName(member.name)}
|
||||
</span>
|
||||
{member.gitBranch ? (
|
||||
{member.gitBranch && !showWorkspaceBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
<GitBranch size={10} />
|
||||
{member.gitBranch}
|
||||
</span>
|
||||
) : null}
|
||||
{showWorkspaceBadge ? (
|
||||
<span
|
||||
className="shrink-0 rounded border border-emerald-400/35 bg-emerald-400/10 px-1 py-0.5 text-[9px] font-semibold uppercase leading-none text-emerald-300"
|
||||
title={workspaceBadgeTitle}
|
||||
>
|
||||
worktree
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0 rounded border border-emerald-400/35 bg-emerald-400/10 px-1 py-0.5 text-[9px] font-semibold uppercase leading-none text-emerald-300">
|
||||
worktree
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm text-xs leading-relaxed">
|
||||
<div className="space-y-1">
|
||||
{workspaceTooltipLines.map((line) => (
|
||||
<p key={line} className="break-words">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{currentTask ? (
|
||||
<CurrentTaskIndicator
|
||||
|
|
@ -380,8 +382,8 @@ export const MemberCard = ({
|
|||
{runtimeAdvisoryTone === 'error' ? (
|
||||
<AlertTriangle className="size-3 shrink-0 text-red-400" />
|
||||
) : (
|
||||
<Loader2
|
||||
className={`size-3 shrink-0 animate-spin ${runtimeAdvisoryLabel ? 'text-amber-400' : ''}`}
|
||||
<SyncedLoader2
|
||||
className={`size-3 shrink-0 ${runtimeAdvisoryLabel ? 'text-amber-400' : ''}`}
|
||||
style={runtimeAdvisoryLabel ? undefined : { color: colors.border }}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -436,8 +438,8 @@ export const MemberCard = ({
|
|||
className="flex shrink-0 items-center gap-1"
|
||||
title={runtimeEntry?.runtimeDiagnostic}
|
||||
>
|
||||
<Loader2
|
||||
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
|
||||
<SyncedLoader2
|
||||
className="size-3.5 shrink-0 text-[var(--color-text-muted)]"
|
||||
aria-label={launchBadgeLabel}
|
||||
/>
|
||||
<Badge
|
||||
|
|
@ -480,7 +482,7 @@ export const MemberCard = ({
|
|||
onClick={handleSkipFailedLaunch}
|
||||
>
|
||||
{skippingLaunch ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<Ban className="size-3.5" />
|
||||
)}
|
||||
|
|
@ -503,7 +505,7 @@ export const MemberCard = ({
|
|||
onClick={handleRetryFailedLaunch}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
|
|
@ -545,7 +547,7 @@ export const MemberCard = ({
|
|||
onClick={handleRetryFailedLaunch}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { isLeadMember } from '@shared/utils/leadDetection';
|
|||
import { Pencil } from 'lucide-react';
|
||||
|
||||
import { MemberRoleEditor } from './MemberRoleEditor';
|
||||
import { MemberPresenceDot } from './MemberPresenceDot';
|
||||
|
||||
import type {
|
||||
LeadActivityState,
|
||||
|
|
@ -116,10 +117,7 @@ export const MemberDetailHeader = ({
|
|||
className="size-12 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
||||
aria-label={badgeLabel}
|
||||
/>
|
||||
<MemberPresenceDot className={`size-3 ${dotClass}`} label={badgeLabel} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="truncate" style={{ color: colors.text }}>
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ interface MemberDraftRowProps {
|
|||
onRestore?: (id: string) => void;
|
||||
hideActionButton?: boolean;
|
||||
warningText?: string | null;
|
||||
infoText?: string | null;
|
||||
disableGeminiOption?: boolean;
|
||||
modelIssueText?: string | null;
|
||||
showWorktreeIsolationControls?: boolean;
|
||||
|
|
@ -122,6 +123,7 @@ export const MemberDraftRow = ({
|
|||
onRestore,
|
||||
hideActionButton = false,
|
||||
warningText,
|
||||
infoText,
|
||||
disableGeminiOption = false,
|
||||
modelIssueText,
|
||||
showWorktreeIsolationControls = false,
|
||||
|
|
@ -419,6 +421,14 @@ export const MemberDraftRow = ({
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!isRemoved && infoText ? (
|
||||
<div className="md:col-span-3">
|
||||
<div className="ml-3 flex items-start gap-2 rounded-md border border-sky-400/25 bg-sky-500/10 px-3 py-2 text-[11px] leading-relaxed text-sky-100">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-300" />
|
||||
<p className="min-w-0 whitespace-pre-wrap break-words">{infoText}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{showWorkflow && onWorkflowChange && workflowExpanded ? (
|
||||
<div className="space-y-0.5 pl-3 md:col-span-3">
|
||||
<label
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provi
|
|||
|
||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||
import { MemberPresenceDot } from './MemberPresenceDot';
|
||||
|
||||
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
|
|
@ -191,10 +192,7 @@ export const MemberHoverCard = ({
|
|||
className="size-10 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
||||
aria-label={badgeLabel}
|
||||
/>
|
||||
<MemberPresenceDot className={`size-3 ${dotClass}`} label={badgeLabel} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
|
|
|||
25
src/renderer/components/team/members/MemberPresenceDot.tsx
Normal file
25
src/renderer/components/team/members/MemberPresenceDot.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
||||
const PULSE_DURATION_MS = 2000;
|
||||
|
||||
interface MemberPresenceDotProps {
|
||||
className?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function MemberPresenceDot({ className, label }: MemberPresenceDotProps): React.JSX.Element {
|
||||
const shouldSyncPulse = className?.includes('animate-pulse') === true;
|
||||
const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-[var(--color-surface)]',
|
||||
className
|
||||
)}
|
||||
style={syncedPulseStyle}
|
||||
aria-label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -111,6 +111,7 @@ export interface MembersEditorSectionProps {
|
|||
modelLockReason?: string;
|
||||
softDeleteMembers?: boolean;
|
||||
memberWarningById?: Record<string, string | null | undefined>;
|
||||
memberInfoById?: Record<string, string | null | undefined>;
|
||||
disableGeminiOption?: boolean;
|
||||
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||
disableAddMember?: boolean;
|
||||
|
|
@ -149,6 +150,7 @@ export const MembersEditorSection = ({
|
|||
modelLockReason,
|
||||
softDeleteMembers = false,
|
||||
memberWarningById,
|
||||
memberInfoById,
|
||||
disableGeminiOption = false,
|
||||
memberModelIssueById,
|
||||
disableAddMember = false,
|
||||
|
|
@ -415,6 +417,7 @@ export const MembersEditorSection = ({
|
|||
identityLockReason={identityLockReason}
|
||||
modelLockReason={modelLockReason}
|
||||
warningText={memberWarningById?.[member.id] ?? null}
|
||||
infoText={memberInfoById?.[member.id] ?? null}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelIssueText={memberModelIssueById?.[member.id] ?? null}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ interface TeamRosterEditorSectionProps {
|
|||
softDeleteMembers?: boolean;
|
||||
leadWarningText?: string | null;
|
||||
memberWarningById?: Record<string, string | null | undefined>;
|
||||
memberInfoById?: Record<string, string | null | undefined>;
|
||||
disableGeminiOption?: boolean;
|
||||
leadModelIssueText?: string | null;
|
||||
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||
|
|
@ -88,6 +89,7 @@ export const TeamRosterEditorSection = ({
|
|||
softDeleteMembers = false,
|
||||
leadWarningText,
|
||||
memberWarningById,
|
||||
memberInfoById,
|
||||
disableGeminiOption = false,
|
||||
leadModelIssueText,
|
||||
memberModelIssueById,
|
||||
|
|
@ -148,6 +150,7 @@ export const TeamRosterEditorSection = ({
|
|||
</div>
|
||||
}
|
||||
memberWarningById={memberWarningById}
|
||||
memberInfoById={memberInfoById}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
28
src/renderer/components/ui/SyncedLoader2.tsx
Normal file
28
src/renderer/components/ui/SyncedLoader2.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
const DEFAULT_SPIN_DURATION_MS = 1000;
|
||||
|
||||
export type SyncedLoader2Props = ComponentProps<typeof Loader2> & {
|
||||
spinDurationMs?: number;
|
||||
};
|
||||
|
||||
export function SyncedLoader2({
|
||||
className,
|
||||
style,
|
||||
spinDurationMs = DEFAULT_SPIN_DURATION_MS,
|
||||
...props
|
||||
}: SyncedLoader2Props): React.JSX.Element {
|
||||
const syncedStyle = useSyncedAnimationStyle(true, spinDurationMs);
|
||||
|
||||
return (
|
||||
<Loader2
|
||||
{...props}
|
||||
className={cn('animate-spin', className)}
|
||||
style={{ ...syncedStyle, ...style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
src/renderer/hooks/useSyncedAnimationStyle.ts
Normal file
29
src/renderer/hooks/useSyncedAnimationStyle.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const DEFAULT_ANIMATION_DURATION_MS = 1000;
|
||||
|
||||
function getCurrentTimeMs(): number {
|
||||
return typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
|
||||
export function useSyncedAnimationStyle(
|
||||
enabled: boolean,
|
||||
durationMs = DEFAULT_ANIMATION_DURATION_MS
|
||||
): CSSProperties | undefined {
|
||||
return useMemo(() => {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
const safeDurationMs =
|
||||
Number.isFinite(durationMs) && durationMs > 0 ? durationMs : DEFAULT_ANIMATION_DURATION_MS;
|
||||
const phaseMs = getCurrentTimeMs() % safeDurationMs;
|
||||
return {
|
||||
animationDelay: `${-phaseMs}ms`,
|
||||
animationDuration: `${safeDurationMs}ms`,
|
||||
};
|
||||
}, [durationMs, enabled]);
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@ export const SPAWN_DOT_COLORS: Record<MemberSpawnStatus, string> = {
|
|||
offline: 'bg-zinc-600',
|
||||
waiting: 'bg-zinc-400 animate-pulse',
|
||||
spawning: 'bg-amber-400',
|
||||
online: 'bg-emerald-400 animate-[dot-online-jelly_0.45s_ease-out]',
|
||||
online: 'bg-emerald-400 animate-pulse',
|
||||
error: 'bg-red-400',
|
||||
skipped: 'bg-zinc-500',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ const NOISE_TAG_PATTERNS = [
|
|||
/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/gi,
|
||||
/<system-reminder>[\s\S]*?<\/system-reminder>/gi,
|
||||
/<task-notification>[\s\S]*?<\/task-notification>/gi,
|
||||
/<opencode_runtime_identity>[\s\S]*?<\/opencode_runtime_identity>/gi,
|
||||
/<opencode_app_message_delivery>[\s\S]*?<\/opencode_app_message_delivery>/gi,
|
||||
/<opencode_delivery_context>[\s\S]*?<\/opencode_delivery_context>/gi,
|
||||
/<opencode_delivery_retry>[\s\S]*?<\/opencode_delivery_retry>/gi,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -27,6 +31,8 @@ const NOISE_TAG_PATTERNS = [
|
|||
* task notifications.
|
||||
*/
|
||||
const TASK_OUTPUT_INSTRUCTION_PATTERN = / ?Read the output file to retrieve the result: [^\s]+/g;
|
||||
const OPENCODE_INBOUND_APP_MESSAGE_PATTERN =
|
||||
/<opencode_inbound_app_message>\s*([\s\S]*?)\s*<\/opencode_inbound_app_message>/gi;
|
||||
|
||||
export interface CommandOutputInfo {
|
||||
stream: 'stdout' | 'stderr';
|
||||
|
|
@ -121,6 +127,10 @@ export function sanitizeDisplayContent(content: string): string {
|
|||
for (const pattern of NOISE_TAG_PATTERNS) {
|
||||
sanitized = sanitized.replace(pattern, '');
|
||||
}
|
||||
sanitized = sanitized.replace(
|
||||
OPENCODE_INBOUND_APP_MESSAGE_PATTERN,
|
||||
(_match, innerContent: string | undefined) => innerContent?.trim() ?? ''
|
||||
);
|
||||
|
||||
// Also remove any remaining command tags (in case of mixed content)
|
||||
sanitized = sanitized
|
||||
|
|
@ -131,7 +141,7 @@ export function sanitizeDisplayContent(content: string): string {
|
|||
// Remove follow-up instructions that only make sense in raw XML form.
|
||||
sanitized = sanitized.replace(TASK_OUTPUT_INSTRUCTION_PATTERN, '');
|
||||
|
||||
return sanitized.trim();
|
||||
return sanitized.replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
[
|
||||
{
|
||||
"from": "nobody",
|
||||
"to": "user",
|
||||
"text": "plainprobe",
|
||||
"timestamp": "2026-04-23T17:45:03.432Z",
|
||||
"read": false,
|
||||
"summary": "plainprobe",
|
||||
"messageId": "a3ed3161-c883-4a6d-aff1-bc64e5eb547f"
|
||||
}
|
||||
]
|
||||
|
|
@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import {
|
||||
OpenCodeRuntimeManifestEvidenceReader,
|
||||
getOpenCodeRuntimeManifestPath,
|
||||
getOpenCodeLaneScopedRuntimeFilePath,
|
||||
getOpenCodeRuntimeLaneIndexPath,
|
||||
getOpenCodeTeamRuntimeDirectory,
|
||||
|
|
@ -13,8 +14,10 @@ import {
|
|||
migrateLegacyOpenCodeRuntimeState,
|
||||
readOpenCodeRuntimeLaneIndex,
|
||||
recoverStaleOpenCodeRuntimeLaneIndexEntry,
|
||||
setOpenCodeRuntimeActiveRunManifest,
|
||||
upsertOpenCodeRuntimeLaneIndexEntry,
|
||||
} from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||
import { createDefaultRuntimeStoreManifest } from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest';
|
||||
|
||||
describe('OpenCodeRuntimeManifestEvidenceReader migration', () => {
|
||||
let tempDir: string;
|
||||
|
|
@ -350,4 +353,98 @@ describe('OpenCodeRuntimeManifestEvidenceReader migration', () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('persists lane-scoped activeRunId for runtime evidence after app restart', async () => {
|
||||
const teamName = 'team-theta';
|
||||
const laneId = 'secondary:opencode:jack';
|
||||
const reader = new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempDir });
|
||||
|
||||
await setOpenCodeRuntimeActiveRunManifest({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-opencode-jack',
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await expect(reader.read(teamName, laneId)).resolves.toMatchObject({
|
||||
activeRunId: 'run-opencode-jack',
|
||||
highWatermark: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates raw legacy runtime manifests without dropping existing capability metadata', async () => {
|
||||
const teamName = 'team-iota';
|
||||
const laneId = 'secondary:opencode:alice';
|
||||
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId);
|
||||
const legacyManifest = {
|
||||
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T10:00:00.000Z'),
|
||||
activeRunId: 'run-old',
|
||||
activeCapabilitySnapshotId: 'cap-existing',
|
||||
activeBehaviorFingerprint: 'behavior-existing',
|
||||
highWatermark: 5,
|
||||
};
|
||||
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await fs.writeFile(manifestPath, `${JSON.stringify(legacyManifest, null, 2)}\n`, 'utf8');
|
||||
|
||||
await setOpenCodeRuntimeActiveRunManifest({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-new',
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await expect(
|
||||
new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempDir }).read(teamName, laneId)
|
||||
).resolves.toMatchObject({
|
||||
activeRunId: 'run-new',
|
||||
capabilitySnapshotId: 'cap-existing',
|
||||
highWatermark: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves committed manifest highWatermark when persisting activeRunId', async () => {
|
||||
const teamName = 'team-kappa';
|
||||
const laneId = 'secondary:opencode:bob';
|
||||
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId);
|
||||
const committedManifest = {
|
||||
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T10:00:00.000Z'),
|
||||
activeRunId: 'run-old',
|
||||
highWatermark: 5,
|
||||
lastCommittedBatchId: 'batch-1',
|
||||
entries: [
|
||||
{
|
||||
schemaName: 'opencode.launchState',
|
||||
schemaVersion: 1,
|
||||
relativePath: 'launch-state.json',
|
||||
contentHash: 'sha256:test',
|
||||
fileSize: 12,
|
||||
mtimeMs: 123,
|
||||
runId: 'run-old',
|
||||
capabilitySnapshotId: null,
|
||||
behaviorFingerprint: null,
|
||||
lastWriteReceiptId: 'receipt-1',
|
||||
state: 'healthy',
|
||||
},
|
||||
],
|
||||
};
|
||||
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await fs.writeFile(manifestPath, `${JSON.stringify(committedManifest, null, 2)}\n`, 'utf8');
|
||||
|
||||
await setOpenCodeRuntimeActiveRunManifest({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-new',
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await expect(
|
||||
new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempDir }).read(teamName, laneId)
|
||||
).resolves.toMatchObject({
|
||||
activeRunId: 'run-new',
|
||||
highWatermark: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -260,6 +260,70 @@ describe('OpenCodeTaskLogStreamSource', () => {
|
|||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it('sanitizes OpenCode delivery retry envelopes from projected task log text', async () => {
|
||||
const bridge = {
|
||||
getOpenCodeTranscript: vi.fn(async () => ({
|
||||
sessionId: 'session-opencode',
|
||||
logProjection: {
|
||||
messages: [
|
||||
textLogMessage({
|
||||
uuid: 'task-delivery',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-21T10:05:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: [
|
||||
'<opencode_inbound_app_message>',
|
||||
'<opencode_delivery_retry>',
|
||||
'This is retry attempt 3/3 for inbound app messageId "message-1".',
|
||||
'</opencode_delivery_retry>',
|
||||
'',
|
||||
'New task assigned to you: #task-a Investigate failing command',
|
||||
'</opencode_inbound_app_message>',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
})),
|
||||
};
|
||||
const chunkBuilder = {
|
||||
buildBundleChunks: vi.fn((messages) => [
|
||||
{
|
||||
id: 'chunk-sanitized',
|
||||
kind: 'assistant',
|
||||
messages,
|
||||
},
|
||||
]),
|
||||
};
|
||||
const source = new OpenCodeTaskLogStreamSource(
|
||||
bridge as never,
|
||||
{ resolve: async () => '/tmp/claude' },
|
||||
{
|
||||
getTasks: async () => [createTask()],
|
||||
getDeletedTasks: async () => [],
|
||||
} as never,
|
||||
chunkBuilder as never,
|
||||
{ readTaskRecords: vi.fn(async () => []) }
|
||||
);
|
||||
|
||||
const response = await source.getTaskLogStream('team-a', 'task-a');
|
||||
|
||||
expect(response?.source).toBe('opencode_runtime_fallback');
|
||||
const projectedMessage = chunkBuilder.buildBundleChunks.mock.calls[0]?.[0]?.[0] as
|
||||
| { content: Array<{ type: string; text?: string }> }
|
||||
| undefined;
|
||||
expect(projectedMessage?.content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'New task assigned to you: #task-a Investigate failing command',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when the task has no owner', async () => {
|
||||
const source = new OpenCodeTaskLogStreamSource(
|
||||
{ getOpenCodeTranscript: vi.fn() } as never,
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
claudeRoot: '',
|
||||
appDataRoot: '',
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getClaudeBasePath: () => hoisted.claudeRoot,
|
||||
getAppDataPath: () => hoisted.appDataRoot,
|
||||
}));
|
||||
|
||||
import { TeamMemberWorktreeManager } from '../../../../src/main/services/team/TeamMemberWorktreeManager';
|
||||
|
|
@ -43,6 +45,26 @@ function shortHash(value: string): string {
|
|||
return createHash('sha256').update(value).digest('hex').slice(0, 10);
|
||||
}
|
||||
|
||||
function expectedWorktreePath(repoPath: string, teamName = 'Atlas HQ', memberName = 'Bob'): string {
|
||||
return path.join(
|
||||
hoisted.appDataRoot,
|
||||
'team-worktrees',
|
||||
`${slugify(path.basename(repoPath))}-${shortHash(repoPath)}`,
|
||||
slugify(teamName),
|
||||
slugify(memberName)
|
||||
);
|
||||
}
|
||||
|
||||
function legacyWorktreePath(repoPath: string, teamName = 'Atlas HQ', memberName = 'Bob'): string {
|
||||
return path.join(
|
||||
hoisted.claudeRoot,
|
||||
'team-worktrees',
|
||||
shortHash(repoPath),
|
||||
slugify(teamName),
|
||||
slugify(memberName)
|
||||
);
|
||||
}
|
||||
|
||||
async function createGitRepo(root: string): Promise<string> {
|
||||
const repoPath = path.join(root, 'repo');
|
||||
await fs.mkdir(repoPath, { recursive: true });
|
||||
|
|
@ -59,7 +81,9 @@ describe('TeamMemberWorktreeManager', () => {
|
|||
beforeEach(async () => {
|
||||
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-member-worktree-'));
|
||||
hoisted.claudeRoot = path.join(tempRoot, 'claude');
|
||||
hoisted.appDataRoot = path.join(tempRoot, 'app-data');
|
||||
await fs.mkdir(hoisted.claudeRoot, { recursive: true });
|
||||
await fs.mkdir(hoisted.appDataRoot, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -78,23 +102,37 @@ describe('TeamMemberWorktreeManager', () => {
|
|||
|
||||
expect(resolution.baseRepoPath).toBe(repoPath);
|
||||
expect(resolution.branchName).toBe(`agent-teams/atlas-hq/bob-${shortHash(repoPath)}`);
|
||||
expect(resolution.worktreePath).toBe(
|
||||
path.join(hoisted.claudeRoot, 'team-worktrees', shortHash(repoPath), 'atlas-hq', 'bob')
|
||||
);
|
||||
expect(resolution.worktreePath).toBe(expectedWorktreePath(repoPath));
|
||||
expect(resolution.worktreePath.startsWith(hoisted.appDataRoot)).toBe(true);
|
||||
expect(resolution.worktreePath.startsWith(hoisted.claudeRoot)).toBe(false);
|
||||
await expect(execGit(['rev-parse', '--abbrev-ref', 'HEAD'], resolution.worktreePath)).resolves.toBe(
|
||||
resolution.branchName
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses legacy deterministic worktree paths for existing teammates', async () => {
|
||||
const repoPath = await createGitRepo(tempRoot);
|
||||
const manager = new TeamMemberWorktreeManager();
|
||||
const branchName = `agent-teams/atlas-hq/bob-${shortHash(repoPath)}`;
|
||||
const legacyPath = legacyWorktreePath(repoPath);
|
||||
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
|
||||
await execGit(['worktree', 'add', '-b', branchName, legacyPath, 'HEAD'], repoPath);
|
||||
|
||||
const resolution = await manager.ensureMemberWorktree({
|
||||
teamName: 'Atlas HQ',
|
||||
memberName: 'Bob',
|
||||
baseCwd: repoPath,
|
||||
});
|
||||
|
||||
expect(resolution.worktreePath).toBe(legacyPath);
|
||||
await expect(execGit(['rev-parse', '--abbrev-ref', 'HEAD'], resolution.worktreePath)).resolves.toBe(
|
||||
branchName
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an existing deterministic path checked out on the wrong branch', async () => {
|
||||
const repoPath = await createGitRepo(tempRoot);
|
||||
const wrongPath = path.join(
|
||||
hoisted.claudeRoot,
|
||||
'team-worktrees',
|
||||
shortHash(repoPath),
|
||||
slugify('Atlas HQ'),
|
||||
slugify('Bob')
|
||||
);
|
||||
const wrongPath = expectedWorktreePath(repoPath);
|
||||
await fs.mkdir(path.dirname(wrongPath), { recursive: true });
|
||||
await execGit(['worktree', 'add', '-b', 'some-other-branch', wrongPath, 'HEAD'], repoPath);
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore
|
|||
import {
|
||||
getOpenCodeLaneScopedRuntimeFilePath,
|
||||
getOpenCodeRuntimeManifestPath,
|
||||
OpenCodeRuntimeManifestEvidenceReader,
|
||||
readOpenCodeRuntimeLaneIndex,
|
||||
upsertOpenCodeRuntimeLaneIndexEntry,
|
||||
} from '@main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||
|
|
@ -4510,6 +4511,102 @@ describe('TeamProvisioningService', () => {
|
|||
expect(retryText).toContain('What did you find?');
|
||||
});
|
||||
|
||||
it('keeps OpenCode task delivery pending after read-only non-visible tool activity', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
prePromptCursor: 'cursor-before',
|
||||
responseObservation: {
|
||||
state: 'responded_non_visible_tool' as const,
|
||||
deliveredUserMessageId: 'oc-user-task',
|
||||
assistantMessageId: 'oc-assistant-read',
|
||||
toolCallNames: ['read', 'bash'],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: null,
|
||||
},
|
||||
diagnostics: [],
|
||||
}));
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
sendMessageToMember,
|
||||
observeMessageDelivery: vi.fn(),
|
||||
} as any,
|
||||
]);
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
|
||||
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
|
||||
(svc as any).provisioningRunByTeam.set('team-a', 'run-1');
|
||||
(svc as any).setSecondaryRuntimeRun({
|
||||
teamName: 'team-a',
|
||||
runId: 'opencode-run-bob',
|
||||
providerId: 'opencode',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
members: [
|
||||
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
],
|
||||
})),
|
||||
};
|
||||
(svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({
|
||||
launchIdentity: { providerId: 'codex' },
|
||||
providerId: 'codex',
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'Start task #task-1 now.',
|
||||
messageId: 'msg-task-read-only',
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: 'do',
|
||||
taskRefs: [
|
||||
{
|
||||
taskId: 'task-1',
|
||||
displayId: 'task-1',
|
||||
teamName: 'team-a',
|
||||
},
|
||||
],
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
accepted: true,
|
||||
responsePending: true,
|
||||
responseState: 'responded_non_visible_tool',
|
||||
ledgerStatus: 'retry_scheduled',
|
||||
reason: 'non_visible_tool_without_task_progress',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks OpenCode delivery terminal after max attempts instead of leaving it pending', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const emptyResponseObservation = {
|
||||
|
|
@ -5191,10 +5288,40 @@ describe('TeamProvisioningService', () => {
|
|||
diagnostics: [],
|
||||
},
|
||||
];
|
||||
const manifestPath = getOpenCodeRuntimeManifestPath(
|
||||
tempTeamsBase,
|
||||
teamName,
|
||||
'secondary:opencode:bob'
|
||||
);
|
||||
await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T10:00:00.000Z'),
|
||||
activeRunId: 'stale-run',
|
||||
highWatermark: 2,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
|
||||
await vi.waitFor(async () => {
|
||||
expect(adapterLaunch).toHaveBeenCalledTimes(1);
|
||||
const launchInput = adapterLaunch.mock.calls[0]?.[0] as { runId?: string } | undefined;
|
||||
expect(launchInput?.runId).toEqual(expect.any(String));
|
||||
await expect(
|
||||
new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempTeamsBase }).read(
|
||||
teamName,
|
||||
'secondary:opencode:bob'
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
activeRunId: launchInput?.runId,
|
||||
highWatermark: 0,
|
||||
});
|
||||
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
|
||||
lanes: {
|
||||
'secondary:opencode:bob': {
|
||||
|
|
@ -7683,6 +7810,112 @@ describe('TeamProvisioningService', () => {
|
|||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('restores missing OpenCode teammates into config before post-launch registration audit', async () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'mixed-opencode-post-launch-config';
|
||||
const teamDir = path.join(tempTeamsBase, teamName);
|
||||
const jackWorktree = path.join(tempClaudeRoot, 'worktrees', 'jack');
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: teamName,
|
||||
projectPath: '/old/project',
|
||||
leadSessionId: 'old-lead-session',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', providerId: 'anthropic' }],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const { svc } = createSafeLaunchService();
|
||||
await (svc as any).updateConfigPostLaunch(
|
||||
teamName,
|
||||
tempClaudeRoot,
|
||||
'new-lead-session',
|
||||
undefined,
|
||||
{
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/google/gemini-2.5-flash',
|
||||
},
|
||||
{
|
||||
name: 'jack',
|
||||
role: 'Developer',
|
||||
workflow: 'Work in the isolated checkout.',
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/qwen/qwen3-coder',
|
||||
isolation: 'worktree',
|
||||
cwd: jackWorktree,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const config = JSON.parse(
|
||||
fs.readFileSync(path.join(teamDir, 'config.json'), 'utf8')
|
||||
) as {
|
||||
leadSessionId?: string;
|
||||
projectPath?: string;
|
||||
members: Array<{
|
||||
name: string;
|
||||
agentId?: string;
|
||||
agentType?: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
isolation?: string;
|
||||
cwd?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(config.leadSessionId).toBe('new-lead-session');
|
||||
expect(config.projectPath).toBe(tempClaudeRoot);
|
||||
expect(config.members).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'team-lead',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'bob',
|
||||
agentId: `bob@${teamName}`,
|
||||
agentType: 'general-purpose',
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/google/gemini-2.5-flash',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'jack',
|
||||
agentId: `jack@${teamName}`,
|
||||
agentType: 'general-purpose',
|
||||
role: 'Developer',
|
||||
workflow: 'Work in the isolated checkout.',
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/qwen/qwen3-coder',
|
||||
isolation: 'worktree',
|
||||
cwd: jackWorktree,
|
||||
}),
|
||||
]);
|
||||
expect(config.members.some((member) => member.name === 'alice')).toBe(false);
|
||||
});
|
||||
|
||||
it('launches isolated OpenCode side lanes from the resolved member worktree cwd', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
|
|
@ -9659,6 +9892,50 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('clears stale OpenCode bridge launch failure when the runtime process is verified alive', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||
async () =>
|
||||
new Map([
|
||||
[
|
||||
'bob',
|
||||
{
|
||||
alive: true,
|
||||
model: 'openrouter/google/gemini-2.5-flash',
|
||||
livenessKind: 'runtime_process',
|
||||
providerId: 'opencode',
|
||||
runtimeDiagnostic: 'OpenCode runtime process detected',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('12vector-room-10', {
|
||||
bob: createMemberSpawnStatusEntry({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
error: 'OpenCode bridge reported member launch failure',
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'OpenCode bridge reported member launch failure',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.bob).toMatchObject({
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
error: undefined,
|
||||
runtimeModel: 'openrouter/google/gemini-2.5-flash',
|
||||
livenessKind: 'runtime_process',
|
||||
runtimeDiagnostic: 'OpenCode runtime process detected',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
livenessSource: 'process',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps suffixed live runtime metadata keys back onto canonical spawn statuses', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const openTeamTab = vi.fn();
|
|||
const fetchCliStatus = vi.fn();
|
||||
const createSchedule = vi.fn();
|
||||
const updateSchedule = vi.fn();
|
||||
const teamRosterEditorSectionMock = vi.hoisted(() => ({ lastProps: null as any }));
|
||||
|
||||
const storeState = {
|
||||
appConfig: { general: { multimodelEnabled: true } },
|
||||
|
|
@ -144,6 +145,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
|||
providerId?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
isolation?: 'worktree';
|
||||
}>
|
||||
) =>
|
||||
members.map((member, index) => ({
|
||||
|
|
@ -153,6 +155,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
|||
roleSelection: '',
|
||||
customRole: member.role ?? '',
|
||||
workflow: member.workflow ?? '',
|
||||
isolation: member.isolation,
|
||||
providerId: member.providerId,
|
||||
model: member.model ?? '',
|
||||
effort: member.effort,
|
||||
|
|
@ -166,7 +169,10 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/members/TeamRosterEditorSection', () => ({
|
||||
TeamRosterEditorSection: () => React.createElement('div', null, 'team-roster-editor'),
|
||||
TeamRosterEditorSection: (props: any) => {
|
||||
teamRosterEditorSectionMock.lastProps = props;
|
||||
return React.createElement('div', null, 'team-roster-editor');
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/dialogs/SkipPermissionsCheckbox', () => ({
|
||||
|
|
@ -444,6 +450,7 @@ describe('LaunchTeamDialog', () => {
|
|||
vi.clearAllMocks();
|
||||
storeState.cliStatus = { providers: [] };
|
||||
storeState.launchParamsByTeam = {};
|
||||
teamRosterEditorSectionMock.lastProps = null;
|
||||
});
|
||||
|
||||
it('renders relaunch-specific title, warning and submit label', async () => {
|
||||
|
|
@ -485,6 +492,101 @@ describe('LaunchTeamDialog', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('passes existing teammate worktree path info to the roster editor', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(LaunchTeamDialog, {
|
||||
mode: 'launch',
|
||||
open: true,
|
||||
teamName: 'team-alpha',
|
||||
members: [
|
||||
{
|
||||
name: 'jack',
|
||||
role: 'developer',
|
||||
isolation: 'worktree',
|
||||
cwd: '/tmp/project/.worktrees/jack',
|
||||
},
|
||||
] as any,
|
||||
defaultProjectPath: '/tmp/project',
|
||||
provisioningError: null,
|
||||
clearProvisioningError: vi.fn(),
|
||||
activeTeams: [],
|
||||
onClose: vi.fn(),
|
||||
onLaunch: vi.fn(async () => {}),
|
||||
})
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(teamRosterEditorSectionMock.lastProps?.memberInfoById).toEqual({
|
||||
'draft-0':
|
||||
'This teammate will continue from its existing worktree: /tmp/project/.worktrees/jack',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves existing teammate worktree path info from saved launch request fallback', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
||||
teamName: 'team-alpha',
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.5',
|
||||
members: [
|
||||
{
|
||||
name: 'jack',
|
||||
role: 'developer',
|
||||
isolation: 'worktree',
|
||||
cwd: '/tmp/project/.worktrees/jack',
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/qwen/qwen3-coder',
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(LaunchTeamDialog, {
|
||||
mode: 'launch',
|
||||
open: true,
|
||||
teamName: 'team-alpha',
|
||||
members: [],
|
||||
defaultProjectPath: '/tmp/project',
|
||||
provisioningError: null,
|
||||
clearProvisioningError: vi.fn(),
|
||||
activeTeams: [],
|
||||
onClose: vi.fn(),
|
||||
onLaunch: vi.fn(async () => {}),
|
||||
})
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(teamRosterEditorSectionMock.lastProps?.memberInfoById).toEqual({
|
||||
'draft-0':
|
||||
'This teammate will continue from its existing worktree: /tmp/project/.worktrees/jack',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits relaunch through onRelaunch without replacing members in-dialog', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
|
|
|
|||
|
|
@ -49,11 +49,15 @@ vi.mock('@renderer/components/team/CollapsibleTeamSection', () => ({
|
|||
children,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
badge,
|
||||
headerExtra,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
badge?: React.ReactNode;
|
||||
headerExtra?: React.ReactNode;
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(defaultOpen);
|
||||
React.useEffect(() => {
|
||||
|
|
@ -68,7 +72,13 @@ vi.mock('@renderer/components/team/CollapsibleTeamSection', () => ({
|
|||
type: 'button',
|
||||
onClick: () => setOpen((value) => !value),
|
||||
},
|
||||
title
|
||||
title,
|
||||
badge !== undefined
|
||||
? React.createElement('span', { 'data-testid': `section-badge-${title}` }, badge)
|
||||
: null,
|
||||
headerExtra
|
||||
? React.createElement('span', { 'data-testid': `section-extra-${title}` }, headerExtra)
|
||||
: null
|
||||
),
|
||||
title === 'Changes' && open ? React.createElement('div', null, children) : null
|
||||
);
|
||||
|
|
@ -237,7 +247,7 @@ function makeSummary(taskId: string): TaskChangeSetV2 {
|
|||
|
||||
function clickChangesSection(host: HTMLElement): void {
|
||||
const button = [...host.querySelectorAll('button')].find(
|
||||
(candidate) => candidate.textContent === 'Changes'
|
||||
(candidate) => candidate.textContent?.startsWith('Changes') === true
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error('Changes section button not found');
|
||||
|
|
@ -250,6 +260,7 @@ describe('TaskDetailDialog changes summary loading', () => {
|
|||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not drop a new task changes request while another task summary is still in flight', async () => {
|
||||
|
|
@ -260,8 +271,8 @@ describe('TaskDetailDialog changes summary loading', () => {
|
|||
.mockImplementationOnce(() => first.promise)
|
||||
.mockImplementationOnce(() => second.promise);
|
||||
|
||||
const taskA = makeTask('task-a');
|
||||
const taskB = makeTask('task-b');
|
||||
const taskA: TeamTaskWithKanban = { ...makeTask('task-a'), changePresence: 'has_changes' };
|
||||
const taskB: TeamTaskWithKanban = { ...makeTask('task-b'), changePresence: 'has_changes' };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
|
@ -335,4 +346,187 @@ describe('TaskDetailDialog changes summary loading', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the changes section lazy-loadable when the task needs attention', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
hoisted.getTaskChanges.mockResolvedValueOnce({
|
||||
...makeSummary('task-attention'),
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
confidence: 'low',
|
||||
warnings: ['No file changes were recorded for this task.'],
|
||||
});
|
||||
|
||||
const task: TeamTaskWithKanban = {
|
||||
...makeTask('task-attention'),
|
||||
changePresence: 'needs_attention',
|
||||
};
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskDetailDialog, {
|
||||
open: true,
|
||||
variant: 'team',
|
||||
teamName: 'team-a',
|
||||
task,
|
||||
taskMap: new Map<string, TeamTaskWithKanban>(),
|
||||
members: [],
|
||||
onClose: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
[...host.querySelectorAll('button')].some((button) => button.textContent === 'Changes')
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
clickChangesSection(host);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith(
|
||||
'team-a',
|
||||
'task-attention',
|
||||
expect.objectContaining({ summaryOnly: true })
|
||||
);
|
||||
expect(host.textContent).toContain('No file changes recorded');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('preloads the changes summary after 1.5 seconds and shows header loading state', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const request = deferred<TaskChangeSetV2>();
|
||||
hoisted.getTaskChanges.mockImplementationOnce(() => request.promise);
|
||||
|
||||
const task: TeamTaskWithKanban = { ...makeTask('task-autoload'), changePresence: 'unknown' };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskDetailDialog, {
|
||||
open: true,
|
||||
variant: 'team',
|
||||
teamName: 'team-a',
|
||||
task,
|
||||
taskMap: new Map<string, TeamTaskWithKanban>(),
|
||||
members: [],
|
||||
onClose: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.getTaskChanges).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_499);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(hoisted.getTaskChanges).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith(
|
||||
'team-a',
|
||||
'task-autoload',
|
||||
expect.objectContaining({ summaryOnly: true, forceFresh: false })
|
||||
);
|
||||
expect(host.querySelector('[data-testid="section-badge-Changes"]')).toBeNull();
|
||||
expect(
|
||||
host.querySelector('[data-testid="section-extra-Changes"] .animate-spin')
|
||||
).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
request.resolve(makeSummary('task-autoload'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(host.querySelector('[data-testid="section-badge-Changes"]')?.textContent).toBe('1');
|
||||
|
||||
await act(async () => {
|
||||
clickChangesSection(host);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1);
|
||||
expect(host.textContent).toContain('src/task-autoload.ts');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the changes section visible for pending tasks and loads without a review handler', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
hoisted.getTaskChanges.mockResolvedValueOnce(makeSummary('task-pending'));
|
||||
|
||||
const task: TeamTaskWithKanban = {
|
||||
...makeTask('task-pending'),
|
||||
status: 'pending',
|
||||
changePresence: 'unknown',
|
||||
workIntervals: [],
|
||||
} as unknown as TeamTaskWithKanban;
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskDetailDialog, {
|
||||
open: true,
|
||||
variant: 'team',
|
||||
teamName: 'team-a',
|
||||
task,
|
||||
taskMap: new Map<string, TeamTaskWithKanban>(),
|
||||
members: [],
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
[...host.querySelectorAll('button')].some((button) => button.textContent === 'Changes')
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
clickChangesSection(host);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith(
|
||||
'team-a',
|
||||
'task-pending',
|
||||
expect.objectContaining({ summaryOnly: true })
|
||||
);
|
||||
expect(host.textContent).toContain('src/task-pending.ts');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -74,4 +74,30 @@ describe('CurrentTaskIndicator', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('syncs the spinner animation phase across independently mounted indicators', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(CurrentTaskIndicator, {
|
||||
task,
|
||||
borderColor: '#3b82f6',
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const spinner = host.querySelector('svg.animate-spin') as SVGElement | null;
|
||||
expect(spinner?.style.animationDelay).toMatch(/^-?\d+(\.\d+)?ms$/);
|
||||
expect(spinner?.style.animationDuration).toBe('1000ms');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ describe('MemberCard starting-state visuals', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps runtime-pending accessibility copy honest even when launch badge is hidden by an active task', async () => {
|
||||
it('keeps runtime-pending launch status visible even when the teammate has an active task', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -254,6 +254,7 @@ describe('MemberCard starting-state visuals', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('waiting for bootstrap');
|
||||
expect(host.textContent).not.toContain('online');
|
||||
expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull();
|
||||
|
||||
|
|
@ -263,6 +264,50 @@ describe('MemberCard starting-state visuals', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps registered-only OpenCode status visible next to active task context', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member: {
|
||||
...member,
|
||||
providerId: 'opencode',
|
||||
currentTaskId: currentTask.id,
|
||||
},
|
||||
memberColor: 'blue',
|
||||
currentTask,
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'waiting',
|
||||
spawnLaunchState: 'runtime_pending_bootstrap',
|
||||
spawnRuntimeAlive: false,
|
||||
runtimeEntry: {
|
||||
memberName: 'alice',
|
||||
alive: false,
|
||||
restartable: false,
|
||||
providerId: 'opencode',
|
||||
livenessKind: 'registered_only',
|
||||
runtimeDiagnostic: 'registered runtime metadata without live process',
|
||||
updatedAt: '2026-04-27T12:17:58.714Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('registered');
|
||||
expect(host.querySelector('[aria-label="registered"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the starting treatment and runtime summary visible while a runtime is still joining', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
|
|
@ -520,11 +565,8 @@ describe('MemberCard starting-state visuals', () => {
|
|||
});
|
||||
|
||||
expect(host.textContent).toContain('worktree');
|
||||
expect(
|
||||
host.querySelector(
|
||||
'[title="Worktree isolation configured. Worktree path: /tmp/project-alice-worktree"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(host.textContent).toContain('Worktree isolation is enabled.');
|
||||
expect(host.textContent).toContain('Path: /tmp/project-alice-worktree');
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
|
|
@ -552,13 +594,8 @@ describe('MemberCard starting-state visuals', () => {
|
|||
});
|
||||
|
||||
expect(host.textContent).toContain('worktree');
|
||||
expect(
|
||||
host.querySelector(
|
||||
'[title="Worktree isolation is configured, but the runtime path is not available yet"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(host.querySelector('[title="Worktree isolation configured. Runtime cwd: /tmp/project"]'))
|
||||
.toBeNull();
|
||||
expect(host.textContent).toContain('Path is not available yet.');
|
||||
expect(host.textContent).not.toContain('Runtime cwd: /tmp/project');
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
|
|
@ -1006,4 +1043,40 @@ describe('MemberCard starting-state visuals', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('moves worktree branch details into the worktree badge tooltip', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member: {
|
||||
...member,
|
||||
name: 'jack',
|
||||
isolation: 'worktree',
|
||||
cwd: '/Users/belief/.claude/team-worktrees/sol-team-proj-abc/room/jack',
|
||||
gitBranch: 'agent-teams/room/jack-abc',
|
||||
},
|
||||
memberColor: 'turquoise',
|
||||
isTeamAlive: true,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('worktree');
|
||||
expect(host.textContent).toContain(
|
||||
'Path: /Users/belief/.claude/team-worktrees/sol-team-proj-abc/room/jack'
|
||||
);
|
||||
expect(host.textContent).toContain('Branch: agent-teams/room/jack-abc');
|
||||
expect(host.textContent?.match(/agent-teams\/room\/jack-abc/g)).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MemberPresenceDot } from '@renderer/components/team/members/MemberPresenceDot';
|
||||
|
||||
describe('MemberPresenceDot', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('uses a shared wall-clock phase for pulse animations', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
vi.spyOn(performance, 'now').mockReturnValue(725);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberPresenceDot, {
|
||||
className: 'size-2.5 bg-emerald-400 animate-pulse',
|
||||
label: 'ready',
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const dot = host.querySelector('span') as HTMLSpanElement | null;
|
||||
expect(dot?.style.animationDelay).toBe('-725ms');
|
||||
expect(dot?.style.animationDuration).toBe('2000ms');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add animation timing to static status dots', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberPresenceDot, {
|
||||
className: 'size-2.5 bg-zinc-600',
|
||||
label: 'offline',
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const dot = host.querySelector('span') as HTMLSpanElement | null;
|
||||
expect(dot?.style.animationDelay).toBe('');
|
||||
expect(dot?.style.animationDuration).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -52,6 +52,18 @@ describe('memberHelpers spawn-aware presence', () => {
|
|||
undefined
|
||||
)
|
||||
).toContain('bg-emerald-400');
|
||||
expect(
|
||||
getSpawnAwareDotClass(
|
||||
member,
|
||||
'online',
|
||||
'runtime_pending_bootstrap',
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
undefined
|
||||
)
|
||||
).toContain('animate-pulse');
|
||||
});
|
||||
|
||||
it('keeps accepted-but-not-yet-online teammates in starting state', () => {
|
||||
|
|
|
|||
|
|
@ -45,3 +45,25 @@ describe('contentSanitizer task notifications', () => {
|
|||
expect(parseTaskNotifications('normal user content')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contentSanitizer OpenCode delivery envelopes', () => {
|
||||
it('hides OpenCode delivery instructions and retry metadata while keeping inbound content', () => {
|
||||
const content = [
|
||||
'<opencode_app_message_delivery>',
|
||||
'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send.',
|
||||
'</opencode_app_message_delivery>',
|
||||
'',
|
||||
'<opencode_inbound_app_message>',
|
||||
'<opencode_delivery_retry>',
|
||||
'This is retry attempt 3/3 for inbound app messageId "message-1".',
|
||||
'</opencode_delivery_retry>',
|
||||
'',
|
||||
'New task assigned to you: #task-a Investigate failing command',
|
||||
'</opencode_inbound_app_message>',
|
||||
].join('\n');
|
||||
|
||||
expect(sanitizeDisplayContent(content)).toBe(
|
||||
'New task assigned to you: #task-a Investigate failing command'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue