fix(team): stabilize mixed launch runtime states
This commit is contained in:
parent
d20fe2a538
commit
cde85c0396
16 changed files with 634 additions and 55 deletions
|
|
@ -545,6 +545,7 @@ export class TeamGraphAdapter {
|
||||||
spawnLaunchState: spawn?.launchState,
|
spawnLaunchState: spawn?.launchState,
|
||||||
spawnLivenessSource: spawn?.livenessSource,
|
spawnLivenessSource: spawn?.livenessSource,
|
||||||
spawnRuntimeAlive: spawn?.runtimeAlive,
|
spawnRuntimeAlive: spawn?.runtimeAlive,
|
||||||
|
spawnBootstrapConfirmed: spawn?.bootstrapConfirmed,
|
||||||
spawnBootstrapStalled: spawn?.bootstrapStalled,
|
spawnBootstrapStalled: spawn?.bootstrapStalled,
|
||||||
runtimeAdvisory: member.runtimeAdvisory,
|
runtimeAdvisory: member.runtimeAdvisory,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,7 @@ const MemberPopoverContent = ({
|
||||||
spawnLaunchState: spawnEntry?.launchState,
|
spawnLaunchState: spawnEntry?.launchState,
|
||||||
spawnLivenessSource: spawnEntry?.livenessSource,
|
spawnLivenessSource: spawnEntry?.livenessSource,
|
||||||
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||||
|
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
|
||||||
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
||||||
runtimeAdvisory: member.runtimeAdvisory,
|
runtimeAdvisory: member.runtimeAdvisory,
|
||||||
isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false,
|
isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false,
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,24 @@ import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
|
||||||
|
|
||||||
const logger = createLogger('IPC:teams');
|
const logger = createLogger('IPC:teams');
|
||||||
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000;
|
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000;
|
||||||
|
|
||||||
|
type VisibleDirectReplyProtocol = 'send_message' | 'agent_teams_message_send';
|
||||||
|
|
||||||
|
function resolveVisibleDirectReplyProtocol(input: {
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
isLeadRecipient: boolean;
|
||||||
|
replyRecipient: string;
|
||||||
|
}): VisibleDirectReplyProtocol {
|
||||||
|
if (
|
||||||
|
!input.isLeadRecipient &&
|
||||||
|
input.replyRecipient.trim().toLowerCase() === 'user' &&
|
||||||
|
input.providerId === 'codex'
|
||||||
|
) {
|
||||||
|
return 'agent_teams_message_send';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'send_message';
|
||||||
|
}
|
||||||
const TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS = 250;
|
const TEAM_DATA_DRAFT_CLASSIFICATION_ACCESS_TIMEOUT_MS = 250;
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
|
@ -2473,7 +2491,11 @@ function buildMessageDeliveryText(
|
||||||
opts: {
|
opts: {
|
||||||
actionMode?: AgentActionMode;
|
actionMode?: AgentActionMode;
|
||||||
isLeadRecipient: boolean;
|
isLeadRecipient: boolean;
|
||||||
|
memberName?: string;
|
||||||
|
messageId?: string;
|
||||||
|
protocol?: VisibleDirectReplyProtocol;
|
||||||
replyRecipient?: string;
|
replyRecipient?: string;
|
||||||
|
teamName?: string;
|
||||||
}
|
}
|
||||||
): string {
|
): string {
|
||||||
const hiddenBlocks: string[] = [];
|
const hiddenBlocks: string[] = [];
|
||||||
|
|
@ -2482,22 +2504,49 @@ function buildMessageDeliveryText(
|
||||||
hiddenBlocks.push(actionModeBlock);
|
hiddenBlocks.push(actionModeBlock);
|
||||||
}
|
}
|
||||||
if (!opts.isLeadRecipient) {
|
if (!opts.isLeadRecipient) {
|
||||||
const replyRecipient =
|
const rawReplyRecipient =
|
||||||
typeof opts.replyRecipient === 'string' && opts.replyRecipient.trim().length > 0
|
typeof opts.replyRecipient === 'string' && opts.replyRecipient.trim().length > 0
|
||||||
? opts.replyRecipient.trim()
|
? opts.replyRecipient.trim()
|
||||||
: 'user';
|
: 'user';
|
||||||
const senderDescriptor = replyRecipient === 'user' ? 'the human user' : `"${replyRecipient}"`;
|
const isUserReplyRecipient = rawReplyRecipient.toLowerCase() === 'user';
|
||||||
|
const replyRecipient = isUserReplyRecipient ? 'user' : rawReplyRecipient;
|
||||||
|
const senderDescriptor = isUserReplyRecipient ? 'the human user' : `"${replyRecipient}"`;
|
||||||
|
const protocol = opts.protocol ?? 'send_message';
|
||||||
|
const canUseAgentTeamsMessageSend =
|
||||||
|
protocol === 'agent_teams_message_send' &&
|
||||||
|
isUserReplyRecipient &&
|
||||||
|
typeof opts.teamName === 'string' &&
|
||||||
|
opts.teamName.trim().length > 0 &&
|
||||||
|
typeof opts.memberName === 'string' &&
|
||||||
|
opts.memberName.trim().length > 0 &&
|
||||||
|
typeof opts.messageId === 'string' &&
|
||||||
|
opts.messageId.trim().length > 0;
|
||||||
|
const replyInstructionLines = canUseAgentTeamsMessageSend
|
||||||
|
? [
|
||||||
|
'CRITICAL: Reply using the Agent Teams MCP message_send tool, not SendMessage.',
|
||||||
|
'Use tool agent-teams_message_send or mcp__agent-teams__message_send, whichever exposed name is available.',
|
||||||
|
`CRITICAL: The tool input must include teamName="${opts.teamName!.trim()}", to="user", from="${opts.memberName!.trim()}", text, summary, source="runtime_delivery", and relayOfMessageId="${opts.messageId!.trim()}".`,
|
||||||
|
'Do NOT answer only with normal assistant text when the Agent Teams message_send tool is available because that will not appear in the UI message thread.',
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'CRITICAL: Reply using the SendMessage tool, not plain assistant text.',
|
||||||
|
`CRITICAL: The destination must be exactly to="${replyRecipient}".`,
|
||||||
|
'CRITICAL: The SendMessage tool input must use the exact field names `to`, `summary`, and `message`.',
|
||||||
|
'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.',
|
||||||
|
];
|
||||||
hiddenBlocks.push(
|
hiddenBlocks.push(
|
||||||
[
|
[
|
||||||
AGENT_BLOCK_OPEN,
|
AGENT_BLOCK_OPEN,
|
||||||
`You received a direct message from ${senderDescriptor} via the UI.`,
|
`You received a direct message from ${senderDescriptor} via the UI.`,
|
||||||
'CRITICAL: Reply using the SendMessage tool, not plain assistant text.',
|
...replyInstructionLines,
|
||||||
`CRITICAL: The destination must be exactly to="${replyRecipient}".`,
|
|
||||||
'CRITICAL: The SendMessage tool input must use the exact field names `to`, `summary`, and `message`.',
|
|
||||||
'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.',
|
|
||||||
`Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`,
|
`Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`,
|
||||||
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
|
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
|
||||||
...(replyRecipient === 'user'
|
...(canUseAgentTeamsMessageSend
|
||||||
|
? [
|
||||||
|
'If neither Agent Teams MCP message_send tool name is available before any visible-message tool attempt, write exactly the concise reply text as normal assistant text so the runtime can relay it.',
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(isUserReplyRecipient
|
||||||
? [
|
? [
|
||||||
'CRITICAL: If the user asks you to check with the lead or another teammate before you can fully answer, FIRST send a short acknowledgement to "user" so the human sees you started (for example: "Принял, сейчас уточню и вернусь с ответом.").',
|
'CRITICAL: If the user asks you to check with the lead or another teammate before you can fully answer, FIRST send a short acknowledgement to "user" so the human sees you started (for example: "Принял, сейчас уточню и вернусь с ответом.").',
|
||||||
'Only after that first acknowledgement may you message the lead or another teammate.',
|
'Only after that first acknowledgement may you message the lead or another teammate.',
|
||||||
|
|
@ -2848,22 +2897,37 @@ async function handleSendMessage(
|
||||||
typeof payload.from === 'string' && payload.from.trim().length > 0
|
typeof payload.from === 'string' && payload.from.trim().length > 0
|
||||||
? payload.from.trim()
|
? payload.from.trim()
|
||||||
: 'user';
|
: 'user';
|
||||||
const isOpenCodeRecipient =
|
const storedFrom = replyRecipient.toLowerCase() === 'user' ? 'user' : replyRecipient;
|
||||||
!isLeadRecipient && (await provisioning.isOpenCodeRuntimeRecipient(tn, memberName));
|
const recipientProviderId = !isLeadRecipient
|
||||||
|
? await provisioning.resolveRuntimeRecipientProviderId(tn, memberName)
|
||||||
|
: undefined;
|
||||||
|
const isOpenCodeRecipient = recipientProviderId === 'opencode';
|
||||||
|
const directReplyProtocol = resolveVisibleDirectReplyProtocol({
|
||||||
|
isLeadRecipient,
|
||||||
|
replyRecipient,
|
||||||
|
...(recipientProviderId ? { providerId: recipientProviderId } : {}),
|
||||||
|
});
|
||||||
|
const inboxMessageId =
|
||||||
|
directReplyProtocol === 'agent_teams_message_send' ? crypto.randomUUID() : undefined;
|
||||||
const memberDeliveryText = buildMessageDeliveryText(baseText, {
|
const memberDeliveryText = buildMessageDeliveryText(baseText, {
|
||||||
actionMode,
|
actionMode,
|
||||||
isLeadRecipient,
|
isLeadRecipient,
|
||||||
|
memberName,
|
||||||
|
protocol: directReplyProtocol,
|
||||||
replyRecipient,
|
replyRecipient,
|
||||||
|
teamName: tn,
|
||||||
|
...(inboxMessageId ? { messageId: inboxMessageId } : {}),
|
||||||
});
|
});
|
||||||
const inboxText = isOpenCodeRecipient ? baseText : memberDeliveryText;
|
const inboxText = isOpenCodeRecipient ? baseText : memberDeliveryText;
|
||||||
const result = await getTeamDataService().sendMessage(tn, {
|
const result = await getTeamDataService().sendMessage(tn, {
|
||||||
member: memberName,
|
member: memberName,
|
||||||
text: inboxText,
|
text: inboxText,
|
||||||
summary: payload.summary,
|
summary: payload.summary,
|
||||||
from: payload.from,
|
from: storedFrom,
|
||||||
actionMode,
|
actionMode,
|
||||||
source: 'user_sent',
|
source: 'user_sent',
|
||||||
taskRefs: validatedTaskRefs.value,
|
taskRefs: validatedTaskRefs.value,
|
||||||
|
...(inboxMessageId ? { messageId: inboxMessageId } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Teammate inbox relay DISABLED (2026-03-23).
|
// Teammate inbox relay DISABLED (2026-03-23).
|
||||||
|
|
|
||||||
|
|
@ -2417,6 +2417,11 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: {
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
const runtimeAlive = true;
|
const runtimeAlive = true;
|
||||||
|
const livenessKind =
|
||||||
|
input.current.livenessKind === 'runtime_process' ||
|
||||||
|
input.current.livenessKind === 'confirmed_bootstrap'
|
||||||
|
? input.current.livenessKind
|
||||||
|
: 'confirmed_bootstrap';
|
||||||
return {
|
return {
|
||||||
...input.previous,
|
...input.previous,
|
||||||
...input.current,
|
...input.current,
|
||||||
|
|
@ -2428,12 +2433,7 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: {
|
||||||
hardFailureReason: undefined,
|
hardFailureReason: undefined,
|
||||||
runtimeRunId: input.session.runId ?? input.current.runtimeRunId,
|
runtimeRunId: input.session.runId ?? input.current.runtimeRunId,
|
||||||
runtimeSessionId: input.session.id,
|
runtimeSessionId: input.session.id,
|
||||||
livenessKind: runtimeAlive
|
livenessKind,
|
||||||
? input.current.livenessKind
|
|
||||||
: input.current.livenessKind === 'runtime_process' ||
|
|
||||||
input.current.livenessKind === 'runtime_process_candidate'
|
|
||||||
? input.current.livenessKind
|
|
||||||
: 'confirmed_bootstrap',
|
|
||||||
runtimeDiagnostic: 'OpenCode bootstrap evidence committed.',
|
runtimeDiagnostic: 'OpenCode bootstrap evidence committed.',
|
||||||
runtimeDiagnosticSeverity: 'info',
|
runtimeDiagnosticSeverity: 'info',
|
||||||
firstSpawnAcceptedAt:
|
firstSpawnAcceptedAt:
|
||||||
|
|
@ -6280,17 +6280,28 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise<boolean> {
|
async resolveRuntimeRecipientProviderId(
|
||||||
|
teamName: string,
|
||||||
|
memberName: string
|
||||||
|
): Promise<TeamProviderId | undefined> {
|
||||||
const normalizedMemberName = memberName.trim().toLowerCase();
|
const normalizedMemberName = memberName.trim().toLowerCase();
|
||||||
if (!normalizedMemberName) {
|
if (!normalizedMemberName) {
|
||||||
return false;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [config, metaMembers] = await Promise.all([
|
const [config, metaMembers] = await Promise.all([
|
||||||
this.readConfigSnapshot(teamName).catch(() => null),
|
this.readConfigSnapshot(teamName).catch(() => null),
|
||||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||||
]);
|
]);
|
||||||
return this.isOpenCodeRuntimeRecipientFromSources(normalizedMemberName, config, metaMembers);
|
return this.resolveRuntimeRecipientProviderIdFromSources(
|
||||||
|
normalizedMemberName,
|
||||||
|
config,
|
||||||
|
metaMembers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise<boolean> {
|
||||||
|
return (await this.resolveRuntimeRecipientProviderId(teamName, memberName)) === 'opencode';
|
||||||
}
|
}
|
||||||
|
|
||||||
private isOpenCodeDeliveryResponseReadCommitAllowed(input: {
|
private isOpenCodeDeliveryResponseReadCommitAllowed(input: {
|
||||||
|
|
@ -18829,10 +18840,17 @@ export class TeamProvisioningService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata);
|
const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata);
|
||||||
|
const metadataLivenessKind =
|
||||||
|
current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive'
|
||||||
|
? metadata.livenessKind === 'runtime_process' ||
|
||||||
|
metadata.livenessKind === 'confirmed_bootstrap'
|
||||||
|
? metadata.livenessKind
|
||||||
|
: current.livenessKind
|
||||||
|
: metadata.livenessKind;
|
||||||
const nextEntry: MemberSpawnStatusEntry = {
|
const nextEntry: MemberSpawnStatusEntry = {
|
||||||
...current,
|
...current,
|
||||||
...(metadata.model ? { runtimeModel: metadata.model } : {}),
|
...(metadata.model ? { runtimeModel: metadata.model } : {}),
|
||||||
...(metadata.livenessKind ? { livenessKind: metadata.livenessKind } : {}),
|
...(metadataLivenessKind ? { livenessKind: metadataLivenessKind } : {}),
|
||||||
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||||
...(metadata.runtimeDiagnosticSeverity
|
...(metadata.runtimeDiagnosticSeverity
|
||||||
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
|
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
|
||||||
|
|
@ -19740,7 +19758,8 @@ export class TeamProvisioningService {
|
||||||
return (
|
return (
|
||||||
previous?.launchState !== member.launchState ||
|
previous?.launchState !== member.launchState ||
|
||||||
previous?.bootstrapConfirmed !== member.bootstrapConfirmed ||
|
previous?.bootstrapConfirmed !== member.bootstrapConfirmed ||
|
||||||
previous?.runtimeSessionId !== member.runtimeSessionId
|
previous?.runtimeSessionId !== member.runtimeSessionId ||
|
||||||
|
previous?.livenessKind !== member.livenessKind
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -19773,7 +19792,9 @@ export class TeamProvisioningService {
|
||||||
previous: PersistedTeamLaunchMemberState | null
|
previous: PersistedTeamLaunchMemberState | null
|
||||||
): boolean {
|
): boolean {
|
||||||
if (current.launchState === 'confirmed_alive' && current.bootstrapConfirmed) {
|
if (current.launchState === 'confirmed_alive' && current.bootstrapConfirmed) {
|
||||||
return false;
|
return (
|
||||||
|
current.livenessKind !== 'confirmed_bootstrap' && current.livenessKind !== 'runtime_process'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
previous?.launchState === 'confirmed_alive' &&
|
previous?.launchState === 'confirmed_alive' &&
|
||||||
|
|
@ -25257,7 +25278,7 @@ export class TeamProvisioningService {
|
||||||
if (!hasSpawnFailures && !hasPendingBootstrap) {
|
if (!hasSpawnFailures && !hasPendingBootstrap) {
|
||||||
// Fire "Team Launched" notification only for clean launches.
|
// Fire "Team Launched" notification only for clean launches.
|
||||||
void this.fireTeamLaunchedNotification(run);
|
void this.fireTeamLaunchedNotification(run);
|
||||||
} else {
|
} else if (hasSpawnFailures) {
|
||||||
void this.fireTeamLaunchIncompleteNotification(
|
void this.fireTeamLaunchIncompleteNotification(
|
||||||
run,
|
run,
|
||||||
failedSpawnMembers,
|
failedSpawnMembers,
|
||||||
|
|
@ -25442,7 +25463,7 @@ export class TeamProvisioningService {
|
||||||
if (!hasSpawnFailures && !hasPendingBootstrap) {
|
if (!hasSpawnFailures && !hasPendingBootstrap) {
|
||||||
// Fire "Team Launched" notification only for clean launches.
|
// Fire "Team Launched" notification only for clean launches.
|
||||||
void this.fireTeamLaunchedNotification(run);
|
void this.fireTeamLaunchedNotification(run);
|
||||||
} else {
|
} else if (hasSpawnFailures) {
|
||||||
void this.fireTeamLaunchIncompleteNotification(
|
void this.fireTeamLaunchIncompleteNotification(
|
||||||
run,
|
run,
|
||||||
failedSpawnMembers,
|
failedSpawnMembers,
|
||||||
|
|
@ -25551,6 +25572,9 @@ export class TeamProvisioningService {
|
||||||
failedMembers,
|
failedMembers,
|
||||||
snapshot
|
snapshot
|
||||||
);
|
);
|
||||||
|
if (failedNames.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const pendingNames = this.getLaunchIncompletePendingNames(
|
const pendingNames = this.getLaunchIncompletePendingNames(
|
||||||
run,
|
run,
|
||||||
expectedMembers,
|
expectedMembers,
|
||||||
|
|
@ -25627,6 +25651,15 @@ export class TeamProvisioningService {
|
||||||
const failedNames = new Set(failedMembers.map((member) => member.name).filter(Boolean));
|
const failedNames = new Set(failedMembers.map((member) => member.name).filter(Boolean));
|
||||||
for (const memberName of expectedMembers) {
|
for (const memberName of expectedMembers) {
|
||||||
const { live, persisted } = this.getLaunchIncompleteMemberEvidence(run, snapshot, memberName);
|
const { live, persisted } = this.getLaunchIncompleteMemberEvidence(run, snapshot, memberName);
|
||||||
|
const liveResolved =
|
||||||
|
live?.launchState === 'confirmed_alive' ||
|
||||||
|
live?.bootstrapConfirmed === true ||
|
||||||
|
live?.launchState === 'skipped_for_launch' ||
|
||||||
|
live?.skippedForLaunch === true;
|
||||||
|
if (liveResolved) {
|
||||||
|
failedNames.delete(memberName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
live?.launchState === 'failed_to_start' ||
|
live?.launchState === 'failed_to_start' ||
|
||||||
persisted?.launchState === 'failed_to_start' ||
|
persisted?.launchState === 'failed_to_start' ||
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ export const MemberCard = memo(function MemberCard({
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
|
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
|
||||||
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
runtimeAdvisory: member.runtimeAdvisory,
|
runtimeAdvisory: member.runtimeAdvisory,
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,7 @@ export const MemberDetailDialog = ({
|
||||||
spawnLaunchState={spawnEntry?.launchState}
|
spawnLaunchState={spawnEntry?.launchState}
|
||||||
spawnLivenessSource={spawnEntry?.livenessSource}
|
spawnLivenessSource={spawnEntry?.livenessSource}
|
||||||
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
||||||
|
spawnBootstrapConfirmed={spawnEntry?.bootstrapConfirmed}
|
||||||
spawnBootstrapStalled={spawnEntry?.bootstrapStalled}
|
spawnBootstrapStalled={spawnEntry?.bootstrapStalled}
|
||||||
runtimeEntry={runtimeEntry}
|
runtimeEntry={runtimeEntry}
|
||||||
isLaunchSettling={isLaunchSettling}
|
isLaunchSettling={isLaunchSettling}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ interface MemberDetailHeaderProps {
|
||||||
spawnLaunchState?: MemberLaunchState;
|
spawnLaunchState?: MemberLaunchState;
|
||||||
spawnLivenessSource?: MemberSpawnLivenessSource;
|
spawnLivenessSource?: MemberSpawnLivenessSource;
|
||||||
spawnRuntimeAlive?: boolean;
|
spawnRuntimeAlive?: boolean;
|
||||||
|
spawnBootstrapConfirmed?: boolean;
|
||||||
spawnBootstrapStalled?: boolean;
|
spawnBootstrapStalled?: boolean;
|
||||||
isLaunchSettling?: boolean;
|
isLaunchSettling?: boolean;
|
||||||
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
|
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
|
||||||
|
|
@ -55,6 +56,7 @@ export const MemberDetailHeader = ({
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
|
spawnBootstrapConfirmed,
|
||||||
spawnBootstrapStalled,
|
spawnBootstrapStalled,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
onUpdateRole,
|
onUpdateRole,
|
||||||
|
|
@ -81,6 +83,7 @@ export const MemberDetailHeader = ({
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
|
spawnBootstrapConfirmed,
|
||||||
spawnBootstrapStalled,
|
spawnBootstrapStalled,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
runtimeAdvisory: member.runtimeAdvisory,
|
runtimeAdvisory: member.runtimeAdvisory,
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
||||||
spawnLaunchState: spawnEntry?.launchState,
|
spawnLaunchState: spawnEntry?.launchState,
|
||||||
spawnLivenessSource: spawnEntry?.livenessSource,
|
spawnLivenessSource: spawnEntry?.livenessSource,
|
||||||
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||||
|
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
|
||||||
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
runtimeAdvisory: member.runtimeAdvisory,
|
runtimeAdvisory: member.runtimeAdvisory,
|
||||||
|
|
|
||||||
|
|
@ -232,11 +232,14 @@ function buildSpawnBackedDisplayRow(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (spawn.status === 'online' && hasConfirmedSpawnLiveness(spawn)) {
|
if (
|
||||||
|
(spawn.status === 'online' && hasConfirmedSpawnLiveness(spawn)) ||
|
||||||
|
isConfirmedSpawnLaunch(spawn)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
memberName,
|
memberName,
|
||||||
state: 'running',
|
state: 'running',
|
||||||
stateReason: spawn.runtimeDiagnostic ?? 'Spawn status is online',
|
stateReason: spawn.runtimeDiagnostic ?? 'Bootstrap confirmed',
|
||||||
source: 'spawn-status',
|
source: 'spawn-status',
|
||||||
updatedAt: spawn.livenessLastCheckedAt ?? spawn.lastHeartbeatAt ?? spawn.updatedAt,
|
updatedAt: spawn.livenessLastCheckedAt ?? spawn.lastHeartbeatAt ?? spawn.updatedAt,
|
||||||
runtimeModel: spawn.runtimeModel,
|
runtimeModel: spawn.runtimeModel,
|
||||||
|
|
@ -299,6 +302,10 @@ function hasConfirmedSpawnLiveness(spawn: MemberSpawnStatusEntry): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConfirmedSpawnLaunch(spawn: MemberSpawnStatusEntry): boolean {
|
||||||
|
return spawn.launchState === 'confirmed_alive' && spawn.bootstrapConfirmed === true;
|
||||||
|
}
|
||||||
|
|
||||||
function formatRuntimePidLabel(runtime: TeamAgentRuntimeEntry): string | undefined {
|
function formatRuntimePidLabel(runtime: TeamAgentRuntimeEntry): string | undefined {
|
||||||
const runtimePid = getFinitePid(runtime.runtimePid);
|
const runtimePid = getFinitePid(runtime.runtimePid);
|
||||||
if (runtimePid != null) return `runtime pid ${runtimePid}`;
|
if (runtimePid != null) return `runtime pid ${runtimePid}`;
|
||||||
|
|
|
||||||
|
|
@ -746,6 +746,7 @@ export function buildMemberLaunchPresentation({
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
|
spawnBootstrapConfirmed,
|
||||||
spawnBootstrapStalled,
|
spawnBootstrapStalled,
|
||||||
runtimeAdvisory,
|
runtimeAdvisory,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
|
|
@ -759,6 +760,7 @@ export function buildMemberLaunchPresentation({
|
||||||
spawnLaunchState: MemberLaunchState | undefined;
|
spawnLaunchState: MemberLaunchState | undefined;
|
||||||
spawnLivenessSource: MemberSpawnLivenessSource | undefined;
|
spawnLivenessSource: MemberSpawnLivenessSource | undefined;
|
||||||
spawnRuntimeAlive: boolean | undefined;
|
spawnRuntimeAlive: boolean | undefined;
|
||||||
|
spawnBootstrapConfirmed?: boolean;
|
||||||
spawnBootstrapStalled?: boolean;
|
spawnBootstrapStalled?: boolean;
|
||||||
runtimeAdvisory: MemberRuntimeAdvisory | undefined;
|
runtimeAdvisory: MemberRuntimeAdvisory | undefined;
|
||||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
|
|
@ -767,12 +769,19 @@ export function buildMemberLaunchPresentation({
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
}): MemberLaunchPresentation {
|
}): MemberLaunchPresentation {
|
||||||
|
const hasConfirmedSpawnLaunch =
|
||||||
|
spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true;
|
||||||
|
const effectiveSpawnStatus =
|
||||||
|
hasConfirmedSpawnLaunch && (spawnStatus === 'waiting' || spawnStatus === 'spawning')
|
||||||
|
? 'online'
|
||||||
|
: spawnStatus;
|
||||||
|
const effectiveSpawnRuntimeAlive = hasConfirmedSpawnLaunch ? true : spawnRuntimeAlive;
|
||||||
const presenceLabel = getLaunchAwarePresenceLabel(
|
const presenceLabel = getLaunchAwarePresenceLabel(
|
||||||
member,
|
member,
|
||||||
spawnStatus,
|
effectiveSpawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnRuntimeAlive,
|
effectiveSpawnRuntimeAlive,
|
||||||
runtimeAdvisory,
|
runtimeAdvisory,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
|
|
@ -781,18 +790,18 @@ export function buildMemberLaunchPresentation({
|
||||||
);
|
);
|
||||||
const baseDotClass = getSpawnAwareDotClass(
|
const baseDotClass = getSpawnAwareDotClass(
|
||||||
member,
|
member,
|
||||||
spawnStatus,
|
effectiveSpawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnRuntimeAlive,
|
effectiveSpawnRuntimeAlive,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity
|
leadActivity
|
||||||
);
|
);
|
||||||
const cardClass = getSpawnCardClass(
|
const cardClass = getSpawnCardClass(
|
||||||
spawnStatus,
|
effectiveSpawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnRuntimeAlive,
|
effectiveSpawnRuntimeAlive,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning
|
isTeamProvisioning
|
||||||
|
|
@ -812,18 +821,23 @@ export function buildMemberLaunchPresentation({
|
||||||
launchVisualState = 'permission_pending';
|
launchVisualState = 'permission_pending';
|
||||||
} else if (spawnBootstrapStalled === true) {
|
} else if (spawnBootstrapStalled === true) {
|
||||||
launchVisualState = 'bootstrap_stalled';
|
launchVisualState = 'bootstrap_stalled';
|
||||||
} else if (runtimeEntry?.livenessKind === 'shell_only') {
|
} else if (!hasConfirmedSpawnLaunch && runtimeEntry?.livenessKind === 'shell_only') {
|
||||||
launchVisualState = 'shell_only';
|
launchVisualState = 'shell_only';
|
||||||
} else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') {
|
} else if (
|
||||||
|
!hasConfirmedSpawnLaunch &&
|
||||||
|
runtimeEntry?.livenessKind === 'runtime_process_candidate'
|
||||||
|
) {
|
||||||
launchVisualState = 'runtime_candidate';
|
launchVisualState = 'runtime_candidate';
|
||||||
} else if (runtimeEntry?.livenessKind === 'registered_only') {
|
} else if (!hasConfirmedSpawnLaunch && runtimeEntry?.livenessKind === 'registered_only') {
|
||||||
launchVisualState = 'registered_only';
|
launchVisualState = 'registered_only';
|
||||||
} else if (
|
} else if (
|
||||||
runtimeEntry?.livenessKind === 'stale_metadata' ||
|
!hasConfirmedSpawnLaunch &&
|
||||||
runtimeEntry?.livenessKind === 'not_found'
|
(runtimeEntry?.livenessKind === 'stale_metadata' ||
|
||||||
|
runtimeEntry?.livenessKind === 'not_found')
|
||||||
) {
|
) {
|
||||||
launchVisualState = 'stale_runtime';
|
launchVisualState = 'stale_runtime';
|
||||||
} else if (
|
} else if (
|
||||||
|
!hasConfirmedSpawnLaunch &&
|
||||||
isQueuedOpenCodeLaunch(
|
isQueuedOpenCodeLaunch(
|
||||||
member,
|
member,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
|
|
@ -835,6 +849,7 @@ export function buildMemberLaunchPresentation({
|
||||||
) {
|
) {
|
||||||
launchVisualState = 'queued';
|
launchVisualState = 'queued';
|
||||||
} else if (
|
} else if (
|
||||||
|
!hasConfirmedSpawnLaunch &&
|
||||||
isLaunchStillStarting(
|
isLaunchStillStarting(
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
|
|
@ -844,16 +859,13 @@ export function buildMemberLaunchPresentation({
|
||||||
) {
|
) {
|
||||||
launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting';
|
launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting';
|
||||||
} else if (
|
} else if (
|
||||||
|
!hasConfirmedSpawnLaunch &&
|
||||||
spawnLaunchState === 'runtime_pending_bootstrap' &&
|
spawnLaunchState === 'runtime_pending_bootstrap' &&
|
||||||
(runtimeEntry?.livenessKind === 'runtime_process' ||
|
(runtimeEntry?.livenessKind === 'runtime_process' ||
|
||||||
(spawnStatus === 'online' && spawnRuntimeAlive === true))
|
(spawnStatus === 'online' && spawnRuntimeAlive === true))
|
||||||
) {
|
) {
|
||||||
launchVisualState = 'runtime_pending';
|
launchVisualState = 'runtime_pending';
|
||||||
} else if (
|
} else if (isLaunchSettling && spawnLaunchState === 'confirmed_alive') {
|
||||||
isLaunchSettling &&
|
|
||||||
spawnStatus === 'online' &&
|
|
||||||
spawnLaunchState === 'confirmed_alive'
|
|
||||||
) {
|
|
||||||
launchVisualState = 'settling';
|
launchVisualState = 'settling';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,35 @@ import type {
|
||||||
TeamProviderId,
|
TeamProviderId,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
function shouldShowRuntimeMemory(
|
||||||
|
spawnEntry: MemberSpawnStatusEntry | undefined,
|
||||||
|
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
||||||
|
): boolean {
|
||||||
|
if (typeof runtimeEntry?.rssBytes !== 'number' || runtimeEntry.rssBytes <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
spawnEntry?.status === 'offline' ||
|
||||||
|
spawnEntry?.status === 'skipped' ||
|
||||||
|
spawnEntry?.launchState === 'skipped_for_launch'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!spawnEntry) {
|
||||||
|
return runtimeEntry.alive === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
runtimeEntry.alive === true ||
|
||||||
|
spawnEntry.runtimeAlive === true ||
|
||||||
|
spawnEntry.bootstrapConfirmed === true ||
|
||||||
|
spawnEntry.livenessSource === 'process' ||
|
||||||
|
spawnEntry.livenessSource === 'heartbeat'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMemberBackendLabel(
|
function normalizeMemberBackendLabel(
|
||||||
providerId: TeamProviderId,
|
providerId: TeamProviderId,
|
||||||
backendLabel: string | undefined
|
backendLabel: string | undefined
|
||||||
|
|
@ -101,10 +130,9 @@ export function resolveMemberRuntimeSummary(
|
||||||
configuredProvider,
|
configuredProvider,
|
||||||
formatTeamProviderBackendLabel(configuredProvider, configuredProviderBackendId)
|
formatTeamProviderBackendLabel(configuredProvider, configuredProviderBackendId)
|
||||||
);
|
);
|
||||||
const memorySuffix =
|
const memorySuffix = shouldShowRuntimeMemory(spawnEntry, runtimeEntry)
|
||||||
typeof runtimeEntry?.rssBytes === 'number' && runtimeEntry.rssBytes > 0
|
? ` · ${formatBytes(runtimeEntry!.rssBytes!)}`
|
||||||
? ` · ${formatBytes(runtimeEntry.rssBytes)}`
|
: '';
|
||||||
: '';
|
|
||||||
|
|
||||||
if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) {
|
if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) {
|
||||||
const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider;
|
const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
TeamViewSnapshot,
|
TeamViewSnapshot,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
|
TeamProviderId,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
} from '@shared/types/team';
|
} from '@shared/types/team';
|
||||||
|
|
||||||
|
|
@ -217,7 +218,16 @@ describe('ipc teams handlers', () => {
|
||||||
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
||||||
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
||||||
updateConfig: vi.fn(async () => ({ name: 'My Team' })),
|
updateConfig: vi.fn(async () => ({ name: 'My Team' })),
|
||||||
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
|
sendMessage: vi.fn(
|
||||||
|
async (_teamName: string, _request: unknown) => ({ deliveredToInbox: true, messageId: 'm1' })
|
||||||
|
) as ReturnType<
|
||||||
|
typeof vi.fn<
|
||||||
|
(
|
||||||
|
teamName: string,
|
||||||
|
request: unknown
|
||||||
|
) => Promise<{ deliveredToInbox: boolean; messageId: string }>
|
||||||
|
>
|
||||||
|
>,
|
||||||
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
|
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
|
||||||
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
|
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
|
||||||
requestReview: vi.fn(async () => undefined),
|
requestReview: vi.fn(async () => undefined),
|
||||||
|
|
@ -269,6 +279,14 @@ describe('ipc teams handlers', () => {
|
||||||
pushLiveLeadProcessMessage: vi.fn(),
|
pushLiveLeadProcessMessage: vi.fn(),
|
||||||
relayLeadInboxMessages: vi.fn(async () => 0),
|
relayLeadInboxMessages: vi.fn(async () => 0),
|
||||||
relayMemberInboxMessages: vi.fn(async () => 0),
|
relayMemberInboxMessages: vi.fn(async () => 0),
|
||||||
|
resolveRuntimeRecipientProviderId: vi.fn(
|
||||||
|
async (_teamName: string, _memberName: string): Promise<TeamProviderId | undefined> =>
|
||||||
|
undefined
|
||||||
|
) as ReturnType<
|
||||||
|
typeof vi.fn<
|
||||||
|
(teamName: string, memberName: string) => Promise<TeamProviderId | undefined>
|
||||||
|
>
|
||||||
|
>,
|
||||||
isOpenCodeRuntimeRecipient: vi.fn(async () => false),
|
isOpenCodeRuntimeRecipient: vi.fn(async () => false),
|
||||||
relayOpenCodeMemberInboxMessages: vi.fn(async () => ({
|
relayOpenCodeMemberInboxMessages: vi.fn(async () => ({
|
||||||
relayed: 0,
|
relayed: 0,
|
||||||
|
|
@ -348,6 +366,8 @@ describe('ipc teams handlers', () => {
|
||||||
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
||||||
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
|
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
|
||||||
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
|
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
|
||||||
|
provisioningService.resolveRuntimeRecipientProviderId.mockReset();
|
||||||
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValue(undefined);
|
||||||
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
|
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
|
||||||
initializeTeamHandlers(
|
initializeTeamHandlers(
|
||||||
service as never,
|
service as never,
|
||||||
|
|
@ -645,8 +665,63 @@ describe('ipc teams handlers', () => {
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses Agent Teams MCP reply instructions for Codex user direct messages', async () => {
|
||||||
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('codex');
|
||||||
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||||
|
expect(sendHandler).toBeDefined();
|
||||||
|
|
||||||
|
const result = (await sendHandler!({} as never, 'my-team', {
|
||||||
|
member: 'jack',
|
||||||
|
from: ' User ',
|
||||||
|
text: 'Здесь?',
|
||||||
|
})) as { success: boolean };
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const request = service.sendMessage.mock.calls.at(-1)?.[1] as
|
||||||
|
| { from?: string; text?: string; messageId?: string }
|
||||||
|
| undefined;
|
||||||
|
expect(request).toBeDefined();
|
||||||
|
expect(request?.from).toBe('user');
|
||||||
|
expect(request?.messageId).toEqual(expect.any(String));
|
||||||
|
expect(request?.text).toContain('agent-teams_message_send');
|
||||||
|
expect(request?.text).toContain('mcp__agent-teams__message_send');
|
||||||
|
expect(request?.text).toContain('teamName="my-team"');
|
||||||
|
expect(request?.text).toContain('to="user"');
|
||||||
|
expect(request?.text).toContain('from="jack"');
|
||||||
|
expect(request?.text).toContain('source="runtime_delivery"');
|
||||||
|
expect(request?.text).toContain(`relayOfMessageId="${request?.messageId}"`);
|
||||||
|
expect(request?.text).toContain('before any visible-message tool attempt');
|
||||||
|
expect(request?.text).not.toContain('tool call fails before sending');
|
||||||
|
expect(request?.text).not.toContain('Reply using the SendMessage tool');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['anthropic' as const],
|
||||||
|
['gemini' as const],
|
||||||
|
[undefined],
|
||||||
|
])('keeps SendMessage reply instructions for %s user direct messages', async (providerId) => {
|
||||||
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce(providerId);
|
||||||
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||||
|
expect(sendHandler).toBeDefined();
|
||||||
|
|
||||||
|
const result = (await sendHandler!({} as never, 'my-team', {
|
||||||
|
member: 'alice',
|
||||||
|
text: 'Здесь?',
|
||||||
|
})) as { success: boolean };
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const request = service.sendMessage.mock.calls.at(-1)?.[1] as
|
||||||
|
| { text?: string; messageId?: string }
|
||||||
|
| undefined;
|
||||||
|
expect(request).toBeDefined();
|
||||||
|
expect(request).not.toHaveProperty('messageId');
|
||||||
|
expect(request?.text).toContain('Reply using the SendMessage tool');
|
||||||
|
expect(request?.text).toContain('to="user"');
|
||||||
|
expect(request?.text).not.toContain('agent-teams_message_send');
|
||||||
|
});
|
||||||
|
|
||||||
it('stores base text and returns runtimeDelivery success for OpenCode teammate sends', async () => {
|
it('stores base text and returns runtimeDelivery success for OpenCode teammate sends', async () => {
|
||||||
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true);
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
||||||
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
|
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
|
||||||
relayed: 1,
|
relayed: 1,
|
||||||
attempted: 1,
|
attempted: 1,
|
||||||
|
|
@ -699,7 +774,7 @@ describe('ipc teams handlers', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns runtimeDelivery failure without hiding the persisted OpenCode message', async () => {
|
it('returns runtimeDelivery failure without hiding the persisted OpenCode message', async () => {
|
||||||
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true);
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
||||||
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
|
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
|
||||||
relayed: 0,
|
relayed: 0,
|
||||||
attempted: 1,
|
attempted: 1,
|
||||||
|
|
@ -734,7 +809,7 @@ describe('ipc teams handlers', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns runtimeDelivery acceptanceUnknown for OpenCode observe-pending timeout sends', async () => {
|
it('returns runtimeDelivery acceptanceUnknown for OpenCode observe-pending timeout sends', async () => {
|
||||||
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true);
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
||||||
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
|
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
|
||||||
relayed: 0,
|
relayed: 0,
|
||||||
attempted: 1,
|
attempted: 1,
|
||||||
|
|
@ -774,7 +849,7 @@ describe('ipc teams handlers', () => {
|
||||||
it('maps OpenCode UI relay timeout to pending acceptance-unknown delivery', async () => {
|
it('maps OpenCode UI relay timeout to pending acceptance-unknown delivery', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true);
|
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
||||||
provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(
|
provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(
|
||||||
new Promise(() => undefined)
|
new Promise(() => undefined)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -583,9 +583,214 @@ describe('TeamProvisioningService', () => {
|
||||||
await expect(svc.warmup()).resolves.not.toThrow();
|
await expect(svc.warmup()).resolves.not.toThrow();
|
||||||
expect(spawnCli).toHaveBeenCalled();
|
expect(spawnCli).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('team launch notifications', () => {
|
describe('team launch notifications', () => {
|
||||||
|
it('does not fire incomplete notification for pending-only teammates still joining', async () => {
|
||||||
|
const { NotificationManager } =
|
||||||
|
await import('@main/services/infrastructure/NotificationManager');
|
||||||
|
const addTeamNotification = vi.fn(async (_payload: unknown) => undefined);
|
||||||
|
NotificationManager.setInstance({ addTeamNotification } as never);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = {
|
||||||
|
runId: 'run-beacon-desk-15',
|
||||||
|
teamName: 'beacon-desk-15',
|
||||||
|
isLaunch: true,
|
||||||
|
request: {
|
||||||
|
cwd: tempClaudeRoot,
|
||||||
|
displayName: 'beacon-desk-15',
|
||||||
|
},
|
||||||
|
expectedMembers: ['alice', 'bob', 'jack', 'tom'],
|
||||||
|
allEffectiveMembers: [
|
||||||
|
{ name: 'alice' },
|
||||||
|
{ name: 'bob' },
|
||||||
|
{ name: 'jack' },
|
||||||
|
{ name: 'tom' },
|
||||||
|
],
|
||||||
|
memberSpawnStatuses: new Map(
|
||||||
|
['alice', 'bob', 'jack', 'tom'].map((name) => [
|
||||||
|
name,
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const pendingSnapshot = {
|
||||||
|
expectedMembers: ['alice', 'bob', 'jack', 'tom'],
|
||||||
|
members: Object.fromEntries(
|
||||||
|
['alice', 'bob', 'jack', 'tom'].map((name) => [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
lastEvaluatedAt: '2026-04-13T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
summary: {
|
||||||
|
confirmedCount: 0,
|
||||||
|
pendingCount: 4,
|
||||||
|
failedCount: 0,
|
||||||
|
runtimeAlivePendingCount: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await (svc as any).fireTeamLaunchIncompleteNotification(
|
||||||
|
run,
|
||||||
|
[],
|
||||||
|
pendingSnapshot.summary,
|
||||||
|
pendingSnapshot
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
NotificationManager.resetInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(addTeamNotification).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores stale failed summary without concrete failed member evidence', async () => {
|
||||||
|
const { NotificationManager } =
|
||||||
|
await import('@main/services/infrastructure/NotificationManager');
|
||||||
|
const addTeamNotification = vi.fn(async (_payload: unknown) => undefined);
|
||||||
|
NotificationManager.setInstance({ addTeamNotification } as never);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = {
|
||||||
|
runId: 'run-stale-summary',
|
||||||
|
teamName: 'stale-summary-team',
|
||||||
|
isLaunch: true,
|
||||||
|
request: {
|
||||||
|
cwd: tempClaudeRoot,
|
||||||
|
displayName: 'stale-summary-team',
|
||||||
|
},
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
allEffectiveMembers: [{ name: 'alice' }],
|
||||||
|
memberSpawnStatuses: new Map([
|
||||||
|
[
|
||||||
|
'alice',
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const staleSnapshot = {
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
members: {
|
||||||
|
alice: {
|
||||||
|
name: 'alice',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
lastEvaluatedAt: '2026-04-13T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
confirmedCount: 0,
|
||||||
|
pendingCount: 0,
|
||||||
|
failedCount: 1,
|
||||||
|
runtimeAlivePendingCount: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await (svc as any).fireTeamLaunchIncompleteNotification(
|
||||||
|
run,
|
||||||
|
[],
|
||||||
|
staleSnapshot.summary,
|
||||||
|
staleSnapshot
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
NotificationManager.resetInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(addTeamNotification).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers live confirmed evidence over stale persisted failed member evidence', async () => {
|
||||||
|
const { NotificationManager } =
|
||||||
|
await import('@main/services/infrastructure/NotificationManager');
|
||||||
|
const addTeamNotification = vi.fn(async (_payload: unknown) => undefined);
|
||||||
|
NotificationManager.setInstance({ addTeamNotification } as never);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = {
|
||||||
|
runId: 'run-live-confirmed',
|
||||||
|
teamName: 'live-confirmed-team',
|
||||||
|
isLaunch: true,
|
||||||
|
request: {
|
||||||
|
cwd: tempClaudeRoot,
|
||||||
|
displayName: 'live-confirmed-team',
|
||||||
|
},
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
allEffectiveMembers: [{ name: 'alice' }],
|
||||||
|
memberSpawnStatuses: new Map([
|
||||||
|
[
|
||||||
|
'alice',
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const staleSnapshot = {
|
||||||
|
expectedMembers: ['alice'],
|
||||||
|
members: {
|
||||||
|
alice: {
|
||||||
|
name: 'alice',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: 'stale failure',
|
||||||
|
lastEvaluatedAt: '2026-04-13T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
confirmedCount: 0,
|
||||||
|
pendingCount: 0,
|
||||||
|
failedCount: 1,
|
||||||
|
runtimeAlivePendingCount: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await (svc as any).fireTeamLaunchIncompleteNotification(
|
||||||
|
run,
|
||||||
|
[],
|
||||||
|
staleSnapshot.summary,
|
||||||
|
staleSnapshot
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
NotificationManager.resetInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(addTeamNotification).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('uses live member evidence instead of stale summary for incomplete launch copy', async () => {
|
it('uses live member evidence instead of stale summary for incomplete launch copy', async () => {
|
||||||
const { NotificationManager } =
|
const { NotificationManager } =
|
||||||
await import('@main/services/infrastructure/NotificationManager');
|
await import('@main/services/infrastructure/NotificationManager');
|
||||||
|
|
@ -12752,6 +12957,7 @@ describe('TeamProvisioningService', () => {
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
hardFailureReason: undefined,
|
hardFailureReason: undefined,
|
||||||
|
livenessKind: 'registered_only',
|
||||||
diagnostics: [
|
diagnostics: [
|
||||||
'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.',
|
'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.',
|
||||||
],
|
],
|
||||||
|
|
@ -12767,6 +12973,7 @@ describe('TeamProvisioningService', () => {
|
||||||
agentToolAccepted: true,
|
agentToolAccepted: true,
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
runtimeAlive: true,
|
runtimeAlive: true,
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
});
|
});
|
||||||
const persisted = JSON.parse(
|
const persisted = JSON.parse(
|
||||||
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
|
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
|
||||||
|
|
@ -12776,6 +12983,7 @@ describe('TeamProvisioningService', () => {
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
runtimeAlive: true,
|
runtimeAlive: true,
|
||||||
runtimeSessionId: 'ses-tom',
|
runtimeSessionId: 'ses-tom',
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -12884,6 +13092,74 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(persistedAfterMissingWrite.teamLaunchState).toBe('clean_success');
|
expect(persistedAfterMissingWrite.teamLaunchState).toBe('clean_success');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes stale confirmed OpenCode secondary liveness from committed bootstrap evidence', async () => {
|
||||||
|
const teamName = 'zz-opencode-committed-overlay-normalizes-liveness';
|
||||||
|
const leadSessionId = 'lead-session';
|
||||||
|
const laneId = 'secondary:opencode:tom';
|
||||||
|
const runId = 'opencode-run-tom';
|
||||||
|
|
||||||
|
writeMembersMeta(teamName, [{ name: 'tom', providerId: 'opencode' }]);
|
||||||
|
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||||
|
teamsBasePath: tempTeamsBase,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
state: 'active',
|
||||||
|
});
|
||||||
|
await writeCommittedOpenCodeSessionStore({
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
runId,
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: 'ses-tom',
|
||||||
|
teamName,
|
||||||
|
memberName: 'tom',
|
||||||
|
laneId,
|
||||||
|
runId,
|
||||||
|
observedAt: '2026-04-22T12:00:00.000Z',
|
||||||
|
source: 'runtime_bootstrap_checkin',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
writeLaunchState(teamName, leadSessionId, {
|
||||||
|
tom: {
|
||||||
|
providerId: 'opencode',
|
||||||
|
laneId,
|
||||||
|
laneKind: 'secondary',
|
||||||
|
laneOwnerProviderId: 'opencode',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
runtimeSessionId: 'ses-tom',
|
||||||
|
livenessKind: 'registered_only',
|
||||||
|
runtimeDiagnostic: 'OpenCode bootstrap evidence committed.',
|
||||||
|
diagnostics: ['opencode_bootstrap_evidence_committed'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.statuses.tom).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
|
});
|
||||||
|
const persisted = JSON.parse(
|
||||||
|
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
|
||||||
|
);
|
||||||
|
expect(persisted.members.tom).toMatchObject({
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('marks a live teammate bootstrap as confirmed from transcript even when runtime discovery is stale', async () => {
|
it('marks a live teammate bootstrap as confirmed from transcript even when runtime discovery is stale', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
const teamName = 'zz-live-bootstrap-transcript-success-without-runtime';
|
const teamName = 'zz-live-bootstrap-transcript-success-without-runtime';
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,29 @@ describe('buildTeamRuntimeDisplayRows', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats confirmed spawn bootstrap as running even if stale status is still waiting', () => {
|
||||||
|
const rows = buildTeamRuntimeDisplayRows({
|
||||||
|
members: [{ name: 'alice' }],
|
||||||
|
spawnStatuses: {
|
||||||
|
alice: createSpawnStatus({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
livenessKind: 'registered_only',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
memberName: 'alice',
|
||||||
|
state: 'running',
|
||||||
|
source: 'spawn-status',
|
||||||
|
stateReason: 'Bootstrap confirmed',
|
||||||
|
actionsAllowed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('maps a non-alive runtime with error diagnostics to degraded', () => {
|
it('maps a non-alive runtime with error diagnostics to degraded', () => {
|
||||||
const rows = buildTeamRuntimeDisplayRows({
|
const rows = buildTeamRuntimeDisplayRows({
|
||||||
members: [{ name: 'alice' }],
|
members: [{ name: 'alice' }],
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,7 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
spawnLaunchState: 'confirmed_alive',
|
spawnLaunchState: 'confirmed_alive',
|
||||||
spawnLivenessSource: 'process',
|
spawnLivenessSource: 'process',
|
||||||
spawnRuntimeAlive: true,
|
spawnRuntimeAlive: true,
|
||||||
|
spawnBootstrapConfirmed: true,
|
||||||
runtimeEntry: {
|
runtimeEntry: {
|
||||||
memberName: 'alice',
|
memberName: 'alice',
|
||||||
alive: false,
|
alive: false,
|
||||||
|
|
@ -399,10 +400,38 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
isTeamProvisioning: false,
|
isTeamProvisioning: false,
|
||||||
})
|
})
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
presenceLabel: 'registered',
|
presenceLabel: 'online',
|
||||||
launchVisualState: 'registered_only',
|
launchVisualState: null,
|
||||||
launchStatusLabel: 'registered',
|
launchStatusLabel: null,
|
||||||
dotClass: expect.stringContaining('bg-zinc-400'),
|
dotClass: expect.stringContaining('bg-emerald-400'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildMemberLaunchPresentation({
|
||||||
|
member,
|
||||||
|
spawnStatus: 'waiting',
|
||||||
|
spawnLaunchState: 'confirmed_alive',
|
||||||
|
spawnLivenessSource: 'process',
|
||||||
|
spawnRuntimeAlive: true,
|
||||||
|
spawnBootstrapConfirmed: true,
|
||||||
|
runtimeEntry: {
|
||||||
|
memberName: 'alice',
|
||||||
|
alive: false,
|
||||||
|
restartable: true,
|
||||||
|
livenessKind: 'registered_only',
|
||||||
|
runtimeDiagnostic: 'registered runtime metadata without live process',
|
||||||
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isLaunchSettling: false,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
presenceLabel: 'online',
|
||||||
|
launchVisualState: null,
|
||||||
|
launchStatusLabel: null,
|
||||||
|
dotClass: expect.stringContaining('bg-emerald-400'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,30 @@ describe('resolveMemberRuntimeSummary', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides stale runtime memory when the spawn state is explicitly offline', () => {
|
||||||
|
const member = createMember({ model: 'gpt-5.4-mini' });
|
||||||
|
const spawnEntry = createSpawnEntry({
|
||||||
|
status: 'offline',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
});
|
||||||
|
const runtimeEntry = {
|
||||||
|
memberName: 'alice',
|
||||||
|
alive: true,
|
||||||
|
restartable: false,
|
||||||
|
providerId: 'opencode',
|
||||||
|
pid: 333,
|
||||||
|
pidSource: 'opencode_bridge',
|
||||||
|
rssBytes: 97.3 * 1024 * 1024,
|
||||||
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry, runtimeEntry as never)).toBe(
|
||||||
|
'5.4 Mini · Medium · Codex'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the persisted backend lane visible in the runtime summary', () => {
|
it('keeps the persisted backend lane visible in the runtime summary', () => {
|
||||||
const member = createMember({ model: 'gpt-5.4-mini' });
|
const member = createMember({ model: 'gpt-5.4-mini' });
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue