fix(team): harden opencode runtime status and effort UI

This commit is contained in:
777genius 2026-04-30 23:07:45 +03:00
parent 752ae9ea4b
commit 0ace2a6255
14 changed files with 496 additions and 41 deletions

View file

@ -11,6 +11,8 @@ const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
const MESSAGE_FEED_CACHE_MAX_AGE_MS = 5_000;
const logger = createLogger('Service:TeamMessageFeedService');
type TeamConfigMember = NonNullable<TeamConfig['members']>[number];
interface TeamMessageFeedDeps {
getConfig: (teamName: string) => Promise<TeamConfig | null>;
getInboxMessages: (teamName: string) => Promise<InboxMessage[]>;
@ -69,6 +71,68 @@ function isLeadThoughtCandidateForSlashResult(message: InboxMessage): boolean {
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 {
let pendingSlash = null as InboxMessage['slashCommand'] | null;
@ -381,7 +445,8 @@ export class TeamMessageFeedService {
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 = ensureEffectiveMessageIds(messages);
messages = dedupeByMessageId(messages);

View file

@ -157,6 +157,7 @@ import {
getOpenCodeLaneScopedRuntimeFilePath,
getOpenCodeRuntimeRunTombstonesPath,
getOpenCodeTeamRuntimeDirectory,
inspectOpenCodeRuntimeLaneStorage,
migrateLegacyOpenCodeRuntimeState,
OpenCodeRuntimeManifestEvidenceReader,
readOpenCodeRuntimeLaneIndex,
@ -3826,7 +3827,7 @@ function buildLaunchDiagnosticsFromRun(
memberName,
severity: 'warning',
code: 'runtime_process_candidate',
label: `${memberName} - process candidate`,
label: `${memberName} - bootstrap unconfirmed`,
detail: entry.runtimeDiagnostic,
observedAt,
});
@ -6102,20 +6103,30 @@ export class TeamProvisioningService {
if (
runtimeActive &&
laneIdentity.laneKind === 'secondary' &&
laneIdentity.laneOwnerProviderId === 'opencode' &&
!liveSecondaryLaneRunId
laneIdentity.laneOwnerProviderId === 'opencode'
) {
const laneStorage = await inspectOpenCodeRuntimeLaneStorage({
teamsBasePath: getTeamsBasePath(),
teamName,
laneId: laneIdentity.laneId,
});
const staleLane = await recoverStaleOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: getTeamsBasePath(),
teamName,
laneId: laneIdentity.laneId,
});
if (staleLane.stale) {
this.deleteSecondaryRuntimeRun(teamName, laneIdentity.laneId);
if (!laneStorage.hasRuntimeEvidenceOnDisk) {
if (staleLane.stale) {
this.deleteSecondaryRuntimeRun(teamName, laneIdentity.laneId);
}
return {
delivered: false,
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 = {
...refreshed,
livenessKind: metadata.livenessKind,
runtimeDiagnostic: runtimeDiagnostic ?? 'runtime process candidate detected',
runtimeDiagnostic:
runtimeDiagnostic ?? 'Runtime process candidate detected, but bootstrap is unconfirmed.',
runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning',
livenessLastCheckedAt: nowIso(),
};
@ -12533,7 +12545,7 @@ export class TeamProvisioningService {
return (
`---\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 ` +
`request briefly hits rate limiting.\n\n` +
`Waiting...`
@ -12544,7 +12556,7 @@ export class TeamProvisioningService {
return (
`---\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 ` +
`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` +
@ -12558,21 +12570,21 @@ export class TeamProvisioningService {
return (
`---\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` +
`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` +
`- A stalled or delayed Cloud response\n\n` +
`- A stalled or delayed model response\n\n` +
`Consider canceling and trying with a different model.`
);
}
private buildStallProgressMessage(silenceSec: number, elapsed: string): string {
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.runtimeCandidatePendingCount
? `${launchSummary.runtimeCandidatePendingCount} process candidates`
? `${launchSummary.runtimeCandidatePendingCount} bootstrap unconfirmed`
: '',
launchSummary.noRuntimePendingCount
? `${launchSummary.noRuntimePendingCount} waiting for runtime`

View file

@ -328,7 +328,7 @@ export function resolveTeamMemberRuntimeLiveness(
paneCurrentCommand: pane.currentCommand,
runtimeSessionId,
processCommand: sanitizeProcessCommandForDiagnostics(candidate.command),
runtimeDiagnostic: 'runtime process candidate detected',
runtimeDiagnostic: 'Runtime process candidate detected, but bootstrap is unconfirmed.',
runtimeDiagnosticSeverity: 'warning',
diagnostics: [...diagnostics, 'tmux descendant found without runtime identity match'],
});

View file

@ -2183,9 +2183,6 @@ export const CreateTeamDialog = ({
Open Existing Team
</Button>
) : null}
<Button variant="outline" size="sm" onClick={onClose}>
Close
</Button>
<Button
size="sm"
disabled={!canCreate || !draftLoaded || isSubmitting || hasCreateFormErrors}

View file

@ -1,9 +1,9 @@
import React from 'react';
import React, { useEffect, useMemo } from 'react';
import { Label } from '@renderer/components/ui/label';
import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus';
import { cn } from '@renderer/lib/utils';
import { getTeamEffortOptions } from '@renderer/utils/teamEffortOptions';
import { getTeamEffortSelectorPresentation } from '@renderer/utils/teamEffortOptions';
import { Brain } from 'lucide-react';
import type { TeamProviderId } from '@shared/types';
@ -26,10 +26,27 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
limitContext,
}) => {
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 =
providerId === 'anthropic' && effortOptions.some((option) => option.value === 'max');
useEffect(() => {
if (!presentation.canValidateValue || !value || validValues.has(value)) {
return;
}
onValueChange('');
}, [onValueChange, presentation.canValidateValue, validValues, value]);
return (
<div className="mb-3">
<Label htmlFor={id} className="label-optional mb-1.5 block">
@ -43,11 +60,14 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
key={opt.value || '__default__'}
type="button"
id={opt.value === value ? id : undefined}
disabled={presentation.disabled}
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
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)]'
presentation.disabled
? 'cursor-not-allowed text-[var(--color-text-muted)] opacity-70'
: 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)}
>
@ -56,10 +76,10 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
))}
</div>
</div>
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
Controls how much reasoning the selected provider invests before responding. Default uses
the provider&apos;s standard behavior for the selected model.
</p>
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">{presentation.helperText}</p>
{presentation.unavailableText ? (
<p className="mt-1 text-[11px] text-amber-300">{presentation.unavailableText}</p>
) : null}
{showsAnthropicMax ? (
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
Max is Anthropic&apos;s heavier reasoning mode and only appears when the resolved launch

View file

@ -2895,9 +2895,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null}
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={closeDialog}>
{isLaunchMode ? 'Close' : 'Cancel'}
</Button>
<Button
size="sm"
className="bg-emerald-600 text-white hover:bg-emerald-700"

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { getTeamEffortOptions } from '../teamEffortOptions';
import { getTeamEffortOptions, getTeamEffortSelectorPresentation } from '../teamEffortOptions';
import type { CliProviderStatus } from '@shared/types';
@ -210,4 +210,33 @@ describe('team effort options', () => {
getTeamEffortOptions({ providerId: 'anthropic', model: 'haiku', providerStatus })
).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.',
});
});
});

View file

@ -581,7 +581,7 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
case 'shell_only':
return 'shell only';
case 'runtime_candidate':
return 'process candidate';
return 'bootstrap unconfirmed';
case 'registered_only':
return 'registered';
case 'stale_runtime':

View file

@ -20,6 +20,14 @@ interface TeamEffortOption {
label: string;
}
interface TeamEffortSelectorPresentation {
options: readonly TeamEffortOption[];
disabled: boolean;
helperText: string;
unavailableText: string | null;
canValidateValue: boolean;
}
function getCatalogModel(
providerId: TeamProviderId | undefined,
providerStatus: CliProviderStatus | null | undefined,
@ -126,3 +134,57 @@ export function getTeamEffortOptions(params: {
{ 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,
};
}

View file

@ -257,7 +257,7 @@ function buildPendingDiagnosticPhrase({
const namedParts = [
formatNamedPendingDiagnostic('Shell-only', groups.shellOnly),
formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess),
formatNamedPendingDiagnostic('Process candidates', groups.runtimeCandidate),
formatNamedPendingDiagnostic('Bootstrap unconfirmed', groups.runtimeCandidate),
formatNamedPendingDiagnostic('Awaiting permission', groups.permission),
formatNamedPendingDiagnostic('Waiting for runtime', groups.noRuntime),
].filter(Boolean);
@ -270,7 +270,7 @@ function buildPendingDiagnosticPhrase({
const countParts = [
formatCountPendingDiagnostic(summary.shellOnlyPendingCount, 'shell-only'),
formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'),
formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'process candidates'),
formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'bootstrap unconfirmed'),
formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'),
formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'waiting for runtime'),
].filter(Boolean);

View file

@ -100,4 +100,45 @@ describe('TeamMessageFeedService', () => {
)
).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.'
);
});
});

View file

@ -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 () => {
const svc = new TeamProvisioningService();
const teamName = 'team-a';
@ -9059,11 +9151,11 @@ describe('TeamProvisioningService', () => {
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();
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(
@ -9073,11 +9165,11 @@ describe('TeamProvisioningService', () => {
).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();
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(

View file

@ -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();
});
});

View file

@ -255,6 +255,32 @@ describe('memberHelpers spawn-aware presence', () => {
launchVisualState: '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', () => {