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 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);
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'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's heavier reasoning mode and only appears when the resolved launch
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue