fix(team): harden opencode runtime status and effort UI
This commit is contained in:
parent
752ae9ea4b
commit
0ace2a6255
14 changed files with 496 additions and 41 deletions
|
|
@ -11,6 +11,8 @@ const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
||||||
const MESSAGE_FEED_CACHE_MAX_AGE_MS = 5_000;
|
const MESSAGE_FEED_CACHE_MAX_AGE_MS = 5_000;
|
||||||
const logger = createLogger('Service:TeamMessageFeedService');
|
const logger = createLogger('Service:TeamMessageFeedService');
|
||||||
|
|
||||||
|
type TeamConfigMember = NonNullable<TeamConfig['members']>[number];
|
||||||
|
|
||||||
interface TeamMessageFeedDeps {
|
interface TeamMessageFeedDeps {
|
||||||
getConfig: (teamName: string) => Promise<TeamConfig | null>;
|
getConfig: (teamName: string) => Promise<TeamConfig | null>;
|
||||||
getInboxMessages: (teamName: string) => Promise<InboxMessage[]>;
|
getInboxMessages: (teamName: string) => Promise<InboxMessage[]>;
|
||||||
|
|
@ -69,6 +71,68 @@ function isLeadThoughtCandidateForSlashResult(message: InboxMessage): boolean {
|
||||||
return message.source === 'lead_session' || message.source === 'lead_process';
|
return message.source === 'lead_session' || message.source === 'lead_process';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveLeadName(config: TeamConfig): string {
|
||||||
|
const lead =
|
||||||
|
config.members?.find((member) => member.agentType === 'team-lead' || member.role === 'Lead') ??
|
||||||
|
config.members?.find((member) => member.name === 'team-lead') ??
|
||||||
|
config.members?.[0];
|
||||||
|
return lead?.name?.trim() || 'team-lead';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOpenCodeBootstrapTimestamp(config: TeamConfig, member: TeamConfigMember): string {
|
||||||
|
const raw = member.joinedAt ?? (config as { createdAt?: unknown }).createdAt;
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||||
|
return new Date(raw).toISOString();
|
||||||
|
}
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const parsed = Date.parse(raw);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return new Date(parsed).toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Date(0).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenCodeBootstrapDisplayPrompt(config: TeamConfig, member: TeamConfigMember): string {
|
||||||
|
const role = member.role?.trim() || member.agentType?.trim() || 'team member';
|
||||||
|
const displayName = config.description?.trim() || config.name;
|
||||||
|
const providerLine = '\nProvider override for this teammate: opencode.';
|
||||||
|
const modelLine = member.model?.trim()
|
||||||
|
? `\nModel override for this teammate: ${member.model.trim()}.`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `You are ${member.name}, a ${role} on team "${displayName}" (${config.name}).${providerLine}${modelLine}
|
||||||
|
|
||||||
|
The team has already been created and you are being attached as a persistent teammate.
|
||||||
|
Your FIRST action: call MCP tool member_briefing on the "agent-teams" server with:
|
||||||
|
{ teamName: "${config.name}", memberName: "${member.name}", runtimeProvider: "opencode" }
|
||||||
|
Call member_briefing directly yourself. Do NOT use Agent, any subagent, or a delegated helper for this step.
|
||||||
|
After member_briefing succeeds, wait for instructions from the lead and use team mailbox/task tools normally.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSyntheticOpenCodeBootstrapMessages(config: TeamConfig): InboxMessage[] {
|
||||||
|
const members = Array.isArray(config.members) ? config.members : [];
|
||||||
|
const leadName = resolveLeadName(config);
|
||||||
|
return members
|
||||||
|
.filter(
|
||||||
|
(member) =>
|
||||||
|
member &&
|
||||||
|
member.name?.trim() &&
|
||||||
|
member.providerId === 'opencode' &&
|
||||||
|
member.removedAt == null &&
|
||||||
|
(member as { isActive?: unknown }).isActive !== false
|
||||||
|
)
|
||||||
|
.map((member) => ({
|
||||||
|
from: leadName,
|
||||||
|
to: member.name,
|
||||||
|
text: buildOpenCodeBootstrapDisplayPrompt(config, member),
|
||||||
|
timestamp: resolveOpenCodeBootstrapTimestamp(config, member),
|
||||||
|
read: true,
|
||||||
|
source: 'system_notification' as const,
|
||||||
|
messageId: `opencode-bootstrap-start:${config.name}:${member.name}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function annotateSlashCommandResponses(messages: InboxMessage[]): void {
|
function annotateSlashCommandResponses(messages: InboxMessage[]): void {
|
||||||
let pendingSlash = null as InboxMessage['slashCommand'] | null;
|
let pendingSlash = null as InboxMessage['slashCommand'] | null;
|
||||||
|
|
||||||
|
|
@ -381,7 +445,8 @@ export class TeamMessageFeedService {
|
||||||
this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]),
|
this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let messages = [...inboxMessages, ...leadTexts, ...sentMessages];
|
const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config);
|
||||||
|
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages];
|
||||||
messages = dedupeLeadProcessCopies(messages, leadTexts);
|
messages = dedupeLeadProcessCopies(messages, leadTexts);
|
||||||
messages = ensureEffectiveMessageIds(messages);
|
messages = ensureEffectiveMessageIds(messages);
|
||||||
messages = dedupeByMessageId(messages);
|
messages = dedupeByMessageId(messages);
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ import {
|
||||||
getOpenCodeLaneScopedRuntimeFilePath,
|
getOpenCodeLaneScopedRuntimeFilePath,
|
||||||
getOpenCodeRuntimeRunTombstonesPath,
|
getOpenCodeRuntimeRunTombstonesPath,
|
||||||
getOpenCodeTeamRuntimeDirectory,
|
getOpenCodeTeamRuntimeDirectory,
|
||||||
|
inspectOpenCodeRuntimeLaneStorage,
|
||||||
migrateLegacyOpenCodeRuntimeState,
|
migrateLegacyOpenCodeRuntimeState,
|
||||||
OpenCodeRuntimeManifestEvidenceReader,
|
OpenCodeRuntimeManifestEvidenceReader,
|
||||||
readOpenCodeRuntimeLaneIndex,
|
readOpenCodeRuntimeLaneIndex,
|
||||||
|
|
@ -3826,7 +3827,7 @@ function buildLaunchDiagnosticsFromRun(
|
||||||
memberName,
|
memberName,
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
code: 'runtime_process_candidate',
|
code: 'runtime_process_candidate',
|
||||||
label: `${memberName} - process candidate`,
|
label: `${memberName} - bootstrap unconfirmed`,
|
||||||
detail: entry.runtimeDiagnostic,
|
detail: entry.runtimeDiagnostic,
|
||||||
observedAt,
|
observedAt,
|
||||||
});
|
});
|
||||||
|
|
@ -6102,20 +6103,30 @@ export class TeamProvisioningService {
|
||||||
if (
|
if (
|
||||||
runtimeActive &&
|
runtimeActive &&
|
||||||
laneIdentity.laneKind === 'secondary' &&
|
laneIdentity.laneKind === 'secondary' &&
|
||||||
laneIdentity.laneOwnerProviderId === 'opencode' &&
|
laneIdentity.laneOwnerProviderId === 'opencode'
|
||||||
!liveSecondaryLaneRunId
|
|
||||||
) {
|
) {
|
||||||
|
const laneStorage = await inspectOpenCodeRuntimeLaneStorage({
|
||||||
|
teamsBasePath: getTeamsBasePath(),
|
||||||
|
teamName,
|
||||||
|
laneId: laneIdentity.laneId,
|
||||||
|
});
|
||||||
const staleLane = await recoverStaleOpenCodeRuntimeLaneIndexEntry({
|
const staleLane = await recoverStaleOpenCodeRuntimeLaneIndexEntry({
|
||||||
teamsBasePath: getTeamsBasePath(),
|
teamsBasePath: getTeamsBasePath(),
|
||||||
teamName,
|
teamName,
|
||||||
laneId: laneIdentity.laneId,
|
laneId: laneIdentity.laneId,
|
||||||
});
|
});
|
||||||
if (staleLane.stale) {
|
if (!laneStorage.hasRuntimeEvidenceOnDisk) {
|
||||||
this.deleteSecondaryRuntimeRun(teamName, laneIdentity.laneId);
|
if (staleLane.stale) {
|
||||||
|
this.deleteSecondaryRuntimeRun(teamName, laneIdentity.laneId);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
delivered: false,
|
delivered: false,
|
||||||
reason: 'opencode_runtime_not_active',
|
reason: 'opencode_runtime_not_active',
|
||||||
diagnostics: staleLane.diagnostics,
|
diagnostics: staleLane.diagnostics.length
|
||||||
|
? staleLane.diagnostics
|
||||||
|
: [
|
||||||
|
`OpenCode runtime bootstrap evidence is not ready for ${canonicalMemberName}. Message was saved and will be retried after runtime check-in.`,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10957,7 +10968,8 @@ export class TeamProvisioningService {
|
||||||
const next = {
|
const next = {
|
||||||
...refreshed,
|
...refreshed,
|
||||||
livenessKind: metadata.livenessKind,
|
livenessKind: metadata.livenessKind,
|
||||||
runtimeDiagnostic: runtimeDiagnostic ?? 'runtime process candidate detected',
|
runtimeDiagnostic:
|
||||||
|
runtimeDiagnostic ?? 'Runtime process candidate detected, but bootstrap is unconfirmed.',
|
||||||
runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning',
|
runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning',
|
||||||
livenessLastCheckedAt: nowIso(),
|
livenessLastCheckedAt: nowIso(),
|
||||||
};
|
};
|
||||||
|
|
@ -12533,7 +12545,7 @@ export class TeamProvisioningService {
|
||||||
return (
|
return (
|
||||||
`---\n\n` +
|
`---\n\n` +
|
||||||
`**Waiting for CLI response** (silent for ${elapsed})\n\n` +
|
`**Waiting for CLI response** (silent for ${elapsed})\n\n` +
|
||||||
`The process is running but not producing output yet. Cloud sometimes delays logs, ` +
|
`The process is running but not producing output yet. Model responses can delay logs, ` +
|
||||||
`and short waits like this are normal. The SDK also retries automatically if the ` +
|
`and short waits like this are normal. The SDK also retries automatically if the ` +
|
||||||
`request briefly hits rate limiting.\n\n` +
|
`request briefly hits rate limiting.\n\n` +
|
||||||
`Waiting...`
|
`Waiting...`
|
||||||
|
|
@ -12544,7 +12556,7 @@ export class TeamProvisioningService {
|
||||||
return (
|
return (
|
||||||
`---\n\n` +
|
`---\n\n` +
|
||||||
`**Waiting for CLI response** (silent for ${elapsed})\n\n` +
|
`**Waiting for CLI response** (silent for ${elapsed})\n\n` +
|
||||||
`The process is still waiting on Cloud. Logs can sometimes show up after ` +
|
`The process is still waiting for a model response. Logs can sometimes show up after ` +
|
||||||
`1-1.5 minutes, and that is still okay. The SDK retries automatically if the ` +
|
`1-1.5 minutes, and that is still okay. The SDK retries automatically if the ` +
|
||||||
`request hits rate limiting (error 429 / model cooldown).\n\n` +
|
`request hits rate limiting (error 429 / model cooldown).\n\n` +
|
||||||
`If there is still no output after 2 minutes, that starts to look unusual.\n\n` +
|
`If there is still no output after 2 minutes, that starts to look unusual.\n\n` +
|
||||||
|
|
@ -12558,21 +12570,21 @@ export class TeamProvisioningService {
|
||||||
return (
|
return (
|
||||||
`---\n\n` +
|
`---\n\n` +
|
||||||
`**Extended CLI wait** (silent for ${elapsed})\n\n` +
|
`**Extended CLI wait** (silent for ${elapsed})\n\n` +
|
||||||
`Model **${modelName}**${effortLabel} is still waiting on Cloud. Some delay is normal, ` +
|
`Model **${modelName}**${effortLabel} is still waiting to respond. Some delay is normal, ` +
|
||||||
`but no logs for ${elapsed} is already unusual.\n\n` +
|
`but no logs for ${elapsed} is already unusual.\n\n` +
|
||||||
`Possible causes:\n` +
|
`Possible causes:\n` +
|
||||||
`- Rate limiting / model cooldown (429) — SDK retries automatically\n` +
|
`- Rate limiting / model cooldown (429) - SDK retries automatically\n` +
|
||||||
`- API server overload for this model\n` +
|
`- API server overload for this model\n` +
|
||||||
`- A stalled or delayed Cloud response\n\n` +
|
`- A stalled or delayed model response\n\n` +
|
||||||
`Consider canceling and trying with a different model.`
|
`Consider canceling and trying with a different model.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildStallProgressMessage(silenceSec: number, elapsed: string): string {
|
private buildStallProgressMessage(silenceSec: number, elapsed: string): string {
|
||||||
if (silenceSec < 120) {
|
if (silenceSec < 120) {
|
||||||
return `Waiting on Cloud response for ${elapsed} — logs can be delayed, this is still OK`;
|
return `Waiting for model response for ${elapsed} - logs can be delayed, this is still OK`;
|
||||||
}
|
}
|
||||||
return `Still waiting on Cloud response for ${elapsed} — this is unusual`;
|
return `Still waiting for model response for ${elapsed} - this is unusual`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -17331,7 +17343,7 @@ export class TeamProvisioningService {
|
||||||
? `${launchSummary.runtimeProcessPendingCount} waiting for bootstrap`
|
? `${launchSummary.runtimeProcessPendingCount} waiting for bootstrap`
|
||||||
: '',
|
: '',
|
||||||
launchSummary.runtimeCandidatePendingCount
|
launchSummary.runtimeCandidatePendingCount
|
||||||
? `${launchSummary.runtimeCandidatePendingCount} process candidates`
|
? `${launchSummary.runtimeCandidatePendingCount} bootstrap unconfirmed`
|
||||||
: '',
|
: '',
|
||||||
launchSummary.noRuntimePendingCount
|
launchSummary.noRuntimePendingCount
|
||||||
? `${launchSummary.noRuntimePendingCount} waiting for runtime`
|
? `${launchSummary.noRuntimePendingCount} waiting for runtime`
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ export function resolveTeamMemberRuntimeLiveness(
|
||||||
paneCurrentCommand: pane.currentCommand,
|
paneCurrentCommand: pane.currentCommand,
|
||||||
runtimeSessionId,
|
runtimeSessionId,
|
||||||
processCommand: sanitizeProcessCommandForDiagnostics(candidate.command),
|
processCommand: sanitizeProcessCommandForDiagnostics(candidate.command),
|
||||||
runtimeDiagnostic: 'runtime process candidate detected',
|
runtimeDiagnostic: 'Runtime process candidate detected, but bootstrap is unconfirmed.',
|
||||||
runtimeDiagnosticSeverity: 'warning',
|
runtimeDiagnosticSeverity: 'warning',
|
||||||
diagnostics: [...diagnostics, 'tmux descendant found without runtime identity match'],
|
diagnostics: [...diagnostics, 'tmux descendant found without runtime identity match'],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2183,9 +2183,6 @@ export const CreateTeamDialog = ({
|
||||||
Open Existing Team
|
Open Existing Team
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button variant="outline" size="sm" onClick={onClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canCreate || !draftLoaded || isSubmitting || hasCreateFormErrors}
|
disabled={!canCreate || !draftLoaded || isSubmitting || hasCreateFormErrors}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import React from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus';
|
import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { getTeamEffortOptions } from '@renderer/utils/teamEffortOptions';
|
import { getTeamEffortSelectorPresentation } from '@renderer/utils/teamEffortOptions';
|
||||||
import { Brain } from 'lucide-react';
|
import { Brain } from 'lucide-react';
|
||||||
|
|
||||||
import type { TeamProviderId } from '@shared/types';
|
import type { TeamProviderId } from '@shared/types';
|
||||||
|
|
@ -26,10 +26,27 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
|
||||||
limitContext,
|
limitContext,
|
||||||
}) => {
|
}) => {
|
||||||
const { providerStatus } = useEffectiveCliProviderStatus(providerId);
|
const { providerStatus } = useEffectiveCliProviderStatus(providerId);
|
||||||
const effortOptions = getTeamEffortOptions({ providerId, model, limitContext, providerStatus });
|
const presentation = getTeamEffortSelectorPresentation({
|
||||||
|
providerId,
|
||||||
|
model,
|
||||||
|
limitContext,
|
||||||
|
providerStatus,
|
||||||
|
});
|
||||||
|
const effortOptions = presentation.options;
|
||||||
|
const validValues = useMemo(
|
||||||
|
() => new Set(effortOptions.map((option) => option.value)),
|
||||||
|
[effortOptions]
|
||||||
|
);
|
||||||
const showsAnthropicMax =
|
const showsAnthropicMax =
|
||||||
providerId === 'anthropic' && effortOptions.some((option) => option.value === 'max');
|
providerId === 'anthropic' && effortOptions.some((option) => option.value === 'max');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!presentation.canValidateValue || !value || validValues.has(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onValueChange('');
|
||||||
|
}, [onValueChange, presentation.canValidateValue, validValues, value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<Label htmlFor={id} className="label-optional mb-1.5 block">
|
<Label htmlFor={id} className="label-optional mb-1.5 block">
|
||||||
|
|
@ -43,11 +60,14 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
|
||||||
key={opt.value || '__default__'}
|
key={opt.value || '__default__'}
|
||||||
type="button"
|
type="button"
|
||||||
id={opt.value === value ? id : undefined}
|
id={opt.value === value ? id : undefined}
|
||||||
|
disabled={presentation.disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||||
value === opt.value
|
presentation.disabled
|
||||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
? 'cursor-not-allowed text-[var(--color-text-muted)] opacity-70'
|
||||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
: value === opt.value
|
||||||
|
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||||
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||||
)}
|
)}
|
||||||
onClick={() => onValueChange(opt.value)}
|
onClick={() => onValueChange(opt.value)}
|
||||||
>
|
>
|
||||||
|
|
@ -56,10 +76,10 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
|
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">{presentation.helperText}</p>
|
||||||
Controls how much reasoning the selected provider invests before responding. Default uses
|
{presentation.unavailableText ? (
|
||||||
the provider's standard behavior for the selected model.
|
<p className="mt-1 text-[11px] text-amber-300">{presentation.unavailableText}</p>
|
||||||
</p>
|
) : null}
|
||||||
{showsAnthropicMax ? (
|
{showsAnthropicMax ? (
|
||||||
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
|
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
|
||||||
Max is Anthropic's heavier reasoning mode and only appears when the resolved launch
|
Max is Anthropic's heavier reasoning mode and only appears when the resolved launch
|
||||||
|
|
|
||||||
|
|
@ -2895,9 +2895,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={closeDialog}>
|
|
||||||
{isLaunchMode ? 'Close' : 'Cancel'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { getTeamEffortOptions } from '../teamEffortOptions';
|
import { getTeamEffortOptions, getTeamEffortSelectorPresentation } from '../teamEffortOptions';
|
||||||
|
|
||||||
import type { CliProviderStatus } from '@shared/types';
|
import type { CliProviderStatus } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -210,4 +210,33 @@ describe('team effort options', () => {
|
||||||
getTeamEffortOptions({ providerId: 'anthropic', model: 'haiku', providerStatus })
|
getTeamEffortOptions({ providerId: 'anthropic', model: 'haiku', providerStatus })
|
||||||
).toEqual([{ value: '', label: 'Default' }]);
|
).toEqual([{ value: '', label: 'Default' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('presents Anthropic no-effort models as disabled with explicit copy', () => {
|
||||||
|
const providerStatus = createProviderStatus('anthropic', {
|
||||||
|
id: 'haiku',
|
||||||
|
launchModel: 'claude-haiku-4-5-20251001',
|
||||||
|
displayName: 'Haiku 4.5',
|
||||||
|
hidden: false,
|
||||||
|
supportedReasoningEfforts: [],
|
||||||
|
defaultReasoningEffort: null,
|
||||||
|
inputModalities: ['text', 'image'],
|
||||||
|
supportsPersonality: false,
|
||||||
|
isDefault: false,
|
||||||
|
upgrade: false,
|
||||||
|
source: 'anthropic-models-api',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getTeamEffortSelectorPresentation({
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: 'claude-haiku-4-5-20251001',
|
||||||
|
providerStatus,
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
options: [{ value: '', label: 'Not supported' }],
|
||||||
|
disabled: true,
|
||||||
|
canValidateValue: true,
|
||||||
|
unavailableText: 'Effort is unavailable for this model.',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -581,7 +581,7 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
|
||||||
case 'shell_only':
|
case 'shell_only':
|
||||||
return 'shell only';
|
return 'shell only';
|
||||||
case 'runtime_candidate':
|
case 'runtime_candidate':
|
||||||
return 'process candidate';
|
return 'bootstrap unconfirmed';
|
||||||
case 'registered_only':
|
case 'registered_only':
|
||||||
return 'registered';
|
return 'registered';
|
||||||
case 'stale_runtime':
|
case 'stale_runtime':
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,14 @@ interface TeamEffortOption {
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TeamEffortSelectorPresentation {
|
||||||
|
options: readonly TeamEffortOption[];
|
||||||
|
disabled: boolean;
|
||||||
|
helperText: string;
|
||||||
|
unavailableText: string | null;
|
||||||
|
canValidateValue: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function getCatalogModel(
|
function getCatalogModel(
|
||||||
providerId: TeamProviderId | undefined,
|
providerId: TeamProviderId | undefined,
|
||||||
providerStatus: CliProviderStatus | null | undefined,
|
providerStatus: CliProviderStatus | null | undefined,
|
||||||
|
|
@ -126,3 +134,57 @@ export function getTeamEffortOptions(params: {
|
||||||
{ value: 'high', label: TEAM_EFFORT_LABELS.high },
|
{ value: 'high', label: TEAM_EFFORT_LABELS.high },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTeamEffortSelectorPresentation(params: {
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
model?: string;
|
||||||
|
limitContext?: boolean;
|
||||||
|
providerStatus?: CliProviderStatus | null;
|
||||||
|
}): TeamEffortSelectorPresentation {
|
||||||
|
const options = getTeamEffortOptions(params);
|
||||||
|
const defaultHelperText =
|
||||||
|
"Controls how much reasoning the selected provider invests before responding. Default uses the provider's standard behavior for the selected model.";
|
||||||
|
|
||||||
|
if (params.providerId !== 'anthropic') {
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
disabled: false,
|
||||||
|
helperText: defaultHelperText,
|
||||||
|
unavailableText: null,
|
||||||
|
canValidateValue: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = resolveAnthropicRuntimeSelection({
|
||||||
|
source: {
|
||||||
|
modelCatalog: params.providerStatus?.modelCatalog,
|
||||||
|
runtimeCapabilities: params.providerStatus?.runtimeCapabilities,
|
||||||
|
},
|
||||||
|
selectedModel: params.model,
|
||||||
|
limitContext: params.limitContext === true,
|
||||||
|
});
|
||||||
|
const hasCatalogTruth =
|
||||||
|
selection.catalogSource !== 'unavailable' && selection.catalogStatus !== 'unavailable';
|
||||||
|
const supportsConfigurableEffort = selection.supportedEfforts.length > 0;
|
||||||
|
|
||||||
|
if (!hasCatalogTruth || supportsConfigurableEffort) {
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
disabled: false,
|
||||||
|
helperText: defaultHelperText,
|
||||||
|
unavailableText: null,
|
||||||
|
canValidateValue: hasCatalogTruth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelLabel =
|
||||||
|
selection.displayName ?? selection.resolvedLaunchModel ?? params.model?.trim() ?? 'This model';
|
||||||
|
|
||||||
|
return {
|
||||||
|
options: [{ value: '', label: 'Not supported' }],
|
||||||
|
disabled: true,
|
||||||
|
helperText: `${modelLabel} does not support configurable reasoning effort. The app will omit --effort and use the provider default.`,
|
||||||
|
unavailableText: 'Effort is unavailable for this model.',
|
||||||
|
canValidateValue: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,7 @@ function buildPendingDiagnosticPhrase({
|
||||||
const namedParts = [
|
const namedParts = [
|
||||||
formatNamedPendingDiagnostic('Shell-only', groups.shellOnly),
|
formatNamedPendingDiagnostic('Shell-only', groups.shellOnly),
|
||||||
formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess),
|
formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess),
|
||||||
formatNamedPendingDiagnostic('Process candidates', groups.runtimeCandidate),
|
formatNamedPendingDiagnostic('Bootstrap unconfirmed', groups.runtimeCandidate),
|
||||||
formatNamedPendingDiagnostic('Awaiting permission', groups.permission),
|
formatNamedPendingDiagnostic('Awaiting permission', groups.permission),
|
||||||
formatNamedPendingDiagnostic('Waiting for runtime', groups.noRuntime),
|
formatNamedPendingDiagnostic('Waiting for runtime', groups.noRuntime),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
@ -270,7 +270,7 @@ function buildPendingDiagnosticPhrase({
|
||||||
const countParts = [
|
const countParts = [
|
||||||
formatCountPendingDiagnostic(summary.shellOnlyPendingCount, 'shell-only'),
|
formatCountPendingDiagnostic(summary.shellOnlyPendingCount, 'shell-only'),
|
||||||
formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'),
|
formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'),
|
||||||
formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'process candidates'),
|
formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'bootstrap unconfirmed'),
|
||||||
formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'),
|
formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'),
|
||||||
formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'waiting for runtime'),
|
formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'waiting for runtime'),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
|
||||||
|
|
@ -100,4 +100,45 @@ describe('TeamMessageFeedService', () => {
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds UI-only OpenCode bootstrap start rows for side-lane teammates', async () => {
|
||||||
|
const opencodeConfig: TeamConfig = {
|
||||||
|
name: 'relay-works-14',
|
||||||
|
description: 'relay-works-14 team for provisioning flow',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', role: 'Lead', providerId: 'codex' },
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'developer',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'openrouter/moonshotai/kimi-k2.6',
|
||||||
|
joinedAt: 1777570946947,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const service = new TeamMessageFeedService({
|
||||||
|
getConfig: vi.fn(async () => opencodeConfig),
|
||||||
|
getInboxMessages: vi.fn(async () => []),
|
||||||
|
getLeadSessionMessages: vi.fn(async () => []),
|
||||||
|
getSentMessages: vi.fn(async () => []),
|
||||||
|
});
|
||||||
|
|
||||||
|
const feed = await service.getFeed('relay-works-14');
|
||||||
|
|
||||||
|
expect(feed.messages).toHaveLength(1);
|
||||||
|
expect(feed.messages[0]).toMatchObject({
|
||||||
|
from: 'team-lead',
|
||||||
|
to: 'bob',
|
||||||
|
source: 'system_notification',
|
||||||
|
messageId: 'opencode-bootstrap-start:relay-works-14:bob',
|
||||||
|
timestamp: '2026-04-30T17:42:26.947Z',
|
||||||
|
});
|
||||||
|
expect(feed.messages[0]?.text).toContain('Provider override for this teammate: opencode.');
|
||||||
|
expect(feed.messages[0]?.text).toContain(
|
||||||
|
'Model override for this teammate: openrouter/moonshotai/kimi-k2.6.'
|
||||||
|
);
|
||||||
|
expect(feed.messages[0]?.text).toContain(
|
||||||
|
'The team has already been created and you are being attached as a persistent teammate.'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5443,6 +5443,98 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('waits for OpenCode runtime evidence before delivering to a fresh active secondary lane', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const teamName = 'team-a';
|
||||||
|
const laneId = 'secondary:opencode:bob';
|
||||||
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
memberName: String(input.memberName),
|
||||||
|
sessionId: 'oc-session-bob',
|
||||||
|
diagnostics: [],
|
||||||
|
}));
|
||||||
|
const registry = new TeamRuntimeAdapterRegistry([
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
prepare: vi.fn(),
|
||||||
|
launch: vi.fn(),
|
||||||
|
reconcile: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
sendMessageToMember,
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
svc.setRuntimeAdapterRegistry(registry);
|
||||||
|
|
||||||
|
(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 upsertOpenCodeRuntimeLaneIndexEntry({
|
||||||
|
teamsBasePath: tempTeamsBase,
|
||||||
|
teamName,
|
||||||
|
laneId,
|
||||||
|
state: 'active',
|
||||||
|
});
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId);
|
||||||
|
await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||||
|
await fsPromises.writeFile(
|
||||||
|
manifestPath,
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
...createDefaultRuntimeStoreManifest(teamName, now),
|
||||||
|
activeRunId: 'opencode-run-starting-empty',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.deliverOpenCodeMemberMessage(teamName, {
|
||||||
|
memberName: 'bob',
|
||||||
|
text: 'wait until runtime check-in',
|
||||||
|
messageId: 'msg-fresh-empty-manifest',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
delivered: false,
|
||||||
|
reason: 'opencode_runtime_not_active',
|
||||||
|
diagnostics: [
|
||||||
|
expect.stringContaining('OpenCode runtime bootstrap evidence is not ready for bob'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(sendMessageToMember).not.toHaveBeenCalled();
|
||||||
|
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
|
||||||
|
lanes: {
|
||||||
|
[laneId]: {
|
||||||
|
state: 'active',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to lane manifest when a tracked primary run lacks the secondary lane snapshot', async () => {
|
it('falls back to lane manifest when a tracked primary run lacks the secondary lane snapshot', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const teamName = 'team-a';
|
const teamName = 'team-a';
|
||||||
|
|
@ -9059,11 +9151,11 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(settings.permissions?.allow).toEqual(['mcp__agent-teams__team_stop']);
|
expect(settings.permissions?.allow).toEqual(['mcp__agent-teams__team_stop']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses a non-alarming cloud delay message before 2 minutes of silence', () => {
|
it('uses a non-alarming model delay message before 2 minutes of silence', () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
expect((svc as any).buildStallProgressMessage(90, '1m 30s')).toBe(
|
expect((svc as any).buildStallProgressMessage(90, '1m 30s')).toBe(
|
||||||
'Waiting on Cloud response for 1m 30s — logs can be delayed, this is still OK'
|
'Waiting for model response for 1m 30s - logs can be delayed, this is still OK'
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -9073,11 +9165,11 @@ describe('TeamProvisioningService', () => {
|
||||||
).toContain('Logs can sometimes show up after 1-1.5 minutes, and that is still okay.');
|
).toContain('Logs can sometimes show up after 1-1.5 minutes, and that is still okay.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks a cloud wait as unusual after 2 minutes of silence', () => {
|
it('marks a model wait as unusual after 2 minutes of silence', () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
expect((svc as any).buildStallProgressMessage(120, '2m')).toBe(
|
expect((svc as any).buildStallProgressMessage(120, '2m')).toBe(
|
||||||
'Still waiting on Cloud response for 2m — this is unusual'
|
'Still waiting for model response for 2m - this is unusual'
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
|
||||||
|
|
||||||
|
import type { CliProviderStatus } from '@shared/types';
|
||||||
|
|
||||||
|
let providerStatus: CliProviderStatus | null = null;
|
||||||
|
|
||||||
|
vi.mock('@renderer/hooks/useEffectiveCliProviderStatus', () => ({
|
||||||
|
useEffectiveCliProviderStatus: () => ({
|
||||||
|
providerStatus,
|
||||||
|
cliStatus: null,
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createAnthropicProviderStatus(): CliProviderStatus {
|
||||||
|
return {
|
||||||
|
providerId: 'anthropic',
|
||||||
|
displayName: 'Anthropic',
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'api_key',
|
||||||
|
verificationState: 'verified',
|
||||||
|
models: ['claude-haiku-4-5-20251001'],
|
||||||
|
modelCatalog: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
providerId: 'anthropic',
|
||||||
|
source: 'anthropic-models-api',
|
||||||
|
status: 'ready',
|
||||||
|
fetchedAt: '2026-04-30T00:00:00.000Z',
|
||||||
|
staleAt: '2026-04-30T00:10:00.000Z',
|
||||||
|
defaultModelId: 'haiku',
|
||||||
|
defaultLaunchModel: 'claude-haiku-4-5-20251001',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: 'haiku',
|
||||||
|
launchModel: 'claude-haiku-4-5-20251001',
|
||||||
|
displayName: 'Haiku 4.5',
|
||||||
|
hidden: false,
|
||||||
|
supportedReasoningEfforts: [],
|
||||||
|
defaultReasoningEffort: null,
|
||||||
|
inputModalities: ['text', 'image'],
|
||||||
|
supportsPersonality: false,
|
||||||
|
isDefault: true,
|
||||||
|
upgrade: false,
|
||||||
|
source: 'anthropic-models-api',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics: {
|
||||||
|
configReadState: 'ready',
|
||||||
|
appServerState: 'healthy',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modelAvailability: [],
|
||||||
|
runtimeCapabilities: {
|
||||||
|
modelCatalog: { dynamic: true, source: 'anthropic-models-api' },
|
||||||
|
reasoningEffort: {
|
||||||
|
supported: true,
|
||||||
|
values: [],
|
||||||
|
configPassthrough: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
canLoginFromUi: true,
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: true,
|
||||||
|
extensions: {
|
||||||
|
plugins: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
mcp: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
skills: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EffortLevelSelector', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
providerStatus = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables effort selection and resets stale effort for Anthropic models without effort support', async () => {
|
||||||
|
providerStatus = createAnthropicProviderStatus();
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<EffortLevelSelector
|
||||||
|
value="high"
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
providerId="anthropic"
|
||||||
|
model="claude-haiku-4-5-20251001"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('Not supported');
|
||||||
|
expect(host.textContent).toContain('Haiku 4.5 does not support configurable reasoning effort');
|
||||||
|
expect(host.querySelector('button')?.disabled).toBe(true);
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith('');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -255,6 +255,32 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
launchVisualState: 'shell_only',
|
launchVisualState: 'shell_only',
|
||||||
launchStatusLabel: 'shell only',
|
launchStatusLabel: 'shell only',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildMemberLaunchPresentation({
|
||||||
|
member,
|
||||||
|
spawnStatus: 'online',
|
||||||
|
spawnLaunchState: 'runtime_pending_bootstrap',
|
||||||
|
spawnLivenessSource: 'process',
|
||||||
|
spawnRuntimeAlive: true,
|
||||||
|
runtimeEntry: {
|
||||||
|
memberName: 'alice',
|
||||||
|
alive: false,
|
||||||
|
restartable: true,
|
||||||
|
livenessKind: 'runtime_process_candidate',
|
||||||
|
runtimeDiagnostic: 'OpenCode runtime process detected, but bootstrap is not confirmed',
|
||||||
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isLaunchSettling: false,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
presenceLabel: 'bootstrap unconfirmed',
|
||||||
|
launchVisualState: 'runtime_candidate',
|
||||||
|
launchStatusLabel: 'bootstrap unconfirmed',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns shared launch status labels without changing generic presence labels', () => {
|
it('returns shared launch status labels without changing generic presence labels', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue